feat: add message template

This commit is contained in:
engigu
2025-12-06 00:28:18 +08:00
parent ff194daef2
commit a511512793
21 changed files with 2608 additions and 155 deletions
+3 -2
View File
@@ -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>支持 TextHTMLMarkdown 三种格式根据实例配置精确发送对应类型</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>
+32 -8
View File
@@ -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>
<!-- 代码示例 -->