feat: reoede web to tailwindcss

This commit is contained in:
engigu
2025-08-10 14:32:24 +08:00
parent 0a12a25473
commit d557547bc0
192 changed files with 9624 additions and 9694 deletions
@@ -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>
+10
View File
@@ -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="请输入自定义的网站logosvg文本)" />
<!-- 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>