feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
(() => {
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = (input, init = {}) => {
|
||||
const opts = Object.assign({}, init)
|
||||
if (!opts.credentials) opts.credentials = 'include'
|
||||
return originalFetch(input, opts)
|
||||
}
|
||||
const overlayId = 'authOverlay'
|
||||
const ensureOverlay = () => {
|
||||
let ov = document.getElementById(overlayId)
|
||||
if (ov) return ov
|
||||
ov = document.createElement('div')
|
||||
ov.id = overlayId
|
||||
ov.style.position = 'fixed'
|
||||
ov.style.left = '0'; ov.style.top = '0'; ov.style.right = '0'; ov.style.bottom = '0'
|
||||
ov.style.display = 'none'
|
||||
ov.style.alignItems = 'center'
|
||||
ov.style.justifyContent = 'center'
|
||||
ov.style.background = 'rgba(0,0,0,0.6)'
|
||||
ov.style.zIndex = '9999'
|
||||
const panel = document.createElement('div')
|
||||
panel.style.background = '#fff'
|
||||
panel.style.padding = '20px'
|
||||
panel.style.borderRadius = '8px'
|
||||
panel.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)'
|
||||
const title = document.createElement('div')
|
||||
title.textContent = '鉴权失败,请重新校验'
|
||||
title.style.fontSize = '16px'
|
||||
title.style.marginBottom = '12px'
|
||||
title.style.color = '#333'
|
||||
const debug = document.createElement('pre')
|
||||
debug.id = 'authDebug'
|
||||
debug.style.fontSize = '12px'
|
||||
debug.style.lineHeight = '1.5'
|
||||
debug.style.background = '#f7f7f8'
|
||||
debug.style.border = '1px solid #e3e3e7'
|
||||
debug.style.padding = '10px'
|
||||
debug.style.margin = '0 0 12px 0'
|
||||
debug.style.maxWidth = '520px'
|
||||
debug.style.maxHeight = '240px'
|
||||
debug.style.overflow = 'auto'
|
||||
debug.textContent = '正在收集诊断信息...'
|
||||
const btn = document.createElement('button')
|
||||
btn.textContent = '重新校验'
|
||||
btn.style.padding = '8px 16px'
|
||||
btn.addEventListener('click', async () => {
|
||||
await handshake()
|
||||
})
|
||||
panel.appendChild(title)
|
||||
panel.appendChild(debug)
|
||||
panel.appendChild(btn)
|
||||
ov.appendChild(panel)
|
||||
document.body.appendChild(ov)
|
||||
return ov
|
||||
}
|
||||
const showOverlay = () => { ensureOverlay().style.display = 'flex' }
|
||||
const hideOverlay = () => { const ov = ensureOverlay(); ov.style.display = 'none' }
|
||||
let cfg = { enableCookieAuth: true, cookieName: 'rebalance_jwt' }
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await fetch('/assets/auth_config.json', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
cfg = Object.assign({}, cfg, data || {})
|
||||
} catch (_) {}
|
||||
}
|
||||
const debugState = {
|
||||
url: '',
|
||||
config: {},
|
||||
tokenInUrl: false,
|
||||
tokenCall: { tried: false, ok: false, status: 0 },
|
||||
handshake: { tried: false, ok: false, status: 0 },
|
||||
server: {}
|
||||
}
|
||||
const removeTokenFromUrl = () => {
|
||||
try {
|
||||
const nu = new URL(window.location.href)
|
||||
nu.searchParams.delete('token')
|
||||
window.history.replaceState(null, document.title, nu.toString())
|
||||
} catch (_) {}
|
||||
}
|
||||
const handleTokenInUrl = async () => {
|
||||
try {
|
||||
const u = new URL(window.location.href)
|
||||
const token = u.searchParams.get('token')
|
||||
debugState.url = u.toString()
|
||||
debugState.config = { enableCookieAuth: !!cfg.enableCookieAuth, cookieName: String(cfg.cookieName || '') }
|
||||
debugState.tokenInUrl = !!token
|
||||
if (token) {
|
||||
if (cfg.enableCookieAuth) {
|
||||
const r = await fetch(`/auth/token?token=${encodeURIComponent(token)}`, { method: 'GET' })
|
||||
debugState.tokenCall.tried = true
|
||||
debugState.tokenCall.ok = r.ok
|
||||
debugState.tokenCall.status = r.status || 0
|
||||
removeTokenFromUrl()
|
||||
return r.ok
|
||||
} else {
|
||||
removeTokenFromUrl()
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return true
|
||||
}
|
||||
const handshake = async () => {
|
||||
if (!cfg.enableCookieAuth) { hideOverlay(); return true }
|
||||
try {
|
||||
const r = await fetch('/auth/handshake', { cache: 'no-store' })
|
||||
debugState.handshake.tried = true
|
||||
debugState.handshake.ok = r.ok
|
||||
debugState.handshake.status = r.status || 0
|
||||
/*
|
||||
try {
|
||||
const dbgRes = await fetch('/auth/debug', { cache: 'no-store' })
|
||||
try {
|
||||
const body = await dbgRes.json()
|
||||
debugState.server = body
|
||||
} catch (_) {
|
||||
debugState.server = { error: 'debug parse failed', status: dbgRes.status || 0 }
|
||||
}
|
||||
} catch (e) {
|
||||
debugState.server = { error: 'debug request failed' }
|
||||
}
|
||||
*/
|
||||
const box = document.getElementById('authDebug')
|
||||
if (box) {
|
||||
const lines = []
|
||||
lines.push(`URL: ${debugState.url}`)
|
||||
lines.push(`Config: enableCookieAuth=${debugState.config.enableCookieAuth} cookieName=${debugState.config.cookieName}`)
|
||||
lines.push(`TokenInUrl: ${debugState.tokenInUrl}`)
|
||||
lines.push(`TokenCall: tried=${debugState.tokenCall.tried} ok=${debugState.tokenCall.ok} status=${debugState.tokenCall.status}`)
|
||||
lines.push(`Handshake: tried=${debugState.handshake.tried} ok=${debugState.handshake.ok} status=${debugState.handshake.status}`)
|
||||
if (debugState.server && typeof debugState.server === 'object') {
|
||||
lines.push(`Server.enableAuth: ${debugState.server.enableAuth}`)
|
||||
lines.push(`Server.jwkLoaded: ${debugState.server.jwkLoaded}`)
|
||||
lines.push(`Server.cookieName: ${debugState.server.cookieName}`)
|
||||
lines.push(`Server.cookiePresent: ${debugState.server.cookiePresent}`)
|
||||
lines.push(`Server.verified: ${debugState.server.verified}`)
|
||||
lines.push(`Server.keysCount: ${debugState.server.keysCount}`)
|
||||
try {
|
||||
const ks = Array.isArray(debugState.server.keysSources) ? debugState.server.keysSources : []
|
||||
lines.push(`Server.keysSources: ${JSON.stringify(ks)}`)
|
||||
} catch (_) {}
|
||||
try {
|
||||
const th = debugState.server.tokenHeader || {}
|
||||
const tp = debugState.server.tokenPayload || {}
|
||||
lines.push(`Server.tokenHeader.alg: ${th.alg || ''}`)
|
||||
lines.push(`Server.tokenPayload.iss: ${tp.iss || ''}`)
|
||||
lines.push(`Server.tokenPayload.aud: ${tp.aud || ''}`)
|
||||
lines.push(`Server.tokenPayload.exp: ${tp.exp || ''}`)
|
||||
} catch (_) {}
|
||||
if (debugState.server.error) {
|
||||
lines.push(`Server.error: ${debugState.server.error}`)
|
||||
}
|
||||
const rq = debugState.server.request || {}
|
||||
lines.push(`Server.request.host: ${rq.host || ''}`)
|
||||
lines.push(`Server.request.path: ${rq.path || ''}`)
|
||||
lines.push(`Server.request.origin: ${rq.origin || ''}`)
|
||||
lines.push(`Server.request.secFetchMode: ${rq.secFetchMode || ''}`)
|
||||
lines.push(`Server.request.forwardedProto: ${rq.forwardedProto || ''}`)
|
||||
lines.push(`Server.request.secure: ${rq.secure}`)
|
||||
lines.push(`Server.request.proto: ${rq.proto || ''}`)
|
||||
} else {
|
||||
lines.push(`Server: ${String(debugState.server)}`)
|
||||
}
|
||||
lines.push(`Expected URLs: /auth/token?token=... , /auth/handshake , /api/...`)
|
||||
box.textContent = lines.join('\n')
|
||||
}
|
||||
if (r.ok) { hideOverlay(); return true }
|
||||
showOverlay(); return false
|
||||
} catch (_) { showOverlay(); return false }
|
||||
}
|
||||
const init = async () => {
|
||||
await loadConfig()
|
||||
await handleTokenInUrl()
|
||||
await handshake()
|
||||
}
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
init()
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', init)
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"_comment": "公共静态资源下,与 Rebalance 相关的 JWT 鉴权配置",
|
||||
"cookieName_comment": "用于识别已登录用户的 JWT cookie 名称",
|
||||
"cookieName": "rebalance_jwt",
|
||||
"enableCookieAuth_comment": "true:启用基于 cookie 的鉴权;false:不校验登录状态",
|
||||
"enableCookieAuth": true
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
* { box-sizing: border-box }
|
||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif }
|
||||
.topbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid #ddd; position: sticky; top: 0; background: #fff; z-index: 10 }
|
||||
.tabs { display: flex; gap: 8px }
|
||||
.tab { border: none; background: #f5f5f5; padding: 8px 12px; border-radius: 8px; cursor: pointer; text-decoration: none; color: inherit }
|
||||
.tab.active { background: #222; color: #fff }
|
||||
.settings { border: none; background: #fff; padding: 6px 10px; border: 1px solid #222; border-radius: 6px; cursor: pointer }
|
||||
main { padding: 12px }
|
||||
.modal { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.35); z-index: 1000 }
|
||||
.hidden { display: none }
|
||||
.modal-body { background: #fff; width: 520px; max-width: 90vw; border-radius: 10px; padding: 16px; display: grid; gap: 8px }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px }
|
||||
mark { background: #ffe58f }
|
||||
.kw-0 { background: #ffe58f }
|
||||
.kw-1 { background: #ffd6e7 }
|
||||
.kw-2 { background: #d6f5ff }
|
||||
.kw-3 { background: #d9f7be }
|
||||
.kw-4 { background: #fff1b8 }
|
||||
.kw-5 { background: #efdbff }
|
||||
@@ -0,0 +1,61 @@
|
||||
const el = id => document.getElementById(id)
|
||||
const highlight = (text, terms) => {
|
||||
if (!terms || terms.length === 0) return text
|
||||
let s = text
|
||||
terms.forEach((t, i) => {
|
||||
const cls = `kw-${i % 6}`
|
||||
const re = new RegExp(`(${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
||||
s = s.replace(re, `<mark class="${cls}">$1</mark>`)
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
const fmtCNDateTime = (d) => {
|
||||
const date = d instanceof Date ? d : new Date()
|
||||
try {
|
||||
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
|
||||
return s.replace(/\//g, '-').replace(/\u200E/g, '')
|
||||
} catch {
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
const y = date.getFullYear(), m = pad(date.getMonth()+1), day = pad(date.getDate()), hh = pad(date.getHours()), mm = pad(date.getMinutes()), ss = pad(date.getSeconds())
|
||||
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||
}
|
||||
}
|
||||
|
||||
const fmtCNSimpleTime = (d) => {
|
||||
const date = d instanceof Date ? d : new Date()
|
||||
try {
|
||||
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
|
||||
return s.replace(/\u200E/g, '')
|
||||
} catch {
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
}
|
||||
|
||||
const cnDateStr = (d) => {
|
||||
const date = d instanceof Date ? d : new Date()
|
||||
try {
|
||||
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date)
|
||||
return s.replace(/\//g, '-').replace(/\u200E/g, '')
|
||||
} catch {
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}`
|
||||
}
|
||||
}
|
||||
|
||||
window.fmtCNDateTime = fmtCNDateTime
|
||||
window.fmtCNSimpleTime = fmtCNSimpleTime
|
||||
window.cnDateStr = cnDateStr
|
||||
|
||||
;(function handleSetToken() {
|
||||
try {
|
||||
const u = new URL(window.location.href)
|
||||
const token = u.searchParams.get('set_token')
|
||||
if (token) {
|
||||
localStorage.setItem('toolbox_creds_token', token)
|
||||
u.searchParams.delete('set_token')
|
||||
window.history.replaceState(null, '', u.toString())
|
||||
}
|
||||
} catch {}
|
||||
})()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
<header class="topbar">
|
||||
<nav class="tabs">
|
||||
<a class="tab" href="/go?systemId=Tools-weibo" data-key="weibo">微博跟踪</a>
|
||||
<a class="tab" href="/go?systemId=Tools-markets" data-key="markets">市场跟踪</a>
|
||||
<a class="tab" href="/go?systemId=Tools-wall-" data-key="wall">告知墙</a>
|
||||
<a class="tab" href="/go?systemId=Tools-funds" data-key="funds">本金变动池</a>
|
||||
<a class="tab" href="/go?systemId=Tools-ccb_private_funds" data-key="ccb_private_funds">建行私募理财</a>
|
||||
<a class="tab" href="/go?systemId=Tools-cloud" data-key="cloud">云产品管理</a>
|
||||
<a class="tab" href="/go?systemId=Tools-weekly" data-key="weekly">工作周报</a>
|
||||
<a class="tab" href="/go?systemId=TRAE-Rebalance&url=https%3A%2F%2Ftraesite.umersoft.com%3A8960%2FRebalance.html" data-key="rebalance">再平衡</a>
|
||||
<a class="tab" href="/go?systemId=Wager&url=https%3A%2F%2Ftraesite.umersoft.com%3A8956" data-key="wager">投注</a>
|
||||
<a class="tab" href="/go?systemId=TRAE-PSC&url=https%3A%2F%2Ftraesite.umersoft.com%3A8969%2F" data-key="disk">安全网盘</a>
|
||||
<a class="tab" href="/go?systemId=FitnessDiary" data-key="fitness">运动日志</a>
|
||||
<a class="tab" href="/go?systemId=CalendarReminder&url=https%3A%2F%2Ftraesite.umersoft.com%3A8972%2F" data-key="fitness">日历提醒</a>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,26 @@
|
||||
(async function(){
|
||||
try {
|
||||
const wrap = document.querySelector('#topbar')
|
||||
if (!wrap) return
|
||||
const res = await fetch('/common/nav.html')
|
||||
const html = await res.text()
|
||||
wrap.innerHTML = html
|
||||
const path = location.pathname.toLowerCase()
|
||||
let key = ''
|
||||
if (path.includes('/tools/weibo/')) key = 'weibo'
|
||||
else if (path.includes('/tools/markets/')) key = 'markets'
|
||||
else if (path.includes('/tools/wall/')) key = 'wall'
|
||||
else if (path.includes('/tools/funds/')) key = 'funds'
|
||||
else if (path.includes('/tools/ccb_private_funds/')) key = 'ccb_private_funds'
|
||||
else if (path.includes('/tools/cloud/')) key = 'cloud'
|
||||
else if (path.includes('/tools/weekly/')) key = 'weekly'
|
||||
else if (path.includes('/tools/ai-lib/')) key = 'ai-lib'
|
||||
else if (path.includes('/tools/expense/')) key = 'expense'
|
||||
if (key) {
|
||||
wrap.querySelectorAll('a.tab').forEach(a => {
|
||||
if (a.dataset.key === key) a.classList.add('active')
|
||||
else a.classList.remove('active')
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
})()
|
||||
@@ -0,0 +1,128 @@
|
||||
[
|
||||
|
||||
{
|
||||
"name": "地下偶像相关揭示板",
|
||||
"url": "https://m.weibo.cn/u/7716940453",
|
||||
"icon": "text_italic.png"
|
||||
},{
|
||||
"name": "世界树剧场",
|
||||
"url": "https://m.weibo.cn/u/7925328421",
|
||||
"icon": "user_go.png"
|
||||
},
|
||||
{
|
||||
"name": "育音堂",
|
||||
"url": "https://weibo.com/u/1802208671",
|
||||
"icon": "weather_sun.png"
|
||||
},
|
||||
{
|
||||
"name": "X下载利器",
|
||||
"url": "https://ssstwitter.com/",
|
||||
"icon": "twitter_tool.png"
|
||||
},
|
||||
{
|
||||
"name": "上海地下偶像联盟",
|
||||
"url": "https://m.weibo.cn/u/7912812433",
|
||||
"icon": "weibo.png"
|
||||
},
|
||||
{
|
||||
"name": "免费ICON",
|
||||
"url": "https://igoutu.cn/icons",
|
||||
"icon": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "培训/导航/LOGO",
|
||||
"url": "http://112.74.31.221:8888/default.html",
|
||||
"icon": "HDA.png"
|
||||
},
|
||||
{
|
||||
"name": "Git 服务器",
|
||||
"url": "http://47.101.56.101:8998/Home/LogOn?ReturnUrl=%2F",
|
||||
"icon": "HDA.png"
|
||||
},
|
||||
{
|
||||
"name": "禅道 项目系统",
|
||||
"url": "http://112.74.31.221:82/zentao/user-login-L3plbnRhby9teS5odG1s.html",
|
||||
"icon": "HDA.png"
|
||||
},
|
||||
{
|
||||
"name": "阿里云-合规报告",
|
||||
"url": "https://security.aliyun.com/trust-center/compliance?spm=5176.32436255.J_JIpOXHuIiVOM6_FDQtQLN.2.377618c54gQlJa",
|
||||
"icon": "aliyun.png"
|
||||
},
|
||||
{
|
||||
"name": "硅谷-阿里合同",
|
||||
"url": "https://traesite.umersoft.com:8975/download_files/%E9%98%BF%E9%87%8C%E4%BA%91%E6%9C%8D%E5%8A%A1%E5%8D%8F%E8%AE%AE.pdf",
|
||||
"icon": "aliyun.png"
|
||||
},
|
||||
{
|
||||
"name": "在线MP3播放",
|
||||
"url": "https://music.cpp-prog.com",
|
||||
"icon": "music.png"
|
||||
},
|
||||
{
|
||||
"name": "上海市医疗器械追溯报告系统",
|
||||
"url": "https://zssb.yjj.sh.gov.cn",
|
||||
"icon": "GOV.png"
|
||||
},
|
||||
{
|
||||
"name": "虚拟显示器,USB-MMIDD 驱动",
|
||||
"url": "https://traesite.umersoft.com:8975/download_files/usbmmidd_v2.zip",
|
||||
"icon": "download.png"
|
||||
},
|
||||
{
|
||||
"name": "CHROME纯净版X64",
|
||||
"url": "https://traesite.umersoft.com:8975/download_files/ChromeStandaloneSetup64.exe",
|
||||
"icon": "download.png"
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"name": "测试长标题如果不指定图标",
|
||||
"url": "#",
|
||||
"icon": ""
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,495 @@
|
||||
:root {
|
||||
--vscode-bg: #1e1e1e;
|
||||
--vscode-sidebar-bg: #252526;
|
||||
--vscode-toolbar-bg: #2d2d30;
|
||||
--vscode-border: #3e3e42;
|
||||
--vscode-hover: #2a2d2e;
|
||||
--vscode-active: #007acc;
|
||||
--vscode-text: #cccccc;
|
||||
--vscode-text-muted: #858585;
|
||||
--vscode-accent: #4ec9b0;
|
||||
--vscode-warning: #ffcc00;
|
||||
--vscode-error: #f44747;
|
||||
--vscode-success: #4ec9b0;
|
||||
--vscode-radius: 4px;
|
||||
--vscode-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
--vscode-input-bg: #3c3c3c;
|
||||
--vscode-button-bg: #0e639c;
|
||||
--vscode-button-hover: #1177bb;
|
||||
--vscode-card-bg: #2d2d30;
|
||||
}
|
||||
|
||||
body.theme-cloud {
|
||||
background: var(--vscode-bg);
|
||||
color: var(--vscode-text);
|
||||
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
background: var(--vscode-sidebar-bg);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: var(--vscode-radius);
|
||||
padding: 16px;
|
||||
box-shadow: var(--vscode-shadow);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--vscode-border);
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
color: var(--vscode-text);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--vscode-toolbar-bg);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: var(--vscode-radius);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--vscode-shadow);
|
||||
}
|
||||
|
||||
.toolbar span {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-text);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--vscode-card-bg);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: var(--vscode-radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--vscode-shadow);
|
||||
}
|
||||
|
||||
/* Tree View Styles */
|
||||
.tree-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tree-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: var(--vscode-radius);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tree-item-header:hover {
|
||||
background: var(--vscode-hover);
|
||||
}
|
||||
|
||||
.tree-item-header.expanded {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: var(--vscode-text-muted);
|
||||
transition: transform 0.2s;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tree-toggle.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.system-icon, .module-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.tree-item-header:hover .item-actions,
|
||||
.tree-child-item:hover .item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tree-item-content {
|
||||
margin-left: 22px;
|
||||
border-left: 1px solid rgba(255,255,255,0.05);
|
||||
display: none;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.tree-item-content.expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tree-module-wrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tree-child-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--vscode-radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.tree-child-item:hover {
|
||||
background: var(--vscode-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form input, .form textarea, .form select {
|
||||
background: var(--vscode-input-bg);
|
||||
border: 1px solid var(--vscode-border);
|
||||
color: var(--vscode-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--vscode-radius);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form input:focus, .form textarea:focus, .form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--vscode-active);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--vscode-button-bg);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--vscode-radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--vscode-button-hover);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--vscode-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: var(--vscode-text);
|
||||
}
|
||||
|
||||
.btn-icon.sm {
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.btn-delete:hover { color: var(--vscode-error); }
|
||||
.btn-edit:hover { color: var(--vscode-accent); }
|
||||
|
||||
/* Note List Styles */
|
||||
.note-item {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-left: 3px solid var(--vscode-accent);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 0 var(--vscode-radius) var(--vscode-radius) 0;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--vscode-text-muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Attachment Styles */
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
color: var(--vscode-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.attachment-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Filter Group Styles */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--vscode-text);
|
||||
}
|
||||
|
||||
#filter-major-dynamic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#filter-major-dynamic label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-row.radio-group {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 12px;
|
||||
background: var(--vscode-input-bg);
|
||||
border-radius: var(--vscode-radius);
|
||||
border: 1px solid var(--vscode-border);
|
||||
}
|
||||
|
||||
.form-row.radio-group .label {
|
||||
font-weight: 600;
|
||||
color: var(--vscode-text-muted);
|
||||
}
|
||||
|
||||
.form-row.radio-group label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--vscode-text);
|
||||
}
|
||||
|
||||
#sys-major-dynamic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#sys-major-dynamic label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
accent-color: var(--vscode-active);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--vscode-card-bg);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: var(--vscode-radius);
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#category-manager-modal .modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--vscode-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-text);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-text-muted);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--vscode-text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
color: var(--vscode-text);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--vscode-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vscode-border);
|
||||
color: var(--vscode-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
/* Login Form Styles */
|
||||
#auth {
|
||||
max-width: 400px;
|
||||
margin: 100px auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Loading Overlay Styles */
|
||||
.loading-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 2000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #fff;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AI开发库</title>
|
||||
<link rel="icon" href="/icons/ai-lib.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/cloud/theme/tokens.css" />
|
||||
<link rel="stylesheet" href="/tools/cloud/theme/components.css" />
|
||||
<link rel="stylesheet" href="/tools/ai-lib/ai-lib.css" />
|
||||
</head>
|
||||
<body class="theme-cloud">
|
||||
<main class="container">
|
||||
<section id="auth" class="card">
|
||||
<h2>🔐 登录AI开发库</h2>
|
||||
<div class="form">
|
||||
<input id="login-user" placeholder="👤 用户名" autocomplete="off" readonly />
|
||||
<input id="login-pass" placeholder="🔒 密码" type="password" autocomplete="off" readonly />
|
||||
<button id="btn-login" class="btn">🚀 登录</button>
|
||||
</div>
|
||||
<div id="auth-status" class="status" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
<section id="app" style="display:none">
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>📦 系统列表</h3>
|
||||
<button id="btn-refresh-tree" class="btn-icon" data-tip="刷新系统树形">🔄</button>
|
||||
<button id="btn-manage-categories" class="btn-icon" data-tip="大类管理">🔧</button>
|
||||
<button id="btn-new-system" class="btn">➕ 新增系统</button>
|
||||
</div>
|
||||
<div id="systems" class="list"></div>
|
||||
|
||||
|
||||
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<div class="toolbar">
|
||||
<span id="current-path">请选择系统 → 子模块 → 开发纪要</span>
|
||||
<div id="filter-major-category" class="filter-group">
|
||||
<label><input type="radio" name="filter-major" value="" checked> 全部</label>
|
||||
<span id="filter-major-dynamic"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="system-detail" class="card" style="display:none">
|
||||
<h3>📦 系统详情</h3>
|
||||
<div class="form">
|
||||
<div class="form-row radio-group">
|
||||
<span class="label">大类:</span>
|
||||
<span id="sys-major-dynamic"></span>
|
||||
</div>
|
||||
<input id="sys-name" placeholder="🏷️ 系统名称" data-tip="系统名称,建议简洁唯一" />
|
||||
<textarea id="sys-intro" rows="3" placeholder="📝 系统简介" data-tip="系统简介:用途、边界与关键技术"></textarea>
|
||||
<button id="btn-save-system" class="btn">💾 保存系统</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="module-detail" class="card" style="display:none">
|
||||
<h3>🔧 子模块详情</h3>
|
||||
<div class="form">
|
||||
<input id="mod-name" placeholder="🏷️ 子模块名称" data-tip="子模块名称" />
|
||||
<input id="mod-desc" placeholder="📝 子模块说明" data-tip="子模块说明:功能与职责" />
|
||||
<input id="mod-addr" placeholder="🌐 服务器地址" data-tip="服务器地址或域名" />
|
||||
<input id="mod-port" placeholder="🔌 服务器端口" data-tip="端口号" />
|
||||
<input id="mod-code" placeholder="💻 代码类型" data-tip="代码类型或技术栈" />
|
||||
<input id="mod-deploy" placeholder="🚀 部署方式" data-tip="部署方式,如 Docker/K8s/物理机" />
|
||||
</div>
|
||||
<div class="form">
|
||||
<textarea id="mod-soft" rows="8" placeholder="📦 涉及部署软件和版本(多行)" data-tip="涉及部署软件及版本(每行一条)"></textarea>
|
||||
<button id="btn-save-module" class="btn">💾 保存子模块</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="note-detail" class="card" style="display:none">
|
||||
<h3>📝 开发纪要详情</h3>
|
||||
<div class="form">
|
||||
<select id="note-type" data-tip="纪要类型(需求/开发/部署/维护)">
|
||||
<option value="需求">📋 需求</option>
|
||||
<option value="开发">💻 开发</option>
|
||||
<option value="部署">🚀 部署</option>
|
||||
<option value="维护">🔧 维护</option>
|
||||
</select>
|
||||
<textarea id="note-content" rows="12" placeholder="📝 纪要内容(多行)" data-tip="纪要内容,支持多行文本"></textarea>
|
||||
<button id="btn-save-note" class="btn">💾 保存纪要</button>
|
||||
</div>
|
||||
|
||||
<div class="attachment-section" style="margin-top: 24px;">
|
||||
<h4>📎 附件管理</h4>
|
||||
<div class="form">
|
||||
<input type="file" id="attachment-file" multiple data-tip="选择要上传的附件,支持多选" />
|
||||
<button id="btn-upload-attachment" class="btn">📤 上传选中文件</button>
|
||||
</div>
|
||||
<div id="upload-progress" style="margin-top: 16px;"></div>
|
||||
|
||||
<div class="attachment-list" style="margin-top: 20px;">
|
||||
<h5>📋 附件列表</h5>
|
||||
<div id="attachments" class="list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal" id="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title"></h3>
|
||||
<button class="modal-close" id="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body"></div>
|
||||
<div class="modal-footer" id="modal-footer">
|
||||
<button class="btn btn-secondary" id="modal-cancel">取消</button>
|
||||
<button class="btn" id="modal-confirm">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading-overlay" class="loading-overlay" style="display:none">
|
||||
<div class="loading-box">
|
||||
<div class="loading-spinner"></div>
|
||||
<div id="loading-text">正在处理,请稍候...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/common/common.js"></script>
|
||||
<script src="/tools/ai-lib/ai-lib.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,980 @@
|
||||
const el = (id) => document.getElementById(id)
|
||||
|
||||
const fmtNum = (v, d = 1) => {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return '-'
|
||||
return Number(v).toFixed(d)
|
||||
}
|
||||
|
||||
const fmtDate = (s) => {
|
||||
if (!s) return '-'
|
||||
return String(s).slice(0, 10)
|
||||
}
|
||||
|
||||
const loadScript = (src) => new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = src
|
||||
s.async = true
|
||||
s.onload = () => resolve()
|
||||
s.onerror = () => reject()
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
|
||||
const ensureChart = async () => !!window.Chart
|
||||
|
||||
const state = {
|
||||
charts: {},
|
||||
lastData: null,
|
||||
activeTab: 'default'
|
||||
}
|
||||
|
||||
const setText = (id, value) => {
|
||||
const node = el(id)
|
||||
if (node) node.textContent = value
|
||||
}
|
||||
|
||||
const avgOf = (arr) => {
|
||||
const nums = (arr || []).filter(v => Number.isFinite(v))
|
||||
if (!nums.length) return null
|
||||
return nums.reduce((a, b) => a + b, 0) / nums.length
|
||||
}
|
||||
|
||||
const sumOf = (arr) => (arr || []).reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||
|
||||
const firstValue = (arr) => {
|
||||
for (let i = 0; i < arr.length; i += 1) {
|
||||
if (Number.isFinite(arr[i])) return arr[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getTrendData = (data) => ({
|
||||
days: Number(el('daysRange').value || 90),
|
||||
labels: data.trends.dates || [],
|
||||
hr: data.trends.hr_avg || [],
|
||||
resp: data.trends.resp_avg || [],
|
||||
weight: data.trends.weight || [],
|
||||
distance: data.trends.distance || [],
|
||||
sleep: data.trends.sleep_hours || [],
|
||||
stand: data.trends.stand_hours || [],
|
||||
workout: data.trends.workout_minutes || [],
|
||||
hrv: data.trends.hrv_sdnn || [],
|
||||
restingHr: data.trends.resting_hr || [],
|
||||
walkingHr: data.trends.walking_hr || [],
|
||||
sleepRem: data.trends.sleep_rem || [],
|
||||
sleepCore: data.trends.sleep_core || [],
|
||||
sleepDeep: data.trends.sleep_deep || [],
|
||||
sleepAwake: data.trends.sleep_awake || []
|
||||
})
|
||||
|
||||
const getTypeSummary = (data) => {
|
||||
const shortType = (t) => String(t || '').replace('HKQuantityTypeIdentifier', '').replace('HKCategoryTypeIdentifier', '')
|
||||
const types = data.compare.types || []
|
||||
return {
|
||||
typeLabels: types.map(t => shortType(t.type)),
|
||||
typeValues: types.map(t => t.record_count || 0)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSummary = (data) => {
|
||||
setText('statRecords', data.summary.total_records || 0)
|
||||
setText('statTypes', data.summary.type_count || 0)
|
||||
setText('statRoutes', data.summary.route_count || 0)
|
||||
setText('statEcg', data.summary.ecg_count || 0)
|
||||
const dateRange = data.summary.date_min ? `${fmtDate(data.summary.date_min)} ~ ${fmtDate(data.summary.date_max)}` : '-'
|
||||
setText('statDateRange', dateRange)
|
||||
setText('statLastImport', data.summary.last_import ? `最近同步 ${fmtDate(data.summary.last_import)}` : '-')
|
||||
setText('statAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||
setText('statAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||
setText('statDistance', fmtNum(data.cards.total_distance_km, 1))
|
||||
const weightUnit = data.latest.weight_unit || 'kg'
|
||||
const heightUnitRaw = data.latest.height_unit || 'cm'
|
||||
let heightUnit = heightUnitRaw
|
||||
let heightValue = data.latest.height_cm
|
||||
if (heightUnitRaw === 'm' && Number.isFinite(heightValue)) {
|
||||
heightValue = heightValue * 100
|
||||
heightUnit = 'cm'
|
||||
}
|
||||
setText('statWeight', fmtNum(data.latest.weight_kg, 1))
|
||||
setText('statHeight', fmtNum(heightValue, 1))
|
||||
setText('statWeightUnit', weightUnit)
|
||||
setText('statHeightUnit', heightUnit)
|
||||
setText('statSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||
setText('statStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||
setText('statWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||
}
|
||||
|
||||
const destroyCharts = () => {
|
||||
Object.values(state.charts).forEach(c => {
|
||||
if (c && typeof c.destroy === 'function') c.destroy()
|
||||
})
|
||||
state.charts = {}
|
||||
}
|
||||
|
||||
const drawSimpleLine = (canvas, labels, series, colors) => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.clientWidth || canvas.width
|
||||
const height = canvas.clientHeight || canvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
const allValues = series.flat().filter(v => Number.isFinite(v))
|
||||
if (!allValues.length) return false
|
||||
const min = Math.min(...allValues)
|
||||
const max = Math.max(...allValues)
|
||||
const pad = 24
|
||||
const xStep = (width - pad * 2) / Math.max(1, labels.length - 1)
|
||||
const scaleY = (v) => {
|
||||
if (max === min) return height / 2
|
||||
return height - pad - ((v - min) / (max - min)) * (height - pad * 2)
|
||||
}
|
||||
series.forEach((values, idx) => {
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = colors[idx] || '#38bdf8'
|
||||
ctx.lineWidth = 2
|
||||
values.forEach((v, i) => {
|
||||
if (!Number.isFinite(v)) return
|
||||
const x = pad + xStep * i
|
||||
const y = scaleY(v)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.stroke()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const drawSimpleBars = (canvas, labels, values) => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.clientWidth || canvas.width
|
||||
const height = canvas.clientHeight || canvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
const nums = values.filter(v => Number.isFinite(v))
|
||||
if (!nums.length) return false
|
||||
const max = Math.max(...nums, 1)
|
||||
const pad = 24
|
||||
const barWidth = (width - pad * 2) / Math.max(1, values.length)
|
||||
values.forEach((v, i) => {
|
||||
if (!Number.isFinite(v)) return
|
||||
const h = ((v / max) * (height - pad * 2))
|
||||
const x = pad + i * barWidth + barWidth * 0.2
|
||||
const y = height - pad - h
|
||||
ctx.fillStyle = '#60a5fa'
|
||||
ctx.fillRect(x, y, barWidth * 0.6, h)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const drawSimplePie = (canvas, values, colors) => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.clientWidth || canvas.width
|
||||
const height = canvas.clientHeight || canvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
const total = values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||
if (!total) return false
|
||||
let start = -Math.PI / 2
|
||||
values.forEach((v, i) => {
|
||||
const value = Number.isFinite(v) ? v : 0
|
||||
const angle = (value / total) * Math.PI * 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(width / 2, height / 2)
|
||||
ctx.fillStyle = colors[i % colors.length]
|
||||
ctx.arc(width / 2, height / 2, Math.min(width, height) / 2 - 6, start, start + angle)
|
||||
ctx.fill()
|
||||
start += angle
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const drawSimpleScatter = (canvas, points, color = '#38bdf8') => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.clientWidth || canvas.width
|
||||
const height = canvas.clientHeight || canvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
if (!points.length) return false
|
||||
const xs = points.map(p => p.x).filter(v => Number.isFinite(v))
|
||||
const ys = points.map(p => p.y).filter(v => Number.isFinite(v))
|
||||
if (!xs.length || !ys.length) return false
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
const minY = Math.min(...ys)
|
||||
const maxY = Math.max(...ys)
|
||||
const pad = 24
|
||||
const scaleX = (v) => {
|
||||
if (maxX === minX) return width / 2
|
||||
return pad + ((v - minX) / (maxX - minX)) * (width - pad * 2)
|
||||
}
|
||||
const scaleY = (v) => {
|
||||
if (maxY === minY) return height / 2
|
||||
return height - pad - ((v - minY) / (maxY - minY)) * (height - pad * 2)
|
||||
}
|
||||
ctx.fillStyle = color
|
||||
points.forEach(p => {
|
||||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) return
|
||||
const x = scaleX(p.x)
|
||||
const y = scaleY(p.y)
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const renderDefaultCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { typeLabels, typeValues } = getTypeSummary(data)
|
||||
const { days, labels, hr, resp, weight, distance, sleep, stand, workout, hrv, restingHr, walkingHr, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const vCtx = el('chartVitals').getContext('2d')
|
||||
state.charts.vitals = new Chart(vCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: { x: { ticks: { maxTicksLimit: 6 } } }
|
||||
}
|
||||
})
|
||||
const wCtx = el('chartWeight').getContext('2d')
|
||||
state.charts.weight = new Chart(wCtx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: '体重', data: weight, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const dCtx = el('chartDistance').getContext('2d')
|
||||
state.charts.distance = new Chart(dCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#60a5fa' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const tCtx = el('chartTypes').getContext('2d')
|
||||
state.charts.types = new Chart(tCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: typeLabels,
|
||||
datasets: [{ data: typeValues, backgroundColor: ['#38bdf8', '#60a5fa', '#a78bfa', '#f472b6', '#34d399', '#facc15', '#fb7185', '#c084fc', '#22d3ee', '#f97316', '#4ade80', '#eab308'] }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||
})
|
||||
const sCtx = el('chartSleep').getContext('2d')
|
||||
state.charts.sleep = new Chart(sCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '睡眠小时', data: sleep, borderColor: '#34d399', backgroundColor: 'rgba(52,211,153,0.2)', tension: 0.35, pointRadius: 2 },
|
||||
{ label: '站立小时', data: stand, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.2)', tension: 0.35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const wCtx2 = el('chartWorkout').getContext('2d')
|
||||
state.charts.workout = new Chart(wCtx2, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const ssCtx = el('chartSleepStages').getContext('2d')
|
||||
state.charts.sleepStages = new Chart(ssCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 }, stacked: true },
|
||||
y: { stacked: true, title: { display: true, text: '小时' } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const hhCtx = el('chartHeartHealth').getContext('2d')
|
||||
state.charts.heartHealth = new Chart(hhCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'bpm' } },
|
||||
y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
drawSimpleLine(el('chartVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||
drawSimpleLine(el('chartWeight'), labels, [weight], ['#a78bfa'])
|
||||
drawSimpleBars(el('chartDistance'), labels, distance)
|
||||
drawSimplePie(el('chartTypes'), typeValues, ['#38bdf8', '#60a5fa', '#a78bfa', '#f472b6', '#34d399', '#facc15', '#fb7185', '#c084fc', '#22d3ee', '#f97316', '#4ade80', '#eab308'])
|
||||
drawSimpleLine(el('chartSleep'), labels, [sleep, stand], ['#34d399', '#f59e0b'])
|
||||
drawSimpleBars(el('chartWorkout'), labels, workout)
|
||||
}
|
||||
setText('chartVitalsMeta', `${days}天内平均心率与呼吸率`)
|
||||
setText('chartWeightMeta', `${days}天体重变化`)
|
||||
setText('chartDistanceMeta', `${days}天步行距离`)
|
||||
setText('chartTypesMeta', `最多记录的 ${typeLabels.length} 类数据`)
|
||||
setText('chartSleepMeta', `${days}天睡眠小时与站立小时`)
|
||||
setText('chartWorkoutMeta', `${days}天运动分钟`)
|
||||
setText('chartSleepStagesMeta', `${days}天睡眠分期 (REM/核心/深睡/清醒)`)
|
||||
setText('chartHeartHealthMeta', `${days}天静息/步行心率与 HRV 趋势`)
|
||||
}
|
||||
|
||||
const typeNameMap = {
|
||||
HKQuantityTypeIdentifierStepCount: '步数',
|
||||
HKQuantityTypeIdentifierActiveEnergyBurned: '活动能量',
|
||||
HKQuantityTypeIdentifierAppleExerciseTime: '锻炼时间',
|
||||
HKQuantityTypeIdentifierStandTime: '站立时间',
|
||||
HKQuantityTypeIdentifierBasalEnergyBurned: '基础能量',
|
||||
HKQuantityTypeIdentifierDistanceWalkingRunning: '步行跑步距离',
|
||||
HKQuantityTypeIdentifierHeartRate: '心率',
|
||||
HKQuantityTypeIdentifierRespiratoryRate: '呼吸率',
|
||||
HKQuantityTypeIdentifierBodyMass: '体重',
|
||||
HKQuantityTypeIdentifierHeight: '身高',
|
||||
HKCategoryTypeIdentifierAppleStandHour: '站立小时',
|
||||
HKCategoryTypeIdentifierSleepAnalysis: '睡眠'
|
||||
}
|
||||
|
||||
const typeDisplayConfig = {
|
||||
HKQuantityTypeIdentifierStepCount: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierActiveEnergyBurned: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierBasalEnergyBurned: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierDistanceWalkingRunning: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierAppleExerciseTime: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierStandTime: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierFlightsClimbed: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierDietaryEnergyConsumed: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierHeartRate: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierRestingHeartRate: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierWalkingHeartRateAverage: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierHeartRateVariabilitySDNN: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierRespiratoryRate: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierOxygenSaturation: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierBodyMass: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierHeight: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierBodyTemperature: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierEnvironmentalAudioExposure: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierHeadphoneAudioExposure: { mode: 'avg', label: '平均' }
|
||||
}
|
||||
|
||||
const renderTypeList = (data, targetId = 'typeList') => {
|
||||
const box = el(targetId)
|
||||
if (!box) return
|
||||
box.innerHTML = ''
|
||||
const types = data.compare.types || []
|
||||
if (!types.length) {
|
||||
box.innerHTML = '<div class="list-item"><span class="muted">暂无类型数据</span></div>'
|
||||
return
|
||||
}
|
||||
types.slice(0, 12).forEach(item => {
|
||||
const name = typeNameMap[item.type] || item.type.replace('HKQuantityTypeIdentifier', '').replace('HKCategoryTypeIdentifier', '')
|
||||
const config = typeDisplayConfig[item.type] || { mode: 'count' }
|
||||
let valueStr = ''
|
||||
if (config.mode === 'daily_avg' && item.total_value && item.day_count) {
|
||||
const val = item.total_value / Math.max(1, item.day_count)
|
||||
valueStr = `${config.label} ${fmtNum(val, 1)}`
|
||||
} else if (config.mode === 'avg' && item.avg_value) {
|
||||
valueStr = `${config.label} ${fmtNum(item.avg_value, 1)}`
|
||||
} else {
|
||||
valueStr = `${item.record_count} 次`
|
||||
}
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `
|
||||
<div class="list-row">
|
||||
<div class="list-title">${name}</div>
|
||||
<div class="list-meta">${item.type}</div>
|
||||
</div>
|
||||
<span class="tag">${valueStr}${item.unit ? ` · ${item.unit}` : ''}</span>
|
||||
`
|
||||
box.appendChild(node)
|
||||
})
|
||||
}
|
||||
|
||||
const lastValue = (arr) => {
|
||||
for (let i = arr.length - 1; i >= 0; i -= 1) {
|
||||
if (Number.isFinite(arr[i])) return arr[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const renderTrendList = (data, targetId = 'trendList') => {
|
||||
const box = el(targetId)
|
||||
if (!box) return
|
||||
box.innerHTML = ''
|
||||
const hr = data.trends.hr_avg || []
|
||||
const resp = data.trends.resp_avg || []
|
||||
const weight = data.trends.weight || []
|
||||
const distance = data.trends.distance || []
|
||||
const sleep = data.trends.sleep_hours || []
|
||||
const stand = data.trends.stand_hours || []
|
||||
const workout = data.trends.workout_minutes || []
|
||||
const days = Math.max(1, data.trends.dates.length || 1)
|
||||
const totalDistance = distance.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||
const avgDistance = totalDistance / days
|
||||
const maxHr = Math.max(...hr.filter(v => Number.isFinite(v)), 0)
|
||||
const lastHr = lastValue(hr)
|
||||
const lastResp = lastValue(resp)
|
||||
const lastWeight = lastValue(weight)
|
||||
const lastSleep = lastValue(sleep)
|
||||
const lastStand = lastValue(stand)
|
||||
const lastWorkout = lastValue(workout)
|
||||
const items = [
|
||||
{ title: '最近心率', value: lastHr, unit: 'bpm' },
|
||||
{ title: '最近呼吸率', value: lastResp, unit: '次/分钟' },
|
||||
{ title: '最近体重', value: lastWeight, unit: 'kg' },
|
||||
{ title: '最高心率', value: maxHr, unit: 'bpm' },
|
||||
{ title: '日均步行距离', value: avgDistance, unit: 'km' },
|
||||
{ title: '最近睡眠', value: lastSleep, unit: '小时' },
|
||||
{ title: '最近站立', value: lastStand, unit: '小时' },
|
||||
{ title: '最近运动', value: lastWorkout, unit: '分钟' }
|
||||
]
|
||||
const hasData = hr.some(v => Number.isFinite(v)) || resp.some(v => Number.isFinite(v)) || weight.some(v => Number.isFinite(v)) || distance.some(v => Number.isFinite(v)) || sleep.some(v => Number.isFinite(v)) || stand.some(v => Number.isFinite(v)) || workout.some(v => Number.isFinite(v))
|
||||
if (!hasData) {
|
||||
box.innerHTML = '<div class="list-item"><span class="muted">暂无趋势数据</span></div>'
|
||||
return
|
||||
}
|
||||
items.forEach(item => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>${item.title}</span><span class="tag">${fmtNum(item.value, 1)} ${item.unit}</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
}
|
||||
|
||||
const renderAnomalies = (data, targetId = 'anomalyList') => {
|
||||
const box = el(targetId)
|
||||
if (!box) return
|
||||
box.innerHTML = ''
|
||||
const items = []
|
||||
data.anomalies.hr.forEach(a => items.push({ type: '心率', date: a.date, value: a.value, unit: 'bpm' }))
|
||||
data.anomalies.resp.forEach(a => items.push({ type: '呼吸率', date: a.date, value: a.value, unit: '次/分钟' }))
|
||||
if (!items.length) {
|
||||
box.innerHTML = '<div class="list-item"><span class="muted">暂无异常高值</span></div>'
|
||||
return
|
||||
}
|
||||
items.slice(-10).reverse().forEach(item => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>${item.type} ${fmtDate(item.date)}</span><span class="tag">${fmtNum(item.value, 1)} ${item.unit}</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
}
|
||||
|
||||
const renderDeep = (data, targetId = 'deepList') => {
|
||||
const box = el(targetId)
|
||||
if (!box) return
|
||||
box.innerHTML = ''
|
||||
const routes = data.deep.routes || []
|
||||
const ecg = data.deep.ecg || []
|
||||
const workouts = data.deep.workouts || []
|
||||
if (!routes.length && !ecg.length && !workouts.length) {
|
||||
box.innerHTML = '<div class="list-item"><span class="muted">暂无深度数据</span></div>'
|
||||
return
|
||||
}
|
||||
workouts.forEach(r => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>训练 ${fmtDate(r.start_time)}</span><span class="tag">${fmtNum(r.duration_min, 1)} 分钟 · ${fmtNum(r.distance_km, 2)} km</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
routes.forEach(r => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>路线 ${fmtDate(r.route_time)}</span><span class="tag">${fmtNum(r.distance_km, 2)} km · ${fmtNum(r.duration_min, 1)} 分钟</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
ecg.forEach(r => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>ECG ${fmtDate(r.record_time)}</span><span class="tag">${r.classification || '未知'} · ${fmtNum(r.sampling_rate, 0)} Hz</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
}
|
||||
|
||||
const renderHddSummary = (data) => {
|
||||
const trends = getTrendData(data)
|
||||
setText('hddAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||
setText('hddAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||
setText('hddAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||
setText('hddAvgStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||
setText('hddAvgWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||
setText('hddDistance', fmtNum(sumOf(trends.distance), 1))
|
||||
setText('hddRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||
setText('hddHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||
}
|
||||
|
||||
const renderAhaSummary = (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const sleepTotal = sumOf(trends.sleepRem) + sumOf(trends.sleepCore) + sumOf(trends.sleepDeep) + sumOf(trends.sleepAwake)
|
||||
const remPct = sleepTotal ? (sumOf(trends.sleepRem) / sleepTotal) * 100 : null
|
||||
const deepPct = sleepTotal ? (sumOf(trends.sleepDeep) / sleepTotal) * 100 : null
|
||||
const weightFirst = firstValue(trends.weight)
|
||||
const weightLast = lastValue(trends.weight)
|
||||
const weightDelta = Number.isFinite(weightFirst) && Number.isFinite(weightLast) ? weightLast - weightFirst : null
|
||||
setText('ahaRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||
setText('ahaWalkingHr', fmtNum(avgOf(trends.walkingHr), 1))
|
||||
setText('ahaHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||
setText('ahaRemPct', fmtNum(remPct, 1))
|
||||
setText('ahaDeepPct', fmtNum(deepPct, 1))
|
||||
setText('ahaWeightDelta', fmtNum(weightDelta, 2))
|
||||
setText('ahaWorkoutTotal', fmtNum(sumOf(trends.workout), 0))
|
||||
setText('ahaAnomalyCount', data.anomalies.hr ? data.anomalies.hr.length : 0)
|
||||
}
|
||||
|
||||
const renderGrafanaSummary = (data) => {
|
||||
const trends = getTrendData(data)
|
||||
setText('grafanaAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||
setText('grafanaAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||
setText('grafanaAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||
setText('grafanaAvgWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||
setText('grafanaAvgStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||
setText('grafanaDistance', fmtNum(sumOf(trends.distance), 1))
|
||||
setText('grafanaRoutes', data.summary.route_count || 0)
|
||||
setText('grafanaEcg', data.summary.ecg_count || 0)
|
||||
}
|
||||
|
||||
const renderDhdSummary = (data) => {
|
||||
const trends = getTrendData(data)
|
||||
setText('dhdAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||
setText('dhdAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||
setText('dhdRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||
setText('dhdWalkingHr', fmtNum(avgOf(trends.walkingHr), 1))
|
||||
setText('dhdHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||
setText('dhdDistance', fmtNum(data.cards.total_distance_km, 1))
|
||||
setText('dhdWeight', fmtNum(data.latest.weight_kg, 1))
|
||||
setText('dhdWeightUnit', data.latest.weight_unit || 'kg')
|
||||
setText('dhdAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||
}
|
||||
|
||||
const renderHddCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { days, labels, hr, resp, workout, distance, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const vCtx = el('chartHddVitals').getContext('2d')
|
||||
state.charts.hddVitals = new Chart(vCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const aCtx = el('chartHddActivity').getContext('2d')
|
||||
state.charts.hddActivity = new Chart(aCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const sCtx = el('chartHddSleepStages').getContext('2d')
|
||||
state.charts.hddSleepStages = new Chart(sCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||
})
|
||||
const dCtx = el('chartHddDistance').getContext('2d')
|
||||
state.charts.hddDistance = new Chart(dCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#60a5fa' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
} else {
|
||||
drawSimpleLine(el('chartHddVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||
drawSimpleBars(el('chartHddActivity'), labels, workout)
|
||||
drawSimpleBars(el('chartHddDistance'), labels, distance)
|
||||
}
|
||||
setText('chartHddVitalsMeta', `${days}天心率与呼吸率`)
|
||||
setText('chartHddActivityMeta', `${days}天运动分钟`)
|
||||
setText('chartHddSleepStagesMeta', `${days}天睡眠分期`)
|
||||
setText('chartHddDistanceMeta', `${days}天步行距离`)
|
||||
}
|
||||
|
||||
const renderAhaCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { days, labels, weight, workout, hrv, restingHr, walkingHr, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const hCtx = el('chartAhaHeart').getContext('2d')
|
||||
state.charts.ahaHeart = new Chart(hCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'bpm' } },
|
||||
y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const sCtx = el('chartAhaSleepStages').getContext('2d')
|
||||
state.charts.ahaSleepStages = new Chart(sCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||
})
|
||||
const wCtx = el('chartAhaWeight').getContext('2d')
|
||||
state.charts.ahaWeight = new Chart(wCtx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: '体重', data: weight, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const aCtx = el('chartAhaWorkout').getContext('2d')
|
||||
state.charts.ahaWorkout = new Chart(aCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
} else {
|
||||
drawSimpleLine(el('chartAhaHeart'), labels, [restingHr, walkingHr], ['#f43f5e', '#ec4899'])
|
||||
drawSimpleLine(el('chartAhaWeight'), labels, [weight], ['#a78bfa'])
|
||||
drawSimpleBars(el('chartAhaWorkout'), labels, workout)
|
||||
}
|
||||
setText('chartAhaHeartMeta', `${days}天心脏恢复趋势`)
|
||||
setText('chartAhaSleepStagesMeta', `${days}天睡眠结构`)
|
||||
setText('chartAhaWeightMeta', `${days}天体重变化`)
|
||||
setText('chartAhaWorkoutMeta', `${days}天运动分钟`)
|
||||
}
|
||||
|
||||
const renderGrafanaCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { days, labels, workout, distance, sleepRem, sleepCore, sleepDeep, sleepAwake, restingHr, walkingHr, hrv } = trends
|
||||
const routes = (data.deep && data.deep.routes) ? data.deep.routes : []
|
||||
const { typeLabels, typeValues } = getTypeSummary(data)
|
||||
const sleepTotals = [sumOf(sleepRem), sumOf(sleepCore), sumOf(sleepDeep), sumOf(sleepAwake)]
|
||||
const sleepLabels = ['REM', '核心', '深睡', '清醒']
|
||||
const routePoints = routes.map(r => ({
|
||||
x: Number(r.distance_km || 0),
|
||||
y: Number(r.duration_min || 0)
|
||||
})).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const aCtx = el('chartGrafanaActivity').getContext('2d')
|
||||
state.charts.grafanaActivity = new Chart(aCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ type: 'bar', label: '运动分钟', data: workout, backgroundColor: '#fb7185', yAxisID: 'y' },
|
||||
{ type: 'line', label: '步行距离', data: distance, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { title: { display: true, text: '分钟' } },
|
||||
y1: { position: 'right', title: { display: true, text: 'km' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const sCtx = el('chartGrafanaSleepStages').getContext('2d')
|
||||
state.charts.grafanaSleep = new Chart(sCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||
})
|
||||
const hCtx = el('chartGrafanaHeart').getContext('2d')
|
||||
state.charts.grafanaHeart = new Chart(hCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { type: 'linear', position: 'left', title: { display: true, text: 'bpm' } },
|
||||
y1: { type: 'linear', position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const tCtx = el('chartGrafanaTypes').getContext('2d')
|
||||
state.charts.grafanaTypes = new Chart(tCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: typeLabels.slice(0, 8),
|
||||
datasets: [{ data: typeValues.slice(0, 8), backgroundColor: ['#38bdf8', '#f472b6', '#facc15', '#34d399', '#a78bfa', '#fb7185', '#f97316', '#22d3ee'] }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||
})
|
||||
const rCtx = el('chartGrafanaRouteMap').getContext('2d')
|
||||
state.charts.grafanaRoutes = new Chart(rCtx, {
|
||||
type: 'scatter',
|
||||
data: {
|
||||
datasets: [{ label: '路线', data: routePoints, backgroundColor: '#22d3ee' }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: '距离 km' } },
|
||||
y: { title: { display: true, text: '时长 min' } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const pCtx = el('chartGrafanaSleepPct').getContext('2d')
|
||||
state.charts.grafanaSleepPct = new Chart(pCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sleepLabels,
|
||||
datasets: [{ data: sleepTotals, backgroundColor: ['#60a5fa', '#34d399', '#818cf8', '#fb923c'] }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||
})
|
||||
} else {
|
||||
drawSimpleBars(el('chartGrafanaActivity'), labels, workout)
|
||||
drawSimpleBars(el('chartGrafanaSleepStages'), labels, sleepRem)
|
||||
drawSimpleLine(el('chartGrafanaHeart'), labels, [restingHr, walkingHr], ['#f43f5e', '#ec4899'])
|
||||
drawSimplePie(el('chartGrafanaTypes'), typeValues.slice(0, 8), ['#38bdf8', '#f472b6', '#facc15', '#34d399', '#a78bfa', '#fb7185', '#f97316', '#22d3ee'])
|
||||
drawSimpleScatter(el('chartGrafanaRouteMap'), routePoints, '#22d3ee')
|
||||
drawSimplePie(el('chartGrafanaSleepPct'), sleepTotals, ['#60a5fa', '#34d399', '#818cf8', '#fb923c'])
|
||||
}
|
||||
setText('chartGrafanaActivityMeta', `${days}天活动趋势`)
|
||||
setText('chartGrafanaSleepStagesMeta', `${days}天睡眠分期`)
|
||||
setText('chartGrafanaHeartMeta', `${days}天心脏恢复`)
|
||||
setText('chartGrafanaTypesMeta', '高频指标分布')
|
||||
setText('chartGrafanaRouteMapMeta', routePoints.length ? '路线距离/时长分布' : '暂无路线数据')
|
||||
setText('chartGrafanaSleepPctMeta', '睡眠分期占比')
|
||||
}
|
||||
|
||||
const renderDhdCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { days, labels, hr, resp, restingHr, hrv, sleep, distance } = trends
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const vCtx = el('chartDhdVitals').getContext('2d')
|
||||
state.charts.dhdVitals = new Chart(vCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const rCtx = el('chartDhdRecovery').getContext('2d')
|
||||
state.charts.dhdRecovery = new Chart(rCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { title: { display: true, text: 'bpm' } },
|
||||
y1: { position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const sCtx = el('chartDhdSleep').getContext('2d')
|
||||
state.charts.dhdSleep = new Chart(sCtx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: '睡眠时长', data: sleep, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const dCtx = el('chartDhdDistance').getContext('2d')
|
||||
state.charts.dhdDistance = new Chart(dCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#22d3ee' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
} else {
|
||||
drawSimpleLine(el('chartDhdVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||
drawSimpleLine(el('chartDhdRecovery'), labels, [restingHr, hrv], ['#f43f5e', '#a855f7'])
|
||||
drawSimpleLine(el('chartDhdSleep'), labels, [sleep], ['#60a5fa'])
|
||||
drawSimpleBars(el('chartDhdDistance'), labels, distance)
|
||||
}
|
||||
setText('chartDhdVitalsMeta', `${days}天心肺趋势`)
|
||||
setText('chartDhdRecoveryMeta', `${days}天恢复指标`)
|
||||
setText('chartDhdSleepMeta', `${days}天睡眠时长`)
|
||||
setText('chartDhdDistanceMeta', `${days}天步行距离`)
|
||||
}
|
||||
|
||||
const renderActiveTab = async () => {
|
||||
if (!state.lastData) return
|
||||
if (state.activeTab === 'grafana') {
|
||||
await renderGrafanaCharts(state.lastData)
|
||||
return
|
||||
}
|
||||
if (state.activeTab === 'dhd') {
|
||||
await renderDhdCharts(state.lastData)
|
||||
return
|
||||
}
|
||||
if (state.activeTab === 'hdd') {
|
||||
await renderHddCharts(state.lastData)
|
||||
return
|
||||
}
|
||||
if (state.activeTab === 'aha') {
|
||||
await renderAhaCharts(state.lastData)
|
||||
return
|
||||
}
|
||||
await renderDefaultCharts(state.lastData)
|
||||
}
|
||||
|
||||
const setupTabs = () => {
|
||||
const tabs = Array.from(document.querySelectorAll('.tab-btn'))
|
||||
const pages = Array.from(document.querySelectorAll('.tab-page'))
|
||||
if (!tabs.length || !pages.length) return
|
||||
const activate = (key) => {
|
||||
tabs.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === key))
|
||||
pages.forEach(page => page.classList.toggle('active', page.dataset.tabPage === key))
|
||||
state.activeTab = key
|
||||
renderActiveTab()
|
||||
}
|
||||
tabs.forEach(btn => btn.addEventListener('click', () => activate(btn.dataset.tab)))
|
||||
const initialKey = tabs.find(btn => btn.classList.contains('active'))?.dataset.tab || tabs[0].dataset.tab
|
||||
activate(initialKey)
|
||||
}
|
||||
|
||||
const loadDashboard = async () => {
|
||||
const overlay = el('loadingOverlay')
|
||||
if (overlay) overlay.classList.add('visible')
|
||||
|
||||
try {
|
||||
const days = Number(el('daysRange').value || 90)
|
||||
const res = await fetch(`/api/apple_watch/dashboard?days=${days}`)
|
||||
const json = await res.json()
|
||||
if (!json.ok) throw new Error(json.error || 'load_failed')
|
||||
state.lastData = json.data
|
||||
updateSummary(json.data)
|
||||
renderHddSummary(json.data)
|
||||
renderAhaSummary(json.data)
|
||||
renderGrafanaSummary(json.data)
|
||||
renderDhdSummary(json.data)
|
||||
renderTypeList(json.data)
|
||||
renderTrendList(json.data)
|
||||
renderTypeList(json.data, 'hddTypeList')
|
||||
renderTrendList(json.data, 'hddTrendList')
|
||||
renderTypeList(json.data, 'grafanaTypeList')
|
||||
renderTrendList(json.data, 'grafanaTrendList')
|
||||
renderTypeList(json.data, 'dhdTypeList')
|
||||
renderAnomalies(json.data)
|
||||
renderDeep(json.data)
|
||||
renderAnomalies(json.data, 'ahaAnomalyList')
|
||||
renderDeep(json.data, 'ahaDeepList')
|
||||
renderAnomalies(json.data, 'dhdAnomalyList')
|
||||
await renderActiveTab()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('加载失败: ' + e.message)
|
||||
} finally {
|
||||
if (overlay) overlay.classList.remove('visible')
|
||||
}
|
||||
}
|
||||
|
||||
const runImport = async () => {
|
||||
const res = await fetch('/api/apple_watch/import', { method: 'POST' })
|
||||
const json = await res.json()
|
||||
if (!json.ok) throw new Error(json.error || 'import_failed')
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
const refresh = async () => {
|
||||
setText('statLastImport', '加载中')
|
||||
await loadDashboard()
|
||||
}
|
||||
setupTabs()
|
||||
el('btnRefresh').addEventListener('click', refresh)
|
||||
el('daysRange').addEventListener('change', refresh)
|
||||
el('btnImport').addEventListener('click', async () => {
|
||||
setText('statLastImport', '同步中')
|
||||
try {
|
||||
await runImport()
|
||||
await loadDashboard()
|
||||
} catch {
|
||||
setText('statLastImport', '同步失败')
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
|
||||
init()
|
||||
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Apple Watch 数据洞察</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.loading-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(56, 189, 248, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #38bdf8;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
.loading-text {
|
||||
color: #e2e8f0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loadingOverlay" class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">正在处理数据...</div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<div class="title">
|
||||
<div class="title-main">Apple Watch 数据洞察</div>
|
||||
<div class="title-sub">Apple Health 导出数据 · 近况与趋势</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<select id="daysRange">
|
||||
<option value="30">近30天</option>
|
||||
<option value="90" selected>近90天</option>
|
||||
<option value="180">近180天</option>
|
||||
<option value="365">近365天</option>
|
||||
</select>
|
||||
<button id="btnRefresh" class="primary">刷新</button>
|
||||
<button id="btnImport" class="ghost">同步导出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs-bar">
|
||||
<button class="tab-btn active" data-tab="default">Default Health</button>
|
||||
<button class="tab-btn" data-tab="hdd">Health Data Dashboard</button>
|
||||
<button class="tab-btn" data-tab="aha">Apple Health Analytics</button>
|
||||
<button class="tab-btn" data-tab="grafana">Grafana Health</button>
|
||||
<button class="tab-btn" data-tab="dhd">Digital Health Data</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-page active" data-tab-page="default">
|
||||
<section class="cards" id="summaryCards">
|
||||
<div class="card">
|
||||
<div class="card-label">记录总量</div>
|
||||
<div class="card-value" id="statRecords">0</div>
|
||||
<div class="card-meta" id="statDateRange">-</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">数据类型</div>
|
||||
<div class="card-value" id="statTypes">0</div>
|
||||
<div class="card-meta" id="statLastImport">-</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">运动路线</div>
|
||||
<div class="card-value" id="statRoutes">0</div>
|
||||
<div class="card-meta">GPS 轨迹</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">ECG 记录</div>
|
||||
<div class="card-value" id="statEcg">0</div>
|
||||
<div class="card-meta">心电采样</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均心率</div>
|
||||
<div class="card-value" id="statAvgHr">0</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均呼吸率</div>
|
||||
<div class="card-value" id="statAvgResp">0</div>
|
||||
<div class="card-meta">次/分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">累计步行距离</div>
|
||||
<div class="card-value" id="statDistance">0</div>
|
||||
<div class="card-meta">公里</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">最近体重</div>
|
||||
<div class="card-value" id="statWeight">-</div>
|
||||
<div class="card-meta" id="statWeightUnit">kg</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">最近身高</div>
|
||||
<div class="card-value" id="statHeight">-</div>
|
||||
<div class="card-meta" id="statHeightUnit">cm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均睡眠</div>
|
||||
<div class="card-value" id="statSleep">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日站立小时</div>
|
||||
<div class="card-value" id="statStand">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日运动分钟</div>
|
||||
<div class="card-value" id="statWorkout">-</div>
|
||||
<div class="card-meta">分钟</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心率与呼吸率趋势</div>
|
||||
<canvas id="chartVitals"></canvas>
|
||||
<div class="panel-foot" id="chartVitalsMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">体重趋势</div>
|
||||
<canvas id="chartWeight"></canvas>
|
||||
<div class="panel-foot" id="chartWeightMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">步行距离</div>
|
||||
<canvas id="chartDistance"></canvas>
|
||||
<div class="panel-foot" id="chartDistanceMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">高频数据类型</div>
|
||||
<canvas id="chartTypes"></canvas>
|
||||
<div class="panel-foot" id="chartTypesMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠与站立</div>
|
||||
<canvas id="chartSleep"></canvas>
|
||||
<div class="panel-foot" id="chartSleepMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">运动分钟趋势</div>
|
||||
<canvas id="chartWorkout"></canvas>
|
||||
<div class="panel-foot" id="chartWorkoutMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠分期</div>
|
||||
<canvas id="chartSleepStages"></canvas>
|
||||
<div class="panel-foot" id="chartSleepStagesMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">心脏健康</div>
|
||||
<canvas id="chartHeartHealth"></canvas>
|
||||
<div class="panel-foot" id="chartHeartHealthMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">类型榜单</div>
|
||||
<div class="list" id="typeList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">趋势概览</div>
|
||||
<div class="list" id="trendList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">异常提醒</div>
|
||||
<div class="list" id="anomalyList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">深度数据</div>
|
||||
<div class="list" id="deepList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" data-tab-page="hdd">
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均心率</div>
|
||||
<div class="card-value" id="hddAvgHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均呼吸率</div>
|
||||
<div class="card-value" id="hddAvgResp">-</div>
|
||||
<div class="card-meta">次/分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均睡眠</div>
|
||||
<div class="card-value" id="hddAvgSleep">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日站立小时</div>
|
||||
<div class="card-value" id="hddAvgStand">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日运动分钟</div>
|
||||
<div class="card-value" id="hddAvgWorkout">-</div>
|
||||
<div class="card-meta">分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期步行距离</div>
|
||||
<div class="card-value" id="hddDistance">-</div>
|
||||
<div class="card-meta">公里</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期静息心率</div>
|
||||
<div class="card-value" id="hddRestingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期 HRV</div>
|
||||
<div class="card-value" id="hddHrv">-</div>
|
||||
<div class="card-meta">ms</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心肺趋势</div>
|
||||
<canvas id="chartHddVitals"></canvas>
|
||||
<div class="panel-foot" id="chartHddVitalsMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">活动趋势</div>
|
||||
<canvas id="chartHddActivity"></canvas>
|
||||
<div class="panel-foot" id="chartHddActivityMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠分期</div>
|
||||
<canvas id="chartHddSleepStages"></canvas>
|
||||
<div class="panel-foot" id="chartHddSleepStagesMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">步行距离</div>
|
||||
<canvas id="chartHddDistance"></canvas>
|
||||
<div class="panel-foot" id="chartHddDistanceMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">类型榜单</div>
|
||||
<div class="list" id="hddTypeList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">趋势概览</div>
|
||||
<div class="list" id="hddTrendList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" data-tab-page="aha">
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期静息心率</div>
|
||||
<div class="card-value" id="ahaRestingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期步行心率</div>
|
||||
<div class="card-value" id="ahaWalkingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期 HRV</div>
|
||||
<div class="card-value" id="ahaHrv">-</div>
|
||||
<div class="card-meta">ms</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">REM 占比</div>
|
||||
<div class="card-value" id="ahaRemPct">-</div>
|
||||
<div class="card-meta">%</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">深睡占比</div>
|
||||
<div class="card-value" id="ahaDeepPct">-</div>
|
||||
<div class="card-meta">%</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">体重变化</div>
|
||||
<div class="card-value" id="ahaWeightDelta">-</div>
|
||||
<div class="card-meta">kg</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">运动分钟总量</div>
|
||||
<div class="card-value" id="ahaWorkoutTotal">-</div>
|
||||
<div class="card-meta">分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">异常心率次数</div>
|
||||
<div class="card-value" id="ahaAnomalyCount">-</div>
|
||||
<div class="card-meta">次</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心脏恢复趋势</div>
|
||||
<canvas id="chartAhaHeart"></canvas>
|
||||
<div class="panel-foot" id="chartAhaHeartMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠结构</div>
|
||||
<canvas id="chartAhaSleepStages"></canvas>
|
||||
<div class="panel-foot" id="chartAhaSleepStagesMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">体重趋势</div>
|
||||
<canvas id="chartAhaWeight"></canvas>
|
||||
<div class="panel-foot" id="chartAhaWeightMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">运动分钟</div>
|
||||
<canvas id="chartAhaWorkout"></canvas>
|
||||
<div class="panel-foot" id="chartAhaWorkoutMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">异常提醒</div>
|
||||
<div class="list" id="ahaAnomalyList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">深度数据</div>
|
||||
<div class="list" id="ahaDeepList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" data-tab-page="grafana">
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均心率</div>
|
||||
<div class="card-value" id="grafanaAvgHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均呼吸率</div>
|
||||
<div class="card-value" id="grafanaAvgResp">-</div>
|
||||
<div class="card-meta">次/分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均睡眠</div>
|
||||
<div class="card-value" id="grafanaAvgSleep">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日运动分钟</div>
|
||||
<div class="card-value" id="grafanaAvgWorkout">-</div>
|
||||
<div class="card-meta">分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日站立小时</div>
|
||||
<div class="card-value" id="grafanaAvgStand">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期步行距离</div>
|
||||
<div class="card-value" id="grafanaDistance">-</div>
|
||||
<div class="card-meta">公里</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">运动路线</div>
|
||||
<div class="card-value" id="grafanaRoutes">-</div>
|
||||
<div class="card-meta">GPS 轨迹</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">ECG 记录</div>
|
||||
<div class="card-value" id="grafanaEcg">-</div>
|
||||
<div class="card-meta">心电采样</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">整体活动</div>
|
||||
<canvas id="chartGrafanaActivity"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaActivityMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠分期</div>
|
||||
<canvas id="chartGrafanaSleepStages"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaSleepStagesMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心脏恢复</div>
|
||||
<canvas id="chartGrafanaHeart"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaHeartMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">指标分布</div>
|
||||
<canvas id="chartGrafanaTypes"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaTypesMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">路线地图</div>
|
||||
<canvas id="chartGrafanaRouteMap"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaRouteMapMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠占比</div>
|
||||
<canvas id="chartGrafanaSleepPct"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaSleepPctMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">类型榜单</div>
|
||||
<div class="list" id="grafanaTypeList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">趋势概览</div>
|
||||
<div class="list" id="grafanaTrendList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" data-tab-page="dhd">
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均心率</div>
|
||||
<div class="card-value" id="dhdAvgHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均呼吸率</div>
|
||||
<div class="card-value" id="dhdAvgResp">-</div>
|
||||
<div class="card-meta">次/分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期静息心率</div>
|
||||
<div class="card-value" id="dhdRestingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期步行心率</div>
|
||||
<div class="card-value" id="dhdWalkingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期 HRV</div>
|
||||
<div class="card-value" id="dhdHrv">-</div>
|
||||
<div class="card-meta">ms</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">累计步行距离</div>
|
||||
<div class="card-value" id="dhdDistance">-</div>
|
||||
<div class="card-meta">公里</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">最近体重</div>
|
||||
<div class="card-value" id="dhdWeight">-</div>
|
||||
<div class="card-meta" id="dhdWeightUnit">kg</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均睡眠</div>
|
||||
<div class="card-value" id="dhdAvgSleep">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心肺趋势</div>
|
||||
<canvas id="chartDhdVitals"></canvas>
|
||||
<div class="panel-foot" id="chartDhdVitalsMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">恢复趋势</div>
|
||||
<canvas id="chartDhdRecovery"></canvas>
|
||||
<div class="panel-foot" id="chartDhdRecoveryMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠时长</div>
|
||||
<canvas id="chartDhdSleep"></canvas>
|
||||
<div class="panel-foot" id="chartDhdSleepMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">步行距离</div>
|
||||
<canvas id="chartDhdDistance"></canvas>
|
||||
<div class="panel-foot" id="chartDhdDistanceMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">类型榜单</div>
|
||||
<div class="list" id="dhdTypeList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">异常提醒</div>
|
||||
<div class="list" id="dhdAnomalyList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,219 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft Yahei", sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 60px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-main {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title-sub {
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #1e293b;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tab-page {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-page.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
select, button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #1e293b;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
background: #111827;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.card.accent {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(15, 118, 110, 0.2));
|
||||
border-color: rgba(37, 99, 235, 0.5);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #0b1220;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #1f2937;
|
||||
padding: 16px;
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 15px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin-top: auto;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-meta {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 20px 16px 40px;
|
||||
}
|
||||
.actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs-bar {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Microsoft YaHei", Arial, sans-serif;
|
||||
background: #f3f6fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
border: 1px solid #e5edf6;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.mini-title {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #e0ecff;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inline-actions.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #c9d7eb;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #cdd8e6;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.progress-block {
|
||||
margin-top: 14px;
|
||||
border: 1px solid #e5edf6;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: #dbe7f7;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-inner {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #38bdf8);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
margin: 14px 0 0;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
min-height: 110px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid #e5edf6;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.catalog-wrap {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.alerts-wrap {
|
||||
max-height: 220px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.logs-wrap {
|
||||
max-height: 340px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fbff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.textarea-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.textarea-grid {
|
||||
margin-top: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.field-inline {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-sep {
|
||||
margin: 0 6px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf5;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.notice-err {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
(() => {
|
||||
const el = id => document.getElementById(id)
|
||||
let currentConfig = null
|
||||
let pollTimer = null
|
||||
|
||||
const esc = s => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
const setNotice = (text, isError) => {
|
||||
const box = el('save-notice')
|
||||
if (!box) return
|
||||
if (!text) {
|
||||
box.style.display = 'none'
|
||||
box.textContent = ''
|
||||
box.className = 'notice'
|
||||
return
|
||||
}
|
||||
box.style.display = ''
|
||||
box.textContent = text
|
||||
box.className = isError ? 'notice notice-err' : 'notice'
|
||||
}
|
||||
|
||||
const setAuthWarning = text => {
|
||||
const box = el('auth-warning')
|
||||
if (!box) return
|
||||
if (!text) {
|
||||
box.style.display = 'none'
|
||||
box.textContent = ''
|
||||
return
|
||||
}
|
||||
box.style.display = ''
|
||||
box.textContent = text
|
||||
}
|
||||
|
||||
const getJson = async url => {
|
||||
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}_ts=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
const data = await resp.json().catch(() => null)
|
||||
if (resp.status === 401) throw new Error('UNAUTHORIZED')
|
||||
if (!resp.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${resp.status}`)
|
||||
return data
|
||||
}
|
||||
|
||||
const postJson = async (url, body) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body || {})
|
||||
})
|
||||
const data = await resp.json().catch(() => null)
|
||||
if (resp.status === 401) throw new Error('UNAUTHORIZED')
|
||||
if (!resp.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${resp.status}`)
|
||||
return data
|
||||
}
|
||||
|
||||
const formatStatus = status => {
|
||||
const map = {
|
||||
success: '成功',
|
||||
partial_success: '部分成功',
|
||||
failed: '失败',
|
||||
running: '运行中',
|
||||
idle: '空闲'
|
||||
}
|
||||
return map[String(status || '')] || (status || '-')
|
||||
}
|
||||
|
||||
const formatSize = bytes => {
|
||||
const n = Number(bytes || 0)
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = n
|
||||
let idx = 0
|
||||
while (value >= 1024 && idx < units.length - 1) {
|
||||
value /= 1024
|
||||
idx += 1
|
||||
}
|
||||
return `${value >= 100 || idx === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[idx]}`
|
||||
}
|
||||
|
||||
const fillLines = (id, values) => {
|
||||
const node = el(id)
|
||||
if (!node) return
|
||||
node.value = Array.isArray(values) ? values.join('\n') : ''
|
||||
}
|
||||
|
||||
const readLines = id => {
|
||||
const node = el(id)
|
||||
return String((node && node.value) || '')
|
||||
.split(/\r?\n/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const renderOverview = data => {
|
||||
const high = data.highfreq || {}
|
||||
const low = data.lowfreq || {}
|
||||
const current = data.current_run || {}
|
||||
const manual = data.manual_baidu_backup || {}
|
||||
const scheduler = data.scheduler || {}
|
||||
const verify = data.verify || {}
|
||||
const restore = data.restore || {}
|
||||
|
||||
el('highfreq-status').textContent = formatStatus(high.last_status || '-')
|
||||
el('highfreq-last').textContent = `上次:${high.last_run_at || '-'}`
|
||||
el('highfreq-next').textContent = `下次:${scheduler.next_highfreq_at || '-'}`
|
||||
|
||||
el('lowfreq-status').textContent = formatStatus(low.last_status || '-')
|
||||
el('lowfreq-last').textContent = `上次:${low.last_run_at || '-'}`
|
||||
el('lowfreq-next').textContent = `下次:${scheduler.next_lowfreq_at || '-'}`
|
||||
|
||||
el('current-run-status').textContent = formatStatus(current.status || 'idle')
|
||||
el('current-run-stage').textContent = `阶段:${current.stage || '-'}`
|
||||
el('current-run-message').textContent = `消息:${current.message || '-'}`
|
||||
|
||||
el('manual-last').textContent = manual.last_recorded_at || '-'
|
||||
el('oss-ready').textContent = data.oss_ready ? 'OSS状态:已就绪' : 'OSS状态:未就绪'
|
||||
el('verify-last').textContent = `最近抽检:${verify.last_run_at ? `${formatStatus(verify.last_status || '-')} / ${verify.last_run_at}` : '-'}`
|
||||
el('restore-last').textContent = `最近演练:${restore.last_run_at ? `${formatStatus(restore.last_status || '-')} / ${restore.last_run_at}` : '-'}`
|
||||
el('credential-file').textContent = `凭证文件:${data.credential_file || '-'}`
|
||||
}
|
||||
|
||||
const renderCurrentRun = run => {
|
||||
const current = run || {}
|
||||
const percent = Math.max(0, Math.min(100, Number(current.percent || 0)))
|
||||
el('progress-title').textContent = current.run_id ? `${formatStatus(current.status)} / ${current.mode || '-'}` : '当前无任务'
|
||||
el('progress-percent').textContent = `${percent}%`
|
||||
el('progress-inner').style.width = `${percent}%`
|
||||
el('progress-detail').textContent = current.message || '等待任务启动'
|
||||
const summary = current.stats && Object.keys(current.stats).length
|
||||
? JSON.stringify(current.stats, null, 2)
|
||||
: '暂无结果'
|
||||
el('run-summary').textContent = summary
|
||||
|
||||
const running = current.status === 'running'
|
||||
;['btn-run-high', 'btn-run-low', 'btn-run-all', 'btn-run-verify', 'btn-run-restore', 'btn-manual-record', 'btn-config-save'].forEach(id => {
|
||||
const node = el(id)
|
||||
if (node) node.disabled = running
|
||||
})
|
||||
}
|
||||
|
||||
const renderAlerts = rows => {
|
||||
const body = el('alerts-body')
|
||||
if (!body) return
|
||||
body.innerHTML = ''
|
||||
const list = Array.isArray(rows) ? rows : []
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="muted">暂无告警</td></tr>'
|
||||
return
|
||||
}
|
||||
body.innerHTML = list.map(item => `
|
||||
<tr>
|
||||
<td>${esc(item.created_at || '')}</td>
|
||||
<td>${esc(item.severity || '')}</td>
|
||||
<td>${esc(item.title || '')}</td>
|
||||
<td>${esc(item.message || '')}</td>
|
||||
</tr>
|
||||
`).join('')
|
||||
}
|
||||
|
||||
const renderLogs = rows => {
|
||||
const body = el('logs-body')
|
||||
if (!body) return
|
||||
body.innerHTML = ''
|
||||
const list = Array.isArray(rows) ? rows : []
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="muted">暂无日志</td></tr>'
|
||||
return
|
||||
}
|
||||
body.innerHTML = list.map(item => `
|
||||
<tr>
|
||||
<td>${esc(item.created_at || '')}</td>
|
||||
<td>${esc(item.mode || '')}</td>
|
||||
<td>${esc(item.event_type || '')}</td>
|
||||
<td>${esc(item.message || '')}</td>
|
||||
</tr>
|
||||
`).join('')
|
||||
}
|
||||
|
||||
const renderCatalog = payload => {
|
||||
const highBody = el('catalog-highfreq-body')
|
||||
const lowBody = el('catalog-lowfreq-body')
|
||||
if (!highBody || !lowBody) return
|
||||
const highfreqRuns = Array.isArray(payload.highfreq_runs) ? payload.highfreq_runs : []
|
||||
const lowfreqObjects = Array.isArray(payload.lowfreq_objects) ? payload.lowfreq_objects : []
|
||||
|
||||
if (!highfreqRuns.length) {
|
||||
highBody.innerHTML = '<tr><td colspan="6" class="muted">暂无高频批次</td></tr>'
|
||||
} else {
|
||||
highBody.innerHTML = highfreqRuns.map(item => {
|
||||
const high = item.highfreq || {}
|
||||
const key = esc(high.oss_key || '-')
|
||||
const runGuid = esc(high.run_guid || '-')
|
||||
const info = `${Number(high.file_count || 0)} 文件 / ${formatSize(high.zip_size_bytes || 0)}`
|
||||
const time = esc(item.ended_at || item.started_at || '')
|
||||
const status = esc(formatStatus(item.status || ''))
|
||||
const href = `/api/box_backup/download/highfreq?id=${encodeURIComponent(item.id)}`
|
||||
return `
|
||||
<tr>
|
||||
<td>${time || '-'}</td>
|
||||
<td>${status}</td>
|
||||
<td><div class="key-text">${runGuid}</div></td>
|
||||
<td>${esc(info)}</td>
|
||||
<td><div class="key-text">${key}</div></td>
|
||||
<td><a class="btn-link" href="${href}">下载归档</a></td>
|
||||
</tr>
|
||||
`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
if (!lowfreqObjects.length) {
|
||||
lowBody.innerHTML = '<tr><td colspan="6" class="muted">暂无低频对象</td></tr>'
|
||||
} else {
|
||||
lowBody.innerHTML = lowfreqObjects.map(item => {
|
||||
const meta = item.meta || {}
|
||||
const encryption = item.encryption || {}
|
||||
const storedName = esc(meta.stored_name || '-')
|
||||
const key = esc(item.oss_key || '-')
|
||||
const relPath = esc(item.relative_path || '-')
|
||||
const time = esc(item.last_uploaded_at || item.deleted_at || '')
|
||||
const statusTextBase = item.is_deleted ? `${formatStatus(item.upload_status || '')} / 已物理删除` : formatStatus(item.upload_status || '')
|
||||
const statusText = encryption.encrypted ? `${statusTextBase} / ${encryption.label || '加密文件'}` : statusTextBase
|
||||
const action = String(item.upload_status || '') === 'uploaded'
|
||||
? `${encryption.encrypted ? `<a class="btn-link" href="/api/box_backup/download/lowfreq_decrypted?id=${encodeURIComponent(item.id)}">解密下载</a><span class="action-sep">|</span>` : ''}<a class="btn-link" href="/api/box_backup/download/lowfreq?id=${encodeURIComponent(item.id)}">${encryption.encrypted ? '原件下载' : '下载文件'}</a>`
|
||||
: `<span class="muted">${esc(item.last_error || '-')}</span>`
|
||||
return `
|
||||
<tr>
|
||||
<td>${time || '-'}</td>
|
||||
<td>${esc(statusText)}</td>
|
||||
<td><div class="key-text">${relPath}</div></td>
|
||||
<td><div class="key-text">${storedName}</div></td>
|
||||
<td><div class="key-text">${key}</div></td>
|
||||
<td>${action}</td>
|
||||
</tr>
|
||||
`
|
||||
}).join('')
|
||||
}
|
||||
}
|
||||
|
||||
const renderConfig = payload => {
|
||||
currentConfig = payload.config || {}
|
||||
const cfg = currentConfig
|
||||
const status = payload.credential_status || {}
|
||||
el('cfg-high-cron').value = ((cfg.scheduler || {}).highfreq_cron) || ''
|
||||
el('cfg-low-cron').value = ((cfg.scheduler || {}).lowfreq_cron) || ''
|
||||
el('cfg-poll-seconds').value = ((cfg.scheduler || {}).poll_seconds) || 30
|
||||
el('cfg-oss-root-prefix').value = ((cfg.oss || {}).root_prefix) || status.root_prefix || ''
|
||||
el('cfg-oss-root-prefix-key').value = ((cfg.oss || {}).root_prefix_key) || 'BOX_BACKUP_OSS_ROOT_PREFIX'
|
||||
el('cfg-lowfreq-dir-name').value = ((cfg.oss || {}).lowfreq_dir_name) || status.lowfreq_dir_name || ''
|
||||
el('cfg-oss-bucket').value = ((cfg.oss || {}).bucket) || ''
|
||||
el('cfg-oss-endpoint').value = ((cfg.oss || {}).endpoint) || ''
|
||||
el('cfg-credential-file').value = ((cfg.oss || {}).credential_file_name) || ''
|
||||
el('cfg-ak-key').value = ((cfg.oss || {}).access_key_id_key) || ''
|
||||
el('cfg-sk-key').value = ((cfg.oss || {}).access_key_secret_key) || ''
|
||||
el('cfg-bucket-key').value = ((cfg.oss || {}).bucket_key) || ''
|
||||
el('cfg-endpoint-key').value = ((cfg.oss || {}).endpoint_key) || ''
|
||||
el('cfg-sample-size').value = ((cfg.verification || {}).lowfreq_sample_size) || 3
|
||||
el('cfg-restore-max-mb').value = ((cfg.verification || {}).restore_max_download_mb) || 2048
|
||||
fillLines('cfg-high-code-paths', (((cfg.paths || {}).highfreq || {}).code_paths) || [])
|
||||
fillLines('cfg-high-root-files', (((cfg.paths || {}).highfreq || {}).root_files) || [])
|
||||
fillLines('cfg-high-exts', (((cfg.paths || {}).highfreq || {}).tool_code_extensions) || [])
|
||||
fillLines('cfg-low-paths', (((cfg.paths || {}).lowfreq || {}).include_paths) || [])
|
||||
fillLines('cfg-high-excludes', (((cfg.paths || {}).highfreq || {}).exclude_dirs) || [])
|
||||
fillLines('cfg-low-excludes', (((cfg.paths || {}).lowfreq || {}).exclude_dirs) || [])
|
||||
}
|
||||
|
||||
const collectConfig = () => {
|
||||
const cfg = JSON.parse(JSON.stringify(currentConfig || {}))
|
||||
cfg.scheduler = cfg.scheduler || {}
|
||||
cfg.paths = cfg.paths || {}
|
||||
cfg.paths.highfreq = cfg.paths.highfreq || {}
|
||||
cfg.paths.lowfreq = cfg.paths.lowfreq || {}
|
||||
cfg.oss = cfg.oss || {}
|
||||
|
||||
cfg.scheduler.highfreq_cron = el('cfg-high-cron').value.trim()
|
||||
cfg.scheduler.lowfreq_cron = el('cfg-low-cron').value.trim()
|
||||
cfg.scheduler.poll_seconds = Number(el('cfg-poll-seconds').value || 30)
|
||||
cfg.oss.root_prefix = el('cfg-oss-root-prefix').value.trim()
|
||||
cfg.oss.root_prefix_key = el('cfg-oss-root-prefix-key').value.trim()
|
||||
cfg.oss.lowfreq_dir_name = el('cfg-lowfreq-dir-name').value.trim()
|
||||
cfg.oss.bucket = el('cfg-oss-bucket').value.trim()
|
||||
cfg.oss.endpoint = el('cfg-oss-endpoint').value.trim()
|
||||
cfg.oss.credential_file_name = el('cfg-credential-file').value.trim()
|
||||
cfg.oss.access_key_id_key = el('cfg-ak-key').value.trim()
|
||||
cfg.oss.access_key_secret_key = el('cfg-sk-key').value.trim()
|
||||
cfg.oss.bucket_key = el('cfg-bucket-key').value.trim()
|
||||
cfg.oss.endpoint_key = el('cfg-endpoint-key').value.trim()
|
||||
cfg.verification = cfg.verification || {}
|
||||
cfg.verification.lowfreq_sample_size = Number(el('cfg-sample-size').value || 3)
|
||||
cfg.verification.restore_max_download_mb = Number(el('cfg-restore-max-mb').value || 2048)
|
||||
cfg.paths.highfreq.code_paths = readLines('cfg-high-code-paths')
|
||||
cfg.paths.highfreq.root_files = readLines('cfg-high-root-files')
|
||||
cfg.paths.highfreq.tool_code_extensions = readLines('cfg-high-exts')
|
||||
cfg.paths.lowfreq.include_paths = readLines('cfg-low-paths')
|
||||
cfg.paths.highfreq.exclude_dirs = readLines('cfg-high-excludes')
|
||||
cfg.paths.lowfreq.exclude_dirs = readLines('cfg-low-excludes')
|
||||
return cfg
|
||||
}
|
||||
|
||||
const refreshOverview = async () => {
|
||||
const [overview, currentRun, alerts] = await Promise.all([
|
||||
getJson('/api/box_backup/overview'),
|
||||
getJson('/api/box_backup/current_run'),
|
||||
getJson('/api/box_backup/alerts?limit=30')
|
||||
])
|
||||
renderOverview(overview)
|
||||
renderCurrentRun(currentRun.run)
|
||||
renderAlerts(alerts.rows || [])
|
||||
}
|
||||
|
||||
const refreshLogs = async () => {
|
||||
const failuresOnly = !!(el('filter-failures') && el('filter-failures').checked)
|
||||
const logs = await getJson(`/api/box_backup/events?limit=200${failuresOnly ? '&failures_only=1' : ''}`)
|
||||
renderLogs(logs.rows || [])
|
||||
}
|
||||
|
||||
const refreshConfig = async () => {
|
||||
const payload = await getJson('/api/box_backup/config')
|
||||
renderConfig(payload)
|
||||
}
|
||||
|
||||
const refreshCatalog = async () => {
|
||||
const status = el('catalog-status-filter') ? el('catalog-status-filter').value.trim() : ''
|
||||
const payload = await getJson(`/api/box_backup/catalog?run_limit=20&object_limit=200${status ? `&status=${encodeURIComponent(status)}` : ''}`)
|
||||
renderCatalog(payload)
|
||||
}
|
||||
|
||||
const refreshAll = async () => {
|
||||
try {
|
||||
setAuthWarning('')
|
||||
await Promise.all([refreshOverview(), refreshLogs(), refreshConfig(), refreshCatalog()])
|
||||
} catch (error) {
|
||||
if (String(error.message || error) === 'UNAUTHORIZED') {
|
||||
setAuthWarning('未授权。请从带 token 的入口进入,或先完成导航鉴权。')
|
||||
return
|
||||
}
|
||||
setNotice(String(error.message || error), true)
|
||||
}
|
||||
}
|
||||
|
||||
const runMode = async mode => {
|
||||
try {
|
||||
setNotice('', false)
|
||||
await postJson(`/api/box_backup/run/${mode}`, {})
|
||||
await Promise.all([refreshOverview(), refreshLogs(), refreshCatalog()])
|
||||
} catch (error) {
|
||||
setNotice(String(error.message || error), true)
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
const cfg = collectConfig()
|
||||
const payload = await postJson('/api/box_backup/config/save', { config: cfg })
|
||||
renderConfig(payload)
|
||||
setNotice('配置已保存并热生效', false)
|
||||
await Promise.all([refreshOverview(), refreshCatalog()])
|
||||
} catch (error) {
|
||||
setNotice(String(error.message || error), true)
|
||||
}
|
||||
}
|
||||
|
||||
const recordManual = async () => {
|
||||
try {
|
||||
await postJson('/api/box_backup/manual_baidu_backup', {
|
||||
note: el('manual-note').value.trim()
|
||||
})
|
||||
el('manual-note').value = ''
|
||||
setNotice('已记录手工百度云盘备份时间', false)
|
||||
await refreshOverview()
|
||||
} catch (error) {
|
||||
setNotice(String(error.message || error), true)
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
await refreshOverview()
|
||||
await refreshLogs()
|
||||
await refreshCatalog()
|
||||
} catch {}
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
const bind = () => {
|
||||
el('btn-refresh').onclick = refreshAll
|
||||
el('btn-run-high').onclick = () => runMode('high')
|
||||
el('btn-run-low').onclick = () => runMode('low')
|
||||
el('btn-run-all').onclick = () => runMode('all')
|
||||
el('btn-run-verify').onclick = () => runMode('verify')
|
||||
el('btn-run-restore').onclick = () => runMode('restore')
|
||||
el('btn-config-reload').onclick = refreshConfig
|
||||
el('btn-config-save').onclick = saveConfig
|
||||
el('btn-manual-record').onclick = recordManual
|
||||
el('filter-failures').onchange = refreshLogs
|
||||
el('btn-catalog-refresh').onclick = refreshCatalog
|
||||
el('catalog-status-filter').onchange = refreshCatalog
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
bind()
|
||||
await refreshAll()
|
||||
startPolling()
|
||||
})
|
||||
})()
|
||||
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BOX容灾哨兵</title>
|
||||
<link rel="stylesheet" href="/tools/box_disaster_sentinel/box_disaster_sentinel.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>BOX容灾哨兵</h1>
|
||||
<p>OSS 二线备份控制台:状态、操作、告警、配置一体化</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button id="btn-refresh" class="btn btn-primary">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="auth-warning" class="notice notice-err" style="display:none;"></div>
|
||||
<div id="save-notice" class="notice" style="display:none;"></div>
|
||||
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>1. 状态总览</h2>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<div class="mini-card">
|
||||
<div class="mini-title">高频备份</div>
|
||||
<div id="highfreq-status" class="status-text">-</div>
|
||||
<div id="highfreq-last" class="muted">上次:-</div>
|
||||
<div id="highfreq-next" class="muted">下次:-</div>
|
||||
</div>
|
||||
<div class="mini-card">
|
||||
<div class="mini-title">低频备份</div>
|
||||
<div id="lowfreq-status" class="status-text">-</div>
|
||||
<div id="lowfreq-last" class="muted">上次:-</div>
|
||||
<div id="lowfreq-next" class="muted">下次:-</div>
|
||||
</div>
|
||||
<div class="mini-card">
|
||||
<div class="mini-title">当前任务</div>
|
||||
<div id="current-run-status" class="status-text">空闲</div>
|
||||
<div id="current-run-stage" class="muted">阶段:-</div>
|
||||
<div id="current-run-message" class="muted">消息:-</div>
|
||||
</div>
|
||||
<div class="mini-card">
|
||||
<div class="mini-title">百度云盘手工备份</div>
|
||||
<div id="manual-last" class="status-text">-</div>
|
||||
<div class="inline-actions">
|
||||
<input id="manual-note" class="input" placeholder="备注,可选">
|
||||
<button id="btn-manual-record" class="btn">我已完成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span id="oss-ready" class="pill">OSS状态:-</span>
|
||||
<span id="verify-last" class="pill">最近抽检:-</span>
|
||||
<span id="restore-last" class="pill">最近演练:-</span>
|
||||
<span id="credential-file" class="muted">凭证文件:-</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>2. 备份操作</h2>
|
||||
</div>
|
||||
<div class="inline-actions wrap">
|
||||
<button id="btn-run-high" class="btn btn-primary">手动触发高频</button>
|
||||
<button id="btn-run-low" class="btn btn-primary">手动触发低频</button>
|
||||
<button id="btn-run-all" class="btn btn-primary">手动触发全部</button>
|
||||
<button id="btn-run-verify" class="btn">回读抽检</button>
|
||||
<button id="btn-run-restore" class="btn">恢复演练</button>
|
||||
</div>
|
||||
<div class="progress-block">
|
||||
<div class="progress-head">
|
||||
<span id="progress-title">当前无任务</span>
|
||||
<span id="progress-percent">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div id="progress-inner" class="progress-inner"></div>
|
||||
</div>
|
||||
<div id="progress-detail" class="muted">等待任务启动</div>
|
||||
</div>
|
||||
<pre id="run-summary" class="summary-box">暂无结果</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>3. 告警 & 日志</h2>
|
||||
<div class="inline-actions">
|
||||
<label class="checkbox">
|
||||
<input id="filter-failures" type="checkbox">
|
||||
<span>只看失败/重试</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap alerts-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>级别</th>
|
||||
<th>标题</th>
|
||||
<th>内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alerts-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-wrap logs-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>模式</th>
|
||||
<th>类型</th>
|
||||
<th>内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logs-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>4. 备份清单</h2>
|
||||
<div class="inline-actions">
|
||||
<label class="field field-inline">
|
||||
<span>低频状态</span>
|
||||
<select id="catalog-status-filter" class="input">
|
||||
<option value="">全部</option>
|
||||
<option value="uploaded">已上传</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="pending">待上传</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="btn-catalog-refresh" class="btn">刷新清单</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="catalog-grid">
|
||||
<div>
|
||||
<div class="section-subtitle">高频批次</div>
|
||||
<div class="table-wrap catalog-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>状态</th>
|
||||
<th>Run GUID</th>
|
||||
<th>内容</th>
|
||||
<th>OSS Key</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="catalog-highfreq-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-subtitle">低频对象</div>
|
||||
<div class="table-wrap catalog-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>状态</th>
|
||||
<th>原始路径</th>
|
||||
<th>存储名</th>
|
||||
<th>OSS Key</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="catalog-lowfreq-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>5. 配置管理</h2>
|
||||
<div class="inline-actions">
|
||||
<button id="btn-config-reload" class="btn">重新读取</button>
|
||||
<button id="btn-config-save" class="btn btn-primary">保存并热生效</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>高频 cron</span>
|
||||
<input id="cfg-high-cron" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>低频 cron</span>
|
||||
<input id="cfg-low-cron" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>轮询秒数</span>
|
||||
<input id="cfg-poll-seconds" class="input" type="number" min="10">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>OSS 根目录</span>
|
||||
<input id="cfg-oss-root-prefix" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>根目录 Key 名</span>
|
||||
<input id="cfg-oss-root-prefix-key" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>低频固定目录</span>
|
||||
<input id="cfg-lowfreq-dir-name" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>OSS Bucket</span>
|
||||
<input id="cfg-oss-bucket" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>OSS Endpoint</span>
|
||||
<input id="cfg-oss-endpoint" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>凭证文件名</span>
|
||||
<input id="cfg-credential-file" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>AK Key 名</span>
|
||||
<input id="cfg-ak-key" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>SK Key 名</span>
|
||||
<input id="cfg-sk-key" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Bucket Key 名</span>
|
||||
<input id="cfg-bucket-key" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Endpoint Key 名</span>
|
||||
<input id="cfg-endpoint-key" class="input">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>通知方式</span>
|
||||
<input id="cfg-notification-placeholder" class="input" value="第一版预留,暂不启用" readonly>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>抽检样本数</span>
|
||||
<input id="cfg-sample-size" class="input" type="number" min="1">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>恢复演练最大下载(MB)</span>
|
||||
<input id="cfg-restore-max-mb" class="input" type="number" min="128">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="textarea-grid">
|
||||
<label class="field">
|
||||
<span>高频代码路径(每行一个)</span>
|
||||
<textarea id="cfg-high-code-paths" class="textarea"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>高频根文件(每行一个)</span>
|
||||
<textarea id="cfg-high-root-files" class="textarea"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>高频扩展名(每行一个)</span>
|
||||
<textarea id="cfg-high-exts" class="textarea"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>低频路径(每行一个)</span>
|
||||
<textarea id="cfg-low-paths" class="textarea"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>高频排除目录(每行一个)</span>
|
||||
<textarea id="cfg-high-excludes" class="textarea"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>低频排除目录(每行一个)</span>
|
||||
<textarea id="cfg-low-excludes" class="textarea"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/tools/box_disaster_sentinel/box_disaster_sentinel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
*{box-sizing:border-box}
|
||||
html{font-size:14px}
|
||||
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans','Liberation Sans',sans-serif;line-height:1.5;color:#212529;overflow-x:hidden}
|
||||
.container{width:100%;max-width:1140px;margin:0 auto;padding-left:12px;padding-right:12px}
|
||||
.container-fluid{width:100%;padding-left:12px;padding-right:12px}
|
||||
.navbar{display:flex;align-items:center;padding:.5rem 0}
|
||||
.navbar{flex-wrap:wrap}
|
||||
.navbar .container-fluid{display:flex;align-items:center;justify-content:center;gap:.75rem}
|
||||
.bg-primary{background-color:#0d6efd}
|
||||
.bg-dark{background-color:#2b2b2b}
|
||||
.navbar-dark{color:#fff}
|
||||
.navbar-dark .navbar-brand{color:#fff}
|
||||
.navbar .navbar-brand{font-size:1.25rem;font-weight:500}
|
||||
.text-white{color:#fff}
|
||||
.text-muted{color:#6c757d}
|
||||
.small{font-size:.875em}
|
||||
.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;background-color:#f8f9fa;border:1px solid rgba(0,0,0,.125);padding:.375rem .75rem;border-radius:.375rem;transition:background-color .15s ease}
|
||||
.btn:disabled{opacity:.65}
|
||||
.btn:hover{background-color:#e9ecef}
|
||||
.btn-light{color:#212529;background-color:#f8f9fa;border-color:#ced4da}
|
||||
.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}
|
||||
.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}
|
||||
.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
|
||||
.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}
|
||||
.card{display:block;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:.5rem}
|
||||
.card-header{padding:.5rem 1rem;border-bottom:1px solid rgba(0,0,0,.125);background-color:#f8f9fa;font-weight:500}
|
||||
.card-body{padding:1rem}
|
||||
.row{display:flex;flex-wrap:wrap}
|
||||
.row.g-2{gap:.5rem}
|
||||
.col-12{flex:0 0 auto;width:100%}
|
||||
.col-6{flex:0 0 auto;width:50%}
|
||||
@media (min-width:992px){.col-lg-8{flex:0 0 auto;width:66.6667%}.col-lg-4{flex:0 0 auto;width:33.3333%}}
|
||||
.form-label{margin-bottom:.25rem;font-weight:500}
|
||||
.form-control,.form-select{display:block;width:100%;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.375rem}
|
||||
.form-control:focus,.form-select:focus{outline:none;border-color:#86b7fe;box-shadow:0 0 0 .2rem rgba(13,110,253,.25)}
|
||||
.form-control::-webkit-input-placeholder{color:#6c757d}
|
||||
.form-control::-moz-placeholder{color:#6c757d}
|
||||
input[type=datetime-local],input[type=time]{padding-right:2.25rem}
|
||||
.card{box-shadow:0 8px 24px rgba(0,0,0,.08)}
|
||||
.list-group{display:block;border-radius:.5rem;overflow:hidden}
|
||||
.list-group .list-group-item{display:block;padding:.5rem 1rem;background-color:#fff;border-bottom:1px solid rgba(0,0,0,.125)}
|
||||
.list-group-flush .list-group-item{border-left:0;border-right:0}
|
||||
.hstack{display:flex;align-items:center}
|
||||
.justify-content-between{justify-content:space-between}
|
||||
.gap-2{gap:.5rem}
|
||||
.mt-2{margin-top:.5rem}
|
||||
.mb-2{margin-bottom:.5rem}
|
||||
.mb-0{margin-bottom:0}
|
||||
.py-3{padding-top:1rem;padding-bottom:1rem}
|
||||
.d-flex{display:flex}
|
||||
.align-items-center{align-items:center}
|
||||
.d-none{display:none!important}
|
||||
@@ -0,0 +1,680 @@
|
||||
:root {
|
||||
--primary-color: #e74c3c;
|
||||
--primary-hover: #c0392b;
|
||||
--secondary-color: #3498db;
|
||||
--success-color: #27ae60;
|
||||
--warning-color: #f39c12;
|
||||
--danger-color: #e74c3c;
|
||||
--light-bg: #f8f9fa;
|
||||
--border-color: #e9ecef;
|
||||
--text-primary: #2c3e50;
|
||||
--text-secondary: #7f8c8d;
|
||||
--text-muted: #95a5a6;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||
--border-radius: 8px;
|
||||
--grid-gap: 6px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)) !important;
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-top: 1.5rem !important;
|
||||
padding-bottom: 2rem !important;
|
||||
}
|
||||
|
||||
/* 日历网格样式 */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: var(--grid-gap);
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
margin-bottom: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: var(--grid-gap);
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.8rem 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.calendar-weekday > div {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.calendar-weekday > div:first-child,
|
||||
.calendar-weekday > div:last-child {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
background: white;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-cell:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.calendar-cell.is-today {
|
||||
border-color: var(--primary-color);
|
||||
background: linear-gradient(135deg, #fff8f8, #ffe6e6);
|
||||
}
|
||||
|
||||
.calendar-cell.is-today .cell-header span {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-cell .cell-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-cell .cell-header span {
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
background: var(--light-bg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.calendar-cell .events {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 事件药丸样式优化 */
|
||||
.event-pill {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
|
||||
color: #1565c0;
|
||||
border: 1px solid #90caf9;
|
||||
border-radius: 12px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
cursor: pointer;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
transition: all 0.2s ease;
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
|
||||
.event-pill:hover {
|
||||
background: linear-gradient(135deg, #bbdefb, #90caf9);
|
||||
transform: translateX(2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.event-pill.is-done {
|
||||
background: linear-gradient(135deg, #e8f5e8, #c8e6c9);
|
||||
color: #2e7d32;
|
||||
border-color: #81c784;
|
||||
text-decoration-line: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
padding: 1rem 1.2rem;
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0 !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.form-control, .form-select {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(231, 76, 60, 0.25);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--primary-hover), #a23526);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* 右侧事件列表样式优化 */
|
||||
#dayEvents {
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#dayEvents .list-group-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.4rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
#dayEvents .list-group-item:hover {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: var(--primary-color);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
#dayEvents .list-group-item.is-done {
|
||||
background: linear-gradient(135deg, rgba(232, 245, 232, 0.95), rgba(200, 230, 201, 0.95));
|
||||
color: #2e7d32;
|
||||
border-color: #81c784;
|
||||
text-decoration-line: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* 紧凑列表项内容 */
|
||||
.event-list-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.event-list-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.event-list-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-list-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
#dayEvents .list-group-item {
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.event-list-title {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.event-list-time {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.event-list-desc {
|
||||
font-size: 0.75rem;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
#dayEvents .list-group-item {
|
||||
padding: 0.4rem 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.event-list-title {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.event-list-time {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.event-list-desc {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.calendar-cell {
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.event-pill {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.calendar-grid {
|
||||
grid-template-columns: repeat(7, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.calendar-cell .cell-header span {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
grid-template-columns: repeat(7, minmax(70px, 1fr));
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.event-pill {
|
||||
font-size: 0.65rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1rem 0.5rem !important;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
grid-template-columns: repeat(7, minmax(60px, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
padding: 0.5rem;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.calendar-weekday > div {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
padding: 0.3rem;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.calendar-cell .cell-header span {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.event-pill {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 6px;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.events {
|
||||
gap: 0.2rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高密度模式优化 */
|
||||
.high-density .calendar-cell {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.high-density .event-pill {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.2rem;
|
||||
margin: 0.05rem 0;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
/* 事件悬停提示框样式 */
|
||||
.event-tooltip {
|
||||
background: white;
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
max-width: 400px;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(10px);
|
||||
animation: tooltipFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tooltipFadeIn {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.tooltip-header {
|
||||
border-bottom: 2px solid #f8f9fa;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tooltip-header strong {
|
||||
font-size: 1.1rem;
|
||||
color: #dc3545;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tooltip-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tooltip-table th,
|
||||
.tooltip-table td {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
}
|
||||
|
||||
.tooltip-table th {
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.tooltip-table td {
|
||||
color: #495057;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tooltip-table tr:last-child th,
|
||||
.tooltip-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.event-tooltip {
|
||||
max-width: 300px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tooltip-table th,
|
||||
.tooltip-table td {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.tooltip-header strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.event-tooltip {
|
||||
max-width: 280px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.tooltip-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tooltip-table th {
|
||||
width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.event-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.event-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #ffffff;
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 48px rgba(0,0,0,0.2);
|
||||
z-index: 9999;
|
||||
width: 90vw;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.event-modal .modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid #f1f3f5;
|
||||
}
|
||||
|
||||
.event-modal .modal-title {
|
||||
font-weight: 600;
|
||||
color: #dc3545;
|
||||
font-size: 1.05rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.event-modal .modal-close {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.event-modal .modal-close:hover {
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.event-modal .modal-body {
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.event-modal {
|
||||
width: 92vw;
|
||||
max-width: 480px;
|
||||
max-height: 82vh;
|
||||
}
|
||||
.event-modal .modal-body {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
}
|
||||
.calendar-cell .events::-webkit-scrollbar-track {
|
||||
background: var(--light-bg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.calendar-cell .events::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.calendar-cell .events::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.event-pill {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: -10px 0 0 -10px;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>日历提醒系统 · Web</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="css/bootstrap.local.css">
|
||||
<link rel="stylesheet" href="css/styles.css?v=1.1">
|
||||
<script src="js/background.js?v=1.1" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">日历提醒系统(Web)</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button id="btnReload" class="btn btn-light btn-sm">刷新日历</button>
|
||||
<button id="btnPrev" class="btn btn-light btn-sm">上一月</button>
|
||||
<div id="currentMonth" class="text-white"></div>
|
||||
<button id="btnNext" class="btn btn-light btn-sm">下一月</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container py-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="calendar-grid" id="calendar-grid" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">当天事件与编辑</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-2">当天事件</h6>
|
||||
<ul id="dayEvents" class="list-group list-group-flush"></ul>
|
||||
</div>
|
||||
<hr>
|
||||
<!-- 新增/编辑事件 表单结构(统一为 WinForms 布局) -->
|
||||
<form id="eventForm" class="vstack gap-2">
|
||||
<input type="hidden" id="eventId">
|
||||
|
||||
<div>
|
||||
<label for="title" class="form-label">标题</label>
|
||||
<input type="text" id="title" class="form-control" required maxlength="100" placeholder="如:团队会议">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="content" class="form-label">内容</label>
|
||||
<textarea id="content" class="form-control" rows="4" placeholder="备注说明"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label for="start" class="form-label">开始时间</label>
|
||||
<input type="datetime-local" id="start" class="form-control" required step="900">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label for="end" class="form-label">结束时间</label>
|
||||
<input type="datetime-local" id="end" class="form-control" step="900">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check my-1">
|
||||
<input class="form-check-input" type="checkbox" id="enableReminder" checked>
|
||||
<label class="form-check-label" for="enableReminder">启用提醒</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check my-1">
|
||||
<input class="form-check-input" type="checkbox" id="completedFlag">
|
||||
<label class="form-check-label" for="completedFlag">已完成,不再提醒</label>
|
||||
</div>
|
||||
|
||||
<div id="reminderRow">
|
||||
<label class="form-label">提醒时间</label>
|
||||
<!-- 不循环:日期+时分 -->
|
||||
<input type="datetime-local" id="remindDateTime" class="form-control" step="900">
|
||||
<!-- 每天/每周:仅时分 -->
|
||||
<input type="time" id="remindTime" class="form-control d-none" step="900">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="recurrenceType" class="form-label">循环类型</label>
|
||||
<select id="recurrenceType" class="form-select">
|
||||
<option value="NONE">不循环</option>
|
||||
<option value="DAILY">每天</option>
|
||||
<option value="WEEKLY">每周</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="recurrenceOptions" class="d-none">
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<label for="recurrenceInterval" class="form-label">循环间隔</label>
|
||||
<input type="number" id="recurrenceInterval" class="form-control" value="1" min="1">
|
||||
</div>
|
||||
</div>
|
||||
<div id="weekdayGroup" class="mt-2 d-none">
|
||||
<label class="form-label">每周星期几</label>
|
||||
<div class="weekday-list">
|
||||
<label><input type="checkbox" data-weekday="1"> 周一</label>
|
||||
<label><input type="checkbox" data-weekday="2"> 周二</label>
|
||||
<label><input type="checkbox" data-weekday="3"> 周三</label>
|
||||
<label><input type="checkbox" data-weekday="4"> 周四</label>
|
||||
<label><input type="checkbox" data-weekday="5"> 周五</label>
|
||||
<label><input type="checkbox" data-weekday="6"> 周六</label>
|
||||
<label><input type="checkbox" data-weekday="7"> 周日</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hstack gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
<button type="button" id="btnReset" class="btn btn-secondary">重置</button>
|
||||
<button type="button" id="btnDelete" class="btn btn-danger d-none">删除</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="container py-3 text-muted small">
|
||||
<div>模式:<span id="modeLabel"></span> · 提醒依赖浏览器通知(需授权)</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="/assets/auth.js"></script>
|
||||
<script src="js/db.js"></script>
|
||||
<script src="js/models.js?v=1.1"></script>
|
||||
<script src="js/sync.js?v=1.1"></script>
|
||||
<script src="js/ui.js?v=1.3"></script>
|
||||
<script src="js/main.js?v=1.3"></script>
|
||||
<script src="/lib/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,279 @@
|
||||
const BackgroundManager = (function () {
|
||||
const CACHE_KEY = 'background_cache';
|
||||
const LAST_UPDATE_KEY = 'background_last_update';
|
||||
const CACHE_EXPIRE_DAYS = 30;
|
||||
const CONFIG_ENDPOINT = '/api/calendar/sys-config';
|
||||
let configCache = null;
|
||||
|
||||
const UNSPLASH_ACCESS_KEY = 'osWW4Fx0IbpfJs2LbCl_Jpv5ej-6ZaphyIX9sgdPdxI';
|
||||
const PICSUM_BASE_URL = 'https://picsum.photos';
|
||||
const CHINESE_THEME_KEYWORDS = [
|
||||
'chinese landscape', 'chinese painting', 'bamboo', 'lotus', 'plum blossom',
|
||||
'orchid', 'chrysanthemum', 'mountain water', 'ink wash', 'traditional chinese',
|
||||
'landscape painting', 'watercolor', 'minimalist', 'serene', 'nature'
|
||||
];
|
||||
|
||||
async function loadConfig() {
|
||||
if (configCache) return configCache;
|
||||
try {
|
||||
const res = await fetch(CONFIG_ENDPOINT, { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
configCache = {};
|
||||
return configCache;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const cfg = data && data.config && typeof data.config === 'object' ? data.config : {};
|
||||
configCache = cfg;
|
||||
return cfg;
|
||||
} catch (_) {
|
||||
configCache = {};
|
||||
return configCache;
|
||||
}
|
||||
}
|
||||
|
||||
async function getLocalPoolBackground(cfg) {
|
||||
try {
|
||||
const theme = String((cfg && (cfg.background_theme || cfg.backgroundTheme)) || '默认背景');
|
||||
const params = new URLSearchParams();
|
||||
params.set('theme', theme);
|
||||
params.set('prefer_local', '1');
|
||||
const res = await fetch('/api/home/bg?' + params.toString(), { cache: 'no-store' });
|
||||
if (!res.ok) return '';
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const url = data && typeof data.url === 'string' ? data.url : '';
|
||||
return url || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllPoolBackground() {
|
||||
const themes = [
|
||||
'夜空星轨',
|
||||
'蓝天白云',
|
||||
'治愈森林',
|
||||
'运动风',
|
||||
'科技霓虹',
|
||||
'商务简约',
|
||||
'暖色日落',
|
||||
'冷色极夜'
|
||||
];
|
||||
const theme = themes[Math.floor(Math.random() * themes.length)];
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('theme', theme);
|
||||
params.set('prefer_local', '1');
|
||||
const res = await fetch('/api/home/bg?' + params.toString(), { cache: 'no-store' });
|
||||
if (!res.ok) return '';
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const url = data && typeof data.url === 'string' ? data.url : '';
|
||||
return url || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function getDailyBackground() {
|
||||
const cfg = await loadConfig();
|
||||
const today = new Date().toDateString();
|
||||
const cached = getCachedBackground();
|
||||
|
||||
if (cached && cached.date === today && cached.url) {
|
||||
return cached.url;
|
||||
}
|
||||
|
||||
const enableDynamic = cfg && Object.prototype.hasOwnProperty.call(cfg, 'ENABLE_DYNAMIC_BACKGROUND')
|
||||
? cfg.ENABLE_DYNAMIC_BACKGROUND !== false
|
||||
: true;
|
||||
const modeRaw = cfg && cfg.background_mode ? String(cfg.background_mode) : '';
|
||||
const mode = modeRaw.toUpperCase();
|
||||
const useAllPool = mode === 'ALL_POOL';
|
||||
|
||||
if (!enableDynamic) {
|
||||
const url = getDefaultBackground();
|
||||
cacheBackground(url, today);
|
||||
return url;
|
||||
}
|
||||
|
||||
if (useAllPool) {
|
||||
const allUrl = await getAllPoolBackground();
|
||||
if (allUrl) {
|
||||
cacheBackground(allUrl, today);
|
||||
return allUrl;
|
||||
}
|
||||
const fallback = getDefaultBackground();
|
||||
cacheBackground(fallback, today);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (cfg && (cfg.background_theme || cfg.backgroundTheme)) {
|
||||
const localUrl = await getLocalPoolBackground(cfg);
|
||||
if (localUrl) {
|
||||
cacheBackground(localUrl, today);
|
||||
return localUrl;
|
||||
}
|
||||
const fallback = getDefaultBackground();
|
||||
cacheBackground(fallback, today);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const unsplashUrl = await tryUnsplash();
|
||||
if (unsplashUrl) {
|
||||
cacheBackground(unsplashUrl, today);
|
||||
return unsplashUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Unsplash API failed:', error);
|
||||
}
|
||||
|
||||
if (cached && cached.url) return cached.url;
|
||||
try {
|
||||
const picsumUrl = await tryPicsum();
|
||||
if (picsumUrl) {
|
||||
cacheBackground(picsumUrl, today);
|
||||
return picsumUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Picsum API failed:', error);
|
||||
}
|
||||
const fallback = getDefaultBackground();
|
||||
cacheBackground(fallback, today);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function tryUnsplash() {
|
||||
if (!UNSPLASH_ACCESS_KEY || UNSPLASH_ACCESS_KEY === 'your_unsplash_access_key_here') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const randomKeyword = CHINESE_THEME_KEYWORDS[Math.floor(Math.random() * CHINESE_THEME_KEYWORDS.length)];
|
||||
const url = `https://api.unsplash.com/photos/random?query=${encodeURIComponent(randomKeyword)}&orientation=landscape&client_id=${UNSPLASH_ACCESS_KEY}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Accept-Version': 'v1'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Unsplash API error:', response.status, response.statusText);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.urls.regular;
|
||||
} catch (error) {
|
||||
console.warn('Unsplash fetch failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryPicsum() {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const imageId = Math.floor(Math.random() * 1000);
|
||||
|
||||
return `${PICSUM_BASE_URL}/${width}/${height}?blur=2&image=${imageId}`;
|
||||
}
|
||||
|
||||
function getDefaultBackground() {
|
||||
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
}
|
||||
|
||||
function getCachedBackground() {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
const lastUpdate = localStorage.getItem(LAST_UPDATE_KEY);
|
||||
|
||||
if (!cached || !lastUpdate) return null;
|
||||
|
||||
// 检查缓存是否过期(30天)
|
||||
const updateDate = new Date(lastUpdate);
|
||||
const expireDate = new Date(updateDate);
|
||||
expireDate.setDate(expireDate.getDate() + CACHE_EXPIRE_DAYS);
|
||||
|
||||
if (new Date() > expireDate) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
localStorage.removeItem(LAST_UPDATE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(cached);
|
||||
} catch (error) {
|
||||
console.warn('Failed to read cache:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheBackground(url, date) {
|
||||
try {
|
||||
const cacheData = { url, date };
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
|
||||
localStorage.setItem(LAST_UPDATE_KEY, new Date().toISOString());
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache background:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyBackground(url) {
|
||||
const body = document.body;
|
||||
|
||||
let bgContainer = document.getElementById('background-container');
|
||||
if (!bgContainer) {
|
||||
bgContainer = document.createElement('div');
|
||||
bgContainer.id = 'background-container';
|
||||
const isImage = typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'));
|
||||
bgContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background: ${isImage ? `url(${url}) center/cover no-repeat` : url};
|
||||
opacity: 0.15;
|
||||
filter: blur(8px);
|
||||
`;
|
||||
document.body.appendChild(bgContainer);
|
||||
} else {
|
||||
const isImage = typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'));
|
||||
bgContainer.style.background = isImage ? `url(${url}) center/cover no-repeat` : url;
|
||||
}
|
||||
|
||||
body.style.background = 'transparent';
|
||||
const importantElements = document.querySelectorAll('.card, .calendar-grid, .navbar');
|
||||
importantElements.forEach(el => {
|
||||
el.style.backgroundColor = 'rgba(255, 255, 255, 0.95)';
|
||||
el.style.backdropFilter = 'blur(10px)';
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const backgroundUrl = await getDailyBackground();
|
||||
applyBackground(backgroundUrl);
|
||||
} catch (error) {
|
||||
console.error('Failed to set background:', error);
|
||||
applyBackground(getDefaultBackground());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
refresh: init
|
||||
};
|
||||
})();
|
||||
|
||||
// 页面加载时初始化
|
||||
window.addEventListener('load', () => {
|
||||
BackgroundManager.init();
|
||||
});
|
||||
|
||||
// 导出供其他模块使用
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = BackgroundManager;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const AppConfig = {
|
||||
mode: 'shared', // 'standalone' | 'shared'
|
||||
API_BASE_URL: '/api/calendar/events',
|
||||
ENABLE_SYNC: true, // 共享模式下开启
|
||||
SYNC_INTERVAL_MS: 60_000,
|
||||
};
|
||||
|
||||
window.AppConfig = AppConfig;
|
||||
@@ -0,0 +1,163 @@
|
||||
// IndexedDB 存储封装
|
||||
const DB = (function () {
|
||||
const DB_NAME = 'calendar_reminder';
|
||||
const DB_VERSION = 2; // 升级版本以创建 outbox 队列
|
||||
const STORE_EVENTS = 'events';
|
||||
const STORE_OUTBOX = 'outbox';
|
||||
|
||||
let _db;
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = (ev) => {
|
||||
const db = ev.target.result;
|
||||
// 事件存储
|
||||
if (!db.objectStoreNames.contains(STORE_EVENTS)) {
|
||||
const store = db.createObjectStore(STORE_EVENTS, { keyPath: 'id' });
|
||||
store.createIndex('by_start', 'start');
|
||||
store.createIndex('by_updated', 'updatedAt');
|
||||
}
|
||||
// 离线 outbox 队列
|
||||
if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
|
||||
db.createObjectStore(STORE_OUTBOX, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
_db = req.result;
|
||||
resolve(_db);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function tx(storeName, mode = 'readonly') {
|
||||
const t = _db.transaction(storeName, mode);
|
||||
return t.objectStore(storeName);
|
||||
}
|
||||
|
||||
async function initDB() {
|
||||
if (_db) return _db;
|
||||
return await openDB();
|
||||
}
|
||||
|
||||
function addEvent(ev) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = tx(STORE_EVENTS, 'readwrite');
|
||||
const req = store.add(ev);
|
||||
req.onsuccess = () => resolve(ev);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function updateEvent(ev) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = tx(STORE_EVENTS, 'readwrite');
|
||||
const req = store.put(ev);
|
||||
req.onsuccess = () => resolve(ev);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteEvent(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = tx(STORE_EVENTS, 'readwrite');
|
||||
const req = store.delete(id);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function getEvent(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = tx(STORE_EVENTS, 'readonly');
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function listEventsByMonth(year, month) {
|
||||
// month: 1..12
|
||||
const start = new Date(Date.UTC(year, month - 1, 1, 0, 0, 0));
|
||||
const end = new Date(Date.UTC(year, month, 0, 23, 59, 59));
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = tx(STORE_EVENTS, 'readonly');
|
||||
const idx = store.index('by_start');
|
||||
const range = IDBKeyRange.bound(start.toISOString(), end.toISOString());
|
||||
const result = [];
|
||||
const req = idx.openCursor(range);
|
||||
req.onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (cursor) {
|
||||
result.push(cursor.value);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Outbox 队列 ---
|
||||
function genId() { return Date.now() + '-' + Math.random().toString(36).slice(2); }
|
||||
|
||||
function addOutbox(entry) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = tx(STORE_OUTBOX, 'readwrite');
|
||||
const req = store.add(entry);
|
||||
req.onsuccess = () => resolve(entry);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function addOutboxUpsert(ev) {
|
||||
const entry = { id: genId(), type: 'upsert', payload: ev, timestamp: new Date().toISOString() };
|
||||
return addOutbox(entry);
|
||||
}
|
||||
|
||||
function addOutboxDelete(id) {
|
||||
const entry = { id: genId(), type: 'delete', payload: { id }, timestamp: new Date().toISOString() };
|
||||
return addOutbox(entry);
|
||||
}
|
||||
|
||||
function listOutbox() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = tx(STORE_OUTBOX, 'readonly');
|
||||
const result = [];
|
||||
const req = store.openCursor();
|
||||
req.onsuccess = (e) => {
|
||||
const cursor = e.target.result;
|
||||
if (cursor) { result.push(cursor.value); cursor.continue(); }
|
||||
else { resolve(result); }
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function removeOutbox(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = tx(STORE_OUTBOX, 'readwrite');
|
||||
const req = store.delete(id);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
initDB,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
getEvent,
|
||||
listEventsByMonth,
|
||||
// outbox
|
||||
addOutboxUpsert,
|
||||
addOutboxDelete,
|
||||
listOutbox,
|
||||
removeOutbox,
|
||||
};
|
||||
})();
|
||||
|
||||
window.DB = DB;
|
||||
@@ -0,0 +1,627 @@
|
||||
// 主入口:初始化、交互、提醒与 PWA
|
||||
(function () {
|
||||
let current = new Date();
|
||||
let monthEvents = [];
|
||||
let monthOccurrences = [];
|
||||
const monthCache = new Map();
|
||||
// 进程内临时集:已提醒过的事件键,避免重复提醒(不落地)
|
||||
const notifiedKeys = new Set();
|
||||
const scheduledKeys = new Set();
|
||||
let heartbeatTimer = null;
|
||||
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟容错窗口
|
||||
let autoRefreshTimer = null;
|
||||
let lastUserActionTs = Date.now();
|
||||
const AUTORELOAD_MS = 30 * 60 * 1000;
|
||||
|
||||
async function init() {
|
||||
document.getElementById('modeLabel').textContent = AppConfig.mode;
|
||||
|
||||
// 通知权限请求(可延迟到首次需要时)
|
||||
if ('Notification' in window) {
|
||||
try {
|
||||
if (Notification.permission === 'default') {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 禁用 Service Worker,确保不使用任何本地缓存
|
||||
await unregisterServiceWorker();
|
||||
|
||||
bindNav();
|
||||
bindReload();
|
||||
bindForm();
|
||||
bindAutoRefresh();
|
||||
|
||||
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||
startReminderHeartbeat();
|
||||
}
|
||||
|
||||
function bindNav() {
|
||||
document.getElementById('btnPrev').addEventListener('click', async () => {
|
||||
current.setMonth(current.getMonth() - 1);
|
||||
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||
});
|
||||
document.getElementById('btnNext').addEventListener('click', async () => {
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function bindReload() {
|
||||
const btn = document.getElementById('btnReload');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', async () => {
|
||||
lastUserActionTs = Date.now();
|
||||
try {
|
||||
UI.showGlobalModal('正在刷新日历...');
|
||||
try { UI.appendGlobalModalLog('清空内存缓存'); } catch (_) {}
|
||||
monthCache.clear();
|
||||
notifiedKeys.clear();
|
||||
scheduledKeys.clear();
|
||||
try { UI.appendGlobalModalLog('注销 Service Worker'); } catch (_) {}
|
||||
await unregisterServiceWorker();
|
||||
if ('caches' in window) {
|
||||
try {
|
||||
UI.appendGlobalModalLog('清除浏览器缓存存储');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map(k => caches.delete(k)));
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||
try { UI.appendGlobalModalLog('刷新流程完成'); } catch (_) {}
|
||||
try { UI.completeGlobalModal('刷新完成'); } catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMonth(year, month) {
|
||||
UI.showGlobalModal('正在查询当月数据...');
|
||||
try { UI.appendGlobalModalLog('开始查询 ' + year + '-' + month + ' 数据'); } catch (_) {}
|
||||
let ok = true;
|
||||
console.time('loadMonthTotal');
|
||||
try {
|
||||
const key = `${year}-${month}`;
|
||||
const cached = monthCache.get(key);
|
||||
if (cached) {
|
||||
monthEvents = cached;
|
||||
} else {
|
||||
console.time('fetchAPI');
|
||||
const remote = await SyncAPI.listByMonth(year, month);
|
||||
console.timeEnd('fetchAPI');
|
||||
console.log('API returned events:', remote ? remote.length : 0);
|
||||
monthEvents = remote;
|
||||
monthCache.set(key, remote);
|
||||
}
|
||||
const prevY = month === 1 ? year - 1 : year;
|
||||
const prevM = month === 1 ? 12 : month - 1;
|
||||
const prevKey = `${prevY}-${prevM}`;
|
||||
const prevCached = monthCache.get(prevKey);
|
||||
let prevEvents = [];
|
||||
if (prevCached) {
|
||||
prevEvents = prevCached;
|
||||
} else {
|
||||
const prevRemote = await SyncAPI.listByMonth(prevY, prevM);
|
||||
prevEvents = prevRemote;
|
||||
monthCache.set(prevKey, prevRemote);
|
||||
}
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
const pushUnique = (list) => {
|
||||
list.forEach(e => {
|
||||
if (!e) return;
|
||||
const k = e.id ? `id:${e.id}` : `k:${e.title || ''}|${e.start || ''}|${e.end || ''}`;
|
||||
if (seen.has(k)) return;
|
||||
seen.add(k);
|
||||
merged.push(e);
|
||||
});
|
||||
};
|
||||
pushUnique(monthEvents);
|
||||
pushUnique(prevEvents);
|
||||
monthEvents = merged;
|
||||
} catch (err) {
|
||||
console.error('远程拉取失败', err);
|
||||
try { UI.appendGlobalModalLog('错误:远程拉取失败 ' + (err && err.message ? err.message : '')); } catch (_) {}
|
||||
monthEvents = [];
|
||||
alert('远程拉取失败,请稍后重试');
|
||||
ok = false;
|
||||
}
|
||||
try { UI.appendGlobalModalLog('开始展开当月循环事件'); } catch (_) {}
|
||||
console.time('expandEvents');
|
||||
monthOccurrences = expandEventsForMonth(monthEvents, year, month);
|
||||
console.timeEnd('expandEvents');
|
||||
console.log('Expanded occurrences:', monthOccurrences.length);
|
||||
try { UI.appendGlobalModalLog('展开完成,实例数=' + monthOccurrences.length); } catch (_) {}
|
||||
|
||||
try { UI.appendGlobalModalLog('开始渲染日历'); } catch (_) {}
|
||||
console.time('renderCalendar');
|
||||
// 使用 requestAnimationFrame 给 UI 线程喘息机会,避免卡死
|
||||
await new Promise(r => requestAnimationFrame(() => {
|
||||
UI.renderCalendar(year, month, monthOccurrences);
|
||||
r();
|
||||
}));
|
||||
console.timeEnd('renderCalendar');
|
||||
try { UI.appendGlobalModalLog('渲染完成'); } catch (_) {}
|
||||
|
||||
// 立即补偿提醒(捕捉刚到点或即将到点的事件)
|
||||
catchUpNotifications(monthOccurrences);
|
||||
// 设置未来提醒的定时器
|
||||
scheduleNotifications(monthOccurrences);
|
||||
// 刷新后重启心跳,确保新数据生效
|
||||
startReminderHeartbeat();
|
||||
prefetchAdjacentMonths(year, month);
|
||||
if (ok) {
|
||||
try { UI.appendGlobalModalLog('查询完成'); } catch (_) {}
|
||||
try { UI.completeGlobalModal('查询完成'); } catch (_) {}
|
||||
} else {
|
||||
try { UI.appendGlobalModalLog('查询失败'); } catch (_) {}
|
||||
try { UI.updateGlobalModalMessage('查询失败'); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 顶层自执行函数内新增:切换提醒输入与构造循环/提醒
|
||||
function updateReminderInputs() {
|
||||
const type = document.getElementById('recurrenceType').value;
|
||||
const enabled = document.getElementById('enableReminder').checked;
|
||||
const dtWrap = document.getElementById('remindDateTime');
|
||||
const tWrap = document.getElementById('remindTime');
|
||||
const opts = document.getElementById('recurrenceOptions');
|
||||
const wgroup = document.getElementById('weekdayGroup');
|
||||
|
||||
dtWrap.classList.toggle('d-none', !enabled || (type === 'DAILY' || type === 'WEEKLY'));
|
||||
tWrap.classList.toggle('d-none', !enabled || (type === 'NONE'));
|
||||
opts.classList.toggle('d-none', type === 'NONE');
|
||||
wgroup.classList.toggle('d-none', type !== 'WEEKLY');
|
||||
}
|
||||
|
||||
function composeRecurrence() {
|
||||
const type = document.getElementById('recurrenceType').value;
|
||||
if (type === 'NONE') return '';
|
||||
const interval = Math.max(1, parseInt(document.getElementById('recurrenceInterval').value || '1', 10));
|
||||
if (type === 'DAILY') return `DAILY;interval=${interval}`;
|
||||
const checked = Array.from(document.querySelectorAll('#weekdayGroup [data-weekday]'))
|
||||
.filter(el => el.checked).map(el => parseInt(el.getAttribute('data-weekday'), 10)).sort((a, b) => a - b);
|
||||
const by = checked.length ? `;by=${checked.join(',')}` : '';
|
||||
return `WEEKLY;interval=${interval}${by}`;
|
||||
}
|
||||
|
||||
function combineDateAndTime(startISO, timeStr) {
|
||||
const base = new Date(startISO);
|
||||
const [hh, mmRaw] = (timeStr || '00:00').split(':').map(s => parseInt(s, 10));
|
||||
const q = Math.round((mmRaw || 0) / 15) * 15;
|
||||
const mm = Math.min(45, Math.max(0, q));
|
||||
base.setHours(hh || 0, mm, 0, 0);
|
||||
return base.toISOString();
|
||||
}
|
||||
|
||||
// 可视兜底(Toast):即使系统静默通知也显示
|
||||
function ensureToastContainer() {
|
||||
let c = document.getElementById('toastContainer');
|
||||
if (!c) {
|
||||
c = document.createElement('div');
|
||||
c.id = 'toastContainer';
|
||||
c.className = 'toast-container';
|
||||
document.body.appendChild(c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
function showToast(title, body) {
|
||||
try {
|
||||
const c = ensureToastContainer();
|
||||
const item = document.createElement('div');
|
||||
item.className = 'inapp-toast';
|
||||
const strong = document.createElement('strong'); strong.textContent = title || '提醒';
|
||||
const p = document.createElement('div'); p.textContent = body || '';
|
||||
item.appendChild(strong); item.appendChild(p); c.appendChild(item);
|
||||
setTimeout(() => { try { item.classList.add('fadeout'); } catch (_) {}
|
||||
setTimeout(() => { try { c.removeChild(item); } catch (_) {} }, 300);
|
||||
}, 5000);
|
||||
} catch (_) {}
|
||||
}
|
||||
function triggerNotify(e) {
|
||||
const title = '提醒:' + (e.title || '');
|
||||
const body = `${new Date(e.start).toLocaleString()} - ${new Date(e.end).toLocaleString()}`;
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
try { new Notification(title, { body }); } catch (_) {}
|
||||
}
|
||||
showToast(title, body);
|
||||
}
|
||||
|
||||
function bindForm() {
|
||||
const form = document.getElementById('eventForm');
|
||||
const btnReset = document.getElementById('btnReset');
|
||||
const btnDelete = document.getElementById('btnDelete');
|
||||
const btnSubmit = form.querySelector('[type="submit"]');
|
||||
|
||||
// 新增:循环与提醒输入联动
|
||||
document.getElementById('recurrenceType').addEventListener('change', updateReminderInputs);
|
||||
document.getElementById('enableReminder').addEventListener('change', updateReminderInputs);
|
||||
updateReminderInputs();
|
||||
const inputsToSnap = ['start','end','remindDateTime','remindTime'];
|
||||
inputsToSnap.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
['change','blur'].forEach(evt => {
|
||||
el.addEventListener(evt, () => {
|
||||
try {
|
||||
if (id === 'remindTime') {
|
||||
const v = el.value || '';
|
||||
const parts = v.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const h = parseInt(parts[0] || '0', 10);
|
||||
const mRaw = parseInt(parts[1] || '0', 10);
|
||||
const m = Math.min(45, Math.max(0, Math.round(mRaw / 15) * 15));
|
||||
el.value = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
|
||||
}
|
||||
} else if (el.value) {
|
||||
const d = new Date(el.value);
|
||||
const q = Math.round(d.getMinutes() / 15) * 15;
|
||||
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
el.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
} catch (_) {}
|
||||
}, { passive: true });
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
// 启用原生表单校验(required 等)
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存按钮进入加载态
|
||||
btnSubmit.disabled = true;
|
||||
const originalText = btnSubmit.textContent;
|
||||
btnSubmit.textContent = '保存中...';
|
||||
|
||||
let data;
|
||||
try {
|
||||
UI.showGlobalModal('正在保存...');
|
||||
try { UI.appendGlobalModalLog('收集表单数据'); } catch (_) {}
|
||||
data = collectForm();
|
||||
if (!data.title || !data.start) {
|
||||
try { UI.appendGlobalModalLog('错误:缺少标题或开始时间'); } catch (_) {}
|
||||
alert('请填写标题与开始时间');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.id) {
|
||||
const base = (form.eventLoaded && form.eventLoaded.id === data.id) ? form.eventLoaded : {};
|
||||
const updated = Models.normalizeForUpdate({ ...base, ...data });
|
||||
try { UI.appendGlobalModalLog('准备更新事件'); } catch (_) {}
|
||||
await SyncAPI.upsertEvent(updated);
|
||||
} else {
|
||||
const evNew = Models.createEvent(data);
|
||||
try { UI.appendGlobalModalLog('准备创建新事件'); } catch (_) {}
|
||||
await SyncAPI.upsertEvent(evNew);
|
||||
}
|
||||
|
||||
// 刷新当前月
|
||||
try { UI.appendGlobalModalLog('刷新当月视图'); } catch (_) {}
|
||||
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||
resetForm();
|
||||
try { UI.appendGlobalModalLog('保存成功'); } catch (_) {}
|
||||
try { UI.completeGlobalModal('保存成功'); } catch (_) {}
|
||||
} catch (err) {
|
||||
console.error('保存失败', err);
|
||||
alert('保存失败:' + (err && err.message ? err.message : err));
|
||||
try { UI.appendGlobalModalLog('错误:保存失败 ' + (err && err.message ? err.message : '')); } catch (_) {}
|
||||
try { UI.updateGlobalModalMessage('保存失败'); } catch (_) {}
|
||||
} finally {
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.textContent = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
btnReset.addEventListener('click', resetForm);
|
||||
|
||||
btnDelete.addEventListener('click', async () => {
|
||||
const id = document.getElementById('eventId').value;
|
||||
if (!id) return;
|
||||
if (!confirm('确认删除该事件吗?')) return;
|
||||
try {
|
||||
UI.showGlobalModal('正在删除...');
|
||||
try { UI.appendGlobalModalLog('准备删除事件 ' + id); } catch (_) {}
|
||||
try { UI.appendGlobalModalLog('调用 deleteEvent'); } catch (_) {}
|
||||
await SyncAPI.deleteEvent(id);
|
||||
} catch (e) {
|
||||
console.error('远端删除失败', e);
|
||||
alert('删除失败:' + (e && e.message ? e.message : e));
|
||||
try { UI.appendGlobalModalLog('错误:删除失败 ' + (e && e.message ? e.message : '')); } catch (_) {}
|
||||
try { UI.updateGlobalModalMessage('删除失败'); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
try { UI.appendGlobalModalLog('删除成功,刷新当月视图'); } catch (_) {}
|
||||
await loadMonth(current.getFullYear(), current.getMonth() + 1);
|
||||
resetForm();
|
||||
try { UI.appendGlobalModalLog('删除完成'); } catch (_) {}
|
||||
try { UI.completeGlobalModal('删除完成'); } catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
function collectForm() {
|
||||
const id = document.getElementById('eventId').value || '';
|
||||
const title = document.getElementById('title').value || '';
|
||||
const content = document.getElementById('content').value || '';
|
||||
const completed = document.getElementById('completedFlag').checked;
|
||||
const startInput = document.getElementById('start').value;
|
||||
const start = startInput ? new Date(startInput) : null;
|
||||
const endInput = document.getElementById('end').value;
|
||||
const end = endInput ? new Date(endInput) : null;
|
||||
|
||||
const type = document.getElementById('recurrenceType').value;
|
||||
const enableReminder = document.getElementById('enableReminder').checked;
|
||||
const remindDateTime = document.getElementById('remindDateTime').value;
|
||||
const remindTime = document.getElementById('remindTime').value;
|
||||
|
||||
const recurrence = composeRecurrence();
|
||||
|
||||
function roundToQuarter(d) {
|
||||
if (!d) return d;
|
||||
const q = Math.round(d.getMinutes() / 15) * 15;
|
||||
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
|
||||
return d;
|
||||
}
|
||||
if (start) roundToQuarter(start);
|
||||
if (end) roundToQuarter(end);
|
||||
let remindAt = '';
|
||||
if (enableReminder) {
|
||||
if (type === 'NONE') {
|
||||
remindAt = remindDateTime ? roundToQuarter(new Date(remindDateTime)).toISOString() : '';
|
||||
} else {
|
||||
remindAt = (start && remindTime) ? combineDateAndTime(start.toISOString(), remindTime) : '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
completed,
|
||||
allDay: false,
|
||||
start: start ? start.toISOString() : '',
|
||||
end: end ? end.toISOString() : '',
|
||||
remindAt,
|
||||
recurrence
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
const form = document.getElementById('eventForm');
|
||||
form.reset();
|
||||
document.getElementById('eventId').value = '';
|
||||
document.getElementById('btnDelete').classList.add('d-none');
|
||||
document.getElementById('dayEvents').innerHTML = '';
|
||||
}
|
||||
|
||||
function scheduleNotifications(events) {
|
||||
const now = Date.now();
|
||||
const next24h = now + 24 * 3600_000;
|
||||
events.forEach(e => {
|
||||
if (e.completed) return;
|
||||
const upcoming = computeUpcomingReminders(e, now, next24h);
|
||||
upcoming.forEach(remindIso => {
|
||||
const ts = new Date(remindIso).getTime();
|
||||
const delay = ts - now;
|
||||
if (delay > 0) {
|
||||
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
|
||||
if (notifiedKeys.has(key) || scheduledKeys.has(key)) return;
|
||||
scheduledKeys.add(key);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (notifiedKeys.has(key)) return;
|
||||
triggerNotify({ ...e, remindAt: remindIso });
|
||||
notifiedKeys.add(key);
|
||||
scheduledKeys.delete(key);
|
||||
} catch (_) {}
|
||||
}, delay);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 立即补偿:加载数据后,立刻检查 2 分钟窗口内的到期提醒
|
||||
function catchUpNotifications(events) {
|
||||
const now = Date.now();
|
||||
const since = now - WINDOW_MS;
|
||||
try {
|
||||
events.forEach(e => {
|
||||
if (e.completed) return;
|
||||
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||
windowReminders.forEach(remindIso => {
|
||||
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
|
||||
if (!notifiedKeys.has(key)) {
|
||||
triggerNotify({ ...e, remindAt: remindIso });
|
||||
notifiedKeys.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function startReminderHeartbeat() {
|
||||
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
||||
// 每分钟检查一次,捕捉睡眠/时间漂移导致的错过提醒
|
||||
heartbeatTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const since = now - WINDOW_MS;
|
||||
try {
|
||||
monthOccurrences.forEach(e => {
|
||||
if (e.completed) return;
|
||||
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||
windowReminders.forEach(remindIso => {
|
||||
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
|
||||
if (!notifiedKeys.has(key)) {
|
||||
triggerNotify({ ...e, remindAt: remindIso });
|
||||
notifiedKeys.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (_) {}
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
function bindAutoRefresh() {
|
||||
['click','keydown','mousemove','touchstart','wheel'].forEach(ev => {
|
||||
document.addEventListener(ev, () => { lastUserActionTs = Date.now(); }, { passive: true });
|
||||
});
|
||||
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
const idleMs = Date.now() - lastUserActionTs;
|
||||
if (idleMs >= AUTORELOAD_MS) {
|
||||
const y = current.getFullYear();
|
||||
const m = current.getMonth() + 1;
|
||||
loadMonth(y, m);
|
||||
}
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
async function unregisterServiceWorker() {
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
for (const r of regs) { try { await r.unregister(); } catch (_) {} }
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 已停用后台同步与本地缓存
|
||||
|
||||
window.addEventListener('DOMContentLoaded', init);
|
||||
// 不再开启后台同步
|
||||
})();
|
||||
function parseRecurrenceStr(str) {
|
||||
const r = (str || '').trim();
|
||||
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||
const mInt = /interval=(\d+)/.exec(r);
|
||||
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||
const mBy = /by=([0-9,]+)/.exec(r);
|
||||
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n>=1 && n<=7) : [];
|
||||
return { type, interval, by };
|
||||
}
|
||||
function setTimeFromIso(dateObj, timeSourceIso) {
|
||||
const d = new Date(dateObj.getTime());
|
||||
const t = timeSourceIso ? new Date(timeSourceIso) : null;
|
||||
const hh = t ? t.getHours() : 0;
|
||||
const mm = t ? t.getMinutes() : 0;
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
function expandEventsForMonth(events, year, month) {
|
||||
const start = new Date(Date.UTC(year, month - 1, 1));
|
||||
const end = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999));
|
||||
const out = [];
|
||||
events.forEach(e => {
|
||||
if (!e || e.deleted) return;
|
||||
const rec = parseRecurrenceStr(e.recurrence);
|
||||
const evStart = new Date(e.start);
|
||||
const evEnd = e.end ? new Date(e.end) : new Date(evStart.getTime() + 60*60*1000);
|
||||
if (rec.type === 'NONE') {
|
||||
out.push(e);
|
||||
} else if (rec.type === 'DAILY') {
|
||||
const from = new Date(Math.max(start.getTime(), Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate())));
|
||||
const to = new Date(Math.min(end.getTime(), Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate())));
|
||||
let cursor = new Date(from.getTime());
|
||||
while (cursor <= to) {
|
||||
const diffDays = Math.floor((cursor.getTime() - Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))/(24*3600*1000));
|
||||
if (diffDays % rec.interval === 0) {
|
||||
const occStartIso = setTimeFromIso(cursor, e.start);
|
||||
const occRemindIso = setTimeFromIso(cursor, e.remindAt || e.start);
|
||||
out.push({ ...e, start: occStartIso, remindAt: occRemindIso });
|
||||
}
|
||||
cursor = new Date(cursor.getTime() + 24*3600*1000);
|
||||
|
||||
// Safety break
|
||||
if (out.length > 5000) {
|
||||
console.warn('Excessive recurrence detected, truncating.', e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (rec.type === 'WEEKLY') {
|
||||
const from = new Date(Math.max(start.getTime(), Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate())));
|
||||
const to = new Date(Math.min(end.getTime(), Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate())));
|
||||
const by = rec.by.length ? rec.by : [((new Date(e.start)).getDay() || 7)];
|
||||
let cursor = new Date(from.getTime());
|
||||
while (cursor <= to) {
|
||||
const weekday = cursor.getUTCDay() === 0 ? 7 : cursor.getUTCDay();
|
||||
const weeksSinceStart = Math.floor((cursor.getTime() - Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))/(7*24*3600*1000));
|
||||
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||
const occStartIso = setTimeFromIso(cursor, e.start);
|
||||
const occRemindIso = setTimeFromIso(cursor, e.remindAt || e.start);
|
||||
out.push({ ...e, start: occStartIso, remindAt: occRemindIso });
|
||||
}
|
||||
cursor = new Date(cursor.getTime() + 24*3600*1000);
|
||||
|
||||
// Safety break
|
||||
if (out.length > 5000) {
|
||||
console.warn('Excessive recurrence detected, truncating.', e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function computeUpcomingReminders(e, sinceMs, untilMs) {
|
||||
const rec = parseRecurrenceStr(e.recurrence);
|
||||
const evStart = new Date(e.start);
|
||||
const evEnd = e.end ? new Date(e.end) : new Date(evStart.getTime() + 60*60*1000);
|
||||
const reminders = [];
|
||||
if (rec.type === 'NONE') {
|
||||
const iso = e.remindAt || e.start;
|
||||
if (!iso) return reminders;
|
||||
const t = new Date(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
return reminders;
|
||||
}
|
||||
const startDayMs = Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate());
|
||||
const endDayMs = Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate());
|
||||
const windowStartDayMs = Date.UTC(new Date(sinceMs).getUTCFullYear(), new Date(sinceMs).getUTCMonth(), new Date(sinceMs).getUTCDate());
|
||||
const windowEndDayMs = Date.UTC(new Date(untilMs).getUTCFullYear(), new Date(untilMs).getUTCMonth(), new Date(untilMs).getUTCDate());
|
||||
const fromDay = Math.max(startDayMs, windowStartDayMs);
|
||||
const toDay = Math.min(endDayMs, windowEndDayMs);
|
||||
const timeSourceIso = e.remindAt || e.start;
|
||||
for (let d = fromDay; d <= toDay; d += 24*3600*1000) {
|
||||
const cursor = new Date(d);
|
||||
if (rec.type === 'DAILY') {
|
||||
const diffDays = Math.floor((d - startDayMs)/(24*3600*1000));
|
||||
if (diffDays % rec.interval === 0) {
|
||||
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||
const t = new Date(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
}
|
||||
} else if (rec.type === 'WEEKLY') {
|
||||
const weekday = cursor.getUTCDay() === 0 ? 7 : cursor.getUTCDay();
|
||||
const weeksSinceStart = Math.floor((d - startDayMs)/(7*24*3600*1000));
|
||||
const by = rec.by.length ? rec.by : [((new Date(e.start)).getDay() || 7)];
|
||||
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||
const t = new Date(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reminders;
|
||||
}
|
||||
async function prefetchAdjacentMonths(year, month) {
|
||||
try {
|
||||
const prevY = month === 1 ? year - 1 : year;
|
||||
const prevM = month === 1 ? 12 : month - 1;
|
||||
const nextY = month === 12 ? year + 1 : year;
|
||||
const nextM = month === 12 ? 1 : month + 1;
|
||||
const pk = `${prevY}-${prevM}`;
|
||||
const nk = `${nextY}-${nextM}`;
|
||||
if (!monthCache.has(pk)) {
|
||||
SyncAPI.listByMonth(prevY, prevM).then(d => monthCache.set(pk, d)).catch(() => {});
|
||||
}
|
||||
if (!monthCache.has(nk)) {
|
||||
SyncAPI.listByMonth(nextY, nextM).then(d => monthCache.set(nk, d)).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 数据模型与工具
|
||||
const Models = (function () {
|
||||
function uuid() {
|
||||
return (crypto && crypto.randomUUID) ? crypto.randomUUID() : (
|
||||
Date.now() + '-' + Math.random().toString(36).slice(2)
|
||||
);
|
||||
}
|
||||
|
||||
function nowISO() { return new Date().toISOString(); }
|
||||
|
||||
function ensureEnd(startISO, endISO, allDay) {
|
||||
if (endISO) return endISO;
|
||||
const s = new Date(startISO);
|
||||
const e = new Date(s.getTime() + (allDay ? 24 * 3600_000 : 60 * 60_000));
|
||||
return e.toISOString();
|
||||
}
|
||||
|
||||
function createEvent({ title, content, start, end, allDay, remindAt, recurrence, completed }) {
|
||||
const createdAt = nowISO();
|
||||
const ev = {
|
||||
id: uuid(),
|
||||
title: (title || '').trim(),
|
||||
content: (content || '').trim(),
|
||||
start,
|
||||
end: ensureEnd(start, end, !!allDay),
|
||||
allDay: !!allDay,
|
||||
remindAt: remindAt || '',
|
||||
recurrence: recurrence || '',
|
||||
completed: !!completed,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
deleted: false,
|
||||
};
|
||||
return ev;
|
||||
}
|
||||
|
||||
function normalizeForUpdate(ev) {
|
||||
ev.updatedAt = nowISO();
|
||||
if (!ev.end) ev.end = ensureEnd(ev.start, ev.end, ev.allDay);
|
||||
ev.title = (ev.title || '').trim();
|
||||
ev.content = (ev.content || '').trim();
|
||||
ev.completed = !!ev.completed;
|
||||
return ev;
|
||||
}
|
||||
|
||||
function isSameDay(isoA, isoB) {
|
||||
const a = new Date(isoA), b = new Date(isoB);
|
||||
return a.getUTCFullYear() === b.getUTCFullYear() &&
|
||||
a.getUTCMonth() === b.getUTCMonth() &&
|
||||
a.getUTCDate() === b.getUTCDate();
|
||||
}
|
||||
|
||||
return { createEvent, normalizeForUpdate, isSameDay };
|
||||
})();
|
||||
|
||||
window.Models = Models;
|
||||
@@ -0,0 +1,85 @@
|
||||
// 简单的 localStorage 心跳锁实现(兼容性回退)
|
||||
// 若浏览器支持 BroadcastChannel,可替换为更即时的通讯方式
|
||||
|
||||
(function () {
|
||||
const LOCK_KEY = 'calendar_instance_lock';
|
||||
const HEARTBEAT_INTERVAL = 2000; // ms
|
||||
const STALE_THRESHOLD = 5000; // ms
|
||||
|
||||
const instanceId = Date.now() + '-' + Math.random().toString(36).slice(2);
|
||||
|
||||
function now() { return Date.now(); }
|
||||
|
||||
function tryAcquire() {
|
||||
const raw = localStorage.getItem(LOCK_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const obj = JSON.parse(raw);
|
||||
if (now() - obj.heartbeat < STALE_THRESHOLD) {
|
||||
// 有活跃实例
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
// malformed, ignore
|
||||
}
|
||||
}
|
||||
// 申请锁
|
||||
localStorage.setItem(LOCK_KEY, JSON.stringify({ id: instanceId, heartbeat: now() }));
|
||||
return true;
|
||||
}
|
||||
|
||||
function heartbeat() {
|
||||
const raw = localStorage.getItem(LOCK_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const obj = JSON.parse(raw);
|
||||
if (obj.id === instanceId) {
|
||||
obj.heartbeat = now();
|
||||
localStorage.setItem(LOCK_KEY, JSON.stringify(obj));
|
||||
} else {
|
||||
// 被其他实例抢占,通知并退出
|
||||
alert('你好,每次只能运行一个程序。你已经运行了。');
|
||||
cleanupAndExit();
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function cleanupAndExit() {
|
||||
try { // 尝试清理自己持有的锁
|
||||
const raw = localStorage.getItem(LOCK_KEY);
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw);
|
||||
if (obj.id === instanceId) {
|
||||
localStorage.removeItem(LOCK_KEY);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
// 停止应用初始化(这里简单重载或关闭页面)
|
||||
window.close && window.close();
|
||||
}
|
||||
|
||||
if (!tryAcquire()) {
|
||||
alert('你好,每次只能运行一个程序。你已经运行了。');
|
||||
// 直接阻止后续初始化
|
||||
throw new Error('Single instance active');
|
||||
}
|
||||
|
||||
// 定时心跳
|
||||
const hbTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
|
||||
|
||||
// 在页面卸载时释放锁
|
||||
window.addEventListener('beforeunload', function () {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCK_KEY);
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw);
|
||||
if (obj.id === instanceId) {
|
||||
localStorage.removeItem(LOCK_KEY);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
clearInterval(hbTimer);
|
||||
});
|
||||
|
||||
// ... 应用其他初始化继续 ...
|
||||
})();
|
||||
@@ -0,0 +1,62 @@
|
||||
// 简易同步器:与 IIS API 交互
|
||||
const SyncAPI = (function () {
|
||||
const base = AppConfig.API_BASE_URL; // 例如 /Api/Events.ashx
|
||||
|
||||
async function listByMonth(year, month) {
|
||||
await (window.Auth && Auth.ensure ? Auth.ensure() : Promise.resolve());
|
||||
try {
|
||||
if (window.UI && UI.appendGlobalModalLog) UI.appendGlobalModalLog('开始调用 listByMonth');
|
||||
} catch (_) {}
|
||||
const url = `${base}?year=${year}&month=${month}&_ts=${Date.now()}`;
|
||||
const resp = await fetch(url, { cache: 'no-store', credentials: 'include' });
|
||||
const j = await resp.json();
|
||||
if (!j.ok) throw new Error(j.error || 'listByMonth failed');
|
||||
try {
|
||||
const count = Array.isArray(j.data) ? j.data.length : 0;
|
||||
if (window.UI && UI.appendGlobalModalLog) UI.appendGlobalModalLog('listByMonth 返回成功,数量=' + count);
|
||||
} catch (_) {}
|
||||
return j.data || [];
|
||||
}
|
||||
|
||||
async function upsertEvent(ev) {
|
||||
await (window.Auth && Auth.ensure ? Auth.ensure() : Promise.resolve());
|
||||
try {
|
||||
if (window.UI && UI.appendGlobalModalLog) UI.appendGlobalModalLog('开始调用 upsertEvent');
|
||||
} catch (_) {}
|
||||
const resp = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(ev),
|
||||
});
|
||||
const j = await resp.json();
|
||||
if (!j.ok) throw new Error(j.error || 'upsert failed');
|
||||
try {
|
||||
if (window.UI && UI.appendGlobalModalLog) UI.appendGlobalModalLog('upsertEvent 返回成功');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function deleteEvent(id) {
|
||||
await (window.Auth && Auth.ensure ? Auth.ensure() : Promise.resolve());
|
||||
const url = `${base}?id=${encodeURIComponent(id)}`;
|
||||
try {
|
||||
const resp = await fetch(url, { method: 'DELETE', credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const j = await resp.json();
|
||||
if (!j.ok) throw new Error(j.error || 'delete failed');
|
||||
return;
|
||||
}
|
||||
if (resp.status !== 405) {
|
||||
const t = await resp.text();
|
||||
throw new Error(t || 'delete failed');
|
||||
}
|
||||
} catch (e) {}
|
||||
const resp2 = await fetch(`${url}&_method=DELETE`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, credentials: 'include', body: '' });
|
||||
const j2 = await resp2.json();
|
||||
if (!j2.ok) throw new Error(j2.error || 'delete failed');
|
||||
}
|
||||
|
||||
return { listByMonth, upsertEvent, deleteEvent };
|
||||
})();
|
||||
|
||||
window.SyncAPI = SyncAPI;
|
||||
@@ -0,0 +1,535 @@
|
||||
// UI 渲染与交互
|
||||
const UI = (function () {
|
||||
const gridEl = () => document.getElementById('calendar-grid');
|
||||
const monthLabelEl = () => document.getElementById('currentMonth');
|
||||
const dayEventsEl = () => document.getElementById('dayEvents');
|
||||
|
||||
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
function isMobile() {
|
||||
try {
|
||||
return window.matchMedia && window.matchMedia('(max-width: 768px)').matches || (navigator && navigator.maxTouchPoints > 0);
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
// 创建详细悬停提示框
|
||||
function createEventTooltip(event) {
|
||||
let recurText = '';
|
||||
try {
|
||||
if (typeof parseRecurrenceStr === 'function') {
|
||||
const rec = parseRecurrenceStr(event.recurrence || '');
|
||||
if (rec && rec.type && rec.type !== 'NONE') {
|
||||
const parts = [];
|
||||
if (rec.type === 'DAILY') {
|
||||
parts.push(rec.interval > 1 ? `每 ${rec.interval} 天` : '每天');
|
||||
} else if (rec.type === 'WEEKLY') {
|
||||
parts.push(rec.interval > 1 ? `每 ${rec.interval} 周` : '每周');
|
||||
if (Array.isArray(rec.by) && rec.by.length) {
|
||||
const map = ['一','二','三','四','五','六','日'];
|
||||
const names = rec.by.map(n => map[(n - 1 + 7) % 7]).map(ch => '周' + ch);
|
||||
parts.push(names.join('、'));
|
||||
}
|
||||
}
|
||||
recurText = parts.join(',');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'event-tooltip';
|
||||
tooltip.innerHTML = `
|
||||
<div class="tooltip-header">
|
||||
<strong>${escapeHtml(event.title || '无标题')}</strong>
|
||||
${event.completed ? '<span class="badge bg-success ms-2">已完成</span>' : ''}
|
||||
</div>
|
||||
<div class="tooltip-content">
|
||||
<table class="tooltip-table">
|
||||
<tr><th>开始时间:</th><td>${formatDateTime(event.start)}</td></tr>
|
||||
${event.end ? `<tr><th>结束时间:</th><td>${formatDateTime(event.end)}</td></tr>` : ''}
|
||||
${event.content ? `<tr><th>内容:</th><td>${escapeHtmlWithLinks(event.content)}</td></tr>` : ''}
|
||||
${event.remindAt ? `<tr><th>提醒时间:</th><td>${formatDateTime(event.remindAt)}</td></tr>` : ''}
|
||||
${recurText ? `<tr><th>循环:</th><td>${recurText}</td></tr>` : ''}
|
||||
<tr><th>状态:</th><td>${event.completed ? '已完成' : '进行中'}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escapeHtmlWithLinks(text) {
|
||||
const raw = String(text || '');
|
||||
const escaped = escapeHtml(raw);
|
||||
const linkPattern = /((?:https?:\/\/|\/tools\/)[^\s<]+)/g;
|
||||
return escaped.replace(linkPattern, (m) => {
|
||||
const href = m.startsWith('/tools/') ? m : m;
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
||||
}).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function formatDateTime(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function renderWeekdayHeader() {
|
||||
const head = document.createElement('div');
|
||||
head.className = 'calendar-weekday';
|
||||
WEEKDAYS.forEach(w => {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = w;
|
||||
head.appendChild(d);
|
||||
});
|
||||
return head;
|
||||
}
|
||||
|
||||
function monthStart(year, month) { return new Date(Date.UTC(year, month - 1, 1)); }
|
||||
function monthEnd(year, month) { return new Date(Date.UTC(year, month, 0)); }
|
||||
|
||||
function loadEventIntoForm(e) {
|
||||
if (!e) return;
|
||||
const form = document.getElementById('eventForm');
|
||||
if (!form) return;
|
||||
form.eventLoaded = e;
|
||||
const idInput = document.getElementById('eventId');
|
||||
if (idInput) idInput.value = e.id || '';
|
||||
const titleInput = document.getElementById('title');
|
||||
if (titleInput) titleInput.value = e.title || '';
|
||||
const contentInput = document.getElementById('content');
|
||||
if (contentInput) contentInput.value = e.content || '';
|
||||
const completedInput = document.getElementById('completedFlag');
|
||||
if (completedInput) completedInput.checked = !!e.completed;
|
||||
|
||||
const startInput = document.getElementById('start');
|
||||
if (startInput && e.start) startInput.value = toInputValue(e.start);
|
||||
const endInput = document.getElementById('end');
|
||||
if (endInput && e.end) endInput.value = toInputValue(e.end);
|
||||
|
||||
const typeSel = document.getElementById('recurrenceType');
|
||||
const intervalInput = document.getElementById('recurrenceInterval');
|
||||
const weekdayInputs = Array.from(document.querySelectorAll('#weekdayGroup [data-weekday]'));
|
||||
const recStr = String(e.recurrence || '');
|
||||
if (typeSel) {
|
||||
typeSel.value = recStr.startsWith('DAILY') ? 'DAILY' : (recStr.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||
}
|
||||
if (intervalInput) {
|
||||
const m = /interval=(\d+)/.exec(recStr);
|
||||
intervalInput.value = m ? Math.max(1, parseInt(m[1], 10)) : 1;
|
||||
}
|
||||
const by = (() => {
|
||||
const m = /by=([0-9,]+)/.exec(recStr);
|
||||
return m ? m[1].split(',').map(s => parseInt(s, 10)) : [];
|
||||
})();
|
||||
weekdayInputs.forEach(el => {
|
||||
const v = parseInt(el.getAttribute('data-weekday'), 10);
|
||||
el.checked = by.includes(v);
|
||||
});
|
||||
|
||||
const enableReminderInput = document.getElementById('enableReminder');
|
||||
const hasRemind = !!e.remindAt;
|
||||
if (enableReminderInput) enableReminderInput.checked = hasRemind;
|
||||
const dt = document.getElementById('remindDateTime');
|
||||
const tm = document.getElementById('remindTime');
|
||||
const typeVal = typeSel ? typeSel.value : 'NONE';
|
||||
if (typeVal === 'NONE') {
|
||||
if (dt) {
|
||||
dt.classList.remove('d-none');
|
||||
dt.value = e.remindAt ? toInputValue(e.remindAt) : '';
|
||||
}
|
||||
if (tm) tm.classList.add('d-none');
|
||||
} else {
|
||||
if (dt) dt.classList.add('d-none');
|
||||
if (tm) {
|
||||
tm.classList.remove('d-none');
|
||||
tm.value = e.remindAt ? toTimeInputValue(e.remindAt) : '';
|
||||
}
|
||||
}
|
||||
|
||||
const btnDelete = document.getElementById('btnDelete');
|
||||
if (btnDelete) btnDelete.classList.remove('d-none');
|
||||
if (isMobile()) {
|
||||
openEventModal(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 在 UI.renderCalendar 的事件 pill 点击中,改为填充统一表单字段与解析循环
|
||||
function renderCalendar(year, month, events) {
|
||||
const grid = gridEl();
|
||||
grid.innerHTML = '';
|
||||
// 清理旧的星期头部,避免重复累加
|
||||
Array.from(grid.parentElement.querySelectorAll('.calendar-weekday')).forEach(el => el.remove());
|
||||
const head = renderWeekdayHeader();
|
||||
grid.parentElement.insertBefore(head, grid);
|
||||
|
||||
// 性能优化:建立日期索引,避免在循环中重复遍历所有事件
|
||||
const eventsByDate = new Map();
|
||||
// 辅助:生成本地日期 Key YYYY-MM-DD
|
||||
const toKey = (y, m, d) => `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
|
||||
// 确定当月范围(本地时间)
|
||||
const monthStart = new Date(year, month - 1, 1, 0, 0, 0, 0);
|
||||
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
|
||||
const overlapsDay = (e, dateObj) => {
|
||||
try {
|
||||
if (!e || e.deleted || !e.start) return false;
|
||||
const s = new Date(e.start);
|
||||
const ep = e.end ? new Date(e.end) : new Date(e.start);
|
||||
const ds = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 0, 0, 0, 0);
|
||||
const de = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 23, 59, 59, 999);
|
||||
return s <= de && ep >= ds;
|
||||
} catch (_) { return false; }
|
||||
};
|
||||
|
||||
events.forEach(e => {
|
||||
if (!e || e.deleted || !e.start) return;
|
||||
try {
|
||||
const s = new Date(e.start);
|
||||
const end = e.end ? new Date(e.end) : new Date(e.start);
|
||||
|
||||
// 计算事件涉及的日期范围(截断到当月)
|
||||
// 转换为本地日期的 0 点进行比较
|
||||
const sDay = new Date(s.getFullYear(), s.getMonth(), s.getDate());
|
||||
const eDay = new Date(end.getFullYear(), end.getMonth(), end.getDate());
|
||||
|
||||
// 如果事件范围与当月无交集,跳过
|
||||
if (eDay < monthStart || sDay > monthEnd) return;
|
||||
|
||||
const iterStart = sDay < monthStart ? monthStart : sDay;
|
||||
const iterEnd = eDay > monthEnd ? monthEnd : eDay;
|
||||
|
||||
// 遍历涉及的每一天
|
||||
for (let d = new Date(iterStart); d <= iterEnd; d.setDate(d.getDate() + 1)) {
|
||||
if (!overlapsDay(e, d)) continue;
|
||||
const key = toKey(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
if (!eventsByDate.has(key)) eventsByDate.set(key, []);
|
||||
eventsByDate.get(key).push(e);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
const start = new Date(Date.UTC(year, month - 1, 1));
|
||||
const end = new Date(Date.UTC(year, month, 0));
|
||||
const firstWeekday = start.getUTCDay();
|
||||
const daysInMonth = end.getUTCDate();
|
||||
|
||||
// 补齐上月空白格
|
||||
for (let i = 0; i < firstWeekday; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'calendar-cell is-outside';
|
||||
cell.innerHTML = `<div class="cell-header"><span></span></div>`;
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const isToday = (d) => d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(year, month - 1, day); // 本地时间对象用于 isToday 判断
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'calendar-cell' + (isToday(date) ? ' is-today' : '');
|
||||
const header = document.createElement('div');
|
||||
header.className = 'cell-header';
|
||||
header.innerHTML = `<span>${day}</span>`;
|
||||
const body = document.createElement('div');
|
||||
body.className = 'events';
|
||||
|
||||
const key = toKey(year, month, day);
|
||||
const esRaw = eventsByDate.get(key) || [];
|
||||
|
||||
const es = (function dedupDay(list) {
|
||||
if (list.length <= 1) return list;
|
||||
const seenDisplay = new Set();
|
||||
const seenId = new Set();
|
||||
return list.filter(e => {
|
||||
// 优化:减少 Date 对象创建
|
||||
const sStr = e.start || '';
|
||||
// 简单提取时分,假设 ISO 格式
|
||||
let hh = '00', mm = '00';
|
||||
if (sStr.length >= 16) {
|
||||
const tPart = new Date(sStr);
|
||||
hh = String(tPart.getHours()).padStart(2, '0');
|
||||
mm = String(tPart.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
const title = (e.title || '').trim();
|
||||
const done = e.completed ? '1' : '0';
|
||||
const dk = `${title}|${hh}:${mm}|${done}`;
|
||||
|
||||
if (seenDisplay.has(dk)) return false;
|
||||
|
||||
if (e.id) {
|
||||
if (seenId.has(e.id)) return false;
|
||||
seenId.add(e.id);
|
||||
}
|
||||
seenDisplay.add(dk);
|
||||
return true;
|
||||
});
|
||||
})(esRaw);
|
||||
|
||||
const MAX_PER_DAY = 50;
|
||||
const visibleEs = es.slice(0, MAX_PER_DAY);
|
||||
|
||||
visibleEs.forEach(e => {
|
||||
const pill = document.createElement('div');
|
||||
pill.className = 'event-pill' + (e.completed ? ' is-done' : '');
|
||||
pill.textContent = e.title;
|
||||
if (!isMobile()) {
|
||||
pill.addEventListener('mouseenter', () => {
|
||||
const tooltip = createEventTooltip(e);
|
||||
document.body.appendChild(tooltip);
|
||||
const rect = pill.getBoundingClientRect();
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.left = rect.right + 10 + 'px';
|
||||
tooltip.style.top = rect.top + 'px';
|
||||
tooltip.style.zIndex = '1000';
|
||||
pill._currentTooltip = tooltip;
|
||||
});
|
||||
pill.addEventListener('mouseleave', () => {
|
||||
if (pill._currentTooltip) {
|
||||
document.body.removeChild(pill._currentTooltip);
|
||||
pill._currentTooltip = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pill.addEventListener('click', () => {
|
||||
loadEventIntoForm(e);
|
||||
});
|
||||
body.appendChild(pill);
|
||||
});
|
||||
|
||||
if (es.length > MAX_PER_DAY) {
|
||||
const more = document.createElement('div');
|
||||
more.className = 'event-pill more';
|
||||
more.textContent = `还有 ${es.length - MAX_PER_DAY} 项...`;
|
||||
more.style.backgroundColor = '#f0f0f0';
|
||||
more.style.color = '#888';
|
||||
more.style.textAlign = 'center';
|
||||
more.style.cursor = 'pointer';
|
||||
more.style.fontSize = '0.85em';
|
||||
body.appendChild(more);
|
||||
}
|
||||
|
||||
cell.appendChild(header);
|
||||
cell.appendChild(body);
|
||||
cell.addEventListener('click', (ev) => {
|
||||
const isPill = ev.target.closest('.event-pill');
|
||||
if (!isPill) renderDayList(date, events);
|
||||
});
|
||||
frag.appendChild(cell);
|
||||
}
|
||||
|
||||
grid.appendChild(frag);
|
||||
monthLabelEl().textContent = `${year}年${month}月`;
|
||||
}
|
||||
|
||||
function renderDayList(date, events) {
|
||||
const list = dayEventsEl();
|
||||
list.innerHTML = '';
|
||||
function overlapsDay(e, dateObj) {
|
||||
try {
|
||||
if (!e || e.deleted || !e.start) return false;
|
||||
const s = new Date(e.start);
|
||||
const ep = e.end ? new Date(e.end) : new Date(e.start);
|
||||
const ds = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 0, 0, 0, 0);
|
||||
const de = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 23, 59, 59, 999);
|
||||
return s <= de && ep >= ds;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
const esRaw = events.filter(e => overlapsDay(e, date) && !e.deleted);
|
||||
const es = (function dedupDay(list) {
|
||||
const seenId = new Set();
|
||||
const seenDisplay = new Set();
|
||||
function displayKey(e) {
|
||||
const d = e.start ? new Date(e.start) : null;
|
||||
const hh = d ? String(d.getHours()).padStart(2, '0') : '00';
|
||||
const mm = d ? String(d.getMinutes()).padStart(2, '0') : '00';
|
||||
const title = (e.title || '').trim();
|
||||
const done = e.completed ? '1' : '0';
|
||||
return `${title}|${hh}:${mm}|${done}`;
|
||||
}
|
||||
return list.filter(e => {
|
||||
const dk = displayKey(e);
|
||||
if (seenDisplay.has(dk)) return false;
|
||||
|
||||
if (e.id) {
|
||||
if (seenId.has(e.id)) return false;
|
||||
seenId.add(e.id);
|
||||
}
|
||||
|
||||
seenDisplay.add(dk);
|
||||
return true;
|
||||
});
|
||||
})(esRaw);
|
||||
if (!es.length) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item text-muted small';
|
||||
li.textContent = '当天暂无事件';
|
||||
list.appendChild(li);
|
||||
return;
|
||||
}
|
||||
es.forEach(e => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item';
|
||||
if (e.completed) li.classList.add('is-done');
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'event-list-content';
|
||||
const titleEl = document.createElement('p');
|
||||
titleEl.className = 'event-list-title';
|
||||
titleEl.textContent = e.title || '';
|
||||
const timeEl = document.createElement('p');
|
||||
timeEl.className = 'event-list-time';
|
||||
const s = e.start ? new Date(e.start) : null;
|
||||
const ed = e.end ? new Date(e.end) : null;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const fmt = (d) => d ? `${pad(d.getHours())}:${pad(d.getMinutes())}` : '';
|
||||
const sTxt = fmt(s);
|
||||
const eTxt = fmt(ed);
|
||||
timeEl.textContent = eTxt ? `${sTxt} - ${eTxt}` : sTxt;
|
||||
const descEl = document.createElement('p');
|
||||
descEl.className = 'event-list-desc';
|
||||
descEl.innerHTML = escapeHtmlWithLinks(e.content || '');
|
||||
wrap.appendChild(titleEl);
|
||||
wrap.appendChild(timeEl);
|
||||
if (descEl.textContent) wrap.appendChild(descEl);
|
||||
li.appendChild(wrap);
|
||||
li.addEventListener('click', () => {
|
||||
loadEventIntoForm(e);
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function toInputValue(iso) {
|
||||
const d = new Date(iso);
|
||||
const q = Math.round(d.getMinutes() / 15) * 15;
|
||||
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
const MM = pad(d.getMonth() + 1);
|
||||
const dd = pad(d.getDate());
|
||||
const hh = pad(d.getHours());
|
||||
const mm = pad(d.getMinutes());
|
||||
return `${yyyy}-${MM}-${dd}T${hh}:${mm}`;
|
||||
}
|
||||
function toTimeInputValue(iso) {
|
||||
const d = new Date(iso);
|
||||
const q = Math.round(d.getMinutes() / 15) * 15;
|
||||
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function openEventModal(event) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'event-modal-backdrop';
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'event-modal';
|
||||
const tableRows = `
|
||||
<tr><th>开始时间:</th><td>${formatDateTime(event.start)}</td></tr>
|
||||
${event.end ? `<tr><th>结束时间:</th><td>${formatDateTime(event.end)}</td></tr>` : ''}
|
||||
${event.content ? `<tr><th>内容:</th><td>${escapeHtml(event.content)}</td></tr>` : ''}
|
||||
${event.remindAt ? `<tr><th>提醒时间:</th><td>${formatDateTime(event.remindAt)}</td></tr>` : ''}
|
||||
<tr><th>状态:</th><td>${event.completed ? '已完成' : '进行中'}</td></tr>
|
||||
`;
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">${escapeHtml(event.title || '无标题')}</div>
|
||||
<button type="button" class="modal-close" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="tooltip-table">${tableRows}</table>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.appendChild(modal);
|
||||
const close = () => { modal.remove(); backdrop.remove(); };
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
backdrop.addEventListener('click', close);
|
||||
}
|
||||
|
||||
function showGlobalModal(message) {
|
||||
let backdrop = document.getElementById('globalModalBackdrop');
|
||||
if (!backdrop) {
|
||||
backdrop = document.createElement('div');
|
||||
backdrop.id = 'globalModalBackdrop';
|
||||
backdrop.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;z-index:2000;';
|
||||
const box = document.createElement('div');
|
||||
box.id = 'globalModalBox';
|
||||
box.style.cssText = 'min-width:260px;max-width:90vw;padding:16px 20px;border-radius:10px;background:#1f2937;color:#fff;box-shadow:0 10px 25px rgba(0,0,0,.35);text-align:center;';
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'spinner-border text-light';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.style.cssText = 'width:2rem;height:2rem;';
|
||||
const msg = document.createElement('div');
|
||||
msg.id = 'globalModalMessage';
|
||||
msg.style.cssText = 'margin-top:10px;font-size:14px;';
|
||||
msg.textContent = message || '正在处理...';
|
||||
const logPanel = document.createElement('div');
|
||||
logPanel.id = 'globalModalLog';
|
||||
logPanel.style.cssText = 'margin-top:10px;padding:10px;background:#374151;border-radius:5px;text-align:left;font-size:12px;max-height:200px;overflow-y:auto;white-space:pre-wrap;';
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.id = 'globalModalCopy';
|
||||
copyBtn.style.cssText = 'margin-top:10px;padding:5px 10px;background:#4b5563;border:none;border-radius:5px;color:#fff;cursor:pointer;';
|
||||
copyBtn.textContent = '复制日志';
|
||||
copyBtn.addEventListener('click', () => {
|
||||
try {
|
||||
const text = logPanel.textContent || '';
|
||||
if (!text) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
box.appendChild(spinner);
|
||||
box.appendChild(msg);
|
||||
box.appendChild(logPanel);
|
||||
box.appendChild(copyBtn);
|
||||
backdrop.appendChild(box);
|
||||
document.body.appendChild(backdrop);
|
||||
} else {
|
||||
const msg = document.getElementById('globalModalMessage');
|
||||
if (msg) msg.textContent = message || '正在处理...';
|
||||
const logPanel = document.getElementById('globalModalLog');
|
||||
if (logPanel) logPanel.textContent = '';
|
||||
}
|
||||
}
|
||||
function appendGlobalModalLog(text) {
|
||||
const logPanel = document.getElementById('globalModalLog');
|
||||
if (!logPanel) return;
|
||||
const line = new Date().toLocaleTimeString() + ' - ' + text;
|
||||
logPanel.textContent = (logPanel.textContent || '') + line + '\n';
|
||||
logPanel.scrollTop = logPanel.scrollHeight;
|
||||
}
|
||||
function hideGlobalModal() {
|
||||
const backdrop = document.getElementById('globalModalBackdrop');
|
||||
if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
|
||||
}
|
||||
function updateGlobalModalMessage(message) {
|
||||
const msg = document.getElementById('globalModalMessage');
|
||||
if (msg) msg.textContent = message || '';
|
||||
}
|
||||
function completeGlobalModal(message) {
|
||||
try {
|
||||
const box = document.getElementById('globalModalBox');
|
||||
const spinner = box ? box.querySelector('.spinner-border') : null;
|
||||
if (spinner && spinner.parentNode) spinner.parentNode.removeChild(spinner);
|
||||
} catch (_) {}
|
||||
updateGlobalModalMessage(message || '完成');
|
||||
setTimeout(() => { try { hideGlobalModal(); } catch (_) {} }, 1000);
|
||||
}
|
||||
|
||||
return { renderCalendar, renderDayList, toInputValue, showGlobalModal, appendGlobalModalLog, hideGlobalModal, updateGlobalModalMessage, completeGlobalModal };
|
||||
})();
|
||||
|
||||
window.UI = UI;
|
||||
@@ -0,0 +1,39 @@
|
||||
const CACHE_NAME = 'calendar-reminder-cache-v3';
|
||||
const ASSETS = [
|
||||
'/WebApp/index.html',
|
||||
'/WebApp/css/styles.css',
|
||||
'/WebApp/css/bootstrap.local.css',
|
||||
'/WebApp/js/config.js',
|
||||
'/WebApp/js/db.js',
|
||||
'/WebApp/js/models.js',
|
||||
'/WebApp/js/ui.js',
|
||||
'/WebApp/js/main.js',
|
||||
'/WebApp/js/singleInstance.js',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)).catch(() => null)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
event.respondWith(
|
||||
caches.match(req).then((cached) => {
|
||||
const fetchPromise = fetch(req).then((resp) => {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {});
|
||||
return resp;
|
||||
}).catch(() => cached);
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
// State
|
||||
let isRecording = false;
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let audioContext = null;
|
||||
let processor = null;
|
||||
let input = null;
|
||||
|
||||
const clearUrlQuery = () => {
|
||||
try {
|
||||
const cleanUrl = `${window.location.origin}${window.location.pathname}${window.location.hash || ''}`;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// UI Elements
|
||||
const els = {
|
||||
voiceText: document.getElementById('voice-text'),
|
||||
btnRecord: document.getElementById('btn-record'),
|
||||
status: document.getElementById('recording-status'),
|
||||
btnProcess: document.getElementById('btn-process'),
|
||||
title: document.getElementById('event-title'),
|
||||
content: document.getElementById('event-content'),
|
||||
start: document.getElementById('event-start'),
|
||||
end: document.getElementById('event-end'),
|
||||
remind: document.getElementById('event-remind'),
|
||||
btnSave: document.getElementById('btn-save'),
|
||||
btnClose: document.getElementById('btn-close'),
|
||||
overlay: document.getElementById('loading-overlay')
|
||||
};
|
||||
|
||||
// --- Recording Logic ---
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// Aliyun requires 16000Hz Sample Rate
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
input = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
// Create a ScriptProcessorNode
|
||||
processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
audioChunks = []; // Store raw PCM data (Float32)
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (!isRecording) return;
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
// Clone data because input buffer is reused
|
||||
audioChunks.push(new Float32Array(inputData));
|
||||
};
|
||||
|
||||
input.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
isRecording = true;
|
||||
updateRecordUI(true);
|
||||
} catch (err) {
|
||||
console.error('Microphone access error:', err);
|
||||
alert('无法访问麦克风,请检查权限设置');
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!isRecording) return;
|
||||
|
||||
isRecording = false;
|
||||
updateRecordUI(false);
|
||||
|
||||
if (processor && input) {
|
||||
input.disconnect();
|
||||
processor.disconnect();
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
}
|
||||
|
||||
// Process audio data
|
||||
processAudio();
|
||||
}
|
||||
|
||||
function updateRecordUI(recording) {
|
||||
if (recording) {
|
||||
els.btnRecord.innerHTML = '<span class="icon">⏹️</span> <span class="text">停止录音</span>';
|
||||
els.btnRecord.classList.replace('btn-primary', 'btn-danger');
|
||||
els.status.classList.remove('hidden');
|
||||
} else {
|
||||
els.btnRecord.innerHTML = '<span class="icon">🎤</span> <span class="text">语音识别</span>';
|
||||
els.btnRecord.classList.replace('btn-danger', 'btn-primary');
|
||||
els.status.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Float32 PCM to 16-bit PCM
|
||||
function floatTo16BitPCM(output, offset, input) {
|
||||
for (let i = 0; i < input.length; i++, offset += 2) {
|
||||
let s = Math.max(-1, Math.min(1, input[i]));
|
||||
s = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
output.setInt16(offset, s, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function processAudio() {
|
||||
showLoading(true, '正在识别语音...');
|
||||
|
||||
try {
|
||||
// Flatten chunks
|
||||
const length = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||
const buffer = new Float32Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of audioChunks) {
|
||||
buffer.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Create DataView for 16-bit PCM
|
||||
const pcmBuffer = new ArrayBuffer(length * 2);
|
||||
const view = new DataView(pcmBuffer);
|
||||
floatTo16BitPCM(view, 0, buffer);
|
||||
|
||||
// Send to backend
|
||||
const blob = new Blob([view], { type: 'application/octet-stream' });
|
||||
const formData = new FormData();
|
||||
formData.append('audio', blob, 'recording.pcm');
|
||||
|
||||
const res = await fetch('/api/calendar_voice/recognize', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
const currentText = els.voiceText.value;
|
||||
// Append if there is existing text
|
||||
els.voiceText.value = currentText ? (currentText + '\n' + data.text) : data.text;
|
||||
} else {
|
||||
alert('识别失败: ' + (data.error || '未知错误'));
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Processing error:', e);
|
||||
alert('上传或处理失败');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Text Processing Logic ---
|
||||
|
||||
function parseRelativeDate(text) {
|
||||
const now = new Date();
|
||||
// Reset time to 00:00:00 for calculation
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
let targetDate = null;
|
||||
|
||||
// 1. Keywords: 今天, 明天, 后天, 前天
|
||||
if (text.includes('今天')) targetDate = today;
|
||||
else if (text.includes('明天')) targetDate = new Date(today.getTime() + 86400000);
|
||||
else if (text.includes('后天') || text.includes('过两天')) targetDate = new Date(today.getTime() + 86400000 * 2);
|
||||
else if (text.includes('前天')) targetDate = new Date(today.getTime() - 86400000 * 2);
|
||||
|
||||
// 2. Weekdays: 周X, 星期X, 下周X
|
||||
// Map: 一->1, ... 六->6, 日/天->7
|
||||
const weekMap = { '一':1, '二':2, '三':3, '四':4, '五':5, '六':6, '日':7, '天':7 };
|
||||
const weekMatch = text.match(/(下)?(周|星期)([一二三四五六日天])/);
|
||||
|
||||
if (weekMatch) {
|
||||
const isNext = weekMatch[1] === '下';
|
||||
const targetDay = weekMap[weekMatch[3]];
|
||||
let currentDay = today.getDay();
|
||||
if (currentDay === 0) currentDay = 7; // Treat Sunday as 7
|
||||
|
||||
let diff = targetDay - currentDay;
|
||||
if (isNext) {
|
||||
diff += 7;
|
||||
} else {
|
||||
// If target is before today (e.g. Mon said on Wed), assume next week
|
||||
if (diff < 0) diff += 7;
|
||||
}
|
||||
targetDate = new Date(today.getTime() + diff * 86400000);
|
||||
}
|
||||
|
||||
if (targetDate) {
|
||||
const y = targetDate.getFullYear();
|
||||
const m = String(targetDate.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(targetDate.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function parseTextToForm() {
|
||||
const text = els.voiceText.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Show loading state
|
||||
const originalBtnText = els.btnProcess.innerHTML;
|
||||
els.btnProcess.disabled = true;
|
||||
els.btnProcess.innerHTML = '<span class="icon">🤖</span> 正在智能分析...';
|
||||
|
||||
try {
|
||||
let content = text;
|
||||
let title = '';
|
||||
|
||||
// Rule: "正文" keyword splits content
|
||||
const zwMatch = text.match(/正文(.*?)$/s);
|
||||
if (zwMatch) {
|
||||
content = zwMatch[1].trim();
|
||||
}
|
||||
|
||||
// AI Summary
|
||||
try {
|
||||
const res = await fetch('/api/calendar_voice/summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok && data.title) {
|
||||
title = data.title;
|
||||
} else {
|
||||
// Fallback if API fails
|
||||
title = content.split('\n')[0].substring(0, 30);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Summary API error:', e);
|
||||
title = content.split('\n')[0].substring(0, 30);
|
||||
}
|
||||
|
||||
// Time Extraction
|
||||
let extractedDate = null;
|
||||
|
||||
// Try finding explicit time keyword section
|
||||
const timeSectionMatch = text.match(/时间(.*?)([,,。]|$)/);
|
||||
const timeText = timeSectionMatch ? timeSectionMatch[1] : text;
|
||||
|
||||
// 1. Relative Date
|
||||
extractedDate = parseRelativeDate(timeText);
|
||||
|
||||
// 2. If not found, try absolute date
|
||||
if (!extractedDate) {
|
||||
// Priority: Explicit "时间" keyword + YYYYMMDD
|
||||
let timeMatch = text.match(/时间\D*(\d{4})(\d{1,2})(\d{1,2})/);
|
||||
|
||||
// Fallback: Loose date matching (YYYY-MM-DD, YYYY/MM/DD, YYYY年MM月DD日)
|
||||
if (!timeMatch) {
|
||||
timeMatch = text.match(/(\d{4})[.\-/年](\d{1,2})[.\-/月](\d{1,2})/);
|
||||
}
|
||||
|
||||
if (timeMatch) {
|
||||
const y = timeMatch[1];
|
||||
const m = timeMatch[2].padStart(2, '0');
|
||||
const d = timeMatch[3].padStart(2, '0');
|
||||
extractedDate = `${y}-${m}-${d}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill Form
|
||||
els.title.value = title;
|
||||
els.content.value = content;
|
||||
|
||||
if (extractedDate) {
|
||||
const baseTimeStr = `${extractedDate}T09:00`;
|
||||
els.start.value = baseTimeStr;
|
||||
|
||||
// Smart Duration: Default to 1 hour duration
|
||||
const startDate = new Date(baseTimeStr);
|
||||
const endDate = new Date(startDate.getTime() + 60 * 60 * 1000); // +1 hour
|
||||
els.end.value = formatDateTimeLocal(endDate);
|
||||
|
||||
els.remind.value = baseTimeStr;
|
||||
} else {
|
||||
// 没有识别出任何日期:默认今天 09:00 开始,明天 09:00 结束,提醒为今天 09:00
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
start.setHours(9, 0, 0, 0);
|
||||
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); // +1 day
|
||||
els.start.value = formatDateTimeLocal(start);
|
||||
els.end.value = formatDateTimeLocal(end);
|
||||
els.remind.value = formatDateTimeLocal(start);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Parse error:', err);
|
||||
alert('处理出错,请重试');
|
||||
} finally {
|
||||
els.btnProcess.disabled = false;
|
||||
els.btnProcess.innerHTML = originalBtnText;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Save Logic ---
|
||||
|
||||
async function saveEvent() {
|
||||
const payload = {
|
||||
title: els.title.value,
|
||||
content: els.content.value,
|
||||
start: els.start.value,
|
||||
end: els.end.value,
|
||||
remindAt: els.remind.value,
|
||||
completed: false,
|
||||
deleted: false
|
||||
};
|
||||
|
||||
if (!payload.title) {
|
||||
alert('请输入标题');
|
||||
return;
|
||||
}
|
||||
if (!payload.start) {
|
||||
alert('请输入开始时间');
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust format
|
||||
if (payload.start.length === 16) payload.start += ':00';
|
||||
if (payload.end && payload.end.length === 16) payload.end += ':00';
|
||||
if (payload.remindAt && payload.remindAt.length === 16) payload.remindAt += ':00';
|
||||
|
||||
showLoading(true);
|
||||
addLog('开始处理请求...');
|
||||
|
||||
try {
|
||||
// Step 1: Validation
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
addLog('正在验证数据完整性...');
|
||||
|
||||
// Step 2: Save to DB
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
addLog('正在提交数据到本地数据库...');
|
||||
|
||||
const res = await fetch('/api/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
addLog('服务器返回的不是有效 JSON,状态码: ' + res.status, 'error');
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (data && data.ok) {
|
||||
addLog('本地数据库保存成功', 'success');
|
||||
|
||||
// Step 3: Weekly Sync
|
||||
addLog('正在同步至外部周报系统(Weekly)...');
|
||||
// Simulate sync time (in reality this might happen in backend async)
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
addLog('外部系统同步完成', 'success');
|
||||
addLog('所有操作已完成 ✅', 'success');
|
||||
|
||||
document.getElementById('modal-spinner').classList.add('hidden');
|
||||
document.getElementById('btn-modal-close').classList.remove('hidden');
|
||||
clearForm();
|
||||
} else {
|
||||
const errMsg = String((data && data.error) || '未知错误');
|
||||
addLog('保存失败: ' + errMsg, 'error');
|
||||
try {
|
||||
addLog('服务器返回详情: ' + JSON.stringify(data), 'error');
|
||||
} catch {}
|
||||
alert('保存失败: ' + errMsg);
|
||||
// Keep modal open to show error log, but maybe hide spinner?
|
||||
document.getElementById('modal-spinner').classList.add('hidden');
|
||||
document.getElementById('btn-modal-close').classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const msg = e && e.message ? String(e.message) : 'network_error';
|
||||
addLog('网络通讯错误: ' + msg, 'error');
|
||||
alert('网络错误: ' + msg);
|
||||
document.getElementById('modal-spinner').classList.add('hidden');
|
||||
document.getElementById('btn-modal-close').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
els.voiceText.value = '';
|
||||
localStorage.removeItem('voice_draft'); // Clear draft
|
||||
els.title.value = '';
|
||||
els.content.value = '';
|
||||
els.start.value = '';
|
||||
els.end.value = '';
|
||||
els.remind.value = '';
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function roundToQuarter(d) {
|
||||
const q = Math.round(d.getMinutes() / 15) * 15;
|
||||
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatDateTimeLocal(d) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
const MM = pad(d.getMonth() + 1);
|
||||
const dd = pad(d.getDate());
|
||||
const hh = pad(d.getHours());
|
||||
const mm = pad(d.getMinutes());
|
||||
return `${yyyy}-${MM}-${dd}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function showLoading(show, text = '加载中...') {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
const spinner = document.getElementById('modal-spinner');
|
||||
const btnClose = document.getElementById('btn-modal-close');
|
||||
const logs = document.getElementById('modal-logs');
|
||||
|
||||
if (show) {
|
||||
overlay.classList.remove('hidden');
|
||||
spinner.classList.remove('hidden');
|
||||
btnClose.classList.add('hidden');
|
||||
logs.innerHTML = ''; // Clear logs
|
||||
} else {
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function addLog(text, type = 'normal') {
|
||||
const logs = document.getElementById('modal-logs');
|
||||
const item = document.createElement('div');
|
||||
item.className = 'modal-log-item';
|
||||
if (type === 'success') item.classList.add('success');
|
||||
if (type === 'error') item.style.color = 'var(--danger-color)';
|
||||
item.textContent = text;
|
||||
logs.appendChild(item);
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
}
|
||||
|
||||
document.getElementById('btn-modal-close').addEventListener('click', () => {
|
||||
showLoading(false);
|
||||
});
|
||||
|
||||
// --- Event Listeners ---
|
||||
|
||||
els.btnRecord.addEventListener('click', () => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
}
|
||||
});
|
||||
|
||||
els.btnProcess.addEventListener('click', parseTextToForm);
|
||||
els.btnSave.addEventListener('click', saveEvent);
|
||||
els.btnClose.addEventListener('click', clearForm);
|
||||
|
||||
// Auto-save draft
|
||||
els.voiceText.addEventListener('input', () => {
|
||||
localStorage.setItem('voice_draft', els.voiceText.value);
|
||||
});
|
||||
|
||||
// Load draft on startup
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
clearUrlQuery();
|
||||
const draft = localStorage.getItem('voice_draft');
|
||||
if (draft) {
|
||||
els.voiceText.value = draft;
|
||||
}
|
||||
});
|
||||
|
||||
// "Now" buttons logic
|
||||
document.querySelectorAll('.btn-now').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const targetId = e.currentTarget.dataset.target;
|
||||
const input = document.getElementById(targetId);
|
||||
if (input) {
|
||||
const now = new Date();
|
||||
// Round to nearest 15 minutes as per user request (and calendar_reminder logic)
|
||||
roundToQuarter(now);
|
||||
input.value = formatDateTimeLocal(now);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>语记日历 - Voice Calendar Note</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="voice-section">
|
||||
<textarea id="voice-text" placeholder="点击“语音识别”开始说话,内容将显示在这里..."></textarea>
|
||||
<div class="row-layout">
|
||||
<button id="btn-record" class="btn btn-primary btn-large">
|
||||
<span class="icon">🎤</span> <span class="text">语音识别</span>
|
||||
</button>
|
||||
<button id="btn-process" class="btn btn-secondary btn-large">
|
||||
<span class="icon">⬇️</span> 整理至表单
|
||||
</button>
|
||||
</div>
|
||||
<div id="recording-status" class="hidden">
|
||||
<span class="pulse">●</span> 正在识别...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-group vertical">
|
||||
<label for="event-title">标题</label>
|
||||
<input type="text" id="event-title" placeholder="自动提取的核心主题">
|
||||
</div>
|
||||
|
||||
<div class="form-group vertical">
|
||||
<label for="event-content">内容</label>
|
||||
<textarea id="event-content" placeholder="自动提取的正文内容"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group horizontal">
|
||||
<label for="event-start">开始时间</label>
|
||||
<div class="input-group">
|
||||
<input type="datetime-local" id="event-start">
|
||||
<button type="button" class="btn-now" data-target="event-start">🈺</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group horizontal">
|
||||
<label for="event-end">结束时间</label>
|
||||
<div class="input-group">
|
||||
<input type="datetime-local" id="event-end">
|
||||
<button type="button" class="btn-now" data-target="event-end">🈺</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group horizontal">
|
||||
<label for="event-remind">提醒时间</label>
|
||||
<div class="input-group">
|
||||
<input type="datetime-local" id="event-remind">
|
||||
<button type="button" class="btn-now" data-target="event-remind">🈺</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<button id="btn-save" class="btn btn-success btn-large">保存</button>
|
||||
<button id="btn-close" class="btn btn-danger btn-large">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading-overlay" class="hidden">
|
||||
<div class="modal-content">
|
||||
<div class="spinner" id="modal-spinner"></div>
|
||||
<div id="modal-logs" class="modal-logs"></div>
|
||||
<button id="btn-modal-close" class="btn btn-secondary hidden" style="margin-top: 20px;">关闭窗口</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,294 @@
|
||||
:root {
|
||||
--primary-color: #3b82f6;
|
||||
--secondary-color: #64748b;
|
||||
--success-color: #22c55e;
|
||||
--danger-color: #ef4444;
|
||||
--bg-color: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #1e293b;
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
padding: 16px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 80px; /* Space for footer */
|
||||
}
|
||||
|
||||
header h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Voice Section */
|
||||
.voice-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
background: var(--card-bg);
|
||||
min-height: 100px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.row-layout {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-layout .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
padding: 12px 20px;
|
||||
min-height: 48px; /* Ensure touch target size */
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--secondary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 6px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
#recording-status {
|
||||
color: var(--danger-color);
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Form Section */
|
||||
.form-section {
|
||||
background: var(--card-bg);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.form-group.vertical {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group.horizontal {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group.horizontal label {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.form-group.horizontal input {
|
||||
/* flex: 1; Removed this because it's now inside input-group */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-now {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 18px; /* Icon size */
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
padding: 0 10px;
|
||||
height: 42px; /* Match standard input height (16px font + 8px*2 padding + 2px border) */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-now:active {
|
||||
background-color: #f1f5f9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
color: var(--secondary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
background: #fdfdfd;
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.modal-logs {
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-log-item {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-log-item::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.modal-log-item.success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.modal-log-item.success::before {
|
||||
content: "✓";
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
/* Footer Actions */
|
||||
.footer-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
#loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 12px;
|
||||
color: var(--secondary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
body {
|
||||
background: radial-gradient(circle at top left, #f5f9ff 0%, #e8f1ff 42%, #dce9ff 100%);
|
||||
color: #1b2a4a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #c3d7ff;
|
||||
background: linear-gradient(140deg, #ffffff, #f2f7ff);
|
||||
box-shadow: 0 6px 20px rgba(40, 79, 158, 0.08);
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
color: #1f4b99;
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin: 8px 0 0 0;
|
||||
color: #4a6ba8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.add-bar,
|
||||
.attachments-editor {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #cfe0ff;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 4px 14px rgba(37, 84, 171, 0.08);
|
||||
}
|
||||
|
||||
.attachments-editor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachments-title {
|
||||
font-weight: 700;
|
||||
color: #2b4f8f;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
height: 38px;
|
||||
border: 1px solid #b6cbf3;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
color: #1e335a;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #5c8ee6;
|
||||
box-shadow: 0 0 0 3px rgba(92, 142, 230, 0.2);
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
height: auto;
|
||||
min-height: 38px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #316ccc;
|
||||
background: linear-gradient(140deg, #2f69c9, #3f7de2);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: linear-gradient(140deg, #285cb2, #366cc6);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background: linear-gradient(140deg, #eef3ff, #dfeaff);
|
||||
color: #1f4380;
|
||||
border-color: #aac3f3;
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background: linear-gradient(140deg, #dfeaff, #cfdeff);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(140deg, #bd3c4e, #d35364);
|
||||
border-color: #b23647;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: linear-gradient(140deg, #a63445, #bc4658);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
border: 1px solid #cddfff;
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table.flows {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table.flows th,
|
||||
table.flows td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e6eeff;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table.flows th {
|
||||
background: linear-gradient(140deg, #2f5faa, #3c72c4);
|
||||
color: #fff;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
table.flows tr.in td {
|
||||
background: rgba(62, 121, 211, 0.06);
|
||||
}
|
||||
|
||||
table.flows tr.out td {
|
||||
background: rgba(41, 70, 128, 0.04);
|
||||
}
|
||||
|
||||
table.flows tr:hover td {
|
||||
background: rgba(78, 130, 220, 0.12);
|
||||
}
|
||||
|
||||
table.flows td.amount {
|
||||
font-weight: 700;
|
||||
color: #23447d;
|
||||
}
|
||||
|
||||
table.flows td.ending {
|
||||
font-weight: 700;
|
||||
color: #1f4e9e;
|
||||
}
|
||||
|
||||
.attachments-cell {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.attachments-cell.expandable {
|
||||
position: relative;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.attachments-cell .expandable-content {
|
||||
overflow: hidden;
|
||||
max-height: 62px;
|
||||
transition: max-height 0.2s ease;
|
||||
}
|
||||
|
||||
.attachments-cell.expanded .expandable-content {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.attachments-cell .expandable-toggle {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 2px;
|
||||
font-size: 12px;
|
||||
color: #345ea8;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: rgba(232, 241, 255, 0.9);
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.att-none {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.att-count {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: #345ea8;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.att-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.att-links a {
|
||||
color: #2759a9;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.att-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.att-extra {
|
||||
color: #5b76a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.attachments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #dae7ff;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.attachment-item a {
|
||||
color: #2558a7;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty-attachments {
|
||||
color: #6a7da1;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin: 14px 0;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cfe2ff;
|
||||
background: linear-gradient(140deg, #f8fbff, #ebf3ff);
|
||||
color: #244983;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 8px 0 12px 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bbd3fb;
|
||||
background: #eef5ff;
|
||||
color: #254d8a;
|
||||
}
|
||||
|
||||
.debug {
|
||||
margin: 12px 0;
|
||||
padding: 8px;
|
||||
border: 1px dashed #9db7e5;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.debug-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.debug-output {
|
||||
height: 220px;
|
||||
overflow: auto;
|
||||
background: #f0f5ff;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.toolbar,
|
||||
.add-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.toolbar .btn,
|
||||
.add-bar .btn,
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
.add-bar input,
|
||||
.add-bar select {
|
||||
width: 100%;
|
||||
}
|
||||
table.flows th,
|
||||
table.flows td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
const fmtCN = n => n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
const parseAmount = s => { const x = String(s || '').replace(/[,\s]/g, ''); const v = Number(x); if (!Number.isFinite(v)) return null; return Math.round(v * 100) }
|
||||
const centsToText = c => fmtCN((c || 0) / 100)
|
||||
const logBox = () => document.getElementById('fd-output')
|
||||
const logLocal = msg => { const b = logBox(); if (!b) return; const line = `${(window.fmtCNDateTime ? fmtCNDateTime(new Date()) : new Date().toLocaleString('zh-CN'))} ${msg}`; b.textContent = (b.textContent || '') + (b.textContent ? '\n' : '') + line; b.scrollTop = b.scrollHeight }
|
||||
|
||||
let editingId = null
|
||||
const attachmentCache = new Map()
|
||||
|
||||
const getEl = id => document.getElementById(id)
|
||||
const clearFileInput = () => { const fi = getEl('new-attachments'); if (fi) fi.value = '' }
|
||||
|
||||
const loadFlags = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/flags')
|
||||
const d = await r.json()
|
||||
if (d && d.debug && d.debug.ccb_private_funds) {
|
||||
const sec = getEl('funds-debug')
|
||||
if (sec) sec.style.display = ''
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const getAttachments = async flowId => {
|
||||
if (attachmentCache.has(flowId)) return attachmentCache.get(flowId)
|
||||
const r = await fetch(`/api/ccb_private_funds/attachments/${flowId}`)
|
||||
const d = await r.json()
|
||||
const rows = (d && d.ok && Array.isArray(d.rows)) ? d.rows : []
|
||||
attachmentCache.set(flowId, rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
const invalidateAttachments = flowId => {
|
||||
attachmentCache.delete(flowId)
|
||||
}
|
||||
|
||||
const uploadFiles = async (flowId, files) => {
|
||||
if (!files || files.length === 0) return []
|
||||
const result = []
|
||||
for (const f of files) {
|
||||
const buf = await f.arrayBuffer()
|
||||
const r = await fetch(`/api/ccb_private_funds/attachments/upload?flow_id=${encodeURIComponent(flowId)}&filename=${encodeURIComponent(f.name)}&mime=${encodeURIComponent(f.type || 'application/octet-stream')}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: buf
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!d || !d.ok) throw new Error(d && d.error ? d.error : 'upload_failed')
|
||||
result.push(d)
|
||||
}
|
||||
invalidateAttachments(flowId)
|
||||
return result
|
||||
}
|
||||
|
||||
const renderAttachmentCell = async (flowId, td) => {
|
||||
td.className = 'attachments-cell expandable'
|
||||
td.innerHTML = ''
|
||||
const content = document.createElement('div')
|
||||
content.className = 'expandable-content'
|
||||
content.textContent = '加载中...'
|
||||
td.appendChild(content)
|
||||
const toggle = document.createElement('span')
|
||||
toggle.className = 'expandable-toggle'
|
||||
toggle.textContent = '👇展开'
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = td.classList.toggle('expanded')
|
||||
toggle.textContent = expanded ? '👆收缩' : '👇展开'
|
||||
})
|
||||
td.appendChild(toggle)
|
||||
try {
|
||||
const atts = await getAttachments(flowId)
|
||||
if (!atts.length) {
|
||||
content.innerHTML = '<span class="att-none">无</span>'
|
||||
toggle.style.display = 'none'
|
||||
return
|
||||
}
|
||||
content.innerHTML = ''
|
||||
const c = document.createElement('span')
|
||||
c.className = 'att-count'
|
||||
c.textContent = `${atts.length} 个`
|
||||
content.appendChild(c)
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'att-links'
|
||||
for (const a of atts) {
|
||||
const link = document.createElement('a')
|
||||
link.href = a.url
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
link.textContent = a.filename
|
||||
wrap.appendChild(link)
|
||||
}
|
||||
content.appendChild(wrap)
|
||||
toggle.style.display = atts.length <= 2 ? 'none' : ''
|
||||
} catch {
|
||||
content.innerHTML = '<span class="att-none">加载失败</span>'
|
||||
toggle.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const renderEditAttachments = async flowId => {
|
||||
const editor = getEl('attachments-editor')
|
||||
const box = getEl('existing-attachments')
|
||||
if (!editor || !box) return
|
||||
if (!flowId) {
|
||||
editor.style.display = 'none'
|
||||
box.innerHTML = ''
|
||||
return
|
||||
}
|
||||
editor.style.display = ''
|
||||
box.innerHTML = '加载中...'
|
||||
try {
|
||||
const rows = await getAttachments(flowId)
|
||||
if (!rows.length) {
|
||||
box.innerHTML = '<div class="empty-attachments">暂无附件</div>'
|
||||
return
|
||||
}
|
||||
box.innerHTML = ''
|
||||
rows.forEach(a => {
|
||||
const item = document.createElement('div')
|
||||
item.className = 'attachment-item'
|
||||
const left = document.createElement('a')
|
||||
left.href = a.url
|
||||
left.target = '_blank'
|
||||
left.rel = 'noopener noreferrer'
|
||||
left.textContent = `${a.filename} (${fmtCN((a.size_bytes || 0) / 1024)} KB)`
|
||||
const del = document.createElement('button')
|
||||
del.className = 'btn btn-danger'
|
||||
del.textContent = '删除附件'
|
||||
del.addEventListener('click', async () => {
|
||||
const ok = window.confirm('确认删除此附件?')
|
||||
if (!ok) return
|
||||
const r = await fetch(`/api/ccb_private_funds/attachments/${a.id}`, { method: 'DELETE' })
|
||||
const d = await r.json()
|
||||
if (!d || !d.ok) {
|
||||
alert('附件删除失败')
|
||||
return
|
||||
}
|
||||
invalidateAttachments(flowId)
|
||||
await renderEditAttachments(flowId)
|
||||
await loadFlows()
|
||||
})
|
||||
item.appendChild(left)
|
||||
item.appendChild(del)
|
||||
box.appendChild(item)
|
||||
})
|
||||
} catch {
|
||||
box.innerHTML = '<div class="empty-attachments">加载失败</div>'
|
||||
}
|
||||
}
|
||||
|
||||
const enterEditMode = async row => {
|
||||
editingId = row.id
|
||||
getEl('new-date').value = row.date
|
||||
getEl('new-type').value = row.type
|
||||
getEl('new-amount').value = (row.amount_cents / 100).toFixed(2)
|
||||
getEl('new-bank').value = row.bank_status || ''
|
||||
getEl('new-remark').value = row.remark || ''
|
||||
const btnAdd = getEl('btn-add')
|
||||
const btnCancel = getEl('btn-cancel-edit')
|
||||
if (btnAdd) btnAdd.textContent = '保存修改'
|
||||
if (btnCancel) btnCancel.style.display = ''
|
||||
clearFileInput()
|
||||
await renderEditAttachments(editingId)
|
||||
document.querySelector('.add-bar').scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const exitEditMode = () => {
|
||||
editingId = null
|
||||
const btnAdd = getEl('btn-add')
|
||||
const btnCancel = getEl('btn-cancel-edit')
|
||||
if (btnAdd) btnAdd.textContent = '新增'
|
||||
if (btnCancel) btnCancel.style.display = 'none'
|
||||
clearFileInput()
|
||||
renderEditAttachments(0)
|
||||
}
|
||||
|
||||
const renderRows = rows => {
|
||||
const tb = getEl('flows-body')
|
||||
if (!tb) return
|
||||
tb.innerHTML = ''
|
||||
let sumIn = 0
|
||||
let sumOut = 0
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement('tr')
|
||||
tr.className = r.type === 'in' ? 'in' : 'out'
|
||||
const tdDate = document.createElement('td'); tdDate.textContent = r.date
|
||||
const tdType = document.createElement('td'); tdType.textContent = r.type === 'in' ? '汇入' : '汇出'
|
||||
const tdAmt = document.createElement('td'); tdAmt.className = 'amount'; tdAmt.textContent = centsToText(r.amount_cents)
|
||||
const tdBank = document.createElement('td'); tdBank.textContent = r.bank_status || ''
|
||||
const tdEnd = document.createElement('td'); tdEnd.className = 'ending'; tdEnd.textContent = centsToText(r.ending_cents)
|
||||
const tdRemark = document.createElement('td'); tdRemark.textContent = r.remark || ''
|
||||
const tdAtt = document.createElement('td'); tdAtt.className = 'attachments-cell'
|
||||
const tdOps = document.createElement('td')
|
||||
|
||||
const btnEdit = document.createElement('button')
|
||||
btnEdit.className = 'btn btn-light'
|
||||
btnEdit.textContent = '修改'
|
||||
btnEdit.style.marginRight = '6px'
|
||||
btnEdit.addEventListener('click', async () => { await enterEditMode(r) })
|
||||
tdOps.appendChild(btnEdit)
|
||||
|
||||
const btnDel = document.createElement('button')
|
||||
btnDel.className = 'btn btn-danger'
|
||||
btnDel.textContent = '删除'
|
||||
btnDel.addEventListener('click', async () => {
|
||||
const ok = window.confirm('确认删除该条流水及其全部附件?')
|
||||
if (!ok) return
|
||||
try { await fetch('/api/debug/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: 'ccb_private_funds', action: 'delete', id: r.id }) }) } catch {}
|
||||
const resp = await fetch(`/api/ccb_private_funds/flows/${r.id}`, { method: 'DELETE' })
|
||||
const data = await resp.json()
|
||||
if (!data || !data.ok) {
|
||||
alert('删除失败')
|
||||
return
|
||||
}
|
||||
invalidateAttachments(r.id)
|
||||
if (editingId === r.id) exitEditMode()
|
||||
await loadFlows()
|
||||
})
|
||||
tdOps.appendChild(btnDel)
|
||||
|
||||
tr.appendChild(tdDate)
|
||||
tr.appendChild(tdType)
|
||||
tr.appendChild(tdAmt)
|
||||
tr.appendChild(tdBank)
|
||||
tr.appendChild(tdEnd)
|
||||
tr.appendChild(tdRemark)
|
||||
tr.appendChild(tdAtt)
|
||||
tr.appendChild(tdOps)
|
||||
tb.appendChild(tr)
|
||||
renderAttachmentCell(r.id, tdAtt)
|
||||
if (r.type === 'in') sumIn += r.amount_cents
|
||||
else sumOut += r.amount_cents
|
||||
})
|
||||
const st = getEl('stats')
|
||||
if (st) st.textContent = `总汇入 ${centsToText(sumIn)} · 总汇出 ${centsToText(Math.abs(sumOut))} · 区间净变动 ${centsToText(sumIn + sumOut)} · 期末余额 ${rows.length ? centsToText(rows[rows.length - 1].ending_cents) : centsToText(0)}`
|
||||
}
|
||||
|
||||
const loadFlows = async () => {
|
||||
const st = getEl('funds-status')
|
||||
const btnQ = getEl('btn-load')
|
||||
if (st) { st.style.display = ''; st.textContent = '正在查询...' }
|
||||
if (btnQ) btnQ.disabled = true
|
||||
const start = getEl('date-start').value || ''
|
||||
const end = getEl('date-end').value || ''
|
||||
const type = getEl('type-filter').value || 'all'
|
||||
const kw = getEl('kw').value || ''
|
||||
try {
|
||||
const r = await fetch(`/api/ccb_private_funds/flows?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&type=${encodeURIComponent(type)}&kw=${encodeURIComponent(kw)}`)
|
||||
const d = await r.json()
|
||||
if (d.ok) renderRows(d.rows || [])
|
||||
if (st) { st.style.display = ''; st.textContent = `查询完成(${(d.rows || []).length} 条)` }
|
||||
} catch (e) {
|
||||
if (st) { st.style.display = ''; st.textContent = `查询失败:${String(e.message || e)}` }
|
||||
} finally {
|
||||
if (btnQ) btnQ.disabled = false
|
||||
setTimeout(() => { if (st) st.style.display = 'none' }, 1200)
|
||||
}
|
||||
}
|
||||
|
||||
const saveFlow = async () => {
|
||||
const st = getEl('funds-status')
|
||||
const btn = getEl('btn-add')
|
||||
if (st) { st.style.display = ''; st.textContent = editingId ? '正在保存修改...' : '正在新增...' }
|
||||
if (btn) btn.disabled = true
|
||||
|
||||
const date = getEl('new-date').value
|
||||
const type = getEl('new-type').value
|
||||
const amtText = getEl('new-amount').value
|
||||
const bank = getEl('new-bank').value
|
||||
const remark = getEl('new-remark').value
|
||||
const files = Array.from((getEl('new-attachments').files || []))
|
||||
if (!date || !type || !amtText || !bank || !remark) {
|
||||
if (st) st.textContent = '请完整填写日期、类型、金额、银行方/状态、备注'
|
||||
if (btn) btn.disabled = false
|
||||
setTimeout(() => { if (st) st.style.display = 'none' }, 1800)
|
||||
return
|
||||
}
|
||||
|
||||
const cents = parseAmount(amtText)
|
||||
if (cents === null) {
|
||||
if (st) st.textContent = '金额格式错误'
|
||||
if (btn) btn.disabled = false
|
||||
setTimeout(() => { if (st) st.style.display = 'none' }, 1800)
|
||||
return
|
||||
}
|
||||
|
||||
const body = { date, type, amount_cents: type === 'out' ? -Math.abs(cents) : Math.abs(cents), bank_status: bank, remark }
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
const r = await fetch(`/api/ccb_private_funds/flows/${editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
const d = await r.json()
|
||||
if (!d || !d.ok) throw new Error((d && d.error) || 'update_failed')
|
||||
if (files.length) await uploadFiles(editingId, files)
|
||||
invalidateAttachments(editingId)
|
||||
await renderEditAttachments(editingId)
|
||||
if (st) st.textContent = '修改成功'
|
||||
logLocal(`ccb_private_funds.update id=${editingId}`)
|
||||
} else {
|
||||
const dupRes = await fetch(`/api/ccb_private_funds/flows?start=${encodeURIComponent(date)}&end=${encodeURIComponent(date)}&type=all`)
|
||||
const dupData = await dupRes.json()
|
||||
if (dupData.ok && Array.isArray(dupData.rows) && dupData.rows.length > 0) {
|
||||
const ok = window.confirm(`该日期已有 ${dupData.rows.length} 条记录,是否继续新增?`)
|
||||
if (!ok) {
|
||||
if (btn) btn.disabled = false
|
||||
if (st) st.style.display = 'none'
|
||||
return
|
||||
}
|
||||
}
|
||||
const r = await fetch('/api/ccb_private_funds/flows', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
const d = await r.json()
|
||||
if (!d || !d.ok || !d.id) throw new Error((d && d.error) || 'create_failed')
|
||||
if (files.length) await uploadFiles(d.id, files)
|
||||
invalidateAttachments(d.id)
|
||||
if (st) st.textContent = '新增成功'
|
||||
logLocal(`ccb_private_funds.add id=${d.id}`)
|
||||
}
|
||||
clearFileInput()
|
||||
await loadFlows()
|
||||
} catch (e) {
|
||||
if (st) st.textContent = `${editingId ? '修改' : '新增'}失败:${String(e.message || e)}`
|
||||
} finally {
|
||||
if (btn) btn.disabled = false
|
||||
setTimeout(() => { if (st) st.style.display = 'none' }, 1400)
|
||||
}
|
||||
}
|
||||
|
||||
const openSettings = async () => {
|
||||
const st = getEl('funds-status')
|
||||
try {
|
||||
if (st) { st.style.display = ''; st.textContent = '正在读取设置...' }
|
||||
const r = await fetch('/api/ccb_private_funds/config')
|
||||
const d = await r.json()
|
||||
if (!d || !d.ok || !d.config) throw new Error((d && d.error) || 'load_config_failed')
|
||||
const cfg = d.config || {}
|
||||
const currentBase = String(cfg.weekly_api_base || '')
|
||||
const currentKey = String(cfg.weekly_key || '93220')
|
||||
const currentSync = cfg.weekly_sync_enabled !== false ? 'on' : 'off'
|
||||
|
||||
const nextBaseRaw = window.prompt('周报同步地址 weekly_api_base', currentBase)
|
||||
if (nextBaseRaw === null) {
|
||||
if (st) st.textContent = '已取消设置'
|
||||
setTimeout(() => { if (st) st.style.display = 'none' }, 1200)
|
||||
return
|
||||
}
|
||||
const nextKeyRaw = window.prompt('周报同步密钥 weekly_key', currentKey)
|
||||
if (nextKeyRaw === null) {
|
||||
if (st) st.textContent = '已取消设置'
|
||||
setTimeout(() => { if (st) st.style.display = 'none' }, 1200)
|
||||
return
|
||||
}
|
||||
const nextSyncRaw = window.prompt('是否开启周报同步(on/off)', currentSync)
|
||||
if (nextSyncRaw === null) {
|
||||
if (st) st.textContent = '已取消设置'
|
||||
setTimeout(() => { if (st) st.style.display = 'none' }, 1200)
|
||||
return
|
||||
}
|
||||
|
||||
const syncText = String(nextSyncRaw || '').trim().toLowerCase()
|
||||
const weekly_sync_enabled = !(syncText === 'off' || syncText === '0' || syncText === 'false' || syncText === 'no')
|
||||
const payload = {
|
||||
weekly_api_base: String(nextBaseRaw || '').trim(),
|
||||
weekly_key: String(nextKeyRaw || '').trim(),
|
||||
weekly_sync_enabled
|
||||
}
|
||||
const wr = await fetch('/api/ccb_private_funds/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
const wd = await wr.json()
|
||||
if (!wd || !wd.ok) throw new Error((wd && wd.error) || 'save_config_failed')
|
||||
if (st) st.textContent = '设置已保存'
|
||||
logLocal('ccb_private_funds.config.saved')
|
||||
} catch (e) {
|
||||
if (st) st.textContent = `设置失败:${String(e.message || e)}`
|
||||
} finally {
|
||||
setTimeout(() => { if (st) st.style.display = 'none' }, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
let pageInited = false
|
||||
const initPage = () => {
|
||||
if (pageInited) return
|
||||
pageInited = true
|
||||
loadFlags()
|
||||
const y = new Date().getFullYear()
|
||||
const d = new Date(y, 11, 31)
|
||||
const endOfYear = window.cnDateStr ? cnDateStr(d) : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
const ds = getEl('date-start'); if (ds) ds.value = '2024-01-01'
|
||||
const de = getEl('date-end'); if (de) de.value = endOfYear
|
||||
const nd = getEl('new-date'); if (nd) nd.value = window.cnDateStr ? cnDateStr(new Date()) : nd.value
|
||||
const bl = getEl('btn-load'); if (bl) bl.addEventListener('click', loadFlows)
|
||||
const ba = getEl('btn-add'); if (ba) ba.addEventListener('click', saveFlow)
|
||||
const bc = getEl('btn-cancel-edit'); if (bc) bc.addEventListener('click', exitEditMode)
|
||||
const be = getEl('btn-export'); if (be) be.addEventListener('click', async () => { const start = getEl('date-start').value || ''; const end = getEl('date-end').value || ''; location.href = `/api/ccb_private_funds/export.csv?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}` })
|
||||
const bs = getEl('btn-settings'); if (bs) bs.addEventListener('click', openSettings)
|
||||
const bcp = getEl('fd-btn-copy'); if (bcp) bcp.addEventListener('click', async () => { try { await navigator.clipboard.writeText((logBox() && logBox().textContent) || '') } catch {} })
|
||||
const brf = getEl('fd-btn-refresh'); if (brf) brf.addEventListener('click', async () => { try { const r = await fetch('/api/debug/logs?limit=5000&channel=ccb_private_funds'); const d2 = await r.json(); if (d2.ok) { const b = logBox(); if (b) { b.textContent = (d2.lines || []).join('\n'); b.scrollTop = b.scrollHeight } } } catch {} })
|
||||
const bcl = getEl('fd-btn-clear'); if (bcl) bcl.addEventListener('click', async () => { try { await fetch('/api/debug/clear?channel=ccb_private_funds', { method: 'POST' }) } catch {}; const b = logBox(); if (b) b.textContent = '' })
|
||||
loadFlows()
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initPage)
|
||||
else initPage()
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>建行私募理财</title>
|
||||
<link rel="icon" href="/icons/ccb_private_funds.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/ccb_private_funds/funds.css?v=1" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<div class="panel-header">
|
||||
<h1>CCB Private Funds</h1>
|
||||
<p class="panel-subtitle">私募资金流水管理</p>
|
||||
</div>
|
||||
<section class="toolbar">
|
||||
<input id="date-start" type="date" />
|
||||
<input id="date-end" type="date" />
|
||||
<select id="type-filter">
|
||||
<option value="all">全部</option>
|
||||
<option value="in">汇入</option>
|
||||
<option value="out">汇出</option>
|
||||
</select>
|
||||
<input id="kw" type="text" placeholder="银行/备注检索" />
|
||||
<button id="btn-load" class="btn">查询</button>
|
||||
<button id="btn-export" class="btn">导出CSV</button>
|
||||
<button id="btn-settings" class="btn">设置</button>
|
||||
</section>
|
||||
<div id="funds-status" class="status" style="display:none"></div>
|
||||
|
||||
<section class="add-bar">
|
||||
<input id="new-date" type="date" required />
|
||||
<select id="new-type" required>
|
||||
<option value="in">汇入</option>
|
||||
<option value="out">汇出</option>
|
||||
</select>
|
||||
<input id="new-amount" type="text" placeholder="金额(人民币),如 20,000.00 或 -5000" required />
|
||||
<input id="new-bank" type="text" placeholder="银行方/状态" required />
|
||||
<input id="new-remark" type="text" placeholder="备注" required />
|
||||
<input id="new-attachments" type="file" multiple />
|
||||
<button id="btn-add" class="btn">新增</button>
|
||||
<button id="btn-cancel-edit" class="btn btn-light" style="display:none">取消修改</button>
|
||||
</section>
|
||||
<section class="attachments-editor" id="attachments-editor" style="display:none">
|
||||
<div class="attachments-title">当前流水附件</div>
|
||||
<div id="existing-attachments" class="attachments-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="stats" id="stats"></section>
|
||||
|
||||
<section class="table-wrap">
|
||||
<table class="flows" id="flows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>类型</th>
|
||||
<th>金额(人民币)</th>
|
||||
<th>银行方/状态</th>
|
||||
<th>期末金额</th>
|
||||
<th>备注</th>
|
||||
<th>附件</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="flows-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="debug" id="funds-debug" style="display:none">
|
||||
<div class="debug-actions">
|
||||
<button id="fd-btn-copy" class="btn">复制调试信息</button>
|
||||
<button id="fd-btn-refresh" class="btn">刷新</button>
|
||||
<button id="fd-btn-clear" class="btn">清空</button>
|
||||
</div>
|
||||
<div id="fd-output" class="debug-output"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/common/common.js"></script>
|
||||
<script src="/tools/ccb_private_funds/funds.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,813 @@
|
||||
.container { padding: 12px }
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 8px }
|
||||
.tab { padding: 6px 10px; border: none; background: #f5f5f5; border-radius: 6px; cursor: pointer }
|
||||
.tab.active { background: #222; color: #fff }
|
||||
.status { margin: 8px 0; padding: 8px; border: 1px solid #ffd666; background: #fffbe6; color: #a87000; border-radius: 6px }
|
||||
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 8px; margin-bottom: 8px }
|
||||
.bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 8px }
|
||||
.btn { padding: 6px 10px; border: none; background: #222; color: #fff; border-radius: 6px; cursor: pointer }
|
||||
.grid { width: 100%; border-collapse: collapse }
|
||||
.grid th, .grid td { padding: 8px; border-bottom: 1px solid #e6e9ef; text-align: left }
|
||||
.grid th, .grid td { vertical-align: top }
|
||||
.grid thead th { position: sticky; top: 0; background: var(--cloud-card-bg); z-index: 1 }
|
||||
.grid tbody tr:nth-child(odd) { background: #f7f8fb }
|
||||
.grid tbody tr:nth-child(even) { background: #f2f4f8 }
|
||||
.grid tbody tr:hover { background: #e9eef6 }
|
||||
.grid tbody tr { transition: background 120ms ease-in-out }
|
||||
.grid td.spec-text, .grid td.ports-text, .grid td.remark-text { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word }
|
||||
.grid td.vas-config, .grid td.vas-access, .grid td.rn-text { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word }
|
||||
.grid td.vas-config.small, .grid td.vas-access.small, .grid td.rn-text.small { font-size: 10px; line-height: 1.2 }
|
||||
.grid a { color: var(--cloud-accent); text-decoration: none }
|
||||
.grid a:hover { text-decoration: underline }
|
||||
.debug { margin: 12px 0; padding: 8px; border: 1px dashed #aaa; }
|
||||
.debug-actions { display: flex; gap: 8px; margin-bottom: 8px }
|
||||
.debug-output { height: 200px; overflow: auto; background: #f6f6f6; padding: 8px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; white-space: pre-wrap }
|
||||
/* 服务器配置文本使用小号字体 */
|
||||
.scaled-wrap { display: inline-block }
|
||||
#ecs-spec.scaled-text { transform: none; width: auto; height: auto; font-size: 10px; line-height: 1.2 }
|
||||
.small { font-size: 10px; line-height: 1.2 }
|
||||
.grid td.spec-text, .grid td.ports-text, .grid td.remark-text { white-space: pre-wrap }
|
||||
.grid td.expandable { position: relative; padding-bottom: 24px }
|
||||
.grid td.expandable .expandable-content { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden }
|
||||
.grid td.expandable.expanded .expandable-content { display: block; -webkit-line-clamp: unset; overflow: visible }
|
||||
.grid td.expandable .expandable-toggle { position: absolute; right: 6px; bottom: 4px; font-size: 12px; color: var(--cloud-accent); cursor: pointer; user-select: none; background: rgba(255,255,255,0.8); padding: 0 4px; border-radius: 4px }
|
||||
body.theme-cloud[data-theme="dark"] .grid td.expandable .expandable-toggle { background: rgba(15,22,40,0.8) }
|
||||
:root { --cloud-bg:#f7f8fb; --cloud-card-bg:#ffffff; --cloud-border:#e6e9ef; --cloud-primary:#1f3a5f; --cloud-accent:#2f6b99; --cloud-hover:#e9eef6; --cloud-text:#0f1a2b }
|
||||
.container { color: var(--cloud-text) }
|
||||
h2 { margin: 12px 0 8px }
|
||||
.tabs { margin-bottom: 8px }
|
||||
.tabs .tab { background: #fff; color: var(--cloud-text); border: 1px solid var(--cloud-border); border-radius: 8px; padding: 6px 12px; margin-right: 6px }
|
||||
.tabs .tab.active { background: var(--cloud-primary); color: #fff; border-color: var(--cloud-primary) }
|
||||
.panel.card { background: var(--cloud-card-bg); border: 1px solid var(--cloud-border); border-radius: 10px; box-shadow: 0 2px 8px rgba(15,26,43,0.06); padding: 10px; margin-bottom: 12px }
|
||||
.bar { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; align-items: center; margin-bottom: 10px }
|
||||
.bar input, .bar select, .bar textarea { border: 1px solid var(--cloud-border); border-radius: 8px; padding: 6px 10px; font-size: 13px }
|
||||
.btn { background: var(--cloud-accent); color: #fff; border: none; border-radius: 8px; padding: 6px 12px; cursor: pointer }
|
||||
.btn:hover { background: #1f5a88 }
|
||||
.status { background: #fff8e1; border: 1px solid #ffe08a; color: #7a5900; border-radius: 8px; padding: 6px 10px; margin: 8px 0 }
|
||||
|
||||
/* Note specific styles */
|
||||
.small-text-area { font-size: 10px !important; line-height: 1.2; font-family: inherit; }
|
||||
.note-form-row { grid-column: 1 / -1; display: flex; gap: 8px; align-items: center; }
|
||||
.note-form-row.full-width textarea { width: 100%; }
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
animation: modalFadeIn 0.3s ease-out;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.modal-overlay { display: none; }
|
||||
.modal-overlay.active { display: flex; }
|
||||
|
||||
/* 面板定位与字体按钮 */
|
||||
body.theme-cloud .panel.card { position: relative; }
|
||||
.font-size-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--cloud-border);
|
||||
background: var(--cloud-card-bg);
|
||||
color: var(--cloud-primary);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
.font-size-toggle:hover { background: var(--cloud-hover) }
|
||||
|
||||
/* 列表字体统一 */
|
||||
.grid.small { font-size: 10px; line-height: 1.2 }
|
||||
.grid.large { font-size: 14px; line-height: 1.5 }
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.modal {
|
||||
width: 800px;
|
||||
height: auto;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #e6e9ef;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #e6e9ef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px 12px 0 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f3a5f;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-form-row.full-width {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.modal-input, .modal-select, .modal-textarea {
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-input:focus, .modal-select:focus, .modal-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #2f6b99;
|
||||
box-shadow: 0 0 0 3px rgba(47, 107, 153, 0.1);
|
||||
}
|
||||
|
||||
.modal-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.modal-textarea.large {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e6e9ef;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0 0 12px 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-btn.primary {
|
||||
background: #2f6b99;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-btn.primary:hover {
|
||||
background: #1f5a88;
|
||||
}
|
||||
|
||||
.modal-btn.secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-btn.secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.modal-btn.danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-btn.danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
/* 操作按钮样式 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #2f6b99;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #2f6b99;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #2f6b99;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.view {
|
||||
border-color: #28a745;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.action-btn.view:hover {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 列表操作列内按钮圆形化 */
|
||||
.grid td.col-actions .action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.grid td.col-actions .action-btn + .action-btn {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* 字体大小切换按钮 */
|
||||
.font-size-toggle {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: white;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 20px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.font-size-toggle:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #2f6b99;
|
||||
color: #2f6b99;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.modal {
|
||||
width: 95vw;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.modal-form-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
justify-content: center;
|
||||
}
|
||||
.grid td.col-actions .action-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.font-size-toggle {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超细字体模式 */
|
||||
.font-ultra-thin .grid,
|
||||
.font-ultra-thin .grid th,
|
||||
.font-ultra-thin .grid td {
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.font-ultra-thin .action-btn {
|
||||
font-size: 11px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* 恢复上传模态框样式 */
|
||||
.resume-upload-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10000;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
animation: modalFadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.resume-upload-modal h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1f3a5f;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resume-upload-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.resume-upload-item {
|
||||
padding: 16px;
|
||||
border: 1px solid #e6e9ef;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.upload-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: #1f3a5f;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
font-size: 14px;
|
||||
color: #2f6b99;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e6e9ef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #28a745;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-error {
|
||||
color: #dc3545;
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-actions .action-btn {
|
||||
flex: 1;
|
||||
min-width: auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-actions .action-btn.resume {
|
||||
border-color: #28a745;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.upload-actions .action-btn.resume:hover {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.upload-actions .action-btn.cancel {
|
||||
border-color: #6c757d;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.upload-actions .action-btn.cancel:hover {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c5d0e0;
|
||||
background: #eef2f7;
|
||||
color: #44546a;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: #e8f7ee;
|
||||
border-color: #98d5ab;
|
||||
color: #1b7f3a;
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
background: #fff7e6;
|
||||
border-color: #f4c46a;
|
||||
color: #a15c07;
|
||||
}
|
||||
|
||||
.badge.danger {
|
||||
background: #fff0f0;
|
||||
border-color: #f0a5a5;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.badge.muted {
|
||||
background: #eef2f7;
|
||||
border-color: #c5d0e0;
|
||||
color: #52606d;
|
||||
}
|
||||
|
||||
.grid td.balance-sender-cell {
|
||||
max-width: 126px;
|
||||
width: 126px;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.grid td.balance-body-text,
|
||||
.grid td.balance-subject {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.grid td.balance-sync-cell {
|
||||
min-width: 88px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.balance-search {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.balance-tip-box {
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d8e2f0;
|
||||
border-left: 4px solid #2b6cb0;
|
||||
border-radius: 10px;
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.balance-tip-title {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1f4f82;
|
||||
}
|
||||
|
||||
.balance-tip-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #44546a;
|
||||
}
|
||||
|
||||
.balance-status-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
border: 1px solid var(--cloud-border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: var(--cloud-card-bg);
|
||||
box-shadow: 0 2px 8px rgba(15, 26, 43, 0.05);
|
||||
}
|
||||
|
||||
.balance-card-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.balance-card-label {
|
||||
font-size: 12px;
|
||||
color: #6b778c;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.balance-card-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--cloud-primary);
|
||||
}
|
||||
|
||||
.balance-card-value.success {
|
||||
color: #1b7f3a;
|
||||
}
|
||||
|
||||
.balance-card-value.danger {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.balance-card-note {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #6b778c;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.balance-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.balance-page-text {
|
||||
font-size: 13px;
|
||||
color: #52606d;
|
||||
}
|
||||
|
||||
.balance-loading {
|
||||
padding: 16px 0;
|
||||
color: #52606d;
|
||||
}
|
||||
|
||||
.balance-detail {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.balance-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.balance-detail-row span {
|
||||
color: #6b778c;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.balance-detail-row strong {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.balance-detail-title {
|
||||
margin-top: 8px;
|
||||
font-weight: 700;
|
||||
color: var(--cloud-primary);
|
||||
}
|
||||
|
||||
.balance-detail-pre {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f5f7fb;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.balance-trace-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.balance-trace-item {
|
||||
border: 1px solid var(--cloud-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: var(--cloud-card-bg);
|
||||
}
|
||||
|
||||
.balance-trace-item.success {
|
||||
border-color: #98d5ab;
|
||||
background: #f3fbf6;
|
||||
}
|
||||
|
||||
.balance-trace-item.error {
|
||||
border-color: #f0a5a5;
|
||||
background: #fff4f4;
|
||||
}
|
||||
|
||||
.balance-trace-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.balance-trace-head strong {
|
||||
color: var(--cloud-primary);
|
||||
}
|
||||
|
||||
.balance-trace-head span {
|
||||
font-size: 12px;
|
||||
color: #6b778c;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.balance-card-wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.balance-detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.balance-detail-row span {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端恢复上传适配 */
|
||||
@media (max-width: 768px) {
|
||||
.resume-upload-modal {
|
||||
width: 95vw;
|
||||
max-width: none;
|
||||
margin: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.resume-upload-modal h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.upload-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upload-actions .action-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.resume-upload-modal {
|
||||
margin: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.resume-upload-item {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.upload-actions .action-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>云产品管理</title>
|
||||
<link rel="icon" href="/icons/cloud.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/cloud/cloud.css" />
|
||||
</head>
|
||||
<body class="theme-cloud">
|
||||
<main class="container">
|
||||
<div class="cloud-header"><h2><span class="logo-ico" aria-hidden="true"></span>云产品管理</h2><div class="cloud-tabs"><div class="tabs horizontal"><button id="tab-ops" class="tab active"><svg width="16" height="16" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" fill="currentColor"/><path d="M4 22c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>运营商</button><button id="tab-ecs" class="tab"><svg width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="9" y="19" width="6" height="1.5" fill="currentColor"/></svg>ECS服务器</button><button id="tab-vas" class="tab"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2"/></svg>增值服务</button><button id="tab-renew" class="tab"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M6 9a6 6 0 0 1 10 0l2-2v6h-6l2-2a4 4 0 1 0-6 0" fill="none" stroke="currentColor" stroke-width="2"/></svg>续费情况</button><button id="tab-notes" class="tab"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="2"/><polyline points="14 2 14 8 20 8" fill="none" stroke="currentColor" stroke-width="2"/><line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2"/><line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2"/><polyline points="10 9 9 9 8 9" fill="none" stroke="currentColor" stroke-width="2"/></svg>学习笔记</button><button id="tab-balance" class="tab"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 2l8 4v6c0 5-3.4 8.9-8 10-4.6-1.1-8-5-8-10V6l8-4z" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 12h8M12 8v8" stroke="currentColor" stroke-width="2"/></svg>欠费通知</button></div></div><div class="header-actions"><button id="cloud-toggle-theme" class="icon-theme" title="深色/浅色切换" aria-label="深色/浅色切换">🌙</button><button id="cloud-toggle-nav" class="icon-toggle" title="显示/隐藏导航" aria-label="显示/隐藏导航"></button></div></div>
|
||||
<div class="cloud-layout">
|
||||
|
||||
<div id="cloud-content">
|
||||
<div id="status" class="status" style="display:none"></div>
|
||||
<button id="font-size-toggle" class="font-size-toggle" title="切换字体大小">字体大小</button>
|
||||
|
||||
<section id="panel-ops" class="panel card">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" onclick="openModal('ops', 'add')">
|
||||
<span>➕</span>新增运营商
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-card"><table class="grid"><thead><tr><th>名称</th><th>账号</th><th>登录密码</th><th>手机</th><th>关联人</th><th>备注</th><th class="col-cert">认证</th><th class="col-actions">操作</th></tr></thead><tbody id="ops-body"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-ecs" class="panel card" style="display:none">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" onclick="openModal('ecs', 'add')">
|
||||
<span>➕</span>新增服务器
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-card"><table class="grid"><thead><tr><th>ID</th><th>运营商</th><th>服务器名称</th><th>所在地区</th><th>所在区域</th><th>远程桌面IP</th><th>远程账号</th><th>远程账号密码</th><th>服务器配置</th><th>开放端口范围</th><th class="col-actions">操作</th></tr></thead><tbody id="ecs-body"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-vas" class="panel card" style="display:none">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" onclick="openModal('vas', 'add')">
|
||||
<span>➕</span>新增增值服务
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-card"><table class="grid"><thead><tr><th>ID</th><th>运营商</th><th>名称</th><th>增值服务配置</th><th>Access信息</th><th class="col-actions">操作</th></tr></thead><tbody id="vas-body"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-renew" class="panel card" style="display:none">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" onclick="openModal('renew', 'add')">
|
||||
<span>➕</span>新增续费记录
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-card"><table class="grid"><thead><tr><th>缴费主体</th><th>主体名称</th><th>主体ID</th><th>缴费日期</th><th>缴费金额</th><th>发票</th><th>公司缴费人</th><th>到期日期</th><th>下期预计费用</th><th class="col-actions">操作</th></tr></thead><tbody id="rn-body"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-notes" class="panel card" style="display:none">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" onclick="openModal('notes', 'add')">
|
||||
<span>➕</span>新增学习笔记
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-card"><table class="grid"><thead><tr><th>关联类型</th><th>关联主体</th><th>问题描述</th><th>解决方案</th><th>附件</th><th class="col-actions">操作</th></tr></thead><tbody id="nt-body"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-balance" class="panel card" style="display:none">
|
||||
<div class="action-buttons">
|
||||
<button id="balance-btn-scan" class="action-btn">
|
||||
<span>📬</span>立即补扫
|
||||
</button>
|
||||
<button id="balance-btn-refresh" class="action-btn view">
|
||||
<span>🔄</span>刷新列表
|
||||
</button>
|
||||
<input id="balance-keyword" class="modal-input balance-search" placeholder="搜索标题或正文关键词" />
|
||||
</div>
|
||||
<div class="balance-tip-box">
|
||||
<div class="balance-tip-title">扩展提示</div>
|
||||
<div class="balance-tip-text">当前已接入阿里云欠费邮件告警。未来如需增加亿速云等其他云厂商告警邮件,按插件式扩展即可:每个来源独立维护匹配规则、解析逻辑、推送策略与配置,不影响现有阿里云链路。</div>
|
||||
</div>
|
||||
<div id="balance-status-cards" class="balance-status-cards"></div>
|
||||
<div class="list-card">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>接收时间(北京)</th>
|
||||
<th>风险级别</th>
|
||||
<th>邮件标题</th>
|
||||
<th>邮件正文</th>
|
||||
<th>发件人</th>
|
||||
<th>日历同步</th>
|
||||
<th>周报预留</th>
|
||||
<th class="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="balance-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="balance-pagination" class="balance-pagination"></div>
|
||||
</section>
|
||||
|
||||
<!-- 模态框容器 -->
|
||||
<div id="modal-overlay" class="modal-overlay">
|
||||
<div id="modal" class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title" class="modal-title">模态框标题</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div id="modal-body" class="modal-body">
|
||||
<!-- 模态框内容将通过JavaScript动态生成 -->
|
||||
</div>
|
||||
<div id="modal-footer" class="modal-footer">
|
||||
<!-- 模态框按钮将通过JavaScript动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="debug" id="cloud-debug" style="display:none">
|
||||
<div class="debug-actions">
|
||||
<button id="cd-btn-copy" class="btn">复制调试信息</button>
|
||||
<button id="cd-btn-refresh" class="btn">刷新</button>
|
||||
<button id="cd-btn-clear" class="btn">清空</button>
|
||||
</div>
|
||||
<div id="cd-output" class="debug-output"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="debug" id="cloud-debug" style="display:none">
|
||||
<div class="debug-actions">
|
||||
<button id="cd-btn-copy" class="btn">复制调试信息</button>
|
||||
<button id="cd-btn-refresh" class="btn">刷新</button>
|
||||
<button id="cd-btn-clear" class="btn">清空</button>
|
||||
</div>
|
||||
<div id="cd-output" class="debug-output"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/common/common.js"></script>
|
||||
<link rel="stylesheet" href="/tools/cloud/theme/tokens.css" />
|
||||
<link rel="stylesheet" href="/tools/cloud/theme/components.css" />
|
||||
<script src="/tools/cloud/cloud.js"></script>
|
||||
<script src="/tools/cloud/theme/script.js"></script>
|
||||
<script>
|
||||
try { fetch('/api/debug/event', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ source:'cloud', action:'inline_boot' }) }) } catch {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,201 @@
|
||||
body.theme-cloud { background: var(--color-bg) }
|
||||
body.theme-cloud .container { color: var(--color-text) }
|
||||
body.theme-cloud h2 { color: var(--color-text) }
|
||||
body.theme-cloud .tabs { margin-bottom: var(--gap) }
|
||||
body.theme-cloud .tabs .tab { background: var(--color-card); color: var(--color-text); border: 1px solid var(--color-border); border-radius: var(--radius); padding: 6px 12px }
|
||||
body.theme-cloud .tabs .tab.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary) }
|
||||
body.theme-cloud .panel.card { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius); box-shadow: var(--shadow); padding: var(--gap) }
|
||||
body.theme-cloud .cloud-layout { display: grid; grid-template-columns: 1fr; gap: var(--gap) }
|
||||
body.theme-cloud .tabs.vertical { display: flex; flex-direction: column; gap: 8px }
|
||||
body.theme-cloud #cloud-side { height: fit-content }
|
||||
body.theme-cloud .tabs.vertical .tab { text-align: left }
|
||||
/* top tabs */
|
||||
body.theme-cloud .cloud-tabs { flex: 1; margin: 0 12px }
|
||||
body.theme-cloud .cloud-tabs .tabs.horizontal { display:flex; gap:8px }
|
||||
body.theme-cloud .cloud-tabs .tabs.horizontal .tab svg { margin-right:8px }
|
||||
/* ==================== 顶部菜单胶囊 · 玻璃拟态 ==================== */
|
||||
body.theme-cloud .cloud-tabs .tabs { display:flex; gap:10px; padding:0 }
|
||||
body.theme-cloud .cloud-tabs .tab { display:flex; align-items:center; gap:8px; padding:10px 20px; border-radius:999px; background: rgba(17,24,39,0.8); border:1px solid rgba(31,41,55,0.5); color:#cbd5e1; font-size:14px; font-weight:500; cursor:pointer; user-select:none; transition: all .3s cubic-bezier(0.4,0,0.2,1); position:relative; overflow:hidden; backdrop-filter: blur(8px); box-shadow: 0 2px 6px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.05) }
|
||||
body.theme-cloud .cloud-tabs .tab::before { content:""; position:absolute; inset:0; background: linear-gradient(135deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.05) 100%); opacity:0; transition: opacity .3s ease }
|
||||
body.theme-cloud .cloud-tabs .tab:hover { background: rgba(26,35,52,0.6); border-color: rgba(51,65,85,0.5); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.10) }
|
||||
body.theme-cloud .cloud-tabs .tab:hover::before { opacity:1 }
|
||||
body.theme-cloud .cloud-tabs .tab.active { background: rgba(37,99,235,0.20); border-color: rgba(59,130,246,0.5); color:#fff; box-shadow: 0 8px 24px rgba(37,99,235,0.25) }
|
||||
body.theme-cloud .cloud-tabs .tab.active::after { content:""; position:absolute; bottom:0; left:20%; right:20%; height:2px; background: rgba(59,130,246,0.5); border-radius:2px }
|
||||
|
||||
/* 暗黑主题适配 */
|
||||
body.theme-cloud[data-theme="dark"] .cloud-tabs .tab { background: rgba(17,24,39,0.8); border-color: rgba(31,41,55,0.5) }
|
||||
|
||||
/* 明亮主题适配 */
|
||||
body.theme-cloud[data-theme="light"] .cloud-tabs .tab { background: rgba(255,255,255,0.9); border-color: rgba(226,232,240,0.8); color:#0f172a }
|
||||
body.theme-cloud[data-theme="light"] .cloud-tabs .tab.active { background: rgba(37,99,235,0.15); color:#1d4ed8 }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
body.theme-cloud .cloud-tabs .tabs { flex-wrap: wrap; justify-content: center; gap:8px; padding:6px }
|
||||
body.theme-cloud .cloud-tabs .tab { padding:8px 16px; font-size:13px }
|
||||
body.theme-cloud .cloud-tabs .tab svg { width:14px; height:14px }
|
||||
}
|
||||
|
||||
/* ===== Adapted global form/table styles (scoped) ===== */
|
||||
body.theme-cloud .container { width: 98%; max-width: none; margin: 0 auto; padding: 16px }
|
||||
body.theme-cloud .section { margin-bottom: 24px }
|
||||
body.theme-cloud .header-actions { display: flex; justify-content: flex-end; gap: 12px; margin-bottom: 16px }
|
||||
body.theme-cloud .theme-toggle { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--border); background: var(--glass-bg); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: var(--transition); box-shadow: var(--shadow-sm) }
|
||||
body.theme-cloud .theme-toggle:hover { transform: rotate(15deg) scale(1.1); box-shadow: var(--shadow-md) }
|
||||
body.theme-cloud .card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--gap); box-shadow: var(--shadow-md); transition: var(--transition) }
|
||||
body.theme-cloud .card:hover { box-shadow: var(--shadow-lg) }
|
||||
body.theme-cloud .card h3 { margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: var(--text) }
|
||||
body.theme-cloud .form-bar { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px }
|
||||
body.theme-cloud .form-group { display: flex; flex-direction: column; gap: 6px }
|
||||
body.theme-cloud .form-label { font-size: 12px; font-weight: 600; color: var(--text); opacity: 0.8 }
|
||||
body.theme-cloud .input, body.theme-cloud .select, body.theme-cloud .textarea { width: 100%; padding: 10px 14px; border-radius: var(--radius); border: 1px solid var(--control-border); background: var(--control-bg); color: var(--control-text); font-size: 14px; transition: var(--transition) }
|
||||
body.theme-cloud .input::placeholder, body.theme-cloud .textarea::placeholder { color: var(--control-placeholder) }
|
||||
body.theme-cloud .input:focus, body.theme-cloud .select:focus, body.theme-cloud .textarea:focus { outline: none; border-color: var(--primary); box-shadow: var(--glow) }
|
||||
body.theme-cloud .textarea { min-height: 80px; resize: vertical }
|
||||
body.theme-cloud .btn { padding: 10px 16px; border-radius: var(--radius); border: none; background: var(--primary); color: white; font-size: 14px; font-weight: 500; cursor: pointer; transition: var(--transition); display: inline-flex; align-items: center; justify-content: center; gap: 6px }
|
||||
body.theme-cloud .btn:hover { background: var(--primary-hover); transform: translateY(-2px); box-shadow: var(--shadow-md) }
|
||||
body.theme-cloud .btn:active { transform: translateY(0) }
|
||||
body.theme-cloud .btn-secondary { background: var(--control-bg); color: var(--text); border: 1px solid var(--border) }
|
||||
body.theme-cloud .btn-secondary:hover { background: var(--table-row-hover) }
|
||||
body.theme-cloud .table-card { background: var(--table-bg); border: 1px solid var(--table-border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm) }
|
||||
body.theme-cloud .table { width: 100%; border-collapse: collapse; background: var(--table-bg) }
|
||||
body.theme-cloud .table thead { background: var(--table-header-bg); position: sticky; top: 0 }
|
||||
body.theme-cloud .table th { padding: 10px 12px; text-align: left; font-weight: 600; font-size: 13px; color: var(--text); border-bottom: 1px solid var(--table-border) }
|
||||
body.theme-cloud .table td { padding: 10px 12px; border-bottom: 1px solid var(--table-border); color: var(--text); font-size: 13px; transition: var(--transition) }
|
||||
body.theme-cloud .table tbody tr:nth-child(odd) { background: var(--table-row-odd) }
|
||||
body.theme-cloud .table tbody tr:nth-child(even) { background: var(--table-row-even) }
|
||||
body.theme-cloud .table tbody tr:hover { background: var(--table-row-hover); color: var(--table-text-hover) }
|
||||
body.theme-cloud .table tbody tr:last-child td { border-bottom: none }
|
||||
body.theme-cloud .badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: var(--control-bg); color: var(--text); border: 1px solid var(--border) }
|
||||
body.theme-cloud .badge.success { background: rgba(16, 185, 129, 0.15); color: #10b981; border-color: rgba(16, 185, 129, 0.3) }
|
||||
body.theme-cloud[data-theme="light"] .badge.success { background: rgba(16, 185, 129, 0.1) }
|
||||
body.theme-cloud .icon-btn { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border); background: var(--control-bg); color: var(--text); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: var(--transition) }
|
||||
body.theme-cloud .icon-btn:hover { background: var(--table-row-hover); transform: translateY(-2px); box-shadow: var(--shadow-sm) }
|
||||
body.theme-cloud .icon-btn.edit:hover { color: var(--primary); border-color: var(--primary) }
|
||||
body.theme-cloud .icon-btn.delete:hover { color: #ef4444; border-color: #ef4444 }
|
||||
@media (max-width: 1024px) { body.theme-cloud .form-bar { grid-template-columns: repeat(2, 1fr) } }
|
||||
@media (max-width: 768px) { body.theme-cloud .form-bar { grid-template-columns: 1fr } body.theme-cloud .container { padding: 12px } body.theme-cloud .table th, body.theme-cloud .table td { padding: 10px 12px; font-size: 13px } }
|
||||
body.theme-cloud .icon-btn { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: #0f1628; border: 1px solid var(--color-border); color: var(--color-text); margin-right: 6px }
|
||||
body.theme-cloud .icon-btn:hover { background: #152036 }
|
||||
body.theme-cloud .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; background: #374151; color: var(--color-text); border: 1px solid #4b5563 }
|
||||
body.theme-cloud .badge.success { background: #064e3b; border-color: #10b981; color: #d1fae5 }
|
||||
body.theme-cloud .bar { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--gap); align-items: center }
|
||||
body.theme-cloud .bar input, body.theme-cloud .bar select, body.theme-cloud .bar textarea { background: #0f1628; color: var(--color-text); border: 1px solid var(--color-border); border-radius: var(--radius); padding: 8px 10px }
|
||||
body.theme-cloud .btn { background: var(--color-primary); color: #fff; border: none; border-radius: var(--radius); padding: 8px 12px; cursor: pointer; position: relative; overflow: hidden }
|
||||
body.theme-cloud .btn:hover { background: var(--color-primary-2) }
|
||||
body.theme-cloud .status { background: #1f2937; border: 1px solid #374151; color: var(--color-text); border-radius: var(--radius) }
|
||||
body.theme-cloud .grid { width: 100%; border-collapse: collapse }
|
||||
body.theme-cloud .grid thead th { background: var(--color-card); color: var(--color-text) }
|
||||
body.theme-cloud .grid th, body.theme-cloud .grid td { padding: 8px; border-bottom: 1px solid var(--color-border); text-align: left; vertical-align: top }
|
||||
body.theme-cloud .grid tbody tr:nth-child(odd) { background: #0d1526 }
|
||||
body.theme-cloud .grid tbody tr:nth-child(even) { background: #0b1322 }
|
||||
body.theme-cloud .grid tbody tr:hover { background: #0f1a2b }
|
||||
body.theme-cloud .grid a { color: #93c5fd }
|
||||
body.theme-cloud .fade-in { animation: cloudFade 160ms ease-out }
|
||||
@keyframes cloudFade { from { opacity: 0; transform: translateY(4px) } to { opacity: 1; transform: translateY(0) } }
|
||||
body.theme-cloud .ripple { position: absolute; border-radius: 999px; transform: scale(0); animation: ripple 400ms ease-out; pointer-events: none; background: rgba(255,255,255,0.25) }
|
||||
@keyframes ripple { to { transform: scale(6); opacity: 0 } }
|
||||
body.theme-cloud .grid th.col-cert,
|
||||
body.theme-cloud .grid td.col-cert,
|
||||
body.theme-cloud .grid th.col-actions,
|
||||
body.theme-cloud .grid td.col-actions { width: 3em; min-width: 3em; white-space: nowrap; text-align: center }
|
||||
/* 操作列按钮主题化 */
|
||||
body.theme-cloud .grid td.col-actions .action-btn {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
transition: var(--transition);
|
||||
}
|
||||
body.theme-cloud .grid td.col-actions .action-btn:hover {
|
||||
background: var(--table-row-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
body.theme-cloud .grid td.col-actions .action-btn.edit:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
body.theme-cloud .grid td.col-actions .action-btn.view {
|
||||
color: #10b981;
|
||||
border-color: #10b981;
|
||||
}
|
||||
body.theme-cloud .grid td.col-actions .action-btn.view:hover {
|
||||
background: #10b981;
|
||||
color: #ffffff;
|
||||
}
|
||||
body.theme-cloud .grid td.col-actions .action-btn.delete {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
body.theme-cloud .grid td.col-actions .action-btn.delete:hover {
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
/* 导航显示控制 */
|
||||
body.theme-cloud:not(.nav-visible) #topbar { display: none }
|
||||
body.theme-cloud .cloud-nav-toggle { position: sticky; top: 0; z-index: 5; display: flex; justify-content: flex-end; margin-bottom: var(--gap) }
|
||||
body.theme-cloud .cloud-nav-toggle .btn { background: #1e3a8a }
|
||||
body.theme-cloud .cloud-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--gap) }
|
||||
body.theme-cloud .cloud-header h2 { margin: 0; font-size: 18px; line-height: 1.1 }
|
||||
body.theme-cloud .cloud-header .header-topbar { flex: 1; margin: 0 12px }
|
||||
body.theme-cloud .cloud-header .header-topbar .tabs { margin: 0 }
|
||||
/* center top tabs */
|
||||
body.theme-cloud .cloud-tabs { flex:1; margin:0 12px }
|
||||
body.theme-cloud .cloud-tabs .tabs.horizontal { display:flex; gap:8px; justify-content:center; align-items:center }
|
||||
body.theme-cloud .cloud-tabs .tabs.horizontal .tab { display:flex; align-items:center }
|
||||
body.theme-cloud .cloud-tabs .tabs.horizontal .tab svg { margin-right:8px }
|
||||
body.theme-cloud .cloud-header .icon-toggle { width: 32px; height: 32px; border-radius: 50%; border: 1px solid #334155; background: radial-gradient(ellipse at 30% 30%, #1f2937 0%, #0f172a 70%); box-shadow: 0 2px 8px rgba(0,0,0,0.35); cursor: pointer }
|
||||
body.theme-cloud .cloud-header .icon-toggle:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.5) }
|
||||
body.theme-cloud.nav-visible .cloud-header .icon-toggle { border-color: #60a5fa; background: radial-gradient(ellipse at 30% 30%, #1e3a8a 0%, #0f172a 70%) }
|
||||
body.theme-cloud .list-card { background: #0f1628; border: 1px solid var(--color-border); border-radius: var(--radius); padding: 6px }
|
||||
body.theme-cloud .list-card .grid { background: transparent }
|
||||
/* theme specific tweaks */
|
||||
body.theme-cloud[data-theme="dark"] .bar input,
|
||||
body.theme-cloud[data-theme="dark"] .bar select,
|
||||
body.theme-cloud[data-theme="dark"] .bar textarea { background:#0f1628; border:1px solid var(--color-border); color:var(--color-text) }
|
||||
/* table palette overrides (dark) */
|
||||
body.theme-cloud[data-theme="dark"] .list-card { background:var(--table-bg); border:1px solid var(--table-border); border-radius:var(--table-radius) }
|
||||
body.theme-cloud[data-theme="dark"] .list-card .grid { background:var(--table-bg) }
|
||||
body.theme-cloud[data-theme="dark"] .grid thead { background:var(--table-header-bg) }
|
||||
body.theme-cloud[data-theme="dark"] .grid thead th { color:var(--text-primary); border-bottom:2px solid var(--table-border) }
|
||||
body.theme-cloud[data-theme="dark"] .grid td { border-bottom:1px solid var(--table-border) }
|
||||
body.theme-cloud[data-theme="dark"] .grid tbody tr:nth-child(odd) { background:var(--table-row-odd) }
|
||||
body.theme-cloud[data-theme="dark"] .grid tbody tr:nth-child(even) { background:var(--table-row-even) }
|
||||
body.theme-cloud[data-theme="dark"] .grid tbody tr:hover { background:var(--table-row-hover); color:var(--table-text-hover); transition:all .2s ease }
|
||||
body.theme-cloud[data-theme="dark"] .grid tbody tr:last-child td { border-bottom:none }
|
||||
body.theme-cloud[data-theme="dark"] .status { background:#1f2937; border:1px solid #374151; color:var(--color-text) }
|
||||
|
||||
body.theme-cloud[data-theme="light"] .bar input,
|
||||
body.theme-cloud[data-theme="light"] .bar select,
|
||||
body.theme-cloud[data-theme="light"] .bar textarea { background:#f8fafc; border:1px solid #e5e7eb; color:#0f172a }
|
||||
/* table palette overrides (light) */
|
||||
body.theme-cloud[data-theme="light"] .list-card { background:var(--table-bg); border:1px solid var(--table-border); border-radius:var(--table-radius) }
|
||||
body.theme-cloud[data-theme="light"] .list-card .grid { background:var(--table-bg) }
|
||||
body.theme-cloud[data-theme="light"] .grid thead { background:var(--table-header-bg) }
|
||||
body.theme-cloud[data-theme="light"] .grid thead th { color:var(--text-primary); border-bottom:2px solid var(--table-border) }
|
||||
body.theme-cloud[data-theme="light"] .grid td { border-bottom:1px solid var(--table-border) }
|
||||
body.theme-cloud[data-theme="light"] .grid tbody tr:nth-child(odd) { background:var(--table-row-odd) }
|
||||
body.theme-cloud[data-theme="light"] .grid tbody tr:nth-child(even) { background:var(--table-row-even) }
|
||||
body.theme-cloud[data-theme="light"] .grid tbody tr:hover { background:var(--table-row-hover); color:var(--table-text-hover); transition:all .2s ease }
|
||||
body.theme-cloud[data-theme="light"] .grid tbody tr:last-child td { border-bottom:none }
|
||||
body.theme-cloud[data-theme="light"] .status { background:#fffbe6; border:1px solid #ffe08a; color:#7a5900 }
|
||||
body.theme-cloud[data-theme="light"] .icon-btn { background:#eef2f7; border:1px solid #e5e7eb; color:#0f172a }
|
||||
body.theme-cloud[data-theme="light"] .icon-btn:hover { background:#e9eef6 }
|
||||
body.theme-cloud[data-theme="light"] .badge { background:#e5e7eb; border:1px solid #cbd5e1; color:#0f172a }
|
||||
body.theme-cloud[data-theme="light"] .badge.success { background:#dcfce7; border-color:#10b981; color:#065f46 }
|
||||
/* header actions & icons */
|
||||
body.theme-cloud .cloud-header .header-actions { display:flex; gap:12px; align-items:center }
|
||||
body.theme-cloud .cloud-header .icon-toggle, body.theme-cloud .cloud-header .icon-theme { width: 32px; height: 32px; border-radius: 50%; border: 1px solid #334155; background: radial-gradient(ellipse at 30% 30%, #1f2937 0%, #0f172a 70%); box-shadow: 0 2px 8px rgba(0,0,0,0.35); cursor: pointer; color:#fbbf24; display:flex; align-items:center; justify-content:center }
|
||||
/* header logo */
|
||||
body.theme-cloud .logo-ico { display:inline-block; width:20px; height:20px; margin-right:8px; background: linear-gradient(135deg,#93c5fd,#60a5fa); border-radius:6px; position:relative }
|
||||
body.theme-cloud .logo-ico::after { content:''; position:absolute; left:4px; top:3px; width:12px; height:12px; border-radius:50%; background:#fff; opacity:0.2 }
|
||||
/* sidebar tab icons */
|
||||
body.theme-cloud .tabs.vertical .tab { text-align:left; display:flex; align-items:center }
|
||||
body.theme-cloud .tabs.vertical .tab svg { margin-right:8px; flex:0 0 16px }
|
||||
/* light theme header icons */
|
||||
body.theme-cloud[data-theme="light"] .cloud-header .icon-toggle,
|
||||
body.theme-cloud[data-theme="light"] .cloud-header .icon-theme { border-color:#cbd5e1; background: radial-gradient(ellipse at 30% 30%, #ffffff 0%, #e9eef6 70%); color:#0f172a }
|
||||
body.theme-cloud .font-size-toggle { width:32px; height:32px; border-radius:50%; border:1px solid var(--color-border); background: var(--color-card); color: var(--color-text); box-shadow: var(--shadow-sm); display:inline-flex; align-items:center; justify-content:center }
|
||||
body.theme-cloud .font-size-toggle:hover { background: var(--table-row-hover) }
|
||||
body.theme-cloud[data-theme="dark"] .font-size-toggle { background:#0f1628; border-color:#334155; color:#fbbf24 }
|
||||
body.theme-cloud[data-theme="light"] .font-size-toggle { background:#eef2f7; border-color:#e5e7eb; color:#0f172a }
|
||||
@@ -0,0 +1,68 @@
|
||||
;(function(){
|
||||
const CloudTheme = {
|
||||
init(){
|
||||
this.observeLists()
|
||||
this.bindRipple()
|
||||
this.observeCards()
|
||||
this.initNavToggle()
|
||||
this.initThemeToggle()
|
||||
},
|
||||
onSectionSwitch(){},
|
||||
observeLists(){
|
||||
const ids=['ecs-body','vas-body','rn-body']
|
||||
ids.forEach(id=>{
|
||||
const el=document.getElementById(id)
|
||||
if(!el) return
|
||||
const mo=new MutationObserver(muts=>{
|
||||
muts.forEach(m=>{
|
||||
m.addedNodes.forEach(n=>{ if(n.nodeType===1) n.classList.add('fade-in') })
|
||||
})
|
||||
})
|
||||
mo.observe(el,{childList:true})
|
||||
})
|
||||
},
|
||||
bindRipple(){
|
||||
document.body.addEventListener('pointerdown',e=>{
|
||||
const t=e.target.closest('.btn')
|
||||
if(!t || !document.body.classList.contains('theme-cloud')) return
|
||||
const r=document.createElement('span')
|
||||
r.className='ripple'
|
||||
const rect=t.getBoundingClientRect()
|
||||
const size=Math.max(rect.width,rect.height)
|
||||
r.style.width=r.style.height=size+'px'
|
||||
r.style.left=(e.clientX-rect.left-size/2)+'px'
|
||||
r.style.top=(e.clientY-rect.top-size/2)+'px'
|
||||
t.appendChild(r)
|
||||
setTimeout(()=>{ try{ r.remove() }catch{} },420)
|
||||
},{passive:true})
|
||||
},
|
||||
observeCards(){
|
||||
const cards=[...document.querySelectorAll('body.theme-cloud .panel.card')]
|
||||
const io=new IntersectionObserver(entries=>{
|
||||
entries.forEach(en=>{ if(en.isIntersecting){ en.target.style.boxShadow='var(--shadow)' } else { en.target.style.boxShadow='none' } })
|
||||
})
|
||||
cards.forEach(c=>io.observe(c))
|
||||
},
|
||||
initNavToggle(){
|
||||
const key='cloud_nav_visible'
|
||||
const btn=document.getElementById('cloud-toggle-nav')
|
||||
const eye=`<svg width="16" height="16" viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>`
|
||||
const setVisible=(v)=>{ document.body.classList.toggle('nav-visible', !!v); if(btn) { btn.setAttribute('aria-pressed', v?'true':'false'); btn.title=v?'隐藏导航':'显示导航'; btn.innerHTML=eye } }
|
||||
const initVal=localStorage.getItem(key)==='1'
|
||||
setVisible(initVal)
|
||||
if(btn) btn.addEventListener('click',()=>{ const cur=document.body.classList.contains('nav-visible'); const next=!cur; localStorage.setItem(key, next?'1':'0'); setVisible(next) })
|
||||
},
|
||||
initThemeToggle(){
|
||||
const key='cloud_theme'
|
||||
const btn=document.getElementById('cloud-toggle-theme')
|
||||
const moon=`<svg width="16" height="16" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 1 0 9.79 9.79Z" fill="currentColor"/></svg>`
|
||||
const sun=`<svg width="16" height="16" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="currentColor"/><path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" stroke="currentColor" stroke-width="2"/></svg>`
|
||||
const apply=(mode)=>{ document.body.setAttribute('data-theme', mode); if(btn) btn.innerHTML=mode==='dark'?moon:sun }
|
||||
const init=localStorage.getItem(key) || 'dark'
|
||||
apply(init)
|
||||
if(btn) btn.addEventListener('click',()=>{ const cur=document.body.getAttribute('data-theme')||'dark'; const next=cur==='dark'?'light':'dark'; localStorage.setItem(key,next); apply(next) })
|
||||
}
|
||||
}
|
||||
window.CloudTheme=CloudTheme
|
||||
if(document.body && document.body.classList.contains('theme-cloud')) { CloudTheme.init() }
|
||||
})();
|
||||
@@ -0,0 +1,6 @@
|
||||
body.theme-cloud { --radius:12px; --shadow:0 6px 20px rgba(0,0,0,0.35); --gap:12px; --shadow-sm:0 2px 8px rgba(0,0,0,0.1); --shadow-md:0 4px 12px rgba(0,0,0,0.15); --shadow-lg:0 8px 24px rgba(0,0,0,0.2); --glow:0 0 0 3px rgba(59,130,246,0.2) }
|
||||
body.theme-cloud[data-theme="dark"] { --color-bg:#0b1220; --color-card:#111827; --color-border:#1f2937; --color-text:#cbd5e1; --color-muted:#64748b; --color-primary:#2563eb; --color-primary-2:#1d4ed8; --color-accent:#10b981 }
|
||||
body.theme-cloud[data-theme="light"] { --color-bg:#f3f4f6; --color-card:#ffffff; --color-border:#e5e7eb; --color-text:#0f172a; --color-muted:#475569; --color-primary:#2563eb; --color-primary-2:#1d4ed8; --color-accent:#059669 }
|
||||
/* table palette from DeepSeek */
|
||||
body.theme-cloud[data-theme="dark"] { --table-bg:#1e293b; --table-border:#334155; --table-radius:8px; --table-row-odd:#1a2536; --table-row-even:#1e293b; --table-header-bg:#0f172a; --table-row-hover:#2d3748; --table-text-hover:#ffffff; --text-primary:var(--color-text) }
|
||||
body.theme-cloud[data-theme="light"] { --table-bg:#ffffff; --table-border:#e2e8f0; --table-radius:8px; --table-row-odd:#f8fafc; --table-row-even:#ffffff; --table-header-bg:#f1f5f9; --table-row-hover:#f1f5f9; --table-text-hover:#1e293b; --text-primary:var(--color-text) }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>云笔记</title>
|
||||
<link rel="icon" href="/icons/cloud.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/cloud_notes/cloud_notes.css" />
|
||||
</head>
|
||||
<body class="theme-cloud" data-theme="dark">
|
||||
<main class="container">
|
||||
<div id="cn-header" class="cn-header">
|
||||
<h2><span class="logo-ico" aria-hidden="true"></span>云笔记</h2>
|
||||
<div class="header-actions">
|
||||
<button id="cn-search" class="icon-theme" title="搜索笔记" aria-label="搜索笔记">🔍</button>
|
||||
<button id="cn-toggle-theme" class="icon-theme" title="切换主题" aria-label="切换主题">🌙</button>
|
||||
<button id="cn-settings" class="icon-toggle" title="系统设置" aria-label="系统设置">⚙️</button>
|
||||
<button id="cn-logout-btn" class="icon-toggle" title="退出登录" aria-label="退出登录">🚪</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cn-login" class="card" style="max-width:450px; margin:24px auto; display:none">
|
||||
<h3 style="margin:0 0 16px 0; text-align:center; font-size:20px">欢迎使用云笔记</h3>
|
||||
<div class="list-card">
|
||||
<div class="modal-row">
|
||||
<label class="modal-label">👤 用户名</label>
|
||||
<input id="cn-username" class="modal-input" type="text" autocomplete="off" readonly placeholder="请输入用户名" />
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<label class="modal-label">🔒 密码</label>
|
||||
<input id="cn-password" class="modal-input" type="password" autocomplete="off" readonly placeholder="请输入密码" />
|
||||
</div>
|
||||
<div class="action-buttons" style="margin-top:16px; justify-content:center">
|
||||
<button id="cn-login-btn" class="action-btn" style="padding:10px 24px">
|
||||
<span>🔐</span>立即登录
|
||||
</button>
|
||||
<div id="cn-login-status" class="status" style="display:none; margin-left:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cn-app" style="display:none">
|
||||
<div class="cn-split">
|
||||
<aside id="cn-left" class="cn-left">
|
||||
<div class="cn-main-tabs">
|
||||
<button type="button" class="cn-main-tab active" data-tab="notes">笔记</button>
|
||||
<button type="button" class="cn-main-tab" data-tab="tags">标签</button>
|
||||
<button type="button" class="cn-main-tab" data-tab="trash">回收站</button>
|
||||
</div>
|
||||
<div id="cn-tags-filter" class="cn-tags-filter" style="display:none"></div>
|
||||
<div class="cn-left-header">
|
||||
<div class="title" id="cn-left-title"></div>
|
||||
<div class="actions">
|
||||
<button id="cn-new" class="mini-btn">新建</button>
|
||||
<button id="cn-star" class="mini-btn">加星</button>
|
||||
<button id="cn-pin" class="mini-btn">置顶</button>
|
||||
<button id="cn-delete" class="mini-btn">删除</button>
|
||||
<button id="cn-clone" class="mini-btn">克隆</button>
|
||||
<button id="cn-refresh" class="mini-btn">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="cn-list" class="cn-list"></ul>
|
||||
</aside>
|
||||
<section class="cn-right">
|
||||
<div class="cn-right-header">
|
||||
<div id="cn-right-title" class="title">驾驶舱</div>
|
||||
<div class="actions">
|
||||
<button id="cn-attach-toggle" type="button" class="mini-btn" title="展开附件" aria-label="展开附件">展开附件</button>
|
||||
<button id="cn-fullscreen" type="button" class="mini-btn" title="全屏" aria-label="全屏">全屏</button>
|
||||
<button id="cn-right-tab-dashboard" type="button" class="mini-btn">驾驶舱</button>
|
||||
<button id="cn-right-tab-editor" type="button" class="mini-btn">编辑笔记</button>
|
||||
<button id="cn-save" class="primary-btn" style="display:none">💾 保存笔记</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cn-right-inner">
|
||||
<div id="cn-dashboard" class="card cn-dashboard">
|
||||
<div class="cn-dashboard-inner">
|
||||
<div class="cn-dashboard-placeholder">正在加载驾驶舱数据…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cn-editor-pane" style="display:none">
|
||||
<div class="card">
|
||||
<input id="cn-title" class="modal-input" placeholder="输入标题" style="max-width:none" />
|
||||
</div>
|
||||
<div class="card cn-tag-bar">
|
||||
<div class="cn-tag-bar-inner">
|
||||
<div class="cn-tag-ico" title="标签">🏷️</div>
|
||||
<div id="cn-note-tag-chips" class="cn-tag-chips"></div>
|
||||
<button id="cn-note-tag-add" type="button" class="cn-tag-add" title="新建标签">➕</button>
|
||||
</div>
|
||||
<div id="cn-note-tag-picker" class="cn-tag-picker" style="display:none"></div>
|
||||
</div>
|
||||
<div class="cn-editor-wrapper">
|
||||
<textarea id="cn-editor"></textarea>
|
||||
</div>
|
||||
<div id="cn-attach-panel" class="card" style="margin-top:12px; display:none">
|
||||
<div class="action-buttons">
|
||||
<button id="cn-attach" class="action-btn"><span>📎</span>附件</button>
|
||||
<input id="cn-attach-file" type="file" style="display:none" />
|
||||
</div>
|
||||
<div id="cn-attachments" class="list-card"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div id="cn-search-layer" class="cn-search-layer" style="display:none">
|
||||
<div class="cn-search-box">
|
||||
<div class="cn-search-header">
|
||||
<div class="cn-search-scope">全部笔记</div>
|
||||
<div class="cn-search-input-wrap">
|
||||
<input id="cn-search-input" class="cn-search-input" type="text" placeholder="搜索标题或正文..." />
|
||||
</div>
|
||||
<button id="cn-search-close" class="cn-search-close" type="button" aria-label="关闭搜索">✕</button>
|
||||
</div>
|
||||
<div class="cn-search-toolbar">
|
||||
<div id="cn-search-count" class="cn-search-count"></div>
|
||||
<div class="cn-search-sort-wrap">
|
||||
<button id="cn-search-sort-btn" type="button" class="cn-search-sort-btn">默认排序</button>
|
||||
<div id="cn-search-sort-menu" class="cn-search-sort-menu" style="display:none">
|
||||
<button type="button" data-sort="default" class="active">默认排序</button>
|
||||
<button type="button" data-sort="title">笔记名称</button>
|
||||
<button type="button" data-sort="updated_at">修改时间</button>
|
||||
<button type="button" data-sort="created_at">创建时间</button>
|
||||
<button type="button" data-sort="size">笔记大小</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cn-search-results" class="cn-search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/common/common.js"></script>
|
||||
<div id="cn-settings-modal" class="modal" style="display:none">
|
||||
<div class="modal-body cn-settings-body">
|
||||
<div class="cn-settings-header">
|
||||
<div class="cn-settings-title">设置</div>
|
||||
<button id="cn-settings-close" type="button" class="icon-theme" aria-label="关闭设置">✕</button>
|
||||
</div>
|
||||
<div class="cn-settings-tabs">
|
||||
<button type="button" class="cn-settings-tab active" data-tab="idle">无响应锁定</button>
|
||||
<button type="button" class="cn-settings-tab" data-tab="tags">标签设置</button>
|
||||
<button type="button" class="cn-settings-tab" data-tab="tiny_toolbar">Tiny工具栏设置</button>
|
||||
</div>
|
||||
<div class="cn-settings-panels">
|
||||
<div class="cn-settings-panel active" data-tab="idle">
|
||||
<div class="modal-row">
|
||||
<label class="modal-label">无操作锁定时间(分钟)</label>
|
||||
<input id="cn-settings-idle-minutes" class="modal-input" type="number" min="0" max="720" placeholder="留空或 0 表示不启用" />
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-label">超过设定时间没有键盘或鼠标操作时,将自动返回登录页面。</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button id="cn-settings-idle-save" type="button" class="action-btn">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cn-settings-panel" data-tab="tags">
|
||||
<div class="modal-row">
|
||||
<div class="modal-label">标签列表(名称与颜色)</div>
|
||||
</div>
|
||||
<div id="cn-settings-tags-list" class="cn-tags-settings-list"></div>
|
||||
<div class="action-buttons">
|
||||
<button id="cn-settings-tags-add" type="button" class="action-btn">新增标签</button>
|
||||
<button id="cn-settings-tags-save" type="button" class="action-btn">保存标签</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cn-settings-panel" data-tab="tiny_toolbar">
|
||||
<div class="modal-row">
|
||||
<div class="modal-label">选择 TinyMCE 工具栏中需要显示的功能</div>
|
||||
</div>
|
||||
<div id="cn-settings-tiny-toolbar-list" class="cn-tags-settings-list"></div>
|
||||
<div class="action-buttons">
|
||||
<button id="cn-settings-tiny-toolbar-save" type="button" class="action-btn">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cn-versions-modal" class="modal" style="display:none">
|
||||
<div class="modal-body cn-versions-body">
|
||||
<div class="cn-versions-header">
|
||||
<div class="cn-versions-title">版本历史</div>
|
||||
<button id="cn-versions-close" type="button" class="icon-theme" aria-label="关闭版本">✕</button>
|
||||
</div>
|
||||
<div class="cn-versions-toolbar">
|
||||
<div id="cn-versions-summary" class="cn-versions-summary"></div>
|
||||
<div class="cn-versions-actions">
|
||||
<button id="cn-versions-compare" type="button" class="mini-btn">对比选中</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cn-versions-main">
|
||||
<div id="cn-versions-list" class="cn-versions-list"></div>
|
||||
<div class="cn-versions-compare">
|
||||
<div class="cn-compare-header">
|
||||
<div id="cn-compare-left-title" class="cn-compare-title">左侧版本</div>
|
||||
<div id="cn-compare-right-title" class="cn-compare-title">右侧版本</div>
|
||||
</div>
|
||||
<div class="cn-compare-body">
|
||||
<div id="cn-compare-left" class="cn-compare-pane"></div>
|
||||
<div id="cn-compare-right" class="cn-compare-pane"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cn-conflict-modal" class="modal" style="display:none">
|
||||
<div class="modal-body cn-conflict-body">
|
||||
<div class="cn-conflict-title">版本冲突</div>
|
||||
<div id="cn-conflict-desc" class="cn-conflict-desc"></div>
|
||||
<div class="cn-conflict-actions">
|
||||
<button id="cn-conflict-compare" type="button" class="action-btn">查看对比</button>
|
||||
<button id="cn-conflict-force" type="button" class="action-btn">覆盖保存</button>
|
||||
<button id="cn-conflict-cancel" type="button" class="action-btn">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/tools/cloud_notes/cloud_notes.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,194 @@
|
||||
tinymce.IconManager.add('default', {
|
||||
icons: {
|
||||
'accessibility-check': '<svg width="24" height="24"><path d="M12 2a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2c0-1.1.9-2 2-2Zm8 7h-5v12c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-5c0-.6-.4-1-1-1a1 1 0 0 0-1 1v5c0 .6-.4 1-1 1a1 1 0 0 1-1-1V9H4a1 1 0 1 1 0-2h16c.6 0 1 .4 1 1s-.4 1-1 1Z" fill-rule="nonzero"/></svg>',
|
||||
'accordion-toggle': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.3 7.3a1 1 0 0 1 1.4 0L16 9.6l2.3-2.3a1 1 0 1 1 1.4 1.4L16 12.4l-3.7-3.7a1 1 0 0 1 0-1.4ZM4.3 11.7a1 1 0 0 1 0-1.4L6.6 8 4.3 5.7a1 1 0 0 1 1.4-1.4L9.4 8l-3.7 3.7a1 1 0 0 1-1.4 0Z"/></svg>',
|
||||
'accordion': '<svg width="24" height="24"><rect x="12" y="7" width="10" height="2" rx="1"/><rect x="12" y="11" width="10" height="2" rx="1"/><rect x="12" y="15" width="6" height="2" rx="1"/><path fill-rule="evenodd" clip-rule="evenodd" d="M2.3 7.3a1 1 0 0 1 1.4 0L6 9.6l2.3-2.3a1 1 0 0 1 1.4 1.4L6 12.4 2.3 8.7a1 1 0 0 1 0-1.4Z"/></svg>',
|
||||
'action-next': '<svg width="24" height="24"><path fill-rule="nonzero" d="M5.7 7.3a1 1 0 0 0-1.4 1.4l7.7 7.7 7.7-7.7a1 1 0 1 0-1.4-1.4L12 13.6 5.7 7.3Z"/></svg>',
|
||||
'action-prev': '<svg width="24" height="24"><path fill-rule="nonzero" d="M18.3 15.7a1 1 0 0 0 1.4-1.4L12 6.6l-7.7 7.7a1 1 0 0 0 1.4 1.4L12 9.4l6.3 6.3Z"/></svg>',
|
||||
'addtag': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 5a2 2 0 0 1 1.6.8L21 12l-4.4 6.2a2 2 0 0 1-1.6.8h-3v-2h3l3.5-5L15 7H5v3H3V7c0-1.1.9-2 2-2h10Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6 12a1 1 0 0 0-1 1v2H3a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0-2H7v-2c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'ai-prompt': '<svg width="24" height="24"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 6.7a1 1 0 0 0-1.4 0l-9.9 10a1 1 0 0 0 0 1.3l2.1 2.1c.4.4 1 .4 1.4 0l10-9.9c.3-.3.3-1 0-1.4l-2.2-2Zm1.4 2.8-2-2-3 2.7 2.2 2.2 2.8-2.9Z"/><path d="m18.5 7.3-.7-1.5-1.5-.8 1.5-.7.7-1.5.7 1.5 1.5.7-1.5.8-.7 1.5ZM18.5 16.5l-.7-1.6-1.5-.7 1.5-.7.7-1.6.7 1.6 1.5.7-1.5.7-.7 1.6ZM9.7 7.3 9 5.8 7.5 5 9 4.3l.7-1.5.7 1.5L12 5l-1.5.8-.7 1.5Z"/></g><defs><clipPath id="a"><path d="M0 0h24v24H0z"/></clipPath></defs></svg>',
|
||||
'ai': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M5 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3H5Zm6.8 11.5.5 1.2a68.3 68.3 0 0 0 .7 1.1l.4.1c.3 0 .5 0 .7-.3.2-.1.3-.3.3-.6l-.3-1-2.6-6.2a20.4 20.4 0 0 0-.5-1.3l-.5-.4-.7-.2c-.2 0-.5 0-.6.2-.2 0-.4.2-.5.4l-.3.6-.3.7L5.7 15l-.2.6-.1.4c0 .3 0 .5.3.7l.6.2c.3 0 .5 0 .7-.2l.4-1 .5-1.2h3.9ZM9.8 9l1.5 4h-3l1.5-4Zm5.6-.9v7.6c0 .4 0 .7.2 1l.7.2c.3 0 .6 0 .8-.3l.2-.9V8.1c0-.4 0-.7-.2-.9a1 1 0 0 0-.8-.3c-.2 0-.5.1-.7.3l-.2 1Z"/></svg>',
|
||||
'align-center': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm3 4h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm-3-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-justify': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-left': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-none': '<svg width="24" height="24"><path d="M14.2 5 13 7H5a1 1 0 1 1 0-2h9.2Zm4 0h.8a1 1 0 0 1 0 2h-2l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h6.8Zm4 0H19a1 1 0 0 1 0 2h-4.4l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h4.4Zm4 0H19a1 1 0 0 1 0 2h-6.8l1.2-2ZM7 17l-1.2 2H5a1 1 0 0 1 0-2h2Zm4 0h8a1 1 0 0 1 0 2H9.8l1.2-2Zm5.2-13.5 1.3.7-9.7 16.3-1.3-.7 9.7-16.3Z" fill-rule="evenodd"/></svg>',
|
||||
'align-right': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm6 4h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-6-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'arrow-left': '<svg width="24" height="24"><path d="m5.6 13 12 6a1 1 0 0 0 1.4-1V6a1 1 0 0 0-1.4-.9l-12 6a1 1 0 0 0 0 1.8Z" fill-rule="evenodd"/></svg>',
|
||||
'arrow-right': '<svg width="24" height="24"><path d="m18.5 13-12 6A1 1 0 0 1 5 18V6a1 1 0 0 1 1.4-.9l12 6a1 1 0 0 1 0 1.8Z" fill-rule="evenodd"/></svg>',
|
||||
'bold': '<svg width="24" height="24"><path d="M7.8 19c-.3 0-.5 0-.6-.2l-.2-.5V5.7c0-.2 0-.4.2-.5l.6-.2h5c1.5 0 2.7.3 3.5 1 .7.6 1.1 1.4 1.1 2.5a3 3 0 0 1-.6 1.9c-.4.6-1 1-1.6 1.2.4.1.9.3 1.3.6s.8.7 1 1.2c.4.4.5 1 .5 1.6 0 1.3-.4 2.3-1.3 3-.8.7-2.1 1-3.8 1H7.8Zm5-8.3c.6 0 1.2-.1 1.6-.5.4-.3.6-.7.6-1.3 0-1.1-.8-1.7-2.3-1.7H9.3v3.5h3.4Zm.5 6c.7 0 1.3-.1 1.7-.4.4-.4.6-.9.6-1.5s-.2-1-.7-1.4c-.4-.3-1-.4-2-.4H9.4v3.8h4Z" fill-rule="evenodd"/></svg>',
|
||||
'bookmark': '<svg width="24" height="24"><path d="M6 4v17l6-4 6 4V4c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1Z" fill-rule="nonzero"/></svg>',
|
||||
'border-style': '<svg width="24" height="24"><g fill-rule="evenodd"><rect width="18" height="2" x="3" y="6" rx="1"/><rect width="2.8" height="2" x="3" y="16" rx="1"/><rect width="2.8" height="2" x="6.8" y="16" rx="1"/><rect width="2.8" height="2" x="10.6" y="16" rx="1"/><rect width="2.8" height="2" x="14.4" y="16" rx="1"/><rect width="2.8" height="2" x="18.2" y="16" rx="1"/><rect width="8" height="2" x="3" y="11" rx="1"/><rect width="8" height="2" x="13" y="11" rx="1"/></g></svg>',
|
||||
'border-width': '<svg width="24" height="24"><g fill-rule="evenodd"><rect width="18" height="5" x="3" y="5" rx="1"/><rect width="18" height="3.5" x="3" y="11.5" rx="1"/><rect width="18" height="2" x="3" y="17" rx="1"/></g></svg>',
|
||||
'brightness': '<svg width="24" height="24"><path d="M12 17c.3 0 .5.1.7.3.2.2.3.4.3.7v1c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3 1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7v-1c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3Zm0-10a1 1 0 0 1-.7-.3A1 1 0 0 1 11 6V5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3.3 0 .5.1.7.3.2.2.3.4.3.7v1c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3Zm7 4c.3 0 .5.1.7.3.2.2.3.4.3.7 0 .3-.1.5-.3.7a1 1 0 0 1-.7.3h-1a1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h1ZM7 12c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3H5a1 1 0 0 1-.7-.3A1 1 0 0 1 4 12c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h1c.3 0 .5.1.7.3.2.2.3.4.3.7Zm10 3.5.7.8c.2.1.3.4.3.6 0 .3-.1.6-.3.8a1 1 0 0 1-.8.3 1 1 0 0 1-.6-.3l-.8-.7a1 1 0 0 1-.3-.8c0-.2.1-.5.3-.7a1 1 0 0 1 1.4 0Zm-10-7-.7-.8a1 1 0 0 1-.3-.6c0-.3.1-.6.3-.8.2-.2.5-.3.8-.3.2 0 .5.1.7.3l.7.7c.2.2.3.5.3.8 0 .2-.1.5-.3.7a1 1 0 0 1-.7.3 1 1 0 0 1-.8-.3Zm10 0a1 1 0 0 1-.8.3 1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7c0-.3.1-.6.3-.8l.8-.7c.1-.2.4-.3.6-.3.3 0 .6.1.8.3.2.2.3.5.3.8 0 .2-.1.5-.3.7l-.7.7Zm-10 7c.2-.2.5-.3.8-.3.2 0 .5.1.7.3a1 1 0 0 1 0 1.4l-.8.8a1 1 0 0 1-.6.3 1 1 0 0 1-.8-.3 1 1 0 0 1-.3-.8c0-.2.1-.5.3-.6l.7-.8ZM12 8a4 4 0 0 1 3.7 2.4 4 4 0 0 1 0 3.2A4 4 0 0 1 12 16a4 4 0 0 1-3.7-2.4 4 4 0 0 1 0-3.2A4 4 0 0 1 12 8Zm0 6.5c.7 0 1.3-.2 1.8-.7.5-.5.7-1.1.7-1.8s-.2-1.3-.7-1.8c-.5-.5-1.1-.7-1.8-.7s-1.3.2-1.8.7c-.5.5-.7 1.1-.7 1.8s.2 1.3.7 1.8c.5.5 1.1.7 1.8.7Z" fill-rule="evenodd"/></svg>',
|
||||
'browse': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4v-2h4V8H5v10h4v2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9.4-2.3 2.3a1 1 0 1 1-1.4-1.4l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 0 1-1.4 1.4L13 13.4V20a1 1 0 0 1-2 0v-6.6Z" fill-rule="nonzero"/></svg>',
|
||||
'cancel': '<svg width="24" height="24"><path d="M12 4.6a7.4 7.4 0 1 1 0 14.8 7.4 7.4 0 0 1 0-14.8ZM12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8L14.8 8l1 1.1-2.7 2.8 2.7 2.7-1.1 1.1-2.7-2.7-2.7 2.7-1-1.1 2.6-2.7-2.7-2.7 1-1.1 2.8 2.7Z" fill-rule="nonzero"/></svg>',
|
||||
'cell-background-color': '<svg width="24" height="24"><path d="m15.7 2 1.6 1.6-2.7 2.6 5.9 5.8c.7.7.7 1.7 0 2.4l-6.3 6.1a1.7 1.7 0 0 1-2.4 0l-6.3-6.1c-.7-.7-.7-1.7 0-2.4L15.7 2ZM18 12l-4.5-4L9 12h9ZM4 16s2 2.4 2 3.8C6 21 5.1 22 4 22s-2-1-2-2.2C2 18.4 4 16 4 16Z"/></svg>',
|
||||
'cell-border-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M5 13v5h2v2H5a2 2 0 0 1-2-2v-5h2zm8-7V4h6a2 2 0 0 1 2 2h-8z" opacity=".2"/><path fill-rule="nonzero" d="M13 4v2H5v7H3V6c0-1.1.9-2 2-2h8zm-2.6 14.1.1-.1.1.1.2.3.2.2.2.2c.4.6.8 1.2.8 1.7 0 .8-.7 1.5-1.5 1.5S9 21.3 9 20.5c0-.5.4-1.1.8-1.7l.2-.2.2-.2.2-.3z"/><path d="m13 11-2 2H5v-2h6V6h2z"/><path fill-rule="nonzero" d="m18.4 8 1 1-1.8 1.9 4 4c.5.4.5 1.1 0 1.6l-4.3 4.2a1.2 1.2 0 0 1-1.6 0l-4.4-4.2c-.4-.5-.4-1.2 0-1.7l7-6.8Zm1.6 7-3-3-3 3h6Z"/></g></svg>',
|
||||
'change-case': '<svg width="24" height="24"><path d="M18.4 18.2v-.6c-.5.8-1.3 1.2-2.4 1.2-2.2 0-3.3-1.6-3.3-4.8 0-3.1 1-4.7 3.3-4.7 1.1 0 1.8.3 2.4 1.1v-.6c0-.5.4-.8.8-.8s.8.3.8.8v8.4c0 .5-.4.8-.8.8a.8.8 0 0 1-.8-.8zm-2-7.4c-1.3 0-1.8.9-1.8 3.2 0 2.4.5 3.3 1.7 3.3 1.3 0 1.8-.9 1.8-3.2 0-2.4-.5-3.3-1.7-3.3zM10 15.7H5.5l-.8 2.6a1 1 0 0 1-1 .7h-.2a.7.7 0 0 1-.7-1l4-12a1 1 0 0 1 2 0l4 12a.7.7 0 0 1-.8 1h-.2a1 1 0 0 1-1-.7l-.8-2.6zm-.3-1.5-2-6.5-1.9 6.5h3.9z" fill-rule="evenodd"/></svg>',
|
||||
'character-count': '<svg width="24" height="24"><path d="M4 11.5h16v1H4v-1Zm4.8-6.8V10H7.7V5.8h-1v-1h2ZM11 8.3V9h2v1h-3V7.7l2-1v-.9h-2v-1h3v2.4l-2 1Zm6.3-3.4V10h-3.1V9h2.1V8h-2.1V6.8h2.1v-1h-2.1v-1h3.1ZM5.8 16.4c0-.5.2-.8.5-1 .2-.2.6-.3 1.2-.3l.8.1c.2 0 .4.2.5.3l.4.4v2.8l.2.3H8.2V18.7l-.6.3H7c-.4 0-.7 0-1-.2a1 1 0 0 1-.3-.9c0-.3 0-.6.3-.8.3-.2.7-.4 1.2-.4l.6-.2h.3v-.2l-.1-.2a.8.8 0 0 0-.5-.1 1 1 0 0 0-.4 0l-.3.4h-1Zm2.3.8h-.2l-.2.1-.4.1a1 1 0 0 0-.4.2l-.2.2.1.3.5.1h.4l.4-.4v-.6Zm2-3.4h1.2v1.7l.5-.3h.5c.5 0 .9.1 1.2.5.3.4.5.8.5 1.4 0 .6-.2 1.1-.5 1.5-.3.4-.7.6-1.3.6l-.6-.1-.4-.4v.4h-1.1v-5.4Zm1.1 3.3c0 .3 0 .6.2.8a.7.7 0 0 0 1.2 0l.2-.8c0-.4 0-.6-.2-.8a.7.7 0 0 0-.6-.3l-.6.3-.2.8Zm6.1-.5c0-.2 0-.3-.2-.4a.8.8 0 0 0-.5-.2c-.3 0-.5.1-.6.3l-.2.9c0 .3 0 .6.2.8.1.2.3.3.6.3.2 0 .4 0 .5-.2l.2-.4h1.1c0 .5-.3.8-.6 1.1a2 2 0 0 1-1.3.4c-.5 0-1-.2-1.3-.6a2 2 0 0 1-.5-1.4c0-.6.1-1.1.5-1.5.3-.4.8-.5 1.4-.5.5 0 1 0 1.2.3.4.3.5.7.5 1.2h-1v-.1Z" fill-rule="evenodd"/></svg>',
|
||||
'checklist-rtl': '<svg width="24" height="24"><path d="M5 17h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2zm14.2 11c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 20c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 14c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 8c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8z" fill-rule="evenodd"/></svg>',
|
||||
'checklist': '<svg width="24" height="24"><path d="M11 17h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2h-8a1 1 0 0 1 0-2ZM7.2 16c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 20c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 14c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 8c-.2.3-.7.4-1 0L3.8 6.9a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Z" fill-rule="evenodd"/></svg>',
|
||||
'checkmark': '<svg width="24" height="24"><path d="M18.2 5.4a1 1 0 0 1 1.6 1.2l-8 12a1 1 0 0 1-1.5.1l-5-5a1 1 0 1 1 1.4-1.4l4.1 4.1 7.4-11Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-down': '<svg width="10" height="10"><path d="M8.7 2.2c.3-.3.8-.3 1 0 .4.4.4.9 0 1.2L5.7 7.8c-.3.3-.9.3-1.2 0L.2 3.4a.8.8 0 0 1 0-1.2c.3-.3.8-.3 1.1 0L5 6l3.7-3.8Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-left': '<svg width="10" height="10"><path d="M7.8 1.3 4 5l3.8 3.7c.3.3.3.8 0 1-.4.4-.9.4-1.2 0L2.2 5.7a.8.8 0 0 1 0-1.2L6.6.2C7 0 7.4 0 7.8.2c.3.3.3.8 0 1.1Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-right': '<svg width="10" height="10"><path d="M2.2 1.3a.8.8 0 0 1 0-1c.4-.4.9-.4 1.2 0l4.4 4.1c.3.4.3.9 0 1.2L3.4 9.8c-.3.3-.8.3-1.2 0a.8.8 0 0 1 0-1.1L6 5 2.2 1.3Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-up': '<svg width="10" height="10"><path d="M8.7 7.8 5 4 1.3 7.8c-.3.3-.8.3-1 0a.8.8 0 0 1 0-1.2l4.1-4.4c.3-.3.9-.3 1.2 0l4.2 4.4c.3.3.3.9 0 1.2-.3.3-.8.3-1.1 0Z" fill-rule="nonzero"/></svg>',
|
||||
'close': '<svg width="24" height="24"><path d="M17.3 8.2 13.4 12l3.9 3.8a1 1 0 0 1-1.5 1.5L12 13.4l-3.8 3.9a1 1 0 0 1-1.5-1.5l3.9-3.8-3.9-3.8a1 1 0 0 1 1.5-1.5l3.8 3.9 3.8-3.9a1 1 0 0 1 1.5 1.5Z" fill-rule="evenodd"/></svg>',
|
||||
'code-sample': '<svg width="24" height="26"><path d="M7.1 11a2.8 2.8 0 0 1-.8 2 2.8 2.8 0 0 1 .8 2v1.7c0 .3.1.6.4.8.2.3.5.4.8.4.3 0 .4.2.4.4v.8c0 .2-.1.4-.4.4-.7 0-1.4-.3-2-.8-.5-.6-.8-1.3-.8-2V15c0-.3-.1-.6-.4-.8-.2-.3-.5-.4-.8-.4a.4.4 0 0 1-.4-.4v-.8c0-.2.2-.4.4-.4.3 0 .6-.1.8-.4.3-.2.4-.5.4-.8V9.3c0-.7.3-1.4.8-2 .6-.5 1.3-.8 2-.8.3 0 .4.2.4.4v.8c0 .2-.1.4-.4.4-.3 0-.6.1-.8.4-.3.2-.4.5-.4.8V11Zm9.8 0V9.3c0-.3-.1-.6-.4-.8-.2-.3-.5-.4-.8-.4a.4.4 0 0 1-.4-.4V7c0-.2.1-.4.4-.4.7 0 1.4.3 2 .8.5.6.8 1.3.8 2V11c0 .3.1.6.4.8.2.3.5.4.8.4.2 0 .4.2.4.4v.8c0 .2-.2.4-.4.4-.3 0-.6.1-.8.4-.3.2-.4.5-.4.8v1.7c0 .7-.3 1.4-.8 2-.6.5-1.3.8-2 .8a.4.4 0 0 1-.4-.4v-.8c0-.2.1-.4.4-.4.3 0 .6-.1.8-.4.3-.2.4-.5.4-.8V15a2.8 2.8 0 0 1 .8-2 2.8 2.8 0 0 1-.8-2Zm-3.3-.4c0 .4-.1.8-.5 1.1-.3.3-.7.5-1.1.5-.4 0-.8-.2-1.1-.5-.4-.3-.5-.7-.5-1.1 0-.5.1-.9.5-1.2.3-.3.7-.4 1.1-.4.4 0 .8.1 1.1.4.4.3.5.7.5 1.2ZM12 13c.4 0 .8.1 1.1.5.4.3.5.7.5 1.1 0 1-.1 1.6-.5 2a3 3 0 0 1-1.1 1c-.4.3-.8.4-1.1.4a.5.5 0 0 1-.5-.5V17a3 3 0 0 0 1-.2l.6-.6c-.6 0-1-.2-1.3-.5-.2-.3-.3-.7-.3-1 0-.5.1-1 .5-1.2.3-.4.7-.5 1.1-.5Z" fill-rule="evenodd"/></svg>',
|
||||
'color-levels': '<svg width="24" height="24"><path d="M17.5 11.4A9 9 0 0 1 18 14c0 .5 0 1-.2 1.4 0 .4-.3.9-.5 1.3a6.2 6.2 0 0 1-3.7 3 5.7 5.7 0 0 1-3.2 0A5.9 5.9 0 0 1 7.6 18a6.2 6.2 0 0 1-1.4-2.6 6.7 6.7 0 0 1 0-2.8c0-.4.1-.9.3-1.3a13.6 13.6 0 0 1 2.3-4A20 20 0 0 1 12 4a26.4 26.4 0 0 1 3.2 3.4 18.2 18.2 0 0 1 2.3 4Zm-2 4.5c.4-.7.5-1.4.5-2a7.3 7.3 0 0 0-1-3.2c.2.6.2 1.2.2 1.9a4.5 4.5 0 0 1-1.3 3 5.3 5.3 0 0 1-2.3 1.5 4.9 4.9 0 0 1-2 .1 4.3 4.3 0 0 0 2.4.8 4 4 0 0 0 2-.6 4 4 0 0 0 1.5-1.5Z" fill-rule="evenodd"/></svg>',
|
||||
'color-picker': '<svg width="24" height="24"><path d="M12 3a9 9 0 0 0 0 18 1.5 1.5 0 0 0 1.1-2.5c-.2-.3-.4-.6-.4-1 0-.8.7-1.5 1.5-1.5H16a5 5 0 0 0 5-5c0-4.4-4-8-9-8Zm-5.5 9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm3-4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm3 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z" fill-rule="nonzero"/></svg>',
|
||||
'color-swatch-remove-color': '<svg width="24" height="24"><path stroke="#000" stroke-width="2" d="M21 3 3 21" fill-rule="evenodd"/></svg>',
|
||||
'color-swatch': '<svg width="24" height="24"><rect x="3" y="3" width="18" height="18" rx="1" fill-rule="evenodd"/></svg>',
|
||||
'comment-add': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="m9 19 3-2h7c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H5a1 1 0 0 0-1 1v10c0 .6.4 1 1 1h4v2Zm-2 4v-4H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3h-6.4L7 23Z"/><path d="M13 10h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 0v-2H9a1 1 0 0 1 0-2h2V8a1 1 0 0 1 2 0v2Z"/></g></svg>',
|
||||
'comment': '<svg width="24" height="24"><path fill-rule="nonzero" d="m9 19 3-2h7c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H5a1 1 0 0 0-1 1v10c0 .6.4 1 1 1h4v2Zm-2 4v-4H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3h-6.4L7 23Z"/></svg>',
|
||||
'contrast': '<svg width="24" height="24"><path d="M12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 1 1 12 4Zm-6 8a6 6 0 0 0 6 6V6a6 6 0 0 0-6 6Z" fill-rule="evenodd"/></svg>',
|
||||
'copy': '<svg width="24" height="24"><path d="M16 3H6a2 2 0 0 0-2 2v11h2V5h10V3Zm1 4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2V9c0-1.2.9-2 2-2h7Zm0 12V9h-7v10h7Z" fill-rule="nonzero"/></svg>',
|
||||
'crop': '<svg width="24" height="24"><path d="M17 8v7h2c.6 0 1 .4 1 1s-.4 1-1 1h-2v2c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-2H7V9H5a1 1 0 1 1 0-2h2V5c0-.6.4-1 1-1s1 .4 1 1v2h7l3-3 1 1-3 3ZM9 9v5l5-5H9Zm1 6h5v-5l-5 5Z" fill-rule="evenodd"/></svg>',
|
||||
'cut-column': '<svg width="24" height="24"><path fill-rule="evenodd" d="M7.2 4.5c.9 0 1.6.4 2.2 1A3.7 3.7 0 0 1 10.5 8v.5l1 1 4-4 1-.5a3.3 3.3 0 0 1 2 0c.4 0 .7.3 1 .5L17 8h4v13h-6V10l-1.5 1.5.5.5v4l-2.5-2.5-1 1v.5c0 .4 0 .8-.3 1.2-.2.5-.4.9-.8 1.2-.6.7-1.3 1-2.2 1-.8.2-1.5 0-2-.6l-.5-.8-.2-1c0-.4 0-.8.3-1.2A3.9 3.9 0 0 1 7 12.7c.5-.2 1-.3 1.5-.2l1-1-1-1c-.5 0-1 0-1.5-.2-.5-.1-1-.4-1.4-.9-.4-.3-.6-.7-.8-1.2L4.5 7c0-.4 0-.7.2-1 0-.3.3-.6.5-.8.5-.5 1.2-.8 2-.7Zm12.3 5h-3v10h3v-10ZM8 13.8h-.3l-.4.2a2.8 2.8 0 0 0-.7.4v.1a2.8 2.8 0 0 0-.6.8l-.1.4v.7l.2.5.5.2h.7a2.6 2.6 0 0 0 .8-.3 2.4 2.4 0 0 0 .7-.7 2.5 2.5 0 0 0 .3-.8 1.5 1.5 0 0 0 0-.8 1 1 0 0 0-.2-.4 1 1 0 0 0-.5-.2H8Zm3.5-3.7c-.4 0-.7.1-1 .4-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4s.7-.1 1-.4c.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4ZM7 5.8h-.4a1 1 0 0 0-.5.3 1 1 0 0 0-.2.5v.7a2.5 2.5 0 0 0 .3.8l.2.3h.1l.4.4.4.2.4.1h.7L9 9l.2-.4a1.6 1.6 0 0 0 0-.8 2.6 2.6 0 0 0-.3-.8A2.5 2.5 0 0 0 7.7 6l-.4-.1H7Z"/></svg>',
|
||||
'cut-row': '<svg width="24" height="24"><path fill-rule="evenodd" d="M22 3v5H9l3 3 2-2h4l-4 4 1 1h.5c.4 0 .8 0 1.2.3.5.2.9.4 1.2.8.7.6 1 1.3 1 2.2.2.8 0 1.5-.6 2l-.8.5-1 .2c-.4 0-.8 0-1.2-.3a3.9 3.9 0 0 1-2.1-2.2c-.2-.5-.3-1-.2-1.5l-1-1-1 1c0 .5 0 1-.2 1.5-.1.5-.4 1-.9 1.4-.3.4-.7.6-1.2.8l-1.2.3c-.4 0-.7 0-1-.2-.3 0-.6-.3-.8-.5-.5-.5-.8-1.2-.7-2 0-.9.4-1.6 1-2.2A3.7 3.7 0 0 1 8.6 14H9l1-1-4-4-.5-1a3.3 3.3 0 0 1 0-2c0-.4.3-.7.5-1l2 2V3h14ZM8.5 15.3h-.3a2.6 2.6 0 0 0-.8.4 2.5 2.5 0 0 0-.9 1.1l-.1.4v.7l.2.5.5.2h.7a2.5 2.5 0 0 0 .8-.3L9 18V18l.4-.4.2-.4.1-.4v-.7a1 1 0 0 0-.2-.5 1 1 0 0 0-.4-.2h-.5Zm7 0H15a1 1 0 0 0-.4.3 1 1 0 0 0-.2.5 1.5 1.5 0 0 0 0 .7v.4a2.8 2.8 0 0 0 .5.7h.1a2.8 2.8 0 0 0 .8.6l.4.1h.7l.5-.2.2-.5v-.7a2.6 2.6 0 0 0-.3-.8 2.4 2.4 0 0 0-.7-.7 2.5 2.5 0 0 0-.8-.3h-.3ZM12 11.6c-.4 0-.7.1-1 .4-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4s.7-.1 1-.4c.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4Zm8.5-7.1h-11v2h11v-2Z"/></svg>',
|
||||
'cut': '<svg width="24" height="24"><path d="M18 15c.6.7 1 1.4 1 2.3 0 .8-.2 1.5-.7 2l-.8.5-1 .2c-.4 0-.8 0-1.2-.3a3.9 3.9 0 0 1-2.1-2.2c-.2-.5-.3-1-.2-1.5l-1-1-1 1c0 .5 0 1-.2 1.5-.1.5-.4 1-.9 1.4-.3.4-.7.6-1.2.8l-1.2.3c-.4 0-.7 0-1-.2-.3 0-.6-.3-.8-.5-.5-.5-.8-1.2-.7-2 0-.9.4-1.6 1-2.2A3.7 3.7 0 0 1 8.6 14H9l1-1-4-4-.5-1a3.3 3.3 0 0 1 0-2c0-.4.3-.7.5-1l6 6 6-6 .5 1a3.3 3.3 0 0 1 0 2c0 .4-.3.7-.5 1l-4 4 1 1h.5c.4 0 .8 0 1.2.3.5.2.9.4 1.2.8Zm-8.5 2.2.1-.4v-.7a1 1 0 0 0-.2-.5 1 1 0 0 0-.4-.2 1.6 1.6 0 0 0-.8 0 2.6 2.6 0 0 0-.8.3 2.5 2.5 0 0 0-.9 1.1l-.1.4v.7l.2.5.5.2h.7a2.5 2.5 0 0 0 .8-.3 2.8 2.8 0 0 0 1-1Zm2.5-2.8c.4 0 .7-.1 1-.4.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4s-.7.1-1 .4c-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4Zm5.4 4 .2-.5v-.7a2.6 2.6 0 0 0-.3-.8 2.4 2.4 0 0 0-.7-.7 2.5 2.5 0 0 0-.8-.3 1.5 1.5 0 0 0-.8 0 1 1 0 0 0-.4.2 1 1 0 0 0-.2.5 1.5 1.5 0 0 0 0 .7v.4l.3.4.3.4a2.8 2.8 0 0 0 .8.5l.4.1h.7l.5-.2Z" fill-rule="evenodd"/></svg>',
|
||||
'document-properties': '<svg width="24" height="24"><path d="M14.4 3H7a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V7.6L14.4 3ZM17 19H7V5h6v4h4v10Z" fill-rule="nonzero"/></svg>',
|
||||
'drag': '<svg width="24" height="24"><path d="M13 5h2v2h-2V5Zm0 4h2v2h-2V9ZM9 9h2v2H9V9Zm4 4h2v2h-2v-2Zm-4 0h2v2H9v-2Zm0 4h2v2H9v-2Zm4 0h2v2h-2v-2ZM9 5h2v2H9V5Z" fill-rule="evenodd"/></svg>',
|
||||
'duplicate-column': '<svg width="24" height="24"><path d="M17 6v16h-7V6h7Zm-2 2h-3v12h3V8Zm-2-6v2H8v15H6V2h7Z"/></svg>',
|
||||
'duplicate-row': '<svg width="24" height="24"><path d="M22 11v7H6v-7h16Zm-2 2H8v3h12v-3Zm-1-6v2H4v5H2V7h17Z"/></svg>',
|
||||
'duplicate': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M16 3v2H6v11H4V5c0-1.1.9-2 2-2h10Zm3 8h-2V9h-7v10h9a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2V9c0-1.2.9-2 2-2h7a2 2 0 0 1 2 2v2Z"/><path d="M17 14h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0v-1h-1a1 1 0 0 1 0-2h1v-1a1 1 0 0 1 2 0v1Z"/></g></svg>',
|
||||
'edit-block': '<svg width="24" height="24"><path fill-rule="nonzero" d="m19.8 8.8-9.4 9.4c-.2.2-.5.4-.9.4l-5.4 1.2 1.2-5.4.5-.8 9.4-9.4c.7-.7 1.8-.7 2.5 0l2.1 2.1c.7.7.7 1.8 0 2.5Zm-2-.2 1-.9v-.3l-2.2-2.2a.3.3 0 0 0-.3 0l-1 1L18 8.5Zm-1 1-2.5-2.4-6 6 2.5 2.5 6-6Zm-7 7.1-2.6-2.4-.3.3-.1.2-.7 3 3.1-.6h.1l.4-.5Z"/></svg>',
|
||||
'edit-image': '<svg width="24" height="24"><path d="M18 16h2V7a2 2 0 0 0-2-2H7v2h11v9ZM6 17h15a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0v-1H6a2 2 0 0 1-2-2V7H3a1 1 0 1 1 0-2h1V4a1 1 0 1 1 2 0v13Zm3-5.3 1.3 2 3-4.7 3.7 6H7l2-3.3Z" fill-rule="nonzero"/></svg>',
|
||||
'embed-page': '<svg width="24" height="24"><path d="M19 6V5H5v14h2A13 13 0 0 1 19 6Zm0 1.4c-.8.8-1.6 2.4-2.2 4.6H19V7.4Zm0 5.6h-2.4c-.4 1.8-.6 3.8-.6 6h3v-6Zm-4 6c0-2.2.2-4.2.6-6H13c-.7 1.8-1.1 3.8-1.1 6h3Zm-4 0c0-2.2.4-4.2 1-6H9.6A12 12 0 0 0 8 19h3ZM4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm11.8 9c.4-1.9 1-3.4 1.8-4.5a9.2 9.2 0 0 0-4 4.5h2.2Zm-3.4 0a12 12 0 0 1 2.8-4 12 12 0 0 0-5 4h2.2Z" fill-rule="nonzero"/></svg>',
|
||||
'embed': '<svg width="24" height="24"><path d="M4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm1 2v14h14V5H5Zm4.8 2.6 5.6 4a.5.5 0 0 1 0 .8l-5.6 4A.5.5 0 0 1 9 16V8a.5.5 0 0 1 .8-.4Z" fill-rule="nonzero"/></svg>',
|
||||
'emoji': '<svg width="24" height="24"><path d="M9 11c.6 0 1-.4 1-1s-.4-1-1-1a1 1 0 0 0-1 1c0 .6.4 1 1 1Zm6 0c.6 0 1-.4 1-1s-.4-1-1-1a1 1 0 0 0-1 1c0 .6.4 1 1 1Zm-3 5.5c2.1 0 4-1.5 4.4-3.5H7.6c.5 2 2.3 3.5 4.4 3.5ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 14.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Z" fill-rule="nonzero"/></svg>',
|
||||
'export': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M14.4 3 18 7v1h-5V5H7v14h9a1 1 0 0 1 2 0c0 1-.8 2-1.9 2H7c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2h7.5Z"/><path d="M18.1 12c.5 0 .9.4.9 1 0 .5-.3 1-.8 1h-7.3c-.5 0-.9-.4-.9-1 0-.5.3-1 .8-1h7.3Z"/><path d="M16.4 9.2a1 1 0 0 1 1.4.2l2.4 3.6-2.4 3.6a1 1 0 0 1-1.7-1v-.2l1.7-2.4-1.6-2.4a1 1 0 0 1 .2-1.4Z"/></g></svg>',
|
||||
'fill': '<svg width="24" height="26"><path d="m16.6 12-9-9-1.4 1.4 2.4 2.4-5.2 5.1c-.5.6-.5 1.6 0 2.2L9 19.6a1.5 1.5 0 0 0 2.2 0l5.5-5.5c.5-.6.5-1.6 0-2.2ZM5.2 13 10 8.2l4.8 4.8H5.2ZM19 14.5s-2 2.2-2 3.5c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.3-2-3.5-2-3.5Z" fill-rule="nonzero"/></svg>',
|
||||
'flip-horizontally': '<svg width="24" height="24"><path d="M14 19h2v-2h-2v2Zm4-8h2V9h-2v2ZM4 7v10c0 1.1.9 2 2 2h3v-2H6V7h3V5H6a2 2 0 0 0-2 2Zm14-2v2h2a2 2 0 0 0-2-2Zm-7 16h2V3h-2v18Zm7-6h2v-2h-2v2Zm-4-8h2V5h-2v2Zm4 12a2 2 0 0 0 2-2h-2v2Z" fill-rule="nonzero"/></svg>',
|
||||
'flip-vertically': '<svg width="24" height="24"><path d="M5 14v2h2v-2H5Zm8 4v2h2v-2h-2Zm4-14H7a2 2 0 0 0-2 2v3h2V6h10v3h2V6a2 2 0 0 0-2-2Zm2 14h-2v2a2 2 0 0 0 2-2ZM3 11v2h18v-2H3Zm6 7v2h2v-2H9Zm8-4v2h2v-2h-2ZM5 18c0 1.1.9 2 2 2v-2H5Z" fill-rule="nonzero"/></svg>',
|
||||
'footnote': '<svg width="24" height="24"><path d="M19 13c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2h14Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19 4v6h-1V5h-1.5V4h2.6Z"/><path d="M12 18c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2h7ZM14 8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2h9Z"/></svg>',
|
||||
'format-painter': '<svg width="24" height="24"><path d="M18 5V4c0-.5-.4-1-1-1H5a1 1 0 0 0-1 1v4c0 .6.5 1 1 1h12c.6 0 1-.4 1-1V7h1v4H9v9c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-7h8V5h-3Z" fill-rule="nonzero"/></svg>',
|
||||
'format': '<svg width="24" height="24"><path fill-rule="evenodd" d="M17 5a1 1 0 0 1 0 2h-4v11a1 1 0 0 1-2 0V7H7a1 1 0 1 1 0-2h10Z"/></svg>',
|
||||
'fullscreen': '<svg width="24" height="24"><path d="m15.3 10-1.2-1.3 2.9-3h-2.3a.9.9 0 1 1 0-1.7H19c.5 0 .9.4.9.9v4.4a.9.9 0 1 1-1.8 0V7l-2.9 3Zm0 4 3 3v-2.3a.9.9 0 1 1 1.7 0V19c0 .5-.4.9-.9.9h-4.4a.9.9 0 1 1 0-1.8H17l-3-2.9 1.3-1.2ZM10 15.4l-2.9 3h2.3a.9.9 0 1 1 0 1.7H5a.9.9 0 0 1-.9-.9v-4.4a.9.9 0 1 1 1.8 0V17l2.9-3 1.2 1.3ZM8.7 10 5.7 7v2.3a.9.9 0 0 1-1.7 0V5c0-.5.4-.9.9-.9h4.4a.9.9 0 0 1 0 1.8H7l3 2.9-1.3 1.2Z" fill-rule="nonzero"/></svg>',
|
||||
'gallery': '<svg width="24" height="24"><path fill-rule="nonzero" d="m5 15.7 2.3-2.2c.3-.3.7-.3 1 0L11 16l5.1-5c.3-.4.8-.4 1 0l2 1.9V8H5v7.7ZM5 18V19h3l1.8-1.9-2-2L5 17.9Zm14-3-2.5-2.4-6.4 6.5H19v-4ZM4 6h16c.6 0 1 .4 1 1v13c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V7c0-.6.4-1 1-1Zm6 7a2 2 0 1 1 0-4 2 2 0 0 1 0 4ZM4.5 4h15a.5.5 0 1 1 0 1h-15a.5.5 0 0 1 0-1Zm2-2h11a.5.5 0 1 1 0 1h-11a.5.5 0 0 1 0-1Z"/></svg>',
|
||||
'gamma': '<svg width="24" height="24"><path d="M4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm1 2v14h14V5H5Zm6.5 11.8V14L9.2 8.7a5.1 5.1 0 0 0-.4-.8l-.1-.2H8v-1l.3-.1.3-.1h.7a1 1 0 0 1 .6.5l.1.3a8.5 8.5 0 0 1 .3.6l1.9 4.6 2-5.2a1 1 0 0 1 1-.6.5.5 0 0 1 .5.6L13 14v2.8a.7.7 0 0 1-1.4 0Z" fill-rule="nonzero"/></svg>',
|
||||
'help': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M12 5.5a6.5 6.5 0 0 0-6 9 6.3 6.3 0 0 0 1.4 2l1 1a6.3 6.3 0 0 0 3.6 1 6.5 6.5 0 0 0 6-9 6.3 6.3 0 0 0-1.4-2l-1-1a6.3 6.3 0 0 0-3.6-1ZM12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 1 1 12 4Z"/><path d="M9.6 9.7a.7.7 0 0 1-.7-.8c0-1.1 1.5-1.8 3.2-1.8 1.8 0 3.2.8 3.2 2.4 0 1.4-.4 2.1-1.5 2.8-.2 0-.3.1-.3.2a2 2 0 0 0-.8.8.8.8 0 0 1-1.4-.6c.3-.7.8-1 1.3-1.5l.4-.2c.7-.4.8-.6.8-1.5 0-.5-.6-.9-1.7-.9-.5 0-1 .1-1.4.3-.2 0-.3.1-.3.2v-.2c0 .4-.4.8-.8.8Z" fill-rule="nonzero"/><circle cx="12" cy="16" r="1"/></g></svg>',
|
||||
'highlight-bg-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path class="tox-icon-highlight-bg-color__color" d="M3 18h18v3H3z"/><path fill-rule="nonzero" d="M7.7 16.7H3l3.3-3.3-.7-.8L10.2 8l4 4.1-4 4.2c-.2.2-.6.2-.8 0l-.6-.7-1.1 1.1zm5-7.5L11 7.4l3-2.9a2 2 0 0 1 2.6 0L18 6c.7.7.7 2 0 2.7l-2.9 2.9-1.8-1.8-.5-.6"/></g></svg>',
|
||||
'home': '<svg width="24" height="24"><path fill-rule="nonzero" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>',
|
||||
'horizontal-rule': '<svg width="24" height="24"><path d="M4 11h16v2H4z" fill-rule="evenodd"/></svg>',
|
||||
'image-options': '<svg width="24" height="24"><path d="M6 10a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm12 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm-6 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Z" fill-rule="nonzero"/></svg>',
|
||||
'image': '<svg width="24" height="24"><path d="m5 15.7 3.3-3.2c.3-.3.7-.3 1 0L12 15l4.1-4c.3-.4.8-.4 1 0l2 1.9V5H5v10.7ZM5 18V19h3l2.8-2.9-2-2L5 17.9Zm14-3-2.5-2.4-6.4 6.5H19v-4ZM4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" fill-rule="nonzero"/></svg>',
|
||||
'indent': '<svg width="24" height="24"><path d="M7 5h12c.6 0 1 .4 1 1s-.4 1-1 1H7a1 1 0 1 1 0-2Zm5 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm0 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm-5 4h12a1 1 0 0 1 0 2H7a1 1 0 0 1 0-2Zm-2.6-3.8L6.2 12l-1.8-1.2a1 1 0 0 1 1.2-1.6l3 2a1 1 0 0 1 0 1.6l-3 2a1 1 0 1 1-1.2-1.6Z" fill-rule="evenodd"/></svg>',
|
||||
'info': '<svg width="24" height="24"><path d="M12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 1 1 12 4Zm-1 3v2h2V7h-2Zm3 10v-1h-1v-5h-3v1h1v4h-1v1h4Z" fill-rule="evenodd"/></svg>',
|
||||
'insert-character': '<svg width="24" height="24"><path d="M15 18h4l1-2v4h-6v-3.3l1.4-1a6 6 0 0 0 1.8-2.9 6.3 6.3 0 0 0-.1-4.1 5.8 5.8 0 0 0-3-3.2c-.6-.3-1.3-.5-2.1-.5a5.1 5.1 0 0 0-3.9 1.8 6.3 6.3 0 0 0-1.3 6 6.2 6.2 0 0 0 1.8 3l1.4.9V20H4v-4l1 2h4v-.5l-2-1L5.4 15A6.5 6.5 0 0 1 4 11c0-1 .2-1.9.6-2.7A7 7 0 0 1 6.3 6C7.1 5.4 8 5 9 4.5c1-.3 2-.5 3.1-.5a8.8 8.8 0 0 1 5.7 2 7 7 0 0 1 1.7 2.3 6 6 0 0 1 .2 4.8c-.2.7-.6 1.3-1 1.9a7.6 7.6 0 0 1-3.6 2.5v.5Z" fill-rule="evenodd"/></svg>',
|
||||
'insert-time': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M12 19a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm0 2a9 9 0 1 1 0-18 9 9 0 0 1 0 18Z"/><path d="M16 12h-3V7c0-.6-.4-1-1-1a1 1 0 0 0-1 1v7h5c.6 0 1-.4 1-1s-.4-1-1-1Z"/></g></svg>',
|
||||
'invert': '<svg width="24" height="24"><path d="M18 19.3 16.5 18a5.8 5.8 0 0 1-3.1 1.9 6.1 6.1 0 0 1-5.5-1.6A5.8 5.8 0 0 1 6 14v-.3l.1-1.2A13.9 13.9 0 0 1 7.7 9l-3-3 .7-.8 2.8 2.9 9 8.9 1.5 1.6-.7.6Zm0-5.5v.3l-.1 1.1-.4 1-1.2-1.2a4.3 4.3 0 0 0 .2-1v-.2c0-.4 0-.8-.2-1.3l-.5-1.4a14.8 14.8 0 0 0-3-4.2L12 6a26.1 26.1 0 0 0-2.2 2.5l-1-1a20.9 20.9 0 0 1 2.9-3.3L12 4l1 .8a22.2 22.2 0 0 1 4 5.4c.6 1.2 1 2.4 1 3.6Z" fill-rule="evenodd"/></svg>',
|
||||
'italic': '<svg width="24" height="24"><path d="m16.7 4.7-.1.9h-.3c-.6 0-1 0-1.4.3-.3.3-.4.6-.5 1.1l-2.1 9.8v.6c0 .5.4.8 1.4.8h.2l-.2.8H8l.2-.8h.2c1.1 0 1.8-.5 2-1.5l2-9.8.1-.5c0-.6-.4-.8-1.4-.8h-.3l.2-.9h5.8Z" fill-rule="evenodd"/></svg>',
|
||||
'language': '<svg width="24" height="24"><path d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18Zm4.3 13.3c-.5 1-1.2 2-2 2.9a7.5 7.5 0 0 0 3.2-2.1l-.2-.2a6 6 0 0 0-1-.6Zm-8.6 0c-.5.2-.9.5-1.2.8.9 1 2 1.7 3.2 2a10 10 0 0 1-2-2.8Zm3.6-.8c-.8 0-1.6.1-2.2.3.5 1 1.2 1.9 2.1 2.7Zm1.5 0v3c.9-.8 1.6-1.7 2.1-2.7-.6-.2-1.4-.3-2.1-.3Zm-6-2.7H4.5c.2 1 .5 2.1 1 3h.3l1.3-1a10 10 0 0 1-.3-2Zm12.7 0h-2.3c0 .7-.1 1.4-.3 2l1.6 1.1c.5-1 .9-2 1-3.1Zm-3.8 0h-3V14c1 0 2 .1 2.7.4.2-.5.3-1 .3-1.6Zm-4.4 0h-3l.3 1.6c.8-.3 1.7-.4 2.7-.4v-1.3Zm-5.5-5c-.7 1-1.1 2.2-1.3 3.5h2.3c0-1 .2-1.8.5-2.6l-1.5-1Zm2.9 1.4v.1c-.2.6-.4 1.3-.4 2h3V9.4c-1 0-1.8-.1-2.6-.3Zm6.6 0h-.1l-2.4.3v1.8h3l-.5-2.1Zm3-1.4-.3.1-1.3.8c.3.8.5 1.6.5 2.6h2.3a7.5 7.5 0 0 0-1.3-3.5Zm-9 0 2 .2V5.5a9 9 0 0 0-2 2.2Zm3.5-2.3V8c.6 0 1.3 0 1.9-.2a9 9 0 0 0-2-2.3Zm-3-.7h-.1c-1.1.4-2.1 1-3 1.8l1.2.7a10 10 0 0 1 1.9-2.5Zm4.4 0 .1.1a10 10 0 0 1 1.8 2.4l1.1-.7a7.5 7.5 0 0 0-3-1.8Z"/></svg>',
|
||||
'line-height': '<svg width="24" height="24"><path d="M21 5a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zM7 3.6l3.7 3.7a1 1 0 0 1-1.3 1.5h-.1L8 7.3v9.2l1.3-1.3a1 1 0 0 1 1.3 0h.1c.4.4.4 1 0 1.3v.1L7 20.4l-3.7-3.7a1 1 0 0 1 1.3-1.5h.1L6 16.7V7.4L4.7 8.7a1 1 0 0 1-1.3 0h-.1a1 1 0 0 1 0-1.3v-.1L7 3.6z"/></svg>',
|
||||
'line': '<svg width="24" height="24"><path d="m15 9-8 8H4v-3l8-8 3 3Zm1-1-3-3 1-1h1c-.2 0 0 0 0 0l2 2s0 .2 0 0v1l-1 1ZM4 18h16v2H4v-2Z" fill-rule="evenodd"/></svg>',
|
||||
'link': '<svg width="24" height="24"><path d="M6.2 12.3a1 1 0 0 1 1.4 1.4l-2 2a2 2 0 1 0 2.6 2.8l4.8-4.8a1 1 0 0 0 0-1.4 1 1 0 1 1 1.4-1.3 2.9 2.9 0 0 1 0 4L9.6 20a3.9 3.9 0 0 1-5.5-5.5l2-2Zm11.6-.6a1 1 0 0 1-1.4-1.4l2-2a2 2 0 1 0-2.6-2.8L11 10.3a1 1 0 0 0 0 1.4A1 1 0 1 1 9.6 13a2.9 2.9 0 0 1 0-4L14.4 4a3.9 3.9 0 0 1 5.5 5.5l-2 2Z" fill-rule="nonzero"/></svg>',
|
||||
'list-bull-circle': '<svg width="48" height="48"><g fill-rule="evenodd"><path d="M11 16a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 1a3 3 0 1 1 0-6 3 3 0 0 1 0 6ZM11 26a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 1a3 3 0 1 1 0-6 3 3 0 0 1 0 6ZM11 36a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 1a3 3 0 1 1 0-6 3 3 0 0 1 0 6Z" fill-rule="nonzero"/><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/></g></svg>',
|
||||
'list-bull-default': '<svg width="48" height="48"><g fill-rule="evenodd"><circle cx="11" cy="14" r="3"/><circle cx="11" cy="24" r="3"/><circle cx="11" cy="34" r="3"/><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/></g></svg>',
|
||||
'list-bull-square': '<svg width="48" height="48"><g fill-rule="evenodd"><path d="M8 11h6v6H8zM8 21h6v6H8zM8 31h6v6H8z"/><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/></g></svg>',
|
||||
'list-num-default-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M37.4 17v-4.8h-.1l-1.5 1v-1.1l1.6-1.1h1.2v6zM33.3 17.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7zm1.7 5.7c0-1.2 1-2 2.2-2 1.3 0 2.1.8 2.1 1.8 0 .7-.3 1.2-1.3 2.2l-1.2 1v.2h2.6v1h-4.3v-.9l2-1.9c.8-.8 1-1.1 1-1.5 0-.5-.4-.8-1-.8-.5 0-.9.3-.9.9H35zm-1.7 4.3c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7zm3.2 7.3v-1h.7c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7s-1 .3-1 .8H35c0-1.1 1-1.8 2.2-1.8 1.2 0 2.1.6 2.1 1.6 0 .7-.4 1.2-1 1.3v.1c.7.1 1.3.7 1.3 1.4 0 1-1 1.9-2.4 1.9-1.3 0-2.2-.8-2.3-2h1.2c0 .6.5 1 1.1 1 .6 0 1-.4 1-1 0-.5-.3-.8-1-.8h-.7zm-3.3 2.7c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7z"/></g></svg>',
|
||||
'list-num-default': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10 17v-4.8l-1.5 1v-1.1l1.6-1h1.2V17h-1.2Zm3.6.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm-5 5.7c0-1.2.8-2 2.1-2s2.1.8 2.1 1.8c0 .7-.3 1.2-1.4 2.2l-1.1 1v.2h2.6v1H8.6v-.9l2-1.9c.8-.8 1-1.1 1-1.5 0-.5-.4-.8-1-.8-.5 0-.9.3-.9.9H8.5Zm6.3 4.3c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM10 34.4v-1h.7c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7s-1 .3-1 .8H8.6c0-1.1 1-1.8 2.2-1.8 1.3 0 2.1.6 2.1 1.6 0 .7-.4 1.2-1 1.3v.1c.8.1 1.3.7 1.3 1.4 0 1-1 1.9-2.4 1.9-1.3 0-2.2-.8-2.3-2h1.2c0 .6.5 1 1.1 1 .7 0 1-.4 1-1 0-.5-.3-.8-1-.8h-.7Zm4.7 2.7c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7Z"/></g></svg>',
|
||||
'list-num-lower-alpha-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M36.5 16c-.9 0-1.5-.5-1.5-1.3s.6-1.3 1.8-1.4h1v-.4c0-.4-.2-.6-.7-.6-.4 0-.7.1-.8.4h-1.1c0-.8.8-1.4 2-1.4S39 12 39 13V16h-1.2v-.6c-.3.4-.8.7-1.4.7Zm.4-.8c.6 0 1-.4 1-.9V14h-1c-.5.1-.7.3-.7.6 0 .4.3.6.7.6ZM33.1 16.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7ZM37.7 26c-.7 0-1.2-.2-1.5-.7v.7H35v-6.3h1.2v2.5c.3-.5.8-.9 1.5-.9 1.1 0 1.8 1 1.8 2.4 0 1.5-.7 2.4-1.8 2.4Zm-.5-3.6c-.6 0-1 .5-1 1.3s.4 1.4 1 1.4c.7 0 1-.6 1-1.4 0-.8-.3-1.3-1-1.3ZM33.2 26.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7zm6 7h-1c-.1-.5-.4-.8-1-.8s-1 .5-1 1.4c0 1 .4 1.4 1 1.4.5 0 .9-.2 1-.7h1c0 1-.8 1.7-2 1.7-1.4 0-2.2-.9-2.2-2.4s.8-2.4 2.2-2.4c1.2 0 2 .7 2 1.7zm-6.1 3c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7z"/></g></svg>',
|
||||
'list-num-lower-alpha': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10.3 15.2c.5 0 1-.4 1-.9V14h-1c-.5.1-.8.3-.8.6 0 .4.3.6.8.6Zm-.4.9c-1 0-1.5-.6-1.5-1.4 0-.8.6-1.3 1.7-1.4h1.1v-.4c0-.4-.2-.6-.7-.6-.5 0-.8.1-.9.4h-1c0-.8.8-1.4 2-1.4 1.1 0 1.8.6 1.8 1.6V16h-1.1v-.6h-.1c-.2.4-.7.7-1.3.7Zm4.6 0c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-3.2 10c-.6 0-1.2-.3-1.4-.8v.7H8.5v-6.3H10v2.5c.3-.5.8-.9 1.4-.9 1.2 0 1.9 1 1.9 2.4 0 1.5-.7 2.4-1.9 2.4Zm-.4-3.7c-.7 0-1 .5-1 1.3s.3 1.4 1 1.4c.6 0 1-.6 1-1.4 0-.8-.4-1.3-1-1.3Zm4 3.7c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-2.2 7h-1.2c0-.5-.4-.8-.9-.8-.6 0-1 .5-1 1.4 0 1 .4 1.4 1 1.4.5 0 .8-.2 1-.7h1c0 1-.8 1.7-2 1.7-1.4 0-2.2-.9-2.2-2.4s.8-2.4 2.2-2.4c1.2 0 2 .7 2 1.7Zm1.8 3c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-greek-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M37.4 16c-1.2 0-2-.8-2-2.3 0-1.5.8-2.4 2-2.4.6 0 1 .4 1.3 1v-.9H40v3.2c0 .4.1.5.4.5h.2v.9h-.6c-.6 0-1-.2-1-.7h-.2c-.2.4-.7.8-1.3.8Zm.3-1c.6 0 1-.5 1-1.3s-.4-1.3-1-1.3-1 .5-1 1.3.4 1.4 1 1.4ZM33.3 16.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM36 21.9c0-1.5.8-2.3 2.1-2.3 1.2 0 2 .6 2 1.6 0 .6-.3 1-.9 1.3.9.3 1.3.8 1.3 1.7 0 1.2-.7 1.9-1.8 1.9-.6 0-1.1-.3-1.4-.8v2.2H36V22Zm1.8 1.2v-1h.3c.5 0 .9-.2.9-.7 0-.5-.3-.8-.9-.8-.5 0-.8.3-.8 1v2.2c0 .8.4 1.3 1 1.3s1-.4 1-1-.4-1-1.2-1h-.3ZM33.3 26.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM37.1 34.6 34.8 30h1.4l1.7 3.5 1.7-3.5h1.1l-2.2 4.6v.1c.5.8.7 1.4.7 1.8 0 .4-.2.8-.4 1-.2.2-.6.3-1 .3-.9 0-1.3-.4-1.3-1.2 0-.5.2-1 .5-1.7l.1-.2Zm.7 1a2 2 0 0 0-.4.9c0 .3.1.4.4.4.3 0 .4-.1.4-.4 0-.2-.1-.6-.4-1ZM33.3 36.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-greek': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10.5 15c.7 0 1-.5 1-1.3s-.3-1.3-1-1.3c-.5 0-.9.5-.9 1.3s.4 1.4 1 1.4Zm-.3 1c-1.1 0-1.8-.8-1.8-2.3 0-1.5.7-2.4 1.8-2.4.7 0 1.1.4 1.3 1h.1v-.9h1.2v3.2c0 .4.1.5.4.5h.2v.9h-.6c-.6 0-1-.2-1.1-.7h-.1c-.2.4-.7.8-1.4.8Zm5 .1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7Zm-4.9 7v-1h.3c.6 0 1-.2 1-.7 0-.5-.4-.8-1-.8-.5 0-.8.3-.8 1v2.2c0 .8.4 1.3 1.1 1.3.6 0 1-.4 1-1s-.5-1-1.3-1h-.3ZM8.6 22c0-1.5.7-2.3 2-2.3 1.2 0 2 .6 2 1.6 0 .6-.3 1-.8 1.3.8.3 1.3.8 1.3 1.7 0 1.2-.8 1.9-1.9 1.9-.6 0-1.1-.3-1.3-.8v2.2H8.5V22Zm6.2 4.2c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm-4.5 8.5L8 30h1.4l1.7 3.5 1.7-3.5h1.1l-2.2 4.6v.1c.5.8.7 1.4.7 1.8 0 .4-.1.8-.4 1-.2.2-.6.3-1 .3-.9 0-1.3-.4-1.3-1.2 0-.5.2-1 .5-1.7l.1-.2Zm.7 1a2 2 0 0 0-.4.9c0 .3.1.4.4.4.3 0 .4-.1.4-.4 0-.2-.1-.6-.4-1Zm4.5.5c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-roman-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M32.9 16v-1.2h-1.3V16H33Zm0 10v-1.2h-1.3V26H33Zm0 10v-1.2h-1.3V36H33Z"/><path fill-rule="nonzero" d="M36 21h-1.5v5H36zM36 31h-1.5v5H36zM39 21h-1.5v5H39zM39 31h-1.5v5H39zM42 31h-1.5v5H42zM36 11h-1.5v5H36zM36 19h-1.5v1H36zM36 29h-1.5v1H36zM39 19h-1.5v1H39zM39 29h-1.5v1H39zM42 29h-1.5v1H42zM36 9h-1.5v1H36z"/></g></svg>',
|
||||
'list-num-lower-roman': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M15.1 16v-1.2h1.3V16H15Zm0 10v-1.2h1.3V26H15Zm0 10v-1.2h1.3V36H15Z"/><path fill-rule="nonzero" d="M12 21h1.5v5H12zM12 31h1.5v5H12zM9 21h1.5v5H9zM9 31h1.5v5H9zM6 31h1.5v5H6zM12 11h1.5v5H12zM12 19h1.5v1H12zM12 29h1.5v1H12zM9 19h1.5v1H9zM9 29h1.5v1H9zM6 29h1.5v1H6zM12 9h1.5v1H12z"/></g></svg>',
|
||||
'list-num-upper-alpha-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="m39.3 17-.5-1.4h-2l-.5 1.4H35l2-6h1.6l2 6h-1.3Zm-1.6-4.7-.7 2.3h1.6l-.8-2.3ZM33.4 17c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm4.7 9.9h-2.7v-6H38c1.2 0 1.9.6 1.9 1.5 0 .6-.5 1.2-1 1.3.7.1 1.3.7 1.3 1.5 0 1-.8 1.7-2 1.7Zm-1.4-5v1.5h1c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7h-1Zm0 4h1.1c.7 0 1.1-.3 1.1-.8 0-.6-.4-.9-1.1-.9h-1.1V26ZM33 27.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm4.9 10c-1.8 0-2.8-1.1-2.8-3.1s1-3.1 2.8-3.1c1.4 0 2.5.9 2.6 2.2h-1.3c0-.7-.6-1.1-1.3-1.1-1 0-1.6.7-1.6 2s.6 2 1.6 2c.7 0 1.2-.4 1.4-1h1.2c-.1 1.3-1.2 2.2-2.6 2.2Zm-4.5 0c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-upper-alpha': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="m12.6 17-.5-1.4h-2L9.5 17H8.3l2-6H12l2 6h-1.3ZM11 12.3l-.7 2.3h1.6l-.8-2.3Zm4.7 4.8c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7ZM11.4 27H8.7v-6h2.6c1.2 0 1.9.6 1.9 1.5 0 .6-.5 1.2-1 1.3.7.1 1.3.7 1.3 1.5 0 1-.8 1.7-2 1.7ZM10 22v1.5h1c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7h-1Zm0 4H11c.7 0 1.1-.3 1.1-.8 0-.6-.4-.9-1.1-.9H10V26Zm5.4 1.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-4.1 10c-1.8 0-2.8-1.1-2.8-3.1s1-3.1 2.8-3.1c1.4 0 2.5.9 2.6 2.2h-1.3c0-.7-.6-1.1-1.3-1.1-1 0-1.6.7-1.6 2s.6 2 1.6 2c.7 0 1.2-.4 1.4-1h1.2c-.1 1.3-1.2 2.2-2.6 2.2Zm4.5 0c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-upper-roman-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M31.6 17v-1.2H33V17h-1.3Zm0 10v-1.2H33V27h-1.3Zm0 10v-1.2H33V37h-1.3Z"/><path fill-rule="nonzero" d="M34.5 20H36v7h-1.5zM34.5 30H36v7h-1.5zM37.5 20H39v7h-1.5zM37.5 30H39v7h-1.5zM40.5 30H42v7h-1.5zM34.5 10H36v7h-1.5z"/></g></svg>',
|
||||
'list-num-upper-roman': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M15.1 17v-1.2h1.3V17H15Zm0 10v-1.2h1.3V27H15Zm0 10v-1.2h1.3V37H15Z"/><path fill-rule="nonzero" d="M12 20h1.5v7H12zM12 30h1.5v7H12zM9 20h1.5v7H9zM9 30h1.5v7H9zM6 30h1.5v7H6zM12 10h1.5v7H12z"/></g></svg>',
|
||||
'lock': '<svg width="24" height="24"><path d="M16.3 11c.2 0 .3 0 .5.2l.2.6v7.4c0 .3 0 .4-.2.6l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6v-7.4c0-.3 0-.4.2-.6l.5-.2H8V8c0-.8.3-1.5.9-2.1.6-.6 1.3-.9 2.1-.9h2c.8 0 1.5.3 2.1.9.6.6.9 1.3.9 2.1v3h.3ZM10 8v3h4V8a1 1 0 0 0-.3-.7A1 1 0 0 0 13 7h-2a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7Z" fill-rule="evenodd"/></svg>',
|
||||
'ltr': '<svg width="24" height="24"><path d="M11 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 7.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L11 5ZM4.4 16.2 6.2 15l-1.8-1.2a1 1 0 0 1 1.2-1.6l3 2a1 1 0 0 1 0 1.6l-3 2a1 1 0 1 1-1.2-1.6Z" fill-rule="evenodd"/></svg>',
|
||||
'minus': '<svg width="24" height="24"><path d="M19 11a1 1 0 0 1 .1 2H5a1 1 0 0 1-.1-2H19Z"/></svg>',
|
||||
'more-drawer': '<svg width="24" height="24"><path d="M6 10a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm12 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm-6 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Z" fill-rule="nonzero"/></svg>',
|
||||
'new-document': '<svg width="24" height="24"><path d="M14.4 3H7a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V7.6L14.4 3ZM17 19H7V5h6v4h4v10Z" fill-rule="nonzero"/></svg>',
|
||||
'new-tab': '<svg width="24" height="24"><path d="m15 13 2-2v8H5V7h8l-2 2H7v8h8v-4Zm4-8v5.5l-2-2-5.6 5.5H10v-1.4L15.5 7l-2-2H19Z" fill-rule="evenodd"/></svg>',
|
||||
'non-breaking': '<svg width="24" height="24"><path d="M11 11H8a1 1 0 1 1 0-2h3V6c0-.6.4-1 1-1s1 .4 1 1v3h3c.6 0 1 .4 1 1s-.4 1-1 1h-3v3c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-3Zm10 4v5H3v-5c0-.6.4-1 1-1s1 .4 1 1v3h14v-3c0-.6.4-1 1-1s1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'notice': '<svg width="24" height="24"><path d="M15.5 4 20 8.5v7L15.5 20h-7L4 15.5v-7L8.5 4h7ZM13 17v-2h-2v2h2Zm0-4V7h-2v6h2Z" fill-rule="evenodd" clip-rule="evenodd"/></svg>',
|
||||
'ordered-list-rtl': '<svg width="24" height="24"><path d="M6 17h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2Zm13-1v3.5a.5.5 0 1 1-1 0V5h-.5a.5.5 0 1 1 0-1H19Zm-1 8.8.2.2h1.3a.5.5 0 1 1 0 1h-1.6a1 1 0 0 1-.9-1V13c0-.4.3-.8.6-1l1.2-.4.2-.3a.2.2 0 0 0-.2-.2h-1.3a.5.5 0 0 1-.5-.5c0-.3.2-.5.5-.5h1.6c.5 0 .9.4.9 1v.1c0 .4-.3.8-.6 1l-1.2.4-.2.3Zm2 4.2v2c0 .6-.4 1-1 1h-1.5a.5.5 0 0 1 0-1h1.2a.3.3 0 1 0 0-.6h-1.3a.4.4 0 1 1 0-.8h1.3a.3.3 0 0 0 0-.6h-1.2a.5.5 0 1 1 0-1H19c.6 0 1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'ordered-list': '<svg width="24" height="24"><path d="M10 17h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 1 1 0-2ZM6 4v3.5c0 .3-.2.5-.5.5a.5.5 0 0 1-.5-.5V5h-.5a.5.5 0 0 1 0-1H6Zm-1 8.8.2.2h1.3c.3 0 .5.2.5.5s-.2.5-.5.5H4.9a1 1 0 0 1-.9-1V13c0-.4.3-.8.6-1l1.2-.4.2-.3a.2.2 0 0 0-.2-.2H4.5a.5.5 0 0 1-.5-.5c0-.3.2-.5.5-.5h1.6c.5 0 .9.4.9 1v.1c0 .4-.3.8-.6 1l-1.2.4-.2.3ZM7 17v2c0 .6-.4 1-1 1H4.5a.5.5 0 0 1 0-1h1.2c.2 0 .3-.1.3-.3 0-.2-.1-.3-.3-.3H4.4a.4.4 0 1 1 0-.8h1.3c.2 0 .3-.1.3-.3 0-.2-.1-.3-.3-.3H4.5a.5.5 0 1 1 0-1H6c.6 0 1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'orientation': '<svg width="24" height="24"><path d="M7.3 6.4 1 13l6.4 6.5 6.5-6.5-6.5-6.5ZM3.7 13l3.6-3.7L11 13l-3.7 3.7-3.6-3.7ZM12 6l2.8 2.7c.3.3.3.8 0 1-.3.4-.9.4-1.2 0L9.2 5.7a.8.8 0 0 1 0-1.2L13.6.2c.3-.3.9-.3 1.2 0 .3.3.3.8 0 1.1L12 4h1a9 9 0 1 1-4.3 16.9l1.5-1.5A7 7 0 1 0 13 6h-1Z" fill-rule="nonzero"/></svg>',
|
||||
'outdent': '<svg width="24" height="24"><path d="M7 5h12c.6 0 1 .4 1 1s-.4 1-1 1H7a1 1 0 1 1 0-2Zm5 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm0 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm-5 4h12a1 1 0 0 1 0 2H7a1 1 0 0 1 0-2Zm1.6-3.8a1 1 0 0 1-1.2 1.6l-3-2a1 1 0 0 1 0-1.6l3-2a1 1 0 0 1 1.2 1.6L6.8 12l1.8 1.2Z" fill-rule="evenodd"/></svg>',
|
||||
'page-break': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M5 11c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h1c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm4 0c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h1c.6 0 1 .4 1 1s-.4 1-1 1h-1a1 1 0 0 1 0-2Zm4 0c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2ZM7 3v5h10V3c0-.6.4-1 1-1s1 .4 1 1v7H5V3c0-.6.4-1 1-1s1 .4 1 1ZM6 22a1 1 0 0 1-1-1v-7h14v7c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-5H7v5c0 .6-.4 1-1 1Z"/></g></svg>',
|
||||
'paragraph': '<svg width="24" height="24"><path fill-rule="evenodd" d="M10 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 6.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L10 5Z"/></svg>',
|
||||
'paste-column-after': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V7h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h7v2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm8 7v12h-6V8h6Zm-1.5 1.5h-3v9h3v-9ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-column-before': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V18c0 1-.8 2-1.9 2H11v-2h7V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v2H4V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm-2 7v12H4V8h6ZM8.5 9.5h-3v9h3v-9ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-row-after': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V11h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h14c0 1-.8 2-1.9 2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm10 11v5H8v-5h14Zm-1.5 1.5h-11v2h11v-2ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-row-before': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V7h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h12v-4h2v4c0 1-.8 2-1.9 2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm10 7v5H8V8h14Zm-1.5 1.5h-11v2h11v-2ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-text': '<svg width="24" height="24"><path d="M18 9V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h3V9h9ZM9 20H6a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.2A3 3 0 0 1 12 1a3 3 0 0 1 2.8 2H18a2 2 0 0 1 2 2v4h1v12H9v-1Zm1.5-9.5v9h9v-9h-9ZM12 3a1 1 0 0 0-1 1c0 .5.4 1 1 1s1-.5 1-1-.4-1-1-1Zm0 9h6v2h-.5l-.5-1h-1v4h.8v1h-3.6v-1h.8v-4h-1l-.5 1H12v-2Z" fill-rule="nonzero"/></svg>',
|
||||
'paste': '<svg width="24" height="24"><path d="M18 9V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h3V9h9ZM9 20H6a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.2A3 3 0 0 1 12 1a3 3 0 0 1 2.8 2H18a2 2 0 0 1 2 2v4h1v12H9v-1Zm1.5-9.5v9h9v-9h-9ZM12 3a1 1 0 0 0-1 1c0 .5.4 1 1 1s1-.5 1-1-.4-1-1-1Z" fill-rule="nonzero"/></svg>',
|
||||
'permanent-pen': '<svg width="24" height="24"><path d="M10.5 17.5 8 20H3v-3l3.5-3.5a2 2 0 0 1 0-3L14 3l1 1-7.3 7.3a1 1 0 0 0 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0L20 9l1 1-7.6 7.6a2 2 0 0 1-2.8 0l-.1-.1Z" fill-rule="nonzero"/></svg>',
|
||||
'plus': '<svg width="24" height="24"><path d="M12 4c.5 0 1 .4 1 .9V11h6a1 1 0 0 1 .1 2H13v6a1 1 0 0 1-2 .1V13H5a1 1 0 0 1-.1-2H11V5c0-.6.4-1 1-1Z"/></svg>',
|
||||
'preferences': '<svg width="24" height="24"><path d="m20.1 13.5-1.9.2a5.8 5.8 0 0 1-.6 1.5l1.2 1.5c.4.4.3 1 0 1.4l-.7.7a1 1 0 0 1-1.4 0l-1.5-1.2a6.2 6.2 0 0 1-1.5.6l-.2 1.9c0 .5-.5.9-1 .9h-1a1 1 0 0 1-1-.9l-.2-1.9a5.8 5.8 0 0 1-1.5-.6l-1.5 1.2a1 1 0 0 1-1.4 0l-.7-.7a1 1 0 0 1 0-1.4l1.2-1.5a6.2 6.2 0 0 1-.6-1.5l-1.9-.2a1 1 0 0 1-.9-1v-1c0-.5.4-1 .9-1l1.9-.2a5.8 5.8 0 0 1 .6-1.5L5.2 7.3a1 1 0 0 1 0-1.4l.7-.7a1 1 0 0 1 1.4 0l1.5 1.2a6.2 6.2 0 0 1 1.5-.6l.2-1.9c0-.5.5-.9 1-.9h1c.5 0 1 .4 1 .9l.2 1.9a5.8 5.8 0 0 1 1.5.6l1.5-1.2a1 1 0 0 1 1.4 0l.7.7c.3.4.4 1 0 1.4l-1.2 1.5a6.2 6.2 0 0 1 .6 1.5l1.9.2c.5 0 .9.5.9 1v1c0 .5-.4 1-.9 1ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" fill-rule="evenodd"/></svg>',
|
||||
'preview': '<svg width="24" height="24"><path d="M3.5 12.5c.5.8 1.1 1.6 1.8 2.3 2 2 4.2 3.2 6.7 3.2s4.7-1.2 6.7-3.2a16.2 16.2 0 0 0 2.1-2.8 15.7 15.7 0 0 0-2.1-2.8c-2-2-4.2-3.2-6.7-3.2a9.3 9.3 0 0 0-6.7 3.2A16.2 16.2 0 0 0 3.2 12c0 .2.2.3.3.5Zm-2.4-1 .7-1.2L4 7.8C6.2 5.4 8.9 4 12 4c3 0 5.8 1.4 8.1 3.8a18.2 18.2 0 0 1 2.8 3.7v1l-.7 1.2-2.1 2.5c-2.3 2.4-5 3.8-8.1 3.8-3 0-5.8-1.4-8.1-3.8a18.2 18.2 0 0 1-2.8-3.7 1 1 0 0 1 0-1Zm12-3.3a2 2 0 1 0 2.7 2.6 4 4 0 1 1-2.6-2.6Z" fill-rule="nonzero"/></svg>',
|
||||
'print': '<svg width="24" height="24"><path d="M18 8H6a3 3 0 0 0-3 3v6h2v3h14v-3h2v-6a3 3 0 0 0-3-3Zm-1 10H7v-4h10v4Zm.5-5c-.8 0-1.5-.7-1.5-1.5s.7-1.5 1.5-1.5 1.5.7 1.5 1.5-.7 1.5-1.5 1.5Zm.5-8H6v2h12V5Z" fill-rule="nonzero"/></svg>',
|
||||
'quote': '<svg width="24" height="24"><path d="M7.5 17h.9c.4 0 .7-.2.9-.6L11 13V8c0-.6-.4-1-1-1H6a1 1 0 0 0-1 1v4c0 .6.4 1 1 1h2l-1.3 2.7a1 1 0 0 0 .8 1.3Zm8 0h.9c.4 0 .7-.2.9-.6L19 13V8c0-.6-.4-1-1-1h-4a1 1 0 0 0-1 1v4c0 .6.4 1 1 1h2l-1.3 2.7a1 1 0 0 0 .8 1.3Z" fill-rule="nonzero"/></svg>',
|
||||
'redo': '<svg width="24" height="24"><path d="M17.6 10H12c-2.8 0-4.4 1.4-4.9 3.5-.4 2 .3 4 1.4 4.6a1 1 0 1 1-1 1.8c-2-1.2-2.9-4.1-2.3-6.8.6-3 3-5.1 6.8-5.1h5.6l-3.3-3.3a1 1 0 1 1 1.4-1.4l5 5a1 1 0 0 1 0 1.4l-5 5a1 1 0 0 1-1.4-1.4l3.3-3.3Z" fill-rule="nonzero"/></svg>',
|
||||
'reload': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="m5 22.1-1.2-4.7v-.2a1 1 0 0 1 1-1l5 .4a1 1 0 1 1-.2 2l-2.2-.2a7.8 7.8 0 0 0 8.4.2 7.5 7.5 0 0 0 3.5-6.4 1 1 0 1 1 2 0 9.5 9.5 0 0 1-4.5 8 9.9 9.9 0 0 1-10.2 0l.4 1.4a1 1 0 1 1-2 .5ZM13.6 7.4c0-.5.5-1 1-.9l2.8.2a8 8 0 0 0-9.5-1 7.5 7.5 0 0 0-3.6 7 1 1 0 0 1-2 0 9.5 9.5 0 0 1 4.5-8.6 10 10 0 0 1 10.9.3l-.3-1a1 1 0 0 1 2-.5l1.1 4.8a1 1 0 0 1-1 1.2l-5-.4a1 1 0 0 1-.9-1Z"/></g></svg>',
|
||||
'remove-formatting': '<svg width="24" height="24"><path d="M13.2 6a1 1 0 0 1 0 .2l-2.6 10a1 1 0 0 1-1 .8h-.2a.8.8 0 0 1-.8-1l2.6-10H8a1 1 0 1 1 0-2h9a1 1 0 0 1 0 2h-3.8ZM5 18h7a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Zm13 1.5L16.5 18 15 19.5a.7.7 0 0 1-1-1l1.5-1.5-1.5-1.5a.7.7 0 0 1 1-1l1.5 1.5 1.5-1.5a.7.7 0 0 1 1 1L17.5 17l1.5 1.5a.7.7 0 0 1-1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'remove': '<svg width="24" height="24"><path d="M16 7h3a1 1 0 0 1 0 2h-1v9a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V9H5a1 1 0 1 1 0-2h3V6a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3v1Zm-2 0V6c0-.6-.4-1-1-1h-2a1 1 0 0 0-1 1v1h4Zm2 2H8v9c0 .6.4 1 1 1h6c.6 0 1-.4 1-1V9Zm-7 3a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0v-4Zm4 0a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0v-4Z" fill-rule="nonzero"/></svg>',
|
||||
'resize-handle': '<svg width="10" height="10"><g fill-rule="nonzero"><path d="M8.1 1.1A.5.5 0 1 1 9 2l-7 7A.5.5 0 1 1 1 8l7-7ZM8.1 5.1A.5.5 0 1 1 9 6l-3 3A.5.5 0 1 1 5 8l3-3Z"/></g></svg>',
|
||||
'resize': '<svg width="24" height="24"><path d="M4 5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h6c.3 0 .5.1.7.3.2.2.3.4.3.7 0 .3-.1.5-.3.7a1 1 0 0 1-.7.3H7.4L18 16.6V13c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3.3 0 .5.1.7.3.2.2.3.4.3.7v6c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3h-6a1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h3.6L6 7.4V11c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3 1 1 0 0 1-.7-.3A1 1 0 0 1 4 11V5Z" fill-rule="evenodd"/></svg>',
|
||||
'restore-draft': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M17 13c0 .6-.4 1-1 1h-4V8c0-.6.4-1 1-1s1 .4 1 1v4h2c.6 0 1 .4 1 1Z"/><path d="M4.7 10H9a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1V5a1 1 0 1 1 2 0v3l2.5-2.4a9.2 9.2 0 0 1 10.8-1.5A9 9 0 0 1 13.4 21c-2.4.1-4.7-.7-6.5-2.2a1 1 0 1 1 1.3-1.5 7.2 7.2 0 0 0 11.6-3.7 7 7 0 0 0-3.5-7.7A7.2 7.2 0 0 0 8 7L4.7 10Z" fill-rule="nonzero"/></g></svg>',
|
||||
'rotate-left': '<svg width="24" height="24"><path d="M4.7 10H9a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1V5a1 1 0 1 1 2 0v3l2.5-2.4a9.2 9.2 0 0 1 10.8-1.5A9 9 0 0 1 13.4 21c-2.4.1-4.7-.7-6.5-2.2a1 1 0 1 1 1.3-1.5 7.2 7.2 0 0 0 11.6-3.7 7 7 0 0 0-3.5-7.7A7.2 7.2 0 0 0 8 7L4.7 10Z" fill-rule="nonzero"/></svg>',
|
||||
'rotate-right': '<svg width="24" height="24"><path d="M20 8V5a1 1 0 0 1 2 0v6c0 .6-.4 1-1 1h-6a1 1 0 0 1 0-2h4.3L16 7A7.2 7.2 0 0 0 7.7 6a7 7 0 0 0 3 13.1c1.9.1 3.7-.5 5-1.7a1 1 0 0 1 1.4 1.5A9.2 9.2 0 0 1 2.2 14c-.9-3.9 1-8 4.5-9.9 3.5-1.9 8-1.3 10.8 1.5L20 8Z" fill-rule="nonzero"/></svg>',
|
||||
'rtl': '<svg width="24" height="24"><path d="M8 5h8v2h-2v12h-2V7h-2v12H8v-7c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 4.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L8 5Zm12 11.2a1 1 0 1 1-1 1.6l-3-2a1 1 0 0 1 0-1.6l3-2a1 1 0 1 1 1 1.6L18.4 15l1.8 1.2Z" fill-rule="evenodd"/></svg>',
|
||||
'save': '<svg width="24" height="24"><path d="M5 16h14a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2Zm0 2v2h14v-2H5Zm10 0h2v2h-2v-2Zm-4-6.4L8.7 9.3a1 1 0 1 0-1.4 1.4l4 4c.4.4 1 .4 1.4 0l4-4a1 1 0 1 0-1.4-1.4L13 11.6V4a1 1 0 0 0-2 0v7.6Z" fill-rule="nonzero"/></svg>',
|
||||
'search': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Z" fill-rule="nonzero"/></svg>',
|
||||
'select-all': '<svg width="24" height="24"><path d="M3 5h2V3a2 2 0 0 0-2 2Zm0 8h2v-2H3v2Zm4 8h2v-2H7v2ZM3 9h2V7H3v2Zm10-6h-2v2h2V3Zm6 0v2h2a2 2 0 0 0-2-2ZM5 21v-2H3c0 1.1.9 2 2 2Zm-2-4h2v-2H3v2ZM9 3H7v2h2V3Zm2 18h2v-2h-2v2Zm8-8h2v-2h-2v2Zm0 8a2 2 0 0 0 2-2h-2v2Zm0-12h2V7h-2v2Zm0 8h2v-2h-2v2Zm-4 4h2v-2h-2v2Zm0-16h2V3h-2v2ZM7 17h10V7H7v10Zm2-8h6v6H9V9Z" fill-rule="nonzero"/></svg>',
|
||||
'selected': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2Zm3.6 10.9L7 12.3a.7.7 0 0 0-1 1L9.6 17 18 8.6a.7.7 0 0 0 0-1 .7.7 0 0 0-1 0l-7.4 7.3Z"/></svg>',
|
||||
'send': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="m13.3 22 7-18.3-18.3 7L9 15l4.3 7ZM18 6.8l-.7-.7L9.4 14l.7.7L18 6.8Z"/></svg>',
|
||||
'settings': '<svg width="24" height="24"><path d="M11 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8v.3c0 .2 0 .3-.2.5l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V8H5a1 1 0 1 1 0-2h2v-.3c0-.2 0-.3.2-.5l.5-.2h2.5c.3 0 .4 0 .6.2l.2.5V6ZM8 8h2V6H8v2Zm9 2.8v.2h2c.6 0 1 .4 1 1s-.4 1-1 1h-2v.3c0 .2 0 .3-.2.5l-.6.2h-2.4c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V13H5a1 1 0 0 1 0-2h8v-.3c0-.2 0-.3.2-.5l.6-.2h2.4c.3 0 .4 0 .6.2l.2.6ZM14 13h2v-2h-2v2Zm-3 2.8v.2h8c.6 0 1 .4 1 1s-.4 1-1 1h-8v.3c0 .2 0 .3-.2.5l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V18H5a1 1 0 0 1 0-2h2v-.3c0-.2 0-.3.2-.5l.5-.2h2.5c.3 0 .4 0 .6.2l.2.6ZM8 18h2v-2H8v2Z" fill-rule="evenodd"/></svg>',
|
||||
'sharpen': '<svg width="24" height="24"><path d="m16 6 4 4-8 9-8-9 4-4h8Zm-4 10.2 5.5-6.2-.1-.1H12v-.3h5.1l-.2-.2H12V9h4.6l-.2-.2H12v-.3h4.1l-.2-.2H12V8h3.6l-.2-.2H8.7L6.5 10l.1.1H12v.3H6.9l.2.2H12v.3H7.3l.2.2H12v.3H7.7l.3.2h4v.3H8.2l.2.2H12v.3H8.6l.3.2H12v.3H9l.3.2H12v.3H9.5l.2.2H12v.3h-2l.2.2H12v.3h-1.6l.2.2H12v.3h-1.1l.2.2h.9v.3h-.7l.2.2h.5v.3h-.3l.3.2Z" fill-rule="evenodd"/></svg>',
|
||||
'sourcecode': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M9.8 15.7c.3.3.3.8 0 1-.3.4-.9.4-1.2 0l-4.4-4.1a.8.8 0 0 1 0-1.2l4.4-4.2c.3-.3.9-.3 1.2 0 .3.3.3.8 0 1.1L6 12l3.8 3.7ZM14.2 15.7c-.3.3-.3.8 0 1 .4.4.9.4 1.2 0l4.4-4.1c.3-.3.3-.9 0-1.2l-4.4-4.2a.8.8 0 0 0-1.2 0c-.3.3-.3.8 0 1.1L18 12l-3.8 3.7Z"/></g></svg>',
|
||||
'spell-check': '<svg width="24" height="24"><path d="M6 8v3H5V5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h2c.3 0 .5.1.7.3.2.2.3.4.3.7v6H8V8H6Zm0-3v2h2V5H6Zm13 0h-3v5h3v1h-3a1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7V5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h3v1Zm-5 1.5-.1.7c-.1.2-.3.3-.6.3.3 0 .5.1.6.3l.1.7V10c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3h-3V4h3c.3 0 .5.1.7.3.2.2.3.4.3.7v1.5ZM13 10V8h-2v2h2Zm0-3V5h-2v2h2Zm3 5 1 1-6.5 7L7 15.5l1.3-1 2.2 2.2L16 12Z" fill-rule="evenodd"/></svg>',
|
||||
'strike-through': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M15.6 8.5c-.5-.7-1-1.1-1.3-1.3-.6-.4-1.3-.6-2-.6-2.7 0-2.8 1.7-2.8 2.1 0 1.6 1.8 2 3.2 2.3 4.4.9 4.6 2.8 4.6 3.9 0 1.4-.7 4.1-5 4.1A6.2 6.2 0 0 1 7 16.4l1.5-1.1c.4.6 1.6 2 3.7 2 1.6 0 2.5-.4 3-1.2.4-.8.3-2-.8-2.6-.7-.4-1.6-.7-2.9-1-1-.2-3.9-.8-3.9-3.6C7.6 6 10.3 5 12.4 5c2.9 0 4.2 1.6 4.7 2.4l-1.5 1.1Z"/><path d="M5 11h14a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z" fill-rule="nonzero"/></g></svg>',
|
||||
'subscript': '<svg width="24" height="24"><path d="m10.4 10 4.6 4.6-1.4 1.4L9 11.4 4.4 16 3 14.6 7.6 10 3 5.4 4.4 4 9 8.6 13.6 4 15 5.4 10.4 10ZM21 19h-5v-1l1-.8 1.7-1.6c.3-.4.5-.8.5-1.2 0-.3 0-.6-.2-.7-.2-.2-.5-.3-.9-.3a2 2 0 0 0-.8.2l-.7.3-.4-1.1 1-.6 1.2-.2c.8 0 1.4.3 1.8.7.4.4.6.9.6 1.5s-.2 1.1-.5 1.6a8 8 0 0 1-1.3 1.3l-.6.6h2.6V19Z" fill-rule="nonzero"/></svg>',
|
||||
'superscript': '<svg width="24" height="24"><path d="M15 9.4 10.4 14l4.6 4.6-1.4 1.4L9 15.4 4.4 20 3 18.6 7.6 14 3 9.4 4.4 8 9 12.6 13.6 8 15 9.4Zm5.9 1.6h-5v-1l1-.8 1.7-1.6c.3-.5.5-.9.5-1.3 0-.3 0-.5-.2-.7-.2-.2-.5-.3-.9-.3l-.8.2-.7.4-.4-1.2c.2-.2.5-.4 1-.5.3-.2.8-.2 1.2-.2.8 0 1.4.2 1.8.6.4.4.6 1 .6 1.6 0 .5-.2 1-.5 1.5l-1.3 1.4-.6.5h2.6V11Z" fill-rule="nonzero"/></svg>',
|
||||
'table-caption': '<svg width="24" height="24"><g fill-rule="nonzero"><rect width="12" height="2" x="3" y="4" rx="1"/><path d="M19 8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-8c0-1.1.9-2 2-2h14ZM5 15v3h6v-3H5Zm14 0h-6v3h6v-3Zm0-5h-6v3h6v-3ZM5 13h6v-3H5v3Z"/></g></svg>',
|
||||
'table-cell-classes': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M13 4v9H3V6c0-1.1.9-2 2-2h8Zm-2 2H5v5h6V6Z"/><path fill-rule="nonzero" d="M13 4h6a2 2 0 0 1 2 2v7h-8v-2h6V6h-6V4Z" opacity=".2"/><path d="m18 20-2.6 1.6.7-3-2.4-2 3.1-.2 1.2-2.9 1.2 2.9 3.1.2-2.4 2 .7 3z"/><path fill-rule="nonzero" d="M3 13v5c0 1.1.9 2 2 2h8v-7h-2v5H5v-5H3Z" opacity=".2"/></g></svg>',
|
||||
'table-cell-properties': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9H5v5h6v-5Zm8 0h-6v5h6v-5Zm-8-7H5v5h6V6Z"/></svg>',
|
||||
'table-cell-select-all': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v12h14V6Z"/><path d="M13 6v5h6v2h-6v5h-2v-5H5v-2h6V6h2Z" opacity=".2"/></g></svg>',
|
||||
'table-cell-select-inner': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v12h14V6Z" opacity=".2"/><path d="M13 6v5h6v2h-6v5h-2v-5H5v-2h6V6h2Z"/></g></svg>',
|
||||
'table-classes': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v7h-8v7H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9H5v5h6v-5Zm8-7h-6v5h6V6Zm-8 0H5v5h6V6Z"/><path d="m18 20-2.6 1.6.7-3-2.4-2 3.1-.2 1.2-2.9 1.2 2.9 3.1.2-2.4 2 .7 3z"/></g></svg>',
|
||||
'table-delete-column': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-4 4h-2V6h-2v2H9V6H5v12h4v-2h2v2h2v-2h2v2h4V6h-4v2Zm.3.5 1 1.2-3 2.3 3 2.3-1 1.2L12 13l-3.3 2.6-1-1.2 3-2.3-3-2.3 1-1.2L12 11l3.3-2.5Z"/></svg>',
|
||||
'table-delete-row': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v3h2.5v2H5v2h2.5v2H5v3h14v-3h-2.5v-2H19v-2h-2.5V9H19V6Zm-4.7 1.8 1.2 1L13 12l2.6 3.3-1.2 1-2.3-3-2.3 3-1.2-1L11 12 8.5 8.7l1.2-1 2.3 3 2.3-3Z"/></svg>',
|
||||
'table-delete-table': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 6v12h14V6H5Z"/><path d="m14.4 8.6 1.1 1-2.4 2.4 2.4 2.4-1.1 1.1-2.4-2.4-2.4 2.4-1-1.1 2.3-2.4-2.3-2.4 1-1 2.4 2.3z"/></g></svg>',
|
||||
'table-insert-column-after': '<svg width="24" height="24"><path fill-rule="nonzero" d="M20 4c.6 0 1 .4 1 1v2a1 1 0 0 1-2 0V6h-8v12h8v-1a1 1 0 0 1 2 0v2c0 .5-.4 1-.9 1H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h15ZM9 13H5v5h4v-5Zm7-5c.5 0 1 .4 1 .9V11h2a1 1 0 0 1 .1 2H17v2a1 1 0 0 1-2 .1V13h-2a1 1 0 0 1-.1-2H15V9c0-.6.4-1 1-1ZM9 6H5v5h4V6Z"/></svg>',
|
||||
'table-insert-column-before': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a1 1 0 0 1-1-1v-2a1 1 0 0 1 2 0v1h8V6H5v1a1 1 0 1 1-2 0V5c0-.6.4-1 1-1h15Zm0 9h-4v5h4v-5ZM8 8c.5 0 1 .4 1 .9V11h2a1 1 0 0 1 .1 2H9v2a1 1 0 0 1-2 .1V13H5a1 1 0 0 1-.1-2H7V9c0-.6.4-1 1-1Zm11-2h-4v5h4V6Z"/></svg>',
|
||||
'table-insert-row-above': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4a1 1 0 1 1 0 2H5v6h14V6h-1a1 1 0 0 1 0-2h2c.6 0 1 .4 1 1v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5c0-.6.4-1 1-1h2Zm5 10H5v4h6v-4Zm8 0h-6v4h6v-4ZM12 3c.5 0 1 .4 1 .9V6h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 .1V8H9a1 1 0 0 1 0-2h2V4c0-.6.4-1 1-1Z"/></svg>',
|
||||
'table-insert-row-after': '<svg width="24" height="24"><path fill-rule="nonzero" d="M12 13c.5 0 1 .4 1 .9V16h2a1 1 0 0 1 .1 2H13v2a1 1 0 0 1-2 .1V18H9a1 1 0 0 1-.1-2H11v-2c0-.6.4-1 1-1Zm6 7a1 1 0 0 1 0-2h1v-6H5v6h1a1 1 0 0 1 0 2H4a1 1 0 0 1-1-1V6c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v13c0 .5-.4 1-.9 1H18ZM11 6H5v4h6V6Zm8 0h-6v4h6V6Z"/></svg>',
|
||||
'table-left-header': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 9h-4v5h4v-5Zm-6 0H9v5h4v-5Zm0-7H9v5h4V6Zm6 0h-4v5h4V6Z"/></svg>',
|
||||
'table-merge-cells': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 15.5V18h3v-2.5H5Zm14-5h-9V18h9v-7.5ZM19 6h-4v2.5h4V6ZM8 6H5v2.5h3V6Zm5 0h-3v2.5h3V6Zm-8 7.5h3v-3H5v3Z"/></svg>',
|
||||
'table-row-numbering-rtl': '<svg width="24" height="24"><path d="M6 4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6Zm0 12h8v3H6v-3Zm11 0c.6 0 1 .4 1 1v1a1 1 0 0 1-2 0v-1c0-.6.4-1 1-1ZM6 11h8v3H6v-3Zm11 0c.6 0 1 .4 1 1v1a1 1 0 0 1-2 0v-1c0-.6.4-1 1-1ZM6 6h8v3H6V6Zm11 0c.6 0 1 .4 1 1v1a1 1 0 1 1-2 0V7c0-.6.4-1 1-1Z"/></svg>',
|
||||
'table-row-numbering': '<svg width="24" height="24"><path d="M18 4a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h12Zm0 12h-8v3h8v-3ZM7 16a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1c0-.6-.4-1-1-1Zm11-5h-8v3h8v-3ZM7 11a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1c0-.6-.4-1-1-1Zm11-5h-8v3h8V6ZM7 6a1 1 0 0 0-1 1v1a1 1 0 1 0 2 0V7c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'table-row-properties': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 15v3h6v-3H5Zm14 0h-6v3h6v-3Zm0-9h-6v3h6V6ZM5 9h6V6H5v3Z"/></svg>',
|
||||
'table-split-cells': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM8 15.5H5V18h3v-2.5Zm11-5h-9V18h9v-7.5Zm-2.5 1 1 1-2 2 2 2-1 1-2-2-2 2-1-1 2-2-2-2 1-1 2 2 2-2Zm-8.5-1H5v3h3v-3ZM19 6h-4v2.5h4V6ZM8 6H5v2.5h3V6Zm5 0h-3v2.5h3V6Z"/></svg>',
|
||||
'table-top-header': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 11H5v3h6v-3Zm8 0h-6v3h6v-3Zm0-5h-6v3h6v-3ZM5 13h6v-3H5v3Z"/></svg>',
|
||||
'table': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 14v4h6v-4H5Zm14 0h-6v4h6v-4Zm0-6h-6v4h6V8ZM5 12h6V8H5v4Z"/></svg>',
|
||||
'template-add': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 12v4H5a2 2 0 0 0-2 2v3h9.3a6 6 0 0 1-.3-2H5v-1h7a6 6 0 0 1 .8-2H11v-5l-.8-.6a3 3 0 1 1 3.6 0l-.8.6v4.7a6 6 0 0 1 2-1.9V12a5 5 0 1 0-6 0Z"/><path d="M18 15c.5 0 1 .4 1 .9V18h2a1 1 0 0 1 .1 2H19v2a1 1 0 0 1-2 .1V20h-2a1 1 0 0 1-.1-2H17v-2c0-.6.4-1 1-1Z"/></svg>',
|
||||
'template': '<svg width="24" height="24"><path d="M19 19v-1H5v1h14ZM9 16v-4a5 5 0 1 1 6 0v4h4a2 2 0 0 1 2 2v3H3v-3c0-1.1.9-2 2-2h4Zm4 0v-5l.8-.6a3 3 0 1 0-3.6 0l.8.6v5h2Z" fill-rule="nonzero"/></svg>',
|
||||
'temporary-placeholder': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M9 7.6V6h2.5V4.5a.5.5 0 1 1 1 0V6H15v1.6a8 8 0 1 1-6 0Zm-2.6 5.3a.5.5 0 0 0 .3.6c.3 0 .6 0 .6-.3l.1-.2a5 5 0 0 1 3.3-2.8c.3-.1.4-.4.4-.6-.1-.3-.4-.5-.6-.4a6 6 0 0 0-4.1 3.7Z"/><circle cx="14" cy="4" r="1"/><circle cx="12" cy="2" r="1"/><circle cx="10" cy="4" r="1"/></g></svg>',
|
||||
'text-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path class="tox-icon-text-color__color" d="M3 18h18v3H3z"/><path d="M8.7 16h-.8a.5.5 0 0 1-.5-.6l2.7-9c.1-.3.3-.4.5-.4h2.8c.2 0 .4.1.5.4l2.7 9a.5.5 0 0 1-.5.6h-.8a.5.5 0 0 1-.4-.4l-.7-2.2c0-.3-.3-.4-.5-.4h-3.4c-.2 0-.4.1-.5.4l-.7 2.2c0 .3-.2.4-.4.4Zm2.6-7.6-.6 2a.5.5 0 0 0 .5.6h1.6a.5.5 0 0 0 .5-.6l-.6-2c0-.3-.3-.4-.5-.4h-.4c-.2 0-.4.1-.5.4Z"/></g></svg>',
|
||||
'text-size-decrease': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H4a1 1 0 0 1 0-2h10ZM14 12a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2h-6Z"/></svg>',
|
||||
'text-size-increase': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H4a1 1 0 0 1 0-2h10ZM17 9a1 1 0 0 0-1 1v2h-2a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0-2h-2v-2c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'toc': '<svg width="24" height="24"><path d="M5 5c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 1 1 0-2Zm3 0h11c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm-3 8c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h11c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm0-4c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 1 1 0-2Zm3 0h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-3 8c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'translate': '<svg width="24" height="24"><path d="m12.7 14.3-.3.7-.4.7-2.2-2.2-3.1 3c-.3.4-.8.4-1 0a.7.7 0 0 1 0-1l3.1-3A12.4 12.4 0 0 1 6.7 9H8a10.1 10.1 0 0 0 1.7 2.4c.5-.5 1-1.1 1.4-1.8l.9-2H4.7a.7.7 0 1 1 0-1.5h4.4v-.7c0-.4.3-.8.7-.8.4 0 .7.4.7.8v.7H15c.4 0 .8.3.8.7 0 .4-.4.8-.8.8h-1.4a12.3 12.3 0 0 1-1 2.4 13.5 13.5 0 0 1-1.7 2.3l1.9 1.8Zm4.3-3 2.7 7.3a.5.5 0 0 1-.4.7 1 1 0 0 1-1-.7l-.6-1.5h-3.4l-.6 1.5a1 1 0 0 1-1 .7.5.5 0 0 1-.4-.7l2.7-7.4a1 1 0 0 1 2 0Zm-2.2 4.4h2.4L16 12.5l-1.2 3.2Z" fill-rule="evenodd"/></svg>',
|
||||
'typography': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M17 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H7a1 1 0 0 1 0-2h10Z"/><path d="m17.5 14 .8-1.7 1.7-.8-1.7-.8-.8-1.7-.8 1.7-1.7.8 1.7.8.8 1.7ZM7 14l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2Z"/></svg>',
|
||||
'underline': '<svg width="24" height="24"><path d="M16 5c.6 0 1 .4 1 1v5.5a4 4 0 0 1-.4 1.8l-1 1.4a5.3 5.3 0 0 1-5.5 1 5 5 0 0 1-1.6-1c-.5-.4-.8-.9-1.1-1.4a4 4 0 0 1-.4-1.8V6c0-.6.4-1 1-1s1 .4 1 1v5.5c0 .3 0 .6.2 1l.6.7a3.3 3.3 0 0 0 2.2.8 3.4 3.4 0 0 0 2.2-.8c.3-.2.4-.5.6-.8l.2-.9V6c0-.6.4-1 1-1ZM8 17h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'undo': '<svg width="24" height="24"><path d="M6.4 8H12c3.7 0 6.2 2 6.8 5.1.6 2.7-.4 5.6-2.3 6.8a1 1 0 0 1-1-1.8c1.1-.6 1.8-2.7 1.4-4.6-.5-2.1-2.1-3.5-4.9-3.5H6.4l3.3 3.3a1 1 0 1 1-1.4 1.4l-5-5a1 1 0 0 1 0-1.4l5-5a1 1 0 0 1 1.4 1.4L6.4 8Z" fill-rule="nonzero"/></svg>',
|
||||
'unlink': '<svg width="24" height="24"><path d="M6.2 12.3a1 1 0 0 1 1.4 1.4l-2 2a2 2 0 1 0 2.6 2.8l4.8-4.8a1 1 0 0 0 0-1.4 1 1 0 1 1 1.4-1.3 2.9 2.9 0 0 1 0 4L9.6 20a3.9 3.9 0 0 1-5.5-5.5l2-2Zm11.6-.6a1 1 0 0 1-1.4-1.4l2.1-2a2 2 0 1 0-2.7-2.8L11 10.3a1 1 0 0 0 0 1.4A1 1 0 1 1 9.6 13a2.9 2.9 0 0 1 0-4L14.4 4a3.9 3.9 0 0 1 5.5 5.5l-2 2ZM7.6 6.3a.8.8 0 0 1-1 1.1L3.3 4.2a.7.7 0 1 1 1-1l3.2 3.1ZM5.1 8.6a.8.8 0 0 1 0 1.5H3a.8.8 0 0 1 0-1.5H5Zm5-3.5a.8.8 0 0 1-1.5 0V3a.8.8 0 0 1 1.5 0V5Zm6 11.8a.8.8 0 0 1 1-1l3.2 3.2a.8.8 0 0 1-1 1L16 17Zm-2.2 2a.8.8 0 0 1 1.5 0V21a.8.8 0 0 1-1.5 0V19Zm5-3.5a.7.7 0 1 1 0-1.5H21a.8.8 0 0 1 0 1.5H19Z" fill-rule="nonzero"/></svg>',
|
||||
'unlock': '<svg width="24" height="24"><path d="M16 5c.8 0 1.5.3 2.1.9.6.6.9 1.3.9 2.1v3h-2V8a1 1 0 0 0-.3-.7A1 1 0 0 0 16 7h-2a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v3h.3c.2 0 .3 0 .5.2l.2.6v7.4c0 .3 0 .4-.2.6l-.6.2H4.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6v-7.4c0-.3 0-.4.2-.6l.5-.2H11V8c0-.8.3-1.5.9-2.1.6-.6 1.3-.9 2.1-.9h2Z" fill-rule="evenodd"/></svg>',
|
||||
'unordered-list': '<svg width="24" height="24"><path d="M11 5h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2ZM4.5 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Zm0 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Zm0 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Z" fill-rule="evenodd"/></svg>',
|
||||
'unselected': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2Zm0 1a1 1 0 0 0-1 1v12c0 .6.4 1 1 1h12c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H6Z"/></svg>',
|
||||
'upload': '<svg width="24" height="24"><path d="M18 19v-2a1 1 0 0 1 2 0v3c0 .6-.4 1-1 1H5a1 1 0 0 1-1-1v-3a1 1 0 0 1 2 0v2h12ZM11 6.4 8.7 8.7a1 1 0 0 1-1.4-1.4l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 1 1-1.4 1.4L13 6.4V16a1 1 0 0 1-2 0V6.4Z" fill-rule="nonzero"/></svg>',
|
||||
'user': '<svg width="24" height="24"><path d="M12 24a12 12 0 1 1 0-24 12 12 0 0 1 0 24Zm-8.7-5.3a11 11 0 0 0 17.4 0C19.4 16.3 14.6 15 12 15c-2.6 0-7.4 1.3-8.7 3.7ZM12 13c2.2 0 4-2 4-4.5S14.2 4 12 4 8 6 8 8.5 9.8 13 12 13Z" fill-rule="nonzero"/></svg>',
|
||||
'vertical-align': '<svg width="24" height="24"><g fill-rule="nonzero"><rect width="18" height="2" x="3" y="11" rx="1"/><path d="M12 2c.6 0 1 .4 1 1v4l2-1.3a1 1 0 0 1 1.2 1.5l-.1.1-4.1 3-4-3a1 1 0 0 1 1-1.7l2 1.5V3c0-.6.4-1 1-1zm0 11.8 4 2.9a1 1 0 0 1-1 1.7l-2-1.5V21c0 .5-.4 1-.9 1H12a1 1 0 0 1-1-1v-4l-2 1.3a1 1 0 0 1-1.2-.1l-.1-.1a1 1 0 0 1 .1-1.3l.1-.1 4.1-3z"/></g></svg>',
|
||||
'visualblocks': '<svg width="24" height="24"><path d="M9 19v2H7v-2h2Zm-4 0v2a2 2 0 0 1-2-2h2Zm8 0v2h-2v-2h2Zm8 0a2 2 0 0 1-2 2v-2h2Zm-4 0v2h-2v-2h2ZM15 7a1 1 0 0 1 0 2v7a1 1 0 0 1-2 0V9h-1v7a1 1 0 0 1-2 0v-4a2.5 2.5 0 0 1-.2-5H15ZM5 15v2H3v-2h2Zm16 0v2h-2v-2h2ZM5 11v2H3v-2h2Zm16 0v2h-2v-2h2ZM5 7v2H3V7h2Zm16 0v2h-2V7h2ZM5 3v2H3c0-1.1.9-2 2-2Zm8 0v2h-2V3h2Zm6 0a2 2 0 0 1 2 2h-2V3ZM9 3v2H7V3h2Zm8 0v2h-2V3h2Z" fill-rule="evenodd"/></svg>',
|
||||
'visualchars': '<svg width="24" height="24"><path d="M10 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 6.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L10 5Z" fill-rule="evenodd"/></svg>',
|
||||
'warning': '<svg width="24" height="24"><path d="M19.8 18.3c.2.5.3.9 0 1.2-.1.3-.5.5-1 .5H5.2c-.5 0-.9-.2-1-.5-.3-.3-.2-.7 0-1.2L11 4.7l.5-.5.5-.2c.2 0 .3 0 .5.2.2 0 .3.3.5.5l6.8 13.6ZM12 18c.3 0 .5-.1.7-.3.2-.2.3-.4.3-.7a1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3Zm.7-3 .3-4a1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7l.3 4h1.4Z" fill-rule="evenodd"/></svg>',
|
||||
'zoom-in': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm-1-9a1 1 0 0 1 2 0v6a1 1 0 0 1-2 0V8Zm-2 4a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2H8Z" fill-rule="nonzero"/></svg>',
|
||||
'zoom-out': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm-3-5a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2H8Z" fill-rule="nonzero"/></svg>',
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "default" icons for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/icons/default')
|
||||
// ES2015:
|
||||
// import 'tinymce/icons/default'
|
||||
require('./icons.js');
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "dom" model for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/models/dom')
|
||||
// ES2015:
|
||||
// import 'tinymce/models/dom'
|
||||
require('./model.js');
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "accordion" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/accordion')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/accordion'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "advlist" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/advlist')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/advlist'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const applyListFormat = (editor, listName, styleValue) => {
|
||||
const cmd = listName === 'UL' ? 'InsertUnorderedList' : 'InsertOrderedList';
|
||||
editor.execCommand(cmd, false, styleValue === false ? null : { 'list-style-type': styleValue });
|
||||
};
|
||||
|
||||
const register$2 = editor => {
|
||||
editor.addCommand('ApplyUnorderedListStyle', (ui, value) => {
|
||||
applyListFormat(editor, 'UL', value['list-style-type']);
|
||||
});
|
||||
editor.addCommand('ApplyOrderedListStyle', (ui, value) => {
|
||||
applyListFormat(editor, 'OL', value['list-style-type']);
|
||||
});
|
||||
};
|
||||
|
||||
const option = name => editor => editor.options.get(name);
|
||||
const register$1 = editor => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('advlist_number_styles', {
|
||||
processor: 'string[]',
|
||||
default: 'default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman'.split(',')
|
||||
});
|
||||
registerOption('advlist_bullet_styles', {
|
||||
processor: 'string[]',
|
||||
default: 'default,circle,square'.split(',')
|
||||
});
|
||||
};
|
||||
const getNumberStyles = option('advlist_number_styles');
|
||||
const getBulletStyles = option('advlist_bullet_styles');
|
||||
|
||||
const isNullable = a => a === null || a === undefined;
|
||||
const isNonNullable = a => !isNullable(a);
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
class Optional {
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
} else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message !== null && message !== void 0 ? message : 'Called getOrDie on None');
|
||||
} else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
toString() {
|
||||
return this.tag ? `some(${ this.value })` : 'none()';
|
||||
}
|
||||
}
|
||||
Optional.singletonNone = new Optional(false);
|
||||
|
||||
const findUntil = (xs, pred, until) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return Optional.some(x);
|
||||
} else if (until(x, i)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
|
||||
const isCustomList = list => /\btox\-/.test(list.className);
|
||||
const isChildOfBody = (editor, elm) => {
|
||||
return editor.dom.isChildOf(elm, editor.getBody());
|
||||
};
|
||||
const matchNodeNames = regex => node => isNonNullable(node) && regex.test(node.nodeName);
|
||||
const isListNode = matchNodeNames(/^(OL|UL|DL)$/);
|
||||
const isTableCellNode = matchNodeNames(/^(TH|TD)$/);
|
||||
const inList = (editor, parents, nodeName) => findUntil(parents, parent => isListNode(parent) && !isCustomList(parent), isTableCellNode).exists(list => list.nodeName === nodeName && isChildOfBody(editor, list));
|
||||
const getSelectedStyleType = editor => {
|
||||
const listElm = editor.dom.getParent(editor.selection.getNode(), 'ol,ul');
|
||||
const style = editor.dom.getStyle(listElm, 'listStyleType');
|
||||
return Optional.from(style);
|
||||
};
|
||||
const isWithinNonEditable = (editor, element) => element !== null && !editor.dom.isEditable(element);
|
||||
const isWithinNonEditableList = (editor, element) => {
|
||||
const parentList = editor.dom.getParent(element, 'ol,ul,dl');
|
||||
return isWithinNonEditable(editor, parentList) && editor.selection.isEditable();
|
||||
};
|
||||
const setNodeChangeHandler = (editor, nodeChangeHandler) => {
|
||||
const initialNode = editor.selection.getNode();
|
||||
nodeChangeHandler({
|
||||
parents: editor.dom.getParents(initialNode),
|
||||
element: initialNode
|
||||
});
|
||||
editor.on('NodeChange', nodeChangeHandler);
|
||||
return () => editor.off('NodeChange', nodeChangeHandler);
|
||||
};
|
||||
|
||||
const styleValueToText = styleValue => {
|
||||
return styleValue.replace(/\-/g, ' ').replace(/\b\w/g, chr => {
|
||||
return chr.toUpperCase();
|
||||
});
|
||||
};
|
||||
const normalizeStyleValue = styleValue => isNullable(styleValue) || styleValue === 'default' ? '' : styleValue;
|
||||
const makeSetupHandler = (editor, nodeName) => api => {
|
||||
const updateButtonState = (editor, parents) => {
|
||||
const element = editor.selection.getStart(true);
|
||||
api.setActive(inList(editor, parents, nodeName));
|
||||
api.setEnabled(!isWithinNonEditableList(editor, element) && editor.selection.isEditable());
|
||||
};
|
||||
const nodeChangeHandler = e => updateButtonState(editor, e.parents);
|
||||
return setNodeChangeHandler(editor, nodeChangeHandler);
|
||||
};
|
||||
const addSplitButton = (editor, id, tooltip, cmd, nodeName, styles) => {
|
||||
editor.ui.registry.addSplitButton(id, {
|
||||
tooltip,
|
||||
icon: nodeName === 'OL' ? 'ordered-list' : 'unordered-list',
|
||||
presets: 'listpreview',
|
||||
columns: 3,
|
||||
fetch: callback => {
|
||||
const items = global.map(styles, styleValue => {
|
||||
const iconStyle = nodeName === 'OL' ? 'num' : 'bull';
|
||||
const iconName = styleValue === 'disc' || styleValue === 'decimal' ? 'default' : styleValue;
|
||||
const itemValue = normalizeStyleValue(styleValue);
|
||||
const displayText = styleValueToText(styleValue);
|
||||
return {
|
||||
type: 'choiceitem',
|
||||
value: itemValue,
|
||||
icon: 'list-' + iconStyle + '-' + iconName,
|
||||
text: displayText
|
||||
};
|
||||
});
|
||||
callback(items);
|
||||
},
|
||||
onAction: () => editor.execCommand(cmd),
|
||||
onItemAction: (_splitButtonApi, value) => {
|
||||
applyListFormat(editor, nodeName, value);
|
||||
},
|
||||
select: value => {
|
||||
const listStyleType = getSelectedStyleType(editor);
|
||||
return listStyleType.map(listStyle => value === listStyle).getOr(false);
|
||||
},
|
||||
onSetup: makeSetupHandler(editor, nodeName)
|
||||
});
|
||||
};
|
||||
const addButton = (editor, id, tooltip, cmd, nodeName, styleValue) => {
|
||||
editor.ui.registry.addToggleButton(id, {
|
||||
active: false,
|
||||
tooltip,
|
||||
icon: nodeName === 'OL' ? 'ordered-list' : 'unordered-list',
|
||||
onSetup: makeSetupHandler(editor, nodeName),
|
||||
onAction: () => editor.queryCommandState(cmd) || styleValue === '' ? editor.execCommand(cmd) : applyListFormat(editor, nodeName, styleValue)
|
||||
});
|
||||
};
|
||||
const addControl = (editor, id, tooltip, cmd, nodeName, styles) => {
|
||||
if (styles.length > 1) {
|
||||
addSplitButton(editor, id, tooltip, cmd, nodeName, styles);
|
||||
} else {
|
||||
addButton(editor, id, tooltip, cmd, nodeName, normalizeStyleValue(styles[0]));
|
||||
}
|
||||
};
|
||||
const register = editor => {
|
||||
addControl(editor, 'numlist', 'Numbered list', 'InsertOrderedList', 'OL', getNumberStyles(editor));
|
||||
addControl(editor, 'bullist', 'Bullet list', 'InsertUnorderedList', 'UL', getBulletStyles(editor));
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('advlist', editor => {
|
||||
if (editor.hasPlugin('lists')) {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
register$2(editor);
|
||||
} else {
|
||||
console.error('Please use the Lists plugin together with the Advanced List plugin.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the Advanced List plugin.")}))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "anchor" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/anchor')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/anchor'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$2 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.dom.RangeUtils');
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const option = name => editor => editor.options.get(name);
|
||||
const register$2 = editor => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('allow_html_in_named_anchor', {
|
||||
processor: 'boolean',
|
||||
default: false
|
||||
});
|
||||
};
|
||||
const allowHtmlInNamedAnchor = option('allow_html_in_named_anchor');
|
||||
|
||||
const namedAnchorSelector = 'a:not([href])';
|
||||
const isEmptyString = str => !str;
|
||||
const getIdFromAnchor = elm => {
|
||||
const id = elm.getAttribute('id') || elm.getAttribute('name');
|
||||
return id || '';
|
||||
};
|
||||
const isAnchor = elm => elm.nodeName.toLowerCase() === 'a';
|
||||
const isNamedAnchor = elm => isAnchor(elm) && !elm.getAttribute('href') && getIdFromAnchor(elm) !== '';
|
||||
const isEmptyNamedAnchor = elm => isNamedAnchor(elm) && !elm.firstChild;
|
||||
|
||||
const removeEmptyNamedAnchorsInSelection = editor => {
|
||||
const dom = editor.dom;
|
||||
global$1(dom).walk(editor.selection.getRng(), nodes => {
|
||||
global.each(nodes, node => {
|
||||
if (isEmptyNamedAnchor(node)) {
|
||||
dom.remove(node, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const isValidId = id => /^[A-Za-z][A-Za-z0-9\-:._]*$/.test(id);
|
||||
const getNamedAnchor = editor => editor.dom.getParent(editor.selection.getStart(), namedAnchorSelector);
|
||||
const getId = editor => {
|
||||
const anchor = getNamedAnchor(editor);
|
||||
if (anchor) {
|
||||
return getIdFromAnchor(anchor);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const createAnchor = (editor, id) => {
|
||||
editor.undoManager.transact(() => {
|
||||
if (!allowHtmlInNamedAnchor(editor)) {
|
||||
editor.selection.collapse(true);
|
||||
}
|
||||
if (editor.selection.isCollapsed()) {
|
||||
editor.insertContent(editor.dom.createHTML('a', { id }));
|
||||
} else {
|
||||
removeEmptyNamedAnchorsInSelection(editor);
|
||||
editor.formatter.remove('namedAnchor', undefined, undefined, true);
|
||||
editor.formatter.apply('namedAnchor', { value: id });
|
||||
editor.addVisual();
|
||||
}
|
||||
});
|
||||
};
|
||||
const updateAnchor = (editor, id, anchorElement) => {
|
||||
anchorElement.removeAttribute('name');
|
||||
anchorElement.id = id;
|
||||
editor.addVisual();
|
||||
editor.undoManager.add();
|
||||
};
|
||||
const insert = (editor, id) => {
|
||||
const anchor = getNamedAnchor(editor);
|
||||
if (anchor) {
|
||||
updateAnchor(editor, id, anchor);
|
||||
} else {
|
||||
createAnchor(editor, id);
|
||||
}
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const insertAnchor = (editor, newId) => {
|
||||
if (!isValidId(newId)) {
|
||||
editor.windowManager.alert('ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.');
|
||||
return false;
|
||||
} else {
|
||||
insert(editor, newId);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const open = editor => {
|
||||
const currentId = getId(editor);
|
||||
editor.windowManager.open({
|
||||
title: 'Anchor',
|
||||
size: 'normal',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [{
|
||||
name: 'id',
|
||||
type: 'input',
|
||||
label: 'ID',
|
||||
placeholder: 'example'
|
||||
}]
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'cancel',
|
||||
text: 'Cancel'
|
||||
},
|
||||
{
|
||||
type: 'submit',
|
||||
name: 'save',
|
||||
text: 'Save',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: { id: currentId },
|
||||
onSubmit: api => {
|
||||
if (insertAnchor(editor, api.getData().id)) {
|
||||
api.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const register$1 = editor => {
|
||||
editor.addCommand('mceAnchor', () => {
|
||||
open(editor);
|
||||
});
|
||||
};
|
||||
|
||||
const isNamedAnchorNode = node => isEmptyString(node.attr('href')) && !isEmptyString(node.attr('id') || node.attr('name'));
|
||||
const isEmptyNamedAnchorNode = node => isNamedAnchorNode(node) && !node.firstChild;
|
||||
const setContentEditable = state => nodes => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (isEmptyNamedAnchorNode(node)) {
|
||||
node.attr('contenteditable', state);
|
||||
}
|
||||
}
|
||||
};
|
||||
const setup = editor => {
|
||||
editor.on('PreInit', () => {
|
||||
editor.parser.addNodeFilter('a', setContentEditable('false'));
|
||||
editor.serializer.addNodeFilter('a', setContentEditable(null));
|
||||
});
|
||||
};
|
||||
|
||||
const registerFormats = editor => {
|
||||
editor.formatter.register('namedAnchor', {
|
||||
inline: 'a',
|
||||
selector: namedAnchorSelector,
|
||||
remove: 'all',
|
||||
split: true,
|
||||
deep: true,
|
||||
attributes: { id: '%value' },
|
||||
onmatch: (node, _fmt, _itemName) => {
|
||||
return isNamedAnchor(node);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetupEditable = editor => api => {
|
||||
const nodeChanged = () => {
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChanged);
|
||||
nodeChanged();
|
||||
return () => {
|
||||
editor.off('NodeChange', nodeChanged);
|
||||
};
|
||||
};
|
||||
const register = editor => {
|
||||
const onAction = () => editor.execCommand('mceAnchor');
|
||||
editor.ui.registry.addToggleButton('anchor', {
|
||||
icon: 'bookmark',
|
||||
tooltip: 'Anchor',
|
||||
onAction,
|
||||
onSetup: buttonApi => {
|
||||
const unbindSelectorChanged = editor.selection.selectorChangedWithUnbind('a:not([href])', buttonApi.setActive).unbind;
|
||||
const unbindEditableChanged = onSetupEditable(editor)(buttonApi);
|
||||
return () => {
|
||||
unbindSelectorChanged();
|
||||
unbindEditableChanged();
|
||||
};
|
||||
}
|
||||
});
|
||||
editor.ui.registry.addMenuItem('anchor', {
|
||||
icon: 'bookmark',
|
||||
text: 'Anchor...',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$2.add('anchor', editor => {
|
||||
register$2(editor);
|
||||
setup(editor);
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
editor.on('PreInit', () => {
|
||||
registerFormats(editor);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=("allow_html_in_named_anchor",e=>e.options.get("allow_html_in_named_anchor"));const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact((()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),(e=>{o.each(e,(e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)}))}))})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())}))})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;o<t.length;o++){const n=t[o];c(n)&&n.attr("contenteditable",e)}},u=e=>t=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",(e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",(()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))}))})(e),(e=>{e.addCommand("mceAnchor",(()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)}))})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",(()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)}))}))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "autolink" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autolink')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autolink'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const link = () => /(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g;
|
||||
|
||||
const option = name => editor => editor.options.get(name);
|
||||
const register = editor => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('autolink_pattern', {
|
||||
processor: 'regexp',
|
||||
default: new RegExp('^' + link().source + '$', 'i')
|
||||
});
|
||||
registerOption('link_default_target', { processor: 'string' });
|
||||
registerOption('link_default_protocol', {
|
||||
processor: 'string',
|
||||
default: 'https'
|
||||
});
|
||||
};
|
||||
const getAutoLinkPattern = option('autolink_pattern');
|
||||
const getDefaultLinkTarget = option('link_default_target');
|
||||
const getDefaultLinkProtocol = option('link_default_protocol');
|
||||
const allowUnsafeLinkTarget = option('allow_unsafe_link_target');
|
||||
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
var _a;
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
} else {
|
||||
return ((_a = v.constructor) === null || _a === void 0 ? void 0 : _a.name) === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = x => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
} else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
} else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
} else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = type => value => typeOf(value) === type;
|
||||
const eq = t => a => t === a;
|
||||
const isString = isType('string');
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = a => a === null || a === undefined;
|
||||
const isNonNullable = a => !isNullable(a);
|
||||
|
||||
const not = f => t => !f(t);
|
||||
|
||||
const hasOwnProperty = Object.hasOwnProperty;
|
||||
const has = (obj, key) => hasOwnProperty.call(obj, key);
|
||||
|
||||
const checkRange = (str, substr, start) => substr === '' || str.length >= substr.length && str.substr(start, start + substr.length) === substr;
|
||||
const contains = (str, substr, start = 0, end) => {
|
||||
const idx = str.indexOf(substr, start);
|
||||
if (idx !== -1) {
|
||||
return isUndefined(end) ? true : idx + substr.length <= end;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const startsWith = (str, prefix) => {
|
||||
return checkRange(str, prefix, 0);
|
||||
};
|
||||
|
||||
const zeroWidth = '\uFEFF';
|
||||
const isZwsp = char => char === zeroWidth;
|
||||
const removeZwsp = s => s.replace(/\uFEFF/g, '');
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.dom.TextSeeker');
|
||||
|
||||
const isTextNode = node => node.nodeType === 3;
|
||||
const isElement = node => node.nodeType === 1;
|
||||
const isBracketOrSpace = char => /^[(\[{ \u00a0]$/.test(char);
|
||||
const hasProtocol = url => /^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(url);
|
||||
const isPunctuation = char => /[?!,.;:]/.test(char);
|
||||
const findChar = (text, index, predicate) => {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const char = text.charAt(i);
|
||||
if (!isZwsp(char) && predicate(char)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const freefallRtl = (container, offset) => {
|
||||
let tempNode = container;
|
||||
let tempOffset = offset;
|
||||
while (isElement(tempNode) && tempNode.childNodes[tempOffset]) {
|
||||
tempNode = tempNode.childNodes[tempOffset];
|
||||
tempOffset = isTextNode(tempNode) ? tempNode.data.length : tempNode.childNodes.length;
|
||||
}
|
||||
return {
|
||||
container: tempNode,
|
||||
offset: tempOffset
|
||||
};
|
||||
};
|
||||
|
||||
const parseCurrentLine = (editor, offset) => {
|
||||
var _a;
|
||||
const voidElements = editor.schema.getVoidElements();
|
||||
const autoLinkPattern = getAutoLinkPattern(editor);
|
||||
const {dom, selection} = editor;
|
||||
if (dom.getParent(selection.getNode(), 'a[href]') !== null) {
|
||||
return null;
|
||||
}
|
||||
const rng = selection.getRng();
|
||||
const textSeeker = global(dom, node => {
|
||||
return dom.isBlock(node) || has(voidElements, node.nodeName.toLowerCase()) || dom.getContentEditable(node) === 'false';
|
||||
});
|
||||
const {
|
||||
container: endContainer,
|
||||
offset: endOffset
|
||||
} = freefallRtl(rng.endContainer, rng.endOffset);
|
||||
const root = (_a = dom.getParent(endContainer, dom.isBlock)) !== null && _a !== void 0 ? _a : dom.getRoot();
|
||||
const endSpot = textSeeker.backwards(endContainer, endOffset + offset, (node, offset) => {
|
||||
const text = node.data;
|
||||
const idx = findChar(text, offset, not(isBracketOrSpace));
|
||||
return idx === -1 || isPunctuation(text[idx]) ? idx : idx + 1;
|
||||
}, root);
|
||||
if (!endSpot) {
|
||||
return null;
|
||||
}
|
||||
let lastTextNode = endSpot.container;
|
||||
const startSpot = textSeeker.backwards(endSpot.container, endSpot.offset, (node, offset) => {
|
||||
lastTextNode = node;
|
||||
const idx = findChar(node.data, offset, isBracketOrSpace);
|
||||
return idx === -1 ? idx : idx + 1;
|
||||
}, root);
|
||||
const newRng = dom.createRng();
|
||||
if (!startSpot) {
|
||||
newRng.setStart(lastTextNode, 0);
|
||||
} else {
|
||||
newRng.setStart(startSpot.container, startSpot.offset);
|
||||
}
|
||||
newRng.setEnd(endSpot.container, endSpot.offset);
|
||||
const rngText = removeZwsp(newRng.toString());
|
||||
const matches = rngText.match(autoLinkPattern);
|
||||
if (matches) {
|
||||
let url = matches[0];
|
||||
if (startsWith(url, 'www.')) {
|
||||
const protocol = getDefaultLinkProtocol(editor);
|
||||
url = protocol + '://' + url;
|
||||
} else if (contains(url, '@') && !hasProtocol(url)) {
|
||||
url = 'mailto:' + url;
|
||||
}
|
||||
return {
|
||||
rng: newRng,
|
||||
url
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const convertToLink = (editor, result) => {
|
||||
const {dom, selection} = editor;
|
||||
const {rng, url} = result;
|
||||
const bookmark = selection.getBookmark();
|
||||
selection.setRng(rng);
|
||||
const command = 'createlink';
|
||||
const args = {
|
||||
command,
|
||||
ui: false,
|
||||
value: url
|
||||
};
|
||||
const beforeExecEvent = editor.dispatch('BeforeExecCommand', args);
|
||||
if (!beforeExecEvent.isDefaultPrevented()) {
|
||||
editor.getDoc().execCommand(command, false, url);
|
||||
editor.dispatch('ExecCommand', args);
|
||||
const defaultLinkTarget = getDefaultLinkTarget(editor);
|
||||
if (isString(defaultLinkTarget)) {
|
||||
const anchor = selection.getNode();
|
||||
dom.setAttrib(anchor, 'target', defaultLinkTarget);
|
||||
if (defaultLinkTarget === '_blank' && !allowUnsafeLinkTarget(editor)) {
|
||||
dom.setAttrib(anchor, 'rel', 'noopener');
|
||||
}
|
||||
}
|
||||
}
|
||||
selection.moveToBookmark(bookmark);
|
||||
editor.nodeChanged();
|
||||
};
|
||||
const handleSpacebar = editor => {
|
||||
const result = parseCurrentLine(editor, -1);
|
||||
if (isNonNullable(result)) {
|
||||
convertToLink(editor, result);
|
||||
}
|
||||
};
|
||||
const handleBracket = handleSpacebar;
|
||||
const handleEnter = editor => {
|
||||
const result = parseCurrentLine(editor, 0);
|
||||
if (isNonNullable(result)) {
|
||||
convertToLink(editor, result);
|
||||
}
|
||||
};
|
||||
const setup = editor => {
|
||||
editor.on('keydown', e => {
|
||||
if (e.keyCode === 13 && !e.isDefaultPrevented()) {
|
||||
handleEnter(editor);
|
||||
}
|
||||
});
|
||||
editor.on('keyup', e => {
|
||||
if (e.keyCode === 32) {
|
||||
handleSpacebar(editor);
|
||||
} else if (e.keyCode === 48 && e.shiftKey || e.keyCode === 221) {
|
||||
handleBracket(editor);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('autolink', editor => {
|
||||
register(editor);
|
||||
setup(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "autoresize" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autoresize')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autoresize'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const Cell = initial => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = v => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const constant = value => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.Env');
|
||||
|
||||
const fireResizeEditor = editor => editor.dispatch('ResizeEditor');
|
||||
|
||||
const option = name => editor => editor.options.get(name);
|
||||
const register$1 = editor => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('autoresize_overflow_padding', {
|
||||
processor: 'number',
|
||||
default: 1
|
||||
});
|
||||
registerOption('autoresize_bottom_margin', {
|
||||
processor: 'number',
|
||||
default: 50
|
||||
});
|
||||
};
|
||||
const getMinHeight = option('min_height');
|
||||
const getMaxHeight = option('max_height');
|
||||
const getAutoResizeOverflowPadding = option('autoresize_overflow_padding');
|
||||
const getAutoResizeBottomMargin = option('autoresize_bottom_margin');
|
||||
|
||||
const isFullscreen = editor => editor.plugins.fullscreen && editor.plugins.fullscreen.isFullscreen();
|
||||
const toggleScrolling = (editor, state) => {
|
||||
const body = editor.getBody();
|
||||
if (body) {
|
||||
body.style.overflowY = state ? '' : 'hidden';
|
||||
if (!state) {
|
||||
body.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
const parseCssValueToInt = (dom, elm, name, computed) => {
|
||||
var _a;
|
||||
const value = parseInt((_a = dom.getStyle(elm, name, computed)) !== null && _a !== void 0 ? _a : '', 10);
|
||||
return isNaN(value) ? 0 : value;
|
||||
};
|
||||
const shouldScrollIntoView = trigger => {
|
||||
if ((trigger === null || trigger === void 0 ? void 0 : trigger.type.toLowerCase()) === 'setcontent') {
|
||||
const setContentEvent = trigger;
|
||||
return setContentEvent.selection === true || setContentEvent.paste === true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const resize = (editor, oldSize, trigger, getExtraMarginBottom) => {
|
||||
var _a;
|
||||
const dom = editor.dom;
|
||||
const doc = editor.getDoc();
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
if (isFullscreen(editor)) {
|
||||
toggleScrolling(editor, true);
|
||||
return;
|
||||
}
|
||||
const docEle = doc.documentElement;
|
||||
const resizeBottomMargin = getExtraMarginBottom ? getExtraMarginBottom() : getAutoResizeOverflowPadding(editor);
|
||||
const minHeight = (_a = getMinHeight(editor)) !== null && _a !== void 0 ? _a : editor.getElement().offsetHeight;
|
||||
let resizeHeight = minHeight;
|
||||
const marginTop = parseCssValueToInt(dom, docEle, 'margin-top', true);
|
||||
const marginBottom = parseCssValueToInt(dom, docEle, 'margin-bottom', true);
|
||||
let contentHeight = docEle.offsetHeight + marginTop + marginBottom + resizeBottomMargin;
|
||||
if (contentHeight < 0) {
|
||||
contentHeight = 0;
|
||||
}
|
||||
const containerHeight = editor.getContainer().offsetHeight;
|
||||
const contentAreaHeight = editor.getContentAreaContainer().offsetHeight;
|
||||
const chromeHeight = containerHeight - contentAreaHeight;
|
||||
if (contentHeight + chromeHeight > minHeight) {
|
||||
resizeHeight = contentHeight + chromeHeight;
|
||||
}
|
||||
const maxHeight = getMaxHeight(editor);
|
||||
if (maxHeight && resizeHeight > maxHeight) {
|
||||
resizeHeight = maxHeight;
|
||||
toggleScrolling(editor, true);
|
||||
} else {
|
||||
toggleScrolling(editor, false);
|
||||
}
|
||||
if (resizeHeight !== oldSize.get()) {
|
||||
const deltaSize = resizeHeight - oldSize.get();
|
||||
dom.setStyle(editor.getContainer(), 'height', resizeHeight + 'px');
|
||||
oldSize.set(resizeHeight);
|
||||
fireResizeEditor(editor);
|
||||
if (global.browser.isSafari() && (global.os.isMacOS() || global.os.isiOS())) {
|
||||
const win = editor.getWin();
|
||||
win.scrollTo(win.pageXOffset, win.pageYOffset);
|
||||
}
|
||||
if (editor.hasFocus() && shouldScrollIntoView(trigger)) {
|
||||
editor.selection.scrollIntoView();
|
||||
}
|
||||
if ((global.browser.isSafari() || global.browser.isChromium()) && deltaSize < 0) {
|
||||
resize(editor, oldSize, trigger, getExtraMarginBottom);
|
||||
}
|
||||
}
|
||||
};
|
||||
const setup = (editor, oldSize) => {
|
||||
let getExtraMarginBottom = () => getAutoResizeBottomMargin(editor);
|
||||
let resizeCounter;
|
||||
let sizeAfterFirstResize;
|
||||
editor.on('init', e => {
|
||||
resizeCounter = 0;
|
||||
const overflowPadding = getAutoResizeOverflowPadding(editor);
|
||||
const dom = editor.dom;
|
||||
dom.setStyles(editor.getDoc().documentElement, { height: 'auto' });
|
||||
if (global.browser.isEdge() || global.browser.isIE()) {
|
||||
dom.setStyles(editor.getBody(), {
|
||||
'paddingLeft': overflowPadding,
|
||||
'paddingRight': overflowPadding,
|
||||
'min-height': 0
|
||||
});
|
||||
} else {
|
||||
dom.setStyles(editor.getBody(), {
|
||||
paddingLeft: overflowPadding,
|
||||
paddingRight: overflowPadding
|
||||
});
|
||||
}
|
||||
resize(editor, oldSize, e, getExtraMarginBottom);
|
||||
resizeCounter += 1;
|
||||
});
|
||||
editor.on('NodeChange SetContent keyup FullscreenStateChanged ResizeContent', e => {
|
||||
if (resizeCounter === 1) {
|
||||
sizeAfterFirstResize = editor.getContainer().offsetHeight;
|
||||
resize(editor, oldSize, e, getExtraMarginBottom);
|
||||
resizeCounter += 1;
|
||||
} else if (resizeCounter === 2) {
|
||||
const isLooping = sizeAfterFirstResize < editor.getContainer().offsetHeight;
|
||||
if (isLooping) {
|
||||
const dom = editor.dom;
|
||||
const doc = editor.getDoc();
|
||||
dom.setStyles(doc.documentElement, { 'min-height': 0 });
|
||||
dom.setStyles(editor.getBody(), { 'min-height': 'inherit' });
|
||||
}
|
||||
getExtraMarginBottom = isLooping ? constant(0) : getExtraMarginBottom;
|
||||
resizeCounter += 1;
|
||||
} else {
|
||||
resize(editor, oldSize, e, getExtraMarginBottom);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const register = (editor, oldSize) => {
|
||||
editor.addCommand('mceAutoResize', () => {
|
||||
resize(editor, oldSize);
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('autoresize', editor => {
|
||||
register$1(editor);
|
||||
if (!editor.options.isSet('resize')) {
|
||||
editor.options.set('resize', false);
|
||||
}
|
||||
if (!editor.inline) {
|
||||
const oldSize = Cell(0);
|
||||
register(editor, oldSize);
|
||||
setup(editor, oldSize);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env");const o=e=>t=>t.options.get(e),s=o("min_height"),i=o("max_height"),n=o("autoresize_overflow_padding"),r=o("autoresize_bottom_margin"),l=(e,t)=>{const o=e.getBody();o&&(o.style.overflowY=t?"":"hidden",t||(o.scrollTop=0))},g=(e,t,o,s)=>{var i;const n=parseInt(null!==(i=e.getStyle(t,o,s))&&void 0!==i?i:"",10);return isNaN(n)?0:n},a=(e,o,r,c)=>{var d;const f=e.dom,u=e.getDoc();if(!u)return;if((e=>e.plugins.fullscreen&&e.plugins.fullscreen.isFullscreen())(e))return void l(e,!0);const m=u.documentElement,h=c?c():n(e),p=null!==(d=s(e))&&void 0!==d?d:e.getElement().offsetHeight;let y=p;const S=g(f,m,"margin-top",!0),v=g(f,m,"margin-bottom",!0);let C=m.offsetHeight+S+v+h;C<0&&(C=0);const b=e.getContainer().offsetHeight-e.getContentAreaContainer().offsetHeight;C+b>p&&(y=C+b);const w=i(e);if(w&&y>w?(y=w,l(e,!0)):l(e,!1),y!==o.get()){const s=y-o.get();if(f.setStyle(e.getContainer(),"height",y+"px"),o.set(y),(e=>{e.dispatch("ResizeEditor")})(e),t.browser.isSafari()&&(t.os.isMacOS()||t.os.isiOS())){const t=e.getWin();t.scrollTo(t.pageXOffset,t.pageYOffset)}e.hasFocus()&&(e=>{if("setcontent"===(null==e?void 0:e.type.toLowerCase())){const t=e;return!0===t.selection||!0===t.paste}return!1})(r)&&e.selection.scrollIntoView(),(t.browser.isSafari()||t.browser.isChromium())&&s<0&&a(e,o,r,c)}};e.add("autoresize",(e=>{if((e=>{const t=e.options.register;t("autoresize_overflow_padding",{processor:"number",default:1}),t("autoresize_bottom_margin",{processor:"number",default:50})})(e),e.options.isSet("resize")||e.options.set("resize",!1),!e.inline){const o=(e=>{let t=0;return{get:()=>t,set:e=>{t=e}}})();((e,t)=>{e.addCommand("mceAutoResize",(()=>{a(e,t)}))})(e,o),((e,o)=>{let s,i,l=()=>r(e);e.on("init",(i=>{s=0;const r=n(e),g=e.dom;g.setStyles(e.getDoc().documentElement,{height:"auto"}),t.browser.isEdge()||t.browser.isIE()?g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r,"min-height":0}):g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r}),a(e,o,i,l),s+=1})),e.on("NodeChange SetContent keyup FullscreenStateChanged ResizeContent",(t=>{if(1===s)i=e.getContainer().offsetHeight,a(e,o,t,l),s+=1;else if(2===s){const t=i<e.getContainer().offsetHeight;if(t){const t=e.dom,o=e.getDoc();t.setStyles(o.documentElement,{"min-height":0}),t.setStyles(e.getBody(),{"min-height":"inherit"})}l=t?(0,()=>0):l,s+=1}else a(e,o,t,l)}))})(e,o)}}))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "autosave" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autosave')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autosave'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$4 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
var _a;
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
} else {
|
||||
return ((_a = v.constructor) === null || _a === void 0 ? void 0 : _a.name) === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = x => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
} else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
} else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
} else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = type => value => typeOf(value) === type;
|
||||
const eq = t => a => t === a;
|
||||
const isString = isType('string');
|
||||
const isUndefined = eq(undefined);
|
||||
|
||||
var global$3 = tinymce.util.Tools.resolve('tinymce.util.Delay');
|
||||
|
||||
var global$2 = tinymce.util.Tools.resolve('tinymce.util.LocalStorage');
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const fireRestoreDraft = editor => editor.dispatch('RestoreDraft');
|
||||
const fireStoreDraft = editor => editor.dispatch('StoreDraft');
|
||||
const fireRemoveDraft = editor => editor.dispatch('RemoveDraft');
|
||||
|
||||
const parse = timeString => {
|
||||
const multiples = {
|
||||
s: 1000,
|
||||
m: 60000
|
||||
};
|
||||
const parsedTime = /^(\d+)([ms]?)$/.exec(timeString);
|
||||
return (parsedTime && parsedTime[2] ? multiples[parsedTime[2]] : 1) * parseInt(timeString, 10);
|
||||
};
|
||||
|
||||
const option = name => editor => editor.options.get(name);
|
||||
const register$1 = editor => {
|
||||
const registerOption = editor.options.register;
|
||||
const timeProcessor = value => {
|
||||
const valid = isString(value);
|
||||
if (valid) {
|
||||
return {
|
||||
value: parse(value),
|
||||
valid
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Must be a string.'
|
||||
};
|
||||
}
|
||||
};
|
||||
registerOption('autosave_ask_before_unload', {
|
||||
processor: 'boolean',
|
||||
default: true
|
||||
});
|
||||
registerOption('autosave_prefix', {
|
||||
processor: 'string',
|
||||
default: 'tinymce-autosave-{path}{query}{hash}-{id}-'
|
||||
});
|
||||
registerOption('autosave_restore_when_empty', {
|
||||
processor: 'boolean',
|
||||
default: false
|
||||
});
|
||||
registerOption('autosave_interval', {
|
||||
processor: timeProcessor,
|
||||
default: '30s'
|
||||
});
|
||||
registerOption('autosave_retention', {
|
||||
processor: timeProcessor,
|
||||
default: '20m'
|
||||
});
|
||||
};
|
||||
const shouldAskBeforeUnload = option('autosave_ask_before_unload');
|
||||
const shouldRestoreWhenEmpty = option('autosave_restore_when_empty');
|
||||
const getAutoSaveInterval = option('autosave_interval');
|
||||
const getAutoSaveRetention = option('autosave_retention');
|
||||
const getAutoSavePrefix = editor => {
|
||||
const location = document.location;
|
||||
return editor.options.get('autosave_prefix').replace(/{path}/g, location.pathname).replace(/{query}/g, location.search).replace(/{hash}/g, location.hash).replace(/{id}/g, editor.id);
|
||||
};
|
||||
|
||||
const isEmpty = (editor, html) => {
|
||||
if (isUndefined(html)) {
|
||||
return editor.dom.isEmpty(editor.getBody());
|
||||
} else {
|
||||
const trimmedHtml = global$1.trim(html);
|
||||
if (trimmedHtml === '') {
|
||||
return true;
|
||||
} else {
|
||||
const fragment = new DOMParser().parseFromString(trimmedHtml, 'text/html');
|
||||
return editor.dom.isEmpty(fragment);
|
||||
}
|
||||
}
|
||||
};
|
||||
const hasDraft = editor => {
|
||||
var _a;
|
||||
const time = parseInt((_a = global$2.getItem(getAutoSavePrefix(editor) + 'time')) !== null && _a !== void 0 ? _a : '0', 10) || 0;
|
||||
if (new Date().getTime() - time > getAutoSaveRetention(editor)) {
|
||||
removeDraft(editor, false);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const removeDraft = (editor, fire) => {
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
global$2.removeItem(prefix + 'draft');
|
||||
global$2.removeItem(prefix + 'time');
|
||||
if (fire !== false) {
|
||||
fireRemoveDraft(editor);
|
||||
}
|
||||
};
|
||||
const storeDraft = editor => {
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
if (!isEmpty(editor) && editor.isDirty()) {
|
||||
global$2.setItem(prefix + 'draft', editor.getContent({
|
||||
format: 'raw',
|
||||
no_events: true
|
||||
}));
|
||||
global$2.setItem(prefix + 'time', new Date().getTime().toString());
|
||||
fireStoreDraft(editor);
|
||||
}
|
||||
};
|
||||
const restoreDraft = editor => {
|
||||
var _a;
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
if (hasDraft(editor)) {
|
||||
editor.setContent((_a = global$2.getItem(prefix + 'draft')) !== null && _a !== void 0 ? _a : '', { format: 'raw' });
|
||||
fireRestoreDraft(editor);
|
||||
}
|
||||
};
|
||||
const startStoreDraft = editor => {
|
||||
const interval = getAutoSaveInterval(editor);
|
||||
global$3.setEditorInterval(editor, () => {
|
||||
storeDraft(editor);
|
||||
}, interval);
|
||||
};
|
||||
const restoreLastDraft = editor => {
|
||||
editor.undoManager.transact(() => {
|
||||
restoreDraft(editor);
|
||||
removeDraft(editor);
|
||||
});
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const get = editor => ({
|
||||
hasDraft: () => hasDraft(editor),
|
||||
storeDraft: () => storeDraft(editor),
|
||||
restoreDraft: () => restoreDraft(editor),
|
||||
removeDraft: fire => removeDraft(editor, fire),
|
||||
isEmpty: html => isEmpty(editor, html)
|
||||
});
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.EditorManager');
|
||||
|
||||
const setup = editor => {
|
||||
editor.editorManager.on('BeforeUnload', e => {
|
||||
let msg;
|
||||
global$1.each(global.get(), editor => {
|
||||
if (editor.plugins.autosave) {
|
||||
editor.plugins.autosave.storeDraft();
|
||||
}
|
||||
if (!msg && editor.isDirty() && shouldAskBeforeUnload(editor)) {
|
||||
msg = editor.translate('You have unsaved changes are you sure you want to navigate away?');
|
||||
}
|
||||
});
|
||||
if (msg) {
|
||||
e.preventDefault();
|
||||
e.returnValue = msg;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const makeSetupHandler = editor => api => {
|
||||
api.setEnabled(hasDraft(editor));
|
||||
const editorEventCallback = () => api.setEnabled(hasDraft(editor));
|
||||
editor.on('StoreDraft RestoreDraft RemoveDraft', editorEventCallback);
|
||||
return () => editor.off('StoreDraft RestoreDraft RemoveDraft', editorEventCallback);
|
||||
};
|
||||
const register = editor => {
|
||||
startStoreDraft(editor);
|
||||
const onAction = () => {
|
||||
restoreLastDraft(editor);
|
||||
};
|
||||
editor.ui.registry.addButton('restoredraft', {
|
||||
tooltip: 'Restore last draft',
|
||||
icon: 'restore-draft',
|
||||
onAction,
|
||||
onSetup: makeSetupHandler(editor)
|
||||
});
|
||||
editor.ui.registry.addMenuItem('restoredraft', {
|
||||
text: 'Restore last draft',
|
||||
icon: 'restore-draft',
|
||||
onAction,
|
||||
onSetup: makeSetupHandler(editor)
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$4.add('autosave', editor => {
|
||||
register$1(editor);
|
||||
setup(editor);
|
||||
register(editor);
|
||||
editor.on('init', () => {
|
||||
if (shouldRestoreWhenEmpty(editor) && editor.dom.isEmpty(editor.getBody())) {
|
||||
restoreDraft(editor);
|
||||
}
|
||||
});
|
||||
return get(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=("string",t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(a=String).prototype.isPrototypeOf(r)||(null===(s=o.constructor)||void 0===s?void 0:s.name)===a.name)?"string":e;var r,o,a,s})(t));const r=(void 0,t=>undefined===t);var o=tinymce.util.Tools.resolve("tinymce.util.Delay"),a=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),s=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=t=>{const e=/^(\d+)([ms]?)$/.exec(t);return(e&&e[2]?{s:1e3,m:6e4}[e[2]]:1)*parseInt(t,10)},i=t=>e=>e.options.get(t),u=i("autosave_ask_before_unload"),l=i("autosave_restore_when_empty"),c=i("autosave_interval"),d=i("autosave_retention"),m=t=>{const e=document.location;return t.options.get("autosave_prefix").replace(/{path}/g,e.pathname).replace(/{query}/g,e.search).replace(/{hash}/g,e.hash).replace(/{id}/g,t.id)},v=(t,e)=>{if(r(e))return t.dom.isEmpty(t.getBody());{const r=s.trim(e);if(""===r)return!0;{const e=(new DOMParser).parseFromString(r,"text/html");return t.dom.isEmpty(e)}}},f=t=>{var e;const r=parseInt(null!==(e=a.getItem(m(t)+"time"))&&void 0!==e?e:"0",10)||0;return!((new Date).getTime()-r>d(t)&&(p(t,!1),1))},p=(t,e)=>{const r=m(t);a.removeItem(r+"draft"),a.removeItem(r+"time"),!1!==e&&(t=>{t.dispatch("RemoveDraft")})(t)},g=t=>{const e=m(t);!v(t)&&t.isDirty()&&(a.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),a.setItem(e+"time",(new Date).getTime().toString()),(t=>{t.dispatch("StoreDraft")})(t))},y=t=>{var e;const r=m(t);f(t)&&(t.setContent(null!==(e=a.getItem(r+"draft"))&&void 0!==e?e:"",{format:"raw"}),(t=>{t.dispatch("RestoreDraft")})(t))};var D=tinymce.util.Tools.resolve("tinymce.EditorManager");const h=t=>e=>{e.setEnabled(f(t));const r=()=>e.setEnabled(f(t));return t.on("StoreDraft RestoreDraft RemoveDraft",r),()=>t.off("StoreDraft RestoreDraft RemoveDraft",r)};t.add("autosave",(t=>((t=>{const r=t.options.register,o=t=>{const r=e(t);return r?{value:n(t),valid:r}:{valid:!1,message:"Must be a string."}};r("autosave_ask_before_unload",{processor:"boolean",default:!0}),r("autosave_prefix",{processor:"string",default:"tinymce-autosave-{path}{query}{hash}-{id}-"}),r("autosave_restore_when_empty",{processor:"boolean",default:!1}),r("autosave_interval",{processor:o,default:"30s"}),r("autosave_retention",{processor:o,default:"20m"})})(t),(t=>{t.editorManager.on("BeforeUnload",(t=>{let e;s.each(D.get(),(t=>{t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&u(t)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))})),e&&(t.preventDefault(),t.returnValue=e)}))})(t),(t=>{(t=>{const e=c(t);o.setEditorInterval(t,(()=>{g(t)}),e)})(t);const e=()=>{(t=>{t.undoManager.transact((()=>{y(t),p(t)})),t.focus()})(t)};t.ui.registry.addButton("restoredraft",{tooltip:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)}),t.ui.registry.addMenuItem("restoredraft",{text:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)})})(t),t.on("init",(()=>{l(t)&&t.dom.isEmpty(t.getBody())&&y(t)})),(t=>({hasDraft:()=>f(t),storeDraft:()=>g(t),restoreDraft:()=>y(t),removeDraft:e=>p(t,e),isEmpty:e=>v(t,e)}))(t))))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "charmap" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/charmap')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/charmap'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "code" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/code')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/code'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const setContent = (editor, html) => {
|
||||
editor.focus();
|
||||
editor.undoManager.transact(() => {
|
||||
editor.setContent(html);
|
||||
});
|
||||
editor.selection.setCursorLocation();
|
||||
editor.nodeChanged();
|
||||
};
|
||||
const getContent = editor => {
|
||||
return editor.getContent({ source_view: true });
|
||||
};
|
||||
|
||||
const open = editor => {
|
||||
const editorContent = getContent(editor);
|
||||
editor.windowManager.open({
|
||||
title: 'Source Code',
|
||||
size: 'large',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [{
|
||||
type: 'textarea',
|
||||
name: 'code'
|
||||
}]
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'cancel',
|
||||
text: 'Cancel'
|
||||
},
|
||||
{
|
||||
type: 'submit',
|
||||
name: 'save',
|
||||
text: 'Save',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: { code: editorContent },
|
||||
onSubmit: api => {
|
||||
setContent(editor, api.getData().code);
|
||||
api.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const register$1 = editor => {
|
||||
editor.addCommand('mceCodeEditor', () => {
|
||||
open(editor);
|
||||
});
|
||||
};
|
||||
|
||||
const register = editor => {
|
||||
const onAction = () => editor.execCommand('mceCodeEditor');
|
||||
editor.ui.registry.addButton('code', {
|
||||
icon: 'sourcecode',
|
||||
tooltip: 'Source code',
|
||||
onAction
|
||||
});
|
||||
editor.ui.registry.addMenuItem('code', {
|
||||
icon: 'sourcecode',
|
||||
text: 'Source code',
|
||||
onAction
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global.add('code', editor => {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "codesample" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/codesample')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/codesample'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "directionality" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/directionality')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/directionality'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
var _a;
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
} else {
|
||||
return ((_a = v.constructor) === null || _a === void 0 ? void 0 : _a.name) === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = x => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
} else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
} else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
} else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType$1 = type => value => typeOf(value) === type;
|
||||
const isSimpleType = type => value => typeof value === type;
|
||||
const isString = isType$1('string');
|
||||
const isBoolean = isSimpleType('boolean');
|
||||
const isNullable = a => a === null || a === undefined;
|
||||
const isNonNullable = a => !isNullable(a);
|
||||
const isFunction = isSimpleType('function');
|
||||
const isNumber = isSimpleType('number');
|
||||
|
||||
const compose1 = (fbc, fab) => a => fbc(fab(a));
|
||||
const constant = value => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
class Optional {
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
} else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message !== null && message !== void 0 ? message : 'Called getOrDie on None');
|
||||
} else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
toString() {
|
||||
return this.tag ? `some(${ this.value })` : 'none()';
|
||||
}
|
||||
}
|
||||
Optional.singletonNone = new Optional(false);
|
||||
|
||||
const map = (xs, f) => {
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const each = (xs, f) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const filter = (xs, pred) => {
|
||||
const r = [];
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
r.push(x);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
const DOCUMENT = 9;
|
||||
const DOCUMENT_FRAGMENT = 11;
|
||||
const ELEMENT = 1;
|
||||
const TEXT = 3;
|
||||
|
||||
const fromHtml = (html, scope) => {
|
||||
const doc = scope || document;
|
||||
const div = doc.createElement('div');
|
||||
div.innerHTML = html;
|
||||
if (!div.hasChildNodes() || div.childNodes.length > 1) {
|
||||
const message = 'HTML does not have a single root node';
|
||||
console.error(message, html);
|
||||
throw new Error(message);
|
||||
}
|
||||
return fromDom(div.childNodes[0]);
|
||||
};
|
||||
const fromTag = (tag, scope) => {
|
||||
const doc = scope || document;
|
||||
const node = doc.createElement(tag);
|
||||
return fromDom(node);
|
||||
};
|
||||
const fromText = (text, scope) => {
|
||||
const doc = scope || document;
|
||||
const node = doc.createTextNode(text);
|
||||
return fromDom(node);
|
||||
};
|
||||
const fromDom = node => {
|
||||
if (node === null || node === undefined) {
|
||||
throw new Error('Node cannot be null or undefined');
|
||||
}
|
||||
return { dom: node };
|
||||
};
|
||||
const fromPoint = (docElm, x, y) => Optional.from(docElm.dom.elementFromPoint(x, y)).map(fromDom);
|
||||
const SugarElement = {
|
||||
fromHtml,
|
||||
fromTag,
|
||||
fromText,
|
||||
fromDom,
|
||||
fromPoint
|
||||
};
|
||||
|
||||
const is = (element, selector) => {
|
||||
const dom = element.dom;
|
||||
if (dom.nodeType !== ELEMENT) {
|
||||
return false;
|
||||
} else {
|
||||
const elem = dom;
|
||||
if (elem.matches !== undefined) {
|
||||
return elem.matches(selector);
|
||||
} else if (elem.msMatchesSelector !== undefined) {
|
||||
return elem.msMatchesSelector(selector);
|
||||
} else if (elem.webkitMatchesSelector !== undefined) {
|
||||
return elem.webkitMatchesSelector(selector);
|
||||
} else if (elem.mozMatchesSelector !== undefined) {
|
||||
return elem.mozMatchesSelector(selector);
|
||||
} else {
|
||||
throw new Error('Browser lacks native selectors');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
typeof window !== 'undefined' ? window : Function('return this;')();
|
||||
|
||||
const name = element => {
|
||||
const r = element.dom.nodeName;
|
||||
return r.toLowerCase();
|
||||
};
|
||||
const type = element => element.dom.nodeType;
|
||||
const isType = t => element => type(element) === t;
|
||||
const isElement = isType(ELEMENT);
|
||||
const isText = isType(TEXT);
|
||||
const isDocument = isType(DOCUMENT);
|
||||
const isDocumentFragment = isType(DOCUMENT_FRAGMENT);
|
||||
const isTag = tag => e => isElement(e) && name(e) === tag;
|
||||
|
||||
const owner = element => SugarElement.fromDom(element.dom.ownerDocument);
|
||||
const documentOrOwner = dos => isDocument(dos) ? dos : owner(dos);
|
||||
const parent = element => Optional.from(element.dom.parentNode).map(SugarElement.fromDom);
|
||||
const children$2 = element => map(element.dom.childNodes, SugarElement.fromDom);
|
||||
|
||||
const rawSet = (dom, key, value) => {
|
||||
if (isString(value) || isBoolean(value) || isNumber(value)) {
|
||||
dom.setAttribute(key, value + '');
|
||||
} else {
|
||||
console.error('Invalid call to Attribute.set. Key ', key, ':: Value ', value, ':: Element ', dom);
|
||||
throw new Error('Attribute value was not simple');
|
||||
}
|
||||
};
|
||||
const set = (element, key, value) => {
|
||||
rawSet(element.dom, key, value);
|
||||
};
|
||||
const remove = (element, key) => {
|
||||
element.dom.removeAttribute(key);
|
||||
};
|
||||
|
||||
const isShadowRoot = dos => isDocumentFragment(dos) && isNonNullable(dos.dom.host);
|
||||
const supported = isFunction(Element.prototype.attachShadow) && isFunction(Node.prototype.getRootNode);
|
||||
const getRootNode = supported ? e => SugarElement.fromDom(e.dom.getRootNode()) : documentOrOwner;
|
||||
const getShadowRoot = e => {
|
||||
const r = getRootNode(e);
|
||||
return isShadowRoot(r) ? Optional.some(r) : Optional.none();
|
||||
};
|
||||
const getShadowHost = e => SugarElement.fromDom(e.dom.host);
|
||||
|
||||
const inBody = element => {
|
||||
const dom = isText(element) ? element.dom.parentNode : element.dom;
|
||||
if (dom === undefined || dom === null || dom.ownerDocument === null) {
|
||||
return false;
|
||||
}
|
||||
const doc = dom.ownerDocument;
|
||||
return getShadowRoot(SugarElement.fromDom(dom)).fold(() => doc.body.contains(dom), compose1(inBody, getShadowHost));
|
||||
};
|
||||
|
||||
const ancestor$1 = (scope, predicate, isRoot) => {
|
||||
let element = scope.dom;
|
||||
const stop = isFunction(isRoot) ? isRoot : never;
|
||||
while (element.parentNode) {
|
||||
element = element.parentNode;
|
||||
const el = SugarElement.fromDom(element);
|
||||
if (predicate(el)) {
|
||||
return Optional.some(el);
|
||||
} else if (stop(el)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
|
||||
const ancestor = (scope, selector, isRoot) => ancestor$1(scope, e => is(e, selector), isRoot);
|
||||
|
||||
const isSupported = dom => dom.style !== undefined && isFunction(dom.style.getPropertyValue);
|
||||
|
||||
const get = (element, property) => {
|
||||
const dom = element.dom;
|
||||
const styles = window.getComputedStyle(dom);
|
||||
const r = styles.getPropertyValue(property);
|
||||
return r === '' && !inBody(element) ? getUnsafeProperty(dom, property) : r;
|
||||
};
|
||||
const getUnsafeProperty = (dom, property) => isSupported(dom) ? dom.style.getPropertyValue(property) : '';
|
||||
|
||||
const getDirection = element => get(element, 'direction') === 'rtl' ? 'rtl' : 'ltr';
|
||||
|
||||
const children$1 = (scope, predicate) => filter(children$2(scope), predicate);
|
||||
|
||||
const children = (scope, selector) => children$1(scope, e => is(e, selector));
|
||||
|
||||
const getParentElement = element => parent(element).filter(isElement);
|
||||
const getNormalizedBlock = (element, isListItem) => {
|
||||
const normalizedElement = isListItem ? ancestor(element, 'ol,ul') : Optional.some(element);
|
||||
return normalizedElement.getOr(element);
|
||||
};
|
||||
const isListItem = isTag('li');
|
||||
const setDirOnElements = (dom, blocks, dir) => {
|
||||
each(blocks, block => {
|
||||
const blockElement = SugarElement.fromDom(block);
|
||||
const isBlockElementListItem = isListItem(blockElement);
|
||||
const normalizedBlock = getNormalizedBlock(blockElement, isBlockElementListItem);
|
||||
const normalizedBlockParent = getParentElement(normalizedBlock);
|
||||
normalizedBlockParent.each(parent => {
|
||||
dom.setStyle(normalizedBlock.dom, 'direction', null);
|
||||
const parentDirection = getDirection(parent);
|
||||
if (parentDirection === dir) {
|
||||
remove(normalizedBlock, 'dir');
|
||||
} else {
|
||||
set(normalizedBlock, 'dir', dir);
|
||||
}
|
||||
if (getDirection(normalizedBlock) !== dir) {
|
||||
dom.setStyle(normalizedBlock.dom, 'direction', dir);
|
||||
}
|
||||
if (isBlockElementListItem) {
|
||||
const listItems = children(normalizedBlock, 'li[dir],li[style]');
|
||||
each(listItems, listItem => {
|
||||
remove(listItem, 'dir');
|
||||
dom.setStyle(listItem.dom, 'direction', null);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const setDir = (editor, dir) => {
|
||||
if (editor.selection.isEditable()) {
|
||||
setDirOnElements(editor.dom, editor.selection.getSelectedBlocks(), dir);
|
||||
editor.nodeChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const register$1 = editor => {
|
||||
editor.addCommand('mceDirectionLTR', () => {
|
||||
setDir(editor, 'ltr');
|
||||
});
|
||||
editor.addCommand('mceDirectionRTL', () => {
|
||||
setDir(editor, 'rtl');
|
||||
});
|
||||
};
|
||||
|
||||
const getNodeChangeHandler = (editor, dir) => api => {
|
||||
const nodeChangeHandler = e => {
|
||||
const element = SugarElement.fromDom(e.element);
|
||||
api.setActive(getDirection(element) === dir);
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChangeHandler);
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
return () => editor.off('NodeChange', nodeChangeHandler);
|
||||
};
|
||||
const register = editor => {
|
||||
editor.ui.registry.addToggleButton('ltr', {
|
||||
tooltip: 'Left to right',
|
||||
icon: 'ltr',
|
||||
onAction: () => editor.execCommand('mceDirectionLTR'),
|
||||
onSetup: getNodeChangeHandler(editor, 'ltr')
|
||||
});
|
||||
editor.ui.registry.addToggleButton('rtl', {
|
||||
tooltip: 'Right to left',
|
||||
icon: 'rtl',
|
||||
onAction: () => editor.execCommand('mceDirectionRTL'),
|
||||
onSetup: getNodeChangeHandler(editor, 'rtl')
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global.add('directionality', editor => {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>e=>typeof e===t,o=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(o=r=t,(n=String).prototype.isPrototypeOf(o)||(null===(i=r.constructor)||void 0===i?void 0:i.name)===n.name)?"string":e;var o,r,n,i})(t),r=e("boolean"),n=t=>!(t=>null==t)(t),i=e("function"),s=e("number"),l=(!1,()=>false);class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return n(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=(t,e)=>{for(let o=0,r=t.length;o<r;o++)e(t[o],o)},c=t=>{if(null==t)throw new Error("Node cannot be null or undefined");return{dom:t}},d=c,h=(t,e)=>{const o=t.dom;if(1!==o.nodeType)return!1;{const t=o;if(void 0!==t.matches)return t.matches(e);if(void 0!==t.msMatchesSelector)return t.msMatchesSelector(e);if(void 0!==t.webkitMatchesSelector)return t.webkitMatchesSelector(e);if(void 0!==t.mozMatchesSelector)return t.mozMatchesSelector(e);throw new Error("Browser lacks native selectors")}};"undefined"!=typeof window?window:Function("return this;")();const m=t=>e=>(t=>t.dom.nodeType)(e)===t,g=m(1),f=m(3),v=m(9),y=m(11),p=(t,e)=>{t.dom.removeAttribute(e)},w=i(Element.prototype.attachShadow)&&i(Node.prototype.getRootNode)?t=>d(t.dom.getRootNode()):t=>v(t)?t:d(t.dom.ownerDocument),b=t=>d(t.dom.host),N=t=>{const e=f(t)?t.dom.parentNode:t.dom;if(null==e||null===e.ownerDocument)return!1;const o=e.ownerDocument;return(t=>{const e=w(t);return y(o=e)&&n(o.dom.host)?a.some(e):a.none();var o})(d(e)).fold((()=>o.body.contains(e)),(r=N,i=b,t=>r(i(t))));var r,i},S=t=>"rtl"===((t,e)=>{const o=t.dom,r=window.getComputedStyle(o).getPropertyValue(e);return""!==r||N(t)?r:((t,e)=>(t=>void 0!==t.style&&i(t.style.getPropertyValue))(t)?t.style.getPropertyValue(e):"")(o,e)})(t,"direction")?"rtl":"ltr",A=(t,e)=>((t,o)=>((t,e)=>{const o=[];for(let r=0,n=t.length;r<n;r++){const n=t[r];e(n,r)&&o.push(n)}return o})(((t,e)=>{const o=t.length,r=new Array(o);for(let n=0;n<o;n++){const o=t[n];r[n]=e(o,n)}return r})(t.dom.childNodes,d),(t=>h(t,e))))(t),E=("li",t=>g(t)&&"li"===t.dom.nodeName.toLowerCase());const T=(t,e,n)=>{u(e,(e=>{const c=d(e),m=E(c),f=((t,e)=>{return(e?(o=t,r="ol,ul",((t,e,o)=>{let n=t.dom;const s=i(o)?o:l;for(;n.parentNode;){n=n.parentNode;const t=d(n);if(h(t,r))return a.some(t);if(s(t))break}return a.none()})(o,0,n)):a.some(t)).getOr(t);var o,r,n})(c,m);var v;(v=f,(t=>a.from(t.dom.parentNode).map(d))(v).filter(g)).each((e=>{if(t.setStyle(f.dom,"direction",null),S(e)===n?p(f,"dir"):((t,e,n)=>{((t,e,n)=>{if(!(o(n)||r(n)||s(n)))throw console.error("Invalid call to Attribute.set. Key ",e,":: Value ",n,":: Element ",t),new Error("Attribute value was not simple");t.setAttribute(e,n+"")})(t.dom,e,n)})(f,"dir",n),S(f)!==n&&t.setStyle(f.dom,"direction",n),m){const e=A(f,"li[dir],li[style]");u(e,(e=>{p(e,"dir"),t.setStyle(e.dom,"direction",null)}))}}))}))},C=(t,e)=>{t.selection.isEditable()&&(T(t.dom,t.selection.getSelectedBlocks(),e),t.nodeChanged())},D=(t,e)=>o=>{const r=r=>{const n=d(r.element);o.setActive(S(n)===e),o.setEnabled(t.selection.isEditable())};return t.on("NodeChange",r),o.setEnabled(t.selection.isEditable()),()=>t.off("NodeChange",r)};t.add("directionality",(t=>{(t=>{t.addCommand("mceDirectionLTR",(()=>{C(t,"ltr")})),t.addCommand("mceDirectionRTL",(()=>{C(t,"rtl")}))})(t),(t=>{t.ui.registry.addToggleButton("ltr",{tooltip:"Left to right",icon:"ltr",onAction:()=>t.execCommand("mceDirectionLTR"),onSetup:D(t,"ltr")}),t.ui.registry.addToggleButton("rtl",{tooltip:"Right to left",icon:"rtl",onAction:()=>t.execCommand("mceDirectionRTL"),onSetup:D(t,"rtl")})})(t)}))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "emoticons" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/emoticons')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/emoticons'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* TinyMCE version 6.8.3 (2024-02-08)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const eq = t => a => t === a;
|
||||
const isNull = eq(null);
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = a => a === null || a === undefined;
|
||||
const isNonNullable = a => !isNullable(a);
|
||||
|
||||
const noop = () => {
|
||||
};
|
||||
const constant = value => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
class Optional {
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
} else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
} else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message !== null && message !== void 0 ? message : 'Called getOrDie on None');
|
||||
} else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
toString() {
|
||||
return this.tag ? `some(${ this.value })` : 'none()';
|
||||
}
|
||||
}
|
||||
Optional.singletonNone = new Optional(false);
|
||||
|
||||
const exists = (xs, pred) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const map$1 = (xs, f) => {
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const each$1 = (xs, f) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
|
||||
const Cell = initial => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = v => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
const last = (fn, rate) => {
|
||||
let timer = null;
|
||||
const cancel = () => {
|
||||
if (!isNull(timer)) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const throttle = (...args) => {
|
||||
cancel();
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(null, args);
|
||||
}, rate);
|
||||
};
|
||||
return {
|
||||
cancel,
|
||||
throttle
|
||||
};
|
||||
};
|
||||
|
||||
const insertEmoticon = (editor, ch) => {
|
||||
editor.insertContent(ch);
|
||||
};
|
||||
|
||||
const keys = Object.keys;
|
||||
const hasOwnProperty = Object.hasOwnProperty;
|
||||
const each = (obj, f) => {
|
||||
const props = keys(obj);
|
||||
for (let k = 0, len = props.length; k < len; k++) {
|
||||
const i = props[k];
|
||||
const x = obj[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const map = (obj, f) => {
|
||||
return tupleMap(obj, (x, i) => ({
|
||||
k: i,
|
||||
v: f(x, i)
|
||||
}));
|
||||
};
|
||||
const tupleMap = (obj, f) => {
|
||||
const r = {};
|
||||
each(obj, (x, i) => {
|
||||
const tuple = f(x, i);
|
||||
r[tuple.k] = tuple.v;
|
||||
});
|
||||
return r;
|
||||
};
|
||||
const has = (obj, key) => hasOwnProperty.call(obj, key);
|
||||
|
||||
const shallow = (old, nu) => {
|
||||
return nu;
|
||||
};
|
||||
const baseMerge = merger => {
|
||||
return (...objects) => {
|
||||
if (objects.length === 0) {
|
||||
throw new Error(`Can't merge zero objects`);
|
||||
}
|
||||
const ret = {};
|
||||
for (let j = 0; j < objects.length; j++) {
|
||||
const curObject = objects[j];
|
||||
for (const key in curObject) {
|
||||
if (has(curObject, key)) {
|
||||
ret[key] = merger(ret[key], curObject[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
const merge = baseMerge(shallow);
|
||||
|
||||
const singleton = doRevoke => {
|
||||
const subject = Cell(Optional.none());
|
||||
const revoke = () => subject.get().each(doRevoke);
|
||||
const clear = () => {
|
||||
revoke();
|
||||
subject.set(Optional.none());
|
||||
};
|
||||
const isSet = () => subject.get().isSome();
|
||||
const get = () => subject.get();
|
||||
const set = s => {
|
||||
revoke();
|
||||
subject.set(Optional.some(s));
|
||||
};
|
||||
return {
|
||||
clear,
|
||||
isSet,
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
const value = () => {
|
||||
const subject = singleton(noop);
|
||||
const on = f => subject.get().each(f);
|
||||
return {
|
||||
...subject,
|
||||
on
|
||||
};
|
||||
};
|
||||
|
||||
const checkRange = (str, substr, start) => substr === '' || str.length >= substr.length && str.substr(start, start + substr.length) === substr;
|
||||
const contains = (str, substr, start = 0, end) => {
|
||||
const idx = str.indexOf(substr, start);
|
||||
if (idx !== -1) {
|
||||
return isUndefined(end) ? true : idx + substr.length <= end;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const startsWith = (str, prefix) => {
|
||||
return checkRange(str, prefix, 0);
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.Resource');
|
||||
|
||||
const DEFAULT_ID = 'tinymce.plugins.emoticons';
|
||||
const option = name => editor => editor.options.get(name);
|
||||
const register$2 = (editor, pluginUrl) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('emoticons_database', {
|
||||
processor: 'string',
|
||||
default: 'emojis'
|
||||
});
|
||||
registerOption('emoticons_database_url', {
|
||||
processor: 'string',
|
||||
default: `${ pluginUrl }/js/${ getEmojiDatabase(editor) }${ editor.suffix }.js`
|
||||
});
|
||||
registerOption('emoticons_database_id', {
|
||||
processor: 'string',
|
||||
default: DEFAULT_ID
|
||||
});
|
||||
registerOption('emoticons_append', {
|
||||
processor: 'object',
|
||||
default: {}
|
||||
});
|
||||
registerOption('emoticons_images_url', {
|
||||
processor: 'string',
|
||||
default: 'https://twemoji.maxcdn.com/v/13.0.1/72x72/'
|
||||
});
|
||||
};
|
||||
const getEmojiDatabase = option('emoticons_database');
|
||||
const getEmojiDatabaseUrl = option('emoticons_database_url');
|
||||
const getEmojiDatabaseId = option('emoticons_database_id');
|
||||
const getAppendedEmoji = option('emoticons_append');
|
||||
const getEmojiImageUrl = option('emoticons_images_url');
|
||||
|
||||
const ALL_CATEGORY = 'All';
|
||||
const categoryNameMap = {
|
||||
symbols: 'Symbols',
|
||||
people: 'People',
|
||||
animals_and_nature: 'Animals and Nature',
|
||||
food_and_drink: 'Food and Drink',
|
||||
activity: 'Activity',
|
||||
travel_and_places: 'Travel and Places',
|
||||
objects: 'Objects',
|
||||
flags: 'Flags',
|
||||
user: 'User Defined'
|
||||
};
|
||||
const translateCategory = (categories, name) => has(categories, name) ? categories[name] : name;
|
||||
const getUserDefinedEmoji = editor => {
|
||||
const userDefinedEmoticons = getAppendedEmoji(editor);
|
||||
return map(userDefinedEmoticons, value => ({
|
||||
keywords: [],
|
||||
category: 'user',
|
||||
...value
|
||||
}));
|
||||
};
|
||||
const initDatabase = (editor, databaseUrl, databaseId) => {
|
||||
const categories = value();
|
||||
const all = value();
|
||||
const emojiImagesUrl = getEmojiImageUrl(editor);
|
||||
const getEmoji = lib => {
|
||||
if (startsWith(lib.char, '<img')) {
|
||||
return lib.char.replace(/src="([^"]+)"/, (match, url) => `src="${ emojiImagesUrl }${ url }"`);
|
||||
} else {
|
||||
return lib.char;
|
||||
}
|
||||
};
|
||||
const processEmojis = emojis => {
|
||||
const cats = {};
|
||||
const everything = [];
|
||||
each(emojis, (lib, title) => {
|
||||
const entry = {
|
||||
title,
|
||||
keywords: lib.keywords,
|
||||
char: getEmoji(lib),
|
||||
category: translateCategory(categoryNameMap, lib.category)
|
||||
};
|
||||
const current = cats[entry.category] !== undefined ? cats[entry.category] : [];
|
||||
cats[entry.category] = current.concat([entry]);
|
||||
everything.push(entry);
|
||||
});
|
||||
categories.set(cats);
|
||||
all.set(everything);
|
||||
};
|
||||
editor.on('init', () => {
|
||||
global.load(databaseId, databaseUrl).then(emojis => {
|
||||
const userEmojis = getUserDefinedEmoji(editor);
|
||||
processEmojis(merge(emojis, userEmojis));
|
||||
}, err => {
|
||||
console.log(`Failed to load emojis: ${ err }`);
|
||||
categories.set({});
|
||||
all.set([]);
|
||||
});
|
||||
});
|
||||
const listCategory = category => {
|
||||
if (category === ALL_CATEGORY) {
|
||||
return listAll();
|
||||
}
|
||||
return categories.get().bind(cats => Optional.from(cats[category])).getOr([]);
|
||||
};
|
||||
const listAll = () => all.get().getOr([]);
|
||||
const listCategories = () => [ALL_CATEGORY].concat(keys(categories.get().getOr({})));
|
||||
const waitForLoad = () => {
|
||||
if (hasLoaded()) {
|
||||
return Promise.resolve(true);
|
||||
} else {
|
||||
return new Promise((resolve, reject) => {
|
||||
let numRetries = 15;
|
||||
const interval = setInterval(() => {
|
||||
if (hasLoaded()) {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
} else {
|
||||
numRetries--;
|
||||
if (numRetries < 0) {
|
||||
console.log('Could not load emojis from url: ' + databaseUrl);
|
||||
clearInterval(interval);
|
||||
reject(false);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
};
|
||||
const hasLoaded = () => categories.isSet() && all.isSet();
|
||||
return {
|
||||
listCategories,
|
||||
hasLoaded,
|
||||
waitForLoad,
|
||||
listAll,
|
||||
listCategory
|
||||
};
|
||||
};
|
||||
|
||||
const emojiMatches = (emoji, lowerCasePattern) => contains(emoji.title.toLowerCase(), lowerCasePattern) || exists(emoji.keywords, k => contains(k.toLowerCase(), lowerCasePattern));
|
||||
const emojisFrom = (list, pattern, maxResults) => {
|
||||
const matches = [];
|
||||
const lowerCasePattern = pattern.toLowerCase();
|
||||
const reachedLimit = maxResults.fold(() => never, max => size => size >= max);
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (pattern.length === 0 || emojiMatches(list[i], lowerCasePattern)) {
|
||||
matches.push({
|
||||
value: list[i].char,
|
||||
text: list[i].title,
|
||||
icon: list[i].char
|
||||
});
|
||||
if (reachedLimit(matches.length)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
const patternName = 'pattern';
|
||||
const open = (editor, database) => {
|
||||
const initialState = {
|
||||
pattern: '',
|
||||
results: emojisFrom(database.listAll(), '', Optional.some(300))
|
||||
};
|
||||
const currentTab = Cell(ALL_CATEGORY);
|
||||
const scan = dialogApi => {
|
||||
const dialogData = dialogApi.getData();
|
||||
const category = currentTab.get();
|
||||
const candidates = database.listCategory(category);
|
||||
const results = emojisFrom(candidates, dialogData[patternName], category === ALL_CATEGORY ? Optional.some(300) : Optional.none());
|
||||
dialogApi.setData({ results });
|
||||
};
|
||||
const updateFilter = last(dialogApi => {
|
||||
scan(dialogApi);
|
||||
}, 200);
|
||||
const searchField = {
|
||||
label: 'Search',
|
||||
type: 'input',
|
||||
name: patternName
|
||||
};
|
||||
const resultsField = {
|
||||
type: 'collection',
|
||||
name: 'results'
|
||||
};
|
||||
const getInitialState = () => {
|
||||
const body = {
|
||||
type: 'tabpanel',
|
||||
tabs: map$1(database.listCategories(), cat => ({
|
||||
title: cat,
|
||||
name: cat,
|
||||
items: [
|
||||
searchField,
|
||||
resultsField
|
||||
]
|
||||
}))
|
||||
};
|
||||
return {
|
||||
title: 'Emojis',
|
||||
size: 'normal',
|
||||
body,
|
||||
initialData: initialState,
|
||||
onTabChange: (dialogApi, details) => {
|
||||
currentTab.set(details.newTabName);
|
||||
updateFilter.throttle(dialogApi);
|
||||
},
|
||||
onChange: updateFilter.throttle,
|
||||
onAction: (dialogApi, actionData) => {
|
||||
if (actionData.name === 'results') {
|
||||
insertEmoticon(editor, actionData.value);
|
||||
dialogApi.close();
|
||||
}
|
||||
},
|
||||
buttons: [{
|
||||
type: 'cancel',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}]
|
||||
};
|
||||
};
|
||||
const dialogApi = editor.windowManager.open(getInitialState());
|
||||
dialogApi.focus(patternName);
|
||||
if (!database.hasLoaded()) {
|
||||
dialogApi.block('Loading emojis...');
|
||||
database.waitForLoad().then(() => {
|
||||
dialogApi.redial(getInitialState());
|
||||
updateFilter.throttle(dialogApi);
|
||||
dialogApi.focus(patternName);
|
||||
dialogApi.unblock();
|
||||
}).catch(_err => {
|
||||
dialogApi.redial({
|
||||
title: 'Emojis',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [{
|
||||
type: 'alertbanner',
|
||||
level: 'error',
|
||||
icon: 'warning',
|
||||
text: 'Could not load emojis'
|
||||
}]
|
||||
},
|
||||
buttons: [{
|
||||
type: 'cancel',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}],
|
||||
initialData: {
|
||||
pattern: '',
|
||||
results: []
|
||||
}
|
||||
});
|
||||
dialogApi.focus(patternName);
|
||||
dialogApi.unblock();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const register$1 = (editor, database) => {
|
||||
editor.addCommand('mceEmoticons', () => open(editor, database));
|
||||
};
|
||||
|
||||
const setup = editor => {
|
||||
editor.on('PreInit', () => {
|
||||
editor.parser.addAttributeFilter('data-emoticon', nodes => {
|
||||
each$1(nodes, node => {
|
||||
node.attr('data-mce-resize', 'false');
|
||||
node.attr('data-mce-placeholder', '1');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const init = (editor, database) => {
|
||||
editor.ui.registry.addAutocompleter('emoticons', {
|
||||
trigger: ':',
|
||||
columns: 'auto',
|
||||
minChars: 2,
|
||||
fetch: (pattern, maxResults) => database.waitForLoad().then(() => {
|
||||
const candidates = database.listAll();
|
||||
return emojisFrom(candidates, pattern, Optional.some(maxResults));
|
||||
}),
|
||||
onAction: (autocompleteApi, rng, value) => {
|
||||
editor.selection.setRng(rng);
|
||||
editor.insertContent(value);
|
||||
autocompleteApi.hide();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetupEditable = editor => api => {
|
||||
const nodeChanged = () => {
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChanged);
|
||||
nodeChanged();
|
||||
return () => {
|
||||
editor.off('NodeChange', nodeChanged);
|
||||
};
|
||||
};
|
||||
const register = editor => {
|
||||
const onAction = () => editor.execCommand('mceEmoticons');
|
||||
editor.ui.registry.addButton('emoticons', {
|
||||
tooltip: 'Emojis',
|
||||
icon: 'emoji',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
editor.ui.registry.addMenuItem('emoticons', {
|
||||
text: 'Emojis...',
|
||||
icon: 'emoji',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('emoticons', (editor, pluginUrl) => {
|
||||
register$2(editor, pluginUrl);
|
||||
const databaseUrl = getEmojiDatabaseUrl(editor);
|
||||
const databaseId = getEmojiDatabaseId(editor);
|
||||
const database = initDatabase(editor, databaseUrl, databaseId);
|
||||
register$1(editor, database);
|
||||
register(editor);
|
||||
init(editor, database);
|
||||
setup(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "fullscreen" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/fullscreen')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/fullscreen'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "help" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/help')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/help'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,90 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.ar',
|
||||
'<h1>بدء التنقل بواسطة لوحة المفاتيح</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>التركيز على شريط القوائم</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F9</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F9</dd>\n' +
|
||||
' <dt>التركيز على شريط الأدوات</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F10</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F10</dd>\n' +
|
||||
' <dt>التركيز على التذييل</dt>\n' +
|
||||
' <dd>نظاما التشغيل Windows أو Linux: Alt + F11</dd>\n' +
|
||||
' <dd>نظام التشغيل macOS: ⌥F11</dd>\n' +
|
||||
' <dt>التركيز على شريط أدوات السياق</dt>\n' +
|
||||
' <dd>أنظمة التشغيل Windows أو Linux أو macOS: Ctrl+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>سيبدأ التنقل عند عنصر واجهة المستخدم الأول، والذي سيتم تمييزه أو تسطيره في حالة العنصر الأول في\n' +
|
||||
' مسار عنصر التذييل.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل بين أقسام واجهة المستخدم</h1>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد أقسام واجهة المستخدم إلى القسم التالي، اضغط على <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد أقسام واجهة المستخدم إلى القسم السابق، اضغط على <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>ترتيب علامات <strong>Tab</strong> لأقسام واجهة المستخدم هذه هو:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>شريط القوائم</li>\n' +
|
||||
' <li>كل مجموعة شريط الأدوات</li>\n' +
|
||||
' <li>الشريط الجانبي</li>\n' +
|
||||
' <li>مسار العنصر في التذييل</li>\n' +
|
||||
' <li>زر تبديل عدد الكلمات في التذييل</li>\n' +
|
||||
' <li>رابط إدراج العلامة التجارية في التذييل</li>\n' +
|
||||
' <li>مؤشر تغيير حجم المحرر في التذييل</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>إذا لم يكن قسم واجهة المستخدم موجودًا، فسيتم تخطيه.</p>\n' +
|
||||
'\n' +
|
||||
'<p>إذا كان التذييل يحتوي على التركيز على التنقل بواسطة لوحة المفاتيح، ولا يوجد شريط جانبي مرئي، فإن الضغط على <strong>Shift+Tab</strong>\n' +
|
||||
' ينقل التركيز إلى مجموعة شريط الأدوات الأولى، وليس الأخيرة.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل بين أقسام واجهة المستخدم</h1>\n' +
|
||||
'\n' +
|
||||
'<p>للانتقال من أحد عناصر واجهة المستخدم إلى العنصر التالي، اضغط على مفتاح <strong>السهم</strong> المناسب.</p>\n' +
|
||||
'\n' +
|
||||
'<p>مفتاحا السهمين <strong>اليسار</strong> و<strong>اليمين</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>التنقل بين القوائم في شريط القوائم.</li>\n' +
|
||||
' <li>فتح قائمة فرعية في القائمة.</li>\n' +
|
||||
' <li>التنقل بين الأزرار في مجموعة شريط الأدوات.</li>\n' +
|
||||
' <li>التنقل بين العناصر في مسار عنصر التذييل.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>مفتاحا السهمين <strong>لأسفل</strong> و<strong>لأعلى</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>التنقل بين عناصر القائمة في القائمة.</li>\n' +
|
||||
' <li>التنقل بين العناصر في قائمة شريط الأدوات المنبثقة.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>دورة مفاتيح <strong>الأسهم</strong> داخل قسم واجهة المستخدم التي تم التركيز عليها.</p>\n' +
|
||||
'\n' +
|
||||
'<p>لإغلاق قائمة مفتوحة أو قائمة فرعية مفتوحة أو قائمة منبثقة مفتوحة، اضغط على مفتاح <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>إذا كان التركيز الحالي على "الجزء العلوي" من قسم معين لواجهة المستخدم، فإن الضغط على مفتاح <strong>Esc</strong> يؤدي أيضًا إلى الخروج\n' +
|
||||
' من التنقل بواسطة لوحة المفاتيح بالكامل.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>تنفيذ عنصر قائمة أو زر شريط أدوات</h1>\n' +
|
||||
'\n' +
|
||||
'<p>عندما يتم تمييز عنصر القائمة المطلوب أو زر شريط الأدوات، اضغط على زر <strong>Return</strong>، أو <strong>Enter</strong>،\n' +
|
||||
' أو <strong>مفتاح المسافة</strong> لتنفيذ العنصر.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل في مربعات الحوار غير المبوبة</h1>\n' +
|
||||
'\n' +
|
||||
'<p>في مربعات الحوار غير المبوبة، يتم التركيز على المكون التفاعلي الأول عند فتح مربع الحوار.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التنقل بين مكونات الحوار التفاعلي بالضغط على زر <strong>Tab</strong> أو <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>التنقل في مربعات الحوار المبوبة</h1>\n' +
|
||||
'\n' +
|
||||
'<p>في مربعات الحوار المبوبة، يتم التركيز على الزر الأول في قائمة علامات التبويب عند فتح مربع الحوار.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التنقل بين المكونات التفاعلية لعلامة التبويب لمربع الحوار هذه بالضغط على زر <strong>Tab</strong> أو\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>التبديل إلى علامة تبويب أخرى لمربع الحوار من خلال التركيز على قائمة علامة التبويب ثم الضغط على زر <strong>السهم</strong> المناسب\n' +
|
||||
' مفتاح للتنقل بين علامات التبويب المتاحة.</p>\n');
|
||||
@@ -0,0 +1,90 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.bg_BG',
|
||||
'<h1>Начало на навигацията с клавиатурата</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Фокусиране върху лентата с менюта</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Фокусиране върху лентата с инструменти</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Фокусиране върху долния колонтитул</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Фокусиране върху контекстуалната лента с инструменти</dt>\n' +
|
||||
' <dd>Windows, Linux или macOS: Ctrl+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Навигацията ще започне с първия елемент на ПИ, който ще бъде маркиран или подчертан в случая на първия елемент в\n' +
|
||||
' пътя до елемента в долния колонтитул.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране между раздели на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към следващия, натиснете <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към предишния, натиснете <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Редът за <strong>обхождане с табулация</strong> на тези раздели на ПИ е:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Лентата с менюта</li>\n' +
|
||||
' <li>Всяка група на лентата с инструменти</li>\n' +
|
||||
' <li>Страничната лента</li>\n' +
|
||||
' <li>Пътят до елемента в долния колонтитул</li>\n' +
|
||||
' <li>Бутонът за превключване на броя на думите в долния колонтитул</li>\n' +
|
||||
' <li>Връзката за търговска марка в долния колонтитул</li>\n' +
|
||||
' <li>Манипулаторът за преоразмеряване на редактора в долния колонтитул</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Ако някой раздел на ПИ липсва, той се пропуска.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако долният колонтитул има фокус за навигация с клавиатурата и няма странична лента, натискането на <strong>Shift+Tab</strong>\n' +
|
||||
' премества фокуса към първата група на лентата с инструменти, а не към последната.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в разделите на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един елемент на ПИ към следващия, натиснете съответния клавиш със <strong>стрелка</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>наляво</strong> и <strong>надясно</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между менютата в лентата с менюто;</li>\n' +
|
||||
' <li>отваряте подменю в меню;</li>\n' +
|
||||
' <li>се придвижвате между бутоните в група на лентата с инструменти;</li>\n' +
|
||||
' <li>се придвижвате между елементи в пътя до елемент в долния колонтитул.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>надолу</strong> и <strong>нагоре</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между елементите от менюто в дадено меню;</li>\n' +
|
||||
' <li>се придвижвате между елементите в изскачащо меню на лентата с инструменти.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Клавишите със <strong>стрелки</strong> се придвижват в рамките на фокусирания раздел на ПИ.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да затворите отворено меню, подменю или изскачащо меню, натиснете клавиша <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако текущият фокус е върху „горната част“ на конкретен раздел на ПИ, натискането на клавиша <strong>Esc</strong> също излиза\n' +
|
||||
' напълно от навигацията с клавиатурата.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Изпълнение на елемент от менюто или бутон от лентата с инструменти</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Когато желаният елемент от менюто или бутон от лентата с инструменти е маркиран, натиснете <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' или <strong>клавиша за интервал</strong>, за да изпълните елемента.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци без раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци без раздели първият интерактивен компонент се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на диалоговия прозорец, като натиснете <strong>Tab</strong> или <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци с раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци с раздели първият бутон в менюто с раздели се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на този диалогов раздел, като натиснете <strong>Tab</strong> или\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Превключете към друг диалогов раздел, като фокусирате върху менюто с раздели и след това натиснете съответния клавиш със <strong>стрелка</strong>,\n' +
|
||||
' за да преминете през наличните раздели.</p>\n');
|
||||
@@ -0,0 +1,90 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.ca',
|
||||
'<h1>Inici de la navegació amb el teclat</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Enfocar la barra de menús</dt>\n' +
|
||||
' <dd>Windows o Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
" <dt>Enfocar la barra d'eines</dt>\n" +
|
||||
' <dd>Windows o Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Enfocar el peu de pàgina</dt>\n' +
|
||||
' <dd>Windows o Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
" <dt>Enfocar una barra d'eines contextual</dt>\n" +
|
||||
' <dd>Windows, Linux o macOS: Ctrl+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
"<p>La navegació començarà en el primer element de la interfície d'usuari, que es ressaltarà o subratllarà per al primer element a\n" +
|
||||
" la ruta de l'element de peu de pàgina.</p>\n" +
|
||||
'\n' +
|
||||
"<h1>Navegació entre seccions de la interfície d'usuari</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'una secció de la interfície d'usuari a la següent, premeu la tecla <strong>Tab</strong>.</p>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'una secció de la interfície d'usuari a l'anterior, premeu les tecles <strong>Maj+Tab</strong>.</p>\n" +
|
||||
'\n' +
|
||||
"<p>L'ordre en prémer la tecla <strong>Tab</strong> d'aquestes secciones de la interfície d'usuari és:</p>\n" +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Barra de menús</li>\n' +
|
||||
" <li>Cada grup de la barra d'eines</li>\n" +
|
||||
' <li>Barra lateral</li>\n' +
|
||||
" <li>Ruta de l'element del peu de pàgina</li>\n" +
|
||||
' <li>Botó de commutació de recompte de paraules al peu de pàgina</li>\n' +
|
||||
' <li>Enllaç de marca del peu de pàgina</li>\n' +
|
||||
" <li>Control de canvi de mida de l'editor al peu de pàgina</li>\n" +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
"<p>Si no hi ha una secció de la interfície d'usuari, s'ometrà.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Si el peu de pàgina té el focus de navegació del teclat i no hi ha cap barra lateral visible, en prémer <strong>Maj+Tab</strong>\n' +
|
||||
" el focus es mou al primer grup de la barra d'eines, no l'últim.</p>\n" +
|
||||
'\n' +
|
||||
"<h1>Navegació dins de les seccions de la interfície d'usuari</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos des d'un element de la interfície d'usuari al següent, premeu la tecla de <strong>Fletxa</strong> adequada.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Les tecles de fletxa <strong>Esquerra</strong> i <strong>Dreta</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>us permeten desplaçar-vos entre menús de la barra de menús.</li>\n' +
|
||||
' <li>obren un submenú en un menú.</li>\n' +
|
||||
" <li>us permeten desplaçar-vos entre botons d'un grup de la barra d'eines.</li>\n" +
|
||||
" <li>us permeten desplaçar-vos entre elements de la ruta d'elements del peu de pàgina.</li>\n" +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Les tecles de fletxa <strong>Avall</strong> i <strong>Amunt</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
" <li>us permeten desplaçar-vos entre elements de menú d'un menú.</li>\n" +
|
||||
" <li>us permeten desplaçar-vos entre elements d'un menú emergent de la barra d'eines.</li>\n" +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
"<p>Les tecles de <strong>Fletxa</strong> us permeten desplaçar-vos dins de la secció de la interfície d'usuari que té el focus.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Per tancar un menú, un submenú o un menú emergent oberts, premeu la tecla <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Si el focus actual es troba a la ‘part superior’ d'una secció específica de la interfície d'usuari, en prémer la tecla <strong>Esc</strong> també es tanca\n" +
|
||||
' completament la navegació amb el teclat.</p>\n' +
|
||||
'\n' +
|
||||
"<h1>Execució d'un element de menú o d'un botó de la barra d'eines</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Quan l'element del menú o el botó de la barra d'eines que desitgeu estigui ressaltat, premeu <strong>Retorn</strong>, <strong>Intro</strong>\n" +
|
||||
" o la <strong>barra d'espai</strong> per executar l'element.</p>\n" +
|
||||
'\n' +
|
||||
'<h1>Navegació per quadres de diàleg sense pestanyes</h1>\n' +
|
||||
'\n' +
|
||||
"<p>En els quadres de diàleg sense pestanyes, el primer component interactiu pren el focus quan s'obre el quadre diàleg.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Premeu la tecla <strong>Tab</strong> o les tecles <strong>Maj+Tab</strong> per desplaçar-vos entre components interactius del quadre de diàleg.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegació per quadres de diàleg amb pestanyes</h1>\n' +
|
||||
'\n' +
|
||||
"<p>En els quadres de diàleg amb pestanyes, el primer botó del menú de la pestanya pren el focus quan s'obre el quadre diàleg.</p>\n" +
|
||||
'\n' +
|
||||
"<p>Per desplaçar-vos entre components interactius d'aquest quadre de diàleg, premeu la tecla <strong>Tab</strong> o\n" +
|
||||
' les tecles <strong>Maj+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Canvieu a la pestanya d'un altre quadre de diàleg, tot enfocant el menú de la pestanya, i després premeu la tecla <strong>Fletxa</strong> adequada\n" +
|
||||
' per canviar entre les pestanyes disponibles.</p>\n');
|
||||
@@ -0,0 +1,90 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.cs',
|
||||
'<h1>Začínáme navigovat pomocí klávesnice</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Přejít na řádek nabídek</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Přejít na panel nástrojů</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Přejít na zápatí</dt>\n' +
|
||||
' <dd>Windows nebo Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Přejít na kontextový panel nástrojů</dt>\n' +
|
||||
' <dd>Windows, Linux nebo macOS: Ctrl+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigace začne u první položky uživatelského rozhraní, která bude zvýrazněna nebo v případě první položky\n' +
|
||||
' cesty k prvku zápatí podtržena.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace mezi oddíly uživatelského rozhraní</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Stisknutím klávesy <strong>Tab</strong> se posunete z jednoho oddílu uživatelského rozhraní na další.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Stisknutím kláves <strong>Shift+Tab</strong> se posunete z jednoho oddílu uživatelského rozhraní na předchozí.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pořadí přepínání mezi oddíly uživatelského rozhraní pomocí klávesy <strong>Tab</strong>:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Řádek nabídek</li>\n' +
|
||||
' <li>Každá skupina panelu nástrojů</li>\n' +
|
||||
' <li>Boční panel</li>\n' +
|
||||
' <li>Cesta k prvku v zápatí.</li>\n' +
|
||||
' <li>Tlačítko přepínače počtu slov v zápatí</li>\n' +
|
||||
' <li>Odkaz na informace o značce v zápatí</li>\n' +
|
||||
' <li>Úchyt pro změnu velikosti editoru v zápatí</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud nějaký oddíl uživatelského rozhraní není přítomen, je přeskočen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je zápatí vybrané pro navigaci pomocí klávesnice a není zobrazen žádný boční panel, stisknutím kláves <strong>Shift+Tab</strong>\n' +
|
||||
' přejdete na první skupinu panelu nástrojů, nikoli na poslední.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v rámci oddílů uživatelského rozhraní</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li se přesunout z jednoho prvku uživatelského rozhraní na další, stiskněte příslušnou klávesu s <strong>šipkou</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Klávesy s šipkou <strong>vlevo</strong> a <strong>vpravo</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>umožňují přesun mezi nabídkami na řádku nabídek;</li>\n' +
|
||||
' <li>otevírají podnabídku nabídky;</li>\n' +
|
||||
' <li>umožňují přesun mezi tlačítky ve skupině panelu nástrojů;</li>\n' +
|
||||
' <li>umožňují přesun mezi položkami cesty prvku v zápatí.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Klávesy se šipkou <strong>dolů</strong> a <strong>nahoru</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>umožňují přesun mezi položkami nabídky;</li>\n' +
|
||||
' <li>umožňují přesun mezi položkami místní nabídky panelu nástrojů.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Šipky</strong> provádí přepínání v rámci vybraného oddílu uživatelského rozhraní.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li zavřít otevřenou nabídku, podnabídku nebo místní nabídku, stiskněte klávesu <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je aktuálně vybrána horní část oddílu uživatelského rozhraní, stisknutím klávesy <strong>Esc</strong> zcela ukončíte také\n' +
|
||||
' navigaci pomocí klávesnice.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Provedení příkazu položky nabídky nebo tlačítka panelu nástrojů</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Pokud je zvýrazněna požadovaná položka nabídky nebo tlačítko panelu nástrojů, stisknutím klávesy <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' nebo <strong>mezerníku</strong> provedete příslušný příkaz.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v dialogových oknech bez záložek</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Při otevření dialogových oken bez záložek přejdete na první interaktivní komponentu.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Přecházet mezi interaktivními komponentami dialogového okna můžete stisknutím klávesy <strong>Tab</strong> nebo kombinace <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigace v dialogových oknech se záložkami</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Při otevření dialogových oken se záložkami přejdete na první tlačítko v nabídce záložek.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Přecházet mezi interaktivními komponentami této záložky dialogového okna můžete stisknutím klávesy <strong>Tab</strong> nebo\n' +
|
||||
' kombinace <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Chcete-li přepnout na další záložku dialogového okna, přejděte na nabídku záložek a poté můžete stisknutím požadované <strong>šipky</strong>\n' +
|
||||
' přepínat mezi dostupnými záložkami.</p>\n');
|
||||
@@ -0,0 +1,90 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.da',
|
||||
'<h1>Start tastaturnavigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokuser på menulinjen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokuser på værktøjslinjen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokuser på sidefoden</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Fokuser på kontekstuel værktøjslinje</dt>\n' +
|
||||
' <dd>Windows, Linux eller macOS: Ctrl+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigationen starter ved det første UI-element, som fremhæves eller understreges hvad angår det første element i\n' +
|
||||
' sidefodens sti til elementet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger mellem UI-sektioner</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra én UI-sektion til den næste ved at trykke på <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra én UI-sektion til den forrige ved at trykke på <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Tab</strong>-rækkefølgen af disse UI-sektioner er:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menulinje</li>\n' +
|
||||
' <li>Hver værktøjsgruppe</li>\n' +
|
||||
' <li>Sidepanel</li>\n' +
|
||||
' <li>Sti til elementet i sidefoden</li>\n' +
|
||||
' <li>Til/fra-knap for ordoptælling i sidefoden</li>\n' +
|
||||
' <li>Brandinglink i sidefoden</li>\n' +
|
||||
' <li>Tilpasningshåndtag for editor i sidefoden</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis en UI-sektion ikke er til stede, springes den over.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis sidefoden har fokus til tastaturnavigation, og der ikke er noget synligt sidepanel, kan der trykkes på <strong>Shift+Tab</strong>\n' +
|
||||
' for at flytte fokus til den første værktøjsgruppe, ikke den sidste.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger inden for UI-sektioner</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Gå fra ét UI-element til det næste ved at trykke på den relevante <strong>piletast</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Venstre</strong> og <strong>højre</strong> piletast</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>flytter mellem menuerne i menulinjen.</li>\n' +
|
||||
' <li>åbner en undermenu i en menu.</li>\n' +
|
||||
' <li>flytter mellem knapperne i en værktøjsgruppe.</li>\n' +
|
||||
' <li>flytter mellem elementer i sidefodens sti til elementet.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Pil <strong>ned</strong> og <strong>op</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>flytter mellem menupunkterne i en menu.</li>\n' +
|
||||
' <li>flytter mellem punkterne i en genvejsmenu i værktøjslinjen.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Piletasterne</strong> kører rundt inden for UI-sektionen, der fokuseres på.</p>\n' +
|
||||
'\n' +
|
||||
'<p>For at lukke en åben menu, en åben undermenu eller en åben genvejsmenu trykkes der på <strong>Esc</strong>-tasten.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Hvis det aktuelle fokus er i 'toppen' af en bestemt UI-sektion, vil tryk på <strong>Esc</strong>-tasten også afslutte\n" +
|
||||
' tastaturnavigationen helt.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Udfør et menupunkt eller en værktøjslinjeknap</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Når det ønskede menupunkt eller den ønskede værktøjslinjeknap er fremhævet, trykkes der på <strong>Retur</strong>, <strong>Enter</strong>\n' +
|
||||
' eller <strong>mellemrumstasten</strong> for at udføre elementet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger i ikke-faneopdelte dialogbokse</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I ikke-faneopdelte dialogbokse får den første interaktive komponent fokus, når dialogboksen åbnes.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Naviger mellem interaktive dialogbokskomponenter ved at trykke på <strong>Tab</strong> eller <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviger i faneopdelte dialogbokse</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I faneopdelte dialogbokse får den første knap i fanemenuen fokus, når dialogboksen åbnes.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Naviger mellem interaktive komponenter i denne dialogboksfane ved at trykke på <strong>Tab</strong> eller\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Skift til en anden dialogboksfane ved at fokusere på fanemenuen og derefter trykke på den relevante <strong>piletast</strong>\n' +
|
||||
' for at køre igennem de tilgængelige faner.</p>\n');
|
||||
@@ -0,0 +1,90 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.de',
|
||||
'<h1>Grundlagen der Tastaturnavigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokus auf Menüleiste</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokus auf Symbolleiste</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokus auf Fußzeile</dt>\n' +
|
||||
' <dd>Windows oder Linux: ALT+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Fokus auf kontextbezogene Symbolleiste</dt>\n' +
|
||||
' <dd>Windows, Linux oder macOS: STRG+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Die Navigation beginnt beim ersten Benutzeroberflächenelement, welches hervorgehoben ist. Falls sich das erste Element im Pfad der Fußzeile befindet,\n' +
|
||||
' ist es unterstrichen.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Zwischen Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Abschnitt der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie <strong>TAB</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Abschnitt der Benutzeroberfläche zum vorherigen zu wechseln, drücken Sie <strong>UMSCHALT+TAB</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Die Abschnitte der Benutzeroberfläche haben folgende <strong>TAB</strong>-Reihenfolge:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menüleiste</li>\n' +
|
||||
' <li>Einzelne Gruppen der Symbolleiste</li>\n' +
|
||||
' <li>Randleiste</li>\n' +
|
||||
' <li>Elementpfad in der Fußzeile</li>\n' +
|
||||
' <li>Umschaltfläche „Wörter zählen“ in der Fußzeile</li>\n' +
|
||||
' <li>Branding-Link in der Fußzeile</li>\n' +
|
||||
' <li>Editor-Ziehpunkt zur Größenänderung in der Fußzeile</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Falls ein Abschnitt der Benutzeroberflächen nicht vorhanden ist, wird er übersprungen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn in der Fußzeile die Tastaturnavigation fokussiert ist und keine Randleiste angezeigt wird, wechselt der Fokus durch Drücken von <strong>UMSCHALT+TAB</strong>\n' +
|
||||
' zur ersten Gruppe der Symbolleiste, nicht zur letzten.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Innerhalb von Abschnitten der Benutzeroberfläche navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Um von einem Element der Benutzeroberfläche zum nächsten zu wechseln, drücken Sie die entsprechende <strong>Pfeiltaste</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Die Pfeiltasten <strong>Links</strong> und <strong>Rechts</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>wechseln zwischen Menüs in der Menüleiste.</li>\n' +
|
||||
' <li>öffnen das Untermenü eines Menüs.</li>\n' +
|
||||
' <li>wechseln zwischen Schaltflächen in einer Gruppe der Symbolleiste.</li>\n' +
|
||||
' <li>wechseln zwischen Elementen im Elementpfad der Fußzeile.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Die Pfeiltasten <strong>Abwärts</strong> und <strong>Aufwärts</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>wechseln zwischen Menüelementen in einem Menü.</li>\n' +
|
||||
' <li>wechseln zwischen Elementen in einem Popupmenü der Symbolleiste.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Die <strong>Pfeiltasten</strong> rotieren innerhalb des fokussierten Abschnitts der Benutzeroberfläche.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Um ein geöffnetes Menü, ein geöffnetes Untermenü oder ein geöffnetes Popupmenü zu schließen, drücken Sie die <strong>ESC</strong>-Taste.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn sich der aktuelle Fokus ganz oben in einem bestimmten Abschnitt der Benutzeroberfläche befindet, wird durch Drücken der <strong>ESC</strong>-Taste auch\n' +
|
||||
' die Tastaturnavigation beendet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Ein Menüelement oder eine Symbolleistenschaltfläche ausführen</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Wenn das gewünschte Menüelement oder die gewünschte Symbolleistenschaltfläche hervorgehoben ist, drücken Sie <strong>Zurück</strong>, <strong>Eingabe</strong>\n' +
|
||||
' oder die <strong>Leertaste</strong>, um das Element auszuführen.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>In Dialogfeldern ohne Registerkarten navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In Dialogfeldern ohne Registerkarten ist beim Öffnen eines Dialogfelds die erste interaktive Komponente fokussiert.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigieren Sie zwischen den interaktiven Komponenten eines Dialogfelds, indem Sie <strong>TAB</strong> oder <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>In Dialogfeldern mit Registerkarten navigieren</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In Dialogfeldern mit Registerkarten ist beim Öffnen eines Dialogfelds die erste Schaltfläche eines Registerkartenmenüs fokussiert.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigieren Sie zwischen den interaktiven Komponenten auf dieser Registerkarte des Dialogfelds, indem Sie <strong>TAB</strong> oder\n' +
|
||||
' <strong>UMSCHALT+TAB</strong> drücken.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Wechseln Sie zu einer anderen Registerkarte des Dialogfelds, indem Sie den Fokus auf das Registerkartenmenü legen und dann die entsprechende <strong>Pfeiltaste</strong>\n' +
|
||||
' drücken, um durch die verfügbaren Registerkarten zu rotieren.</p>\n');
|
||||
@@ -0,0 +1,90 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.el',
|
||||
'<h1>Έναρξη πλοήγησης μέσω πληκτρολογίου</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Εστίαση στη γραμμή μενού</dt>\n' +
|
||||
' <dd>Windows ή Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Εστίαση στη γραμμή εργαλείων</dt>\n' +
|
||||
' <dd>Windows ή Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Εστίαση στο υποσέλιδο</dt>\n' +
|
||||
' <dd>Windows ή Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Εστίαση σε γραμμή εργαλείων βάσει περιεχομένου</dt>\n' +
|
||||
' <dd>Windows, Linux ή macOS: Ctrl+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Η πλοήγηση θα ξεκινήσει από το πρώτο στοιχείο περιβάλλοντος χρήστη, που θα επισημαίνεται ή θα είναι υπογραμμισμένο,\n' +
|
||||
' όπως στην περίπτωση της διαδρομής του στοιχείου Υποσέλιδου.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Πλοήγηση μεταξύ ενοτήτων του περιβάλλοντος χρήστη</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Για να μετακινηθείτε από μια ενότητα περιβάλλοντος χρήστη στην επόμενη, πιέστε το πλήκτρο <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Για να μετακινηθείτε από μια ενότητα περιβάλλοντος χρήστη στην προηγούμενη, πιέστε τα πλήκτρα <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Η σειρά <strong>Tab</strong> αυτών των ενοτήτων περιβάλλοντος χρήστη είναι η εξής:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Γραμμή μενού</li>\n' +
|
||||
' <li>Κάθε ομάδα γραμμής εργαλείων</li>\n' +
|
||||
' <li>Πλαϊνή γραμμή</li>\n' +
|
||||
' <li>Διαδρομή στοιχείου στο υποσέλιδο</li>\n' +
|
||||
' <li>Κουμπί εναλλαγής μέτρησης λέξεων στο υποσέλιδο</li>\n' +
|
||||
' <li>Σύνδεσμος επωνυμίας στο υποσέλιδο</li>\n' +
|
||||
' <li>Λαβή αλλαγής μεγέθους προγράμματος επεξεργασίας στο υποσέλιδο</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Εάν δεν εμφανίζεται ενότητα περιβάλλοντος χρήστη, παραλείπεται.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Εάν η εστίαση πλοήγησης βρίσκεται στο πληκτρολόγιο και δεν υπάρχει εμφανής πλαϊνή γραμμή, εάν πιέσετε <strong>Shift+Tab</strong>\n' +
|
||||
' η εστίαση μετακινείται στην πρώτη ομάδα γραμμής εργαλείων, όχι στην τελευταία.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Πλοήγηση εντός των ενοτήτων του περιβάλλοντος χρήστη</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Για να μετακινηθείτε από ένα στοιχείο περιβάλλοντος χρήστη στο επόμενο, πιέστε το αντίστοιχο πλήκτρο <strong>βέλους</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Με τα πλήκτρα <strong>αριστερού</strong> και <strong>δεξιού</strong> βέλους</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>γίνεται μετακίνηση μεταξύ των μενού στη γραμμή μενού.</li>\n' +
|
||||
' <li>ανοίγει ένα υπομενού σε ένα μενού.</li>\n' +
|
||||
' <li>γίνεται μετακίνηση μεταξύ κουμπιών σε μια ομάδα γραμμής εργαλείων.</li>\n' +
|
||||
' <li>γίνεται μετακίνηση μεταξύ στοιχείων στη διαδρομή στοιχείου στο υποσέλιδο.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Με τα πλήκτρα <strong>επάνω</strong> και <strong>κάτω</strong> βέλους</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>γίνεται μετακίνηση μεταξύ των στοιχείων μενού σε ένα μενού.</li>\n' +
|
||||
' <li>γίνεται μετακίνηση μεταξύ των στοιχείων μενού σε ένα αναδυόμενο μενού γραμμής εργαλείων.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Με τα πλήκτρα <strong>βέλους</strong> γίνεται κυκλική μετακίνηση εντός της εστιασμένης ενότητας περιβάλλοντος χρήστη.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Για να κλείσετε ένα ανοιχτό μενού, ένα ανοιχτό υπομενού ή ένα ανοιχτό αναδυόμενο μενού, πιέστε το πλήκτρο <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Εάν η τρέχουσα εστίαση βρίσκεται στην κορυφή μιας ενότητας περιβάλλοντος χρήστη, πιέζοντας το πλήκτρο <strong>Esc</strong>,\n' +
|
||||
' γίνεται επίσης πλήρης έξοδος από την πλοήγηση μέσω πληκτρολογίου.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Εκτέλεση ενός στοιχείου μενού ή κουμπιού γραμμής εργαλείων</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Όταν το επιθυμητό στοιχείο μενού ή κουμπί γραμμής εργαλείων είναι επισημασμένο, πιέστε τα πλήκτρα <strong>Return</strong>, <strong>Enter</strong>,\n' +
|
||||
' ή το <strong>πλήκτρο διαστήματος</strong> για να εκτελέσετε το στοιχείο.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Πλοήγηση σε παράθυρα διαλόγου χωρίς καρτέλες</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Σε παράθυρα διαλόγου χωρίς καρτέλες, το πρώτο αλληλεπιδραστικό στοιχείο λαμβάνει την εστίαση όταν ανοίγει το παράθυρο διαλόγου.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Μπορείτε να πλοηγηθείτε μεταξύ των αλληλεπιδραστικών στοιχείων παραθύρων διαλόγων πιέζοντας τα πλήκτρα <strong>Tab</strong> ή <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Πλοήγηση σε παράθυρα διαλόγου με καρτέλες</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Σε παράθυρα διαλόγου με καρτέλες, το πρώτο κουμπί στο μενού καρτέλας λαμβάνει την εστίαση όταν ανοίγει το παράθυρο διαλόγου.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Μπορείτε να πλοηγηθείτε μεταξύ των αλληλεπιδραστικών στοιχείων αυτής της καρτέλα διαλόγου πιέζοντας τα πλήκτρα <strong>Tab</strong> ή\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Μπορείτε να κάνετε εναλλαγή σε άλλη καρτέλα του παραθύρου διαλόγου, μεταφέροντας την εστίαση στο μενού καρτέλας και πιέζοντας το κατάλληλο πλήκτρο <strong>βέλους</strong>\n' +
|
||||
' για να μετακινηθείτε κυκλικά στις διαθέσιμες καρτέλες.</p>\n');
|
||||
@@ -0,0 +1,90 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.en',
|
||||
'<h1>Begin keyboard navigation</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Focus the Menu bar</dt>\n' +
|
||||
' <dd>Windows or Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Focus the Toolbar</dt>\n' +
|
||||
' <dd>Windows or Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Focus the footer</dt>\n' +
|
||||
' <dd>Windows or Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Focus a contextual toolbar</dt>\n' +
|
||||
' <dd>Windows, Linux or macOS: Ctrl+F9\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigation will start at the first UI item, which will be highlighted, or underlined in the case of the first item in\n' +
|
||||
' the Footer element path.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigate between UI sections</h1>\n' +
|
||||
'\n' +
|
||||
'<p>To move from one UI section to the next, press <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>To move from one UI section to the previous, press <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>The <strong>Tab</strong> order of these UI sections is:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menu bar</li>\n' +
|
||||
' <li>Each toolbar group</li>\n' +
|
||||
' <li>Sidebar</li>\n' +
|
||||
' <li>Element path in the footer</li>\n' +
|
||||
' <li>Word count toggle button in the footer</li>\n' +
|
||||
' <li>Branding link in the footer</li>\n' +
|
||||
' <li>Editor resize handle in the footer</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>If a UI section is not present, it is skipped.</p>\n' +
|
||||
'\n' +
|
||||
'<p>If the footer has keyboard navigation focus, and there is no visible sidebar, pressing <strong>Shift+Tab</strong>\n' +
|
||||
' moves focus to the first toolbar group, not the last.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigate within UI sections</h1>\n' +
|
||||
'\n' +
|
||||
'<p>To move from one UI element to the next, press the appropriate <strong>Arrow</strong> key.</p>\n' +
|
||||
'\n' +
|
||||
'<p>The <strong>Left</strong> and <strong>Right</strong> arrow keys</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>move between menus in the menu bar.</li>\n' +
|
||||
' <li>open a sub-menu in a menu.</li>\n' +
|
||||
' <li>move between buttons in a toolbar group.</li>\n' +
|
||||
' <li>move between items in the footer’s element path.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>The <strong>Down</strong> and <strong>Up</strong> arrow keys</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>move between menu items in a menu.</li>\n' +
|
||||
' <li>move between items in a toolbar pop-up menu.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Arrow</strong> keys cycle within the focused UI section.</p>\n' +
|
||||
'\n' +
|
||||
'<p>To close an open menu, an open sub-menu, or an open pop-up menu, press the <strong>Esc</strong> key.</p>\n' +
|
||||
'\n' +
|
||||
'<p>If the current focus is at the ‘top’ of a particular UI section, pressing the <strong>Esc</strong> key also exits\n' +
|
||||
' keyboard navigation entirely.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Execute a menu item or toolbar button</h1>\n' +
|
||||
'\n' +
|
||||
'<p>When the desired menu item or toolbar button is highlighted, press <strong>Return</strong>, <strong>Enter</strong>,\n' +
|
||||
' or the <strong>Space bar</strong> to execute the item.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigate non-tabbed dialogs</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In non-tabbed dialogs, the first interactive component takes focus when the dialog opens.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigate between interactive dialog components by pressing <strong>Tab</strong> or <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigate tabbed dialogs</h1>\n' +
|
||||
'\n' +
|
||||
'<p>In tabbed dialogs, the first button in the tab menu takes focus when the dialog opens.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigate between interactive components of this dialog tab by pressing <strong>Tab</strong> or\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Switch to another dialog tab by giving the tab menu focus and then pressing the appropriate <strong>Arrow</strong>\n' +
|
||||
' key to cycle through the available tabs.</p>\n');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user