Procházet zdrojové kódy

feat(scheduler): 支持编辑计划并优化样式

- 新增计划更新接口与前端编辑逻辑
- 重构标签函数,增加禁用状态视觉反馈
yangkaixiang před 6 dny
rodič
revize
6482a0aed6
3 změnil soubory, kde provedl 87 přidání a 15 odebrání
  1. 7 0
      public/styles.css
  2. 35 6
      src/index.js
  3. 45 9
      views/schedules.ejs

+ 7 - 0
public/styles.css

@@ -228,6 +228,13 @@ dialog:focus {
 .state-on { color: #30d158; }
 .state-off { color: #ff453a; }
 .utility-card h2, .utility-card h3 { margin-top: 0; }
+.schedule-status { font-weight: 600; }
+.schedule-status-enabled { color: #30d158; }
+.schedule-status-disabled { color: #ff453a; }
+.schedule-disabled .schedule-content {
+  color: var(--muted);
+  text-decoration: line-through;
+}
 .holiday-import-grid { align-items: stretch; }
 .holiday-card {
   display: grid;

+ 35 - 6
src/index.js

@@ -9,6 +9,11 @@ const { cleanupLogs, startScheduler } = require('./scheduler');
 
 const app = express();
 const mqttService = new MqttService();
+const weekdayLabel = (value) => ['一', '二', '三', '四', '五', '六', '日'][Number(value) - 1] || '';
+const actionLabel = (action) => ({ open: '开灯', close: '关灯', query: '查询', state_change: '状态变化' }[action] || action);
+const targetLabel = (channel) => Number(channel) === 0 ? '全部灯' : `灯${channel}`;
+const repeatLabel = (type) => ({ daily: '每天', workday: '工作日', holiday: '法定节假日', custom: '自定义' }[type] || type);
+const holidayTypeLabel = (type) => ({ holiday: '节假日', adjusted_workday: '调休' }[type] || type);
 
 app.set('view engine', 'ejs');
 app.set('views', path.join(__dirname, '..', 'views'));
@@ -25,11 +30,11 @@ app.use((req, res, next) => {
 });
 app.locals.dayjs = dayjs;
 app.locals.getMqttStatus = () => mqttService.getStatus();
-app.locals.weekdayLabel = (value) => ['一', '二', '三', '四', '五', '六', '日'][Number(value) - 1] || '';
-app.locals.actionLabel = (action) => ({ open: '开灯', close: '关灯', query: '查询', state_change: '状态变化' }[action] || action);
-app.locals.targetLabel = (channel) => Number(channel) === 0 ? '全部灯' : `灯${channel}`;
-app.locals.repeatLabel = (type) => ({ daily: '每天', workday: '工作日', holiday: '法定节假日', custom: '自定义' }[type] || type);
-app.locals.holidayTypeLabel = (type) => ({ holiday: '节假日', adjusted_workday: '调休' }[type] || type);
+app.locals.weekdayLabel = weekdayLabel;
+app.locals.actionLabel = actionLabel;
+app.locals.targetLabel = targetLabel;
+app.locals.repeatLabel = repeatLabel;
+app.locals.holidayTypeLabel = holidayTypeLabel;
 
 app.get('/', (req, res) => {
   const states = db.prepare('SELECT * FROM light_states ORDER BY channel').all();
@@ -57,7 +62,7 @@ app.get('/api/status', (req, res) => {
     },
     nextOccurrence: nextOccurrence ? {
       action: nextOccurrence.action,
-      actionLabel: res.locals.actionLabel(nextOccurrence.action),
+      actionLabel: actionLabel(nextOccurrence.action),
       at: nextOccurrence.at
     } : null
   });
@@ -115,6 +120,30 @@ app.post('/schedules', (req, res) => {
   res.redirect('/schedules?message=' + encodeURIComponent('计划已创建。'));
 });
 
+app.post('/schedules/:id/update', (req, res) => {
+  if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(req.body.time || '')) {
+    res.redirect('/schedules?error=' + encodeURIComponent('时间必须使用 24 小时制 HH:mm,例如 09:00 或 18:30。'));
+    return;
+  }
+
+  const weekdays = Array.isArray(req.body.weekdays) ? req.body.weekdays.join(',') : (req.body.weekdays || '');
+  db.prepare(`
+    UPDATE schedules
+    SET name = ?, target_channel = ?, action = ?, time = ?, repeat_type = ?, weekdays = ?, updated_at = ?
+    WHERE id = ?
+  `).run(
+    req.body.name,
+    Number(req.body.target_channel || 0),
+    req.body.action,
+    req.body.time,
+    req.body.repeat_type,
+    weekdays,
+    dayjs().toISOString(),
+    req.params.id
+  );
+  res.redirect('/schedules?message=' + encodeURIComponent('计划已更新。'));
+});
+
 app.post('/schedules/:id/toggle', (req, res) => {
   db.prepare('UPDATE schedules SET is_enabled = CASE is_enabled WHEN 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id = ?')
     .run(dayjs().toISOString(), req.params.id);

+ 45 - 9
views/schedules.ejs

@@ -45,12 +45,15 @@
     <section class="tile-light compact">
       <div class="card-grid">
         <% schedules.forEach((item) => { %>
-          <article class="utility-card">
-            <p class="eyebrow"><%= item.is_enabled ? '启用' : '停用' %></p>
-            <h3><%= item.name %></h3>
-            <p><%= item.time %> · <%= actionLabel(item.action) %> · <%= targetLabel(item.target_channel) %></p>
-            <p><%= repeatLabel(item.repeat_type) %><% if (item.weekdays) { %>:<%= item.weekdays.split(',').map(weekdayLabel).join('、') %><% } %></p>
+          <article class="utility-card schedule-card <%= item.is_enabled ? '' : 'schedule-disabled' %>" data-schedule-card data-id="<%= item.id %>" data-name="<%= item.name %>" data-target-channel="<%= item.target_channel %>" data-action="<%= item.action %>" data-time="<%= item.time %>" data-repeat-type="<%= item.repeat_type %>" data-weekdays="<%= item.weekdays %>">
+            <p class="eyebrow schedule-status <%= item.is_enabled ? 'schedule-status-enabled' : 'schedule-status-disabled' %>"><%= item.is_enabled ? '启用' : '停用' %></p>
+            <div class="schedule-content">
+              <h3><%= item.name %></h3>
+              <p><%= item.time %> · <%= actionLabel(item.action) %> · <%= targetLabel(item.target_channel) %></p>
+              <p><%= repeatLabel(item.repeat_type) %><% if (item.weekdays) { %>:<%= item.weekdays.split(',').map(weekdayLabel).join('、') %><% } %></p>
+            </div>
             <div class="inline-actions">
+              <button class="button-secondary small" type="button" data-edit-schedule>修改</button>
               <form method="post" action="/schedules/<%= item.id %>/toggle"><button class="button-secondary small"><%= item.is_enabled ? '停用' : '启用' %></button></form>
               <form method="post" action="/schedules/<%= item.id %>/delete"><button class="button-secondary small">删除</button></form>
             </div>
@@ -64,11 +67,11 @@
     <div class="modal-header">
       <div>
         <p class="eyebrow">Schedule</p>
-        <h2>添加计划</h2>
+        <h2 id="scheduleDialogTitle">添加计划</h2>
       </div>
       <button class="modal-close" type="button" id="closeScheduleDialog" aria-label="关闭">×</button>
     </div>
-    <form method="post" action="/schedules" class="form-grid modal-form">
+    <form method="post" action="/schedules" class="form-grid modal-form" id="scheduleForm">
       <label>名称<input name="name" required placeholder="例如 工作日开灯"></label>
       <label>目标
         <select name="target_channel">
@@ -100,7 +103,7 @@
       </div>
       <div class="modal-actions">
         <button class="button-secondary" type="button" id="cancelScheduleDialog">取消</button>
-        <button class="button-primary">创建计划</button>
+        <button class="button-primary" id="scheduleSubmitButton">创建计划</button>
       </div>
     </form>
   </dialog>
@@ -110,6 +113,9 @@
     const openScheduleDialog = document.getElementById('openScheduleDialog');
     const closeScheduleDialog = document.getElementById('closeScheduleDialog');
     const cancelScheduleDialog = document.getElementById('cancelScheduleDialog');
+    const scheduleDialogTitle = document.getElementById('scheduleDialogTitle');
+    const scheduleForm = document.getElementById('scheduleForm');
+    const scheduleSubmitButton = document.getElementById('scheduleSubmitButton');
     const repeatType = document.getElementById('repeatType');
     const weekdayPicker = document.getElementById('weekdayPicker');
 
@@ -123,7 +129,37 @@
       }
     }
 
-    openScheduleDialog.addEventListener('click', () => scheduleDialog.showModal());
+    function openCreateScheduleDialog() {
+      scheduleDialogTitle.textContent = '添加计划';
+      scheduleSubmitButton.textContent = '创建计划';
+      scheduleForm.action = '/schedules';
+      scheduleForm.reset();
+      syncWeekdayPicker();
+      scheduleDialog.showModal();
+    }
+
+    function openEditScheduleDialog(card) {
+      scheduleDialogTitle.textContent = '修改计划';
+      scheduleSubmitButton.textContent = '保存修改';
+      scheduleForm.action = `/schedules/${card.dataset.id}/update`;
+      scheduleForm.elements.name.value = card.dataset.name || '';
+      scheduleForm.elements.target_channel.value = card.dataset.targetChannel || '0';
+      scheduleForm.elements.action.value = card.dataset.action || 'open';
+      scheduleForm.elements.time.value = card.dataset.time || '';
+      scheduleForm.elements.repeat_type.value = card.dataset.repeatType || 'daily';
+
+      const weekdays = (card.dataset.weekdays || '').split(',').filter(Boolean);
+      weekdayPicker.querySelectorAll('input[type="checkbox"]').forEach((input) => {
+        input.checked = weekdays.includes(input.value);
+      });
+      syncWeekdayPicker();
+      scheduleDialog.showModal();
+    }
+
+    openScheduleDialog.addEventListener('click', openCreateScheduleDialog);
+    document.querySelectorAll('[data-edit-schedule]').forEach((button) => {
+      button.addEventListener('click', () => openEditScheduleDialog(button.closest('[data-schedule-card]')));
+    });
     closeScheduleDialog.addEventListener('click', () => scheduleDialog.close());
     cancelScheduleDialog.addEventListener('click', () => scheduleDialog.close());
     scheduleDialog.addEventListener('click', (event) => {