feat(update): integrate official site update flow

This commit is contained in:
晴天
2026-06-06 13:59:52 +08:00
parent 38934fe754
commit f340b64028
35 changed files with 4074 additions and 2230 deletions

View File

@@ -234,6 +234,7 @@ export function renderSidebar(el) {
const isDark = getTheme() === 'dark'
const sunIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
const moonIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>'
const bellIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
const langCode = getLang()
const langs = getAvailableLangs()
@@ -254,19 +255,22 @@ export function renderSidebar(el) {
html += `
<div class="sidebar-footer">
<div class="nav-item" id="btn-theme-toggle">
${isDark ? sunIcon : moonIcon}
<span>${isDark ? t('sidebar.themeLight') : t('sidebar.themeDark')}</span>
</div>
<div class="lang-switcher" id="lang-switcher">
<button class="nav-item lang-trigger" id="btn-lang-toggle">
${globeIcon}
<span>${currentLang.label}</span>
<svg class="lang-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M18 15l-6-6-6 6"/></svg>
<div class="sidebar-tools" aria-label="ClawPanel tools">
<button class="sidebar-tool-btn site-message-trigger" type="button" title="${t('siteMessages.title')}" aria-label="${t('siteMessages.title')}">
${bellIcon}
<span class="site-message-tool-badge" aria-hidden="true"></span>
</button>
<div class="lang-dropdown" id="lang-dropdown">
${langs.length > 4 ? '<div class="lang-search-wrap"><input class="lang-search" id="lang-search" type="text" placeholder="Search..." autocomplete="off"></div>' : ''}
<div class="lang-options" id="lang-options">${langOptions}</div>
<button class="sidebar-tool-btn" id="btn-theme-toggle" type="button" title="${isDark ? t('sidebar.themeLight') : t('sidebar.themeDark')}" aria-label="${isDark ? t('sidebar.themeLight') : t('sidebar.themeDark')}">
${isDark ? sunIcon : moonIcon}
</button>
<div class="lang-switcher" id="lang-switcher">
<button class="sidebar-tool-btn lang-trigger" id="btn-lang-toggle" type="button" title="${currentLang.label}" aria-label="${currentLang.label}">
${globeIcon}
</button>
<div class="lang-dropdown" id="lang-dropdown">
${langs.length > 4 ? '<div class="lang-search-wrap"><input class="lang-search" id="lang-search" type="text" placeholder="Search..." autocomplete="off"></div>' : ''}
<div class="lang-options" id="lang-options">${langOptions}</div>
</div>
</div>
</div>
<div class="sidebar-meta">
@@ -277,6 +281,7 @@ export function renderSidebar(el) {
`
el.innerHTML = html
window.dispatchEvent(new CustomEvent('clawpanel:site-message-launcher-mounted'))
// 应用折叠态(桌面端)
_setDesktopSidebarCollapsed(collapsed)
@@ -513,4 +518,3 @@ function _filterLangOptions(query) {
opt.style.display = (label.includes(q) || code.includes(q)) ? '' : 'none'
})
}

View File

