feat: 飞书单点登录和通知功能

This commit is contained in:
2026-02-03 11:38:16 +08:00
parent c657fbe01a
commit 78ebc67e2a
18 changed files with 2136 additions and 105 deletions

View File

@@ -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 = `

View File

@@ -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', () => {
//默认显示运维概览

View File

@@ -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
View 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
View 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;
}