fix: 渠道账号群聊同步缓存 key 及成员名称兜底;统一 Docker/Maven 项目约定
This commit is contained in:
47
admin-web/REFACTOR_GUIDE.md
Normal file
47
admin-web/REFACTOR_GUIDE.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# admin-web 视图重构指南
|
||||
|
||||
## 目标
|
||||
把现有 Element Plus 页面统一为新的 Sino-MCI 设计语言:现代、统一、操作便捷、信息密度高。
|
||||
|
||||
## 设计参考
|
||||
参考原型:`.stitch/designs/sino-mci-prototype.html`
|
||||
|
||||
## 通用规范
|
||||
|
||||
### 页面结构
|
||||
1. 顶部工具栏:左侧放搜索/筛选,右侧放唯一主操作按钮(新增/创建/测试发送等)。
|
||||
2. 内容区:白色卡片(`el-card` 或自定义 `.bg-white rounded-xl border`)包裹表格。
|
||||
3. 表格:
|
||||
- 删除无意义复选框;
|
||||
- 删除每行多个操作按钮;
|
||||
- 行可点击,点击后从右侧滑出抽屉(`el-drawer`)查看/编辑详情;
|
||||
- 保留状态开关、标签等必要展示字段;
|
||||
- 需要时显示分页(`el-pagination`)。
|
||||
4. 新增用弹窗(`el-dialog`),编辑用抽屉,保持两者形式统一:标题栏 + 表单 + 底部确认/取消。
|
||||
|
||||
### 视觉
|
||||
- 主色:`#2563EB`
|
||||
- 背景:`#F8FAFC`
|
||||
- 卡片/表面:`#FFFFFF`,圆角 12px,边框 `#E2E8F0`
|
||||
- 文字主色:`#0F172A`,次要:`#64748B`
|
||||
- 字号:正文 14px,标题 16-18px,辅助 12-13px
|
||||
- 间距:页面内边距 24px,卡片内部 16-20px
|
||||
|
||||
### 交互
|
||||
- 行 hover 变灰(`hover:bg-slate-50` 等效样式)。
|
||||
- 按钮层级:主操作 `type="primary"`,次级 `link` 或默认。
|
||||
- 删除操作放在抽屉底部或二次确认弹窗中,不要直接显示在列表行。
|
||||
- 状态变更尽量使用行内 `el-switch`,切换后调用 API 并刷新列表。
|
||||
|
||||
### 代码约束
|
||||
- 保留原有 API 调用和业务逻辑,只改模板和样式。
|
||||
- 使用 `<script setup lang="ts">`。
|
||||
- 继续使用 Element Plus 组件,不引入新依赖。
|
||||
- 每个视图文件底部使用 `<style scoped>` 自定义局部样式,避免污染全局。
|
||||
- TypeScript 编译不能报错;重构后应能通过 `npm run build`。
|
||||
|
||||
### 抽屉内容示例
|
||||
- 对象名称/标题
|
||||
- 基本信息(描述列表或表单)
|
||||
- 关联信息(应用、账号、标签、会话等)
|
||||
- 底部:保存 / 取消 / 删除
|
||||
24
admin-web/package-lock.json
generated
24
admin-web/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "admin-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
"@vue-flow/core": "^1.42.1",
|
||||
@@ -395,6 +396,29 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
|
||||
@@ -10,14 +10,15 @@
|
||||
"test": "vue-tsc -b && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
"@vue-flow/core": "^1.42.1",
|
||||
"axios": "^1.18.1",
|
||||
"element-plus": "^2.14.2",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^4.6.4",
|
||||
"@vue-flow/core": "^1.42.1",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2"
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
|
||||
@@ -28,6 +28,10 @@ export function initChannelAccount(data: any) {
|
||||
return client.post('/api/v1/channel-account/init', data)
|
||||
}
|
||||
|
||||
export function syncChannelAccountContacts(id: number) {
|
||||
return client.post(`/api/v1/channel-account/${id}/sync-contacts`)
|
||||
}
|
||||
|
||||
export function syncChannelAccountConversations(id: number) {
|
||||
return client.post(`/api/v1/channel-account/${id}/sync-conversations`)
|
||||
}
|
||||
|
||||
9
admin-web/src/api/syncTask.ts
Normal file
9
admin-web/src/api/syncTask.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import client from './client'
|
||||
|
||||
export function getSyncTask(id: number) {
|
||||
return client.get(`/api/v1/sync-task/${id}`)
|
||||
}
|
||||
|
||||
export function syncSubjectEnterpriseContacts(id: number) {
|
||||
return client.post(`/api/v1/channel-subject/${id}/sync-contacts`)
|
||||
}
|
||||
@@ -1,64 +1,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>
|
||||
|
||||
61
admin-web/src/components/PageCard.vue
Normal file
61
admin-web/src/components/PageCard.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="sino-page">
|
||||
<div v-if="hasToolbar" class="sino-toolbar">
|
||||
<div class="sino-toolbar-left">
|
||||
<slot name="toolbar-left" />
|
||||
</div>
|
||||
<div class="sino-toolbar-right">
|
||||
<slot name="toolbar-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="sino-card">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.pagination" class="sino-pagination">
|
||||
<slot name="pagination" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
const slots = useSlots()
|
||||
const hasToolbar = computed(() => !!slots['toolbar-left'] || !!slots['toolbar-right'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sino-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
.sino-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sino-toolbar-left,
|
||||
.sino-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.sino-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
.sino-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
</style>
|
||||
@@ -2,19 +2,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: '用户管理' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -30,6 +30,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.setItem('mci-role-codes', JSON.stringify(roles))
|
||||
}
|
||||
|
||||
function setTenant(code: string) {
|
||||
tenantCode.value = code
|
||||
localStorage.setItem('mci-tenant-code', code)
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
token.value = ''
|
||||
tenantCode.value = ''
|
||||
@@ -41,5 +46,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('mci-role-codes')
|
||||
}
|
||||
|
||||
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }
|
||||
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, setTenant, clearAuth }
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'Inter', 'Noto Sans SC', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
@@ -12,6 +12,65 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sino-MCI shared page styles */
|
||||
.sino-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.sino-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sino-toolbar-left,
|
||||
.sino-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sino-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sino-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* Table header unify */
|
||||
.sino-table .el-table__header-wrapper th.el-table__cell {
|
||||
background: #f8fafc !important;
|
||||
color: #64748b !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.sino-table .el-table__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Status cell */
|
||||
.status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreateDialog">创建渠道账号</el-button>
|
||||
</div>
|
||||
<PageCard>
|
||||
<template #toolbar-left>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索账号 / 名称"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
/>
|
||||
<el-select v-model="filterStatus" placeholder="全部状态" clearable style="width: 140px">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</template>
|
||||
<template #toolbar-right>
|
||||
<el-button type="primary" @click="openCreateDialog">新增账号</el-button>
|
||||
</template>
|
||||
|
||||
<el-table :data="accounts" border>
|
||||
<el-table :data="pagedAccounts" class="sino-table" @row-click="handleRowClick">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="accountType" label="类型" width="140">
|
||||
<template #default="{ row }">
|
||||
@@ -16,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;
|
||||
|
||||
221
admin-web/src/views/ChannelApp.vue
Normal file
221
admin-web/src/views/ChannelApp.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<PageCard>
|
||||
<template #toolbar-left>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索应用名称 / AgentID"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
/>
|
||||
</template>
|
||||
<template #toolbar-right>
|
||||
<el-button type="primary" @click="openCreate">新增应用</el-button>
|
||||
</template>
|
||||
|
||||
<el-table :data="pagedApps" class="sino-table" @row-click="handleRowClick">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="appName" label="应用名称" />
|
||||
<el-table-column prop="agentId" label="AgentID" />
|
||||
<el-table-column prop="secret" label="Secret">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.secret">••••••••</span>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sessionArchiveEnabled" label="会话存档" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.sessionArchiveEnabled === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.sessionArchiveEnabled === 1 ? '已开启' : '未开启' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #pagination>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="filteredApps.length"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="新增应用" width="520px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="应用名称"><el-input v-model="form.appName" /></el-form-item>
|
||||
<el-form-item label="AgentID"><el-input v-model="form.agentId" /></el-form-item>
|
||||
<el-form-item label="Secret"><el-input v-model="form.secret" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="会话存档">
|
||||
<el-switch v-model="form.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="drawerVisible" title="应用详情" size="420px">
|
||||
<div class="drawer-body">
|
||||
<div class="drawer-actions">
|
||||
<el-button size="small" type="primary" @click="initAccounts(currentApp!)">初始化账号</el-button>
|
||||
</div>
|
||||
<el-form :model="drawerForm" label-width="100px">
|
||||
<el-form-item label="应用名称"><el-input v-model="drawerForm.appName" /></el-form-item>
|
||||
<el-form-item label="AgentID"><el-input v-model="drawerForm.agentId" /></el-form-item>
|
||||
<el-form-item label="Secret"><el-input v-model="drawerForm.secret" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="会话存档">
|
||||
<el-switch v-model="drawerForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveDrawer">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
|
||||
interface AppItem {
|
||||
id: number
|
||||
appName: string
|
||||
agentId: string
|
||||
secret: string
|
||||
sessionArchiveEnabled: number
|
||||
status: number
|
||||
}
|
||||
|
||||
const apps = ref<AppItem[]>([
|
||||
{ id: 1, appName: '会话存档应用', agentId: '1000002', secret: 'xxx', sessionArchiveEnabled: 1, status: 1 },
|
||||
{ id: 2, appName: '消息推送应用', agentId: '1000005', secret: 'xxx', sessionArchiveEnabled: 0, status: 1 },
|
||||
])
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
const kw = searchKeyword.value.trim().toLowerCase()
|
||||
if (!kw) return apps.value
|
||||
return apps.value.filter(
|
||||
(item) =>
|
||||
item.appName.toLowerCase().includes(kw) ||
|
||||
item.agentId.toLowerCase().includes(kw)
|
||||
)
|
||||
})
|
||||
|
||||
const pagedApps = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filteredApps.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const form = reactive({
|
||||
appName: '',
|
||||
agentId: '',
|
||||
secret: '',
|
||||
sessionArchiveEnabled: 0,
|
||||
})
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const currentApp = ref<AppItem | null>(null)
|
||||
const drawerForm = reactive({
|
||||
appName: '',
|
||||
agentId: '',
|
||||
secret: '',
|
||||
sessionArchiveEnabled: 0,
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.appName = ''
|
||||
form.agentId = ''
|
||||
form.secret = ''
|
||||
form.sessionArchiveEnabled = 0
|
||||
}
|
||||
|
||||
function fillDrawerForm(app: AppItem) {
|
||||
drawerForm.appName = app.appName
|
||||
drawerForm.agentId = app.agentId
|
||||
drawerForm.secret = app.secret
|
||||
drawerForm.sessionArchiveEnabled = app.sessionArchiveEnabled
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleRowClick(row: AppItem) {
|
||||
currentApp.value = row
|
||||
fillDrawerForm(row)
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function save() {
|
||||
apps.value.push({
|
||||
id: Date.now(),
|
||||
appName: form.appName || '未命名应用',
|
||||
agentId: form.agentId,
|
||||
secret: form.secret,
|
||||
sessionArchiveEnabled: form.sessionArchiveEnabled,
|
||||
status: 1,
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
function saveDrawer() {
|
||||
if (!currentApp.value) return
|
||||
const idx = apps.value.findIndex((a) => a.id === currentApp.value!.id)
|
||||
if (idx >= 0) {
|
||||
apps.value[idx] = {
|
||||
...currentApp.value,
|
||||
appName: drawerForm.appName,
|
||||
agentId: drawerForm.agentId,
|
||||
secret: drawerForm.secret,
|
||||
sessionArchiveEnabled: drawerForm.sessionArchiveEnabled,
|
||||
}
|
||||
}
|
||||
ElMessage.success('更新成功')
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
function initAccounts(app: AppItem) {
|
||||
ElMessage.info(`开始初始化账号:${app.appName}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-secondary {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.drawer-body {
|
||||
padding: 20px;
|
||||
}
|
||||
.drawer-actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
189
admin-web/src/views/Messages.vue
Normal file
189
admin-web/src/views/Messages.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="messages-layout">
|
||||
<div class="conversation-list">
|
||||
<div class="conv-search">
|
||||
<el-input v-model="search" placeholder="搜索会话" prefix-icon="Search" />
|
||||
</div>
|
||||
<div class="conv-items">
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
:class="{ active: current?.id === conv.id }"
|
||||
@click="current = conv"
|
||||
>
|
||||
<div class="conv-title">{{ conv.name }}</div>
|
||||
<div class="conv-preview">{{ conv.preview }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-area" v-if="current">
|
||||
<div class="chat-header">
|
||||
<span class="chat-name">{{ current.name }}</span>
|
||||
<div class="chat-actions">
|
||||
<el-button link type="primary">打标签</el-button>
|
||||
<el-button link type="primary">会话详情</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-body">
|
||||
<div
|
||||
v-for="(msg, idx) in current.messages"
|
||||
:key="idx"
|
||||
class="chat-msg"
|
||||
:class="msg.self ? 'self' : 'other'"
|
||||
>
|
||||
<div class="chat-bubble">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-footer">
|
||||
<el-input v-model="reply" placeholder="输入消息..." @keyup.enter="send" />
|
||||
<el-button type="primary" @click="send">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-empty" v-else>
|
||||
<el-empty description="请选择会话" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const search = ref('')
|
||||
const reply = ref('')
|
||||
const current = ref<any>(null)
|
||||
|
||||
const conversations = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '艾先生',
|
||||
preview: '我要查询订单状态',
|
||||
messages: [
|
||||
{ content: '我要查询订单状态', self: false },
|
||||
{ content: '订单号是 20250709001', self: false },
|
||||
{ content: '好的,已为您查询到订单正在发货中。', self: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '华东销售群',
|
||||
preview: '[图片] 车牌号 京A12345',
|
||||
messages: [
|
||||
{ content: '[图片] 车牌号 京A12345', self: false },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function send() {
|
||||
if (!reply.value.trim() || !current.value) return
|
||||
current.value.messages.push({ content: reply.value, self: true })
|
||||
current.value.preview = reply.value
|
||||
reply.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.messages-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 140px);
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.conversation-list {
|
||||
width: 280px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.conv-search {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.conv-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.conv-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.conv-item:hover,
|
||||
.conv-item.active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.conv-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
}
|
||||
.conv-preview {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-header {
|
||||
height: 56px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.chat-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chat-msg.self {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.chat-bubble {
|
||||
max-width: 60%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.chat-msg.other .chat-bubble {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #0f172a;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
.chat-msg.self .chat-bubble {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.chat-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.chat-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,48 +1,63 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreateDialog">创建联系人</el-button>
|
||||
</div>
|
||||
<PageCard>
|
||||
<template #toolbar-left>
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索姓名 / 手机 / 编码"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="personTypeFilter" placeholder="全部类型" clearable style="width: 140px">
|
||||
<el-option label="内部人员" :value="1" />
|
||||
<el-option label="供应商人员" :value="2" />
|
||||
<el-option label="客户" :value="3" />
|
||||
<el-option label="司机" :value="4" />
|
||||
</el-select>
|
||||
</template>
|
||||
<template #toolbar-right>
|
||||
<el-button type="primary" @click="openCreateDialog">新增联系人</el-button>
|
||||
</template>
|
||||
|
||||
<el-table :data="persons" border>
|
||||
<el-table :data="pagedPersons" class="sino-table" @row-click="openDrawer">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="personCode" label="编码" />
|
||||
<el-table-column prop="personName" label="姓名" />
|
||||
<el-table-column prop="phone" label="手机" />
|
||||
<el-table-column prop="personType" label="类型" width="100">
|
||||
<el-table-column prop="personType" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatPersonType(row.personType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="op-row">
|
||||
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
<div class="status-cell" @click.stop>
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
style="margin-left: 12px"
|
||||
@change="(val: any) => handleStatusChange(row, val)"
|
||||
/>
|
||||
</div>
|
||||
<div class="op-row" style="margin-top: 6px;">
|
||||
<el-button link type="primary" size="small" @click="openIdentities(row)">渠道身份</el-button>
|
||||
<el-button link type="primary" size="small" @click="openBindTags(row)">绑定标签</el-button>
|
||||
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑联系人' : '创建联系人'" width="600px">
|
||||
<template #pagination>
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="filteredPersons.length"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="新增联系人" width="600px">
|
||||
<el-form :model="dialogForm" label-width="100px">
|
||||
<el-form-item label="编码">
|
||||
<el-input v-model="dialogForm.personCode" placeholder="人员编码" />
|
||||
@@ -61,7 +76,7 @@
|
||||
<el-option label="司机" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="状态">
|
||||
<el-form-item label="状态">
|
||||
<el-switch
|
||||
v-model="dialogForm.status"
|
||||
:active-value="1"
|
||||
@@ -77,8 +92,55 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="drawerVisible" title="联系人详情" size="520px">
|
||||
<el-form :model="dialogForm" label-width="100px">
|
||||
<el-form-item label="编码">
|
||||
<el-input v-model="dialogForm.personCode" placeholder="人员编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="dialogForm.personName" placeholder="姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机">
|
||||
<el-input v-model="dialogForm.phone" placeholder="手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="dialogForm.personType" placeholder="类型" style="width: 100%">
|
||||
<el-option label="内部人员" :value="1" />
|
||||
<el-option label="供应商人员" :value="2" />
|
||||
<el-option label="客户" :value="3" />
|
||||
<el-option label="司机" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch
|
||||
v-model="dialogForm.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="relation-section">
|
||||
<div class="section-title">关联信息</div>
|
||||
<div class="relation-actions">
|
||||
<el-button type="primary" link @click="openIdentities(currentPerson)">管理渠道身份</el-button>
|
||||
<el-button type="primary" link @click="openBindTags(currentPerson)">绑定标签</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleDelete(currentPerson)">删除</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="identitiesVisible" title="渠道身份" width="700px">
|
||||
<el-table :data="identities" border style="margin-bottom: 16px;">
|
||||
<el-table :data="identities" class="sino-table" border style="margin-bottom: 16px;">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="channelType" label="渠道" />
|
||||
<el-table-column prop="channelUserId" label="渠道用户ID" />
|
||||
@@ -128,11 +190,12 @@
|
||||
<el-button type="primary" @click="handleBindTags">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
bindPersonTags,
|
||||
@@ -145,6 +208,7 @@ import {
|
||||
listTags,
|
||||
updatePerson,
|
||||
} from '@/api/crm'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
|
||||
const persons = ref<any[]>([])
|
||||
const tags = ref<any[]>([])
|
||||
@@ -161,6 +225,36 @@ const dialogForm = reactive({
|
||||
status: 1,
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const personTypeFilter = ref<number | ''>('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const drawerVisible = ref(false)
|
||||
const currentPerson = ref<any>({})
|
||||
|
||||
const filteredPersons = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
return persons.value.filter((p) => {
|
||||
const matchesQuery =
|
||||
!q ||
|
||||
[p.personName, p.phone, p.personCode].some((v) =>
|
||||
String(v || '').toLowerCase().includes(q)
|
||||
)
|
||||
const matchesType =
|
||||
personTypeFilter.value === '' || p.personType === personTypeFilter.value
|
||||
return matchesQuery && matchesType
|
||||
})
|
||||
})
|
||||
|
||||
const pagedPersons = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredPersons.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
watch([searchQuery, personTypeFilter], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
function resetDialogForm() {
|
||||
dialogForm.personCode = ''
|
||||
dialogForm.personName = ''
|
||||
@@ -184,11 +278,13 @@ function openCreateDialog() {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: any) {
|
||||
function openDrawer(row: any) {
|
||||
currentPerson.value = row
|
||||
currentPersonId.value = row.id
|
||||
isEdit.value = true
|
||||
editingId.value = row.id
|
||||
fillDialogForm(row)
|
||||
dialogVisible.value = true
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function formatPersonType(type: number | undefined) {
|
||||
@@ -237,6 +333,7 @@ async function handleSave() {
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
drawerVisible.value = false
|
||||
await loadPersons()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message)
|
||||
@@ -269,6 +366,7 @@ async function handleDelete(row: any) {
|
||||
)
|
||||
await deletePerson(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
drawerVisible.value = false
|
||||
await loadPersons()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
@@ -382,7 +480,19 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
.relation-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0F172A;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.relation-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="send-record-page">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16">
|
||||
<el-row :gutter="16" class="stat-row">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="今日发送总数" :value="statistics.total" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="成功" :value="statistics.successCount">
|
||||
<template #suffix>
|
||||
<el-tag type="success" size="small">成功</el-tag>
|
||||
@@ -17,7 +17,7 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="失败" :value="statistics.failedCount">
|
||||
<template #suffix>
|
||||
<el-tag type="danger" size="small">失败</el-tag>
|
||||
@@ -26,7 +26,7 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="待发送" :value="statistics.pendingCount">
|
||||
<template #suffix>
|
||||
<el-tag type="info" size="small">待发送</el-tag>
|
||||
@@ -36,11 +36,84 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 测试发送 -->
|
||||
<el-card class="mt-4" shadow="never">
|
||||
<template #header>
|
||||
<span>测试发送</span>
|
||||
<PageCard>
|
||||
<template #toolbar-left>
|
||||
<el-form :model="query" inline class="search-form">
|
||||
<el-form-item label="任务 ID">
|
||||
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发送时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD[T]HH:mm:ss"
|
||||
style="width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
<el-select v-model="query.channelType" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option label="企微" value="wecom" />
|
||||
<el-option label="微信个人号" value="wechat_personal" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="待发送" :value="0" />
|
||||
<el-option label="发送中" :value="1" />
|
||||
<el-option label="成功" :value="2" />
|
||||
<el-option label="失败" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #toolbar-right>
|
||||
<el-button type="primary" @click="openTestDialog">测试发送</el-button>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="records"
|
||||
v-loading="loading"
|
||||
@row-click="openDetail"
|
||||
class="sino-table"
|
||||
>
|
||||
<el-table-column prop="taskId" label="任务 ID" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="targetType" label="目标类型" width="110" />
|
||||
<el-table-column prop="channelType" label="渠道" width="120" />
|
||||
<el-table-column prop="sendStatus" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.sendStatus === 0" type="info">待发送</el-tag>
|
||||
<el-tag v-else-if="row.sendStatus === 1" type="warning">发送中</el-tag>
|
||||
<el-tag v-else-if="row.sendStatus === 2" type="success">成功</el-tag>
|
||||
<el-tag v-else-if="row.sendStatus === 3" type="danger">失败</el-tag>
|
||||
<el-tag v-else type="info">未知</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sendTime" label="发送时间" min-width="160" />
|
||||
<el-table-column prop="finishTime" label="完成时间" min-width="160" />
|
||||
</el-table>
|
||||
|
||||
<template #pagination>
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="loadRecords"
|
||||
/>
|
||||
</template>
|
||||
</PageCard>
|
||||
|
||||
<!-- 测试发送弹窗 -->
|
||||
<el-dialog v-model="testDialogVisible" title="测试发送" width="760px">
|
||||
<el-form :model="testForm" label-width="80px" inline>
|
||||
<el-form-item label="目标类型">
|
||||
<el-select v-model="testForm.targetType" style="width: 140px">
|
||||
@@ -97,94 +170,12 @@
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="textContent" placeholder="输入文本消息" style="width: 260px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<el-card class="mt-4" shadow="never">
|
||||
<el-form :model="query" inline>
|
||||
<el-form-item label="任务 ID">
|
||||
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发送时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD[T]HH:mm:ss"
|
||||
style="width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
<el-select v-model="query.channelType" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option label="企微" value="wecom" />
|
||||
<el-option label="微信个人号" value="wechat_personal" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="待发送" :value="0" />
|
||||
<el-option label="发送中" :value="1" />
|
||||
<el-option label="成功" :value="2" />
|
||||
<el-option label="失败" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 记录列表 -->
|
||||
<el-card class="mt-4" shadow="never">
|
||||
<el-table :data="records" border v-loading="loading">
|
||||
<el-table-column prop="taskId" label="任务 ID" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="targetType" label="目标类型" width="110" />
|
||||
<el-table-column prop="channelType" label="渠道" width="120" />
|
||||
<el-table-column prop="sendStatus" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.sendStatus === 0" type="info">待发送</el-tag>
|
||||
<el-tag v-else-if="row.sendStatus === 1" type="warning">发送中</el-tag>
|
||||
<el-tag v-else-if="row.sendStatus === 2" type="success">成功</el-tag>
|
||||
<el-tag v-else-if="row.sendStatus === 3" type="danger">失败</el-tag>
|
||||
<el-tag v-else type="info">未知</el-tag>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreateDialog">创建标签</el-button>
|
||||
</div>
|
||||
<PageCard>
|
||||
<template #toolbar-left>
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索标签名称"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
<template #toolbar-right>
|
||||
<el-button type="primary" @click="openCreateDialog">新增标签</el-button>
|
||||
</template>
|
||||
|
||||
<el-table :data="tags" border>
|
||||
<el-table :data="pagedTags" class="sino-table" @row-click="openDrawer">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="tagName" label="名称" />
|
||||
<el-table-column prop="tagPath" label="路径" show-overflow-tooltip />
|
||||
@@ -13,31 +25,32 @@
|
||||
{{ formatLevel(row.level) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="op-row">
|
||||
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
<div class="status-cell" @click.stop>
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
style="margin-left: 12px"
|
||||
@change="(val: any) => handleStatusChange(row, val)"
|
||||
/>
|
||||
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑标签' : '创建标签'" width="600px">
|
||||
<template #pagination>
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="filteredTags.length"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑标签' : '新增标签'" width="600px">
|
||||
<el-form :model="dialogForm" label-width="100px">
|
||||
<el-form-item label="标签名">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="标签名" />
|
||||
@@ -68,13 +81,48 @@
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="drawerVisible" title="标签详情" size="480px">
|
||||
<el-form :model="dialogForm" label-width="100px">
|
||||
<el-form-item label="标签名">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="标签名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父标签">
|
||||
<el-select v-model="dialogForm.parentId" placeholder="选择父标签" style="width: 100%">
|
||||
<el-option label="无" :value="0" />
|
||||
<el-option
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
:label="t.tagName"
|
||||
:value="t.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch
|
||||
v-model="dialogForm.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleDelete(currentRow)">删除</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createTag, deleteTag, listTags, updateTag } from '@/api/crm'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
|
||||
const tags = ref<any[]>([])
|
||||
|
||||
@@ -88,6 +136,31 @@ const dialogForm = reactive({
|
||||
status: 1,
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const drawerVisible = ref(false)
|
||||
const currentRow = ref<any>({})
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return tags.value
|
||||
return tags.value.filter(
|
||||
(t) =>
|
||||
String(t.tagName || '').toLowerCase().includes(q) ||
|
||||
String(t.tagPath || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const pagedTags = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredTags.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
function resetDialogForm() {
|
||||
dialogForm.tagName = ''
|
||||
dialogForm.parentId = 0
|
||||
@@ -107,11 +180,12 @@ function openCreateDialog() {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: any) {
|
||||
function openDrawer(row: any) {
|
||||
currentRow.value = row
|
||||
isEdit.value = true
|
||||
editingId.value = row.id
|
||||
fillDialogForm(row)
|
||||
dialogVisible.value = true
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function formatLevel(level: number | undefined) {
|
||||
@@ -148,6 +222,7 @@ async function handleSave() {
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
drawerVisible.value = false
|
||||
await loadTags()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message)
|
||||
@@ -178,6 +253,7 @@ async function handleDelete(row: any) {
|
||||
)
|
||||
await deleteTag(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
drawerVisible.value = false
|
||||
await loadTags()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
@@ -188,9 +264,3 @@ async function handleDelete(row: any) {
|
||||
|
||||
onMounted(loadTags)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,57 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreateDialog">创建租户</el-button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user