feat: add send many account mode
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user