feat: add Vue3 frontend with TailwindCSS

This commit is contained in:
OpenClaw Agent 2026-02-07 18:15:24 +00:00
parent 7f581e3fbb
commit e715ca7350
3 changed files with 422 additions and 0 deletions

4
frontend/css/style.css Normal file
View File

@ -0,0 +1,4 @@
/* Custom styles */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

233
frontend/index.html Normal file
View 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
View 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');