feat: 飞书单点登录和通知功能
This commit is contained in:
@@ -24,14 +24,39 @@ function switchTab(tab) {
|
||||
|
||||
//如果切换到日志页,开始实时加载
|
||||
if (tab === 'logs') {
|
||||
startLogStreaming();
|
||||
checkAdminStatus().then(allowed => {
|
||||
if (allowed) {
|
||||
startLogStreaming();
|
||||
} else {
|
||||
const container = document.getElementById('content-logs');
|
||||
container.innerHTML = `
|
||||
<div class="w-full h-[calc(100vh-100px)] rounded-lg overflow-hidden bg-white shadow-sm border border-slate-200">
|
||||
<iframe src="/error.html" class="w-full h-full border-0"></iframe>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
stopLogStreaming();
|
||||
}
|
||||
|
||||
//如果切换到设置页,加载 .env 文件
|
||||
if (tab === 'settings') {
|
||||
loadEnvFile();
|
||||
checkAdminStatus().then(allowed => {
|
||||
if (allowed) {
|
||||
loadEnvFile();
|
||||
} else {
|
||||
const container = document.getElementById('content-settings');
|
||||
// 使用 iframe 嵌入错误页,保持侧边栏导航
|
||||
container.innerHTML = `
|
||||
<div class="w-full h-[calc(100vh-100px)] rounded-lg overflow-hidden bg-white shadow-sm border border-slate-200">
|
||||
<iframe src="/error.html" class="w-full h-full border-0"></iframe>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
return; // 等待检查结果,暂不加载内容
|
||||
}
|
||||
|
||||
//如果切换到使用指南页,加载 README
|
||||
@@ -40,6 +65,17 @@ function switchTab(tab) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAdminStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/me');
|
||||
const data = await res.json();
|
||||
return data.loggedIn && data.isAdmin;
|
||||
} catch (e) {
|
||||
console.error('Check admin status failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//日志流控制
|
||||
let logInterval = null;
|
||||
let lastLogSize = 0;
|
||||
@@ -101,6 +137,17 @@ function escapeHtml(text) {
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
|
||||
if (res.status === 403) {
|
||||
document.getElementById('today-syncs').textContent = '无权限';
|
||||
document.getElementById('repo-count').textContent = '无权限';
|
||||
document.getElementById('error-count').textContent = '无权限';
|
||||
document.getElementById('uptime').textContent = '系统运行时间: 无权限';
|
||||
updateServiceStatus('unknown');
|
||||
loadHistory(); // 也会处理 403
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -124,6 +171,13 @@ async function loadDashboardData() {
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/history');
|
||||
|
||||
if (res.status === 403) {
|
||||
const tbody = document.getElementById('history-table');
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">无权限查看历史记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.history) {
|
||||
@@ -163,6 +217,15 @@ function updateServiceStatus(status) {
|
||||
运行中
|
||||
`;
|
||||
statusText.textContent = '运行中';
|
||||
} else if (status === 'unknown') {
|
||||
badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-slate-100 text-slate-600 border-slate-200';
|
||||
badge.innerHTML = `
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
状态未知
|
||||
`;
|
||||
statusText.textContent = '状态未知';
|
||||
} else {
|
||||
badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-slate-100 text-slate-600 border-slate-200';
|
||||
badge.innerHTML = `
|
||||
|
||||
@@ -107,10 +107,14 @@
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-slate-50 text-slate-900">
|
||||
<div class="flex">
|
||||
<div class="flex relative">
|
||||
<!-- 移动端遮罩 -->
|
||||
<div id="sidebar-overlay" onclick="toggleSidebar()"
|
||||
class="fixed inset-0 bg-gray-900/50 z-20 hidden transition-opacity opacity-0"></div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div
|
||||
class="w-64 bg-slate-900 text-slate-300 flex flex-col h-screen fixed left-0 top-0 border-r border-slate-800">
|
||||
<div id="sidebar"
|
||||
class="w-64 bg-slate-900 text-slate-300 flex flex-col h-screen fixed left-0 top-0 border-r border-slate-800 z-30 transform -translate-x-full md:translate-x-0 transition-transform duration-300 ease-in-out">
|
||||
<div class="h-16 flex items-center px-6 border-b border-slate-800 bg-slate-950">
|
||||
<svg class="w-6 h-6 text-indigo-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -147,6 +151,24 @@
|
||||
</svg>
|
||||
映射配置
|
||||
</button>
|
||||
<button onclick="switchTab('lark')" id="tab-lark"
|
||||
class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9">
|
||||
</path>
|
||||
</svg>
|
||||
飞书提醒
|
||||
</button>
|
||||
<button onclick="switchTab('guide')" id="tab-guide"
|
||||
class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253">
|
||||
</path>
|
||||
</svg>
|
||||
使用指南
|
||||
</button>
|
||||
<button onclick="switchTab('settings')" id="tab-settings"
|
||||
class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -158,15 +180,6 @@
|
||||
</svg>
|
||||
系统设置
|
||||
</button>
|
||||
<button onclick="switchTab('guide')" id="tab-guide"
|
||||
class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253">
|
||||
</path>
|
||||
</svg>
|
||||
使用指南
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-slate-800">
|
||||
@@ -178,15 +191,28 @@
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="flex-1 ml-64 p-8 overflow-y-auto">
|
||||
<main class="flex-1 ml-0 md:ml-64 p-4 md:p-8 overflow-y-auto w-full">
|
||||
<!-- 移动端顶部导航 -->
|
||||
<div class="md:hidden flex items-center justify-between mb-6">
|
||||
<button onclick="toggleSidebar()" class="p-2 -ml-2 text-slate-600 hover:bg-slate-100 rounded-md">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="font-bold text-slate-900">TaskBot控制台</span>
|
||||
<div class="w-8"></div> <!-- 占位保持居中 -->
|
||||
</div>
|
||||
|
||||
<!-- Dashboard 标签页 -->
|
||||
<div id="content-dashboard" class="tab-content">
|
||||
<header class="mb-8 flex justify-between items-end">
|
||||
<header class="mb-6 md:mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 tracking-tight">运维概览</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Jira-Gitea双向同步机器人控制中心</p>
|
||||
<h1 class="text-xl md:text-2xl font-bold text-slate-900 tracking-tight hidden md:block">运维概览
|
||||
</h1>
|
||||
<p class="text-sm text-slate-500 mt-1 hidden md:block">Jira-Gitea双向同步机器人控制中心</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center space-x-2 self-end md:self-auto">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-sm font-medium text-slate-600" id="uptime">加载中...</span>
|
||||
</div>
|
||||
@@ -261,9 +287,9 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<button onclick="controlBot('restart')"
|
||||
class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
class="flex items-center justify-center px-4 py-3 md:py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
|
||||
@@ -272,7 +298,7 @@
|
||||
重启服务
|
||||
</button>
|
||||
<button onclick="clearLogs()"
|
||||
class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
class="flex items-center justify-center px-4 py-3 md:py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
|
||||
@@ -281,7 +307,7 @@
|
||||
清空日志
|
||||
</button>
|
||||
<button onclick="refreshStatus()"
|
||||
class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
class="flex items-center justify-center px-4 py-3 md:py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
|
||||
@@ -414,11 +440,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 飞书提醒标签页 -->
|
||||
<div id="content-lark" class="tab-content hidden">
|
||||
<iframe src="/editor/larkReminder.html" class="w-full border-0 rounded-lg shadow-sm bg-white"
|
||||
style="height: calc(100vh - 100px);"></iframe>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/editor/dashboard-app.js"></script>
|
||||
<script>
|
||||
// 侧边栏切换逻辑
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
const isClosed = sidebar.classList.contains('-translate-x-full');
|
||||
|
||||
if (isClosed) {
|
||||
// 打开
|
||||
sidebar.classList.remove('-translate-x-full');
|
||||
overlay.classList.remove('hidden');
|
||||
setTimeout(() => overlay.classList.remove('opacity-0'), 10);
|
||||
} else {
|
||||
// 关闭
|
||||
sidebar.classList.add('-translate-x-full');
|
||||
overlay.classList.add('opacity-0');
|
||||
setTimeout(() => overlay.classList.add('hidden'), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖原始 switchTab 以在移动端自动关闭侧边栏
|
||||
const originalSwitchTab = window.switchTab;
|
||||
window.switchTab = function (tabId) {
|
||||
if (originalSwitchTab) originalSwitchTab(tabId);
|
||||
|
||||
// 如果是移动端(屏幕宽度小于 768px),点击后关闭侧边栏
|
||||
if (window.innerWidth < 768) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar.classList.contains('-translate-x-full')) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
//默认显示运维概览
|
||||
|
||||
@@ -1,61 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>错误 - TaskBot</title>
|
||||
<title>无权访问</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, sans-serif; }
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div class="max-w-md w-full px-6">
|
||||
<div class="text-center">
|
||||
<div class="mb-6">
|
||||
<svg class="w-24 h-24 mx-auto text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-6xl font-bold text-slate-900 mb-2" id="error-code">404</h1>
|
||||
<h2 class="text-xl font-medium text-slate-700 mb-4" id="error-title">页面未找到</h2>
|
||||
<p class="text-sm text-slate-500 mb-8" id="error-message">抱歉,您访问的页面不存在或已被移除。</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button onclick="window.parent.switchTab('dashboard')" class="block w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-3 px-6 rounded-lg transition-colors">
|
||||
返回控制台
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<p class="text-xs text-slate-400">TaskBot v1.1.0</p>
|
||||
|
||||
<body class="bg-gray-50 flex items-center justify-center h-screen">
|
||||
<div class="text-center p-8 bg-white rounded-lg shadow-lg max-w-md w-full border border-gray-100">
|
||||
<div class="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 text-red-500">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">访问被拒绝</h1>
|
||||
<p class="text-gray-500 mb-6">您没有权限访问此页面 (系统设置)。请联系管理员获取权限。</p>
|
||||
<button id="requestBtn" onclick="requestPermission()"
|
||||
class="px-5 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors font-medium flex items-center justify-center mx-auto">
|
||||
<span>点击发送权限请求</span>
|
||||
</button>
|
||||
<p id="msg" class="text-sm mt-4 h-5"></p>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code') || '404';
|
||||
const title = params.get('title') || '';
|
||||
const message = params.get('message') || '';
|
||||
|
||||
const errorMap = {
|
||||
'400': { title: '请求错误', message: '请求参数有误,请检查后重试。' },
|
||||
'401': { title: '未授权', message: '您没有权限访问此资源,请先登录。' },
|
||||
'403': { title: '禁止访问', message: '您没有权限访问此页面。' },
|
||||
'404': { title: '页面未找到', message: '抱歉,您访问的页面不存在或已被移除。' },
|
||||
'500': { title: '服务器错误', message: '服务器遇到了一些问题,请稍后再试。' },
|
||||
'503': { title: '服务不可用', message: '服务暂时不可用,请稍后再试。' },
|
||||
'施工中': { title: '图形化编辑映射功能暂不可用', message: '该功能预计2026/01/30下午上线' }
|
||||
};
|
||||
|
||||
const error = errorMap[code] || errorMap['404'];
|
||||
|
||||
document.getElementById('error-code').textContent = code;
|
||||
document.getElementById('error-title').textContent = title || error.title;
|
||||
document.getElementById('error-message').textContent = message || error.message;
|
||||
document.title = `${code} ${error.title} - TaskBot`;
|
||||
async function requestPermission() {
|
||||
const btn = document.getElementById('requestBtn');
|
||||
const msg = document.getElementById('msg');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
btn.innerHTML = '<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>发送中...';
|
||||
msg.textContent = '';
|
||||
msg.className = 'text-sm mt-4 h-5 text-gray-500';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/request-permission', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
msg.textContent = data.message;
|
||||
msg.className = 'text-sm mt-4 h-5 text-emerald-600 font-medium';
|
||||
btn.innerHTML = '已发送请求';
|
||||
} else {
|
||||
msg.textContent = data.error;
|
||||
msg.className = 'text-sm mt-4 h-5 text-rose-500';
|
||||
resetBtn(btn);
|
||||
}
|
||||
} catch (e) {
|
||||
msg.textContent = e.message;
|
||||
msg.className = 'text-sm mt-4 h-5 text-rose-500';
|
||||
resetBtn(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function resetBtn(btn) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '点击发送权限请求';
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
131
public/larkReminder.html
Normal file
131
public/larkReminder.html
Normal file
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>飞书提醒配置</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 text-gray-800 min-h-screen p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">飞书提醒配置</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">配置规则,当Gitea事件发生时自动发送飞书通知</p>
|
||||
</header>
|
||||
|
||||
<!-- 新建规则按钮 -->
|
||||
<div class="mb-6">
|
||||
<button onclick="showRuleModal()"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
|
||||
+ 新建规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 规则列表 -->
|
||||
<div class="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-700">已配置的规则</h3>
|
||||
</div>
|
||||
<div id="rulesList" class="divide-y divide-gray-200">
|
||||
<div class="p-8 text-center text-gray-400">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规则编辑模态窗口 -->
|
||||
<div id="ruleModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl font-bold mb-4" id="modalTitle">新建规则</h2>
|
||||
<form id="ruleForm" class="space-y-4">
|
||||
<input type="hidden" id="ruleId">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">规则名称</label>
|
||||
<input type="text" id="ruleName" required placeholder="如: 新Issue通知"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">触发事件</label>
|
||||
<select id="ruleEvent" required onchange="handleEventChange()"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<option value="">-- 选择事件 --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 动态过滤条件 -->
|
||||
<div id="filterField" class="hidden bg-gray-50 p-3 rounded border border-gray-200">
|
||||
<label id="filterLabel" class="block text-sm font-medium text-gray-700 mb-1">过滤条件</label>
|
||||
<input type="text" id="ruleFilterValue"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<input type="hidden" id="ruleFilterKey">
|
||||
<p id="filterHelp" class="text-xs text-gray-500 mt-1"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">消息渠道</label>
|
||||
<select id="ruleChannel" onchange="toggleChannelFields()"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<option value="group">群聊</option>
|
||||
<option value="private">私聊</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 私聊配置 -->
|
||||
<div id="privateFields" class="hidden">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">接收者(手机号或邮箱)</label>
|
||||
<input type="text" id="targetContact" placeholder="如: 13800138000 或 test@example.com"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- @ 用户(仅群聊显示) -->
|
||||
<div id="atUsersField">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">@ 用户(手机号/邮箱,逗号分隔)</label>
|
||||
<input type="text" id="atUsers" placeholder="13800138000,test@example.com 或 all"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<p class="text-xs text-gray-400 mt-1">填 all 表示 @所有人</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" id="ruleEnabled" checked class="mr-2">
|
||||
<span class="text-sm text-gray-700">启用规则</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="modalError"
|
||||
class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button onclick="testSend()"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition">
|
||||
测试发送
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="closeRuleModal()"
|
||||
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium transition">
|
||||
取消
|
||||
</button>
|
||||
<button onclick="saveRule()"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="larkReminder.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
285
public/larkReminder.js
Normal file
285
public/larkReminder.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 飞书提醒规则管理前端
|
||||
*/
|
||||
|
||||
let eventTypes = [];
|
||||
let currentRules = [];
|
||||
|
||||
//页面加载
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadEventTypes();
|
||||
await loadRules();
|
||||
});
|
||||
|
||||
//加载事件类型
|
||||
async function loadEventTypes() {
|
||||
try {
|
||||
const res = await fetch('/api/lark/events');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
eventTypes = data.events;
|
||||
const select = document.getElementById('ruleEvent');
|
||||
eventTypes.forEach(e => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = e.value;
|
||||
opt.text = e.label;
|
||||
select.add(opt);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load event types:', e);
|
||||
}
|
||||
}
|
||||
|
||||
//加载规则列表
|
||||
async function loadRules() {
|
||||
const container = document.getElementById('rulesList');
|
||||
try {
|
||||
const res = await fetch('/api/lark/rules');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
currentRules = data.rules;
|
||||
renderRules();
|
||||
} else {
|
||||
container.innerHTML = `<div class="p-4 text-red-600">${data.error}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="p-4 text-red-600">加载失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
//渲染规则列表
|
||||
function renderRules() {
|
||||
const container = document.getElementById('rulesList');
|
||||
|
||||
if (currentRules.length === 0) {
|
||||
container.innerHTML = `<div class="p-8 text-center text-gray-400">暂无规则,点击上方按钮新建</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = currentRules.map(rule => {
|
||||
const eventLabel = eventTypes.find(e => e.value === rule.event)?.label || rule.event;
|
||||
const channelLabel = rule.channel === 'group' ? '群聊' : '私聊';
|
||||
const statusClass = rule.enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500';
|
||||
const statusLabel = rule.enabled ? '启用' : '禁用';
|
||||
const targetInfo = rule.channel === 'private' && rule.target ? `→ ${rule.target}` : '';
|
||||
const filterInfo = rule.filterValue ?
|
||||
`<span class="mr-3 text-xs bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded">过滤: ${escapeHtml(rule.filterValue)}</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="p-4 flex items-center justify-between hover:bg-gray-50">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">${escapeHtml(rule.name)}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded ${statusClass}">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
<span class="mr-3">事件: ${eventLabel}</span>
|
||||
${filterInfo}
|
||||
<span class="mr-3">渠道: ${channelLabel} ${targetInfo}</span>
|
||||
${rule.atUsers?.length ? `<span>@ ${rule.atUsers.length}人</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editRule('${rule.id}')"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm px-2 py-1">编辑</button>
|
||||
<button onclick="deleteRule('${rule.id}')"
|
||||
class="text-red-600 hover:text-red-800 text-sm px-2 py-1">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
//显示规则编辑窗口
|
||||
function showRuleModal(rule = null) {
|
||||
document.getElementById('modalTitle').textContent = rule ? '编辑规则' : '新建规则';
|
||||
document.getElementById('ruleId').value = rule?.id || '';
|
||||
document.getElementById('ruleName').value = rule?.name || '';
|
||||
document.getElementById('ruleEvent').value = rule?.event || '';
|
||||
document.getElementById('ruleChannel').value = rule?.channel || 'group';
|
||||
document.getElementById('targetContact').value = rule?.target || '';
|
||||
document.getElementById('atUsers').value = rule?.atUsers?.join(',') || '';
|
||||
document.getElementById('ruleEnabled').checked = rule?.enabled !== false;
|
||||
document.getElementById('ruleFilterValue').value = rule?.filterValue || '';
|
||||
//不需要设置 filterKey,因为它由 event 决定
|
||||
|
||||
toggleChannelFields();
|
||||
handleEventChange(); // 更新过滤字段显示
|
||||
hideModalError();
|
||||
document.getElementById('ruleModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
//关闭模态窗口
|
||||
function closeRuleModal() {
|
||||
document.getElementById('ruleModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
//切换渠道相关字段
|
||||
function toggleChannelFields() {
|
||||
const channel = document.getElementById('ruleChannel').value;
|
||||
document.getElementById('privateFields').classList.toggle('hidden', channel !== 'private');
|
||||
document.getElementById('atUsersField').classList.toggle('hidden', channel !== 'group');
|
||||
}
|
||||
|
||||
//处理事件变更,显示/隐藏过滤字段
|
||||
function handleEventChange() {
|
||||
const event = document.getElementById('ruleEvent').value;
|
||||
const container = document.getElementById('filterField');
|
||||
const label = document.getElementById('filterLabel');
|
||||
const input = document.getElementById('ruleFilterValue');
|
||||
const keyInput = document.getElementById('ruleFilterKey');
|
||||
const help = document.getElementById('filterHelp');
|
||||
|
||||
// 默认隐藏
|
||||
container.classList.add('hidden');
|
||||
keyInput.value = '';
|
||||
|
||||
if (event === 'issue.assigned') {
|
||||
container.classList.remove('hidden');
|
||||
label.textContent = '指定被指派人 (用户名)';
|
||||
input.placeholder = '例如: zhangsan';
|
||||
keyInput.value = 'assignee';
|
||||
help.textContent = '留空则通知所有指派事件,填写用户名则仅当指派给该用户时通知';
|
||||
} else if (event === 'issue.label_updated') {
|
||||
container.classList.remove('hidden');
|
||||
label.textContent = '指定标签 (名称)';
|
||||
input.placeholder = '例如: bug';
|
||||
keyInput.value = 'label';
|
||||
help.textContent = '留空则通知所有标签变更,填写标签名则仅当该标签变更时通知';
|
||||
}
|
||||
}
|
||||
|
||||
//保存规则
|
||||
async function saveRule() {
|
||||
const id = document.getElementById('ruleId').value;
|
||||
const channel = document.getElementById('ruleChannel').value;
|
||||
const filterKey = document.getElementById('ruleFilterKey').value;
|
||||
const filterValue = document.getElementById('ruleFilterValue').value.trim();
|
||||
|
||||
const rule = {
|
||||
name: document.getElementById('ruleName').value.trim(),
|
||||
event: document.getElementById('ruleEvent').value,
|
||||
channel,
|
||||
enabled: document.getElementById('ruleEnabled').checked,
|
||||
atUsers: [],
|
||||
filterKey: filterKey || null,
|
||||
filterValue: filterValue || null
|
||||
};
|
||||
|
||||
if (channel === 'private') {
|
||||
rule.target = document.getElementById('targetContact').value.trim();
|
||||
} else {
|
||||
rule.atUsers = document.getElementById('atUsers').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
if (!rule.name || !rule.event) {
|
||||
showModalError('请填写规则名称和选择事件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel === 'private' && !rule.target) {
|
||||
showModalError('请填写接收者手机号或邮箱');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = id ? `/api/lark/rules/${id}` : '/api/lark/rules';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rule)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
closeRuleModal();
|
||||
await loadRules();
|
||||
} else {
|
||||
showModalError(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showModalError('保存失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
//编辑规则
|
||||
function editRule(id) {
|
||||
const rule = currentRules.find(r => r.id === id);
|
||||
if (rule) showRuleModal(rule);
|
||||
}
|
||||
|
||||
//删除规则
|
||||
async function deleteRule(id) {
|
||||
if (!confirm('确定要删除这条规则吗?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/lark/rules/${id}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await loadRules();
|
||||
} else {
|
||||
alert('删除失败: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
//测试发送
|
||||
async function testSend() {
|
||||
const channel = document.getElementById('ruleChannel').value;
|
||||
|
||||
const payload = {
|
||||
channel,
|
||||
atUsers: [],
|
||||
message: '这是一条测试消息\n来自 Gitea-Jira 同步机器人'
|
||||
};
|
||||
|
||||
if (channel === 'private') {
|
||||
payload.target = document.getElementById('targetContact').value.trim();
|
||||
if (!payload.target) {
|
||||
showModalError('请先填写接收者手机号或邮箱');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
payload.atUsers = document.getElementById('atUsers').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/lark/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert('测试消息发送成功!请检查飞书。');
|
||||
} else {
|
||||
showModalError('发送失败: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showModalError('发送失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
//显示/隐藏错误
|
||||
function showModalError(msg) {
|
||||
const el = document.getElementById('modalError');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideModalError() {
|
||||
document.getElementById('modalError').classList.add('hidden');
|
||||
}
|
||||
|
||||
//HTML 转义
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
Reference in New Issue
Block a user