feat: add Vue3 frontend with TailwindCSS
This commit is contained in:
parent
7f581e3fbb
commit
e715ca7350
4
frontend/css/style.css
Normal file
4
frontend/css/style.css
Normal file
@ -0,0 +1,4 @@
|
||||
/* Custom styles */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
233
frontend/index.html
Normal file
233
frontend/index.html
Normal file
@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Apprise Notify Center</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div id="app">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-blue-600 text-white shadow-lg">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-bell text-2xl mr-3"></i>
|
||||
<span class="font-bold text-xl">Notify Center</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="#" @click.prevent="currentView = 'channels'"
|
||||
:class="{'text-blue-200': currentView !== 'channels'}"
|
||||
class="hover:text-blue-200">
|
||||
<i class="fas fa-broadcast-tower mr-1"></i>通道管理
|
||||
</a>
|
||||
<a href="#" @click.prevent="currentView = 'send'"
|
||||
:class="{'text-blue-200': currentView !== 'send'}"
|
||||
class="hover:text-blue-200">
|
||||
<i class="fas fa-paper-plane mr-1"></i>手动发送
|
||||
</a>
|
||||
<a href="#" @click.prevent="currentView = 'history'"
|
||||
:class="{'text-blue-200': currentView !== 'history'}"
|
||||
class="hover:text-blue-200">
|
||||
<i class="fas fa-history mr-1"></i>历史记录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
<!-- 通道管理 -->
|
||||
<div v-if="currentView === 'channels'" class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-800">通道管理</h2>
|
||||
<button @click="showChannelModal = true"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-2"></i>添加通道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">类型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">标签</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="channel in channels" :key="channel.id">
|
||||
<td class="px-6 py-4">{{ channel.name }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-2 py-1 bg-gray-100 rounded text-sm">{{ channel.type }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span v-for="tag in channel.tags" :key="tag"
|
||||
class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs mr-1">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="channel.is_active ? 'text-green-600' : 'text-gray-400'">
|
||||
{{ channel.is_active ? '活跃' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 space-x-2">
|
||||
<button @click="testChannel(channel.id)"
|
||||
class="text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-vial"></i> 测试
|
||||
</button>
|
||||
<button @click="deleteChannel(channel.id)"
|
||||
class="text-red-600 hover:text-red-800">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动发送 -->
|
||||
<div v-if="currentView === 'send'" class="max-w-2xl">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">手动发送通知</h2>
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">选择通道</label>
|
||||
<select v-model="sendForm.channels" multiple
|
||||
class="w-full border rounded-lg px-3 py-2 h-32">
|
||||
<option v-for="channel in channels" :key="channel.id" :value="channel.name">
|
||||
{{ channel.name }} ({{ channel.type }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">优先级</label>
|
||||
<select v-model="sendForm.priority" class="w-full border rounded-lg px-3 py-2">
|
||||
<option value="low">低</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">标题(可选)</label>
|
||||
<input v-model="sendForm.title" type="text"
|
||||
class="w-full border rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">内容</label>
|
||||
<textarea v-model="sendForm.body" rows="4"
|
||||
class="w-full border rounded-lg px-3 py-2" required></textarea>
|
||||
</div>
|
||||
<button @click="sendNotification"
|
||||
:disabled="sending"
|
||||
class="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:bg-gray-400">
|
||||
<i v-if="!sending" class="fas fa-paper-plane mr-2"></i>
|
||||
<i v-else class="fas fa-spinner fa-spin mr-2"></i>
|
||||
{{ sending ? '发送中...' : '发送通知' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<div v-if="currentView === 'history'" class="space-y-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">发送历史</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">时间</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">通道</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">标题</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="n in notifications" :key="n.id">
|
||||
<td class="px-6 py-4 text-sm">{{ formatDate(n.created_at) }}</td>
|
||||
<td class="px-6 py-4">{{ n.channel?.name || '-' }}</td>
|
||||
<td class="px-6 py-4">{{ n.title || '-' }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="getStatusClass(n.status)">
|
||||
{{ n.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 通道编辑模态框 -->
|
||||
<div v-if="showChannelModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-screen overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-bold mb-4">添加通道</h3>
|
||||
<form @submit.prevent="saveChannel" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">名称</label>
|
||||
<input v-model="channelForm.name" type="text" required
|
||||
class="w-full border rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">类型</label>
|
||||
<select v-model="channelForm.type" required
|
||||
class="w-full border rounded-lg px-3 py-2">
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="apprise">Apprise URL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">配置 (JSON)</label>
|
||||
<textarea v-model="channelForm.configJson" rows="4" required
|
||||
class="w-full border rounded-lg px-3 py-2 font-mono text-sm"
|
||||
placeholder='{"webhook_url": "https://..."}'></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">标签(逗号分隔)</label>
|
||||
<input v-model="channelForm.tags" type="text"
|
||||
class="w-full border rounded-lg px-3 py-2"
|
||||
placeholder="alerts, production">
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input v-model="channelForm.is_active" type="checkbox" id="is_active"
|
||||
class="mr-2">
|
||||
<label for="is_active">启用</label>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button type="button" @click="showChannelModal = false"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div v-if="message" :class="`fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg ${message.type === 'success' ? 'bg-green-500' : 'bg-red-500'} text-white`">
|
||||
{{ message.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
185
frontend/js/app.js
Normal file
185
frontend/js/app.js
Normal file
@ -0,0 +1,185 @@
|
||||
const { createApp, ref, onMounted } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const currentView = ref('channels');
|
||||
const channels = ref([]);
|
||||
const notifications = ref([]);
|
||||
const showChannelModal = ref(false);
|
||||
const sending = ref(false);
|
||||
const message = ref(null);
|
||||
|
||||
const channelForm = ref({
|
||||
name: '',
|
||||
type: 'discord',
|
||||
configJson: '{}',
|
||||
tags: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const sendForm = ref({
|
||||
channels: [],
|
||||
title: '',
|
||||
body: '',
|
||||
priority: 'normal'
|
||||
});
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
const showMessage = (text, type = 'success') => {
|
||||
message.value = { text, type };
|
||||
setTimeout(() => message.value = null, 3000);
|
||||
};
|
||||
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/channels`);
|
||||
const data = await res.json();
|
||||
channels.value = data.channels;
|
||||
} catch (e) {
|
||||
showMessage('获取通道失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/notifications`);
|
||||
const data = await res.json();
|
||||
notifications.value = data.notifications;
|
||||
} catch (e) {
|
||||
showMessage('获取历史失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveChannel = async () => {
|
||||
try {
|
||||
const config = JSON.parse(channelForm.value.configJson);
|
||||
const tags = channelForm.value.tags.split(',').map(t => t.trim()).filter(t => t);
|
||||
|
||||
const res = await fetch(`${API_BASE}/channels`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: channelForm.value.name,
|
||||
type: channelForm.value.type,
|
||||
config,
|
||||
tags,
|
||||
is_active: channelForm.value.is_active
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showMessage('通道创建成功');
|
||||
showChannelModal.value = false;
|
||||
fetchChannels();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showMessage(err.detail || '创建失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('配置 JSON 格式错误', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteChannel = async (id) => {
|
||||
if (!confirm('确定删除此通道?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/channels/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
showMessage('删除成功');
|
||||
fetchChannels();
|
||||
} else {
|
||||
showMessage('删除失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const testChannel = async (id) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/channels/${id}/test`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
showMessage('测试消息已发送');
|
||||
} else {
|
||||
showMessage('测试失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('测试失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const sendNotification = async () => {
|
||||
if (!sendForm.value.body) {
|
||||
showMessage('请输入消息内容', 'error');
|
||||
return;
|
||||
}
|
||||
if (sendForm.value.channels.length === 0) {
|
||||
showMessage('请至少选择一个通道', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channels: sendForm.value.channels,
|
||||
title: sendForm.value.title,
|
||||
body: sendForm.value.body,
|
||||
priority: sendForm.value.priority
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showMessage('通知发送成功');
|
||||
sendForm.value = { channels: [], title: '', body: '', priority: 'normal' };
|
||||
} else {
|
||||
showMessage('发送失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('发送失败', 'error');
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
return {
|
||||
'px-2 py-1 rounded text-xs': true,
|
||||
'bg-green-100 text-green-800': status === 'sent',
|
||||
'bg-red-100 text-red-800': status === 'failed',
|
||||
'bg-yellow-100 text-yellow-800': status === 'pending'
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchChannels();
|
||||
fetchNotifications();
|
||||
});
|
||||
|
||||
return {
|
||||
currentView,
|
||||
channels,
|
||||
notifications,
|
||||
showChannelModal,
|
||||
channelForm,
|
||||
sendForm,
|
||||
sending,
|
||||
message,
|
||||
saveChannel,
|
||||
deleteChannel,
|
||||
testChannel,
|
||||
sendNotification,
|
||||
formatDate,
|
||||
getStatusClass
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
Loading…
x
Reference in New Issue
Block a user