@@ -0,0 +1,420 @@
import { t } from '../lib/i18n.js'
const ICON_BELL = '<svg viewBox="0 0 24 24"><path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
const ICON_SEND = '<svg viewBox="0 0 24 24"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>'
const ICON_X = '<svg viewBox="0 0 24 24"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>'
const DISMISS_PREFIX = 'clawpanel_announcement_dismissed_'
const TODAY_CLOSE_KEY = 'clawpanel_site_message_closed_today'
const LAUNCHER_SELECTOR = '.site-message-trigger'
let _fetcher = null
let _overlay = null
let _messages = { notifications: [], announcements: [] }
let _activeTab = 'notifications'
let _launcherBound = false
export function initSiteMessageCenter({ fetcher } = {}) {
_fetcher = typeof fetcher === 'function' ? fetcher : null
bindLaunchers()
updateLauncherBadge()
window.addEventListener('clawpanel:site-message-launcher-mounted', updateLauncherBadge)
window.addEventListener('clawpanel:show-site-messages', async (event) => {
const detail = event.detail || {}
const payload = detail.payload || (detail.notifications || detail.announcements ? detail : null)
if (payload && typeof payload === 'object' && (payload.notifications || payload.announcements)) {
setSiteMessageCenterPayload(payload)
openSiteMessageCenter({ force: true, tab: detail.tab })
return
}
if (detail.tab) {
openSiteMessageCenter({ force: true, tab: detail.tab })
return
}
await refreshSiteMessageCenter({ auto: false, forceOpen: true })
})
}
export async function refreshSiteMessageCenter({ auto = false, forceOpen = false, tab = null } = {}) {
if (!_fetcher) return
try {
const payload = await _fetcher()
setSiteMessageCenterPayload(payload)
if (forceOpen) {
openSiteMessageCenter({ force: true, tab })
} else if (auto && shouldAutoOpen()) {
openSiteMessageCenter({ tab })
}
} catch {
if (forceOpen) openSiteMessageCenter({ force: true, tab })
}
}
export function setSiteMessageCenterPayload(payload) {
_messages = normalizePayload(payload)
if (!_messages[_activeTab]?.length) {
_activeTab = _messages.notifications.length ? 'notifications' : 'announcements'
}
updateLauncherBadge()
}
export function normalizeSiteMessagePayload(payload = {}) {
return normalizePayload(payload)
}
export function openSiteMessageCenter({ force = false, tab = null } = {}) {
const visible = getVisibleMessages()
if (!force && !visible.notifications.length && !visible.announcements.length) return
_activeTab = tab || (visible.notifications.length ? 'notifications' : 'announcements')
renderModal()
}
function bindLaunchers() {
if (_launcherBound) return
_launcherBound = true
document.addEventListener('click', (event) => {
const btn = event.target.closest(LAUNCHER_SELECTOR)
if (!btn) return
event.preventDefault()
refreshSiteMessageCenter({ forceOpen: true }).catch(() => openSiteMessageCenter({ force: true }))
})
}
function updateLauncherBadge() {
const count = getVisibleMessages().notifications.length + getVisibleMessages().announcements.length
document.querySelectorAll(LAUNCHER_SELECTOR).forEach((launcher) => {
launcher.classList.toggle('has-unread', count > 0)
const badge = launcher.querySelector('.site-message-tool-badge, .site-message-fab-badge')
if (badge) badge.textContent = count > 9 ? '9+' : String(count || '')
})
}
function renderModal() {
const old = document.getElementById('site-message-overlay')
if (old) old.remove()
const visible = getVisibleMessages()
const overlay = document.createElement('div')
overlay.id = 'site-message-overlay'
overlay.className = 'site-message-overlay'
overlay.setAttribute('role', 'dialog')
overlay.setAttribute('aria-modal', 'true')
overlay.innerHTML = `
<section class="site-message-modal" tabindex="-1">
<header class="site-message-header">
<div class="site-message-title">
<span class="site-message-title-icon" aria-hidden="true">${ICON_BELL}</span>
<div>
<h2>${t('siteMessages.title')}</h2>
<p>${formatSummary(visible)}</p>
</div>
</div>
<div class="site-message-tabs" role="tablist">
${renderTab('notifications', t('siteMessages.notifications'), ICON_BELL, visible.notifications.length)}
${renderTab('announcements', t('siteMessages.announcements'), ICON_SEND, visible.announcements.length)}
</div>
<button class="site-message-close" type="button" title="${t('common.close')}">${ICON_X}</button>
</header>
<div class="site-message-body">
${_activeTab === 'notifications' ? renderNotifications(visible.notifications) : renderAnnouncements(visible.announcements)}
</div>
<footer class="site-message-footer">
<button class="btn btn-secondary btn-sm" type="button" data-site-message-today>${t('siteMessages.closeToday')}</button>
<button class="btn btn-primary btn-sm" type="button" data-site-message-dismiss>${t('siteMessages.closeCurrent')}</button>
</footer>
</section>
`
document.body.appendChild(overlay)
_overlay = overlay
bindModalEvents(overlay)
const modal = overlay.querySelector('.site-message-modal')
modal?.focus({ preventScroll: true })
}
function renderTab(tab, label, icon, count) {
const active = _activeTab === tab
return `
<button class="site-message-tab ${active ? 'active' : ''}" type="button" role="tab" aria-selected="${active}" data-site-message-tab="${tab}">
${icon}<span>${label}</span>${count ? `<small>${count}</small>` : ''}
</button>
`
}
function renderNotifications(items) {
if (!items.length) {
return `
<div class="site-message-empty">
<span class="site-message-empty-icon" aria-hidden="true">${ICON_BELL}</span>
<strong>${t('siteMessages.emptyNotifications')}</strong>
<p>${t('siteMessages.emptyNotificationsHint')}</p>
</div>
`
}
return `
<div class="site-message-section-head">
<span>${t('siteMessages.notificationFeed')}</span>
<small>${t('siteMessages.sortedByTime')}</small>
</div>
<div class="site-message-timeline">
${items.map(item => `
<article class="site-message-timeline-item level-${escapeAttr(item.level)}">
<div class="site-message-dot" aria-hidden="true"></div>
<div class="site-message-line" aria-hidden="true"></div>
<div class="site-message-timeline-content">
<div class="site-message-item-top">
<h3>${escapeHtml(item.title)}</h3>
<span>${renderLevelLabel(item.level)}</span>
</div>
${item.body ? `<p>${escapeHtml(item.body)}</p>` : ''}
<div class="site-message-meta">${formatMessageTime(item)}</div>
${renderMessageCta(item)}
</div>
</article>
`).join('')}
</div>
`
}
function renderAnnouncements(items) {
if (!items.length) {
return `
<div class="site-message-empty">
<span class="site-message-empty-icon" aria-hidden="true">${ICON_SEND}</span>
<strong>${t('siteMessages.emptyAnnouncements')}</strong>
<p>${t('siteMessages.emptyAnnouncementsHint')}</p>
</div>
`
}
return `
<div class="site-message-section-head">
<span>${t('siteMessages.fixedAnnouncements')}</span>
<small>${t('siteMessages.managedBySite')}</small>
</div>
<div class="site-message-announcement-list">
${items.map((item, index) => `
<article class="site-message-announcement level-${escapeAttr(item.level)} ${index === 0 ? 'featured' : ''}">
<div class="site-message-announcement-main">
<div class="site-message-announcement-kicker">
<span>${escapeHtml(item.badge || t('siteMessages.announcementBadge'))}</span>
<small>${renderLevelLabel(item.level)}</small>
</div>
<h3>${escapeHtml(item.title)}</h3>
${item.body ? `<p>${escapeHtml(item.body)}</p>` : ''}
</div>
<div class="site-message-announcement-side">
<span>${formatMessageTime(item)}</span>
${renderMessageCta(item, true)}
</div>
</article>
`).join('')}
</div>
`
}
function renderMessageCta(item, prominent = false) {
if (!item.ctaText || !item.ctaUrl) return ''
return `<a class="${prominent ? 'site-message-card-cta' : 'site-message-inline-cta'}" href="${escapeAttr(item.ctaUrl)}" target="_blank" rel="noopener">${escapeHtml(item.ctaText)}</a>`
}
function bindModalEvents(overlay) {
overlay.querySelector('.site-message-close')?.addEventListener('click', closeModal)
overlay.querySelector('[data-site-message-today]')?.addEventListener('click', () => {
localStorage.setItem(TODAY_CLOSE_KEY, todayKey())
closeModal()
})
overlay.querySelector('[data-site-message-dismiss]')?.addEventListener('click', () => {
dismissItems(getVisibleMessages()[_activeTab])
closeModal()
updateLauncherBadge()
})
overlay.querySelectorAll('[data-site-message-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.siteMessageTab || 'notifications'
renderModal()
})
})
overlay.addEventListener('click', (event) => {
if (event.target === overlay) closeModal()
})
overlay.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeModal()
})
}
function closeModal() {
_overlay?.remove()
_overlay = null
}
function dismissItems(items) {
for (const item of items || []) {
const key = dismissStorageKey(item)
if (key) localStorage.setItem(key, '1')
}
}
function shouldAutoOpen() {
if (localStorage.getItem(TODAY_CLOSE_KEY) === todayKey()) return false
const visible = getVisibleMessages()
return visible.notifications.length > 0 || visible.announcements.length > 0
}
function getVisibleMessages() {
return {
notifications: _messages.notifications.filter(item => !isDismissed(item)),
announcements: _messages.announcements.filter(item => !isDismissed(item)),
}
}
function normalizePayload(payload = {}) {
const notifications = []
const announcements = []
const appendItem = (raw, fallbackType) => {
if (!acceptsClientSurface(raw)) return
const displayType = classifyDisplayType(raw, fallbackType)
const item = normalizeItem(raw, displayType)
if (displayType === 'notification') {
notifications.push(item)
} else {
announcements.push(item)
}
}
if (Array.isArray(payload.notifications)) {
payload.notifications.forEach(item => appendItem(item, 'notification'))
}
if (Array.isArray(payload.announcements)) {
payload.announcements.forEach(item => appendItem(item, 'announcement'))
}
notifications.sort(sortByTimeDesc)
announcements.sort(sortByPriority)
return { notifications, announcements }
}
function normalizeItem(item = {}, fallbackType = 'notification') {
const displayType = classifyDisplayType(item, fallbackType)
const type = String(item.type || item.kind || item.category || displayType).toLowerCase()
const level = normalizeLevel(item.level)
return {
id: String(item.id || item.dismissKey || `${type}-${item.title || item.updatedAt || Date.now()}`),
type,
displayType,
targetSurface: String(item.targetSurface || item.surface || ''),
level,
title: String(item.title || t('siteMessages.defaultTitle')),
body: String(item.body || item.content || item.summary || ''),
badge: item.badge ? String(item.badge) : '',
ctaText: item.ctaText ? String(item.ctaText) : '',
ctaUrl: item.ctaUrl ? safeHref(item.ctaUrl) : '',
dismissKey: item.dismissKey || item.id || '',
updatedAt: item.updatedAt || item.publishedAt || item.startAt || item.createdAt || '',
pinned: item.pinned === true || item.fixed === true || displayType === 'announcement',
}
}
function acceptsClientSurface(item = {}) {
const surface = String(item.targetSurface || item.surface || '').trim().toLowerCase()
return !surface || surface === 'client' || surface === 'all'
}
function classifyDisplayType(item = {}, fallbackType = 'notification') {
const raw = String(item.displayType || item.display_type || item.type || item.kind || item.category || fallbackType)
.trim()
.toLowerCase()
if (['notification', 'notice', 'message', 'feed', 'timeline'].includes(raw)) return 'notification'
return 'announcement'
}
function normalizeLevel(level) {
const next = String(level || 'info').toLowerCase()
return ['critical', 'warning', 'success', 'info'].includes(next) ? next : 'info'
}
function sortByTimeDesc(a, b) {
return toTime(b.updatedAt) - toTime(a.updatedAt)
}
function sortByPriority(a, b) {
const weight = { critical: 3, warning: 2, success: 1, info: 0 }
return (weight[b.level] || 0) - (weight[a.level] || 0) || sortByTimeDesc(a, b)
}
function formatMessageTime(item) {
const time = toTime(item.updatedAt)
if (!time) return t('siteMessages.timeUnknown')
const date = new Date(time)
const now = Date.now()
const diffDays = Math.floor((now - time) / 86400000)
const absolute = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
if (diffDays <= 0) return `${t('siteMessages.today')} ${absolute}`
if (diffDays < 30) return `${diffDays}${t('siteMessages.daysAgo')} ${absolute}`
const months = Math.max(1, Math.floor(diffDays / 30))
return `${months}${t('siteMessages.monthsAgo')} ${absolute}`
}
function formatSummary(visible) {
return t('siteMessages.summary', {
notifications: visible.notifications.length,
announcements: visible.announcements.length,
})
}
function renderLevelLabel(level) {
const labels = {
critical: t('siteMessages.levelCritical'),
warning: t('siteMessages.levelWarning'),
success: t('siteMessages.levelSuccess'),
info: t('siteMessages.levelInfo'),
}
return labels[level] || labels.info
}
function dismissStorageKey(item) {
const key = item?.dismissKey || item?.id
return key ? `${DISMISS_PREFIX}${key}` : ''
}
function isDismissed(item) {
const key = dismissStorageKey(item)
return !!key && localStorage.getItem(key) === '1'
}
function todayKey() {
return new Date().toISOString().slice(0, 10)
}
function toTime(value) {
if (!value) return 0
const time = new Date(value).getTime()
return Number.isFinite(time) ? time : 0
}
function pad(value) {
return String(value).padStart(2, '0')
}
function safeHref(raw) {
try {
const url = new URL(String(raw || '').trim())
if (url.protocol === 'https:' || url.protocol === 'http:' || url.protocol === 'mailto:') {
return url.toString()
}
} catch {}
return ''
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, '&#39;')
}