mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-25 17:54:10 +08:00
feat(update): integrate official site update flow
This commit is contained in:
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
420
src/components/site-message-center.js
Normal file
420
src/components/site-message-center.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/'/g, ''')
|
||||
}
|
||||
Reference in New Issue
Block a user