wip: baseline changes before channel-conversation-task boundary implementation

This commit is contained in:
2026-07-08 16:23:09 +08:00
parent 89c9e6a066
commit 1298a558e8
139 changed files with 11781 additions and 666 deletions

View File

@@ -6,20 +6,70 @@ export function login(data: { tenantCode: string; username: string; password: st
})
}
export function createTenant(data: { tenantCode: string; tenantName: string }) {
export function createTenant(data: {
tenantCode: string
tenantName: string
inboundWebhookUrl?: string
initAdminUsername?: string
initAdminPassword?: string
}) {
return client.post('/api/v1/account/tenant', data)
}
export function updateTenant(
id: number,
data: { tenantName: string; inboundWebhookUrl?: string; status?: number }
) {
return client.put(`/api/v1/account/tenant/${id}`, data)
}
export function deleteTenant(id: number) {
return client.delete(`/api/v1/account/tenant/${id}`)
}
export function listTenants() {
return client.get('/api/v1/account/tenant/list')
}
export function createUser(data: { username: string; password: string; realName?: string; roleCodes?: string[] }) {
return client.post('/api/v1/account/user', data)
export function listRoles() {
return client.get('/api/v1/account/role/list')
}
export function listUsers() {
return client.get('/api/v1/account/user/list')
export function createUser(
tenantCode: string,
data: {
username: string
password: string
realName?: string
phone?: string
roleCodes?: string[]
}
) {
return client.post('/api/v1/account/user', data, {
headers: { 'X-Tenant-Code': tenantCode },
})
}
export function updateUser(
id: number,
data: {
realName: string
phone?: string
status?: number
roleCodes?: string[]
}
) {
return client.put(`/api/v1/account/user/${id}`, data)
}
export function deleteUser(id: number) {
return client.delete(`/api/v1/account/user/${id}`)
}
export function listUsers(tenantCode?: string) {
return client.get('/api/v1/account/user/list', {
params: tenantCode ? { tenantCode } : {},
})
}
export function me() {

View File

@@ -1,24 +1,6 @@
import client from './client'
export function createChannelSubject(data: any) {
return client.post('/api/v1/channel-subject', data)
}
export function listChannelSubjects() {
return client.get('/api/v1/channel-subject/list')
}
export function createChannelAccount(data: any) {
return client.post('/api/v1/channel-account', data)
}
export function listChannelAccounts() {
return client.get('/api/v1/channel-account/list')
}
export function initChannelAccount(data: any) {
return client.post('/api/v1/channel-account/init', data)
}
export * from './channelSubject'
export * from './channelAccount'
export function createConversation(data: any) {
return client.post('/api/v1/conversation', data)
@@ -28,6 +10,14 @@ export function listConversations() {
return client.get('/api/v1/conversation/list')
}
export function updateConversation(id: number, data: any) {
return client.put(`/api/v1/conversation/${id}`, data)
}
export function deleteConversation(id: number) {
return client.delete(`/api/v1/conversation/${id}`)
}
export function bindConversationAccount(conversationId: number, data: any) {
return client.post(`/api/v1/conversation/${conversationId}/bind-account`, data)
}

View File

@@ -0,0 +1,33 @@
import client from './client'
export function createChannelAccount(data: any) {
return client.post('/api/v1/channel-account', data)
}
export function listChannelAccounts() {
return client.get('/api/v1/channel-account/list')
}
export function updateChannelAccount(id: number, data: any) {
return client.put(`/api/v1/channel-account/${id}`, data)
}
export function deleteChannelAccount(id: number) {
return client.delete(`/api/v1/channel-account/${id}`)
}
export function updateChannelAccountStatus(id: number, data: any) {
return client.patch(`/api/v1/channel-account/${id}/status`, data)
}
export function getChannelAccountStatus(id: number) {
return client.get(`/api/v1/channel-account/${id}/status`)
}
export function initChannelAccount(data: any) {
return client.post('/api/v1/channel-account/init', data)
}
export function syncChannelAccountConversations(id: number) {
return client.post(`/api/v1/channel-account/${id}/sync-conversations`)
}

View File

@@ -0,0 +1,21 @@
import client from './client'
export function createChannelSubject(data: any) {
return client.post('/api/v1/channel-subject', data)
}
export function listChannelSubjects() {
return client.get('/api/v1/channel-subject/list')
}
export function updateChannelSubject(id: number, data: any) {
return client.put(`/api/v1/channel-subject/${id}`, data)
}
export function deleteChannelSubject(id: number) {
return client.delete(`/api/v1/channel-subject/${id}`)
}
export function updateChannelSubjectStatus(id: number, data: any) {
return client.patch(`/api/v1/channel-subject/${id}/status`, data)
}

View File

@@ -21,7 +21,13 @@ client.interceptors.request.use((config) => {
})
client.interceptors.response.use(
(response) => response.data,
(response) => {
const data = response.data
if (data && typeof data === 'object' && 'code' in data && data.code !== 0) {
return Promise.reject(new Error(data.message || '请求失败'))
}
return data
},
(error) => {
if (error.response?.status === 401) {
localStorage.clear()

View File

@@ -8,10 +8,42 @@ export function listPersons() {
return client.get('/api/v1/person/list')
}
export function listTags() {
return client.get('/api/v1/tag/list')
export function updatePerson(id: number, data: any) {
return client.put(`/api/v1/person/${id}`, data)
}
export function deletePerson(id: number) {
return client.delete(`/api/v1/person/${id}`)
}
export function listPersonIdentities(personId: number) {
return client.get(`/api/v1/person/${personId}/identities`)
}
export function createPersonIdentity(personId: number, data: any) {
return client.post(`/api/v1/person/${personId}/identities`, data)
}
export function deletePersonIdentity(personId: number, identityId: number) {
return client.delete(`/api/v1/person/${personId}/identities/${identityId}`)
}
export function bindPersonTags(personId: number, data: any) {
return client.post(`/api/v1/person/${personId}/bind-tags`, data)
}
export function createTag(data: any) {
return client.post('/api/v1/tag', data)
}
export function listTags() {
return client.get('/api/v1/tag/list')
}
export function updateTag(id: number, data: any) {
return client.put(`/api/v1/tag/${id}`, data)
}
export function deleteTag(id: number) {
return client.delete(`/api/v1/tag/${id}`)
}

View File

@@ -0,0 +1,75 @@
import client from './client'
export interface FlowNodeConfig {
[key: string]: any
}
export interface FlowNode {
id: string
type: string
config: FlowNodeConfig
}
export interface FlowEdge {
source: string
target: string
label?: string
}
export interface FlowDefinition {
nodes: FlowNode[]
edges: FlowEdge[]
}
export interface FlowRecord {
id: number
flowCode: string
flowName: string
flowType: number
status: number
definitionJson: string | FlowDefinition
version?: number
createTime?: string
updateTime?: string
}
export interface SaveFlowPayload {
flowCode: string
flowName: string
flowType?: string
definitionJson: FlowDefinition
}
export function listFlows() {
return client.get('/api/v1/inbound/flow/list')
}
export function getFlow(flowCode: string) {
return client.get(`/api/v1/inbound/flow/${flowCode}`)
}
export function saveFlow(data: SaveFlowPayload) {
return client.post('/api/v1/inbound/flow', {
...data,
flowType: data.flowType || 'inbound',
})
}
export function updateFlow(flowCode: string, data: SaveFlowPayload) {
return client.post(`/api/v1/inbound/flow/${flowCode}`, {
...data,
flowType: data.flowType || 'inbound',
})
}
export function publishFlow(flowCode: string) {
return client.post(`/api/v1/inbound/flow/${flowCode}/publish`)
}
export function offlineFlow(flowCode: string) {
return client.post(`/api/v1/inbound/flow/${flowCode}/offline`)
}
export function deleteFlow(flowCode: string) {
return client.delete(`/api/v1/inbound/flow/${flowCode}`)
}

View File

@@ -0,0 +1,13 @@
import client from './client'
export function getInboundWebhookConfig() {
return client.get('/api/v1/account/tenant/webhook')
}
export function updateInboundWebhookConfig(data: any) {
return client.post('/api/v1/account/tenant/webhook', data)
}
export function testInboundWebhook(data: { url: string }) {
return client.post('/api/v1/account/tenant/webhook/test', data)
}

View File

@@ -0,0 +1,75 @@
import client from './client'
export interface FlowNodeConfig {
[key: string]: any
}
export interface FlowNode {
id: string
type: string
config: FlowNodeConfig
}
export interface FlowEdge {
source: string
target: string
label?: string
}
export interface FlowDefinition {
nodes: FlowNode[]
edges: FlowEdge[]
}
export interface FlowRecord {
id: number
flowCode: string
flowName: string
flowType: number
status: number
definitionJson: string | FlowDefinition
version?: number
createTime?: string
updateTime?: string
}
export interface SaveFlowPayload {
flowCode: string
flowName: string
flowType?: string
definitionJson: FlowDefinition
}
export function listFlows() {
return client.get('/api/v1/flow/list')
}
export function getFlow(flowCode: string) {
return client.get(`/api/v1/flow/${flowCode}`)
}
export function saveFlow(data: SaveFlowPayload) {
return client.post('/api/v1/flow', {
...data,
flowType: data.flowType || 'ivr',
})
}
export function updateFlow(flowCode: string, data: SaveFlowPayload) {
return client.post(`/api/v1/flow/${flowCode}`, {
...data,
flowType: data.flowType || 'ivr',
})
}
export function publishFlow(flowCode: string) {
return client.post(`/api/v1/flow/${flowCode}/publish`)
}
export function offlineFlow(flowCode: string) {
return client.post(`/api/v1/flow/${flowCode}/offline`)
}
export function deleteFlow(flowCode: string) {
return client.delete(`/api/v1/flow/${flowCode}`)
}

View File

@@ -0,0 +1,25 @@
import client from './client'
export function listPolicyGroups() {
return client.get('/api/v1/policy-group/list')
}
export function getPolicyGroup(groupCode: string) {
return client.get(`/api/v1/policy-group/${groupCode}`)
}
export function createPolicyGroup(data: any) {
return client.post('/api/v1/policy-group', data)
}
export function updatePolicyGroup(groupCode: string, data: any) {
return client.post(`/api/v1/policy-group/${groupCode}`, data)
}
export function updatePolicyGroupStatus(groupCode: string, status: number) {
return client.post(`/api/v1/policy-group/${groupCode}/status`, { status })
}
export function deletePolicyGroup(groupCode: string) {
return client.post(`/api/v1/policy-group/${groupCode}/delete`)
}

View File

@@ -0,0 +1,31 @@
import client from './client'
export interface QueryRecordsParams {
taskId?: string
start?: string
end?: string
channelType?: string
status?: number | string
page?: number
size?: number
}
export function queryRecords(params: QueryRecordsParams) {
return client.get('/api/v1/send/records', { params })
}
export function getTaskStatus(taskId: string) {
return client.get(`/api/v1/send/${taskId}/status`)
}
export function cancelTask(taskId: string) {
return client.post(`/api/v1/send/${taskId}/cancel`)
}
export function testSend(data: any) {
return client.post('/api/v1/send/test', data)
}
export function getStatistics(start: string, end: string) {
return client.get('/api/v1/send/statistics', { params: { start, end } })
}

View File

@@ -27,12 +27,21 @@
<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>

View File

@@ -8,10 +8,13 @@ import Person from '../views/Person.vue'
import Tag from '../views/Tag.vue'
import SendRecord from '../views/SendRecord.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'
@@ -34,8 +37,11 @@ const routes = [
{ 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: '用户管理' } },
],

View File

@@ -1,11 +1,21 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
function parseJson(value: string | null): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('mci-token') || '')
const tenantCode = ref(localStorage.getItem('mci-tenant-code') || '')
const username = ref('')
const roleCodes = ref<string[]>([])
const username = ref(localStorage.getItem('mci-username') || '')
const roleCodes = ref<string[]>(parseJson(localStorage.getItem('mci-role-codes')))
const isLoggedIn = computed(() => !!token.value)
@@ -16,6 +26,8 @@ export const useAuthStore = defineStore('auth', () => {
roleCodes.value = roles
localStorage.setItem('mci-token', t)
localStorage.setItem('mci-tenant-code', tc)
localStorage.setItem('mci-username', user)
localStorage.setItem('mci-role-codes', JSON.stringify(roles))
}
function clearAuth() {
@@ -25,6 +37,8 @@ export const useAuthStore = defineStore('auth', () => {
roleCodes.value = []
localStorage.removeItem('mci-token')
localStorage.removeItem('mci-tenant-code')
localStorage.removeItem('mci-username')
localStorage.removeItem('mci-role-codes')
}
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }

View File

@@ -1,68 +1,192 @@
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="账号类型">
<el-select v-model="form.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="form.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="所属主体">
<el-select v-model="form.subjectId" placeholder="选择主体">
<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="form.accountId" placeholder="账号唯一标识" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="form.accountName" placeholder="名称" />
</el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="form.sessionArchiveEnabled" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建渠道账号</el-button>
</div>
<el-table :data="accounts" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="accountType" label="类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="accountType" label="类型" width="140">
<template #default="{ row }">
{{ accountTypeLabel(row.accountType) }}
</template>
</el-table-column>
<el-table-column prop="channelType" label="渠道" width="100">
<template #default="{ row }">
{{ channelLabel(row.channelType) }}
</template>
</el-table-column>
<el-table-column prop="accountId" label="账号ID" />
<el-table-column prop="accountName" label="名称" />
<el-table-column prop="loginStatus" label="登录状态" />
<el-table-column prop="onlineStatus" label="在线状态" />
<el-table-column prop="loginStatus" label="登录状态" width="100">
<template #default="{ row }">
<el-tag :type="row.loginStatus === 1 ? 'success' : 'info'">
{{ 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 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"
@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">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="账号类型">
<el-select v-model="dialogForm.accountType" placeholder="类型">
<el-option label="企微内部账号" :value="1" />
<el-option label="企微应用" :value="2" />
<el-option label="pywechat 个人号" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-input v-model="dialogForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="所属主体">
<el-select v-model="dialogForm.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="dialogForm.accountId" placeholder="账号唯一标识" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="dialogForm.accountName" placeholder="名称" />
</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="Server Host">
<el-input v-model="dialogForm.serverHost" placeholder="服务地址" />
</el-form-item>
<el-form-item label="扩展信息">
<el-input v-model="dialogForm.extInfo" type="textarea" :rows="3" placeholder="JSON 扩展信息" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="initDialogVisible" title="初始化结果" width="480px">
<div v-if="initResult">
<el-result
:icon="initResult.success ? 'success' : 'warning'"
:title="initResult.success ? '初始化成功' : '初始化失败'"
:sub-title="initResult.message"
/>
<div v-if="initResult.qrCodeUrl" class="qr-code">
<p>请使用微信扫描二维码登录</p>
<el-image :src="initResult.qrCodeUrl" fit="contain" style="width: 200px; height: 200px" />
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createChannelAccount, listChannelAccounts, listChannelSubjects } from '@/api/channel'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listChannelSubjects } from '@/api/channelSubject'
import {
createChannelAccount,
listChannelAccounts,
updateChannelAccount,
deleteChannelAccount,
updateChannelAccountStatus,
getChannelAccountStatus,
initChannelAccount,
syncChannelAccountConversations,
} from '@/api/channelAccount'
const form = reactive({
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,
channelType: 'wecom',
subjectId: undefined as number | undefined,
accountId: '',
accountName: '',
sessionArchiveEnabled: false,
sessionArchiveEnabled: 0,
serverHost: '',
extInfo: '',
})
const accounts = ref<any[]>([])
const subjects = ref<any[]>([])
const initDialogVisible = ref(false)
const initResult = ref<any>(null)
function resetDialogForm() {
dialogForm.accountType = 1
dialogForm.channelType = 'wecom'
dialogForm.subjectId = undefined
dialogForm.accountId = ''
dialogForm.accountName = ''
dialogForm.sessionArchiveEnabled = 0
dialogForm.serverHost = ''
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 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
}
async function loadAccounts() {
try {
@@ -82,18 +206,138 @@ async function loadSubjects() {
}
}
async function handleCreate() {
async function handleSave() {
try {
const res: any = await createChannelAccount(form)
accounts.value.unshift(res.data)
ElMessage.success('创建成功')
if (isEdit.value && editingId.value != null) {
await updateChannelAccount(editingId.value, dialogForm)
ElMessage.success('更新成功')
} else {
await createChannelAccount(dialogForm)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadAccounts()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确认删除该渠道账号?', '提示', { type: 'warning' })
await deleteChannelAccount(row.id)
ElMessage.success('删除成功')
await loadAccounts()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updateChannelAccountStatus(row.id, { status: val })
ElMessage.success('状态更新成功')
await loadAccounts()
} catch (e: any) {
ElMessage.error(e.message)
await loadAccounts()
}
}
async function handleInit(row: any) {
try {
const res: any = await initChannelAccount({
channelType: row.channelType,
accountType: row.accountType,
accountId: row.id,
subjectId: row.subjectId,
config: {},
})
initResult.value = res.data || null
initDialogVisible.value = true
if (initResult.value?.success) {
await loadAccounts()
}
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleSync(row: any) {
try {
const res: any = await syncChannelAccountConversations(row.id)
ElMessage.success(`同步完成,共 ${res.data?.syncedCount ?? 0} 条会话`)
await loadAccounts()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleRefreshStatus(row: any) {
try {
const res: any = await getChannelAccountStatus(row.id)
const idx = accounts.value.findIndex((a) => a.id === row.id)
if (idx >= 0) {
accounts.value[idx] = { ...accounts.value[idx], ...res.data }
}
ElMessage.success('状态已刷新')
} catch (e: any) {
ElMessage.error(e.message)
}
}
function accountTypeLabel(type: number) {
const map: Record<number, string> = {
1: '企微内部账号',
2: '企微应用',
3: 'pywechat 个人号',
}
return map[type] || type
}
function channelLabel(type: string) {
const map: Record<string, string> = {
wecom: '企微',
wechat_personal: '个微',
dingtalk: '钉钉',
feishu: '飞书',
ivr: 'IVR',
}
return map[type] || type
}
onMounted(() => {
loadAccounts()
loadSubjects()
})
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.online {
background-color: #67c23a;
}
.status-dot.offline {
background-color: #909399;
}
.heartbeat {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.qr-code {
text-align: center;
margin-top: 12px;
}
</style>

View File

@@ -1,59 +1,173 @@
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="渠道">
<el-select v-model="form.channelType" placeholder="选择渠道">
<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="form.subjectCode" placeholder="主体编码" />
</el-form-item>
<el-form-item label="主体名称">
<el-input v-model="form.subjectName" placeholder="主体名称" />
</el-form-item>
<el-form-item label="CorpID">
<el-input v-model="form.corpId" placeholder="CorpID" />
</el-form-item>
<el-form-item label="AgentID">
<el-input v-model="form.agentId" placeholder="AgentID" />
</el-form-item>
<el-form-item label="AgentSecret">
<el-input v-model="form.agentSecret" placeholder="AgentSecret" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建渠道主体</el-button>
</div>
<el-table :data="subjects" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelType" label="渠道">
<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="状态" />
<el-table-column prop="status" label="状态" width="100">
<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="340">
<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>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑渠道主体' : '创建渠道主体'" width="600px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="渠道">
<el-select v-model="dialogForm.channelType" placeholder="选择渠道">
<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="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>
<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>
<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="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>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createChannelSubject, listChannelSubjects } from '@/api/channel'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createChannelSubject,
listChannelSubjects,
updateChannelSubject,
deleteChannelSubject,
updateChannelSubjectStatus,
} from '@/api/channelSubject'
const form = reactive({
const subjects = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
channelType: 'wecom',
subjectCode: '',
subjectName: '',
corpId: '',
corpSecret: '',
agentId: '',
agentSecret: '',
sessionArchiveEnabled: 0,
sessionArchivePrivateKey: '',
})
const subjects = ref<any[]>([])
const drawerVisible = ref(false)
const currentSubject = ref<any>({})
function resetDialogForm() {
dialogForm.channelType = 'wecom'
dialogForm.subjectCode = ''
dialogForm.subjectName = ''
dialogForm.corpId = ''
dialogForm.corpSecret = ''
dialogForm.agentId = ''
dialogForm.agentSecret = ''
dialogForm.sessionArchiveEnabled = 0
dialogForm.sessionArchivePrivateKey = ''
}
function fillDialogForm(row: any) {
dialogForm.channelType = row.channelType ?? 'wecom'
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
dialogForm.sessionArchivePrivateKey = row.sessionArchivePrivateKey ?? ''
}
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 openDetail(row: any) {
currentSubject.value = { ...row }
drawerVisible.value = true
}
async function loadSubjects() {
try {
@@ -64,15 +178,66 @@ async function loadSubjects() {
}
}
async function handleCreate() {
async function handleSave() {
try {
const res: any = await createChannelSubject(form)
subjects.value.unshift(res.data)
ElMessage.success('创建成功')
if (isEdit.value && editingId.value != null) {
await updateChannelSubject(editingId.value, dialogForm)
ElMessage.success('更新成功')
} else {
await createChannelSubject(dialogForm)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadSubjects()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确认删除该渠道主体?', '提示', { type: 'warning' })
await deleteChannelSubject(row.id)
ElMessage.success('删除成功')
await loadSubjects()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updateChannelSubjectStatus(row.id, { status: val })
ElMessage.success('状态更新成功')
await loadSubjects()
} catch (e: any) {
ElMessage.error(e.message)
await loadSubjects()
}
}
function channelLabel(type: string) {
const map: Record<string, string> = {
wecom: '企微',
dingtalk: '钉钉',
feishu: '飞书',
}
return map[type] || type
}
function mask(value: string) {
if (!value) return '-'
if (value.length <= 4) return '****'
return value.slice(0, 2) + '****' + value.slice(-2)
}
onMounted(loadSubjects)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,54 +1,99 @@
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="会话类型">
<el-select v-model="form.conversationType" placeholder="类型">
<el-option label="单聊" :value="1" />
<el-option label="群聊" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-input v-model="form.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="渠道会话ID">
<el-input v-model="form.channelConversationId" placeholder="roomid_xxx" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="form.conversationName" placeholder="群名称" />
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="form.ownerAccountId" placeholder="选择负责人账号">
<el-option
v-for="a in accounts"
:key="a.id"
:label="a.accountName"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建会话</el-button>
</div>
<el-table :data="conversations" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="conversationType" label="类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelConversationId" label="渠道会话ID" />
<el-table-column prop="conversationName" label="名称" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作" width="120">
<el-table-column prop="conversationType" label="类型" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openBind(row)">绑定账号</el-button>
{{ formatConversationType(row.conversationType) }}
</template>
</el-table-column>
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelConversationId" label="渠道会话ID" show-overflow-tooltip />
<el-table-column prop="conversationName" label="名称" />
<el-table-column prop="ownerAccountId" label="负责人">
<template #default="{ row }">
{{ formatAccountName(row.ownerAccountId) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<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>
<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>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="bindVisible" title="绑定账号" width="500px">
<el-form :model="bindForm" label-width="100px">
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑会话' : '创建会话'" 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" :disabled="isEdit" placeholder="roomid_xxx" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="dialogForm.conversationName" placeholder="群名称" />
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="dialogForm.ownerAccountId" placeholder="选择负责人账号" style="width: 100%">
<el-option
v-for="a in accounts"
:key="a.id"
:label="a.accountName"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="bindAccountVisible" title="绑定账号" width="500px">
<el-form :model="bindAccountForm" label-width="100px">
<el-form-item label="账号">
<el-select v-model="bindForm.accountId" placeholder="选择账号">
<el-select v-model="bindAccountForm.accountId" placeholder="选择账号" style="width: 100%">
<el-option
v-for="a in accounts"
:key="a.id"
@@ -58,7 +103,7 @@
</el-select>
</el-form-item>
<el-form-item label="绑定类型">
<el-select v-model="bindForm.bindingType" placeholder="选择绑定类型">
<el-select v-model="bindAccountForm.bindingType" placeholder="选择绑定类型" style="width: 100%">
<el-option label="发送主号" :value="1" />
<el-option label="发送备用号" :value="2" />
<el-option label="接收主号" :value="3" />
@@ -67,40 +112,114 @@
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindVisible = false">取消</el-button>
<el-button type="primary" @click="handleBind">确认</el-button>
<el-button @click="bindAccountVisible = false">取消</el-button>
<el-button type="primary" @click="handleBindAccount">确认</el-button>
</template>
</el-dialog>
<el-dialog v-model="bindTagsVisible" title="绑定标签" width="500px">
<el-form :model="bindTagsForm" label-width="80px">
<el-form-item label="标签">
<el-select v-model="bindTagsForm.tagIds" multiple placeholder="选择标签" style="width: 100%">
<el-option
v-for="t in tags"
:key="t.id"
:label="t.tagName"
:value="t.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindTagsVisible = false">取消</el-button>
<el-button type="primary" @click="handleBindTags">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
bindConversationAccount,
bindConversationTags,
createConversation,
deleteConversation,
listChannelAccounts,
listConversations,
updateConversation,
} from '@/api/channel'
import { listTags } from '@/api/crm'
const form = reactive({
const conversations = ref<any[]>([])
const accounts = ref<any[]>([])
const tags = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
conversationType: 2,
channelType: 'wecom',
channelConversationId: '',
conversationName: '',
ownerAccountId: undefined as number | undefined,
status: 1,
})
const conversations = ref<any[]>([])
const accounts = ref<any[]>([])
const bindVisible = ref(false)
const currentConversationId = ref<number | undefined>()
const bindForm = reactive({
accountId: undefined as number | undefined,
bindingType: 1,
function resetDialogForm() {
dialogForm.conversationType = 2
dialogForm.channelType = 'wecom'
dialogForm.channelConversationId = ''
dialogForm.conversationName = ''
dialogForm.ownerAccountId = undefined
dialogForm.status = 1
}
function fillDialogForm(row: any) {
dialogForm.conversationType = row.conversationType ?? 2
dialogForm.channelType = row.channelType ?? 'wecom'
dialogForm.channelConversationId = row.channelConversationId ?? ''
dialogForm.conversationName = row.conversationName ?? ''
dialogForm.ownerAccountId = row.ownerAccountId
dialogForm.status = row.status ?? 1
}
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
}
const accountMap = computed(() => {
const map = new Map<number, string>()
for (const a of accounts.value) {
map.set(a.id, a.accountName)
}
return map
})
function formatAccountName(id: number | undefined) {
if (id == null) return '-'
return accountMap.value.get(id) || id
}
function formatConversationType(type: number | undefined) {
if (type === 1) return '单聊'
if (type === 2) return '群聊'
return type ?? '-'
}
async function loadConversations() {
try {
const res: any = await listConversations()
@@ -119,37 +238,136 @@ async function loadAccounts() {
}
}
async function handleCreate() {
async function loadTags() {
try {
const res: any = await createConversation(form)
conversations.value.unshift(res.data)
ElMessage.success('创建成功')
const res: any = await listTags()
tags.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
function openBind(row: any) {
currentConversationId.value = row.id
bindForm.accountId = undefined
bindForm.bindingType = 1
bindVisible.value = true
async function handleSave() {
if (!dialogForm.channelType || !dialogForm.channelConversationId) {
ElMessage.warning('请填写渠道和渠道会话ID')
return
}
try {
const payload = {
conversationType: dialogForm.conversationType,
channelType: dialogForm.channelType,
channelConversationId: dialogForm.channelConversationId,
conversationName: dialogForm.conversationName,
ownerAccountId: dialogForm.ownerAccountId,
status: dialogForm.status,
}
if (isEdit.value && editingId.value != null) {
await updateConversation(editingId.value, payload)
ElMessage.success('更新成功')
} else {
const res: any = await createConversation(payload)
conversations.value.unshift(res.data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadConversations()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleBind() {
async function handleStatusChange(row: any, val: any) {
try {
await updateConversation(row.id, {
conversationType: row.conversationType,
channelType: row.channelType,
channelConversationId: row.channelConversationId,
conversationName: row.conversationName,
ownerAccountId: row.ownerAccountId,
status: val,
})
ElMessage.success('状态更新成功')
await loadConversations()
} catch (e: any) {
ElMessage.error(e.message)
await loadConversations()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除会话「${row.conversationName || row.channelConversationId}」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deleteConversation(row.id)
ElMessage.success('删除成功')
await loadConversations()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
const bindAccountVisible = ref(false)
const currentConversationId = ref<number | undefined>()
const bindAccountForm = reactive({
accountId: undefined as number | undefined,
bindingType: 1,
})
function openBindAccount(row: any) {
currentConversationId.value = row.id
bindAccountForm.accountId = undefined
bindAccountForm.bindingType = 1
bindAccountVisible.value = true
}
async function handleBindAccount() {
if (!currentConversationId.value) return
try {
await bindConversationAccount(currentConversationId.value, bindForm)
await bindConversationAccount(currentConversationId.value, bindAccountForm)
ElMessage.success('绑定成功')
bindVisible.value = false
bindAccountVisible.value = false
await loadConversations()
} catch (e: any) {
ElMessage.error(e.message)
}
}
const bindTagsVisible = ref(false)
const bindTagsForm = reactive({
tagIds: [] as number[],
})
function openBindTags(row: any) {
currentConversationId.value = row.id
bindTagsForm.tagIds = []
bindTagsVisible.value = true
}
async function handleBindTags() {
if (!currentConversationId.value) return
try {
await bindConversationTags(currentConversationId.value, { tagIds: bindTagsForm.tagIds })
ElMessage.success('绑定成功')
bindTagsVisible.value = false
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadConversations()
loadAccounts()
loadTags()
})
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,13 +1,9 @@
<template>
<div>
<el-form inline>
<el-form-item label="事件ID">
<el-input v-model="eventId" placeholder="请输入 eventId" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-input v-model="eventId" placeholder="请输入 eventId" clearable style="width: 300px; margin-right: 12px" />
<el-button type="primary" @click="handleQuery">查询</el-button>
</div>
<el-timeline v-if="traces.length > 0">
<el-timeline-item
@@ -50,6 +46,9 @@ async function handleQuery() {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.error-msg {
color: #f56c6c;
}

View File

@@ -1,111 +1,734 @@
<template>
<div class="inbound-flow">
<el-form :model="form" label-width="100px">
<el-form-item label="流程编码">
<el-input v-model="form.flowCode" placeholder="flow_code" />
</el-form-item>
<el-form-item label="流程名称">
<el-input v-model="form.flowName" placeholder="流程名称" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">保存流程</el-button>
</el-form-item>
</el-form>
<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="flow-canvas">
<VueFlow v-model="elements" fit-view-on-init>
<Background pattern-color="#aaa" :gap="16" />
<Controls />
</VueFlow>
<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>
<!-- 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>
</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>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { VueFlow } from '@vue-flow/core'
import { 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'
import { Controls } from '@vue-flow/controls'
import { createFlowDefinition } from '@/api/flow'
import {
listFlows,
getFlow,
saveFlow,
updateFlow,
publishFlow,
offlineFlow,
deleteFlow,
} from '@/api/inboundFlow'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
const form = reactive({
const { project } = useVueFlow()
interface FlowForm {
flowCode: string
flowName: string
}
const form = reactive<FlowForm>({
flowCode: '',
flowName: '',
})
const elements = ref<any[]>([
{
id: '1',
type: 'input',
label: '接收消息',
position: { x: 250, y: 5 },
data: { nodeType: 'receive' },
},
{
id: '2',
label: '意图识别',
position: { x: 250, y: 120 },
data: { nodeType: 'intent', intent: 'rescue_request' },
},
{
id: '3',
label: 'Webhook 推送',
position: { x: 250, y: 240 },
data: { nodeType: 'webhook', webhookUrl: '' },
},
{ id: 'e1-2', source: '1', target: '2' },
{ id: 'e2-3', source: '2', target: '3' },
])
const flows = ref<any[]>([])
const elements = ref<any[]>([])
const selectedNode = ref<any>(null)
const selectedEdge = ref<any>(null)
const isEdit = ref(false)
async function handleCreate() {
const paletteNodes = [
{ type: 'receive', label: '接收消息' },
{ type: 'intent', label: '意图识别' },
{ type: 'condition', label: '条件判断' },
{ type: 'webhook', label: 'Webhook' },
{ type: 'reply', label: '回复消息' },
]
const nodeTypeConfig: Record<string, () => any> = {
receive: () => ({ channel_type: 'wecom' }),
intent: () => ({ mode: 'llm', prompt: '' }),
condition: () => ({ expression: '' }),
webhook: () => ({ url: '', method: 'POST' }),
reply: () => ({ content: { type: 'text', body: '' } }),
}
const nodeTypeLabels: Record<string, string> = {
receive: '接收消息',
intent: '意图识别',
condition: '条件判断',
webhook: 'Webhook',
reply: '回复消息',
}
const nodeTypeIcons: Record<string, string> = {
receive: '▶',
intent: '💡',
condition: '◆',
webhook: '⚡',
reply: '💬',
}
function nodeTypeLabel(type: string) {
return nodeTypeLabels[type] || type
}
function nodeTypeIcon(type: string) {
return nodeTypeIcons[type] || '•'
}
function statusText(status: number) {
if (status === 1) return '已发布'
if (status === 2) return '已下线'
return '草稿'
}
function statusTagType(status: number) {
if (status === 1) return 'success'
if (status === 2) return 'warning'
return 'info'
}
function showIntentPrompt(mode: string) {
return mode === 'llm' || mode === 'both'
}
function createNodeId() {
return `node_${Date.now()}_${Math.floor(Math.random() * 1000)}`
}
function createEdgeId(source: string, target: string) {
return `e_${source}_${target}`
}
function getDefaultPosition() {
const count = elements.value.filter((el) => !('source' in el)).length
const row = Math.floor(count / 2)
const col = count % 2
return { x: 200 + col * 220, y: 80 + row * 140 }
}
function addNode(type: string, position?: { x: number; y: number }) {
const id = createNodeId()
const config = nodeTypeConfig[type]()
elements.value.push({
id,
type: 'flowNode',
label: nodeTypeLabels[type],
position: position || getDefaultPosition(),
data: { nodeType: type, config },
})
}
function onDragStart(event: DragEvent, type: string) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow-node', type)
event.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
function onDrop(event: DragEvent) {
event.preventDefault()
if (!event.dataTransfer) return
const type = event.dataTransfer.getData('application/vueflow-node')
if (!type) return
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect()
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
})
addNode(type, position)
}
function onNodeClick(event: any) {
const node = event.node
selectedEdge.value = null
const found = elements.value.find((el) => el.id === node.id)
selectedNode.value = found || node
}
function onEdgeClick(event: any) {
const edge = event.edge
selectedNode.value = null
const found = elements.value.find((el) => el.id === edge.id)
selectedEdge.value = found || edge
}
function onPaneClick() {
selectedNode.value = null
selectedEdge.value = null
}
function resetCanvas() {
elements.value = []
form.flowCode = ''
form.flowName = ''
isEdit.value = false
selectedNode.value = null
selectedEdge.value = null
}
function createNewFlow() {
resetCanvas()
form.flowCode = `flow_${Date.now()}`
form.flowName = '新建入站流程'
addNode('receive')
addNode('intent')
}
function handleFlowRowClick(row: any) {
loadFlow(row.flowCode)
}
function parseDefinitionJson(row: any): { nodes: any[]; edges: any[] } {
if (!row.definitionJson) return { nodes: [], edges: [] }
if (typeof row.definitionJson === 'string') {
try {
return JSON.parse(row.definitionJson)
} catch {
return { nodes: [], edges: [] }
}
}
return row.definitionJson
}
async function loadFlow(flowCode: string) {
try {
const nodes = elements.value
.filter((el) => !('source' in el))
.map((el) => {
const { nodeType, ...config } = el.data || {}
return {
nodeType,
config,
position: el.position,
}
})
const res: any = await getFlow(flowCode)
const row = res.data
if (!row) {
ElMessage.warning('流程不存在')
return
}
const edges = elements.value
.filter((el) => 'source' in el)
.map((el) => ({
source: el.source,
target: el.target,
}))
form.flowCode = row.flowCode
form.flowName = row.flowName
isEdit.value = true
const res: any = await createFlowDefinition({
flowCode: form.flowCode,
flowName: form.flowName,
flowType: 'inbound',
definitionJson: { nodes, edges },
const definition = parseDefinitionJson(row)
const nodes = (definition.nodes || []).map((n: any, index: number) => {
const type = n.type || n.nodeType || 'receive'
const config = n.config || {}
return {
id: n.id || createNodeId(),
type: 'flowNode',
label: nodeTypeLabels[type] || type,
position: n.position || {
x: 200 + (index % 3) * 220,
y: 80 + Math.floor(index / 3) * 140,
},
data: { nodeType: type, config },
}
})
ElMessage.success('保存成功')
console.log(res)
const nodeIds = new Set(nodes.map((n) => n.id))
const edges = (definition.edges || [])
.filter((e: any) => nodeIds.has(e.source) && nodeIds.has(e.target))
.map((e: any) => ({
id: e.id || createEdgeId(e.source, e.target),
source: e.source,
target: e.target,
label: e.label,
}))
elements.value = [...nodes, ...edges]
selectedNode.value = null
selectedEdge.value = null
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function loadFlows() {
try {
const res: any = await listFlows()
flows.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
function buildPayload() {
const nodes = elements.value
.filter((el) => !('source' in el))
.map((el) => ({
id: el.id,
type: el.data?.nodeType,
config: el.data?.config || {},
position: el.position,
}))
const edges = elements.value
.filter((el) => 'source' in el)
.map((el) => ({
source: el.source,
target: el.target,
label: el.label,
}))
return {
flowCode: form.flowCode,
flowName: form.flowName,
flowType: 'inbound',
definitionJson: { nodes, edges },
}
}
async function handleSave() {
if (!form.flowCode.trim()) {
ElMessage.warning('请输入流程编码')
return
}
if (!form.flowName.trim()) {
ElMessage.warning('请输入流程名称')
return
}
try {
const payload = buildPayload()
if (isEdit.value) {
await updateFlow(form.flowCode, payload)
ElMessage.success('更新成功')
} else {
await saveFlow(payload)
ElMessage.success('保存成功')
isEdit.value = true
}
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handlePublish() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择或保存流程')
return
}
try {
await publishFlow(form.flowCode)
ElMessage.success('发布成功')
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleOffline() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择流程')
return
}
try {
await offlineFlow(form.flowCode)
ElMessage.success('已下线')
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择流程')
return
}
try {
await ElMessageBox.confirm(`确定删除流程 ${form.flowCode} 吗?`, '提示', { type: 'warning' })
await deleteFlow(form.flowCode)
ElMessage.success('删除成功')
resetCanvas()
await loadFlows()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
onMounted(() => {
loadFlows()
})
</script>
<style scoped>
.inbound-flow {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.flow-layout {
display: flex;
flex: 1;
gap: 12px;
min-height: 0;
}
.left-panel {
width: 260px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.panel-section {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
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-size: 14px;
}
.palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.palette-item {
padding: 10px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
text-align: center;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.palette-item:hover {
border-color: #409eff;
color: #409eff;
background: #ecf5ff;
}
.canvas-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e4e7ed;
border-radius: 4px;
background: #fff;
}
.flow-toolbar {
padding: 12px;
border-bottom: 1px solid #e4e7ed;
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;
min-height: 400px;
padding: 12px;
overflow: auto;
}
.config-form {
margin-top: 12px;
}
.form-hint {
font-size: 12px;
color: #909399;
line-height: 1.5;
padding: 0 0 12px 80px;
}
.node-type-tag {
font-size: 12px;
color: #409eff;
background: #ecf5ff;
padding: 2px 8px;
border-radius: 4px;
}
.flow-node {
padding: 10px 16px;
border-radius: 6px;
border: 1px solid #dcdfe6;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
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);
}
.node-icon {
font-size: 16px;
}
.node-label {
font-size: 13px;
font-weight: 500;
}
.node-receive {
border-left: 4px solid #67c23a;
}
.node-intent {
border-left: 4px solid #e6a23c;
}
.node-condition {
border-left: 4px solid #f56c6c;
}
.node-webhook {
border-left: 4px solid #409eff;
}
.node-reply {
border-left: 4px solid #909399;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div>
<el-form :model="form" label-width="140px" style="max-width: 600px;">
<el-form-item label="入站 Webhook URL">
<el-input v-model="form.inbound_webhook_url" placeholder="https://example.com/webhook" />
</el-form-item>
<el-form-item label="签名算法">
<el-select v-model="form.sign_algorithm" placeholder="选择签名算法">
<el-option label="HMAC-SHA256" value="hmac-sha256" />
<el-option label="HMAC-SHA512" value="hmac-sha512" />
</el-select>
</el-form-item>
<el-form-item label="Webhook Secret">
<el-input v-model="form.webhook_secret" type="password" show-password placeholder="用于签名验证的密钥" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button @click="handleTest">测试推送</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getInboundWebhookConfig, updateInboundWebhookConfig, testInboundWebhook } from '@/api/inboundWebhook'
const form = reactive({
inbound_webhook_url: '',
sign_algorithm: 'hmac-sha256',
webhook_secret: '',
})
async function loadConfig() {
try {
const res: any = await getInboundWebhookConfig()
const data = res.data || {}
form.inbound_webhook_url = data.inbound_webhook_url || ''
const authConfig = data.auth_config || {}
form.sign_algorithm = authConfig.sign_algorithm || 'hmac-sha256'
form.webhook_secret = authConfig.webhook_secret || ''
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleSave() {
try {
await updateInboundWebhookConfig({
inbound_webhook_url: form.inbound_webhook_url,
auth_config: {
sign_algorithm: form.sign_algorithm,
webhook_secret: form.webhook_secret,
},
})
ElMessage.success('保存成功')
await loadConfig()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleTest() {
if (!form.inbound_webhook_url) {
ElMessage.warning('请先填写 Webhook URL')
return
}
try {
const res: any = await testInboundWebhook({ url: form.inbound_webhook_url })
const result = res.data || {}
ElMessageBox.alert(result.message || '测试完成', result.success ? '测试成功' : '测试失败', {
type: result.success ? 'success' : 'error',
})
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadConfig()
})
</script>

View File

@@ -0,0 +1,780 @@
<template>
<div class="ivr-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="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>
<!-- 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>
</div>
<el-form label-width="90px" class="config-form">
<!-- start -->
<template v-if="selectedNode.data.nodeType === 'start'">
<el-form-item label="触发类型">
<el-select v-model="selectedNode.data.config.triggerType" style="width: 100%">
<el-option label="呼入电话" value="inbound_call" />
<el-option label="外呼触发" value="outbound_call" />
</el-select>
</el-form-item>
<el-form-item label="电话号码">
<el-input v-model="selectedNode.data.config.phoneNumber" placeholder="接入号码/外呼号码" />
</el-form-item>
</template>
<!-- play -->
<template v-if="selectedNode.data.nodeType === 'play'">
<el-form-item label="播放类型">
<el-select v-model="selectedNode.data.config.playType" style="width: 100%">
<el-option label="音频文件" value="audio" />
<el-option label="TTS 文本" value="tts" />
</el-select>
</el-form-item>
<el-form-item label="音频 URL" v-if="selectedNode.data.config.playType === 'audio'">
<el-input v-model="selectedNode.data.config.audioUrl" placeholder="https://" />
</el-form-item>
<el-form-item label="TTS 文本" v-if="selectedNode.data.config.playType === 'tts'">
<el-input
v-model="selectedNode.data.config.ttsText"
type="textarea"
:rows="4"
placeholder="请输入语音播报文本"
/>
</el-form-item>
</template>
<!-- menu -->
<template v-if="selectedNode.data.nodeType === 'menu'">
<el-form-item label="播报提示">
<el-input
v-model="selectedNode.data.config.prompt"
type="textarea"
:rows="3"
placeholder="请输入按键菜单播报内容"
/>
</el-form-item>
<el-form-item label="超时时间">
<el-input-number v-model="selectedNode.data.config.timeout" :min="1" :max="60" style="width: 100%" />
</el-form-item>
<el-form-item label="按键选项">
<div class="menu-options">
<div v-for="(opt, index) in selectedNode.data.config.options" :key="index" class="menu-option-row">
<el-input v-model="opt.key" placeholder="键" style="width: 60px" />
<el-input v-model="opt.label" placeholder="描述" style="flex: 1" />
<el-button type="danger" link size="small" @click="removeMenuOption(index)">删除</el-button>
</div>
<el-button type="primary" link size="small" @click="addMenuOption">+ 添加选项</el-button>
</div>
</el-form-item>
</template>
<!-- transfer -->
<template v-if="selectedNode.data.nodeType === 'transfer'">
<el-form-item label="转接号码">
<el-input v-model="selectedNode.data.config.destination" placeholder="坐席/队列号码" />
</el-form-item>
</template>
<!-- hangup -->
<template v-if="selectedNode.data.nodeType === 'hangup'">
<el-form-item label="挂断原因">
<el-input v-model="selectedNode.data.config.reason" placeholder="正常结束" />
</el-form-item>
</template>
<!-- api -->
<template v-if="selectedNode.data.nodeType === 'api'">
<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>
<el-form-item label="请求体模板">
<el-input
v-model="selectedNode.data.config.bodyTemplate"
type="textarea"
:rows="6"
placeholder='{"phone":"{{phone}}"}'
/>
</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="分支标签,如 1 / 2 / default" />
</el-form-item>
</el-form>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { 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'
import { Controls } from '@vue-flow/controls'
import {
listFlows,
getFlow,
saveFlow,
updateFlow,
publishFlow,
offlineFlow,
deleteFlow,
} from '@/api/ivrFlow'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
const { project } = useVueFlow()
interface FlowForm {
flowCode: string
flowName: string
}
const form = reactive<FlowForm>({
flowCode: '',
flowName: '',
})
const flows = ref<any[]>([])
const elements = ref<any[]>([])
const selectedNode = ref<any>(null)
const selectedEdge = ref<any>(null)
const isEdit = ref(false)
const paletteNodes = [
{ type: 'start', label: '开始' },
{ type: 'play', label: '播放语音' },
{ type: 'menu', label: '按键菜单' },
{ type: 'transfer', label: '转人工' },
{ type: 'hangup', label: '挂断' },
{ type: 'api', label: 'API 调用' },
]
const defaultMenuOptions = () => [
{ key: '1', label: '' },
{ key: '2', label: '' },
]
const nodeTypeConfig: Record<string, () => any> = {
start: () => ({ triggerType: 'inbound_call', phoneNumber: '' }),
play: () => ({ playType: 'audio', audioUrl: '', ttsText: '' }),
menu: () => ({ prompt: '', timeout: 10, options: defaultMenuOptions() }),
transfer: () => ({ destination: '' }),
hangup: () => ({ reason: '' }),
api: () => ({ url: '', method: 'POST', bodyTemplate: '' }),
}
const nodeTypeLabels: Record<string, string> = {
start: '开始',
play: '播放语音',
menu: '按键菜单',
transfer: '转人工',
hangup: '挂断',
api: 'API 调用',
}
const nodeTypeIcons: Record<string, string> = {
start: '▶',
play: '🔊',
menu: '⌨',
transfer: '📞',
hangup: '⏹',
api: '⚡',
}
function nodeTypeLabel(type: string) {
return nodeTypeLabels[type] || type
}
function nodeTypeIcon(type: string) {
return nodeTypeIcons[type] || '•'
}
function statusText(status: number) {
if (status === 1) return '已发布'
if (status === 2) return '已下线'
return '草稿'
}
function statusTagType(status: number) {
if (status === 1) return 'success'
if (status === 2) return 'warning'
return 'info'
}
function createNodeId() {
return `node_${Date.now()}_${Math.floor(Math.random() * 1000)}`
}
function createEdgeId(source: string, target: string) {
return `e_${source}_${target}`
}
function getDefaultPosition() {
const count = elements.value.filter((el) => !('source' in el)).length
const row = Math.floor(count / 2)
const col = count % 2
return { x: 200 + col * 220, y: 80 + row * 140 }
}
function addNode(type: string, position?: { x: number; y: number }) {
const id = createNodeId()
const config = nodeTypeConfig[type]()
elements.value.push({
id,
type: 'flowNode',
label: nodeTypeLabels[type],
position: position || getDefaultPosition(),
data: { nodeType: type, config },
})
}
function addMenuOption() {
if (!selectedNode.value) return
const options = selectedNode.value.data.config.options
options.push({ key: String(options.length + 1), label: '' })
}
function removeMenuOption(index: number | string) {
if (!selectedNode.value) return
selectedNode.value.data.config.options.splice(Number(index), 1)
}
function onDragStart(event: DragEvent, type: string) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow-node', type)
event.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
function onDrop(event: DragEvent) {
event.preventDefault()
if (!event.dataTransfer) return
const type = event.dataTransfer.getData('application/vueflow-node')
if (!type) return
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect()
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
})
addNode(type, position)
}
function onNodeClick(event: any) {
const node = event.node
selectedEdge.value = null
const found = elements.value.find((el) => el.id === node.id)
selectedNode.value = found || node
}
function onEdgeClick(event: any) {
const edge = event.edge
selectedNode.value = null
const found = elements.value.find((el) => el.id === edge.id)
selectedEdge.value = found || edge
}
function onPaneClick() {
selectedNode.value = null
selectedEdge.value = null
}
function resetCanvas() {
elements.value = []
form.flowCode = ''
form.flowName = ''
isEdit.value = false
selectedNode.value = null
selectedEdge.value = null
}
function createNewFlow() {
resetCanvas()
form.flowCode = `ivr_${Date.now()}`
form.flowName = '新建 IVR 流程'
addNode('start')
addNode('play')
}
function handleFlowRowClick(row: any) {
loadFlow(row.flowCode)
}
function parseDefinitionJson(row: any): { nodes: any[]; edges: any[] } {
if (!row.definitionJson) return { nodes: [], edges: [] }
if (typeof row.definitionJson === 'string') {
try {
return JSON.parse(row.definitionJson)
} catch {
return { nodes: [], edges: [] }
}
}
return row.definitionJson
}
async function loadFlow(flowCode: string) {
try {
const res: any = await getFlow(flowCode)
const row = res.data
if (!row) {
ElMessage.warning('流程不存在')
return
}
form.flowCode = row.flowCode
form.flowName = row.flowName
isEdit.value = true
const definition = parseDefinitionJson(row)
const nodes = (definition.nodes || []).map((n: any, index: number) => {
const type = n.type || n.nodeType || 'start'
const config = n.config || nodeTypeConfig[type]()
return {
id: n.id || createNodeId(),
type: 'flowNode',
label: nodeTypeLabels[type] || type,
position: n.position || {
x: 200 + (index % 3) * 220,
y: 80 + Math.floor(index / 3) * 140,
},
data: { nodeType: type, config },
}
})
const nodeIds = new Set(nodes.map((n) => n.id))
const edges = (definition.edges || [])
.filter((e: any) => nodeIds.has(e.source) && nodeIds.has(e.target))
.map((e: any) => ({
id: e.id || createEdgeId(e.source, e.target),
source: e.source,
target: e.target,
label: e.label,
}))
elements.value = [...nodes, ...edges]
selectedNode.value = null
selectedEdge.value = null
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function loadFlows() {
try {
const res: any = await listFlows()
flows.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
function buildPayload() {
const nodes = elements.value
.filter((el) => !('source' in el))
.map((el) => ({
id: el.id,
type: el.data?.nodeType,
config: el.data?.config || {},
position: el.position,
}))
const edges = elements.value
.filter((el) => 'source' in el)
.map((el) => ({
source: el.source,
target: el.target,
label: el.label,
}))
return {
flowCode: form.flowCode,
flowName: form.flowName,
flowType: 'ivr',
definitionJson: { nodes, edges },
}
}
async function handleSave() {
if (!form.flowCode.trim()) {
ElMessage.warning('请输入流程编码')
return
}
if (!form.flowName.trim()) {
ElMessage.warning('请输入流程名称')
return
}
try {
const payload = buildPayload()
if (isEdit.value) {
await updateFlow(form.flowCode, payload)
ElMessage.success('更新成功')
} else {
await saveFlow(payload)
ElMessage.success('保存成功')
isEdit.value = true
}
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handlePublish() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择或保存流程')
return
}
try {
await publishFlow(form.flowCode)
ElMessage.success('发布成功')
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleOffline() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择流程')
return
}
try {
await offlineFlow(form.flowCode)
ElMessage.success('已下线')
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择流程')
return
}
try {
await ElMessageBox.confirm(`确定删除流程 ${form.flowCode} 吗?`, '提示', { type: 'warning' })
await deleteFlow(form.flowCode)
ElMessage.success('删除成功')
resetCanvas()
await loadFlows()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
onMounted(() => {
loadFlows()
})
</script>
<style scoped>
.ivr-flow {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
}
.flow-layout {
display: flex;
flex: 1;
gap: 12px;
min-height: 0;
}
.left-panel {
width: 260px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.panel-section {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
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-size: 14px;
}
.palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.palette-item {
padding: 10px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
text-align: center;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.palette-item:hover {
border-color: #409eff;
color: #409eff;
background: #ecf5ff;
}
.canvas-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e4e7ed;
border-radius: 4px;
background: #fff;
}
.flow-toolbar {
padding: 12px;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.flow-canvas {
flex: 1;
min-height: 0;
}
.right-panel {
width: 320px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
overflow: auto;
}
.config-form {
margin-top: 12px;
}
.node-type-tag {
font-size: 12px;
color: #409eff;
background: #ecf5ff;
padding: 2px 8px;
border-radius: 4px;
}
.menu-options {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.menu-option-row {
display: flex;
align-items: center;
gap: 8px;
}
.flow-node {
padding: 10px 16px;
border-radius: 6px;
border: 1px solid #dcdfe6;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
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);
}
.node-icon {
font-size: 16px;
}
.node-label {
font-size: 13px;
font-weight: 500;
}
.node-start {
border-left: 4px solid #67c23a;
}
.node-play {
border-left: 4px solid #409eff;
}
.node-menu {
border-left: 4px solid #e6a23c;
}
.node-transfer {
border-left: 4px solid #f56c6c;
}
.node-hangup {
border-left: 4px solid #909399;
}
.node-api {
border-left: 4px solid #9254de;
}
</style>

View File

@@ -34,6 +34,9 @@
登录
</el-button>
</el-form>
<div class="login-hint">
全局管理员租户 system / 用户名 superadmin / 密码 admin123
</div>
</el-card>
</div>
</template>
@@ -107,4 +110,10 @@ async function handleSubmit() {
width: 100%;
margin-top: 8px;
}
.login-hint {
margin-top: 16px;
font-size: 12px;
color: #909399;
text-align: center;
}
</style>

View File

@@ -1,6 +1,5 @@
<template>
<div>
<h2>消息测试</h2>
<el-tabs v-model="activeTab">
<el-tab-pane label="出站测试" name="outbound">
<el-form :model="outboundForm" label-width="120px">

View File

@@ -1,52 +1,201 @@
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="编码">
<el-input v-model="form.personCode" placeholder="人员编码" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.personName" placeholder="姓名" />
</el-form-item>
<el-form-item label="手机">
<el-input v-model="form.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="form.personType" placeholder="类型">
<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>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建联系人</el-button>
</div>
<el-table :data="persons" border>
<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="类型" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="personType" label="类型" width="100">
<template #default="{ row }">
{{ formatPersonType(row.personType) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<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>
<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>
</div>
</template>
</el-table-column>
</el-table>
<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.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 v-if="isEdit" 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-dialog v-model="identitiesVisible" title="渠道身份" width="700px">
<el-table :data="identities" 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" />
<el-table-column prop="channelUserName" label="渠道用户名" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleDeleteIdentity(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-divider />
<el-form :model="identityForm" label-width="100px">
<el-form-item label="渠道">
<el-input v-model="identityForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="渠道用户ID">
<el-input v-model="identityForm.channelUserId" placeholder="渠道用户ID" />
</el-form-item>
<el-form-item label="渠道用户名">
<el-input v-model="identityForm.channelUserName" placeholder="渠道用户名" />
</el-form-item>
<el-form-item label="主体ID">
<el-input-number v-model="identityForm.subjectId" :min="0" :controls="false" placeholder="主体ID" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="identitiesVisible = false">关闭</el-button>
<el-button type="primary" @click="handleCreateIdentity">新增身份</el-button>
</template>
</el-dialog>
<el-dialog v-model="bindTagsVisible" title="绑定标签" width="500px">
<el-form :model="bindTagsForm" label-width="80px">
<el-form-item label="标签">
<el-select v-model="bindTagsForm.tagIds" multiple placeholder="选择标签" style="width: 100%">
<el-option
v-for="t in tags"
:key="t.id"
:label="t.tagName"
:value="t.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindTagsVisible = false">取消</el-button>
<el-button type="primary" @click="handleBindTags">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createPerson, listPersons } from '@/api/crm'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
bindPersonTags,
createPerson,
createPersonIdentity,
deletePerson,
deletePersonIdentity,
listPersonIdentities,
listPersons,
listTags,
updatePerson,
} from '@/api/crm'
const form = reactive({
const persons = ref<any[]>([])
const tags = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
personCode: '',
personName: '',
phone: '',
personType: 3,
status: 1,
})
const persons = ref<any[]>([])
function resetDialogForm() {
dialogForm.personCode = ''
dialogForm.personName = ''
dialogForm.phone = ''
dialogForm.personType = 3
dialogForm.status = 1
}
function fillDialogForm(row: any) {
dialogForm.personCode = row.personCode ?? ''
dialogForm.personName = row.personName ?? ''
dialogForm.phone = row.phone ?? ''
dialogForm.personType = row.personType ?? 3
dialogForm.status = row.status ?? 1
}
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 formatPersonType(type: number | undefined) {
if (type === 3) return '客户'
if (type === 1 || type === 2 || type === 4) return '员工'
return '未知'
}
async function loadPersons() {
try {
@@ -57,15 +206,183 @@ async function loadPersons() {
}
}
async function handleCreate() {
async function loadTags() {
try {
const res: any = await createPerson(form)
persons.value.unshift(res.data)
ElMessage.success('创建成功')
const res: any = await listTags()
tags.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadPersons)
async function handleSave() {
if (!dialogForm.personName) {
ElMessage.warning('请填写姓名')
return
}
try {
const payload = {
personCode: dialogForm.personCode,
personName: dialogForm.personName,
phone: dialogForm.phone,
personType: dialogForm.personType,
status: dialogForm.status,
}
if (isEdit.value && editingId.value != null) {
await updatePerson(editingId.value, payload)
ElMessage.success('更新成功')
} else {
const res: any = await createPerson(payload)
persons.value.unshift(res.data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadPersons()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updatePerson(row.id, {
personCode: row.personCode,
personName: row.personName,
phone: row.phone,
personType: row.personType,
status: val,
})
ElMessage.success('状态更新成功')
await loadPersons()
} catch (e: any) {
ElMessage.error(e.message)
await loadPersons()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除联系人「${row.personName || row.personCode}」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deletePerson(row.id)
ElMessage.success('删除成功')
await loadPersons()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
const identitiesVisible = ref(false)
const identities = ref<any[]>([])
const currentPersonId = ref<number | undefined>()
const identityForm = reactive({
channelType: '',
channelUserId: '',
channelUserName: '',
subjectId: undefined as number | undefined,
})
function resetIdentityForm() {
identityForm.channelType = ''
identityForm.channelUserId = ''
identityForm.channelUserName = ''
identityForm.subjectId = undefined
}
async function openIdentities(row: any) {
currentPersonId.value = row.id
identitiesVisible.value = true
resetIdentityForm()
await loadIdentities()
}
async function loadIdentities() {
if (!currentPersonId.value) return
try {
const res: any = await listPersonIdentities(currentPersonId.value)
identities.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleCreateIdentity() {
if (!currentPersonId.value) return
if (!identityForm.channelType || !identityForm.channelUserId) {
ElMessage.warning('请填写渠道和渠道用户ID')
return
}
try {
const payload: any = {
channelType: identityForm.channelType,
channelUserId: identityForm.channelUserId,
channelUserName: identityForm.channelUserName,
}
if (identityForm.subjectId != null) {
payload.subjectId = identityForm.subjectId
}
await createPersonIdentity(currentPersonId.value, payload)
ElMessage.success('新增成功')
resetIdentityForm()
await loadIdentities()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDeleteIdentity(row: any) {
if (!currentPersonId.value) return
try {
await ElMessageBox.confirm('确定删除该渠道身份吗?', '删除确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
await deletePersonIdentity(currentPersonId.value, row.id)
ElMessage.success('删除成功')
await loadIdentities()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
const bindTagsVisible = ref(false)
const bindTagsForm = reactive({
tagIds: [] as number[],
})
function openBindTags(row: any) {
currentPersonId.value = row.id
bindTagsForm.tagIds = []
bindTagsVisible.value = true
}
async function handleBindTags() {
if (!currentPersonId.value) return
try {
await bindPersonTags(currentPersonId.value, { tagIds: bindTagsForm.tagIds })
ElMessage.success('绑定成功')
bindTagsVisible.value = false
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadPersons()
loadTags()
})
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<div>
<div style="margin-bottom: 16px;">
<el-button type="primary" @click="openCreateDialog">新增策略组</el-button>
</div>
<el-table :data="policyGroups" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="groupCode" label="策略组编码" />
<el-table-column prop="groupName" label="策略组名称" />
<el-table-column prop="channelType" label="渠道类型" />
<el-table-column prop="status" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 1" type="success">启用</el-tag>
<el-tag v-else type="info">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" />
<el-table-column label="操作" width="220">
<template #default="scope">
<el-button link type="primary" @click="openEditDialog(scope.row)">编辑</el-button>
<el-button v-if="scope.row.status === 1" link type="warning" @click="handleToggleStatus(scope.row, 0)">禁用</el-button>
<el-button v-else link type="success" @click="handleToggleStatus(scope.row, 1)">启用</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<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.group_code" :disabled="isEdit" placeholder="唯一编码" />
</el-form-item>
<el-form-item label="策略组名称">
<el-input v-model="dialogForm.group_name" placeholder="显示名称" />
</el-form-item>
<el-form-item label="渠道类型">
<el-select v-model="dialogForm.channel_type" placeholder="选择渠道类型" style="width: 100%">
<el-option label="企微" value="wecom" />
<el-option label="个人微信" value="wechat_personal" />
<el-option label="短信" value="sms" />
<el-option label="IVR" value="ivr" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="dialogForm.status" placeholder="状态">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="策略配置">
<el-input v-model="dialogForm.policies" type="textarea" :rows="10" placeholder='JSON 数组,例如:[{"rule":"channel_type","value":"wecom"}]' />
</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>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listPolicyGroups,
createPolicyGroup,
updatePolicyGroup,
updatePolicyGroupStatus,
deletePolicyGroup,
} from '@/api/sendPolicy'
const policyGroups = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const dialogForm = reactive({
group_code: '',
group_name: '',
channel_type: '',
status: 1,
policies: '[]',
})
async function loadPolicyGroups() {
try {
const res: any = await listPolicyGroups()
policyGroups.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
function resetDialogForm() {
dialogForm.group_code = ''
dialogForm.group_name = ''
dialogForm.channel_type = ''
dialogForm.status = 1
dialogForm.policies = '[]'
}
function openCreateDialog() {
isEdit.value = false
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
dialogForm.group_code = row.groupCode
dialogForm.group_name = row.groupName
dialogForm.channel_type = row.channelType
dialogForm.status = row.status
try {
const policies = row.policies ? JSON.parse(row.policies) : []
dialogForm.policies = JSON.stringify(policies, null, 2)
} catch {
dialogForm.policies = row.policies || '[]'
}
dialogVisible.value = true
}
function buildPayload() {
let policies
try {
policies = JSON.parse(dialogForm.policies)
} catch {
throw new Error('策略配置不是有效的 JSON')
}
return {
groupCode: dialogForm.group_code,
groupName: dialogForm.group_name,
channelType: dialogForm.channel_type,
status: dialogForm.status,
policies,
}
}
async function handleSave() {
try {
const payload = buildPayload()
if (isEdit.value) {
await updatePolicyGroup(dialogForm.group_code, payload)
ElMessage.success('更新成功')
} else {
await createPolicyGroup(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadPolicyGroups()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleToggleStatus(row: any, status: number) {
try {
await updatePolicyGroupStatus(row.groupCode, status)
ElMessage.success(status === 1 ? '已启用' : '已禁用')
await loadPolicyGroups()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确定删除该策略组吗?', '提示', { type: 'warning' })
await deletePolicyGroup(row.groupCode)
ElMessage.success('删除成功')
await loadPolicyGroups()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
onMounted(() => {
loadPolicyGroups()
})
</script>

View File

@@ -1,93 +1,439 @@
<template>
<div>
<el-form :model="form" label-width="80px">
<el-form-item label="目标类型">
<el-select v-model="form.targetType">
<el-option label="会话" value="conversation" />
<el-option label="人员" value="person" />
</el-select>
</el-form-item>
<el-form-item label="目标" v-if="form.targetType === 'conversation'">
<el-select v-model="selectedConversationId" placeholder="选择会话">
<el-option v-for="c in conversations" :key="c.id" :label="c.conversationName" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="内容类型"><el-input v-model="form.contentType" disabled /></el-form-item>
<el-form-item label="内容">
<el-input v-model="textContent" type="textarea" :rows="3" placeholder="输入文本消息" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
</el-form-item>
</el-form>
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="今日发送总数" :value="statistics.total" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="成功" :value="statistics.successCount">
<template #suffix>
<el-tag type="success" size="small">成功</el-tag>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="失败" :value="statistics.failedCount">
<template #suffix>
<el-tag type="danger" size="small">失败</el-tag>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="待发送" :value="statistics.pendingCount">
<template #suffix>
<el-tag type="info" size="small">待发送</el-tag>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<el-divider />
<!-- 测试发送 -->
<el-card class="mt-4" shadow="never">
<template #header>
<span>测试发送</span>
</template>
<el-form :model="testForm" label-width="80px" inline>
<el-form-item label="目标类型">
<el-select v-model="testForm.targetType" style="width: 140px">
<el-option label="会话" value="conversation" />
<el-option label="人员" value="person" />
</el-select>
</el-form-item>
<el-form-item label="目标" v-if="testForm.targetType === 'conversation'" style="width: 280px">
<el-select v-model="selectedConversationId" placeholder="选择会话" clearable>
<el-option
v-for="c in conversations"
:key="c.id"
:label="c.conversationName"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="目标" v-if="testForm.targetType === 'person'" style="width: 280px">
<el-input v-model="personIdInput" placeholder="输入 person_id" />
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="testForm.channelStrategy.channel_type" style="width: 140px">
<el-option label="企微" value="wecom" />
<el-option label="微信个人号" value="wechat_personal" />
</el-select>
</el-form-item>
<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-table :data="records" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="taskId" label="任务ID" />
<el-table-column prop="targetType" label="目标类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="contentType" label="内容类型" />
<el-table-column prop="sendStatus" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.sendStatus === 2" type="success">成功</el-tag>
<el-tag v-else-if="scope.row.sendStatus === 3" type="danger">失败</el-tag>
<el-tag v-else type="info">待发送</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" />
</el-table>
<!-- 筛选栏 -->
<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>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="发送详情" size="520px">
<div v-if="currentRecord" class="detail-content">
<div class="detail-item">
<span class="detail-label">任务 ID</span>
<span class="detail-value">{{ currentRecord.taskId }}</span>
</div>
<div class="detail-item">
<span class="detail-label">目标类型</span>
<span class="detail-value">{{ currentRecord.targetType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">目标值</span>
<pre>{{ formatJson(currentRecord.targetValue) }}</pre>
</div>
<div class="detail-item">
<span class="detail-label">渠道</span>
<span class="detail-value">{{ currentRecord.channelType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">内容</span>
<pre>{{ formatJson(currentRecord.contentBody) }}</pre>
</div>
<div class="detail-item">
<span class="detail-label">渠道结果</span>
<pre>{{ formatJson(currentRecord.channelResult) }}</pre>
</div>
<div class="detail-item">
<span class="detail-label">回调地址</span>
<span class="detail-value">{{ currentRecord.callbackUrl || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">发送时间</span>
<span class="detail-value">{{ currentRecord.sendTime || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">完成时间</span>
<span class="detail-value">{{ currentRecord.finishTime || '-' }}</span>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { testSendMessage, listSendRecords } from '@/api/send'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
queryRecords,
getStatistics,
cancelTask,
testSend,
type QueryRecordsParams,
} from '@/api/sendRecord'
import { listConversations } from '@/api/channel'
const form = reactive({ targetType: 'conversation', contentType: 'text', channelStrategy: { channel_type: 'wecom', strategy: 'primary' } })
const selectedConversationId = ref<number | null>(null)
const textContent = ref('')
const records = ref<any[]>([])
const conversations = ref<any[]>([])
const targetValue = computed(() => {
if (form.targetType === 'conversation' && selectedConversationId.value) {
return { conversation_id: selectedConversationId.value }
}
return {}
})
async function loadRecords() {
const res: any = await listSendRecords()
records.value = res.data || []
interface RecordItem {
id: number
taskId: string
targetType: string
targetValue?: string
channelType: string
sendStatus: number
sendTime?: string
finishTime?: string
contentBody?: string
channelResult?: string
callbackUrl?: string
}
async function loadConversations() {
const res: any = await listConversations()
conversations.value = res.data || []
const statistics = reactive({
total: 0,
pendingCount: 0,
sendingCount: 0,
successCount: 0,
failedCount: 0,
})
const query = reactive<QueryRecordsParams>({
taskId: '',
channelType: '',
status: '',
})
const dateRange = ref<string[]>([])
const pagination = reactive({
page: 1,
size: 20,
total: 0,
})
const records = ref<RecordItem[]>([])
const loading = ref(false)
const detailVisible = ref(false)
const currentRecord = ref<RecordItem | null>(null)
const testForm = reactive({
targetType: 'conversation',
contentType: 'text',
channelStrategy: { channel_type: 'wecom', strategy: 'primary' },
})
const selectedConversationId = ref<number | null>(null)
const personIdInput = ref('')
const textContent = ref('')
const conversations = ref<any[]>([])
function buildTargetValue() {
if (testForm.targetType === 'conversation' && selectedConversationId.value) {
return { conversation_id: selectedConversationId.value }
}
if (testForm.targetType === 'person' && personIdInput.value) {
return { person_id: Number(personIdInput.value) }
}
return {}
}
async function loadRecords() {
loading.value = true
try {
const params: QueryRecordsParams = {
page: pagination.page,
size: pagination.size,
taskId: query.taskId || undefined,
channelType: query.channelType || undefined,
status: query.status === '' ? undefined : query.status,
}
if (dateRange.value && dateRange.value.length === 2) {
params.start = dateRange.value[0]
params.end = dateRange.value[1]
}
const res: any = await queryRecords(params)
const data = res.data || {}
records.value = data.records || data || []
pagination.total = data.total || records.value.length
} catch (e: any) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadRecords()
}
function handleReset() {
query.taskId = ''
query.channelType = ''
query.status = ''
dateRange.value = []
pagination.page = 1
loadRecords()
}
function handleSizeChange() {
pagination.page = 1
loadRecords()
}
async function loadStatistics() {
const now = new Date()
const start = formatDateTime(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0))
const end = formatDateTime(now)
try {
const res: any = await getStatistics(start, end)
Object.assign(statistics, res.data || {})
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleTestSend() {
const targetValue = buildTargetValue()
if (Object.keys(targetValue).length === 0) {
ElMessage.warning('请选择或填写目标')
return
}
try {
await testSendMessage({
targetType: form.targetType,
targetValue: targetValue.value,
await testSend({
targetType: testForm.targetType,
targetValue,
contentType: 'text',
content: { text: textContent.value },
channelStrategy: form.channelStrategy,
channelStrategy: testForm.channelStrategy,
})
ElMessage.success('测试发送已提交')
textContent.value = ''
await loadRecords()
await loadStatistics()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleCancel(taskId: string) {
try {
await ElMessageBox.confirm('确定取消该任务的待发送记录?', '提示', { type: 'warning' })
await cancelTask(taskId)
ElMessage.success('取消成功')
await loadRecords()
await loadStatistics()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '取消失败')
}
}
}
function openDetail(row: RecordItem) {
currentRecord.value = row
detailVisible.value = true
}
function formatJson(value?: string) {
if (!value) return '-'
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch {
return value
}
}
function formatDateTime(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
async function loadConversations() {
try {
const res: any = await listConversations()
conversations.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadRecords()
loadConversations()
loadRecords()
loadStatistics()
})
</script>
<style scoped>
.mt-4 {
margin-top: 16px;
}
.pagination-bar {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.detail-content {
font-size: 14px;
}
.detail-item {
margin-bottom: 16px;
}
.detail-label {
display: block;
color: #606266;
margin-bottom: 6px;
}
.detail-value {
color: #303133;
word-break: break-all;
}
pre {
margin: 0;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -1,47 +1,124 @@
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="标签名">
<el-input v-model="form.tagName" placeholder="标签名" />
</el-form-item>
<el-form-item label="父标签">
<el-select v-model="form.parentId" placeholder="选择父标签">
<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>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建标签</el-button>
</div>
<el-table :data="tags" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tagName" label="名称" />
<el-table-column prop="tagPath" label="路径" />
<el-table-column prop="level" label="层级" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="tagPath" label="路径" show-overflow-tooltip />
<el-table-column prop="level" label="层级" width="100">
<template #default="{ row }">
{{ formatLevel(row.level) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<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>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<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="标签名" />
</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="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createTag, listTags } from '@/api/crm'
const form = reactive({
tagName: '',
parentId: 0,
})
import { ElMessage, ElMessageBox } from 'element-plus'
import { createTag, deleteTag, listTags, updateTag } from '@/api/crm'
const tags = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
tagName: '',
parentId: 0,
status: 1,
})
function resetDialogForm() {
dialogForm.tagName = ''
dialogForm.parentId = 0
dialogForm.status = 1
}
function fillDialogForm(row: any) {
dialogForm.tagName = row.tagName ?? ''
dialogForm.parentId = row.parentId ?? 0
dialogForm.status = row.status ?? 1
}
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 formatLevel(level: number | undefined) {
if (level == null) return '-'
return `${level}`
}
async function loadTags() {
try {
const res: any = await listTags()
@@ -51,15 +128,69 @@ async function loadTags() {
}
}
async function handleCreate() {
async function handleSave() {
if (!dialogForm.tagName) {
ElMessage.warning('请填写标签名')
return
}
try {
const res: any = await createTag(form)
tags.value.unshift(res.data)
ElMessage.success('创建成功')
const payload = {
tagName: dialogForm.tagName,
parentId: dialogForm.parentId,
status: dialogForm.status,
}
if (isEdit.value && editingId.value != null) {
await updateTag(editingId.value, payload)
ElMessage.success('更新成功')
} else {
const res: any = await createTag(payload)
tags.value.unshift(res.data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadTags()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updateTag(row.id, {
tagName: row.tagName,
parentId: row.parentId,
status: val,
})
ElMessage.success('状态更新成功')
await loadTags()
} catch (e: any) {
ElMessage.error(e.message)
await loadTags()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除标签「${row.tagName}」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deleteTag(row.id)
ElMessage.success('删除成功')
await loadTags()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
onMounted(loadTags)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,38 +1,126 @@
<template>
<div>
<h2>租户管理</h2>
<el-form :model="form" inline>
<el-form-item label="租户编码">
<el-input v-model="form.tenantCode" placeholder="租户编码" />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="form.tenantName" placeholder="租户名称" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建租户</el-button>
</div>
<el-table :data="tenants" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" />
<el-table-column prop="tenantName" label="租户名称" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="inboundWebhookUrl" label="入站 Webhook" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</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)"
/>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑租户' : '创建租户'" width="600px">
<el-form :model="dialogForm" label-width="140px">
<el-form-item label="租户编码">
<el-input v-model="dialogForm.tenantCode" :disabled="isEdit" placeholder="租户编码" />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="dialogForm.tenantName" placeholder="租户名称" />
</el-form-item>
<el-form-item label="入站 Webhook">
<el-input v-model="dialogForm.inboundWebhookUrl" placeholder="https://example.com/webhook" />
</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>
<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>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createTenant, listTenants } from '@/api/account'
const form = reactive({
tenantCode: '',
tenantName: '',
})
import { ElMessage, ElMessageBox } from 'element-plus'
import { createTenant, deleteTenant, listTenants, updateTenant } from '@/api/account'
const tenants = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
tenantCode: '',
tenantName: '',
inboundWebhookUrl: '',
status: 1,
createAdmin: true,
initAdminUsername: 'admin',
initAdminPassword: '',
})
function resetDialogForm() {
dialogForm.tenantCode = ''
dialogForm.tenantName = ''
dialogForm.inboundWebhookUrl = ''
dialogForm.status = 1
dialogForm.createAdmin = true
dialogForm.initAdminUsername = 'admin'
dialogForm.initAdminPassword = ''
}
function fillDialogForm(row: any) {
dialogForm.tenantCode = row.tenantCode ?? ''
dialogForm.tenantName = row.tenantName ?? ''
dialogForm.inboundWebhookUrl = row.inboundWebhookUrl ?? ''
dialogForm.status = row.status ?? 1
}
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
}
async function loadTenants() {
try {
@@ -43,15 +131,80 @@ async function loadTenants() {
}
}
async function handleCreate() {
async function handleSave() {
if (!dialogForm.tenantCode || !dialogForm.tenantName) {
ElMessage.warning('请填写租户编码和租户名称')
return
}
try {
await createTenant(form)
ElMessage.success('创建成功')
if (isEdit.value && editingId.value != null) {
await updateTenant(editingId.value, {
tenantName: dialogForm.tenantName,
inboundWebhookUrl: dialogForm.inboundWebhookUrl,
status: dialogForm.status,
})
ElMessage.success('更新成功')
} else {
const payload: any = {
tenantCode: dialogForm.tenantCode,
tenantName: dialogForm.tenantName,
inboundWebhookUrl: dialogForm.inboundWebhookUrl,
}
if (dialogForm.createAdmin) {
if (!dialogForm.initAdminUsername || !dialogForm.initAdminPassword) {
ElMessage.warning('请填写初始化管理员账号和密码')
return
}
payload.initAdminUsername = dialogForm.initAdminUsername
payload.initAdminPassword = dialogForm.initAdminPassword
}
await createTenant(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updateTenant(row.id, {
tenantName: row.tenantName,
inboundWebhookUrl: row.inboundWebhookUrl,
status: val,
})
ElMessage.success('状态更新成功')
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
await loadTenants()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除租户「${row.tenantName}${row.tenantCode})」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deleteTenant(row.id)
ElMessage.success('删除成功')
await loadTenants()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
onMounted(loadTenants)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,62 +1,296 @@
<template>
<div>
<h2>用户管理</h2>
<el-form :model="form" inline>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="密码" />
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="form.realName" placeholder="真实姓名" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-select
v-model="selectedTenant"
placeholder="选择租户"
clearable
style="width: 200px; margin-right: 12px"
:disabled="!isPlatformAdmin"
@change="loadUsers"
>
<el-option
v-for="t in tenants"
:key="t.id"
:label="`${t.tenantName}${t.tenantCode}`"
:value="t.tenantCode"
/>
</el-select>
<el-button type="primary" @click="openCreateDialog">创建用户</el-button>
</div>
<el-table :data="users" border>
<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="roleCodes" label="角色" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="phone" label="手机号" />
<el-table-column prop="roleCodes" label="角色">
<template #default="{ row }">
{{ formatRoles(row.roleCodes) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<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)"
/>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '创建用户'" 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-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="dialogForm.username" :disabled="isEdit" placeholder="用户名" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码">
<el-input v-model="dialogForm.password" type="password" placeholder="密码" />
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="dialogForm.realName" 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.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="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>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createUser, listUsers } from '@/api/account'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import {
createUser,
deleteUser,
listRoles,
listTenants,
listUsers,
updateUser,
} from '@/api/account'
const form = reactive({
const authStore = useAuthStore()
const isPlatformAdmin = computed(() => authStore.roleCodes.includes('platform_admin'))
const users = ref<any[]>([])
const tenants = ref<any[]>([])
const roles = ref<any[]>([])
const selectedTenant = ref(authStore.tenantCode || '')
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
tenantCode: '',
username: '',
password: '',
realName: '',
phone: '',
roleCodes: [] as string[],
status: 1,
})
const users = ref<any[]>([])
function resetDialogForm() {
dialogForm.tenantCode = isPlatformAdmin.value ? '' : (authStore.tenantCode || '')
dialogForm.username = ''
dialogForm.password = ''
dialogForm.realName = ''
dialogForm.phone = ''
dialogForm.roleCodes = []
dialogForm.status = 1
}
function fillDialogForm(row: any) {
dialogForm.tenantCode = row.tenantCode ?? ''
dialogForm.username = row.username ?? ''
dialogForm.password = ''
dialogForm.realName = row.realName ?? ''
dialogForm.phone = row.phone ?? ''
dialogForm.roleCodes = row.roleCodes || []
dialogForm.status = row.status ?? 1
}
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 formatRoles(codes: string[] | undefined) {
if (!codes || codes.length === 0) return '-'
return codes
.map((code) => {
const role = roles.value.find((r) => r.roleCode === code)
return role ? role.roleName : code
})
.join('')
}
async function loadTenants() {
try {
const res: any = await listTenants()
tenants.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function loadRoles() {
try {
const res: any = await listRoles()
roles.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function loadUsers() {
try {
const res: any = await listUsers()
const res: any = await listUsers(selectedTenant.value || undefined)
users.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleCreate() {
async function handleSave() {
if (!dialogForm.username) {
ElMessage.warning('请填写用户名')
return
}
if (!isEdit.value && !dialogForm.password) {
ElMessage.warning('请填写密码')
return
}
if (!dialogForm.tenantCode) {
ElMessage.warning('请选择租户')
return
}
try {
await createUser({ ...form, roleCodes: ['tenant_admin'] })
ElMessage.success('创建成功')
if (isEdit.value && editingId.value != null) {
await updateUser(editingId.value, {
realName: dialogForm.realName,
phone: dialogForm.phone,
status: dialogForm.status,
roleCodes: dialogForm.roleCodes,
})
ElMessage.success('更新成功')
} else {
await createUser(dialogForm.tenantCode, {
username: dialogForm.username,
password: dialogForm.password,
realName: dialogForm.realName,
phone: dialogForm.phone,
roleCodes: dialogForm.roleCodes,
})
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadUsers()
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadUsers)
async function handleStatusChange(row: any, val: any) {
try {
await updateUser(row.id, {
realName: row.realName,
phone: row.phone,
status: val,
roleCodes: row.roleCodes || [],
})
ElMessage.success('状态更新成功')
await loadUsers()
} catch (e: any) {
ElMessage.error(e.message)
await loadUsers()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除用户「${row.username}${row.tenantCode})」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deleteUser(row.id)
ElMessage.success('删除成功')
await loadUsers()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
onMounted(() => {
loadTenants()
loadRoles()
loadUsers()
})
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/flow.ts","./src/api/send.ts","./src/router/index.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/inboundflow.vue","./src/views/person.vue","./src/views/sendrecord.vue","./src/views/tag.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/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"}