@@ -29,6 +29,7 @@
import { Hono } from 'hono' ;
import type { Bindings } from '../types' ;
import { kbdbBase } from './kbdb-proxy' ;
import { validateConsoleSession } from './console-auth' ;
export const consoleDashboardRouter = new Hono < { Bindings : Bindings } > ( ) ;
@@ -164,97 +165,188 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
} ) ;
} ) ;
// GET /console/inbox-data — 收件匣清單(Mira Console 頁 7)。**需 console session**( Bearer):
// dashboard-data 只吐計數可免登入;這裡吐訊息原文(leo 丟給 Telegram bot 的指令內容)屬機敏,鎖登入。
// 資料源同 dashboard-data 的 inbox entries( entry_type=inbox, content JSON {"text","status","result"?})。
consoleDashboardRouter . get ( '/console/inbox-data' , async ( c ) = > {
const ok = await validateConsoleSession ( c . env , c . req . header ( 'authorization' ) ) ;
if ( ! ok ) return c . json ( { error : '需要登入(console session) ' } , 401 ) ;
const tenant = c . env . CONSOLE_TENANT || 'leo' ;
const entries = await fetchEntries ( c . env , tenant , 'inbox' , 200 ) ;
const items = entries . map ( ( e ) = > {
const j = parseJsonContent ( e ) ;
return {
id : e.id ,
text : typeof j ? . text === 'string' ? ( j . text as string ) : ( e . content ? ? '' ) ,
status : j && j . status === 'done' ? 'done' : 'new' ,
result : typeof j ? . result === 'string' ? ( j . result as string ) : '' ,
at : e.created_at ,
} ;
} ) ;
return c . json ( {
items ,
new_count : items.filter ( ( i ) = > i . status !== 'done' ) . length ,
total : items.length ,
} ) ;
} ) ;
function renderDashboardHtml ( ) : string {
return ` <!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>arcrun 駕駛艙</title>
<title>Mira 駕駛艙</title>
<style>
:root { color-scheme: dark; }
/* Mira Console 定稿視覺(紙感暖黑「2a」,Mira Style Guide 2026-07-04):
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀 #e8b45a 強調、呼吸狀態球嵌單字。 */
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "PingFang TC", "Noto Sans TC", sans-serif; background: #0f1115 ; color: #e6e6e6; }
main { padding: 14px; display: grid; gap: 14px; max-width: 560px; margin: 0 auto ; }
.card { background: #161922; border: 1px solid #262a33; border-radius: 12px; padding: 16px ; }
.light-row { display: flex; align-items: center; gap: 12 px; }
.light-emoji { font-size: 44px; line-height: 1 ; }
.light-word { font-size: 20 px; font-weight: 700 ; }
.beat { color: #9aa0aa; font-size: 13 px; margin-top: 8px ; }
.bar-wrap { margin-top : 12 px; }
.bar-label { font-size: 12px; color: #9aa0aa; margin-bottom: 4px; display: flex; justify-content: space-between ; }
.bar { height: 10px; background: #0f1115; border: 1px solid #262a33; border-radius: 999px; overflow: hidden ; }
.bar > i { display: block; h eight: 1 00% ; background: #3a5bfd; border-radius: 999px; transition: width .4s ; }
.subhead { font-size: 12px; color: #9aa0aa; margin: 12px 0 4px; text-transform: uppercase; letter-spacing: .04em ; }
.subhead:first-child { margin-top: 0 ; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
li { font-size: 14px; line-height: 1.5; }
.muted { color: #6b7280; font-size: 13px; }
.inbox { margin-top: 12px; font-size: 13px; color: #9aa0aa ; }
.err { color: #f06565; font-size : 13 px; }
.stamp { text-align: center; color: #4b5563; font-size: 11px ; }
html, body { margin: 0; background: repeating-linear-gradient(0deg,#191410 0px,#191410 3px,#1b1611 3px,#1b1611 4px) ; color: #ede4d3;
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased ; }
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif ; }
main { max-width: 560px; margin: 0 auto; padding: 0 20px 40 px; }
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(232,180,90,.4); display: flex; justify-content: space-between; align-items: baseline ; }
.pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23 px; letter-spacing: .2em ; }
.pagehead .title small { font-size: 14 px; letter-spacing: .3em; color: rgba(237,228,211,.5) ; }
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size : 14 px; color: rgba(237,228,211,.55); }
.orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px ; }
.orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center ; }
.orb span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 30px; font-w eight: 6 00; color: rgba(10,20,14,.85); text-shadow: 0 1px 0 rgba(255,255,255,.25) ; }
.orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600 ; }
.orb-sub { margin-top: 5px; font-size: 15px; color: rgba(237,228,211,.6); line-height: 1.55 ; }
@keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(63,190,120,.35); } 50% { box-shadow: 0 0 42px 14px rgba(63,190,120,.55); } }
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(232,180,90,.35); } 50% { box-shadow: 0 0 42px 14px rgba(232,180,90,.6); } }
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(217,95,76,.4); } 50% { box-shadow: 0 0 44px 16px rgba(217,95,76,.65); } }
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px ; }
.brick { padding: 16px; border-radius : 12 px; }
.brick.amber { background: rgba(232,180,90,.07); border: 1px solid rgba(232,180,90,.22) ; }
.brick.plain { background: rgba(237,228,211,.04); border: 1px solid rgba(237,228,211,.14); }
.brick .lbl { font-size: 13.5px; color: rgba(237,228,211,.55); margin-bottom: 6px; }
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: #e8b45a; }
.brick .num small { font-size: 15px; color: rgba(237,228,211,.5); }
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.08); }
.bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }
.wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(63,190,120,.3); background: rgba(63,190,120,.05); }
.wait-box.has { border-color: rgba(232,180,90,.45); background: rgba(232,180,90,.05); }
.wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(237,228,211,.6); margin-bottom: 10px; text-align: center; }
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: #7fe0a8; letter-spacing: .08em; text-align: center; }
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(232,180,90,.1); border: 1px solid rgba(232,180,90,.3); font-size: 16px; line-height: 1.5; }
.wait-item .dm { color: #e8b45a; font-size: 17px; flex: none; }
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(237,228,211,.6); }
.subhead .m { font-size: 13px; color: rgba(237,228,211,.4); }
ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
ul.route li { display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; border-radius: 11px; background: rgba(237,228,211,.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }
ul.route li.doing { background: rgba(232,180,90,.09); border-color: rgba(232,180,90,.3); }
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
ul.route li.done { color: rgba(237,228,211,.65); }
ul.route li.done .ic { color: #7fe0a8; }
ul.route li.doing .ic { color: #e8b45a; }
ul.route li.todo { color: rgba(237,228,211,.6); }
ul.route li.todo .ic { color: rgba(237,228,211,.35); }
ul.route li.blocked .ic { color: #e58575; }
.muted { color: rgba(237,228,211,.45); font-size: 14px; }
.err { color: #e58575; font-size: 14px; }
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(237,228,211,.35); line-height: 1.8; }
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(232,180,90,.75); text-decoration: none; margin-top: 6px; }
</style>
</head>
<body>
<main>
<div class="card" id="card-light ">
<div class="light-row"><span class="light-emoji" id="light-emoji">⏳</span><span class="light-word" id="light-word">載入中</span ></div>
<div class="beat" id="beat-line "></div>
<div class="bar-wrap" >
<div class="bar-label"><span>今日完成度</span><span id="bar-num"></span></div >
<div class="bar"><i id="bar-fill" style="width:0%"></i ></div>
<div class="pagehead ">
<div class="title serif">Mira<small> 駕駛艙</small ></div>
<div class="date serif" id="date-str "></div>
</div >
<div class="orb-row" >
<div class="orb" id="orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="orb-char">…</span ></div>
<div>
<div class="orb-title" id="orb-title">載入中</div>
<div class="orb-sub" id="orb-sub"></div>
</div>
</div>
<div class="card ">
<div class="subhead">今日路線</div >
<ul id="today-list"><li class="muted">載入中...</li></ul >
<div class="subhead" id="week-head" style="display:none">本週 </div>
<ul id="week-list"></ul >
<div class="subhead" style="margin-top:14px">等你的事< /div>
<ul id="wait-list"></ul >
<div class="inbox" id="inbox-line"> </div>
<div class="bricks ">
<div class="brick amber" >
<div class="lbl">今日完成</div >
<div class="num"><span id="done-n">– </span><small> / <span id="total-n">– </span> 件</small> </div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div >
</div>
<div class="brick plain" >
<div class="lbl">收件匣未處理 </div>
<div class="num"><span id="inbox-n">– </span><small> 條</small></div>
<div class="lbl" style="margin:10px 0 0">來自 Telegram</div>
</div>
</div>
<div class="stamp" id="stamp"></div >
<div class="wait-box" id="wait-box" >
<div class="wait-head">等你的事</div>
<div id="wait-body" class="wait-none">載入中…</div>
</div>
<div class="subhead"><span class="t">今日路線</span><span class="m">狀態即時同步</span></div>
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
<ul class="route" id="week-list"></ul>
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
<a class="enter" href="/console">進入完整控制台 › </a>
</main>
<script>
(function () {
const $ = (id) => document.getElementById(id);
const LIGHT = { green: ['🟢', '系統運轉中'], yellow: ['🟡', '落後趕工中'], red: ['🔴', '卡住'] };
const STATUS_EMOJI = { done : '✅ ', doing: '🔄', todo: '⬜', blocked: '⛔ ' };
const LIGHT = {
green: { ch : '安 ', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen ' },
yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },
red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }
};
const ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function taskLine(t) {
return '<li>' + (STATUS_EMOJI[t.status] || '⚠️') + ' ' + esc(t.title) + '</li> ';
const cls = ICONS[t.status] ? t.status : 'blocked ';
const ic = ICONS[t.status] || '●';
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
}
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
const now = new Date();
$ ('date-str').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
async function load() {
try {
const res = await fetch('/console/dashboard-data');
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
const [emoji, word] = LIGHT[d.light] || ['⚪', d.light] ;
$ ('light-emoji').textContent = emoji ;
$ ('light-word').textContent = wor d;
$ ('beat-line').textContent = d.last_beat
? '最後心跳 ' + d.last_beat.actor + ' ' + d.last_beat.ago_minutes + ' 分鐘前'
: '尚無心跳' ;
const cfg = LIGHT[d.light] || LIGHT.green ;
const orb = $ ('orb') ;
orb.style.background = cfg.gra d;
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$ ('orb-char').textContent = cfg.ch;
$ ('orb-title').textContent = cfg.title ;
$ ('orb-sub').textContent = d.last_beat
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
: '尚無心跳資料';
const done = d.today_done || 0, total = d.today_total || 0;
$ ('bar-num ').textContent = done + ' / ' + total;
$ ('done-n ').textContent = done; $ ('total-n').textContent = total;
$ ('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
$ ('inbox-n').textContent = d.inbox_new || 0;
const wb = $ ('wait-box'), body = $ ('wait-body');
if (d.waiting && d.waiting.length) {
wb.classList.add('has');
body.className = '';
body.innerHTML = d.waiting.map((w) => '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>').join('');
} else {
wb.classList.remove('has');
body.className = 'wait-none';
body.textContent = '無,你不用做任何事';
}
const today = (d.tasks || []).filter((t) => t.scope === 'today');
const week = (d.tasks || []).filter((t) => t.scope === 'week');
$ ('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="muted">今日無排定項目</li>';
$ ('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class=" muted">今日無排定項目</span></ li>';
$ ('week-head').style.display = week.length ? '' : 'none';
$ ('week-list').innerHTML = week.map(taskLine).join('');
$ ('wait-list ').innerHTML = (d.waiting && d.waiting.length)
? d.waiting.map((w) => '<li>🙋 ' + esc(w.title) + '</li>').join('')
: '<li class="muted">無,你不用做任何事</li>';
$ ('inbox-line').textContent = '📥 收件匣未處理:' + (d.inbox_new || 0) + ' 件';
$ ('stamp').textContent = '更新於 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
$ ('stamp ').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
} catch (e) {
$ ('light-emoji ').textContent = '⚪ ';
$ ('light-word ').textContent = '讀不到狀態';
$ ('beat-line ').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
$ ('orb-char ').textContent = '? ';
$ ('orb-title ').textContent = '讀不到狀態';
$ ('orb-sub ').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
}
}
load();