feat: add send many account mode

This commit is contained in:
engigu
2025-12-14 12:43:09 +08:00
parent 2125ddc853
commit 5e20533aa9
26 changed files with 1547 additions and 793 deletions
@@ -1,12 +1,13 @@
<script lang="ts">
import { ref, defineComponent } from 'vue'
import { toast } from 'vue-sonner'
import { ref, defineComponent, watch, toRef } from 'vue'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
// @ts-ignore
import { TemplateApiStrGenerate } from '@/util/viewApi'
import { useInstanceData } from '@/composables/useInstanceData'
import { useApiCodeViewer } from '@/composables/useApiCodeViewer'
export default defineComponent({
name: 'TemplateApiViewer',
@@ -39,58 +40,67 @@ export default defineComponent({
emit('update:open', value)
}
// 当前选中的标签
const activeTab = ref('curl')
// 使用实例数据管理 composable
const { hasDynamicRecipientInstance, enabledChannelNames } = useInstanceData(
'template',
toRef(props, 'templateData'),
toRef(props, 'open')
)
// 代码语言选项
const codeLanguages = [
{ value: 'curl', label: 'cURL', icon: '🌐' },
{ value: 'javascript', label: 'JS', icon: '🟨' },
{ value: 'python', label: 'Python', icon: '🐍' },
{ value: 'php', label: 'PHP', icon: '🐘' },
{ value: 'golang', label: 'Go', icon: '🐹' },
{ value: 'java', label: 'Java', icon: '☕' },
{ value: 'rust', label: 'Rust', icon: '🦀' }
]
// 使用 API 代码查看器 composable
const { activeTab, codeLanguages, copyToClipboard } = useApiCodeViewer()
// 可选参数选项
const showRecipients = ref(false)
// 监听动态接收实例变化,自动勾选
watch(hasDynamicRecipientInstance, (newVal) => {
if (newVal) {
showRecipients.value = true
}
})
// 监听弹窗关闭,重置状态
watch(() => props.open, (newVal) => {
if (!newVal) {
showRecipients.value = false
}
})
// 生成API代码示例
const generateApiCode = (language: string) => {
const templateId = props.templateData?.id || 'TEMPLATE_ID'
const placeholders = props.templateData?.placeholders || '[]'
const options = {
recipients: showRecipients.value
}
switch (language) {
case 'curl':
return TemplateApiStrGenerate.getCurlString(templateId, placeholders)
return TemplateApiStrGenerate.getCurlString(templateId, placeholders, options)
case 'javascript':
return TemplateApiStrGenerate.getNodeString(templateId, placeholders)
return TemplateApiStrGenerate.getNodeString(templateId, placeholders, options)
case 'python':
return TemplateApiStrGenerate.getPythonString(templateId, placeholders)
return TemplateApiStrGenerate.getPythonString(templateId, placeholders, options)
case 'php':
return TemplateApiStrGenerate.getPHPString(templateId, placeholders)
return TemplateApiStrGenerate.getPHPString(templateId, placeholders, options)
case 'golang':
return TemplateApiStrGenerate.getGolangString(templateId, placeholders)
return TemplateApiStrGenerate.getGolangString(templateId, placeholders, options)
case 'java':
return TemplateApiStrGenerate.getJavaString(templateId, placeholders)
return TemplateApiStrGenerate.getJavaString(templateId, placeholders, options)
case 'rust':
return TemplateApiStrGenerate.getRustString(templateId, placeholders)
return TemplateApiStrGenerate.getRustString(templateId, placeholders, options)
default:
return '// 请选择一种编程语言查看示例代码'
}
}
// 复制代码到剪贴板
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success('复制成功')
} catch (err) {
toast.error('复制失败')
}
}
return {
handleUpdateOpen,
activeTab,
hasDynamicRecipientInstance,
enabledChannelNames,
showRecipients,
codeLanguages,
generateApiCode,
copyToClipboard
@@ -123,6 +133,45 @@ export default defineComponent({
<p><strong>可选参数:</strong> 根据模板配置的@提醒字段自动应用</p>
<p class="text-amber-600 dark:text-amber-400"><strong> 注意:</strong> V2接口使用加密token不支持明文ID</p>
</div>
<!-- 已启用的渠道列表 -->
<div v-if="enabledChannelNames.length > 0" class="mt-3 pt-3 border-t">
<p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">已启用的发送渠道</p>
<div class="flex flex-wrap gap-2">
<Badge
v-for="(name, index) in enabledChannelNames"
:key="index"
variant="secondary"
class="text-xs"
>
{{ name }}
</Badge>
</div>
</div>
<div v-else class="mt-3 pt-3 border-t">
<p class="text-xs text-amber-600 dark:text-amber-400"> 该模板暂无启用的发送渠道</p>
</div>
</div>
<!-- 可选参数 -->
<div v-if="hasDynamicRecipientInstance" class="border rounded-lg p-4 bg-gray-50 dark:bg-slate-800/50">
<h3 class="font-semibold mb-3">可选参数</h3>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-not-allowed opacity-75">
<input
type="checkbox"
v-model="showRecipients"
disabled
class="rounded cursor-not-allowed"
>
<span class="text-sm">动态接收者</span>
<Badge variant="secondary" class="text-xs">必填</Badge>
</label>
</div>
<div class="space-y-1 text-xs text-gray-500 dark:text-gray-400 mt-3">
<p>📧 动态接收者该模板配置了动态接收实例发送时必须通过API指定接收者列表群发模式</p>
<p class="text-amber-600 dark:text-amber-400"> 此参数已自动勾选且不可取消因为模板已配置动态接收实例</p>
</div>
</div>
<!-- 代码示例 -->
@@ -1,27 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxList,
ComboboxItem,
ComboboxViewport
} from '@/components/ui/combobox'
import { CheckIcon } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Switch } from '@/components/ui/switch'
import { CONSTANT } from '@/constant'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
import { generateBizUniqueID } from '@/util/uuid'
import InstanceConfig from '@/components/ui/InstanceConfig.vue'
// 组件props
interface Props {
@@ -37,225 +16,6 @@ const props = withDefaults(defineProps<Props>(), {
defineEmits<{
'update:open': [value: boolean]
}>()
// 前端的页面添加配置
const waysConfigMap = CONSTANT.WAYS_DATA
// 搜索相关状态
const searchQuery = ref('')
const isSearching = ref(false)
const channelName = ref('')
// 当前显示的选项(搜索结果或所有选项)
const displayOptions = ref<Array<{ id: string, name: string, type: string }>>([])
// 输入框显示值(只在用户搜索时显示搜索内容)
const inputDisplayValue = computed({
get: () => searchQuery.value,
set: (value: string) => {
searchQuery.value = value
}
})
// 当前选中渠道的配置
const currentChannelConfig = computed(() => {
// 先根据label找到type
let type = displayOptions.value.find(item => item.name === channelName.value)?.type
// 再根据type找到配置
let rs = waysConfigMap.find(item => item.type === type) || null;
return rs;
})
// 表单数据
const formData = ref<Record<string, any>>({})
// 监听渠道变化
const handlechannelNameChange = () => {
// 数据加载后,text/html单选设置默认选中(这里选第一个)
if (currentChannelConfig.value?.taskInsRadios.length > 0) {
formData.value.templ_type = currentChannelConfig.value?.taskInsRadios[0].subLabel
}
}
// 添加单条实例配置
const handleAddSubmit = async () => {
// 验证是否选择了渠道
if (!channelName.value) {
toast.error('请选择发送渠道')
return
}
// 验证内容类型
const contentType = formData.value.templ_type
if (!contentType) {
toast.error('请选择消息格式')
return
}
// 验证模板对应格式的内容是否为空
const templateFieldMap: Record<string, string> = {
'text': 'text_template',
'html': 'html_template',
'markdown': 'markdown_template'
}
const fieldName = templateFieldMap[contentType.toLowerCase()]
if (fieldName) {
const templateContent = props.templateData?.[fieldName] || ''
// 检查是否为空(去除所有空白字符后检查)
if (!templateContent.trim()) {
toast.error(`模板的 ${contentType} 格式内容为空,无法添加此类型的实例`)
return
}
}
// 组建表单数据
let postData = {
"id": generateBizUniqueID('IN'),
"enable": 1,
"template_id": props.templateData.id,
"way_id": displayOptions.value[0]?.id,
"way_type": displayOptions.value[0]?.type,
"way_name": displayOptions.value[0]?.name,
"content_type": formData.value.templ_type,
"config": JSON.stringify(formData.value),
}
try {
const response = await request.post('/templates/ins/addone', postData)
if (response.status === 200 && response.data.code === 200) {
toast.success(response.data.msg)
// 重新加载实例列表
await queryInsListData()
// 清空表单
channelName.value = ''
formData.value = {}
} else {
toast.error(response.data.msg || '添加实例失败')
}
} catch (error: any) {
toast.error(error.response?.data?.msg || '添加实例失败')
}
}
// 实例表格数据
const insTableData = ref<any[]>([])
// 查询实例列表数据
const queryInsListData = async () => {
if (!props.templateData?.id) return
try {
const response = await request.get('/templates/ins/get', {
params: { id: props.templateData.id }
})
if (response.status === 200 && response.data.code === 200) {
const insList = response.data.data.ins_list || []
insTableData.value = insList
}
} catch (error) {
console.error('获取实例列表失败', error)
}
}
// 删除实例
const handleDeleteIns = async (insId: string) => {
try {
const response = await request.post('/sendtasks/ins/delete', { id: insId })
if (response.status === 200 && response.data.code === 200) {
toast.success(response.data.msg)
await queryInsListData()
} else {
toast.error(response.data.msg || '删除失败')
}
} catch (error: any) {
toast.error(error.response?.data?.msg || '删除失败')
}
}
// 切换实例启用状态
const handleToggleEnable = async (insId: string, currentStatus: number | string) => {
const isEnabled = Number(currentStatus) === 1
const newStatus = isEnabled ? 0 : 1
// 立即更新本地状态,提供即时反馈
const insIndex = insTableData.value.findIndex(ins => ins.id === insId)
if (insIndex !== -1) {
insTableData.value[insIndex].enable = newStatus
}
try {
const response = await request.post('/sendtasks/ins/update_enable', {
ins_id: insId,
status: newStatus
})
if (response.status === 200 && response.data.code === 200) {
toast.success(response.data.msg)
// 重新加载确保数据同步
await queryInsListData()
} else {
toast.error(response.data.msg || '更新失败')
// 失败时恢复原状态
if (insIndex !== -1) {
insTableData.value[insIndex].enable = currentStatus
}
}
} catch (error: any) {
console.error('状态切换失败:', error)
toast.error(error.response?.data?.msg || '更新失败')
// 失败时恢复原状态
if (insIndex !== -1) {
insTableData.value[insIndex].enable = currentStatus
}
}
}
// 防抖定时器
let searchTimer: number | null = null
// 搜索渠道(带防抖)
const handleSearch = (query: string) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
if (!query.trim()) {
displayOptions.value = []
return
}
// 设置防抖延迟(500ms
searchTimer = window.setTimeout(async () => {
isSearching.value = true
try {
const response = await request.get('/sendways/list', {
params: { name: query }
})
if (response.status === 200 && response.data.code === 200) {
displayOptions.value = response.data.data.lists.map((item: any) => ({
id: item.id,
name: item.name,
type: item.type
}))
}
} catch (error) {
console.error('搜索渠道失败', error)
displayOptions.value = []
} finally {
isSearching.value = false
}
}, 500)
}
// 监听对话框打开状态,打开时加载实例列表
watch(() => props.open, (newVal) => {
if (newVal && props.templateData?.id) {
queryInsListData()
}
}, { immediate: true })
</script>
<template>
@@ -265,120 +25,12 @@ watch(() => props.open, (newVal) => {
<DialogTitle>配置发送实例</DialogTitle>
</DialogHeader>
<div class="px-4 pb-4 flex-1 overflow-y-auto">
<div class="space-y-4">
<!-- 模板信息 -->
<div class="mb-6 p-3 bg-muted rounded-lg space-y-1">
<div class="flex items-baseline gap-2">
<span class="text-base font-semibold">{{ templateData?.name }}</span>
<Badge variant="outline" class="text-xs">{{ templateData?.id }}</Badge>
</div>
<div class="text-xs text-muted-foreground">为此模板配置发送实例</div>
</div>
<!-- 添加实例表单 -->
<div class="space-y-4">
<div class="flex items-end gap-2">
<div class="flex-1 space-y-2">
<Label class="text-sm font-medium">选择发送渠道</Label>
<Combobox v-model="channelName" @update:model-value="handlechannelNameChange">
<ComboboxAnchor class="w-full">
<ComboboxInput v-model="inputDisplayValue" @input="handleSearch(inputDisplayValue)"
class="flex h-10 w-full" placeholder="搜索或选择渠道类型进行实例的添加..." />
</ComboboxAnchor>
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
<ComboboxViewport>
<ComboboxItem v-for="option in displayOptions" :key="option.id" :value="option.name">
<div class="flex items-center justify-between w-full">
<span>{{ option.name }}</span>
<CheckIcon v-if="channelName === option.name" class="h-4 w-4" />
</div>
</ComboboxItem>
<div v-if="isSearching" class="p-2 text-sm text-muted-foreground">搜索中...</div>
<div v-if="!isSearching && displayOptions.length === 0 && searchQuery" class="p-2 text-sm text-muted-foreground">
未找到匹配的渠道
</div>
</ComboboxViewport>
</ComboboxList>
</Combobox>
</div>
<Button size="sm" variant="outline" @click="handleAddSubmit">添加实例</Button>
</div>
</div>
<!-- 渠道配置表单 -->
<div v-if="currentChannelConfig" class="mt-4">
<!-- 实例配置输入字段 -->
<div v-if="currentChannelConfig.taskInsInputs && currentChannelConfig.taskInsInputs.length > 0" class="mb-2">
<Label class="text-sm font-medium mb-1">实例配置</Label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="input in currentChannelConfig.taskInsInputs" :key="input.col" class="space-y-2">
<label class="text-xs font-medium text-muted-foreground">{{ input.label || input.desc }}</label>
<Input v-model="formData[input.col]" :placeholder="input.desc || `请输入${input.label}`"
:type="input.type || 'text'" class="w-full" />
</div>
</div>
</div>
<!-- 单选框 -->
<div v-if="currentChannelConfig.taskInsRadios && currentChannelConfig.taskInsRadios.length > 0" class="mt-4">
<Label class="text-sm font-medium mb-2">消息格式</Label>
<RadioGroup v-model="formData.templ_type" class="flex gap-4">
<div v-for="radio in currentChannelConfig.taskInsRadios" :key="radio.subLabel" class="flex items-center space-x-2">
<RadioGroupItem :value="radio.subLabel" :id="radio.subLabel" />
<Label :for="radio.subLabel" class="text-sm cursor-pointer">{{ radio.subLabel }}</Label>
</div>
</RadioGroup>
</div>
</div>
<!-- 关联的实例表 -->
<div class="mt-4">
<h3 class="text-sm font-medium mb-3">已经关联的实例</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>渠道名称</TableHead>
<TableHead>内容类型</TableHead>
<TableHead class="text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="ins in insTableData" :key="ins.id">
<TableCell>
<div class="font-medium">{{ ins.way_name || '未命名' }}</div>
<div class="text-xs text-muted-foreground">{{ ins.way_type }}</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{{ ins.content_type }}</Badge>
</TableCell>
<TableCell class="text-center">
<div class="flex items-center justify-center gap-2">
<Switch
:model-value="ins.enable === 1"
@update:model-value="() => handleToggleEnable(ins.id, ins.enable)"
/>
<Button
size="sm"
variant="outline"
class="text-red-500 border-red-300 hover:bg-red-50 hover:border-red-400 hover:text-red-600 hover:shadow-md transition-all duration-200"
@click="handleDeleteIns(ins.id)"
>
删除
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="!insTableData || insTableData.length === 0">
<TableCell :colspan="3" class="h-24">
<EmptyTableState title="暂无实例" description="还没有配置任何实例请先添加" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<InstanceConfig
type="template"
:data="templateData"
:in-dialog="true"
/>
</div>
</DialogContent>
</Dialog>
@@ -1,12 +1,13 @@
<script lang="ts">
import { ref, defineComponent } from 'vue'
import { toast } from 'vue-sonner'
import { ref, defineComponent, watch, toRef } from 'vue'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
// @ts-ignore
import { ApiStrGenerate } from '@/util/viewApi'
import { useInstanceData } from '@/composables/useInstanceData'
import { useApiCodeViewer } from '@/composables/useApiCodeViewer'
export default defineComponent({
name: 'ApiCodeViewer',
@@ -39,8 +40,15 @@ export default defineComponent({
emit('update:open', value)
}
// 当前选中的标签
const activeTab = ref('curl')
// 使用实例数据管理 composable
const { hasDynamicRecipientInstance, enabledChannelNames } = useInstanceData(
'task',
toRef(props, 'taskData'),
toRef(props, 'open')
)
// 使用 API 代码查看器 composable
const { activeTab, codeLanguages, copyToClipboard } = useApiCodeViewer()
// 可选参数选项
const showHtml = ref(false)
@@ -49,17 +57,21 @@ export default defineComponent({
const showAtMobiles = ref(false)
const showAtUserIds = ref(false)
const showAtAll = ref(false)
// 代码语言选项
const codeLanguages = [
{ value: 'curl', label: 'cURL', icon: '🌐' },
{ value: 'javascript', label: 'JS', icon: '🟨' },
{ value: 'python', label: 'Python', icon: '🐍' },
{ value: 'php', label: 'PHP', icon: '🐘' },
{ value: 'golang', label: 'Go', icon: '🐹' },
{ value: 'java', label: 'Java', icon: '☕' },
{ value: 'rust', label: 'Rust', icon: '🦀' }
]
const showRecipients = ref(false)
// 监听动态接收实例变化,自动勾选
watch(hasDynamicRecipientInstance, (newVal) => {
if (newVal) {
showRecipients.value = true
}
})
// 监听弹窗关闭,重置状态
watch(() => props.open, (newVal) => {
if (!newVal) {
showRecipients.value = false
}
})
// 生成API代码示例
const generateApiCode = (language: string) => {
@@ -70,7 +82,8 @@ export default defineComponent({
url: showUrl.value,
at_mobiles: showAtMobiles.value,
at_user_ids: showAtUserIds.value,
at_all: showAtAll.value
at_all: showAtAll.value,
recipients: showRecipients.value
}
switch (language) {
@@ -93,25 +106,18 @@ export default defineComponent({
}
}
// 复制代码到剪贴板
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success('复制成功')
} catch (err) {
toast.error('复制失败')
}
}
return {
handleUpdateOpen,
activeTab,
hasDynamicRecipientInstance,
enabledChannelNames,
showHtml,
showMarkdown,
showUrl,
showAtMobiles,
showAtUserIds,
showAtAll,
showRecipients,
codeLanguages,
generateApiCode,
copyToClipboard
@@ -132,16 +138,31 @@ export default defineComponent({
<div class=" space-y-2">
<!-- API 信息概览 -->
<!-- <div class="space-y-3">
<div class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<Badge variant="default">POST</Badge>
<code class="text-sm">/sendtasks/send</code>
</div>
<p class="text-sm text-gray-600">发送消息创建新的消息</p>
<div class="border rounded-lg p-4 space-y-2 bg-white dark:bg-slate-900">
<div class="flex items-center gap-2">
<Badge variant="default">POST</Badge>
<code class="text-sm bg-gray-100 dark:bg-slate-800 px-2 py-1 rounded">/api/v1/message/send</code>
</div>
</div> -->
<p class="text-sm text-gray-600 dark:text-gray-400">发送消息到任务配置的渠道</p>
<!-- 已启用的渠道列表 -->
<div v-if="enabledChannelNames.length > 0" class="mt-3 pt-3 border-t">
<p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">已启用的发送渠道</p>
<div class="flex flex-wrap gap-2">
<Badge
v-for="(name, index) in enabledChannelNames"
:key="index"
variant="secondary"
class="text-xs"
>
{{ name }}
</Badge>
</div>
</div>
<div v-else class="mt-3 pt-3 border-t">
<p class="text-xs text-amber-600 dark:text-amber-400"> 该任务暂无启用的发送渠道</p>
</div>
</div>
<!-- 可选参数 -->
<div class="border rounded-lg p-4 space-y-3">
@@ -174,9 +195,23 @@ export default defineComponent({
<span class="text-sm">@所有人</span>
<Badge variant="secondary" class="text-xs"></Badge>
</label>
<label
v-if="hasDynamicRecipientInstance"
class="flex items-center gap-2 cursor-not-allowed opacity-75"
>
<input
type="checkbox"
v-model="showRecipients"
disabled
class="rounded cursor-not-allowed"
>
<span class="text-sm">动态接收者</span>
<Badge variant="secondary" class="text-xs">必填</Badge>
</label>
</div>
<div class="space-y-1 text-xs text-gray-500 dark:text-gray-400">
<p>💡 提示@功能仅钉钉和企业微信支持</p>
<p v-if="hasDynamicRecipientInstance" class="text-amber-600 dark:text-amber-400">📧 动态接收者该任务配置了动态接收实例发送时必须通过API指定接收者列表此参数已自动勾选且不可取消</p>
<p>📋 发送顺序实例配置的内容类型优先若为空则按 <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">HTML Markdown Text</code> 顺序回退</p>
</div>
</div>
+13 -308
View File
@@ -1,27 +1,11 @@
<script setup lang="ts">
import { ref, computed, defineProps, withDefaults, onMounted } from 'vue'
import { defineProps, withDefaults } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from "@/components/ui/badge"
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxList,
ComboboxItem,
ComboboxViewport
} from '@/components/ui/combobox'
import { CheckIcon } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Switch } from '@/components/ui/switch'
import { CONSTANT } from '@/constant'
import InstanceConfig from '@/components/ui/InstanceConfig.vue'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
import { generateBizUniqueID } from '@/util/uuid'
// 组件props
interface Props {
@@ -33,198 +17,16 @@ const props = withDefaults(defineProps<Props>(), {
editData: null
})
// 前端的页面添加配置
const waysConfigMap = CONSTANT.WAYS_DATA
// 搜索相关状态
const searchQuery = ref('')
const isSearching = ref(false)
const channelName = ref('')
// 当前显示的选项(搜索结果或所有选项)
const displayOptions = ref<Array<{ id: string, name: string, type: string }>>([])
// 输入框显示值(只在用户搜索时显示搜索内容)
const inputDisplayValue = computed({
get: () => searchQuery.value,
set: (value: string) => {
searchQuery.value = value
}
})
// 当前选中任务的配置
const currentChannelConfig = computed(() => {
// 先根据label找到type
let type = displayOptions.value.find(item => item.name === channelName.value)?.type
// 再根据type找到配置
let rs = waysConfigMap.find(item => item.type === type) || null;
return rs;
})
// 表单数据
const formData = ref<Record<string, any>>({})
// 监听任务模式变化
const handlechannelNameChange = () => {
// 数据加载后,text/html单选设置默认选中(这里选第一个)
if (currentChannelConfig.value?.taskInsRadios.length > 0) {
formData.value.taskInsRadio = currentChannelConfig.value?.taskInsRadios[0].subLabel
}
}
// 添加单条实例配置
const handleAddSubmit = async () => {
// 组建表单数据
let postData = {
"id": generateBizUniqueID('IN'),
"enable": 1,
"task_id": props.editData.id,
"way_id": displayOptions.value[0]?.id,
"way_type": displayOptions.value[0]?.type,
"way_name": displayOptions.value[0]?.name,
"content_type": formData.value.taskInsRadio,
"config": "{}"
};
const { taskInsRadio, ...configValue } = formData.value
if (configValue) {
postData.config = JSON.stringify(configValue)
}
const rsp = await request.post('/sendtasks/ins/addone', postData);
if (await rsp.data.code == 200) {
// 动态添加表格第一行
const newItem = {
...postData,
created_by: '',
modified_by: '',
created_on: new Date().toISOString(),
modified_on: new Date().toISOString(),
extra: ''
};
insTableData.value.unshift(newItem);
toast.success(rsp.data.msg);
}
}
const insTableData = ref<Array<{
id: string;
created_by: string;
modified_by: string;
created_on: string;
modified_on: string;
task_id: string;
way_id: string;
way_type: string;
content_type: string;
config: string;
extra: string;
enable: number;
way_name: string;
}>>([]);
const queryInsListData = async () => {
let params = { id: props.editData.id };
const rsp = await request.get('/sendtasks/ins/gettask', { params: params });
insTableData.value = await rsp.data.data.ins_data;
}
// 防抖定时器
let searchTimer: number | null = null
// 搜索处理函数(带防抖)
const handleSearch = (query: string) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
if (!query.trim()) {
displayOptions.value = []
return
}
// 设置防抖延迟(500ms
searchTimer = window.setTimeout(async () => {
isSearching.value = true
try {
const results = await querySearchWayAsync(query)
displayOptions.value = results.map((item: any) => ({
id: item.id,
name: item.name,
type: item.type
}))
} catch (error) {
console.error('搜索失败:', error)
displayOptions.value = []
} finally {
isSearching.value = false
}
}, 500)
}
const querySearchWayAsync = async (query: string) => {
const params = { name: query }
const rsp = await request.get('/sendways/list', { params })
return rsp.data.data.lists
}
const getWayTypeText = (type: string) => {
const wayData = CONSTANT.WAYS_DATA.find(item => item.type === type)
return wayData ? wayData.label : type
}
// 切换启用状态
const toggleEnable = async (item: any) => {
const newStatus = item.enable === 1 ? 0 : 1
let postData = { ins_id: item.id, status: newStatus };
const rsp = await request.post('/sendtasks/ins/update_enable', postData);
if (await rsp.data.code == 200) {
toast.success(rsp.data.msg);
// 更新本地状态
item.enable = newStatus
}
}
const handleDelete = async (item: any) => {
const rsp = await request.post('/sendtasks/ins/delete', { id: item.id });
if (rsp.status == 200) {
toast.success(rsp.data.msg);
insTableData.value = insTableData.value.filter((ins) => ins.id !== item.id)
}
}
// 定义emits
const emit = defineEmits(['update:open', 'save'])
const handleEditTask = async () => {
let postData = { id: props.editData.id, name: props.editData.name };
const rsp = await request.post('/sendtasks/edit', postData);
let postData = { id: props.editData.id, name: props.editData.name }
const rsp = await request.post('/sendtasks/edit', postData)
if (await rsp.data.code == 200) {
toast.success(rsp.data.msg);
toast.success(rsp.data.msg)
}
}
// 格式化额外信息列的值
const formatInsConfigDisplay = (row: any) => {
if (!row.config) {
return ""
}
if (["Email", "WeChatOFAccount"].includes(row.way_type)) {
let config = JSON.parse(row.config)
let info = `${config.to_account}`
return info
} else {
return ""
}
}
// 组件挂载时加载实例配置列表
onMounted(() => {
queryInsListData();
})
</script>
<template>
@@ -236,111 +38,14 @@ onMounted(() => {
<Button size="sm" variant="outline" class="w-full sm:w-auto sm:ml-auto" @click="handleEditTask">修改</Button>
</div>
<div class="mt-4">
<div class="flex items-end gap-2">
<div class="flex-1 space-y-2">
<Label class="text-sm font-medium">选择发送渠道</Label>
<Combobox v-model="channelName" @update:model-value="handlechannelNameChange">
<ComboboxAnchor class="w-full">
<ComboboxInput v-model="inputDisplayValue" @input="handleSearch(inputDisplayValue)"
class="flex h-10 w-full" placeholder="搜索或选择渠道类型进行实例的添加..." />
</ComboboxAnchor>
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
<ComboboxViewport>
<div v-if="isSearching" class="px-2 py-1.5 text-sm text-muted-foreground">
搜索中...
</div>
<div v-else-if="searchQuery && displayOptions.length === 0"
class="px-2 py-1.5 text-sm text-muted-foreground">
未找到匹配项
</div>
<ComboboxItem v-for="option in displayOptions" :key="option.id" :value="option.name"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<CheckIcon class="mr-2 h-4 w-4" :class="channelName === option.name ? 'opacity-100' : 'opacity-0'" />
{{ option.name }}
</ComboboxItem>
</ComboboxViewport>
</ComboboxList>
</Combobox>
</div>
<Button size="sm" variant="outline" @click="handleAddSubmit">添加实例</Button>
</div>
<!-- 动态任务配置区域 -->
<div v-if="currentChannelConfig" class="mt-4">
<!-- 任务指令输入字段 -->
<div v-if="currentChannelConfig.taskInsInputs && currentChannelConfig.taskInsInputs.length > 0" class="mb-2">
<Label class="text-sm font-medium text-gray-700 mb-1">实例配置</Label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="input in currentChannelConfig.taskInsInputs" :key="input.col" class="space-y-2">
<label class="text-xs font-medium text-gray-600">{{ input.label }}</label>
<Input v-model="formData[input.col]" :placeholder="input.desc || `请输入${input.desc}`"
:type="input.type || 'text'" class="text-sm" />
</div>
</div>
</div>
<!-- 任务指令单选项 -->
<div v-if="currentChannelConfig.taskInsRadios && currentChannelConfig.taskInsRadios.length > 0">
<Label class="text-sm font-medium text-gray-700 mb-1">消息格式</Label>
<RadioGroup v-model="formData.taskInsRadio" class="flex flex-wrap gap-4">
<div v-for="radio in currentChannelConfig.taskInsRadios" :key="radio.value"
class="flex items-center space-x-2">
<RadioGroupItem :value="radio.subLabel" :id="radio.subLabel" />
<Label :for="radio.value"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{{ radio.subLabel }}
</Label>
</div>
</RadioGroup>
</div>
</div>
<!-- 实例配置组件 -->
<div class="p-4">
<InstanceConfig
type="task"
:data="editData"
/>
</div>
</div>
<!-- 关联的实例表 -->
<div class="mt-4">
<h3 class="text-sm font-medium text-gray-900 mb-3">已经关联的实例</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>渠道名</TableHead>
<TableHead>渠道/内容</TableHead>
<TableHead class="text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="item in insTableData" :key="item.id">
<TableCell>{{ item.way_name }}</TableCell>
<TableCell>
<Badge variant="outline">
{{ getWayTypeText(item.way_type) }}
</Badge>
<Badge variant="outline">
{{ item.content_type }}
</Badge>
<Badge variant="outline" v-if="item.config !== '{}'">
{{ formatInsConfigDisplay(item) }}
</Badge>
</TableCell>
<TableCell class="text-center">
<div class="flex items-center justify-center gap-2">
<Switch :model-value="item.enable === 1" @update:model-value="() => toggleEnable(item)" />
<Button size="sm" variant="outline" class="text-red-500 border-red-300 hover:bg-red-50
hover:border-red-400 hover:text-red-600 hover:shadow-md
transition-all duration-200" @click="() => handleDelete(item)">删除</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="!insTableData || insTableData.length === 0">
<TableCell :colspan="3" class="h-24">
<EmptyTableState title="暂无实例" description="还没有配置任何实例请先添加" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>
<script lang="ts">
+30 -2
View File
@@ -250,20 +250,48 @@ const saveButtonText = computed(() => {
<!-- Radio Group -->
<div class="mb-6">
<label class="text-lg font-medium mb-3 block">渠道类型</label>
<RadioGroup v-model="channelMode" @update:model-value="handleChannelModeChange" class="flex flex-wrap gap-4">
<RadioGroup v-model="channelMode" @update:model-value="handleChannelModeChange" class="flex flex-wrap gap-3">
<div v-for="option in channelModeOptions" :key="option.value" class="flex items-center space-x-2">
<RadioGroupItem :value="option.value" :id="option.value" />
<label :for="option.value"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex items-center gap-1.5">
{{ option.label }}
<span
v-if="waysConfigMap.find(item => item.type === option.value)?.dynamicRecipient?.support"
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
:title="`支持${waysConfigMap.find(item => item.type === option.value)?.dynamicRecipient?.label || '动态接收者'}群发`"
>
群发
</span>
</label>
</div>
</RadioGroup>
<p class="text-[11px] text-gray-500 dark:text-gray-400 mt-2.5 leading-relaxed">
💡 "群发"标识的渠道支持动态接收者可在 API 调用时指定多个接收账号
</p>
</div>
<div class="w-full">
<!-- 动态表单 -->
<div v-if="currentChannelConfig" class="mt-6">
<!-- 动态接收者支持提示 -->
<div
v-if="currentChannelConfig.dynamicRecipient?.support"
class="mb-4 p-2.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md"
>
<div class="flex items-start gap-2">
<span class="text-blue-600 dark:text-blue-400 text-sm mt-0.5">📧</span>
<div class="flex-1 space-y-1">
<p class="text-xs text-blue-800 dark:text-blue-200 font-medium">
支持群发模式 - 可在配置实例时启用"动态接收者"通过 API <code class="px-1 py-0.5 bg-blue-100 dark:bg-blue-800 rounded text-[11px]">recipients</code> 参数指定多个{{ currentChannelConfig.dynamicRecipient.label }}
</p>
<p class="text-[11px] text-blue-600 dark:text-blue-400">
适用邮件群发公众号批量推送营销通知等
</p>
</div>
</div>
</div>
<!-- 基本配置输入字段 -->
<div v-if="currentChannelConfig.inputs && currentChannelConfig.inputs.length > 0" class="mb-8">
<h4 class="text-base font-medium mb-4 text-gray-800">基本配置</h4>
+519
View File
@@ -0,0 +1,519 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from "@/components/ui/badge"
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxList,
ComboboxItem,
ComboboxViewport
} from '@/components/ui/combobox'
import { CheckIcon } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Switch } from '@/components/ui/switch'
import { CONSTANT } from '@/constant'
import { toast } from 'vue-sonner'
import { request } from '@/api/api'
import { generateBizUniqueID } from '@/util/uuid'
// 组件props
interface Props {
// 实例类型:'template' 或 'task'
type: 'template' | 'task'
// 关联的数据(模板数据或任务数据)
data: any
// 是否在对话框中显示(用于模板)
inDialog?: boolean
}
const props = withDefaults(defineProps<Props>(), {
inDialog: false
})
// API 配置映射
const apiConfig = computed(() => {
if (props.type === 'template') {
return {
addIns: '/templates/ins/addone',
getIns: '/templates/ins/get',
deleteIns: '/sendtasks/ins/delete',
updateEnable: '/sendtasks/ins/update_enable',
idField: 'template_id',
nameField: 'name'
}
} else {
return {
addIns: '/sendtasks/ins/addone',
getIns: '/sendtasks/ins/gettask',
deleteIns: '/sendtasks/ins/delete',
updateEnable: '/sendtasks/ins/update_enable',
idField: 'task_id',
nameField: 'name'
}
}
})
// 前端的页面添加配置
const waysConfigMap = CONSTANT.WAYS_DATA
// 搜索相关状态
const searchQuery = ref('')
const isSearching = ref(false)
const channelName = ref('')
// 当前显示的选项(搜索结果或所有选项)
const displayOptions = ref<Array<{ id: string, name: string, type: string }>>([])
// 输入框显示值(只在用户搜索时显示搜索内容)
const inputDisplayValue = computed({
get: () => searchQuery.value,
set: (value: string) => {
searchQuery.value = value
}
})
// 当前选中渠道的配置
const currentChannelConfig = computed(() => {
// 先根据label找到type
let type = displayOptions.value.find(item => item.name === channelName.value)?.type
// 再根据type找到配置
let rs = waysConfigMap.find(item => item.type === type) || null
return rs
})
// 表单数据
const formData = ref<Record<string, any>>({
allowMultiRecip: false // 默认false为固定模式,true为动态模式
})
// 是否显示接收者输入框
const shouldShowRecipientInput = computed(() => {
// 支持动态接收者 且 未勾选(固定模式)时显示输入框
return currentChannelConfig.value?.dynamicRecipient?.support && !formData.value.allowMultiRecip
})
// 监听渠道变化
const handlechannelNameChange = () => {
// 数据加载后,text/html单选设置默认选中(这里选第一个)
if (currentChannelConfig.value?.taskInsRadios.length > 0) {
formData.value.templ_type = currentChannelConfig.value?.taskInsRadios[0].subLabel
}
// 重置动态接收者设置
formData.value.allowMultiRecip = false
}
// 添加单条实例配置
const handleAddSubmit = async () => {
// 验证是否选择了渠道
if (!channelName.value) {
toast.error('请选择发送渠道')
return
}
// 检查动态接收和固定接收不能混合使用
if (insTableData.value.length > 0) {
const hasDynamicInstance = insTableData.value.some(ins => {
try {
const config = JSON.parse(ins.config)
return config.allowMultiRecip === true
} catch {
return false
}
})
const entityName = props.type === 'template' ? '模板' : '任务'
// 如果要添加动态接收实例,但已有其他实例
if (formData.value.allowMultiRecip === true) {
if (hasDynamicInstance) {
toast.error(`${entityName}已存在动态接收实例,一个${entityName}只能配置一个动态接收实例`)
return
}
if (insTableData.value.length > 0) {
toast.error(`动态接收实例不能与固定接收实例混合使用,请先删除所有固定实例`)
return
}
}
// 如果要添加固定接收实例,但已有动态接收实例
if (formData.value.allowMultiRecip !== true && hasDynamicInstance) {
toast.error(`${entityName}已配置动态接收实例,不能再添加固定接收实例`)
return
}
}
// 验证内容类型
const contentType = formData.value.templ_type
if (!contentType) {
toast.error('请选择消息格式')
return
}
// 仅模板需要验证对应格式的内容是否为空
if (props.type === 'template') {
const templateFieldMap: Record<string, string> = {
'text': 'text_template',
'html': 'html_template',
'markdown': 'markdown_template'
}
const fieldName = templateFieldMap[contentType.toLowerCase()]
if (fieldName) {
const templateContent = props.data?.[fieldName] || ''
// 检查是否为空(去除所有空白字符后检查)
if (!templateContent.trim()) {
toast.error(`模板的 ${contentType} 格式内容为空,无法添加此类型的实例`)
return
}
}
}
// 组建表单数据
let postData: Record<string, any> = {
"id": generateBizUniqueID('IN'),
"enable": 1,
[apiConfig.value.idField]: props.data.id,
"way_id": displayOptions.value[0]?.id,
"way_type": displayOptions.value[0]?.type,
"way_name": displayOptions.value[0]?.name,
"content_type": formData.value.templ_type,
"config": JSON.stringify(formData.value),
}
try {
const response = await request.post(apiConfig.value.addIns, postData)
if (response.status === 200 && response.data.code === 200) {
toast.success(response.data.msg)
// 重新加载实例列表
await queryInsListData()
// 清空表单
channelName.value = ''
formData.value = { allowMultiRecip: false }
} else {
toast.error(response.data.msg || '添加实例失败')
}
} catch (error: any) {
toast.error(error.response?.data?.msg || '添加实例失败')
}
}
// 实例表格数据
const insTableData = ref<any[]>([])
// 格式化额外信息列的值
const formatInsConfigDisplay = (row: any) => {
if (!row.config) {
return ""
}
if (["Email", "WeChatOFAccount"].includes(row.way_type)) {
let config = JSON.parse(row.config)
// 检查是否为动态接收者模式
if (config.allowMultiRecip === true) {
return "动态接收"
}
// 固定模式,显示接收者
return config.to_account || ""
} else {
return ""
}
}
// 查询实例列表数据
const queryInsListData = async () => {
if (!props.data?.id) return
try {
const response = await request.get(apiConfig.value.getIns, {
params: { id: props.data.id }
})
if (response.status === 200 && response.data.code === 200) {
// 模板返回 ins_list,任务返回 ins_data
const insList = response.data.data.ins_list || response.data.data.ins_data || []
insTableData.value = insList
}
} catch (error) {
console.error('获取实例列表失败', error)
}
}
// 删除实例
const handleDeleteIns = async (insId: string) => {
try {
const response = await request.post(apiConfig.value.deleteIns, { id: insId })
if (response.status === 200 && response.data.code === 200) {
toast.success(response.data.msg)
await queryInsListData()
} else {
toast.error(response.data.msg || '删除失败')
}
} catch (error: any) {
toast.error(error.response?.data?.msg || '删除失败')
}
}
// 切换实例启用状态
const handleToggleEnable = async (insId: string, currentStatus: number | string) => {
const isEnabled = Number(currentStatus) === 1
const newStatus = isEnabled ? 0 : 1
// 立即更新本地状态,提供即时反馈
const insIndex = insTableData.value.findIndex(ins => ins.id === insId)
if (insIndex !== -1) {
insTableData.value[insIndex].enable = newStatus
}
try {
const response = await request.post(apiConfig.value.updateEnable, {
ins_id: insId,
status: newStatus
})
if (response.status === 200 && response.data.code === 200) {
toast.success(response.data.msg)
// 重新加载确保数据同步
await queryInsListData()
} else {
toast.error(response.data.msg || '更新失败')
// 失败时恢复原状态
if (insIndex !== -1) {
insTableData.value[insIndex].enable = currentStatus
}
}
} catch (error: any) {
console.error('状态切换失败:', error)
toast.error(error.response?.data?.msg || '更新失败')
// 失败时恢复原状态
if (insIndex !== -1) {
insTableData.value[insIndex].enable = currentStatus
}
}
}
// 防抖定时器
let searchTimer: number | null = null
// 搜索渠道(带防抖)
const handleSearch = (query: string) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
if (!query.trim()) {
displayOptions.value = []
return
}
// 设置防抖延迟(500ms
searchTimer = window.setTimeout(async () => {
isSearching.value = true
try {
const response = await request.get('/sendways/list', {
params: { name: query }
})
if (response.status === 200 && response.data.code === 200) {
displayOptions.value = response.data.data.lists.map((item: any) => ({
id: item.id,
name: item.name,
type: item.type
}))
}
} catch (error) {
console.error('搜索渠道失败', error)
displayOptions.value = []
} finally {
isSearching.value = false
}
}, 500)
}
// 监听数据变化,自动加载实例列表
watch(() => props.data?.id, (newVal) => {
if (newVal) {
queryInsListData()
}
}, { immediate: true })
// 暴露方法供父组件调用
defineExpose({
queryInsListData
})
</script>
<template>
<div class="space-y-4" :class="{ 'px-4 pb-4': inDialog }">
<!-- 信息展示区域 -->
<div v-if="data" class="p-3 bg-muted rounded-lg space-y-1">
<div class="flex items-baseline gap-2">
<span class="text-base font-semibold">{{ data[apiConfig.nameField] }}</span>
<Badge variant="outline" class="text-xs">{{ data.id }}</Badge>
</div>
<div class="text-xs text-muted-foreground">
为此{{ type === 'template' ? '模板' : '任务' }}配置发送实例
</div>
</div>
<!-- 添加实例表单 -->
<div class="space-y-4">
<div class="flex items-end gap-2">
<div class="flex-1 space-y-2">
<Label class="text-sm font-medium">选择发送渠道</Label>
<Combobox v-model="channelName" @update:model-value="handlechannelNameChange">
<ComboboxAnchor class="w-full">
<ComboboxInput
v-model="inputDisplayValue"
@input="handleSearch(inputDisplayValue)"
class="flex h-10 w-full"
placeholder="搜索或选择渠道类型进行实例的添加..."
/>
</ComboboxAnchor>
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
<ComboboxViewport>
<ComboboxItem v-for="option in displayOptions" :key="option.id" :value="option.name">
<div class="flex items-center justify-between w-full">
<span>{{ option.name }}</span>
<CheckIcon v-if="channelName === option.name" class="h-4 w-4" />
</div>
</ComboboxItem>
<div v-if="isSearching" class="p-2 text-sm text-muted-foreground">搜索中...</div>
<div v-if="!isSearching && displayOptions.length === 0 && searchQuery" class="p-2 text-sm text-muted-foreground">
未找到匹配的渠道
</div>
</ComboboxViewport>
</ComboboxList>
</Combobox>
</div>
<Button size="sm" variant="outline" @click="handleAddSubmit">添加实例</Button>
</div>
</div>
<!-- 渠道配置表单 -->
<div v-if="currentChannelConfig" class="mt-4">
<!-- 动态接收者勾选框 -->
<div v-if="currentChannelConfig.dynamicRecipient?.support" class="mb-4 p-3 border rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="flex items-center space-x-2">
<Switch
:model-value="formData.allowMultiRecip"
@update:model-value="(val: boolean) => formData.allowMultiRecip = val"
:id="`allow-multi-${channelName}`"
/>
<Label :for="`allow-multi-${channelName}`" class="text-sm font-medium cursor-pointer">
动态接收者模式
</Label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-8">
{{ formData.allowMultiRecip ? '支持动态接收者,发送时通过API指定接收者列表(群发模式)' : '固定接收者模式,需要在下方配置固定接收者' }}
</p>
<p v-if="formData.allowMultiRecip" class="text-xs text-orange-500 dark:text-orange-400 mt-1 ml-8 font-medium">
⚠️ 注意:一个{{ type === 'template' ? '模板' : '任务' }}只能配置一个动态接收实例,且不能与固定接收实例混合使用
</p>
</div>
<!-- 接收者输入字段 -->
<div v-if="shouldShowRecipientInput" class="mb-2">
<Label class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">实例配置</Label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">
{{ currentChannelConfig.dynamicRecipient.label }}
</label>
<Input
v-model="formData[currentChannelConfig.dynamicRecipient.field]"
:placeholder="`请输入${currentChannelConfig.dynamicRecipient.desc}`"
type="text"
class="text-sm"
/>
</div>
</div>
</div>
<!-- 实例配置输入字段(排除动态接收者字段) -->
<div v-if="currentChannelConfig.taskInsInputs && currentChannelConfig.taskInsInputs.length > 0" class="mb-2">
<Label class="text-sm font-medium mb-1">实例配置</Label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="input in currentChannelConfig.taskInsInputs.filter((inp: any) => inp.col !== currentChannelConfig?.dynamicRecipient?.field)"
:key="input.col"
class="space-y-2"
>
<label class="text-xs font-medium text-muted-foreground">{{ input.label || input.desc }}</label>
<Input
v-model="formData[input.col]"
:placeholder="input.desc || `请输入${input.label}`"
:type="input.type || 'text'"
class="w-full"
/>
</div>
</div>
</div>
<!-- 单选框 -->
<div v-if="currentChannelConfig.taskInsRadios && currentChannelConfig.taskInsRadios.length > 0" class="mt-4">
<Label class="text-sm font-medium mb-2">消息格式</Label>
<RadioGroup v-model="formData.templ_type" class="flex gap-4">
<div v-for="radio in currentChannelConfig.taskInsRadios" :key="radio.subLabel" class="flex items-center space-x-2">
<RadioGroupItem :value="radio.subLabel" :id="radio.subLabel" />
<Label :for="radio.subLabel" class="text-sm cursor-pointer">{{ radio.subLabel }}</Label>
</div>
</RadioGroup>
</div>
</div>
<!-- 关联的实例表 -->
<div class="mt-4">
<h3 class="text-sm font-medium mb-3">已经关联的实例</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>渠道名称</TableHead>
<TableHead>内容类型</TableHead>
<TableHead>接收者</TableHead>
<TableHead class="text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="ins in insTableData" :key="ins.id">
<TableCell>
<div class="font-medium">{{ ins.way_name || '未命名' }}</div>
<div class="text-xs text-muted-foreground">{{ ins.way_type }}</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{{ ins.content_type }}</Badge>
</TableCell>
<TableCell>
<span class="text-sm">{{ formatInsConfigDisplay(ins) }}</span>
</TableCell>
<TableCell class="text-center">
<div class="flex items-center justify-center gap-2">
<Switch
:model-value="ins.enable === 1"
@update:model-value="() => handleToggleEnable(ins.id, ins.enable)"
/>
<Button
size="sm"
variant="outline"
class="text-red-500 border-red-300 hover:bg-red-50 hover:border-red-400 hover:text-red-600 hover:shadow-md transition-all duration-200"
@click="handleDeleteIns(ins.id)"
>
删除
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="!insTableData || insTableData.length === 0">
<TableCell :colspan="4" class="h-24">
<EmptyTableState title="暂无实例" description="还没有配置任何实例请先添加" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
/**
* API 代码查看器公共逻辑 Composable
*/
export function useApiCodeViewer() {
// 当前选中的标签
const activeTab = ref('curl')
// 代码语言选项
const codeLanguages = [
{ value: 'curl', label: 'cURL', icon: '🌐' },
{ value: 'javascript', label: 'JS', icon: '🟨' },
{ value: 'python', label: 'Python', icon: '🐍' },
{ value: 'php', label: 'PHP', icon: '🐘' },
{ value: 'golang', label: 'Go', icon: '🐹' },
{ value: 'java', label: 'Java', icon: '☕' },
{ value: 'rust', label: 'Rust', icon: '🦀' }
]
// 复制代码到剪贴板
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success('复制成功')
} catch (err) {
toast.error('复制失败')
}
}
return {
activeTab,
codeLanguages,
copyToClipboard
}
}
+83
View File
@@ -0,0 +1,83 @@
import { ref, computed, watch } from 'vue'
import { request } from '@/api/api'
/**
* 实例数据管理 Composable
* 用于任务和模板的实例数据加载和处理
*/
export function useInstanceData(
type: 'task' | 'template',
dataRef: any,
openRef: any
) {
// 实例数据
const instances = ref<any[]>([])
// 检查是否有支持动态接收者的实例
const hasDynamicRecipientInstance = computed(() => {
if (instances.value && Array.isArray(instances.value)) {
return instances.value.some((ins: any) => {
try {
const config = typeof ins.config === 'string' ? JSON.parse(ins.config) : ins.config
return config?.allowMultiRecip === true
} catch {
return false
}
})
}
return false
})
// 获取已启用的实例渠道名称列表
const enabledChannelNames = computed(() => {
if (instances.value && Array.isArray(instances.value)) {
return instances.value
.filter((ins: any) => ins.enable === 1)
.map((ins: any) => ins.way_name)
}
return []
})
// 加载实例数据
const loadInstances = async () => {
const id = dataRef.value?.id
if (!id) return
try {
let response
if (type === 'template') {
response = await request.get('/templates/ins/get', {
params: { id }
})
instances.value = response.data.data.ins_list || []
} else {
response = await request.get('/sendtasks/ins/gettask', {
params: { id }
})
instances.value = response.data.data.ins_data || []
}
} catch (err) {
console.error(`加载${type === 'template' ? '模板' : '任务'}实例失败:`, err)
instances.value = []
}
}
// 监听弹窗打开状态
watch(
() => openRef.value,
async (newVal: boolean) => {
if (newVal) {
await loadInstances()
} else {
instances.value = []
}
}
)
return {
instances,
hasDynamicRecipientInstance,
enabledChannelNames,
loadInstances
}
}
+37 -3
View File
@@ -20,6 +20,13 @@ const CONSTANT = {
{
type: 'Email',
label: '邮箱',
// 动态接收者配置
dynamicRecipient: {
support: true, // 是否支持动态接收者
field: 'to_account', // 接收者字段名
label: '收件邮箱', // 接收者字段标签
desc: '邮箱地址', // 接收者字段描述
},
inputs: [
{ subLabel: 'smtp服务地址', value: '', col: 'server', desc: "smtp@xyz.com" },
{ subLabel: 'smtp服务端口', value: '', col: 'port', desc: "port" },
@@ -32,8 +39,7 @@ const CONSTANT = {
{ subLabel: 'html', content: 'html' },
],
taskInsInputs: [
{ value: '', col: 'to_account', desc: "目的邮箱账号(发给谁)" },
// { value: '', col: 'title', desc: "邮箱标题" },
{ value: '', col: 'to_account', desc: '收件邮箱', label: '收件邮箱' },
],
},
{
@@ -90,6 +96,13 @@ const CONSTANT = {
{
type: 'WeChatOFAccount',
label: '微信测试公众号模板',
// 动态接收者配置
dynamicRecipient: {
support: true, // 是否支持动态接收者
field: 'to_account', // 接收者字段名
label: '接收者OpenId', // 接收者字段标签
desc: 'OpenId', // 接收者字段描述
},
inputs: [
{ subLabel: 'appID', value: '', col: 'appID', desc: "公众号appid" },
{ subLabel: 'appsecret', value: '', col: 'appsecret', desc: "公众号appsecret" },
@@ -103,7 +116,7 @@ const CONSTANT = {
{ subLabel: 'text', content: 'text' },
],
taskInsInputs: [
{ value: '', col: 'to_account', desc: "要发送的OpenId(登录微信公众号后台查看)" },
{ value: '', col: 'to_account', desc: '接收者OpenId', label: '接收者OpenId' },
],
},
{
@@ -121,6 +134,27 @@ const CONSTANT = {
taskInsInputs: [
],
},
// 暂时屏蔽阿里云短信入口
// {
// type: 'AliyunSMS',
// label: '阿里云短信',
// inputs: [
// { subLabel: 'AccessKeyId', value: '', col: 'access_key_id', desc: "阿里云AccessKeyId" },
// { subLabel: 'AccessKeySecret', value: '', col: 'access_key_secret', desc: "阿里云AccessKeySecret" },
// { subLabel: '短信签名', value: '', col: 'sign_name', desc: "短信签名名称" },
// { subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" },
// ],
// tips: {
// text: "阿里云短信说明", desc: "使用阿里云短信服务发送短信,需要在阿里云控制台申请短信签名和模板。<br />AccessKey请在阿里云控制台获取。"
// },
// taskInsRadios: [
// { subLabel: 'text', content: 'text' },
// ],
// taskInsInputs: [
// { value: '', col: 'phone_number', desc: "手机号码(接收短信的手机号)" },
// { value: '', col: 'template_code', desc: "短信模板CODE(在阿里云短信控制台获取)" },
// ],
// },
],
API_VIEW_DATA: [
{ label: "curl", class: "language-shell line-numbers", code: "", func: ApiStrGenerate.getCurlString },
+23 -15
View File
@@ -179,6 +179,8 @@ class ApiStrGenerate {
if (options.at_mobiles) data.at_mobiles = ['13800138000', '13900139000'];
if (options.at_user_ids) data.at_user_ids = ['zhangsan', 'lisi'];
if (options.at_all) data.at_all = true;
// 动态接收者字段(用于邮箱、微信公众号等支持动态接收者的渠道)
if (options.recipients) data.recipients = ['user1@example.com', 'user2@example.com'];
return JSON.stringify(data, null, 4);
}
@@ -218,7 +220,7 @@ class ApiStrGenerate {
// ==================== 模板 API (V2) ====================
class TemplateApiStrGenerate {
static getTemplateDataString(template_id, placeholders_json) {
static getTemplateDataString(template_id, placeholders_json, options = {}) {
// 解析占位符配置
let placeholders = {};
try {
@@ -241,6 +243,12 @@ class TemplateApiStrGenerate {
title: 'message title',
placeholders: placeholders
};
// 添加动态接收者字段(如果需要)
if (options.recipients) {
data.recipients = ['user1@example.com', 'user2@example.com'];
}
return JSON.stringify(data, null, 4);
}
@@ -248,32 +256,32 @@ class TemplateApiStrGenerate {
return `${gethttpOrigin()}/api/v2/message/send`;
}
static getCurlString(template_id, placeholders_json) {
return CodeTemplates.getCurl(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
static getCurlString(template_id, placeholders_json, options = {}) {
return CodeTemplates.getCurl(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json, options));
}
static getGolangString(template_id, placeholders_json) {
return CodeTemplates.getGolang(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
static getGolangString(template_id, placeholders_json, options = {}) {
return CodeTemplates.getGolang(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json, options));
}
static getPythonString(template_id, placeholders_json) {
return CodeTemplates.getPython(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
static getPythonString(template_id, placeholders_json, options = {}) {
return CodeTemplates.getPython(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json, options));
}
static getJavaString(template_id, placeholders_json) {
return CodeTemplates.getJava(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
static getJavaString(template_id, placeholders_json, options = {}) {
return CodeTemplates.getJava(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json, options));
}
static getRustString(template_id, placeholders_json) {
return CodeTemplates.getRust(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
static getRustString(template_id, placeholders_json, options = {}) {
return CodeTemplates.getRust(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json, options));
}
static getPHPString(template_id, placeholders_json) {
return CodeTemplates.getPHP(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
static getPHPString(template_id, placeholders_json, options = {}) {
return CodeTemplates.getPHP(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json, options));
}
static getNodeString(template_id, placeholders_json) {
return CodeTemplates.getNode(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
static getNodeString(template_id, placeholders_json, options = {}) {
return CodeTemplates.getNode(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json, options));
}
}