feat: reoede web to tailwindcss
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { request } from '@/api/api'
|
||||
import { generateBizUniqueID } from '@/util/uuid'
|
||||
import CronMessageForm from './CronMessageForm.vue'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'save', data: any): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'update:open', value: boolean): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
defineOptions({
|
||||
name: 'AddCronMessages'
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
cron_expression: '',
|
||||
title: '',
|
||||
content: '',
|
||||
task_id: '',
|
||||
url: ''
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
let postData = {
|
||||
"name": formData.name,
|
||||
"id": generateBizUniqueID("C"),
|
||||
"title": formData.title,
|
||||
"content": formData.content,
|
||||
"cron": formData.cron_expression,
|
||||
"url": formData.url,
|
||||
"task_id": formData.task_id
|
||||
}
|
||||
|
||||
const rsp = await request.post('/cronmessages/addone', postData)
|
||||
if (rsp.data.code === 200) {
|
||||
toast.success(rsp.data.msg)
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
} else {
|
||||
toast.success(rsp.data.msg)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CronMessageForm
|
||||
v-model="formData"
|
||||
mode="add"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AddCronMessages'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { request } from '@/api/api'
|
||||
|
||||
interface CronMessageFormData {
|
||||
id?: number
|
||||
name: string
|
||||
cron_expression: string
|
||||
title: string
|
||||
content: string
|
||||
task_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: CronMessageFormData
|
||||
mode: 'add' | 'edit'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: CronMessageFormData): void
|
||||
(e: 'submit'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
defineOptions({
|
||||
name: 'CronMessageForm'
|
||||
})
|
||||
|
||||
// 本地表单数据
|
||||
const localFormData = reactive<CronMessageFormData>({ ...props.modelValue })
|
||||
|
||||
// 监听外部数据变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
Object.assign(localFormData, newValue)
|
||||
}, { deep: true })
|
||||
|
||||
// 监听本地数据变化,同步到外部
|
||||
watch(localFormData, (newValue) => {
|
||||
emit('update:modelValue', { ...newValue })
|
||||
}, { deep: true })
|
||||
|
||||
// 可用的发信任务列表
|
||||
const availableTasks = ref<Array<{ id: number, name: string }>>([])
|
||||
|
||||
// 常用的 Cron 表达式模板
|
||||
const cronTemplates = [
|
||||
{ label: '每分钟', value: '* * * * *' },
|
||||
{ label: '每5分钟', value: '*/5 * * * *' },
|
||||
{ label: '每小时', value: '0 * * * *' },
|
||||
{ label: '每天凌晨2点', value: '0 2 * * *' },
|
||||
{ label: '每周一凌晨2点', value: '0 2 * * 1' },
|
||||
{ label: '每月1号凌晨2点', value: '0 2 1 * *' }
|
||||
]
|
||||
|
||||
// 加载可用的发信任务
|
||||
const loadAvailableTasks = async () => {
|
||||
try {
|
||||
const rsp = await request.get('/sendtasks/list', { params: { page: 1, size: 100 } })
|
||||
availableTasks.value = rsp.data.data.lists.map((task: any) => ({
|
||||
id: task.id,
|
||||
name: task.name
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('加载发信任务失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 应用 Cron 模板
|
||||
const applyCronTemplate = (template: string) => {
|
||||
localFormData.cron_expression = template
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = () => {
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
loadAvailableTasks()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="name" class="text-sm">定时消息名称</Label>
|
||||
<Input id="name" v-model="localFormData.name" placeholder="请输入定时消息名称" class="h-8" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="task_id" class="text-sm">关联发信任务</Label>
|
||||
<Select v-model="localFormData.task_id">
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="选择要关联的发信任务" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="task in availableTasks" :key="task.id" :value="String(task.id)">
|
||||
{{ task.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="cron_expression" class="text-sm">Cron表达式</Label>
|
||||
<Input id="cron_expression" v-model="localFormData.cron_expression" placeholder="请输入Cron表达式,如: 0 2 * * *" class="h-8" />
|
||||
<div class="text-xs text-gray-500">
|
||||
<p class="mb-1">常用模板:</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button v-for="template in cronTemplates" :key="template.value" size="sm" variant="outline"
|
||||
@click="applyCronTemplate(template.value)" class="h-6 px-2 text-xs">
|
||||
{{ template.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="title" class="text-sm">标题</Label>
|
||||
<Input id="title" v-model="localFormData.title" placeholder="请输入定时消息标题" class="h-8" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="content" class="text-sm">内容</Label>
|
||||
<Textarea id="content" v-model="localFormData.content" placeholder="请输入定时消息内容" rows="2" class="min-h-[60px] resize-none" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="url" class="text-sm">url(可选)</Label>
|
||||
<Input id="url" v-model="localFormData.url" placeholder="请输入定时消息描述" class="h-8" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-2 pt-2">
|
||||
<Button variant="outline" @click="handleCancel" size="sm" :disabled="loading">
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="handleSubmit" size="sm" :disabled="loading">
|
||||
{{ mode === 'add' ? '创建定时消息' : '更新定时消息' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CronMessageForm'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,257 @@
|
||||
<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 { Switch } from '@/components/ui/switch'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||
import Pagination from '@/components/ui/Pagination.vue'
|
||||
import AddCronMessages from './AddCronMessages.vue'
|
||||
import EditCronMessages from './EditCronMessages.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { request } from '@/api/api';
|
||||
// @ts-ignore
|
||||
import { getPageSize } from '@/util/pageUtils';
|
||||
|
||||
|
||||
interface CronMessageItem {
|
||||
id: string
|
||||
title: string
|
||||
name: string
|
||||
content: string
|
||||
cron: string
|
||||
cron_expression: string
|
||||
task_id: string
|
||||
task_name: string
|
||||
enable: number
|
||||
status: boolean
|
||||
created_on: string
|
||||
modified_on: string
|
||||
next_time?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
let state = reactive({
|
||||
tableData: [] as CronMessageItem[],
|
||||
total: 0,
|
||||
currPage: 1,
|
||||
pageSize: getPageSize(),
|
||||
search: '',
|
||||
optionValue: '',
|
||||
})
|
||||
|
||||
// 状态过滤
|
||||
const selectedStatus = ref('all')
|
||||
|
||||
|
||||
// 新增定时消息 Dialog 状态
|
||||
const isAddCronMessageDialogOpen = ref(false)
|
||||
|
||||
// 编辑定时消息 Dialog 状态
|
||||
const isEditCronMessageDialogOpen = ref(false)
|
||||
const editCronMessageData = ref<CronMessageItem | null>(null)
|
||||
|
||||
// 处理保存新定时消息
|
||||
const handleSaveCronMessage = (_data: any) => {
|
||||
// 保存成功后刷新列表
|
||||
queryListDataWithStatus()
|
||||
}
|
||||
|
||||
// 总页数
|
||||
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||
|
||||
// 打开编辑定时消息Dialog
|
||||
const openEditCronMessageDialog = (cronMessage: CronMessageItem) => {
|
||||
editCronMessageData.value = cronMessage
|
||||
isEditCronMessageDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 处理编辑定时消息保存
|
||||
const handleEditCronMessage = (_data: any) => {
|
||||
// 保存成功后刷新列表
|
||||
queryListDataWithStatus()
|
||||
}
|
||||
|
||||
// 处理查看日志
|
||||
const handleViewLogs = (cronMessage: CronMessageItem) => {
|
||||
// 跳转到定时消息日志页面,携带cronMessageId参数
|
||||
router.push(`/sendlogs?taskid=${cronMessage.task_id}`)
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const toggleStatus = async (cronMessage: CronMessageItem) => {
|
||||
const newStatus = !cronMessage.enable ? 1 : 0
|
||||
cronMessage.enable = newStatus
|
||||
const rsp = await request.post('/cronmessages/edit', cronMessage)
|
||||
if (rsp.data.code === 200) {
|
||||
cronMessage.enable = newStatus
|
||||
toast.success(rsp.data.msg)
|
||||
}
|
||||
}
|
||||
|
||||
const changePage = async (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
state.currPage = page
|
||||
await queryListDataWithStatus()
|
||||
}
|
||||
}
|
||||
|
||||
//触发过滤筛选
|
||||
const filterFunc = async () => {
|
||||
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
|
||||
}
|
||||
|
||||
// 查询数据(包含状态过滤)
|
||||
const queryListDataWithStatus = async () => {
|
||||
const statusParam = selectedStatus.value === 'all' ? '' : selectedStatus.value;
|
||||
await queryListData(state.currPage, state.pageSize, state.search, '', '', statusParam);
|
||||
}
|
||||
|
||||
const queryListData = async (page: number, size: number, name = '', taskType = '', query = '', status = '') => {
|
||||
let params: any = { page: page, size: size, name: name, type: taskType, query: query };
|
||||
if (status !== '') {
|
||||
params.status = status;
|
||||
}
|
||||
const rsp = await request.get('/cronmessages/list', { params: params });
|
||||
state.tableData = await rsp.data.data.lists;
|
||||
state.total = await rsp.data.data.total;
|
||||
}
|
||||
|
||||
// 删除定时消息
|
||||
const handleDelete = async (id: string) => {
|
||||
const rsp = await request.post('/cronmessages/delete', { id: id });
|
||||
if (rsp.status == 200 && await rsp.data.code == 200) {
|
||||
toast.success(rsp.data.msg);
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化查询
|
||||
state.search = route.query.name?.toString() || '';
|
||||
await queryListData(
|
||||
1,
|
||||
state.pageSize,
|
||||
route.query.name?.toString() || '',
|
||||
route.query.task_type?.toString() || ''
|
||||
);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||
<div class="flex-1 sm:flex-initial">
|
||||
<Input v-model="state.search" placeholder="搜索定时任务名称..." class="w-full sm:w-64" @keyup.enter="filterFunc"
|
||||
@blur="filterFunc" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<Dialog v-model:open="isAddCronMessageDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="default" class="w-full sm:w-auto bg-gray-800 hover:bg-gray-900">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
新增定时消息
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent class="w-[500px] max-w-[90vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增定时消息</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="px-4 pb-4">
|
||||
<AddCronMessages v-model:open="isAddCronMessageDialogOpen" @save="handleSaveCronMessage"
|
||||
@cancel="() => isAddCronMessageDialogOpen = false" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20">ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>内容</TableHead>
|
||||
<TableHead>Cron表达式</TableHead>
|
||||
<TableHead>关联任务</TableHead>
|
||||
<TableHead>下次执行时间</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead class="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<!-- 空数据展示 -->
|
||||
<TableRow v-if="state.tableData.length === 0">
|
||||
<TableCell colspan="8" class="text-center py-12">
|
||||
<EmptyTableState title="暂无定时消息" description="还没有配置任何定时消息,请先添加定时消息" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<!-- 数据行 -->
|
||||
<TableRow v-for="cronMessage in state.tableData" :key="cronMessage.id">
|
||||
<TableCell>{{ cronMessage.id }}</TableCell>
|
||||
<TableCell class="max-w-32 truncate" :title="cronMessage.title">{{ cronMessage.title }}</TableCell>
|
||||
<TableCell class="max-w-32 truncate" :title="cronMessage.content">{{ cronMessage.content }}</TableCell>
|
||||
<TableCell>
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm">{{ cronMessage.cron }}</code>
|
||||
</TableCell>
|
||||
<TableCell class="max-w-24 truncate" :title="cronMessage.task_id">{{ cronMessage.task_id }}</TableCell>
|
||||
<TableCell class="max-w-32 truncate" :title="cronMessage.next_time || '-'">{{ cronMessage.next_time || '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="max-w-32 truncate" :title="cronMessage.created_on">{{ cronMessage.created_on }}</TableCell>
|
||||
<!-- <TableCell class="text-center">
|
||||
<Badge :class="cronMessage.status === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'">
|
||||
{{ getStatusText(cronMessage.status) }}
|
||||
</Badge>
|
||||
</TableCell> -->
|
||||
<TableCell class="text-center space-x-2">
|
||||
<Button size="sm" variant="outline" @click="handleViewLogs(cronMessage)">日志</Button>
|
||||
<Button size="sm" variant="outline" @click="openEditCronMessageDialog(cronMessage)">编辑</Button>
|
||||
<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(cronMessage.id)">删除</Button>
|
||||
<Switch :model-value="cronMessage.enable === 1" @update:model-value="toggleStatus(cronMessage)" />
|
||||
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination :total="state.total" :current-page="state.currPage" :page-size="state.pageSize"
|
||||
@page-change="changePage" />
|
||||
|
||||
<!-- 编辑定时消息Dialog -->
|
||||
<Dialog v-model:open="isEditCronMessageDialogOpen">
|
||||
<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">
|
||||
<EditCronMessages v-model:open="isEditCronMessageDialogOpen" :cron-message="editCronMessageData"
|
||||
@save="handleEditCronMessage" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { request } from '@/api/api'
|
||||
import CronMessageForm from './CronMessageForm.vue'
|
||||
|
||||
interface CronMessageItem {
|
||||
id: string
|
||||
name: string
|
||||
title: string
|
||||
content: string
|
||||
cron: string
|
||||
url: string
|
||||
task_id: string
|
||||
enable: number
|
||||
status: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
cronMessage: CronMessageItem | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'save', data: any): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'update:open', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
defineOptions({
|
||||
name: 'EditCronMessages'
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
cron_expression: '',
|
||||
title: '',
|
||||
content: '',
|
||||
task_id: '',
|
||||
url: ''
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!props.cronMessage) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
let postData = {
|
||||
"name": formData.name,
|
||||
"id": props.cronMessage.id,
|
||||
"title": formData.title,
|
||||
"content": formData.content,
|
||||
"cron": formData.cron_expression,
|
||||
"url": formData.url,
|
||||
"task_id": formData.task_id,
|
||||
"enable": props.cronMessage.enable,
|
||||
}
|
||||
|
||||
const rsp = await request.post('/cronmessages/edit', postData)
|
||||
if (rsp.data.code === 200) {
|
||||
toast.success(rsp.data.msg)
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
} else {
|
||||
toast.success(rsp.data.msg)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
// 监听 cronMessage 变化,更新表单数据
|
||||
watch(
|
||||
() => props.cronMessage,
|
||||
(newCronMessage) => {
|
||||
if (newCronMessage) {
|
||||
formData.name = newCronMessage.name
|
||||
formData.cron_expression = newCronMessage.cron
|
||||
formData.title = newCronMessage.title
|
||||
formData.content = newCronMessage.content
|
||||
formData.task_id = newCronMessage.task_id
|
||||
formData.url = newCronMessage.url
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CronMessageForm
|
||||
v-model="formData"
|
||||
mode="edit"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EditCronMessages'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<Card class="w-full">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">
|
||||
{{ title }}
|
||||
</CardTitle>
|
||||
<component :is="icon" class="h-5 w-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{{ value }}</div>
|
||||
<p class="text-xs text-muted-foreground">{{ description }}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
value: string | number
|
||||
description?: string
|
||||
icon?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'CardNum'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DefineComponent } from 'vue';
|
||||
|
||||
declare const CardNum: DefineComponent<{
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
icon?: any;
|
||||
}>;
|
||||
|
||||
export default CardNum;
|
||||
@@ -0,0 +1,412 @@
|
||||
<script setup lang="ts">
|
||||
import StatCard from '@/components/pages/dashboard/CardNum.vue'
|
||||
import { DatabaseIcon, BarChartIcon, SendIcon, CheckCircleIcon, XCircleIcon } from 'lucide-vue-next'
|
||||
// import { LineChart } from "@/components/ui/chart-line"
|
||||
import { onMounted, reactive } from 'vue';
|
||||
import { request } from '@/api/api';
|
||||
// import VueApexCharts from 'vue3-apexcharts'
|
||||
import ApexCharts from 'apexcharts'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface SendData {
|
||||
day: string
|
||||
day_failed_num: number
|
||||
day_succ_num: number
|
||||
num: number
|
||||
succ_num: number
|
||||
}
|
||||
interface CateData {
|
||||
count_num: number
|
||||
way_name: string
|
||||
}
|
||||
|
||||
let state = reactive({
|
||||
data: {
|
||||
message_total_num: 0,
|
||||
hosted_message_total_num: 0,
|
||||
today_total_num: 0,
|
||||
today_succ_num: 0,
|
||||
today_failed_num: 0,
|
||||
way_cate_data: [] as CateData[],
|
||||
latest_send_data: [] as SendData[],
|
||||
}
|
||||
});
|
||||
|
||||
const getStatisticData = async () => {
|
||||
try {
|
||||
const rsp = await request.get('/statistic');
|
||||
if (rsp && rsp.data && rsp.data.code == 200) {
|
||||
state.data = rsp.data.data;
|
||||
// 数据加载完成后重新渲染图表
|
||||
setTimeout(() => {
|
||||
renderLineChart();
|
||||
renderPieChart();
|
||||
}, 100);
|
||||
} else {
|
||||
console.error('Failed to load statistics:', rsp?.data?.msg || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading statistics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const renderLineChart = () => {
|
||||
const options = {
|
||||
series: [
|
||||
{
|
||||
name: '发送总数',
|
||||
data: state.data.latest_send_data.length > 0
|
||||
? state.data.latest_send_data.map(item => item.num || 0)
|
||||
: []
|
||||
},
|
||||
{
|
||||
name: '发送成功数',
|
||||
data: state.data.latest_send_data.length > 0
|
||||
? state.data.latest_send_data.map(item => item.day_succ_num || 0)
|
||||
: []
|
||||
},
|
||||
{
|
||||
name: '发送失败数',
|
||||
data: state.data.latest_send_data.length > 0
|
||||
? state.data.latest_send_data.map(item => item.day_failed_num || 0)
|
||||
: []
|
||||
},
|
||||
],
|
||||
chart: {
|
||||
type: 'line',
|
||||
height: 350,
|
||||
toolbar: { show: false },
|
||||
background: 'transparent',
|
||||
animations: {
|
||||
enabled: true,
|
||||
easing: 'easeinout',
|
||||
speed: 800,
|
||||
animateGradually: {
|
||||
enabled: true,
|
||||
delay: 150
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: true,
|
||||
speed: 350
|
||||
}
|
||||
},
|
||||
dropShadow: {
|
||||
enabled: true,
|
||||
color: '#000',
|
||||
top: 18,
|
||||
left: 7,
|
||||
blur: 10,
|
||||
opacity: 0.2
|
||||
}
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 3,
|
||||
lineCap: 'round'
|
||||
},
|
||||
markers: {
|
||||
size: 6,
|
||||
colors: ['#3b82f6', '#10b981', '#ef4444'],
|
||||
strokeColors: '#fff',
|
||||
strokeWidth: 2,
|
||||
hover: {
|
||||
size: 8,
|
||||
sizeOffset: 3
|
||||
}
|
||||
},
|
||||
xaxis: {
|
||||
categories: state.data.latest_send_data.length > 0
|
||||
? state.data.latest_send_data.map(item => item.day)
|
||||
: [],
|
||||
axisBorder: {
|
||||
show: false
|
||||
},
|
||||
axisTicks: {
|
||||
show: false
|
||||
},
|
||||
labels: {
|
||||
style: {
|
||||
colors: '#64748b',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Inter, sans-serif'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: '#64748b',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Inter, sans-serif'
|
||||
},
|
||||
formatter: function (val: number) {
|
||||
return val + ' 条'
|
||||
}
|
||||
}
|
||||
},
|
||||
colors: ['#3b82f6', '#10b981', '#ef4444'], // 蓝色表示总数,绿色表示成功,红色表示失败
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0.5,
|
||||
gradientToColors: ['#60a5fa', '#34d399', '#f87171'],
|
||||
inverseColors: false,
|
||||
opacityFrom: 0.8,
|
||||
opacityTo: 0.1,
|
||||
stops: [0, 100]
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
borderColor: '#e2e8f0',
|
||||
strokeDashArray: 3,
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
padding: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
horizontalAlign: 'right',
|
||||
floating: true,
|
||||
offsetY: -25,
|
||||
offsetX: -5,
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
markers: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
radius: 4
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
shared: true,
|
||||
intersect: false,
|
||||
theme: 'light',
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Inter, sans-serif'
|
||||
},
|
||||
x: {
|
||||
show: true,
|
||||
format: 'MM/dd'
|
||||
},
|
||||
y: {
|
||||
formatter: function (val: number, { seriesIndex: _seriesIndex }: { seriesIndex: number }) {
|
||||
return val + ' 条'
|
||||
}
|
||||
},
|
||||
marker: {
|
||||
show: true
|
||||
},
|
||||
custom: function ({ series, seriesIndex: _seriesIndex, dataPointIndex, w }: { series: number[][], seriesIndex: number, dataPointIndex: number, w: any }) {
|
||||
const successCount = series[1][dataPointIndex];
|
||||
const failedCount = series[2][dataPointIndex];
|
||||
const total = successCount + failedCount;
|
||||
const successRate = total > 0 ? ((successCount / total) * 100).toFixed(1) : '0.0';
|
||||
|
||||
return `
|
||||
<div class="bg-white p-3 rounded-lg shadow-lg border">
|
||||
<div class="font-medium text-gray-900 mb-2">${w.globals.categoryLabels[dataPointIndex]}</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
<span class="text-sm text-gray-600">成功:</span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900">${successCount} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
<span class="text-sm text-gray-600">失败:</span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900">${failedCount} 条</span>
|
||||
</div>
|
||||
<div class="border-t pt-1 mt-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">成功率:</span>
|
||||
<span class="text-sm font-medium text-green-600">${successRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
responsive: [{
|
||||
breakpoint: 768,
|
||||
options: {
|
||||
chart: {
|
||||
height: 300
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
offsetY: 0
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
const chart = new ApexCharts(document.querySelector("#sales-chart"), options)
|
||||
console.log(1111);
|
||||
|
||||
chart.render();
|
||||
console.log(chart);
|
||||
|
||||
}
|
||||
|
||||
const renderPieChart = () => {
|
||||
const options = {
|
||||
series: state.data.way_cate_data.length > 0
|
||||
? state.data.way_cate_data.map(item => item.count_num)
|
||||
: [],
|
||||
chart: {
|
||||
type: 'pie',
|
||||
height: 350,
|
||||
toolbar: { show: false },
|
||||
background: 'transparent',
|
||||
animations: {
|
||||
enabled: true,
|
||||
easing: 'easeinout',
|
||||
speed: 800,
|
||||
animateGradually: {
|
||||
enabled: true,
|
||||
delay: 150
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: true,
|
||||
speed: 350
|
||||
}
|
||||
}
|
||||
},
|
||||
labels: state.data.way_cate_data.length > 0
|
||||
? state.data.way_cate_data.map(item => item.way_name)
|
||||
: [],
|
||||
colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'],
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
markers: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
radius: 4
|
||||
}
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '0%'
|
||||
},
|
||||
expandOnClick: true
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter: function (val: number) {
|
||||
return val.toFixed(1) + '%'
|
||||
},
|
||||
style: {
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
theme: 'light',
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Inter, sans-serif'
|
||||
},
|
||||
y: {
|
||||
formatter: function (val: number) {
|
||||
return val + ' 条'
|
||||
}
|
||||
}
|
||||
},
|
||||
responsive: [{
|
||||
breakpoint: 768,
|
||||
options: {
|
||||
chart: {
|
||||
height: 300
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
const pieChart = new ApexCharts(document.querySelector("#pie-chart"), options)
|
||||
pieChart.render();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getStatisticData();
|
||||
// renderLineChart();
|
||||
})
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[90%] mx-auto grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<StatCard title="推送记录留存数" :value="state.data.message_total_num" description="" :icon="DatabaseIcon" />
|
||||
<StatCard title="托管消息数" :value="state.data.hosted_message_total_num" description="" :icon="BarChartIcon" />
|
||||
<StatCard title="今日发送总数" :value="state.data.today_total_num" description="" :icon="SendIcon" />
|
||||
<StatCard title="今日成功数" :value="state.data.today_succ_num" description="" :icon="CheckCircleIcon" />
|
||||
<StatCard title="今日失败数" :value="state.data.today_failed_num" description="" :icon="XCircleIcon" />
|
||||
</div>
|
||||
|
||||
<!-- 折线图 -->
|
||||
<!-- <LineChart
|
||||
:data="data"
|
||||
index="year"
|
||||
:categories="['Export Growth Rate', 'Import Growth Rate']"
|
||||
:y-formatter="(tick, i) => {
|
||||
return typeof tick === 'number'
|
||||
? `$ ${new Intl.NumberFormat('us').format(tick).toString()}`
|
||||
: ''
|
||||
}"
|
||||
/> -->
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="w-[90%] mx-auto mt-8 grid grid-cols-1 lg:grid-cols-10 gap-6">
|
||||
<!-- 折线图 -->
|
||||
<Card class="w-full lg:col-span-7">
|
||||
<CardHeader>
|
||||
<CardTitle>消息发送趋势</CardTitle>
|
||||
<CardDescription>最近30天的发送情况统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div id="sales-chart" class="w-full h-[350px]"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 饼图 -->
|
||||
<Card class="w-full lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>发送渠道分布</CardTitle>
|
||||
<CardDescription>各发送渠道的使用情况统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div id="pie-chart" class="w-full h-[350px]"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<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 { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||
import Pagination from '@/components/ui/Pagination.vue'
|
||||
|
||||
import { useRoute } from 'vue-router';
|
||||
import { request } from '@/api/api';
|
||||
// @ts-ignore
|
||||
import { getPageSize } from '@/util/pageUtils';
|
||||
|
||||
|
||||
interface HostedMessageItem {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
url?: string
|
||||
created_on: string
|
||||
modified_on: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const router = useRoute();
|
||||
|
||||
let state = reactive({
|
||||
tableData: [] as HostedMessageItem[],
|
||||
total: 0,
|
||||
currPage: 1,
|
||||
pageSize: getPageSize(),
|
||||
search: '',
|
||||
optionValue: '',
|
||||
})
|
||||
|
||||
// 状态过滤
|
||||
// const selectedStatus = ref('all')
|
||||
// Sheet 相关状态
|
||||
const isSheetOpen = ref(false)
|
||||
const selectedLog = ref('')
|
||||
const selectedTaskName = ref('')
|
||||
// 总页数
|
||||
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
return status === 1 ? '成功' : '失败'
|
||||
}
|
||||
|
||||
// 打开消息详情Sheet
|
||||
const openMessageSheet = (message: HostedMessageItem) => {
|
||||
selectedLog.value = formatMessageDisplayHtml(message);
|
||||
selectedTaskName.value = message.title
|
||||
isSheetOpen.value = true
|
||||
}
|
||||
|
||||
const changePage = async (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
state.currPage = page
|
||||
await queryListData(
|
||||
state.currPage,
|
||||
state.pageSize,
|
||||
state.search,
|
||||
state.optionValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化处理显示的消息文本
|
||||
const formatMessageDisplayHtml = (message: HostedMessageItem) => {
|
||||
let content = `标题: ${message.title}\n\n内容: ${message.content}`;
|
||||
if (message.url) {
|
||||
content += `\n\nURL: ${message.url}`;
|
||||
}
|
||||
content += `\n\n创建时间: ${message.created_on}`;
|
||||
content += `\n修改时间: ${message.modified_on}`;
|
||||
return content;
|
||||
}
|
||||
|
||||
//触发过滤筛选
|
||||
const filterFunc = async () => {
|
||||
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
|
||||
}
|
||||
|
||||
|
||||
const queryListData = async (page: number, size: number, text = '', query = '') => {
|
||||
let params: any = { page: page, size: size, text: text, query: query };
|
||||
const rsp = await request.get('/hostedmessages/list', { params: params });
|
||||
state.tableData = await rsp.data.data.lists || [];
|
||||
state.total = await rsp.data.data.total || 0;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 页面加载触发消息显示
|
||||
state.search = router.query.name?.toString() || '';
|
||||
await queryListData(
|
||||
1,
|
||||
state.pageSize,
|
||||
router.query.name?.toString() || '',
|
||||
router.query.query?.toString() || ''
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<div class="flex-1 sm:flex-initial">
|
||||
<Input v-model="state.search" placeholder="搜索消息..." class="w-full sm:w-64" @keyup.enter="filterFunc"
|
||||
@blur="filterFunc" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20">ID</TableHead>
|
||||
<TableHead>消息标题</TableHead>
|
||||
<TableHead>消息内容</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead class="text-center">详情/状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<!-- 空数据展示 -->
|
||||
<TableRow v-if="state.tableData.length === 0">
|
||||
<TableCell colspan="5" 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="message in state.tableData" :key="message.id">
|
||||
<TableCell>{{ message.id }}</TableCell>
|
||||
<TableCell>{{ message.title }}</TableCell>
|
||||
<TableCell class="max-w-xs truncate" :title="formatMessageDisplayHtml(message)">{{ message.content }}</TableCell>
|
||||
<TableCell>{{ message.created_on }}</TableCell>
|
||||
<TableCell class="text-center space-x-2">
|
||||
<Button size="sm" variant="outline" @click="openMessageSheet(message)">查看</Button>
|
||||
<!-- <Button size="sm" variant="destructive">删除</Button> -->
|
||||
<Badge :class="message.status === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'">
|
||||
{{ getStatusText(message.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="state.total"
|
||||
:current-page="state.currPage"
|
||||
:page-size="state.pageSize"
|
||||
@page-change="changePage"
|
||||
/>
|
||||
|
||||
<!-- 消息详情Sheet -->
|
||||
<Sheet v-model:open="isSheetOpen">
|
||||
<SheetContent class="w-[600px] sm:w-[900px] lg:w-[1000px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ selectedTaskName }} - 托管消息详情</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="mt-6">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<pre class="whitespace-pre-wrap text-sm font-mono">{{ selectedLog }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,215 @@
|
||||
<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 { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||
import Pagination from '@/components/ui/Pagination.vue'
|
||||
|
||||
import { useRoute } from 'vue-router';
|
||||
import { request } from '@/api/api';
|
||||
// @ts-ignore
|
||||
import { getPageSize } from '@/util/pageUtils';
|
||||
|
||||
|
||||
interface LogItem {
|
||||
id: number
|
||||
task_id: number
|
||||
task_name: string
|
||||
log: string
|
||||
created_on: string
|
||||
caller_ip?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const router = useRoute();
|
||||
|
||||
let state = reactive({
|
||||
tableData: [] as LogItem[],
|
||||
total: 0,
|
||||
currPage: 1,
|
||||
pageSize: getPageSize(),
|
||||
search: '',
|
||||
optionValue: '',
|
||||
})
|
||||
|
||||
// 状态过滤
|
||||
const selectedStatus = ref('all')
|
||||
// Sheet 相关状态
|
||||
const isSheetOpen = ref(false)
|
||||
const selectedLog = ref('')
|
||||
const selectedTaskName = ref('')
|
||||
// 总页数
|
||||
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
return status === 1 ? '成功' : '失败'
|
||||
}
|
||||
|
||||
// 打开日志详情Sheet
|
||||
const openLogSheet = (task: LogItem) => {
|
||||
selectedLog.value = formatLogDisplayHtml(task);
|
||||
selectedTaskName.value = task.task_name
|
||||
isSheetOpen.value = true
|
||||
}
|
||||
|
||||
const changePage = async (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
state.currPage = page
|
||||
await queryListData(
|
||||
state.currPage,
|
||||
state.pageSize,
|
||||
state.search,
|
||||
state.optionValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化处理显示的日志文本
|
||||
const formatLogDisplayHtml = (task: LogItem) => {
|
||||
let log = task.log;
|
||||
log += '\n';
|
||||
if (task.caller_ip) {
|
||||
log += `调用来源IP:${task.caller_ip}`;
|
||||
};
|
||||
return log;
|
||||
}
|
||||
|
||||
//触发过滤筛选
|
||||
const filterFunc = async () => {
|
||||
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
|
||||
}
|
||||
|
||||
// 按状态过滤
|
||||
const filterByStatus = async (value: any) => {
|
||||
if (value) {
|
||||
state.currPage = 1; // 重置到第一页
|
||||
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue, JSON.stringify({
|
||||
status: selectedStatus.value === 'all' ? '' : selectedStatus.value
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const queryListData = async (page: number, size: number, name = '', taskid = '', query = '', _status = '') => {
|
||||
let params: any = { page: page, size: size, name: name, taskid: taskid, query: query };
|
||||
if (selectedStatus.value !== '' && selectedStatus.value !== 'all') {
|
||||
params.query = JSON.stringify({
|
||||
status: selectedStatus.value === 'all' ? '' : selectedStatus.value
|
||||
})
|
||||
}
|
||||
|
||||
const rsp = await request.get('/sendlogs/list', { params: params });
|
||||
state.tableData = await rsp.data.data.lists;
|
||||
// state.tableData = []
|
||||
state.total = await rsp.data.data.total;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 页面加载触发日志显示
|
||||
state.search = router.query.name?.toString() || '';
|
||||
await queryListData(
|
||||
1,
|
||||
state.pageSize,
|
||||
router.query.name?.toString() || '',
|
||||
router.query.taskid?.toString() || '',
|
||||
router.query.query?.toString() || ''
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<div class="flex-1 sm:flex-initial">
|
||||
<Input v-model="state.search" placeholder="搜索任务..." class="w-full sm:w-64" @keyup.enter="filterFunc"
|
||||
@blur="filterFunc" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 sm:flex-initial">
|
||||
<Select v-model="selectedStatus" class="w-full" @update:model-value="filterByStatus">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="1">成功</SelectItem>
|
||||
<SelectItem value="0">失败</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20">ID</TableHead>
|
||||
<TableHead>任务名称</TableHead>
|
||||
<TableHead>发信日志</TableHead>
|
||||
<TableHead>发送时间</TableHead>
|
||||
<TableHead class="text-center">详情/状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<!-- 空数据展示 -->
|
||||
<TableRow v-if="state.tableData.length === 0">
|
||||
<TableCell colspan="5" 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="task in state.tableData" :key="task.id">
|
||||
<TableCell>{{ task.task_id }}</TableCell>
|
||||
<TableCell>{{ task.task_name }}</TableCell>
|
||||
<TableCell class="max-w-xs truncate" :title="formatLogDisplayHtml(task)">{{ task.log }}</TableCell>
|
||||
<TableCell>{{ task.created_on }}</TableCell>
|
||||
<TableCell class="text-center space-x-2">
|
||||
<Button size="sm" variant="outline" @click="openLogSheet(task)">查看</Button>
|
||||
<!-- <Button size="sm" variant="destructive">删除</Button> -->
|
||||
<Badge :class="task.status === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'">
|
||||
{{ getStatusText(task.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="state.total"
|
||||
:current-page="state.currPage"
|
||||
:page-size="state.pageSize"
|
||||
@page-change="changePage"
|
||||
/>
|
||||
|
||||
<!-- 日志详情Sheet -->
|
||||
<Sheet v-model:open="isSheetOpen">
|
||||
<SheetContent class="w-[600px] sm:w-[900px] lg:w-[1000px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ selectedTaskName }} - 发信日志详情</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="mt-6">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<pre class="whitespace-pre-wrap text-sm font-mono">{{ selectedLog }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, defineEmits } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { generateBizUniqueID } from '@/util/uuid'
|
||||
import { request } from '@/api/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
// 组件emits
|
||||
const emit = defineEmits<{
|
||||
'save': [data: any]
|
||||
'cancel': []
|
||||
}>()
|
||||
|
||||
// 状态管理
|
||||
const inputValue = ref('')
|
||||
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
inputValue.value = ''
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
// 添加一条任务
|
||||
const handleSubmit = async () => {
|
||||
const taskId = generateBizUniqueID('T');
|
||||
const postData: Record<string, any> = {
|
||||
id: taskId,
|
||||
name: inputValue.value.trim(),
|
||||
ins_data: []
|
||||
}
|
||||
const rsp = await request.post('/sendtasks/ins/addmany', postData);
|
||||
if (await rsp.data.code == 200) {
|
||||
toast.success(rsp.data.msg);
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6 p-6">
|
||||
<!-- 输入框区域 -->
|
||||
<div class="space-y-2">
|
||||
<Label for="task-name">任务名称</Label>
|
||||
<Input id="task-name" v-model="inputValue" placeholder="请输入任务名称" @keyup.enter="handleSubmit" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="handleCancel">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="default" class="!bg-gray-800 !hover:bg-gray-900 text-white" @click="handleSubmit"
|
||||
:disabled="!inputValue.trim()">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AddTasks'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { ref, defineComponent } 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'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ApiCodeViewer',
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Badge
|
||||
},
|
||||
props: {
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
taskData: {
|
||||
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 taskId = props.taskData?.id || 'TASK_ID'
|
||||
const options = { html: false, markdown: false, url: false }
|
||||
|
||||
switch (language) {
|
||||
case 'curl':
|
||||
return ApiStrGenerate.getCurlString(taskId, options)
|
||||
case 'javascript':
|
||||
return ApiStrGenerate.getNodeString(taskId, options)
|
||||
case 'python':
|
||||
return ApiStrGenerate.getPythonString(taskId, options)
|
||||
case 'php':
|
||||
return ApiStrGenerate.getPHPString(taskId, options)
|
||||
case 'golang':
|
||||
return ApiStrGenerate.getGolangString(taskId, options)
|
||||
case 'java':
|
||||
return ApiStrGenerate.getJaveString(taskId, options)
|
||||
case 'rust':
|
||||
return ApiStrGenerate.getRustString(taskId, options)
|
||||
default:
|
||||
return '// 请选择一种编程语言查看示例代码'
|
||||
}
|
||||
}
|
||||
|
||||
// 复制代码到剪贴板
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
// 这里可以添加成功提示
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
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="taskData" variant="outline">{{ taskData.name }}</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<!-- 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>
|
||||
</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-3 rounded-lg overflow-x-auto text-xs leading-tight max-w-full whitespace-pre-wrap break-words"><code class="text-xs font-mono">{{ generateApiCode(lang.value) }}</code></pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</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,367 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, defineEmits, defineProps, withDefaults, onMounted } 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 {
|
||||
open?: boolean
|
||||
editData?: any // 编辑时传入的数据
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
editData: null
|
||||
})
|
||||
|
||||
// 组件emits
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'save': [data: any]
|
||||
}>()
|
||||
|
||||
// 前端的页面添加配置
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭drawer
|
||||
const handleClose = () => {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
// 添加单条实例配置
|
||||
const handleAddSubmit = async () => {
|
||||
// 组建表单数据
|
||||
let postData = {
|
||||
"id": generateBizUniqueID('I'),
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTask = async () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化额外信息列的值
|
||||
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>
|
||||
<div class="w-full">
|
||||
<!-- 任务信息展示区域 -->
|
||||
<div v-if="props.editData" class="flex flex-col sm:flex-row sm:items-center gap-2 border-b p-4">
|
||||
<Label class="w-16 sm:w-16">任务名称</Label>
|
||||
<Input v-model="props.editData.name" placeholder="请输入任务名称" class="w-full sm:w-64" />
|
||||
<Button class="w-full sm:w-auto sm:ml-auto" @click="handleEditTask">修改</Button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="flex gap-4">
|
||||
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 border-b pb-4 mt-2">
|
||||
<Button variant="outline" @click="handleClose">取消</Button>
|
||||
<Button @click="handleAddSubmit">添加实例</Button>
|
||||
</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">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EditTasks'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,278 @@
|
||||
<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, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||
import Pagination from '@/components/ui/Pagination.vue'
|
||||
import AddTasks from './AddTasks.vue'
|
||||
import EditTasks from './EditTasks.vue'
|
||||
import ApiCodeViewer from './ApiCodeViewer.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { request } from '@/api/api';
|
||||
import { CONSTANT } from '@/constant';
|
||||
// @ts-ignore
|
||||
import { getPageSize } from '@/util/pageUtils';
|
||||
|
||||
|
||||
interface WayItem {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
config: string
|
||||
created_on: string
|
||||
modified_on: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
let state = reactive({
|
||||
tableData: [] as WayItem[],
|
||||
total: 0,
|
||||
currPage: 1,
|
||||
pageSize: getPageSize(),
|
||||
search: '',
|
||||
optionValue: '',
|
||||
})
|
||||
|
||||
// 状态过滤
|
||||
const selectedStatus = ref('all')
|
||||
|
||||
// 任务类型过滤
|
||||
const selectedChannelType = ref('all')
|
||||
|
||||
// Sheet 相关状态
|
||||
const isSheetOpen = ref(false)
|
||||
const selectedConfig = ref('')
|
||||
const selectedChannelName = ref('')
|
||||
|
||||
// 新增任务 Sheet 状态
|
||||
const isAddChannelDrawerOpen = ref(false)
|
||||
|
||||
// 编辑任务 Sheet 状态
|
||||
const isEditChannelDrawerOpen = ref(false)
|
||||
const editChannelData = ref<WayItem | null>(null)
|
||||
|
||||
// API代码查看器状态
|
||||
const isApiCodeViewerOpen = ref(false)
|
||||
const selectedTaskData = ref<Record<string, any> | undefined>(undefined)
|
||||
|
||||
// 处理保存新任务
|
||||
const handleSaveChannel = (_data: any) => {
|
||||
// 这里可以添加实际的保存逻辑
|
||||
// 保存成功后刷新列表
|
||||
queryListDataWithStatus()
|
||||
}
|
||||
|
||||
// 总页数
|
||||
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||
|
||||
// const getWayTypeText = (type: string) => {
|
||||
// const wayItem = CONSTANT.WAYS_DATA.find(item => item.type === type)
|
||||
// return wayItem ? wayItem.label : type
|
||||
// }
|
||||
|
||||
// 打开编辑任务Drawer
|
||||
const openEditChannelDrawer = (channel: WayItem) => {
|
||||
editChannelData.value = channel
|
||||
isEditChannelDrawerOpen.value = true
|
||||
}
|
||||
|
||||
// 处理编辑任务保存
|
||||
const handleEditChannel = (_data: any) => {
|
||||
// 保存成功后刷新列表
|
||||
queryListDataWithStatus()
|
||||
}
|
||||
|
||||
// 处理查看日志
|
||||
const handleViewLogs = (channel: WayItem) => {
|
||||
// 跳转到发信日志页面,携带taskid参数
|
||||
router.push(`/sendlogs?taskid=${channel.id}`)
|
||||
}
|
||||
|
||||
// 处理查看API接口
|
||||
const handleViewApi = (channel: WayItem) => {
|
||||
selectedTaskData.value = channel
|
||||
isApiCodeViewerOpen.value = true
|
||||
}
|
||||
|
||||
const changePage = async (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
state.currPage = page
|
||||
await queryListDataWithStatus()
|
||||
}
|
||||
}
|
||||
|
||||
//触发过滤筛选
|
||||
const filterFunc = async () => {
|
||||
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
|
||||
}
|
||||
|
||||
// 按任务类型过滤
|
||||
// const filterByChannelType = async (value: any) => {
|
||||
// if (value) {
|
||||
// selectedChannelType.value = String(value);
|
||||
// state.currPage = 1; // 重置到第一页
|
||||
// await queryListDataWithStatus();
|
||||
// }
|
||||
// }
|
||||
|
||||
// 查询数据(包含状态过滤)
|
||||
const queryListDataWithStatus = async () => {
|
||||
const statusParam = selectedStatus.value === 'all' ? '' : selectedStatus.value;
|
||||
const channelTypeParam = selectedChannelType.value === 'all' ? '' : selectedChannelType.value;
|
||||
await queryListData(state.currPage, state.pageSize, state.search, channelTypeParam, '', statusParam);
|
||||
}
|
||||
|
||||
const queryListData = async (page: number, size: number, name = '', channelType = '', query = '', status = '') => {
|
||||
let params: any = { page: page, size: size, name: name, type: channelType, query: query };
|
||||
if (status !== '') {
|
||||
params.status = status;
|
||||
}
|
||||
const rsp = await request.get('/sendtasks/list', { params: params });
|
||||
state.tableData = await rsp.data.data.lists;
|
||||
state.total = await rsp.data.data.total;
|
||||
}
|
||||
// 删除任务
|
||||
const handleDelete = async (id: string) => {
|
||||
const rsp = await request.post('/sendtasks/delete', { id: id });
|
||||
if (rsp.status == 200 && await rsp.data.code == 200) {
|
||||
// state.tableData.splice(index, 1);
|
||||
toast.success(rsp.data.msg);
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化查询
|
||||
state.search = route.query.name?.toString() || '';
|
||||
await queryListData(
|
||||
1,
|
||||
state.pageSize,
|
||||
route.query.name?.toString() || '',
|
||||
route.query.channel_type?.toString() || ''
|
||||
);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||
<div class="flex-1 sm:flex-initial">
|
||||
<Input v-model="state.search" placeholder="搜索发信方式名称..." class="w-full sm:w-64" @keyup.enter="filterFunc"
|
||||
@blur="filterFunc" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<Dialog v-model:open="isAddChannelDrawerOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="default" class="w-full sm:w-auto bg-gray-800 hover:bg-gray-900">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
新增任务
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent class="w-[500px] max-w-[90vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增发信任务</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="px-4 pb-4">
|
||||
<AddTasks v-model:open="isAddChannelDrawerOpen" @save="handleSaveChannel"
|
||||
@cancel="() => isAddChannelDrawerOpen = false" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20">ID</TableHead>
|
||||
<TableHead>发信任务名称</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead class="text-center">操作/状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<!-- 空数据展示 -->
|
||||
<TableRow v-if="state.tableData.length === 0">
|
||||
<TableCell colspan="6" class="text-center py-12">
|
||||
<EmptyTableState title="暂无发信方式" description="还没有配置任何发信方式,请先添加发信方式" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<!-- 数据行 -->
|
||||
<TableRow v-for="channel in state.tableData" :key="channel.id">
|
||||
<TableCell>{{ channel.id }}</TableCell>
|
||||
<TableCell>{{ channel.name }}</TableCell>
|
||||
<TableCell>{{ channel.created_on }}</TableCell>
|
||||
<TableCell>{{ channel.modified_on }}</TableCell>
|
||||
<TableCell class="text-center space-x-2" v-if="channel.id !== CONSTANT.LOG_TASK_ID">
|
||||
<Button size="sm" variant="outline" @click="handleViewApi(channel)">接口</Button>
|
||||
<Button size="sm" variant="outline" @click="handleViewLogs(channel)">日志</Button>
|
||||
<Button size="sm" variant="outline" @click="openEditChannelDrawer(channel)">编辑</Button>
|
||||
<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(channel.id)">删除</Button>
|
||||
</TableCell>
|
||||
<TableCell class="text-center space-x-2" v-else>
|
||||
<Button size="sm" variant="outline" @click="handleViewLogs(channel)">日志</Button>
|
||||
<label>系统保留任务</label>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination :total="state.total" :current-page="state.currPage" :page-size="state.pageSize"
|
||||
@page-change="changePage" />
|
||||
|
||||
<!-- 编辑任务Dialog -->
|
||||
<Dialog v-model:open="isEditChannelDrawerOpen">
|
||||
<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">
|
||||
<EditTasks v-model:open="isEditChannelDrawerOpen" :edit-data="editChannelData" @save="handleEditChannel" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 配置详情Sheet -->
|
||||
<Sheet v-model:open="isSheetOpen">
|
||||
<SheetContent class="w-[600px] sm:w-[900px] lg:w-[1000px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ selectedChannelName }} - 发信方式配置详情</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="mt-6">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<pre class="whitespace-pre-wrap text-sm font-mono">{{ selectedConfig }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<!-- API代码查看器 -->
|
||||
<ApiCodeViewer v-model:open="isApiCodeViewerOpen" :task-data="selectedTaskData" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, defineProps, withDefaults } from 'vue'
|
||||
import WaysForm from './WaysForm.vue'
|
||||
|
||||
// 组件props
|
||||
interface Props {
|
||||
open?: boolean
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
open: false
|
||||
})
|
||||
|
||||
// 组件emits
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'save': [data: any]
|
||||
}>()
|
||||
|
||||
// 处理保存事件
|
||||
const handleSave = (data: any) => {
|
||||
emit('save', data)
|
||||
}
|
||||
|
||||
// 处理关闭事件
|
||||
const handleUpdateOpen = (value: boolean) => {
|
||||
emit('update:open', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WaysForm
|
||||
:open="open"
|
||||
mode="add"
|
||||
@update:open="handleUpdateOpen"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AddWays'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, defineProps, withDefaults } from 'vue'
|
||||
import WaysForm from './WaysForm.vue'
|
||||
|
||||
// 组件props
|
||||
interface Props {
|
||||
open?: boolean
|
||||
editData?: any // 编辑时传入的数据
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
editData: null
|
||||
})
|
||||
|
||||
// 组件emits
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'save': [data: any]
|
||||
}>()
|
||||
|
||||
// 处理保存事件
|
||||
const handleSave = (data: any) => {
|
||||
emit('save', data)
|
||||
}
|
||||
|
||||
// 处理关闭事件
|
||||
const handleUpdateOpen = (value: boolean) => {
|
||||
emit('update:open', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WaysForm
|
||||
:open="open"
|
||||
:edit-data="editData"
|
||||
mode="edit"
|
||||
@update:open="handleUpdateOpen"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EditWays'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,285 @@
|
||||
<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 { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||
import Pagination from '@/components/ui/Pagination.vue'
|
||||
import AddWays from './AddWays.vue'
|
||||
import EditWays from './EditWays.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { useRoute } from 'vue-router';
|
||||
import { request } from '@/api/api';
|
||||
import { CONSTANT } from '@/constant';
|
||||
// @ts-ignore
|
||||
import { getPageSize } from '@/util/pageUtils';
|
||||
|
||||
|
||||
interface WayItem {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
config: string
|
||||
created_on: string
|
||||
modified_on: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const router = useRoute();
|
||||
|
||||
let state = reactive({
|
||||
tableData: [] as WayItem[],
|
||||
total: 0,
|
||||
currPage: 1,
|
||||
pageSize: getPageSize(),
|
||||
search: '',
|
||||
optionValue: '',
|
||||
})
|
||||
|
||||
// 状态过滤
|
||||
const selectedStatus = ref('all')
|
||||
|
||||
// 渠道类型过滤
|
||||
const selectedChannelType = ref('all')
|
||||
|
||||
// 渠道类型选项 - 从CONSTANT.WAYS_DATA生成
|
||||
const channelTypeOptions = computed(() => {
|
||||
const options = [{ value: 'all', label: '全部类型' }]
|
||||
CONSTANT.WAYS_DATA.forEach(item => {
|
||||
options.push({ value: item.type, label: item.label })
|
||||
})
|
||||
return options
|
||||
})
|
||||
|
||||
// Sheet 相关状态
|
||||
const isSheetOpen = ref(false)
|
||||
const selectedConfig = ref('')
|
||||
const selectedChannelName = ref('')
|
||||
|
||||
// 新增渠道 Sheet 状态
|
||||
const isAddChannelDrawerOpen = ref(false)
|
||||
|
||||
// 编辑渠道 Sheet 状态
|
||||
const isEditChannelDrawerOpen = ref(false)
|
||||
const editChannelData = ref<WayItem | null>(null)
|
||||
|
||||
// 处理保存新渠道
|
||||
const handleSaveChannel = (_data: any) => {
|
||||
// 这里可以添加实际的保存逻辑
|
||||
// 保存成功后刷新列表
|
||||
queryListDataWithStatus()
|
||||
}
|
||||
|
||||
// 总页数
|
||||
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||
|
||||
const getWayTypeText = (type: string) => {
|
||||
const wayData = CONSTANT.WAYS_DATA.find(item => item.type === type)
|
||||
return wayData ? wayData.label : type
|
||||
}
|
||||
|
||||
// 打开编辑渠道Drawer
|
||||
const openEditChannelDrawer = (channel: WayItem) => {
|
||||
editChannelData.value = channel
|
||||
isEditChannelDrawerOpen.value = true
|
||||
}
|
||||
|
||||
// 处理编辑渠道保存
|
||||
const handleEditChannel = (_data: any) => {
|
||||
// 保存成功后刷新列表
|
||||
queryListDataWithStatus()
|
||||
}
|
||||
|
||||
const changePage = async (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
state.currPage = page
|
||||
await queryListDataWithStatus()
|
||||
}
|
||||
}
|
||||
|
||||
//触发过滤筛选
|
||||
const filterFunc = async () => {
|
||||
await queryListData(state.currPage, state.pageSize, state.search, state.optionValue);
|
||||
}
|
||||
|
||||
// 按渠道类型过滤
|
||||
const filterByChannelType = async (value: any) => {
|
||||
if (value) {
|
||||
selectedChannelType.value = String(value);
|
||||
state.currPage = 1; // 重置到第一页
|
||||
await queryListDataWithStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// 查询数据(包含状态过滤)
|
||||
const queryListDataWithStatus = async () => {
|
||||
const statusParam = selectedStatus.value === 'all' ? '' : selectedStatus.value;
|
||||
const channelTypeParam = selectedChannelType.value === 'all' ? '' : selectedChannelType.value;
|
||||
await queryListData(state.currPage, state.pageSize, state.search, channelTypeParam, '', statusParam);
|
||||
}
|
||||
|
||||
const queryListData = async (page: number, size: number, name = '', channelType = '', query = '', status = '') => {
|
||||
let params: any = { page: page, size: size, name: name, type: channelType, query: query };
|
||||
if (status !== '') {
|
||||
params.status = status;
|
||||
}
|
||||
const rsp = await request.get('/sendways/list', { params: params });
|
||||
state.tableData = await rsp.data.data.lists;
|
||||
state.total = await rsp.data.data.total;
|
||||
}
|
||||
// 删除渠道
|
||||
const handleDelete = async (id: number) => {
|
||||
const rsp = await request.post('/sendways/delete', { id: id });
|
||||
if (rsp.status == 200 && await rsp.data.code == 200) {
|
||||
// state.tableData.splice(index, 1);
|
||||
toast.success(rsp.data.msg);
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化查询
|
||||
state.search = router.query.name?.toString() || '';
|
||||
await queryListData(
|
||||
1,
|
||||
state.pageSize,
|
||||
router.query.name?.toString() || '',
|
||||
router.query.channel_type?.toString() || ''
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 w-full max-w-6xl mx-auto space-y-2">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||
<div class="flex-1 sm:flex-initial">
|
||||
<Input v-model="state.search" placeholder="搜索发信方式名称..." class="w-full sm:w-64" @keyup.enter="filterFunc"
|
||||
@blur="filterFunc" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 sm:flex-initial">
|
||||
<Select v-model="selectedChannelType" class="w-full" @update:model-value="filterByChannelType">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择渠道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="option in channelTypeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<Drawer v-model:open="isAddChannelDrawerOpen">
|
||||
<DrawerTrigger as-child>
|
||||
<Button variant="default" class="w-full sm:w-auto bg-gray-800 hover:bg-gray-900">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
新增渠道
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerContent class="w-[800px] max-w-[90vw] mx-auto h-[90vh] max-h-[90vh]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>新增发信渠道</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
|
||||
<div class="px-4 pb-4 overflow-y-auto">
|
||||
<AddWays v-model:open="isAddChannelDrawerOpen" @save="handleSaveChannel" />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20">ID</TableHead>
|
||||
<TableHead>发信方式名称</TableHead>
|
||||
<TableHead>发信方式类型</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead class="text-center">操作/状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<!-- 空数据展示 -->
|
||||
<TableRow v-if="state.tableData.length === 0">
|
||||
<TableCell colspan="6" class="text-center py-12">
|
||||
<EmptyTableState title="暂无发信方式" description="还没有配置任何发信方式,请先添加发信方式" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<!-- 数据行 -->
|
||||
<TableRow v-for="channel in state.tableData" :key="channel.id">
|
||||
<TableCell>{{ channel.id }}</TableCell>
|
||||
<TableCell>{{ channel.name }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{{ getWayTypeText(channel.type) }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{{ channel.created_on }}</TableCell>
|
||||
<TableCell>{{ channel.modified_on }}</TableCell>
|
||||
<TableCell class="text-center space-x-2">
|
||||
<Button size="sm" variant="outline" @click="openEditChannelDrawer(channel)">编辑</Button>
|
||||
<!-- <Button size="sm" variant="outline" @click="openConfigSheet(channel)">查看</Button> -->
|
||||
<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(channel.id)">删除</Button>
|
||||
<!-- <Badge :class="channel.status === 1 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'">
|
||||
{{ getStatusText(channel.status) }} -->
|
||||
<!-- </Badge> -->
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination :total="state.total" :current-page="state.currPage" :page-size="state.pageSize"
|
||||
@page-change="changePage" />
|
||||
|
||||
<!-- 编辑渠道Drawer -->
|
||||
<Drawer v-model:open="isEditChannelDrawerOpen">
|
||||
<DrawerContent class="w-[800px] max-w-[90vw] mx-auto h-[90vh] max-h-[90vh]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>编辑发信渠道</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
|
||||
<div class="px-4 pb-4 overflow-y-auto">
|
||||
<EditWays v-model:open="isEditChannelDrawerOpen" :edit-data="editChannelData" @save="handleEditChannel" />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- 配置详情Sheet -->
|
||||
<Sheet v-model:open="isSheetOpen">
|
||||
<SheetContent class="w-[600px] sm:w-[900px] lg:w-[1000px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ selectedChannelName }} - 发信方式配置详情</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="mt-6">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<pre class="whitespace-pre-wrap text-sm font-mono">{{ selectedConfig }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, defineEmits, defineProps, withDefaults, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { CONSTANT } from '@/constant'
|
||||
import { validateForm, createValidationState, type InputConfig } from '@/util/validation'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { request } from '@/api/api'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
// 组件props
|
||||
interface Props {
|
||||
open?: boolean
|
||||
editData?: any // 编辑时传入的数据
|
||||
mode?: 'add' | 'edit' // 模式:新增或编辑
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
editData: null,
|
||||
mode: 'add'
|
||||
})
|
||||
|
||||
// 组件emits
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'save': [data: any]
|
||||
}>()
|
||||
|
||||
// 前端的页面添加配置
|
||||
let waysConfigMap = CONSTANT.WAYS_DATA;
|
||||
|
||||
// Radio Group 选项 - 根据waysConfigMap动态生成
|
||||
const channelModeOptions = waysConfigMap.map(item => ({
|
||||
value: item.type,
|
||||
label: item.label
|
||||
}))
|
||||
const channelMode = ref(channelModeOptions[0]?.value || '')
|
||||
|
||||
// 当前选中渠道的配置
|
||||
const currentChannelConfig = computed(() => {
|
||||
return waysConfigMap.find(item => item.type === channelMode.value) || null
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<Record<string, any>>({})
|
||||
|
||||
// 校验状态管理
|
||||
const validationState = createValidationState()
|
||||
|
||||
// 初始化表单数据
|
||||
const initFormData = () => {
|
||||
const config = currentChannelConfig.value
|
||||
if (!config) return
|
||||
|
||||
const newFormData: Record<string, any> = {}
|
||||
|
||||
// 如果是编辑模式且有编辑数据,先填充编辑数据
|
||||
if (props.mode === 'edit' && props.editData) {
|
||||
// 设置渠道类型
|
||||
channelMode.value = props.editData.type || channelModeOptions[0]?.value || ''
|
||||
|
||||
// 解析auth数据
|
||||
let authData: Record<string, any> = {}
|
||||
try {
|
||||
authData = props.editData.auth ? JSON.parse(props.editData.auth) : {}
|
||||
} catch (e) {
|
||||
console.error('解析auth数据失败:', e)
|
||||
}
|
||||
|
||||
// 填充基本字段
|
||||
newFormData.name = props.editData.name || ''
|
||||
|
||||
// 填充auth中的字段
|
||||
Object.keys(authData).forEach(key => {
|
||||
newFormData[key] = authData[key]
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化基本输入字段
|
||||
if (config.inputs) {
|
||||
config.inputs.forEach((input: any) => {
|
||||
if (newFormData[input.col] === undefined) {
|
||||
newFormData[input.col] = input.value || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化任务指令输入字段
|
||||
if (config.taskInsInputs) {
|
||||
config.taskInsInputs.forEach((input: any) => {
|
||||
if (newFormData[input.col] === undefined) {
|
||||
newFormData[input.col] = input.value || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化任务指令单选项
|
||||
if (config.taskInsRadios && config.taskInsRadios.length > 0) {
|
||||
if (newFormData.taskInsRadio === undefined) {
|
||||
newFormData.taskInsRadio = config.taskInsRadios[0].value
|
||||
}
|
||||
}
|
||||
|
||||
formData.value = newFormData
|
||||
}
|
||||
|
||||
// 获取所有输入字段配置
|
||||
const getAllInputConfigs = (): InputConfig[] => {
|
||||
const config = currentChannelConfig.value
|
||||
if (!config) return []
|
||||
|
||||
const configs: InputConfig[] = []
|
||||
|
||||
// 基本输入字段
|
||||
if (config.inputs) {
|
||||
configs.push(...config.inputs.map((input: any) => ({
|
||||
col: input.col,
|
||||
label: input.label,
|
||||
subLabel: input.subLabel,
|
||||
type: input.type,
|
||||
required: input.required !== false,
|
||||
minLength: input.minLength,
|
||||
maxLength: input.maxLength
|
||||
})))
|
||||
}
|
||||
|
||||
// 任务指令输入字段
|
||||
if (config.taskInsInputs) {
|
||||
configs.push(...config.taskInsInputs.map((input: any) => ({
|
||||
col: input.col,
|
||||
label: input.label,
|
||||
subLabel: input.subLabel,
|
||||
type: input.type,
|
||||
required: input.required !== false,
|
||||
minLength: input.minLength,
|
||||
maxLength: input.maxLength
|
||||
})))
|
||||
}
|
||||
|
||||
return configs
|
||||
}
|
||||
|
||||
// 校验表单
|
||||
const validateFormData = () => {
|
||||
const inputConfigs = getAllInputConfigs()
|
||||
const result = validateForm(formData.value, inputConfigs)
|
||||
|
||||
validationState.setErrors(result.errors)
|
||||
return result.isValid
|
||||
}
|
||||
|
||||
// 监听渠道模式变化
|
||||
const handleChannelModeChange = () => {
|
||||
initFormData()
|
||||
validationState.clearAllErrors()
|
||||
}
|
||||
|
||||
// 监听编辑数据变化(仅编辑模式)
|
||||
watch(() => props.editData, () => {
|
||||
if (props.mode === 'edit') {
|
||||
initFormData()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 初始化表单数据(新增模式)
|
||||
if (props.mode === 'add') {
|
||||
initFormData()
|
||||
}
|
||||
|
||||
// 关闭drawer
|
||||
const handleClose = () => {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
// 获取最终提交数据
|
||||
const getFinalData = () => {
|
||||
// 根据当前渠道配置的inputs中的col字段,从formData中提取对应的值组成auth对象
|
||||
const config = currentChannelConfig.value
|
||||
const authData: Record<string, any> = {}
|
||||
if (config && config.inputs) {
|
||||
config.inputs.forEach((input: any) => {
|
||||
if (formData.value[input.col] !== undefined && input.col != 'name') {
|
||||
authData[input.col] = formData.value[input.col]
|
||||
if (config.type == 'Email' && input.col == 'port') {
|
||||
authData[input.col] = parseInt(formData.value[input.col])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let postData: Record<string, any> = {
|
||||
auth: JSON.stringify(authData),
|
||||
type: channelMode.value,
|
||||
name: formData.value.name,
|
||||
}
|
||||
|
||||
// 编辑时需要传递ID
|
||||
if (props.mode === 'edit' && props.editData && props.editData.id) {
|
||||
postData.id = props.editData.id
|
||||
}
|
||||
|
||||
return postData
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const handleTest = async () => {
|
||||
if (!validateFormData()) { return }
|
||||
|
||||
let postData = getFinalData()
|
||||
const rsp = await request.post('/sendways/test', postData)
|
||||
if (await rsp.data.code == 200) {
|
||||
toast({ message: await rsp.data.msg, type: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async () => {
|
||||
if (!validateFormData()) { return }
|
||||
|
||||
let postData = getFinalData()
|
||||
|
||||
// 根据模式选择API路径和成功消息
|
||||
const apiUrl = props.mode === 'edit' ? '/sendways/edit' : '/sendways/add'
|
||||
const successMessage = props.mode === 'edit' ? '更新渠道成功!' : '添加渠道成功!'
|
||||
|
||||
const rsp = await request.post(apiUrl, postData);
|
||||
if (await rsp.data.code == 200) {
|
||||
toast(successMessage )
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算保存按钮文本
|
||||
const saveButtonText = computed(() => {
|
||||
return props.mode === 'edit' ? '更新' : '保存'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<!-- 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">
|
||||
<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">
|
||||
{{ option.label }}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<!-- 动态表单 -->
|
||||
<div v-if="currentChannelConfig" class="mt-6">
|
||||
<!-- 基本配置输入字段 -->
|
||||
<div v-if="currentChannelConfig.inputs && currentChannelConfig.inputs.length > 0" class="mb-8">
|
||||
<h4 class="text-base font-medium mb-4 text-gray-800">基本配置</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="input in currentChannelConfig.inputs" :key="input.col" class="space-y-2" :class="{
|
||||
'md:col-span-2': input.isTextArea
|
||||
}">
|
||||
<Label :for="input.col" class="text-sm font-medium">
|
||||
{{ input.subLabel || input.label }}
|
||||
<span v-if="input.tips" class="text-xs text-gray-500 ml-1">({{ input.tips }})</span>
|
||||
</Label>
|
||||
<!-- 配置输入框 -->
|
||||
<Textarea v-if="input.isTextArea" :id="input.col" v-model="formData[input.col]"
|
||||
:placeholder="input.desc || input.placeholder || input.subLabel || input.label" :class="{
|
||||
'w-full': true,
|
||||
'border-red-500 focus:border-red-500': validationState.errors.value[input.col]
|
||||
}" @input="() => validationState.clearFieldError(input.col)" />
|
||||
<Input v-else :id="input.col" v-model="formData[input.col]"
|
||||
:placeholder="input.desc || input.placeholder || input.subLabel || input.label" :class="{
|
||||
'w-full': true,
|
||||
'border-red-500 focus:border-red-500': validationState.errors.value[input.col]
|
||||
}" @input="() => validationState.clearFieldError(input.col)" />
|
||||
<!-- 异常校验提示显示 -->
|
||||
<div v-if="validationState.errors.value[input.col]" class="text-red-500 text-xs mt-1">
|
||||
{{ validationState.errors.value[input.col] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 ml-4" v-if="currentChannelConfig.tips && currentChannelConfig.tips.text">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger class="text-sm hover:text-gray-700 inline-flex items-center gap-1">
|
||||
{{ currentChannelConfig.tips.text }}
|
||||
<span
|
||||
class="cursor-help inline-flex items-center justify-center w-4 h-4 rounded-full border border-gray-300 hover:border-gray-400 text-xs">?</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p class="text-sm">{{ currentChannelConfig.tips.desc }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-6 p-6 bg-gray-50 rounded-lg">
|
||||
<p class="text-gray-500">请选择一个渠道类型开始配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-8 pt-4 border-t">
|
||||
<Button variant="outline" @click="handleClose">取消</Button>
|
||||
<Button @click="handleTest">测试</Button>
|
||||
<Button @click="handleSave">{{ saveButtonText }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'WaysForm'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Github, FileText } from 'lucide-vue-next'
|
||||
import { request } from '@/api/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const state = reactive({
|
||||
version: '1.0.0',
|
||||
description: '一个现代化的消息推送管理平台,支持多种推送渠道和灵活的消息管理功能。',
|
||||
features: [
|
||||
'多渠道消息推送',
|
||||
'定时消息管理',
|
||||
'托管消息服务',
|
||||
'发信日志追踪',
|
||||
'渠道配置管理'
|
||||
],
|
||||
techStack: ['Vue 3', 'TypeScript', 'Vite', 'Tailwind CSS', 'Shadcn/ui'],
|
||||
githubUrl: 'https://github.com/engigu/Message-Push-Nest',
|
||||
copyright: '保留所有权利.',
|
||||
versionLog: ''
|
||||
})
|
||||
|
||||
// 获取关于页面配置
|
||||
const getAboutConfig = async () => {
|
||||
try {
|
||||
const params = { params: { section: 'about' } }
|
||||
const response = await request.get('/settings/getsetting', params)
|
||||
if (response.data.code === 200) {
|
||||
const data = response.data.data
|
||||
if (data.version) state.version = data.version
|
||||
if (data.desc) state.versionLog = data.desc
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('获取关于信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getAboutConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'AboutSettings'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>站点关于</CardTitle>
|
||||
<CardDescription>{{ state.description }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 mb-2">技术栈</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge v-for="tech in state.techStack" :key="tech">{{ tech }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 mb-2">功能特性</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li v-for="feature in state.features" :key="feature">• {{ feature }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 mb-2">系统信息</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">版本:</span>
|
||||
<Badge variant="outline">{{ state.version }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">运行环境:</span>
|
||||
<span>Vue 3 + TypeScript</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 mb-2">版本日志</h3>
|
||||
<Sheet>
|
||||
<SheetTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="inline-flex items-center gap-2">
|
||||
<FileText class="w-4 h-4" />
|
||||
查看更新日志
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent class="w-[600px] sm:w-[800px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>版本更新日志</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="mt-6">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<pre class="whitespace-pre-wrap text-sm font-mono">{{ state.versionLog }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 my-4"></div>
|
||||
|
||||
<div class="text-center text-sm text-gray-500">
|
||||
<p>© {{ new Date().getFullYear() }} {{ state.copyright }}</p>
|
||||
<p class="mt-1">如有问题请联系系统管理员</p>
|
||||
<p class="mt-2">
|
||||
<a :href="state.githubUrl" target="_blank" class="inline-flex items-center gap-1 text-blue-500 hover:text-blue-700 underline">
|
||||
<Github class="w-4 h-4" />
|
||||
GitHub 仓库
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { request } from '@/api/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CONSTANT } from '@/constant'
|
||||
import { HelpCircleIcon } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const state = reactive({
|
||||
section: 'log_config',
|
||||
cron: '',
|
||||
keepNum: '1000',
|
||||
})
|
||||
|
||||
// 提交配置
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const postData = {
|
||||
section: state.section,
|
||||
data: {
|
||||
cron: state.cron.trim(),
|
||||
keep_num: state.keepNum.trim(),
|
||||
},
|
||||
}
|
||||
const response = await request.post('/settings/set', postData)
|
||||
if (response.data.code === 200) {
|
||||
const msg = response.data.msg
|
||||
toast.success(msg)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('保存失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看日志
|
||||
const handleView = async () => {
|
||||
router.push({ path: '/sendlogs', query: { taskid: CONSTANT.LOG_TASK_ID } })
|
||||
}
|
||||
|
||||
// 获取站点配置
|
||||
const getSiteConfig = async () => {
|
||||
try {
|
||||
const params = { params: { section: 'log_config' } }
|
||||
const response = await request.get('/settings/getsetting', params)
|
||||
if (response.data.code === 200) {
|
||||
const data = response.data.data
|
||||
state.cron = data.cron || ''
|
||||
state.keepNum = data.keep_num || '1000'
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('获取配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getSiteConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'LogsSettings'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>日志设置</CardTitle>
|
||||
<CardDescription>配置定时日志清除和保留策略</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="setting-container">
|
||||
<div class="space-y-4">
|
||||
<!-- Cron表达式输入 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">定时清除Cron表达式</label>
|
||||
<div class="flex">
|
||||
<!-- <div class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-50 border border-r-0 border-gray-300 rounded-l-md">
|
||||
cron://
|
||||
</div> -->
|
||||
<Input
|
||||
v-model="state.cron"
|
||||
placeholder="请输入定时日志清除的Cron表达式"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保留数量输入 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">保留日志条数</label>
|
||||
<div class="flex">
|
||||
<!-- <div class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-50 border border-r-0 border-gray-300 rounded-l-md">
|
||||
保留数
|
||||
</div> -->
|
||||
<Input
|
||||
v-model="state.keepNum"
|
||||
placeholder="请输入要保留的最近的日志条数"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区域 -->
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-600">说明</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircleIcon class="w-4 h-4 text-gray-400 hover:text-gray-600" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-xs">
|
||||
<div class="text-sm">
|
||||
<p>cron如果不设置,默认是在每天的0点1分进行清理</p>
|
||||
<p class="mt-1">保留数目如果不设置,默认保留最近1000条</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<Button variant="outline" size="sm" @click="handleView">
|
||||
查看日志
|
||||
</Button>
|
||||
<Button size="sm" @click="handleSubmit">
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { request } from '@/api/api'
|
||||
|
||||
// 重置密码相关
|
||||
const passwordForm = ref({
|
||||
newPassword: '',
|
||||
currentPassword: ''
|
||||
})
|
||||
|
||||
// 重置密码
|
||||
const resetPassword = async () => {
|
||||
|
||||
|
||||
// 检查演示模式
|
||||
// @ts-ignore
|
||||
const isDemoMode = (import.meta as any).env.VITE_RUN_MODE === 'demo'
|
||||
|
||||
if (isDemoMode) {
|
||||
toast.error('演示模式下无法重置密码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let postData = { new_passwd: passwordForm.value.newPassword , old_passwd: passwordForm.value.currentPassword}
|
||||
const rsp = await request.post('/settings/setpasswd', postData)
|
||||
if (rsp.data.code == 200) {
|
||||
let msg = rsp.data.msg
|
||||
toast.success(msg)
|
||||
} else {
|
||||
toast.error(rsp.data.msg || '密码重置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('密码重置失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'PasswordSettings'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>重置密码</CardTitle>
|
||||
<CardDescription>更改您的登录密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="current-password">旧密码</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
v-model="passwordForm.currentPassword"
|
||||
placeholder="请输入旧密码"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="new-password">新密码</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
v-model="passwordForm.newPassword"
|
||||
placeholder="请输入新密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="resetPassword" class="w-full sm:w-auto">
|
||||
重置密码
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import SettingsSidebar from './SettingsSidebar.vue'
|
||||
import PasswordSettings from './PasswordSettings.vue'
|
||||
import LogsSettings from './LogsSettings.vue'
|
||||
import SiteSettings from './SiteSettings.vue'
|
||||
import AboutSettings from './AboutSettings.vue'
|
||||
|
||||
// 当前选中的设置项
|
||||
const activeTab = ref('password')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 w-full max-w-6xl mx-auto">
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- 侧边栏 -->
|
||||
<SettingsSidebar
|
||||
:active-tab="activeTab"
|
||||
@update:active-tab="activeTab = $event"
|
||||
/>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="flex-1">
|
||||
<!-- 重置密码 -->
|
||||
<PasswordSettings v-if="activeTab === 'password'" />
|
||||
|
||||
<!-- 日志清理 -->
|
||||
<LogsSettings v-if="activeTab === 'logs'" />
|
||||
|
||||
<!-- 站点设置 -->
|
||||
<SiteSettings v-if="activeTab === 'site'" />
|
||||
|
||||
<!-- 站点关于 -->
|
||||
<AboutSettings v-if="activeTab === 'about'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { KeyIcon, TrashIcon, SettingsIcon, InfoIcon } from 'lucide-vue-next'
|
||||
|
||||
// 定义props
|
||||
interface Props {
|
||||
activeTab: string
|
||||
}
|
||||
|
||||
// 定义emits
|
||||
interface Emits {
|
||||
(e: 'update:activeTab', value: string): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
// 设置菜单项
|
||||
const settingsMenu = [
|
||||
{ id: 'password', name: '重置密码', icon: KeyIcon },
|
||||
{ id: 'logs', name: '日志清理', icon: TrashIcon },
|
||||
{ id: 'site', name: '站点设置', icon: SettingsIcon },
|
||||
{ id: 'about', name: '站点关于', icon: InfoIcon }
|
||||
]
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'SettingsSidebar'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lg:w-64 flex-shrink-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-lg">设置</CardTitle>
|
||||
<CardDescription>管理系统配置和偏好</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<nav class="space-y-1">
|
||||
<button
|
||||
v-for="item in settingsMenu"
|
||||
:key="item.id"
|
||||
@click="$emit('update:activeTab', item.id)"
|
||||
:class="[
|
||||
'w-full flex items-center px-4 py-3 text-left text-sm font-medium transition-colors',
|
||||
activeTab === item.id
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<component :is="item.icon" class="mr-3 w-5 h-5" />
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</nav>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { request } from '@/api/api'
|
||||
// @ts-ignore
|
||||
import { LocalStieConfigUtils } from '@/util/localSiteConfig'
|
||||
import { HelpCircleIcon } from 'lucide-vue-next'
|
||||
|
||||
const state = reactive({
|
||||
title: '',
|
||||
slogan: '',
|
||||
logo: '',
|
||||
pagesize: '',
|
||||
section: 'site_config',
|
||||
})
|
||||
|
||||
// 提交设置
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const postData = {
|
||||
section: state.section,
|
||||
data: {
|
||||
title: state.title.trim(),
|
||||
slogan: state.slogan.trim(),
|
||||
logo: state.logo.trim(),
|
||||
pagesize: state.pagesize,
|
||||
},
|
||||
}
|
||||
const response = await request.post('/settings/set', postData)
|
||||
if (response.data.code === 200) {
|
||||
const msg = response.data.msg
|
||||
toast.success(msg)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('保存失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复默认设置
|
||||
const handleSubmitReset = async () => {
|
||||
try {
|
||||
const response = await request.post('/settings/reset', {})
|
||||
if (response.data.code === 200) {
|
||||
const msg = response.data.msg
|
||||
toast.success(msg)
|
||||
// 重新获取设置
|
||||
await getSiteConfig()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('恢复默认设置失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取站点配置
|
||||
const getSiteConfig = async () => {
|
||||
try {
|
||||
const params = { params: { section: 'site_config' } }
|
||||
const response = await request.get('/settings/getsetting', params)
|
||||
if (response.data.code === 200) {
|
||||
const data = response.data.data
|
||||
state.title = data.title || ''
|
||||
state.logo = data.logo || ''
|
||||
state.slogan = data.slogan || ''
|
||||
state.pagesize = data.pagesize || ''
|
||||
|
||||
LocalStieConfigUtils.updateLocalConfig(data)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('获取配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getSiteConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'SiteSettings'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>站点设置</CardTitle>
|
||||
<CardDescription>配置站点基本信息和参数</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="setting-container">
|
||||
<div class="space-y-4">
|
||||
<!-- 站点标题 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">站点标题</label>
|
||||
<Input v-model="state.title" placeholder="请输入自定义的网站标题" />
|
||||
</div>
|
||||
|
||||
<!-- 站点标语 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">站点标语</label>
|
||||
<Input v-model="state.slogan" placeholder="请输入自定义的网站slogan" />
|
||||
</div>
|
||||
|
||||
<!-- 站点图标 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">站点图标</label>
|
||||
<Input v-model="state.logo" placeholder="请输入自定义的网站logo(svg文本)" />
|
||||
<!-- SVG预览 -->
|
||||
<div v-if="state.logo" class="mt-2 p-3 border border-gray-200 rounded-md bg-gray-50">
|
||||
<div class="text-xs text-gray-500 mb-2">预览效果:</div>
|
||||
<div class="flex items-center justify-center w-16 h-16 bg-white border border-gray-300 rounded"
|
||||
v-html="state.logo"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页大小 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">分页大小</label>
|
||||
<Input v-model="state.pagesize" placeholder="页面分页大小" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区域 -->
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-600">说明</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircleIcon class="w-4 h-4 text-gray-400 hover:text-gray-600" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-sm">
|
||||
<div class="text-sm space-y-1">
|
||||
<p>1. logo请输入svg文本,替换后登录页面,ico,导航栏logo将全部一起更换</p>
|
||||
<p>2. slogan将在登录页面展示</p>
|
||||
<p>** 将在下一次登录的时候生效,如果不生效请在登录页面Ctrl+F5强制刷新</p>
|
||||
<p>** logo将替换网页ico,登录页面logo,导航栏logo</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<Button variant="outline" size="sm" @click="handleSubmitReset">
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button size="sm" @click="handleSubmit">
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-container {
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user