feat: add message template
This commit is contained in:
@@ -252,6 +252,7 @@ const tabRoutes: TabRoute[] = [
|
||||
{ name: '发信日志', path: '/sendlogs' },
|
||||
{ name: '托管消息', path: '/hostedmessage' },
|
||||
{ name: '定时消息', path: '/cronmessages' },
|
||||
{ name: '模板任务', path: '/templates' },
|
||||
{ name: '发信任务', path: '/sendtasks' },
|
||||
{ name: '发信渠道', path: '/sendways' },
|
||||
{ name: '设置偏好', path: '/settings' }
|
||||
@@ -326,9 +327,9 @@ const siteTitle = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- 桌面端导航 -->
|
||||
<div class="hidden md:flex space-x-6 lg:space-x-8">
|
||||
<div class="hidden md:flex space-x-2 lg:space-x-3">
|
||||
<button v-for="tab in tabRoutes" :key="tab.name" @click="handleTabClick(tab)" :class="[
|
||||
'relative py-2 px-3 text-sm font-medium transition-all duration-200 rounded-md whitespace-nowrap',
|
||||
'relative py-1.5 px-2 text-sm font-medium transition-all duration-200 rounded-md whitespace-nowrap',
|
||||
activeTab === tab.name
|
||||
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-400/10'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:text-blue-400 dark:hover:bg-white/5'
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||
import Pagination from '@/components/ui/Pagination.vue'
|
||||
import TemplateApiViewer from './TemplateApiViewer.vue'
|
||||
import TemplateInstanceConfig from './TemplateInstanceConfig.vue'
|
||||
import TemplateEditor from './TemplateEditor.vue'
|
||||
import { request } from '@/api/api'
|
||||
import { getPageSize } from '@/util/pageUtils'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface MessageTemplate {
|
||||
id: string // 模板ID是字符串类型(UUID)
|
||||
name: string
|
||||
description: string
|
||||
text_template: string
|
||||
html_template: string
|
||||
markdown_template: string
|
||||
placeholders: string
|
||||
at_mobiles?: string
|
||||
at_user_ids?: string
|
||||
is_at_all?: boolean
|
||||
status: string
|
||||
created_on: string
|
||||
modified_on: string
|
||||
}
|
||||
|
||||
interface Placeholder {
|
||||
key: string
|
||||
label: string
|
||||
default: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
let state = reactive({
|
||||
tableData: [] as MessageTemplate[],
|
||||
total: 0,
|
||||
currPage: 1,
|
||||
pageSize: getPageSize() as number,
|
||||
search: '',
|
||||
status: 'all'
|
||||
})
|
||||
|
||||
const isPreviewOpen = ref(false)
|
||||
const currentTemplate = ref<MessageTemplate | null>(null)
|
||||
|
||||
// API代码查看器状态
|
||||
const isApiViewerOpen = ref(false)
|
||||
const selectedTemplateForApi = ref<MessageTemplate | null>(null)
|
||||
|
||||
// 配置实例状态
|
||||
const isInstanceConfigOpen = ref(false)
|
||||
const selectedTemplateForInstance = ref<MessageTemplate | null>(null)
|
||||
|
||||
// 模板编辑器状态
|
||||
const isEditorOpen = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const selectedTemplateForEdit = ref<MessageTemplate | null>(null)
|
||||
|
||||
const previewData = reactive({
|
||||
text: '',
|
||||
html: '',
|
||||
markdown: '',
|
||||
params: {} as Record<string, string>
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||
|
||||
const queryListData = async (page: number, size: number, text = '', status = '') => {
|
||||
const params: any = { page, size, text, status }
|
||||
const rsp = await request.get('/templates/list', { params })
|
||||
state.tableData = rsp.data.data.lists || []
|
||||
state.total = rsp.data.data.total || 0
|
||||
}
|
||||
|
||||
const changePage = async (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
state.currPage = page
|
||||
const statusParam = state.status === 'all' ? '' : state.status
|
||||
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||
}
|
||||
}
|
||||
|
||||
const filterFunc = async () => {
|
||||
state.currPage = 1
|
||||
const statusParam = state.status === 'all' ? '' : state.status
|
||||
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
isEditing.value = false
|
||||
selectedTemplateForEdit.value = null
|
||||
isEditorOpen.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (template: MessageTemplate) => {
|
||||
isEditing.value = true
|
||||
selectedTemplateForEdit.value = template
|
||||
isEditorOpen.value = true
|
||||
}
|
||||
|
||||
const handleEditorSaved = async () => {
|
||||
// 刷新列表
|
||||
const statusParam = state.status === 'all' ? '' : state.status
|
||||
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||
}
|
||||
|
||||
const deleteTemplate = async (id: string) => {
|
||||
const rsp = await request.post('/templates/delete', { id })
|
||||
if (rsp.status === 200 && rsp.data.code === 200) {
|
||||
toast.success(rsp.data.msg)
|
||||
// 刷新列表,处理status参数
|
||||
const statusParam = state.status === 'all' ? '' : state.status
|
||||
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开API查看器
|
||||
const handleViewApi = (template: MessageTemplate) => {
|
||||
selectedTemplateForApi.value = template
|
||||
isApiViewerOpen.value = true
|
||||
}
|
||||
|
||||
// 打开配置实例
|
||||
const handleConfigInstance = (template: MessageTemplate) => {
|
||||
selectedTemplateForInstance.value = template
|
||||
isInstanceConfigOpen.value = true
|
||||
}
|
||||
|
||||
// 查看日志
|
||||
const handleViewLogs = (template: MessageTemplate) => {
|
||||
// 跳转到发信日志页面,携带 taskid 参数(传递模板 id)
|
||||
router.push(`/sendlogs?taskid=${template.id}`)
|
||||
}
|
||||
|
||||
const openPreview = async (template: MessageTemplate) => {
|
||||
currentTemplate.value = template
|
||||
|
||||
// 解析占位符
|
||||
let placeholders: Placeholder[] = []
|
||||
try {
|
||||
placeholders = JSON.parse(template.placeholders || '[]')
|
||||
} catch {}
|
||||
|
||||
// 初始化预览参数
|
||||
previewData.params = {}
|
||||
placeholders.forEach(p => {
|
||||
previewData.params[p.key] = p.default || ''
|
||||
})
|
||||
|
||||
await refreshPreview()
|
||||
isPreviewOpen.value = true
|
||||
}
|
||||
|
||||
const refreshPreview = async () => {
|
||||
if (!currentTemplate.value) return
|
||||
|
||||
try {
|
||||
const rsp = await request.post('/templates/preview', {
|
||||
id: currentTemplate.value.id,
|
||||
params: previewData.params
|
||||
})
|
||||
previewData.text = rsp.data.data.text || ''
|
||||
previewData.html = rsp.data.data.html || ''
|
||||
previewData.markdown = rsp.data.data.markdown || ''
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || '预览失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await queryListData(1, state.pageSize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-2 w-full max-w-6xl mx-auto space-y-2">
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="flex flex-row sm:flex-row sm:items-center gap-2 -mx-2 px-2 sm:mx-0 sm:px-0">
|
||||
<div class="flex-[3] sm:flex-initial min-w-0">
|
||||
<Input
|
||||
v-model="state.search"
|
||||
placeholder="搜索模板名称或描述..."
|
||||
class="w-full sm:w-64"
|
||||
@keyup.enter="filterFunc"
|
||||
@blur="filterFunc"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-[2] sm:flex-initial min-w-0">
|
||||
<Select v-model="state.status" class="w-full" @update:model-value="filterFunc">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="选择状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="enabled">启用</SelectItem>
|
||||
<SelectItem value="disabled">禁用</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<Button @click="openAddDialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-24">ID</TableHead>
|
||||
<TableHead>模板名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>支持格式</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead class="whitespace-nowrap w-[160px]">创建时间</TableHead>
|
||||
<TableHead class="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<!-- 空数据展示 -->
|
||||
<TableRow v-if="state.tableData.length === 0">
|
||||
<TableCell colspan="7" class="text-center py-12">
|
||||
<EmptyTableState
|
||||
title="暂无消息模板"
|
||||
description="还没有创建任何消息模板,点击右上角按钮创建新模板"
|
||||
>
|
||||
<template #icon>
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
</EmptyTableState>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<!-- 数据行 -->
|
||||
<TableRow v-for="item in state.tableData" :key="item.id">
|
||||
<TableCell>{{ item.id }}</TableCell>
|
||||
<TableCell class="font-medium">{{ item.name }}</TableCell>
|
||||
<TableCell>
|
||||
<div class="max-w-xs truncate">{{ item.description || '-' }}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
<Badge v-if="item.text_template" variant="secondary">Text</Badge>
|
||||
<Badge v-if="item.html_template" variant="secondary">HTML</Badge>
|
||||
<Badge v-if="item.markdown_template" variant="secondary">Markdown</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="item.status === 'enabled' ? 'default' : 'secondary'">
|
||||
{{ item.status === 'enabled' ? '启用' : '禁用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap w-[160px]">{{ item.created_on }}</TableCell>
|
||||
<TableCell class="text-center space-x-2">
|
||||
<Button size="sm" variant="outline" @click="handleViewLogs(item)">日志</Button>
|
||||
<Button size="sm" variant="outline" @click="handleViewApi(item)">接口</Button>
|
||||
<Button size="sm" variant="outline" @click="openPreview(item)">预览</Button>
|
||||
<Button size="sm" variant="outline" @click="openEditDialog(item)">编辑</Button>
|
||||
<Button size="sm" variant="outline" @click="handleConfigInstance(item)">实例</Button>
|
||||
|
||||
|
||||
<Button size="sm" variant="destructive" @click="deleteTemplate(item.id)">删除</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="state.total"
|
||||
:current-page="state.currPage"
|
||||
:page-size="state.pageSize"
|
||||
@page-change="changePage"
|
||||
/>
|
||||
|
||||
<!-- 模板编辑器 -->
|
||||
<TemplateEditor
|
||||
:open="isEditorOpen"
|
||||
:is-editing="isEditing"
|
||||
:template-data="selectedTemplateForEdit"
|
||||
@update:open="isEditorOpen = $event"
|
||||
@saved="handleEditorSaved"
|
||||
/>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<Dialog v-model:open="isPreviewOpen">
|
||||
<DialogContent class="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>模板预览 - {{ currentTemplate?.name }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-4">
|
||||
<!-- 参数输入 -->
|
||||
<div v-if="currentTemplate" class="space-y-2">
|
||||
<Label>填写占位符参数</Label>
|
||||
<div
|
||||
v-for="(_, key) in previewData.params"
|
||||
:key="key"
|
||||
class="flex gap-2 items-center"
|
||||
>
|
||||
<Label class="w-32">{{ key }}</Label>
|
||||
<Input
|
||||
v-model="previewData.params[key]"
|
||||
:placeholder="`请输入 ${key}`"
|
||||
@input="refreshPreview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览结果 -->
|
||||
<Tabs default-value="text" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="text">Text</TabsTrigger>
|
||||
<TabsTrigger value="html">HTML</TabsTrigger>
|
||||
<TabsTrigger value="markdown">Markdown</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="text">
|
||||
<div class="p-4 border rounded-md bg-muted/50">
|
||||
<pre class="whitespace-pre-wrap">{{ previewData.text || '无Text模板' }}</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="html">
|
||||
<div class="space-y-2">
|
||||
<div class="p-4 border rounded-md bg-muted/50">
|
||||
<div v-html="previewData.html || '无HTML模板'"></div>
|
||||
</div>
|
||||
<details class="text-xs">
|
||||
<summary class="cursor-pointer">查看HTML源码</summary>
|
||||
<pre class="mt-2 p-2 bg-muted rounded">{{ previewData.html }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="markdown">
|
||||
<div class="p-4 border rounded-md bg-muted/50">
|
||||
<pre class="whitespace-pre-wrap">{{ previewData.markdown || '无Markdown模板' }}</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button @click="isPreviewOpen = false">关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- API代码查看器 -->
|
||||
<TemplateApiViewer
|
||||
:open="isApiViewerOpen"
|
||||
:template-data="selectedTemplateForApi || undefined"
|
||||
@update:open="isApiViewerOpen = $event"
|
||||
/>
|
||||
|
||||
<!-- 配置实例 -->
|
||||
<TemplateInstanceConfig
|
||||
:open="isInstanceConfigOpen"
|
||||
:template-data="selectedTemplateForInstance"
|
||||
@update:open="isInstanceConfigOpen = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { ref, defineComponent } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
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'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TemplateApiViewer',
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Badge
|
||||
},
|
||||
props: {
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
templateData: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:open'],
|
||||
setup(props, { emit }) {
|
||||
// 处理关闭事件
|
||||
const handleUpdateOpen = (value: boolean) => {
|
||||
emit('update:open', value)
|
||||
}
|
||||
|
||||
// 当前选中的标签
|
||||
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: '🦀' }
|
||||
]
|
||||
|
||||
// 生成API代码示例
|
||||
const generateApiCode = (language: string) => {
|
||||
const templateId = props.templateData?.id || 'TEMPLATE_ID'
|
||||
const placeholders = props.templateData?.placeholders || '[]'
|
||||
|
||||
switch (language) {
|
||||
case 'curl':
|
||||
return TemplateApiStrGenerate.getCurlString(templateId, placeholders)
|
||||
case 'javascript':
|
||||
return TemplateApiStrGenerate.getNodeString(templateId, placeholders)
|
||||
case 'python':
|
||||
return TemplateApiStrGenerate.getPythonString(templateId, placeholders)
|
||||
case 'php':
|
||||
return TemplateApiStrGenerate.getPHPString(templateId, placeholders)
|
||||
case 'golang':
|
||||
return TemplateApiStrGenerate.getGolangString(templateId, placeholders)
|
||||
case 'java':
|
||||
return TemplateApiStrGenerate.getJavaString(templateId, placeholders)
|
||||
case 'rust':
|
||||
return TemplateApiStrGenerate.getRustString(templateId, placeholders)
|
||||
default:
|
||||
return '// 请选择一种编程语言查看示例代码'
|
||||
}
|
||||
}
|
||||
|
||||
// 复制代码到剪贴板
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success('复制成功')
|
||||
} catch (err) {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleUpdateOpen,
|
||||
activeTab,
|
||||
codeLanguages,
|
||||
generateApiCode,
|
||||
copyToClipboard
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleUpdateOpen">
|
||||
<DialogContent class="w-[800px] sm:w-[900px] lg:w-[1000px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<span>模板API接口</span>
|
||||
<Badge v-if="templateData" variant="outline">{{ templateData.name }}</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- API 信息概览 -->
|
||||
<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/v2/message/send</code>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">使用模板发送消息(V2接口)</p>
|
||||
<div class="mt-3 space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p><strong>模板ID:</strong> <code class="bg-gray-100 dark:bg-slate-800 px-1 py-0.5 rounded">{{ templateData?.id }}</code></p>
|
||||
<p><strong>必填参数:</strong> token (加密token), title (消息标题), placeholders (占位符键值对)</p>
|
||||
<p><strong>可选参数:</strong> 根据模板配置的@提醒字段自动应用</p>
|
||||
<p class="text-amber-600 dark:text-amber-400"><strong>⚠️ 注意:</strong> V2接口使用加密token,不支持明文ID</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码示例 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold">代码示例</h3>
|
||||
|
||||
<Tabs v-model="activeTab" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-7 gap-1">
|
||||
<TabsTrigger v-for="lang in codeLanguages" :key="lang.value" :value="lang.value"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs">
|
||||
<span>{{ lang.icon }}</span>
|
||||
<span class="hidden sm:inline">{{ lang.label }}</span>
|
||||
<span class="sm:hidden">{{ lang.label.slice(0, 3) }}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent v-for="lang in codeLanguages" :key="lang.value" :value="lang.value" class="mt-4">
|
||||
<div class="relative">
|
||||
<Button size="sm" variant="outline" class="absolute top-2 right-2 z-10"
|
||||
@click="copyToClipboard(generateApiCode(lang.value))">
|
||||
复制代码
|
||||
</Button>
|
||||
<pre
|
||||
class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-xs leading-relaxed max-w-full whitespace-pre-wrap break-words"><code class="text-xs font-mono">{{ generateApiCode(lang.value) }}</code></pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- 说明 -->
|
||||
<div class="border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950 p-3 rounded text-sm space-y-1">
|
||||
<p class="font-semibold text-blue-900 dark:text-blue-200">💡 使用说明</p>
|
||||
<ul class="text-blue-800 dark:text-blue-300 space-y-1 ml-4 list-disc">
|
||||
<li><strong>token 参数:</strong>需要使用加密后的 token,不能直接使用明文模板ID(安全考虑)</li>
|
||||
<li><strong>placeholders 参数:</strong>用于替换模板中的占位符,格式为 <code class="bg-blue-100 dark:bg-blue-900 px-1 rounded">{"key": "value"}</code></li>
|
||||
<li>如果模板配置了@提醒,会自动应用到发送的消息中</li>
|
||||
<li>支持 Text、HTML、Markdown 三种格式,根据实例配置精确发送对应类型</li>
|
||||
<li>系统会自动遍历所有启用的实例进行发送</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 代码块样式优化 */
|
||||
pre {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { request } from '@/api/api'
|
||||
|
||||
interface Placeholder {
|
||||
key: string
|
||||
label: string
|
||||
default: string
|
||||
}
|
||||
|
||||
interface TemplateData {
|
||||
id?: string // 模板ID是字符串类型(UUID)
|
||||
name: string
|
||||
description: string
|
||||
text_template: string
|
||||
html_template: string
|
||||
markdown_template: string
|
||||
placeholders: string
|
||||
at_mobiles?: string
|
||||
at_user_ids?: string
|
||||
is_at_all?: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
// 组件props
|
||||
interface Props {
|
||||
open?: boolean
|
||||
isEditing?: boolean
|
||||
templateData?: TemplateData | null
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
isEditing: false,
|
||||
templateData: null
|
||||
})
|
||||
|
||||
// 组件emits
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
// Textarea refs for inserting placeholders
|
||||
const textTemplateRef = ref<any>(null)
|
||||
const htmlTemplateRef = ref<any>(null)
|
||||
const markdownTemplateRef = ref<any>(null)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<TemplateData>({
|
||||
name: '',
|
||||
description: '',
|
||||
text_template: '',
|
||||
html_template: '',
|
||||
markdown_template: '',
|
||||
placeholders: '[]',
|
||||
at_mobiles: '',
|
||||
at_user_ids: '',
|
||||
is_at_all: false,
|
||||
status: 'enabled'
|
||||
})
|
||||
|
||||
// 使用独立的响应式数组来管理占位符,避免频繁的 JSON 序列化
|
||||
const placeholdersList = ref<Placeholder[]>([])
|
||||
|
||||
// 过滤出有效的占位符(key 不为空)
|
||||
const validPlaceholders = computed(() => {
|
||||
return placeholdersList.value.filter(ph => ph.key && ph.key.trim())
|
||||
})
|
||||
|
||||
// 监听占位符列表变化,同步到 formData(使用防抖)
|
||||
let placeholderDebounceTimer: number | null = null
|
||||
watch(placeholdersList, () => {
|
||||
if (placeholderDebounceTimer) {
|
||||
clearTimeout(placeholderDebounceTimer)
|
||||
}
|
||||
placeholderDebounceTimer = window.setTimeout(() => {
|
||||
formData.value.placeholders = JSON.stringify(placeholdersList.value)
|
||||
}, 300)
|
||||
}, { deep: true })
|
||||
|
||||
// 添加占位符
|
||||
const addPlaceholder = () => {
|
||||
placeholdersList.value.push({ key: '', label: '', default: '' })
|
||||
}
|
||||
|
||||
// 删除占位符
|
||||
const removePlaceholder = (index: number) => {
|
||||
placeholdersList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 插入占位符到模板
|
||||
const insertPlaceholder = async (type: 'text' | 'html' | 'markdown', key: string) => {
|
||||
const placeholder = `{{${key}}}`
|
||||
let targetRef: any = null
|
||||
|
||||
if (type === 'text') targetRef = textTemplateRef.value
|
||||
else if (type === 'html') targetRef = htmlTemplateRef.value
|
||||
else if (type === 'markdown') targetRef = markdownTemplateRef.value
|
||||
|
||||
if (!targetRef) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const textarea = targetRef.$el || targetRef
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const text = formData.value[`${type}_template`]
|
||||
|
||||
const before = text.substring(0, start)
|
||||
const after = text.substring(end)
|
||||
|
||||
formData.value[`${type}_template`] = before + placeholder + after
|
||||
|
||||
await nextTick()
|
||||
textarea.focus()
|
||||
const newPosition = start + placeholder.length
|
||||
textarea.setSelectionRange(newPosition, newPosition)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
text_template: '',
|
||||
html_template: '',
|
||||
markdown_template: '',
|
||||
placeholders: '[]',
|
||||
at_mobiles: '',
|
||||
at_user_ids: '',
|
||||
is_at_all: false,
|
||||
status: 'enabled'
|
||||
}
|
||||
placeholdersList.value = []
|
||||
}
|
||||
|
||||
// 加载模板数据
|
||||
const loadTemplateData = (template: TemplateData) => {
|
||||
formData.value = {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
text_template: template.text_template,
|
||||
html_template: template.html_template,
|
||||
markdown_template: template.markdown_template,
|
||||
placeholders: template.placeholders,
|
||||
at_mobiles: template.at_mobiles || '',
|
||||
at_user_ids: template.at_user_ids || '',
|
||||
is_at_all: template.is_at_all || false,
|
||||
status: template.status
|
||||
}
|
||||
|
||||
// 解析占位符
|
||||
try {
|
||||
placeholdersList.value = JSON.parse(template.placeholders || '[]')
|
||||
} catch {
|
||||
placeholdersList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 保存模板
|
||||
const saveTemplate = async () => {
|
||||
if (!formData.value.name.trim()) {
|
||||
toast.error('请输入模板名称')
|
||||
return
|
||||
}
|
||||
|
||||
// 同步占位符数据
|
||||
formData.value.placeholders = JSON.stringify(placeholdersList.value)
|
||||
|
||||
try {
|
||||
const url = props.isEditing ? '/templates/edit' : '/templates/add'
|
||||
await request.post(url, formData.value)
|
||||
toast.success(props.isEditing ? '更新模板成功' : '添加模板成功')
|
||||
emit('update:open', false)
|
||||
emit('saved')
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听对话框打开状态
|
||||
watch(() => props.open, (newVal) => {
|
||||
if (newVal) {
|
||||
if (props.isEditing && props.templateData) {
|
||||
loadTemplateData(props.templateData)
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="(value) => $emit('update:open', value)">
|
||||
<DialogContent class="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isEditing ? '编辑模板' : '新建模板' }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-4">
|
||||
<!-- 基本信息 -->
|
||||
<div class="space-y-2">
|
||||
<Label for="name">模板名称 *</Label>
|
||||
<Input id="name" v-model="formData.name" placeholder="请输入模板名称" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="description">描述</Label>
|
||||
<Textarea id="description" v-model="formData.description" placeholder="请输入模板描述" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>状态</Label>
|
||||
<Select v-model="formData.status">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="全部" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="enabled">启用</SelectItem>
|
||||
<SelectItem value="disabled">禁用</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 占位符配置 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<Label>占位符配置</Label>
|
||||
<Button size="sm" variant="outline" @click="addPlaceholder">添加占位符</Button>
|
||||
</div>
|
||||
<div v-for="(placeholder, index) in placeholdersList" :key="index" class="flex gap-2 items-center">
|
||||
<Input
|
||||
v-model="placeholder.key"
|
||||
placeholder="key (如: username)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Input
|
||||
v-model="placeholder.label"
|
||||
placeholder="标签 (如: 用户名)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Input
|
||||
v-model="placeholder.default"
|
||||
placeholder="默认值"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" @click="removePlaceholder(index)">删除</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
在模板中使用 <code v-text="'{{key}}'"></code> 来引用占位符,例如:Hello <code v-text="'{{username}}'"></code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- @提醒配置 -->
|
||||
<div class="space-y-2">
|
||||
<Label>@提醒配置</Label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_at_all"
|
||||
v-model="formData.is_at_all"
|
||||
class="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label for="is_at_all" class="cursor-pointer">@所有人</Label>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="at_mobiles">@手机号(多个用逗号分隔)</Label>
|
||||
<Input
|
||||
id="at_mobiles"
|
||||
v-model="formData.at_mobiles"
|
||||
placeholder="例如:13800138000,13900139000"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="at_user_ids">@用户ID(多个用逗号分隔)</Label>
|
||||
<Input
|
||||
id="at_user_ids"
|
||||
v-model="formData.at_user_ids"
|
||||
placeholder="例如:user001,user002"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
配置后,使用此模板发送消息时会自动@指定的用户(适用于支持@功能的渠道,如钉钉、企业微信等)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 模板内容 -->
|
||||
<Tabs default-value="text" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="text">Text</TabsTrigger>
|
||||
<TabsTrigger value="html">HTML</TabsTrigger>
|
||||
<TabsTrigger value="markdown">Markdown</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="text" class="space-y-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label>纯文本模板</Label>
|
||||
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||
<Button
|
||||
v-for="ph in validPlaceholders"
|
||||
:key="ph.key"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||
@click="insertPlaceholder('text', ph.key)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ph.key}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref="textTemplateRef"
|
||||
v-model="formData.text_template"
|
||||
placeholder="请输入纯文本模板内容,可使用 {{key}} 作为占位符"
|
||||
rows="10"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="html" class="space-y-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label>HTML模板</Label>
|
||||
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||
<Button
|
||||
v-for="ph in validPlaceholders"
|
||||
:key="ph.key"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||
@click="insertPlaceholder('html', ph.key)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ph.key}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref="htmlTemplateRef"
|
||||
v-model="formData.html_template"
|
||||
placeholder="请输入HTML模板内容,可使用 {{key}} 作为占位符"
|
||||
rows="10"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="markdown" class="space-y-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label>Markdown模板</Label>
|
||||
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||
<Button
|
||||
v-for="ph in validPlaceholders"
|
||||
:key="ph.key"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||
@click="insertPlaceholder('markdown', ph.key)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ph.key}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref="markdownTemplateRef"
|
||||
v-model="formData.markdown_template"
|
||||
placeholder="请输入Markdown模板内容,可使用 {{key}} 作为占位符"
|
||||
rows="10"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="$emit('update:open', false)">取消</Button>
|
||||
<Button @click="saveTemplate">保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,352 @@
|
||||
<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'
|
||||
|
||||
// 组件props
|
||||
interface Props {
|
||||
open?: boolean
|
||||
templateData?: any // 模板数据
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
templateData: null
|
||||
})
|
||||
|
||||
// 组件emits
|
||||
const emit = 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 () => {
|
||||
// 组建表单数据
|
||||
let postData = {
|
||||
"id": generateBizUniqueID('I'),
|
||||
"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>
|
||||
<Dialog :open="open" @update:open="(value) => $emit('update:open', value)">
|
||||
<DialogContent class="w-[500px] max-w-[90vw] max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader class="flex-shrink-0">
|
||||
<DialogTitle>配置发送实例</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="px-4 pb-4 flex-1 overflow-y-auto">
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- 模板信息 -->
|
||||
<div class="mb-6 p-4 bg-muted rounded-lg">
|
||||
<div class="text-sm text-muted-foreground">模板名称</div>
|
||||
<div class="text-lg font-medium">{{ templateData?.name }}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">ID: {{ templateData?.id }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加实例表单 -->
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
|
||||
<!-- 渠道配置表单 -->
|
||||
<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="flex justify-end gap-2 border-b pb-4 mt-2">
|
||||
<Button @click="handleAddSubmit">添加实例</Button>
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -19,7 +19,8 @@ import { getPageSize } from '@/util/pageUtils';
|
||||
interface LogItem {
|
||||
id: number
|
||||
task_id: number
|
||||
task_name: string
|
||||
type: string // 类型:task 或 template
|
||||
name: string // 任务或模板名称
|
||||
log: string
|
||||
created_on: string
|
||||
caller_ip?: string
|
||||
@@ -34,7 +35,7 @@ let state = reactive({
|
||||
currPage: 1,
|
||||
pageSize: getPageSize(),
|
||||
search: '',
|
||||
optionValue: '',
|
||||
optionValue: '', // 保存 taskid,用于过滤
|
||||
})
|
||||
|
||||
// 状态过滤
|
||||
@@ -50,10 +51,25 @@ const getStatusText = (status: number) => {
|
||||
return status === 1 ? '成功' : '失败'
|
||||
}
|
||||
|
||||
// 获取类型文本
|
||||
const getTypeText = (type: string) => {
|
||||
return type === 'template' ? '模板' : '任务'
|
||||
}
|
||||
|
||||
// 获取类型徽章样式
|
||||
const getTypeBadgeVariant = (type: string) => {
|
||||
return type === 'template' ? 'secondary' : 'default'
|
||||
}
|
||||
|
||||
// 获取显示名称
|
||||
const getDisplayName = (task: LogItem) => {
|
||||
return task.name || '-'
|
||||
}
|
||||
|
||||
// 打开日志详情Sheet
|
||||
const openLogSheet = (task: LogItem) => {
|
||||
selectedLog.value = formatLogDisplayHtml(task);
|
||||
selectedTaskName.value = task.task_name
|
||||
selectedTaskName.value = getDisplayName(task)
|
||||
isSheetOpen.value = true
|
||||
}
|
||||
|
||||
@@ -120,6 +136,8 @@ const queryListData = async (page: number, size: number, name = '', taskid = '',
|
||||
// 解析URL参数并更新筛选状态
|
||||
const parseUrlParams = async () => {
|
||||
state.search = router.query.name?.toString() || '';
|
||||
// 保存 taskid 到 state,用于后续过滤
|
||||
state.optionValue = router.query.taskid?.toString() || '';
|
||||
|
||||
// 解析URL中的query参数,设置状态筛选
|
||||
const queryParam = router.query.query?.toString() || '';
|
||||
@@ -140,8 +158,8 @@ const parseUrlParams = async () => {
|
||||
await queryListData(
|
||||
1,
|
||||
state.pageSize,
|
||||
router.query.name?.toString() || '',
|
||||
router.query.taskid?.toString() || '',
|
||||
state.search,
|
||||
state.optionValue,
|
||||
queryParam
|
||||
);
|
||||
};
|
||||
@@ -185,7 +203,8 @@ onMounted(async () => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20">ID</TableHead>
|
||||
<TableHead>任务名称</TableHead>
|
||||
<TableHead class="w-24">类型</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>发信日志</TableHead>
|
||||
<TableHead class="whitespace-nowrap w-[160px]">发送时间</TableHead>
|
||||
<TableHead class="text-center">详情/状态</TableHead>
|
||||
@@ -195,7 +214,7 @@ onMounted(async () => {
|
||||
<TableBody>
|
||||
<!-- 空数据展示 -->
|
||||
<TableRow v-if="state.tableData.length === 0">
|
||||
<TableCell colspan="5" class="text-center py-12">
|
||||
<TableCell colspan="6" class="text-center py-12">
|
||||
<EmptyTableState
|
||||
title="暂无发信日志"
|
||||
description="还没有任何发信日志记录"
|
||||
@@ -213,7 +232,12 @@ onMounted(async () => {
|
||||
<TableRow v-for="task in state.tableData" :key="task.id">
|
||||
<TableCell>{{ task.id }}</TableCell>
|
||||
<TableCell>
|
||||
<ClickableTruncate :text="task.task_name" wrapper-class="max-w-[220px] sm:max-w-[360px]" preview-title="任务名称" />
|
||||
<Badge :variant="getTypeBadgeVariant(task.type || 'task')">
|
||||
{{ getTypeText(task.type || 'task') }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ClickableTruncate :text="getDisplayName(task)" wrapper-class="max-w-[220px] sm:max-w-[360px]" preview-title="名称" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ClickableTruncate :text="task.log" wrapper-class="max-w-[320px] sm:max-w-[480px]" preview-title="发信日志" />
|
||||
|
||||
@@ -175,7 +175,10 @@ export default defineComponent({
|
||||
<Badge variant="secondary" class="text-xs">新</Badge>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">💡 提示:@功能仅钉钉和企业微信支持</p>
|
||||
<div class="space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p>💡 提示:@功能仅钉钉和企业微信支持</p>
|
||||
<p>📋 发送顺序:实例配置的内容类型优先,若为空则按 <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">HTML → Markdown → Text</code> 顺序回退</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码示例 -->
|
||||
|
||||
Reference in New Issue
Block a user