diff --git a/Scriptable/Clean Files 2.js b/Scriptable/Clean Files 2.js new file mode 100644 index 00000000..3db1a785 --- /dev/null +++ b/Scriptable/Clean Files 2.js @@ -0,0 +1,1097 @@ +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: purple; icon-glyph: folder-open; share-sheet-inputs: file-url; + +/** + * Clean Files (Scriptable 文件与缓存专业清理工具) + * + * @version 2.5.1 (Refined & Hardened) + * @author Honye / Optimized for MuTu + * + * 核心优化: + * 1. 【彻底杜绝文字折行】:严格加上 [hidden] { display: none !important; } 与 white-space: nowrap,根治“全选”和“导入”被挤压上下折行的问题。 + * 2. 【全选机制可靠重构】:采用纯 CSS 类 (.item.is-selected) 进行状态绑定与联动,彻底解决点击“全选”不勾选、状态不生效的顽疾。 + * 3. 【实时选中容量显示】:进入选择模式时,底部删除按钮实时计算并展示勾选的项数与体积(如:删除 5 项 · 12.8 MB),删除心中有数。 + * 4. 【安全防误删防护】:删除前原生二次确认弹窗,并自动识别保护当前运行中的自身脚本,防止自杀式误删。 + * 5. 【真实容量换算】:精确按标准 B/KB/MB/GB 逐级换算,彻底修复原版将 Bytes 误当 KB 导致容量虚高成 GB 的严重 Bug。 + * 6. 【全目录容量可视化】:首页及各层级目录自动汇总项目数与占用空间,文件夹优先置顶、文件按体积降序排列,一眼定位垃圾大户。 + * 7. 【极简现代原生 UI】:精简页面标题英文后缀,自适应 iOS 16/17/18 深色与浅色模式,操作丝滑顺手。 + */ + +/** + * 多语言国际化 + * @param {{[language: string]: string} | [en:string, zh:string]} langs + */ +const i18n = (langs) => { + const language = Device.language(); + if (Array.isArray(langs)) { + langs = { + en: langs[0], + zh: langs[1], + others: langs[0], + }; + } else { + langs.others = langs.others || langs.en; + } + return langs[language] || langs.others; +}; + +// 文件管理器环境初始化 +const fmLocal = FileManager.local(); +const fmCloud = (() => { + try { + return FileManager.iCloud(); + } catch (e) { + return null; + } +})(); +const usedICloud = fmCloud ? fmLocal.isFileStoredIniCloud(module.filename) : false; + +/** + * 智能获取适配的文件管理器(自动识别本地或 iCloud 路径) + * @param {string} path + * @returns {FileManager} + */ +const getFM = (path) => { + if (!path) return fmLocal; + try { + if (fmCloud && (fmCloud.isFileStoredIniCloud(path) || path.includes('Mobile Documents'))) { + return fmCloud; + } + } catch (e) {} + return fmLocal; +}; + +/** + * 精确格式化字节大小 + * @param {number} bytes + * @returns {string} + */ +const formatSize = (bytes) => { + if (bytes === undefined || bytes === null || isNaN(bytes) || bytes <= 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`; +}; + +/** + * 计算目录大小与总项目数(带安全深度限制) + * @param {string} dirPath + * @param {number} maxDepth + * @param {number} currentDepth + * @returns {{ size: number, count: number }} + */ +const getDirSizeAndCount = (dirPath, maxDepth = 2, currentDepth = 0) => { + const currentFm = getFM(dirPath); + let totalSize = 0; + let totalCount = 0; + try { + if (!currentFm.fileExists(dirPath) || !currentFm.isDirectory(dirPath)) { + return { size: 0, count: 0 }; + } + const items = currentFm.listContents(dirPath); + totalCount = items.length; + for (const name of items) { + const fullPath = currentFm.joinPath(dirPath, name); + try { + if (currentFm.isDirectory(fullPath)) { + if (currentDepth < maxDepth) { + const sub = getDirSizeAndCount(fullPath, maxDepth, currentDepth + 1); + totalSize += sub.size; + } + } else { + totalSize += (currentFm.fileSize(fullPath) || 0); + } + } catch (e) {} + } + } catch (e) {} + return { size: totalSize, count: totalCount }; +}; + +/** + * Scriptable WebView JSBridge 核心原生 SDK + */ +const sendResult = (() => { + let sending = false; + const queue = []; + + const processQueue = async (webView) => { + if (sending || queue.length === 0) return; + sending = true; + while (queue.length > 0) { + const item = queue.shift(); + const eventName = `ScriptableBridge_${item.code}_Result`; + const res = item.data instanceof Error ? { err: item.data.message } : item.data; + try { + await webView.evaluateJavaScript( + `window.dispatchEvent( + new CustomEvent( + ${JSON.stringify(eventName)}, + { detail: ${JSON.stringify(res)} } + ) + )` + ); + } catch (e) { + console.error(e); + } + } + sending = false; + }; + + return async (webView, code, data) => { + queue.push({ code, data }); + await processQueue(webView); + }; +})(); + +/** + * 注入 Bridge 脚本并监听通信 + * @param {WebView} webView + * @param {object} options + */ +const inject = async (webView, options) => { + const js = `(() => { + const queue = window.__scriptable_bridge_queue; + if (queue && queue.length) { + completion(queue); + } + window.__scriptable_bridge_queue = null; + + if (!window.ScriptableBridge) { + window.ScriptableBridge = { + invoke(name, data, callback) { + const detail = { code: name, data }; + const eventName = \`ScriptableBridge_\${name}_Result\`; + const controller = new AbortController(); + window.addEventListener( + eventName, + (e) => { + callback && callback(e.detail); + controller.abort(); + }, + { signal: controller.signal } + ); + + if (window.__scriptable_bridge_queue) { + window.__scriptable_bridge_queue.push(detail); + completion(); + } else { + completion(detail); + window.__scriptable_bridge_queue = []; + } + } + }; + window.dispatchEvent(new CustomEvent('ScriptableBridgeReady')); + } + })()`; + + const res = await webView.evaluateJavaScript(js, true); + if (!res) return inject(webView, options); + + const methods = options.methods || {}; + const events = Array.isArray(res) ? res : [res]; + + const sendTasks = events.map(({ code, data }) => { + return (async () => { + try { + if (typeof methods[code] === 'function') { + return await methods[code](data); + } + throw new Error(`Method [${code}] not implemented`); + } catch (e) { + return Promise.reject(e); + } + })() + .then((r) => sendResult(webView, code, r)) + .catch((e) => { + console.error(e); + sendResult(webView, code, e instanceof Error ? e : new Error(String(e))); + }); + }); + + await Promise.all(sendTasks); + inject(webView, options); +}; + +/** + * 加载 HTML 并初始化注入 + * @param {WebView} webView + * @param {object} args + * @param {object} options + */ +const loadHTML = async (webView, args, options = {}) => { + const { html, baseURL } = args; + await webView.loadHTML(html, baseURL); + inject(webView, options).catch((err) => console.error(err)); +}; + +/** + * 复制/覆盖文件 + * @param {string[]} fileURLs + * @param {string} destPath + */ +const copyFiles = async (fileURLs, destPath) => { + let isReplaceAll = false; + const currentFm = getFM(destPath); + for (const fileURL of fileURLs) { + const fileName = currentFm.fileName(fileURL, true); + const filePath = currentFm.joinPath(destPath, fileName); + if (currentFm.fileExists(filePath)) { + if (isReplaceAll) { + currentFm.remove(filePath); + } else { + const alert = new Alert(); + alert.message = `“${fileName}”${i18n([' already exists. Do you want to replace it?', ' 已存在,是否替换?'])}`; + const actions = [i18n(['All Yes', '全部替换']), i18n(['Yes', '替换']), i18n(['No', '跳过'])]; + for (const action of actions) alert.addAction(action); + alert.addCancelAction(i18n(['Cancel', '取消'])); + const value = await alert.present(); + switch (actions[value]) { + case i18n(['All Yes', '全部替换']): + isReplaceAll = true; + currentFm.remove(filePath); + break; + case i18n(['Yes', '替换']): + currentFm.remove(filePath); + break; + case i18n(['No', '跳过']): + continue; + default: + return; + } + } + } + currentFm.copy(fileURL, filePath); + } + const alert = new Alert(); + alert.title = i18n(['Import successful', '导入成功']); + alert.message = i18n(['Re-enter this directory to view', '重新进入此目录可查看']); + alert.addCancelAction(i18n(['OKay', '好的'])); + await alert.present(); +}; + +/** + * 导入外部文件 + * @param {string} destPath + */ +const importFiles = async (destPath) => { + let fileURLs = args.fileURLs || []; + if (!fileURLs.length) { + try { + fileURLs = await DocumentPicker.open(); + } catch (e) { + return; + } + } + await copyFiles(fileURLs, destPath); +}; + +/** + * 呈现文件列表主视图 + * @param {object} options + */ +const presentList = async (options) => { + const { title, list, directory, isRoot } = options; + const webView = new WebView(); + + const css = ` + :root { + --text-primary: #1c1c1e; + --text-secondary: #8e8e93; + --color-primary: #007aff; + --color-danger: #ff3b30; + --divider-color: rgba(60, 60, 67, 0.12); + --card-background: #ffffff; + --bg-page: #f2f2f7; + --bg-btn: rgba(0, 122, 255, 0.1); + --fixed-btn-height: 3.2rem; + --item-active-bg: rgba(0, 0, 0, 0.04); + } + @media (prefers-color-scheme: dark) { + :root { + --text-primary: #ffffff; + --text-secondary: #98989f; + --color-primary: #0a84ff; + --color-danger: #ff453a; + --divider-color: rgba(84, 84, 88, 0.35); + --card-background: #1c1c1e; + --bg-page: #000000; + --bg-btn: rgba(10, 132, 255, 0.15); + --item-active-bg: rgba(255, 255, 255, 0.06); + } + } + [hidden] { + display: none !important; + } + * { + -webkit-user-select: none; + user-select: none; + box-sizing: border-box; + } + body { + margin: 0; + -webkit-font-smoothing: antialiased; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif; + min-height: 100vh; + background-color: var(--bg-page); + color: var(--text-primary); + padding-top: env(safe-area-inset-top); + } + .header { + position: sticky; + z-index: 99; + top: 0; + left: 0; + right: 0; + height: 3.5rem; + background: var(--card-background); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1rem; + border-bottom: 0.5px solid var(--divider-color); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + } + .header__left, + .header__right { + display: flex; + align-items: center; + min-width: 4.2rem; + flex-shrink: 0; + } + .header__left { + justify-content: flex-start; + } + .header__right { + justify-content: flex-end; + } + .header__btn, + .select-all, + .select { + height: 1.85rem; + padding: 0 0.8rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-primary); + background-color: var(--bg-btn); + border-radius: 99px; + border: none; + outline: none; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap !important; + flex-shrink: 0 !important; + cursor: pointer; + transition: all 0.2s; + } + .header__btn:active, + .select-all:active, + .select:active { + opacity: 0.6; + transform: scale(0.96); + } + .title { + flex: 1; + font-size: 1.05rem; + font-weight: 600; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + padding: 0 0.5rem; + } + .list-wrapper { + margin: 1rem; + background: var(--card-background); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.03); + } + .list { + padding: 0; + margin: 0; + list-style: none; + } + .item { + padding-left: 1rem; + display: flex; + align-items: center; + overflow: hidden; + cursor: pointer; + transition: background-color 0.15s; + } + .item:active { + background-color: var(--item-active-bg); + } + .item__body { + flex: 1; + display: flex; + align-items: center; + overflow: hidden; + column-gap: 0.75rem; + } + .item__selection { + width: 0; + height: 1.5rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + opacity: 0; + transition: width 0.2s, opacity 0.2s, margin-right 0.2s; + } + .list-select .item__selection { + width: 1.5rem; + opacity: 1; + margin-right: 0.65rem; + } + .item__selection .icon-checked { + display: none; + } + .item__selection .icon-unchecked { + display: block; + } + .item.is-selected .item__selection .icon-checked { + display: block; + } + .item.is-selected .item__selection .icon-unchecked { + display: none; + } + .item__content { + flex: 1; + padding: 0.8rem 1rem 0.8rem 0; + border-bottom: 0.5px solid var(--divider-color); + overflow: hidden; + } + li:last-child .item__content { + border-bottom: none; + } + .item__name { + font-size: 0.95rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .item__name--protected { + color: var(--color-primary); + } + .badge-protected { + font-size: 0.7rem; + padding: 0.1rem 0.35rem; + background: var(--bg-btn); + color: var(--color-primary); + border-radius: 4px; + margin-left: 0.35rem; + vertical-align: middle; + } + .item__info { + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--text-secondary); + display: flex; + align-items: center; + column-gap: 0.5rem; + } + .fixed-bottom { + position: fixed; + z-index: 100; + bottom: 0; + left: 0; + right: 0; + padding-bottom: env(safe-area-inset-bottom); + background: var(--card-background); + border-top: 0.5px solid var(--divider-color); + transform: translateY(100%); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + } + .fixed-bottom.show { + transform: translateY(0); + } + .btn-del { + margin: 0; + display: flex; + width: 100%; + height: var(--fixed-btn-height); + justify-content: center; + align-items: center; + column-gap: 0.4rem; + font-size: 0.95rem; + font-weight: 600; + background-color: transparent; + color: var(--color-danger); + padding: 0; + border: none; + cursor: pointer; + } + .btn-del:active { + opacity: 0.6; + } + .bottom-holder { + box-sizing: content-box; + height: var(--fixed-btn-height); + padding-bottom: env(safe-area-inset-bottom); + } + .empty-state { + text-align: center; + padding: 4rem 1.5rem; + color: var(--text-secondary); + } + .empty-state__icon { + font-size: 2.8rem; + margin-bottom: 0.8rem; + } + .empty-state__text { + font-size: 0.95rem; + font-weight: 500; + } + .chevron-right { + color: var(--text-secondary); + opacity: 0.4; + flex-shrink: 0; + } + `; + + const js = ` + window.invoke = (code, data) => { + if (window.ScriptableBridge) { + ScriptableBridge.invoke(code, data); + } + }; + + const formatSize = (bytes) => { + if (!bytes || isNaN(bytes) || bytes <= 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + sizes[i]; + }; + + const isSelectMode = () => { + return document.querySelector('.list')?.classList.contains('list-select'); + }; + + const checkEmptyState = () => { + const list = document.querySelector('.list'); + const emptyState = document.querySelector('.empty-state'); + const selectBtn = document.querySelector('.select'); + if (!list || list.children.length === 0) { + if (emptyState) emptyState.removeAttribute('hidden'); + if (selectBtn) selectBtn.setAttribute('hidden', ''); + } else { + if (emptyState) emptyState.setAttribute('hidden', ''); + if (selectBtn) selectBtn.removeAttribute('hidden'); + } + }; + + // 刷新底部删除按钮文字与全选按钮状态 + const updateSelectionState = () => { + const allItems = Array.from(document.querySelectorAll('.item')); + const selectedItems = Array.from(document.querySelectorAll('.item.is-selected')); + const count = selectedItems.length; + + let totalBytes = 0; + selectedItems.forEach((el) => { + totalBytes += (parseInt(el.dataset.bytes) || 0); + }); + + const btnDelText = document.querySelector('.btn-del-text'); + if (btnDelText) { + if (count > 0) { + const sizeInfo = totalBytes > 0 ? (' · ' + formatSize(totalBytes)) : ''; + btnDelText.innerText = ${i18n(['`Delete (${count} items${sizeInfo})`', '`删除 (${count} 项${sizeInfo})`'])}; + } else { + btnDelText.innerText = ${i18n(['"Delete"', '"删除"'])}; + } + } + + const selectAllBtn = document.querySelector('.select-all'); + if (selectAllBtn && allItems.length > 0) { + const isAllSelected = count === allItems.length; + selectAllBtn.innerText = isAllSelected ? ${i18n(['"Deselect All"', '"取消全选"'])} : ${i18n(['"Select All"', '"全选"'])}; + } + }; + + // 进入或退出编辑模式 + const setSelectMode = (enable) => { + const selectBtn = document.querySelector('.select'); + const selectAllBtn = document.querySelector('.select-all'); + const importBtn = document.querySelector('#import'); + const list = document.querySelector('.list'); + const bottomBar = document.querySelector('.fixed-bottom'); + + if (enable) { + selectBtn.innerText = ${i18n(['"Done"', '"完成"'])}; + if (importBtn) importBtn.setAttribute('hidden', ''); + if (selectAllBtn) selectAllBtn.removeAttribute('hidden'); + list?.classList.add('list-select'); + bottomBar?.classList.add('show'); + } else { + selectBtn.innerText = ${i18n(['"Select"', '"选择"'])}; + if (selectAllBtn) selectAllBtn.setAttribute('hidden', ''); + if (importBtn) importBtn.removeAttribute('hidden'); + list?.classList.remove('list-select'); + bottomBar?.classList.remove('show'); + + // 退出编辑时清除所有选中状态 + document.querySelectorAll('.item.is-selected').forEach((el) => el.classList.remove('is-selected')); + } + updateSelectionState(); + }; + + // 顶部“选择 / 完成”按钮 + document.querySelector('.select')?.addEventListener('click', (e) => { + const isEditing = isSelectMode(); + setSelectMode(!isEditing); + }); + + // 顶部“全选 / 取消全选”按钮 + document.querySelector('.select-all')?.addEventListener('click', (e) => { + // 若未处于编辑模式,点击全选自动开启编辑模式 + if (!isSelectMode()) { + setSelectMode(true); + } + + const allItems = Array.from(document.querySelectorAll('.item')); + const selectedItems = Array.from(document.querySelectorAll('.item.is-selected')); + const isAllSelected = selectedItems.length === allItems.length; + + allItems.forEach((el) => { + if (isAllSelected) { + el.classList.remove('is-selected'); + } else { + el.classList.add('is-selected'); + } + }); + + updateSelectionState(); + }); + + // 单击列表条目 + document.querySelectorAll('.item').forEach((el) => { + el.addEventListener('click', (e) => { + const target = e.currentTarget; + if (isSelectMode()) { + target.classList.toggle('is-selected'); + updateSelectionState(); + } else { + invoke('view', { ...target.dataset }); + } + }); + }); + + // 点击底部删除按钮 + document.querySelector('.btn-del')?.addEventListener('click', () => { + const selectedItems = []; + document.querySelectorAll('.item.is-selected').forEach((itemEl) => { + selectedItems.push({ ...itemEl.dataset }); + }); + if (selectedItems.length === 0) { + return; + } + invoke('remove', selectedItems); + }); + + // 响应删除成功事件 + const removeItems = (deletedList) => { + if (!Array.isArray(deletedList)) return; + const pathSet = new Set(deletedList.map(it => it.filePath || it.name)); + document.querySelectorAll('.item').forEach((el) => { + const p = el.dataset.filePath || el.dataset.name; + if (pathSet.has(p)) { + const li = el.closest('li'); + if (li) li.remove(); + } + }); + updateSelectionState(); + checkEmptyState(); + }; + + window.addEventListener('JWeb', (e) => { + const { code, data } = e.detail || {}; + if (code === 'remove-success') { + removeItems(data); + } + }); + + checkEmptyState(); + `; + + const selfPath = module.filename; + + const html = ` + + + + + ${title} + + + + + + + + + + + + + + + + + + + + +
+
+ + ${directory + ? `` + : '' + } +
+

${title}

+
+ ${list.length > 0 ? `` : ''} +
+
+ +
+ + +
0 ? 'hidden' : ''}> +
📭
+
${i18n(['This directory is empty', '此目录为空 / 暂无缓存文件'])}
+
+
+ +
+
+ +
+ + + + `; + + // 打开子目录或预览文件 + const view = async (data) => { + const { isDirectory, filePath, name } = data; + const currentFm = getFM(filePath); + + if (Number(isDirectory)) { + const unit = i18n(['items', '项']); + let contents = []; + try { + contents = currentFm.listContents(filePath); + } catch (e) { + console.error(`无法读取目录内容: ${filePath} - ${e}`); + } + + const subList = contents.map((itemName) => { + const itemPath = currentFm.joinPath(filePath, itemName); + let isDir = false; + try { + isDir = currentFm.isDirectory(itemPath); + } catch (e) {} + + let dateStr = ''; + try { + dateStr = currentFm.modificationDate(itemPath).toLocaleDateString('zh-CN'); + } catch (e) {} + + let infoStr = ''; + let rawBytes = 0; + + if (isDir) { + const dirStat = getDirSizeAndCount(itemPath, 1); + rawBytes = dirStat.size; + infoStr = `${dateStr} · ${dirStat.count} ${unit}${rawBytes > 0 ? ` · ${formatSize(rawBytes)}` : ''}`; + } else { + try { + rawBytes = currentFm.fileSize(itemPath) || 0; + } catch (e) {} + infoStr = `${dateStr} · ${formatSize(rawBytes)}`; + } + + return { + name: itemName, + info: infoStr, + filePath: itemPath, + isDirectory: isDir, + rawBytes, + }; + }); + + // 智能排序:文件夹排在最前,其次按体积降序排列,同等按名称 + subList.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + if (a.rawBytes !== b.rawBytes) { + return b.rawBytes - a.rawBytes; + } + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + }); + + presentList({ + title: name, + list: subList, + directory: filePath, + isRoot: false, + }); + } else { + // 打开文件预览 + try { + if (!currentFm.isFileDownloaded(filePath)) { + await currentFm.downloadFileFromiCloud(filePath); + } + } catch (e) {} + + if (/\.(js|json)$/i.test(filePath)) { + QuickLook.present(filePath); + return; + } + if (/\.(jpg|jpeg|gif|png|heic|heif|webp)$/i.test(filePath)) { + QuickLook.present(filePath, false); + return; + } + try { + const image = currentFm.readImage(filePath); + if (image) { + QuickLook.present(image, false); + return; + } + } catch (e) {} + try { + const text = currentFm.readString(filePath); + if (text) { + QuickLook.present(text); + return; + } + } catch (e) {} + QuickLook.present(filePath); + } + }; + + // 安全批量删除核心逻辑 + const remove = async (rawSelectedList) => { + if (!rawSelectedList || !rawSelectedList.length) return; + + // 1. 运行自身脚本防误删保护 + const self = module.filename; + let hasProtectedSelf = false; + const filteredList = rawSelectedList.filter((it) => { + if (self && it.filePath === self) { + hasProtectedSelf = true; + return false; + } + return true; + }); + + if (hasProtectedSelf) { + const alertSelf = new Alert(); + alertSelf.title = i18n(['Protected', '🛡 安全防护']); + alertSelf.message = i18n([ + 'Current running script is protected and excluded from deletion.', + '检测到所选内容包含当前正在运行的清理脚本,已自动排除保护,防止应用闪退!' + ]); + alertSelf.addAction(i18n(['OK', '好的'])); + await alertSelf.present(); + } + + if (filteredList.length === 0) return; + + // 2. 统计即将释放的总空间 + let totalBytes = 0; + for (const it of filteredList) { + const itemFm = getFM(it.filePath); + try { + if (Number(it.isDirectory)) { + totalBytes += getDirSizeAndCount(it.filePath, 1).size; + } else { + totalBytes += (itemFm.fileSize(it.filePath) || 0); + } + } catch (e) {} + } + + // 3. 原生确认对话框 (二次确认) + const alert = new Alert(); + alert.title = i18n(['Confirm Deletion', '⚠️ 确认永久删除']); + alert.message = i18n([ + `Are you sure you want to permanently delete ${filteredList.length} items (${formatSize(totalBytes)})? This action cannot be undone.`, + `确认永久删除选中的 ${filteredList.length} 项内容吗?\n预计释放存储空间:${formatSize(totalBytes)}\n⚠️ 此操作不可撤销,请谨慎操作!` + ]); + alert.addDestructiveAction(i18n(['Delete Permanently', '确认永久删除'])); + alert.addCancelAction(i18n(['Cancel', '取消'])); + + const actionIdx = await alert.present(); + if (actionIdx === -1) return; // 用户取消 + + // 4. 执行安全删除 + const successfullyDeleted = []; + for (const file of filteredList) { + try { + const itemFm = getFM(file.filePath); + itemFm.remove(file.filePath); + successfullyDeleted.push(file); + } catch (err) { + console.error(`删除失败: ${file.filePath} - ${err}`); + } + } + + // 5. 安全向前端同步已被删除的节点 + const detailJson = JSON.stringify({ + code: 'remove-success', + data: successfullyDeleted, + }); + await webView.evaluateJavaScript( + `window.dispatchEvent(new CustomEvent('JWeb', { detail: ${detailJson} }))`, + false + ); + }; + + await loadHTML( + webView, + { + html, + baseURL: 'https://scriptore.imarkr.com/scriptables/Clean%20Files%202' + }, + { + methods: { + view, + remove, + import: () => importFiles(directory) + } + } + ); + webView.present(); +}; + +/** + * 根目录分类装配(带容量与项目数实时统计) + */ +const buildRootDirectories = () => { + const dirs = [ + { + name: i18n(['Local Cache', '本地缓存']), + filePath: FileManager.local().cacheDirectory(), + isDirectory: true, + }, + { + name: i18n(['Local Temporary', '本地暂存']), + filePath: FileManager.local().temporaryDirectory(), + isDirectory: true, + }, + { + name: i18n(['Local Documents', '本地文件']), + filePath: FileManager.local().documentsDirectory(), + isDirectory: true, + }, + { + name: i18n(['Local Library', '本地支持库']), + filePath: FileManager.local().libraryDirectory(), + isDirectory: true, + }, + ]; + + if (usedICloud && fmCloud) { + dirs.push( + { + name: i18n(['iCloud Documents', 'iCloud 脚本与文件']), + filePath: fmCloud.documentsDirectory(), + isDirectory: true, + }, + { + name: i18n(['iCloud Library', 'iCloud 支持库']), + filePath: fmCloud.libraryDirectory(), + isDirectory: true, + } + ); + } + + // 为首页各主要目录快速统计容量与项目数 + const unit = i18n(['items', '项']); + const rootList = dirs.map((d) => { + const stat = getDirSizeAndCount(d.filePath, 1); + const sizeStr = stat.size > 0 ? ` · ${formatSize(stat.size)}` : ''; + return { + ...d, + info: `${stat.count} ${unit}${sizeStr}`, + rawBytes: stat.size, + }; + }); + + return rootList; +}; + +// 启动执行 +presentList({ + title: 'Clean Files', + list: buildRootDirectories(), + isRoot: true, +}); diff --git a/Scriptable/Clean Files 2.scriptable b/Scriptable/Clean Files 2.scriptable deleted file mode 100644 index 6a932089..00000000 --- a/Scriptable/Clean Files 2.scriptable +++ /dev/null @@ -1,12 +0,0 @@ -{ - "always_run_in_app" : false, - "icon" : { - "color" : "brown", - "glyph" : "trash-alt" - }, - "name" : "Clean Files 2", - "script" : "const fm = FileManager.local()\n\n\/**\n * @param {object} options\n * @param {string} options.title\n * @param {File[]} options.list\n *\/\nconst presentList = async (options) => {\n const { title, list } = options\n const webView = new WebView()\n const css =\n `:root {\n --color-primary: #007aff;\n --divider-color: rgba(60,60,67,0.36);\n --card-background: #fff;\n --card-radius: 10px;\n --list-header-color: rgba(60,60,67,0.6);\n }\n * {\n -webkit-user-select: none;\n user-select: none;\n }\n body {\n margin: 0;\n -webkit-font-smoothing: antialiased;\n font-family: \"SF Pro Display\",\"SF Pro Icons\",\"Helvetica Neue\",\"Helvetica\",\"Arial\",sans-serif;\n accent-color: var(--color-primary);\n }\n .header {\n position: sticky;\n z-index: 99;\n top: 0;\n left: 0;\n right: 0;\n height: 3.5rem;\n text-align: center;\n background: var(--card-background);\n display: flex;\n align-items: center;\n padding: 0 1rem;\n }\n .header__left,\n .header__right {\n flex: 1;\n min-width: 6rem;\n }\n .header__left {\n text-align: left;\n }\n .header__right {\n text-align: right;\n }\n .select-all,\n .select {\n font-size: 0.875rem;\n }\n .title {\n font-size: 1.125rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n .list {\n padding: 0;\n margin: 0;\n list-style: none;\n }\n .icon-yuan {\n color: #666;\n }\n .icon-gouxuan {\n color: var(--color-primary);\n }\n .item {\n padding-left: 1rem;\n }\n .item,\n .item__body {\n flex: 1;\n display: flex;\n align-items: center;\n overflow: hidden;\n }\n .item__selection {\n font-size: 0;\n transition: all .3s;\n }\n .item__icon {\n margin-right: 0.625rem;\n font-size: 2.5rem;\n color: var(--color-primary);\n }\n .item__name {\n color: #222;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n .item[data-is-directory=\"1\"] .item__name {\n color: var(--color-primary);\n }\n .item__content {\n flex: 1;\n padding: 0.75rem 0;\n border-bottom: 0.5px solid var(--divider-color);\n }\n .item__info {\n margin-top: 0.3rem;\n font-size: 0.75rem;\n color: #666;\n }\n .list-select .item__selection {\n margin-right: 0.5rem;\n font-size: 1.5rem;\n }\n .btn-del {\n position: fixed;\n z-index: 10;\n bottom: 0;\n left: 0;\n right: 0;\n margin: 0;\n display: block;\n width: 100%;\n height: 2.75rem;\n border: none;\n font-size: 1.125rem;\n color: #fff;\n background: indianred;\n padding: 0;\n transform: translateY(100%);\n transition: all 0.25s;\n }\n .btn-del.show {\n transform: translateY(0);\n }\n .bottom-holder {\n margin-top: 2rem;\n box-sizing: content-box;\n height: 2.75rem;\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --divider-color: rgba(84,84,88,0.65);\n --card-background: #1c1c1e;\n --list-header-color: rgba(235,235,245,0.6);\n }\n body {\n background: #000;\n color: #fff;\n }\n .item__name {\n color: white;\n }\n .item[data-is-directory=\"1\"] .item__name {\n color: #157EFB;\n }\n }`\n\n const js =\n `window.invoke = (code, data) => {\n window.dispatchEvent(\n new CustomEvent(\n 'JBridge',\n { detail: { code, data } }\n )\n )\n }\n\n const isSelectMode = () => {\n return document.querySelector('.list').classList.contains('list-select')\n }\n\n const removeItems = (items) => {\n const list = document.querySelector('.list')\n for (const item of items) {\n const el = document.querySelector(\\`.item[data-name=\"\\${item.name}\"]\\`)\n el.parentNode.remove()\n }\n }\n \n document.querySelector('.select').addEventListener('click', (e) => {\n \/** @type {HTMLButtonElement} *\/\n const target = e.currentTarget\n target.innerText = target.innerText === '选择' ? '完成' : '选择'\n \n document.querySelector('.select-all').toggleAttribute('hidden')\n document.querySelector('.list').classList.toggle('list-select')\n document.querySelector('.btn-del').classList.toggle('show')\n })\n \n document.querySelectorAll('.item')\n .forEach((el) => {\n el.addEventListener('click', (e) => {\n const target = e.currentTarget\n if (isSelectMode()) {\n \/** @type {HTMLElement} *\/\n const selection = target.querySelector('.item__selection')\n const isSelected = selection.classList.contains('icon-gouxuan')\n if (isSelected) {\n selection.classList.replace('icon-gouxuan', 'icon-yuan')\n } else {\n selection.classList.replace('icon-yuan', 'icon-gouxuan')\n }\n } else {\n const { name } = target.dataset\n invoke('view', target.dataset)\n }\n })\n })\n \n document.querySelector('.select-all').addEventListener('click', (e) => {\n \/** @type {HTMLButtonElement} *\/\n const target = e.currentTarget\n const isSelected = target.innerText === '取消全选'\n target.innerText = isSelected ? '全选' : '取消全选'\n document.querySelectorAll('.item__selection').forEach((e) => {\n if (isSelected) {\n e.classList.replace('icon-gouxuan', 'icon-yuan')\n } else {\n e.classList.replace('icon-yuan', 'icon-gouxuan')\n }\n })\n })\n \n document.querySelector('.btn-del').addEventListener('click', () => {\n const selectedItems = []\n for (const el of document.querySelectorAll('.icon-gouxuan')) {\n selectedItems.push({ ...el.parentNode.dataset })\n }\n invoke('remove', selectedItems)\n })\n\n window.addEventListener('JWeb', (e) => {\n const { code, data } = e.detail\n console.log('收到事件了')\n switch (code) {\n case 'remove-success':\n removeItems(JSON.parse(data))\n break;\n }\n })`\n\n const html =\n `\n \n \n \n \n ${title}<\/title>\n <link rel=\"stylesheet\" href=\"\/\/at.alicdn.com\/t\/c\/font_3772663_0lvf7sx0ati.css\">\n <style>${css}<\/style>\n <\/head>\n <body>\n <div class=\"header\">\n <div class=\"header__left\"><button class=\"select-all\" hidden>全选<\/button><\/div>\n <h3 class=\"title\">${title}<\/h3>\n <div class=\"header__right\"><button class=\"select\">选择<\/button><\/div>\n <\/div>\n <ul class=\"list\">\n ${list.map((file) => (\n `<li>\n <div class=\"item\" data-name=\"${file.name}\"\n data-is-directory=\"${Number(file.isDirectory)}\"\n data-file-path=\"${file.filePath}\"\n >\n <i class=\"iconfont icon-yuan item__selection\"><\/i>\n <div class=\"item__body\">\n <i class=\"iconfont ${file.isDirectory ? 'icon-folder-close' : 'icon-doc'} item__icon\"><\/i>\n <div class=\"item__content\">\n <div class=\"item__name\">${file.name}<\/div>\n ${file.info ? `<div class=\"item__info\">${file.info}<\/div>` : ''}\n <\/div>\n <\/div>\n <\/div>\n <\/li>`\n )).join('')}\n <\/ul>\n <div class=\"bottom-holder\"><\/div>\n <div class=\"fixed-bottom\">\n <button class=\"btn-del\">删除<\/button>\n <\/div>\n <script>${js}<\/script>\n <\/body>\n <\/html>`\n await webView.loadHTML(html, 'https:\/\/www.imarkr.com')\n\n const view = async (data) => {\n const { isDirectory, filePath, name } = data\n if (Number(isDirectory)) {\n const list = fm.listContents(filePath)\n .map((name) => {\n const path = fm.joinPath(filePath, name)\n return {\n name,\n info: `${fm.modificationDate(path).toLocaleString()}`,\n filePath: path,\n isDirectory: FileManager.local().isDirectory(path)\n }\n })\n presentList({\n title: name,\n list\n })\n } else {\n if (!fm.isFileDownloaded(filePath)) {\n await fm.downloadFileFromiCloud(filePath)\n }\n try {\n const image = fm.readImage(filePath)\n QuickLook.present(image, false)\n return\n } catch (e) {\n console.warn(e)\n }\n try {\n const text = fm.readString(filePath)\n QuickLook.present(text)\n } catch (e) {\n console.warn(e)\n }\n }\n }\n\n const remove = async (list) => {\n for (const file of list) {\n fm.remove(file.filePath)\n }\n webView.evaluateJavaScript(\n `window.dispatchEvent(new CustomEvent(\n 'JWeb',\n { detail: {\n code: 'remove-success',\n data: '${JSON.stringify(list)}'\n } }\n ))`,\n false\n )\n }\n\n const injectListener = async () => {\n const event = await webView.evaluateJavaScript(\n `(() => {\n const controller = new AbortController()\n const listener = (e) => {\n completion(e.detail)\n controller.abort()\n }\n window.addEventListener(\n 'JBridge',\n listener,\n { signal: controller.signal }\n )\n })()`,\n true\n ).catch((err) => {\n console.error(err)\n throw err\n })\n const { code, data } = event\n switch (code) {\n case 'view':\n view(data)\n break\n case 'remove':\n remove(data).catch((e) => console.error(e))\n break\n }\n injectListener()\n }\n\n injectListener().catch((e) => {\n console.error(e)\n throw e\n })\n webView.present()\n}\n\npresentList({\n title: '缓存清理',\n list: [\n {\n name: '本地缓存',\n filePath: FileManager.local().cacheDirectory(),\n isDirectory: true\n },\n {\n name: '本地文档',\n filePath: FileManager.local().documentsDirectory(),\n isDirectory: true\n },\n {\n name: '本地媒体',\n filePath: FileManager.local().libraryDirectory(),\n isDirectory: true\n },\n {\n name: '临时目录',\n filePath: FileManager.local().temporaryDirectory(),\n isDirectory: true\n },\n {\n name: 'iCloud 文档',\n filePath: FileManager.iCloud().documentsDirectory(),\n isDirectory: true\n },\n {\n name: 'iCloud 媒体',\n filePath: FileManager.iCloud().libraryDirectory(),\n isDirectory: true\n }\n ]\n})\n\n\/**\n * @typedef {object} File\n * @property {string} File.name\n * @property {string} [File.info]\n * @property {string} File.filePath\n * @property {boolean} File.isDirectory\n *\/\n", - "share_sheet_inputs" : [ - - ] -} \ No newline at end of file diff --git a/Scriptable/DmYY.js b/Scriptable/DmYY.js new file mode 100644 index 00000000..bed91f47 --- /dev/null +++ b/Scriptable/DmYY.js @@ -0,0 +1,2241 @@ +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: teal; icon-glyph: cogs; +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: teal; icon-glyph: cogs; + +/* + * Author: 2Ya + * Github: https://github.com/dompling + * UI 配置升级 感谢 @LSP 大佬提供代码 + */ + +class DmYY { + constructor(arg, defaultSettings) { + this.arg = arg; + this.defaultSettings = defaultSettings || {}; + this.SETTING_KEY = this.md5(Script.name()); + this._init(); + this.isNight = Device.isUsingDarkAppearance(); + } + + BaseCacheKey = 'DmYY'; + _actions = []; + _menuActions = []; + widgetColor; + backGroundColor; + isNight; + + userConfigKey = ['avatar', 'nickname', 'homePageDesc']; + + // 获取 Request 对象 + getRequest = (url = '') => { + return new Request(url); + }; + + // 发起请求 + http = async ( + options = { headers: {}, url: '' }, + type = 'JSON', + onError = () => { + return SFSymbol.named('photo').image; + } + ) => { + let request; + try { + if (type === 'IMG') { + const fileName = `${this.cacheImage}/${this.md5(options.url)}`; + request = this.getRequest(options.url); + let response; + if (await this.FILE_MGR.fileExistsExtra(fileName)) { + request.loadImage().then((res) => { + this.FILE_MGR.writeImage(fileName, res); + }); + return Image.fromFile(fileName); + } else { + response = await request.loadImage(); + this.FILE_MGR.writeImage(fileName, response); + } + return response; + } + request = this.getRequest(); + Object.keys(options).forEach((key) => { + request[key] = options[key]; + }); + request.headers = { ...this.defaultHeaders, ...options.headers }; + + if (type === 'JSON') { + return await request.loadJSON(); + } + if (type === 'STRING') { + return await request.loadString(); + } + return await request.loadJSON(); + } catch (e) { + console.log('error:' + e); + if (type === 'IMG') return onError?.(); + } + }; + + //request 接口请求 + $request = { + get: (url = '', options = {}, type = 'JSON') => { + let params = { ...options, method: 'GET' }; + if (typeof url === 'object') { + params = { ...params, ...url }; + } else { + params.url = url; + } + let _type = type; + if (typeof options === 'string') _type = options; + return this.http(params, _type); + }, + post: (url = '', options = {}, type = 'JSON') => { + let params = { ...options, method: 'POST' }; + if (typeof url === 'object') { + params = { ...params, ...url }; + } else { + params.url = url; + } + let _type = type; + if (typeof options === 'string') _type = options; + return this.http(params, _type); + }, + }; + + // 获取 boxJS 缓存 + getCache = async (key = '', notify = true) => { + try { + let url = 'http://' + this.prefix + '/query/boxdata'; + if (key) url = 'http://' + this.prefix + '/query/data/' + key; + const boxdata = await this.$request.get( + url, + key ? { timeoutInterval: 1 } : {} + ); + if (key) { + this.settings.BoxJSData = { + ...this.settings.BoxJSData, + [key]: boxdata.val, + }; + this.saveSettings(false); + } + if (boxdata.val) return boxdata.val; + + return boxdata.datas; + } catch (e) { + if (key && this.settings.BoxJSData[key]) { + return this.settings.BoxJSData[key]; + } + if (notify) + await this.notify( + `${this.name} - BoxJS 数据读取失败`, + '请检查 BoxJS 域名是否为代理复写的域名,如(boxjs.net 或 boxjs.com)。\n若没有配置 BoxJS 相关模块,请点击通知查看教程', + 'https://chavyleung.gitbook.io/boxjs/awesome/videos' + ); + return false; + } + }; + + transforJSON = (str) => { + if (typeof str == 'string') { + try { + return JSON.parse(str); + } catch (e) { + console.log(e); + return str; + } + } + console.log('It is not a string!'); + }; + + // 选择图片并缓存 + chooseImg = async (verify = false) => { + const response = await Photos.fromLibrary().catch((err) => { + console.log('图片选择异常:' + err); + }); + if (verify) { + const bool = await this.verifyImage(response); + if (bool) return response; + return null; + } + return response; + }; + + // 设置 widget 背景图片 + getWidgetBackgroundImage = async (widget) => { + const backgroundImage = await this.getBackgroundImage(); + if (backgroundImage) { + const opacity = Device.isUsingDarkAppearance() + ? Number(this.settings.darkOpacity) + : Number(this.settings.lightOpacity); + widget.backgroundImage = await this.shadowImage( + backgroundImage, + '#000', + opacity + ); + return true; + } else { + if (this.backGroundColor.colors) { + widget.backgroundGradient = this.backGroundColor; + } else { + widget.backgroundColor = this.backGroundColor; + } + return false; + } + }; + + /** + * 验证图片尺寸: 图片像素超过 1000 左右的时候会导致背景无法加载 + * @param img Image + */ + verifyImage = async (img = {}) => { + const { width, height } = img.size; + const direct = true; + if (width > 1000) { + const options = ['取消', '打开图像处理']; + const message = + '您的图片像素为' + + width + + ' x ' + + height + + '\n' + + '请将图片' + + (direct ? '宽度' : '高度') + + '调整到 1000 以下\n' + + (!direct ? '宽度' : '高度') + + '自动适应'; + const index = await this.generateAlert(message, options); + if (index === 1) + Safari.openInApp('https://www.sojson.com/image/change.html', false); + return false; + } + return true; + }; + + /** + * 获取截图中的组件剪裁图 + * 可用作透明背景 + * 返回图片image对象 + * 代码改自:https://gist.github.com/mzeryck/3a97ccd1e059b3afa3c6666d27a496c9 + * @param {string} title 开始处理前提示用户截图的信息,可选(适合用在组件自定义透明背景时提示) + */ + async getWidgetScreenShot(title = null) { + // Crop an image into the specified rect. + function cropImage(img, rect) { + let draw = new DrawContext(); + draw.size = new Size(rect.width, rect.height); + + draw.drawImageAtPoint(img, new Point(-rect.x, -rect.y)); + return draw.getImage(); + } + + function phoneSizes(inputHeight) { + return { + /* + + Supported devices + ================= + The following device measurements have been confirmed in iOS 18. + + */ + + // 16 Pro Max + 2868: { + text: { + small: 510, + medium: 1092, + large: 1146, + left: 114, + right: 696, + top: 276, + middle: 912, + bottom: 1548, + }, + notext: { + small: 530, + medium: 1138, + large: 1136, + left: 91, + right: 699, + top: 276, + middle: 882, + bottom: 1488, + }, + }, + + // 16 Plus, 15 Plus, 15 Pro Max, 14 Pro Max + 2796: { + text: { + small: 510, + medium: 1092, + large: 1146, + left: 98, + right: 681, + top: 252, + middle: 888, + bottom: 1524, + }, + notext: { + small: 530, + medium: 1139, + large: 1136, + left: 75, + right: 684, + top: 252, + middle: 858, + bottom: 1464, + }, + }, + + // 16 Pro + 2622: { + text: { + small: 486, + medium: 1032, + large: 1098, + left: 87, + right: 633, + top: 261, + middle: 872, + bottom: 1485, + }, + notext: { + small: 495, + medium: 1037, + large: 1035, + left: 84, + right: 626, + top: 270, + middle: 810, + bottom: 1350, + }, + }, + + // 16, 15, 15 Pro, 14 Pro + 2556: { + text: { + small: 474, + medium: 1017, + large: 1062, + left: 81, + right: 624, + top: 240, + middle: 828, + bottom: 1416, + }, + notext: { + small: 495, + medium: 1047, + large: 1047, + left: 66, + right: 618, + top: 243, + middle: 795, + bottom: 1347, + }, + }, + + // SE3, SE2 + 1334: { + text: { + small: 296, + medium: 642, + large: 648, + left: 54, + right: 400, + top: 60, + middle: 412, + bottom: 764, + }, + notext: { + small: 309, + medium: 667, + large: 667, + left: 41, + right: 399, + top: 67, + middle: 425, + bottom: 783, + }, + }, + + /* + + In-limbo devices + ================= + The following device measurements were confirmed in older versions of iOS. + Please comment if you can confirm these for iOS 18. + + */ + + // 14 Plus, 13 Pro Max, 12 Pro Max + 2778: { + small: 510, + medium: 1092, + large: 1146, + left: 96, + right: 678, + top: 246, + middle: 882, + bottom: 1518, + }, + + // 11 Pro Max, XS Max + 2688: { + small: 507, + medium: 1080, + large: 1137, + left: 81, + right: 654, + top: 228, + middle: 858, + bottom: 1488, + }, + + // 14, 13, 13 Pro, 12, 12 Pro + 2532: { + small: 474, + medium: 1014, + large: 1062, + left: 78, + right: 618, + top: 231, + middle: 819, + bottom: 1407, + }, + + // 13 mini, 12 mini / 11 Pro, XS, X + 2436: { + x: { + small: 465, + medium: 987, + large: 1035, + left: 69, + right: 591, + top: 213, + middle: 783, + bottom: 1353, + }, + mini: { + small: 465, + medium: 987, + large: 1035, + left: 69, + right: 591, + top: 231, + middle: 801, + bottom: 1371, + }, + }, + + // 11, XR + 1792: { + small: 338, + medium: 720, + large: 758, + left: 55, + right: 437, + top: 159, + middle: 579, + bottom: 999, + }, + + // 11 and XR in Display Zoom mode + 1624: { + small: 310, + medium: 658, + large: 690, + left: 46, + right: 394, + top: 142, + middle: 522, + bottom: 902, + }, + + /* + + Older devices + ================= + The following devices cannot be updated to iOS 18 or later. + + */ + + // Home button Plus phones + 2208: { + small: 471, + medium: 1044, + large: 1071, + left: 99, + right: 672, + top: 114, + middle: 696, + bottom: 1278, + }, + + // Home button Plus in Display Zoom mode + 2001: { + small: 444, + medium: 963, + large: 972, + left: 81, + right: 600, + top: 90, + middle: 618, + bottom: 1146, + }, + + // SE1 + 1136: { + small: 282, + medium: 584, + large: 622, + left: 30, + right: 332, + top: 59, + middle: 399, + bottom: 399, + }, + }[inputHeight]; + } + + let message = + title || '开始之前,请先前往桌面,截取空白界面的截图。然后回来继续'; + let exitOptions = ['我已截图', '前去截图 >']; + let shouldExit = await this.generateAlert(message, exitOptions); + if (shouldExit) return; + + // Get screenshot and determine phone size. + let img = await Photos.fromLibrary(); + let height = img.size.height; + let phone = phoneSizes(height); + if (!phone) { + message = '好像您选择的照片不是正确的截图,请先前往桌面'; + await this.generateAlert(message, ['我已知晓']); + return; + } + // Extra setup needed for 2436-sized phones. + if (height === 2436) { + const files = this.FILE_MGR_LOCAL; + let cacheName = 'mz-phone-type'; + let cachePath = files.joinPath(files.libraryDirectory(), cacheName); + + // If we already cached the phone size, load it. + if (files.fileExists(cachePath)) { + let typeString = files.readString(cachePath); + phone = phone[typeString]; + // Otherwise, prompt the user. + } else { + message = '您的📱型号是?'; + let types = ['iPhone 12 mini', 'iPhone 11 Pro, XS, or X']; + let typeIndex = await this.generateAlert(message, types); + let type = typeIndex === 0 ? 'mini' : 'x'; + phone = phone[type]; + files.writeString(cachePath, type); + } + } + + // If supported, check whether home screen has text labels or not. + if (phone.text) { + message = '主屏幕是否有文本标签?'; + const textOptions = ['有', '无']; + const _textOptions = ['text', 'notext']; + const textResponse = await this.generateAlert(message, textOptions); + phone = phone[_textOptions[textResponse]]; + } + + // Prompt for widget size and position. + message = '截图中要设置透明背景组件的尺寸类型是?'; + let sizes = ['小尺寸', '中尺寸', '大尺寸']; + let size = await this.generateAlert(message, sizes); + let widgetSize = sizes[size]; + + message = '要设置透明背景的小组件在哪个位置?'; + message += + height === 1136 + ? ' (备注:当前设备只支持两行小组件,所以下边选项中的「中间」和「底部」的选项是一致的)' + : ''; + + // Determine image crop based on phone size. + let crop = { w: '', h: '', x: '', y: '' }; + if (widgetSize === '小尺寸') { + crop.w = phone.small; + crop.h = phone.small; + let positions = [ + '左上角', + '右上角', + '中间左', + '中间右', + '左下角', + '右下角', + ]; + let _posotions = [ + 'Top left', + 'Top right', + 'Middle left', + 'Middle right', + 'Bottom left', + 'Bottom right', + ]; + let position = await this.generateAlert(message, positions); + + // Convert the two words into two keys for the phone size dictionary. + let keys = _posotions[position].toLowerCase().split(' '); + crop.y = phone[keys[0]]; + crop.x = phone[keys[1]]; + } else if (widgetSize === '中尺寸') { + crop.w = phone.medium; + crop.h = phone.small; + + // Medium and large widgets have a fixed x-value. + crop.x = phone.left; + let positions = ['顶部', '中间', '底部']; + let _positions = ['Top', 'Middle', 'Bottom']; + let position = await this.generateAlert(message, positions); + let key = _positions[position].toLowerCase(); + crop.y = phone[key]; + } else if (widgetSize === '大尺寸') { + crop.w = phone.medium; + crop.h = phone.large; + crop.x = phone.left; + let positions = ['顶部', '底部']; + let position = await this.generateAlert(message, positions); + + // Large widgets at the bottom have the "middle" y-value. + crop.y = position ? phone.middle : phone.top; + } + + // Crop image and finalize the widget. + return cropImage(img, new Rect(crop.x, crop.y, crop.w, crop.h)); + } + + setLightAndDark = async (title, desc, val, placeholder = '') => { + try { + const a = new Alert(); + a.title = title; + a.message = desc; + a.addTextField(placeholder, `${this.settings[val] || ''}`); + a.addAction('确定'); + a.addCancelAction('取消'); + const id = await a.presentAlert(); + if (id === -1) return false; + this.settings[val] = a.textFieldValue(0) || ''; + this.saveSettings(); + return true; + } catch (e) { + console.log(e); + } + }; + + /** + * 弹出输入框 + * @param title 标题 + * @param desc 描述 + * @param opt 属性 + * @returns {Promise<void>} + */ + setAlertInput = async (title, desc, opt = {}, isSave = true) => { + const a = new Alert(); + a.title = title; + a.message = !desc ? '' : desc; + Object.keys(opt).forEach((key) => { + a.addTextField(opt[key], this.settings[key]); + }); + a.addAction('确定'); + a.addCancelAction('取消'); + const id = await a.presentAlert(); + if (id === -1) return; + const data = {}; + Object.keys(opt).forEach((key, index) => { + data[key] = a.textFieldValue(index) || ''; + }); + // 保存到本地 + if (isSave) { + this.settings = { ...this.settings, ...data }; + return this.saveSettings(); + } + return data; + }; + + setBaseAlertInput = async (title, desc, opt = {}, isSave = true) => { + const a = new Alert(); + a.title = title; + a.message = !desc ? '' : desc; + Object.keys(opt).forEach((key) => { + a.addTextField(opt[key], this.baseSettings[key] || ''); + }); + a.addAction('确定'); + a.addCancelAction('取消'); + const id = await a.presentAlert(); + if (id === -1) return; + const data = {}; + Object.keys(opt).forEach((key, index) => { + data[key] = a.textFieldValue(index) || ''; + }); + // 保存到本地 + if (isSave) return this.saveBaseSettings(data); + return data; + }; + + /** + * 设置当前项目的 boxJS 缓存 + * @param opt key value + * @returns {Promise<void>} + */ + setCacheBoxJSData = async (opt = {}) => { + const options = ['取消', '确定']; + const message = '代理缓存仅支持 BoxJS 相关的代理!'; + const index = await this.generateAlert(message, options); + if (index === 0) return; + try { + const boxJSData = await this.getCache(); + Object.keys(opt).forEach((key) => { + this.settings[key] = boxJSData[opt[key]] || ''; + }); + // 保存到本地 + this.saveSettings(); + } catch (e) { + console.log(e); + this.notify( + this.name, + 'BoxJS 缓存读取失败!点击查看相关教程', + 'https://chavyleung.gitbook.io/boxjs/awesome/videos' + ); + } + }; + + /** + * 设置组件内容 + * @returns {Promise<void>} + */ + setWidgetConfig = async () => { + const basic = [ + { + icon: { name: 'arrow.clockwise', color: '#1890ff' }, + type: 'input', + title: '刷新时间', + desc: '刷新时间仅供参考,具体刷新时间由系统判断,单位:分钟', + val: 'refreshAfterDate', + }, + { + icon: { name: 'sun.max.fill', color: '#d48806' }, + type: 'color', + title: '白天字体颜色', + desc: '请自行去网站上搜寻颜色(Hex 颜色)', + val: 'lightColor', + }, + { + icon: { name: 'moon.stars.fill', color: '#d4b106' }, + type: 'color', + title: '晚上字体颜色', + desc: '请自行去网站上搜寻颜色(Hex 颜色)', + val: 'darkColor', + }, + ]; + + return this.renderAppView([ + { title: '基础设置', menu: basic }, + { + title: '背景设置', + menu: [ + { + icon: { name: 'photo', color: '#13c2c2' }, + type: 'color', + title: '白天背景颜色', + desc: '请自行去网站上搜寻颜色(Hex 颜色)\n支持渐变色,各颜色之间以英文逗号分隔', + val: 'lightBgColor', + }, + { + icon: { name: 'photo.fill', color: '#52c41a' }, + type: 'color', + title: '晚上背景颜色', + desc: '请自行去网站上搜寻颜色(Hex 颜色)\n支持渐变色,各颜色之间以英文逗号分隔', + val: 'darkBgColor', + }, + ], + }, + { + menu: [ + { + icon: { name: 'photo.on.rectangle', color: '#fa8c16' }, + name: 'dayBg', + type: 'img', + title: '日间背景', + val: this.cacheImage, + verify: true, + }, + { + icon: { name: 'photo.fill.on.rectangle.fill', color: '#fa541c' }, + name: 'nightBg', + type: 'img', + title: '夜间背景', + val: this.cacheImage, + verify: true, + }, + { + icon: { name: 'text.below.photo', color: '#faad14' }, + type: 'img', + name: 'transparentBg', + title: '透明背景', + val: this.cacheImage, + onClick: async (item, __, previewWebView) => { + const backImage = await this.getWidgetScreenShot(); + if (!backImage || !(await this.verifyImage(backImage))) return; + const cachePath = `${item.val}/${item.name}`; + await this.htmlChangeImage(backImage, cachePath, { + previewWebView, + id: item.name, + }); + }, + }, + ], + }, + { + menu: [ + { + icon: { name: 'record.circle', color: '#722ed1' }, + type: 'input', + title: '日间蒙层', + desc: '完全透明请设置为0', + val: 'lightOpacity', + }, + { + icon: { name: 'record.circle.fill', color: '#eb2f96' }, + type: 'input', + title: '夜间蒙层', + desc: '完全透明请设置为0', + val: 'darkOpacity', + }, + ], + }, + { + menu: [ + { + icon: { name: 'clear', color: '#f5222d' }, + name: 'removeBackground', + title: '清空背景图片', + val: `${this.cacheImage}/`, + onClick: async (_, __, previewWebView) => { + const ids = ['dayBg', 'nightBg', 'transparentBg']; + const options = [ + '清空日间', + '清空夜间', + '清空透明', + `清空全部`, + '取消', + ]; + const message = '该操作不可逆,会清空背景图片!'; + const index = await this.generateAlert(message, options); + if (index === 4) return; + switch (index) { + case 3: + await this.htmlChangeImage(false, `${_.val}${ids[0]}`, { + previewWebView, + id: ids[0], + }); + await this.htmlChangeImage(false, `${_.val}${ids[1]}`, { + previewWebView, + id: ids[1], + }); + await this.htmlChangeImage(false, `${_.val}${ids[2]}`, { + previewWebView, + id: ids[2], + }); + return; + default: + await this.htmlChangeImage(false, `${_.val}${ids[index]}`, { + previewWebView, + id: ids[index], + }); + break; + } + }, + }, + ], + }, + { + title: '重置组件', + menu: [ + { + icon: { name: 'trash', color: '#D85888' }, + title: '重置', + desc: '重置当前组件配置', + name: 'reset', + val: 'reset', + onClick: () => { + this.settings = {}; + this.saveSettings(); + this.reopenScript(); + }, + }, + ], + }, + ]).catch((e) => { + console.log(e); + }); + }; + + drawTableIcon = async ( + icon = 'square.grid.2x2', + color = '#504ED5', + cornerWidth = 42 + ) => { + let sfi = SFSymbol.named('square.grid.2x2'); + try { + sfi = SFSymbol.named(icon); + sfi.applyFont(Font.mediumSystemFont(30)); + } catch (e) { + console.log(`图标(${icon})异常:` + e); + } + const imgData = Data.fromPNG(sfi.image).toBase64String(); + const html = ` + <img id="sourceImg" src="data:image/png;base64,${imgData}" /> + <img id="silhouetteImg" src="" /> + <canvas id="mainCanvas" /> + `; + const js = ` + var canvas = document.createElement("canvas"); + var sourceImg = document.getElementById("sourceImg"); + var silhouetteImg = document.getElementById("silhouetteImg"); + var ctx = canvas.getContext('2d'); + var size = sourceImg.width > sourceImg.height ? sourceImg.width : sourceImg.height; + canvas.width = size; + canvas.height = size; + ctx.drawImage(sourceImg, (canvas.width - sourceImg.width) / 2, (canvas.height - sourceImg.height) / 2); + var imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); + var pix = imgData.data; + //convert the image into a silhouette + for (var i=0, n = pix.length; i < n; i+= 4){ + //set red to 0 + pix[i] = 255; + //set green to 0 + pix[i+1] = 255; + //set blue to 0 + pix[i+2] = 255; + //retain the alpha value + pix[i+3] = pix[i+3]; + } + ctx.putImageData(imgData,0,0); + silhouetteImg.src = canvas.toDataURL(); + output=canvas.toDataURL() + `; + + let wv = new WebView(); + await wv.loadHTML(html); + const base64Image = await wv.evaluateJavaScript(js); + const iconImage = await new Request(base64Image).loadImage(); + const size = new Size(160, 160); + const ctx = new DrawContext(); + ctx.opaque = false; + ctx.respectScreenScale = true; + ctx.size = size; + const path = new Path(); + const rect = new Rect(0, 0, size.width, size.width); + + path.addRoundedRect(rect, cornerWidth, cornerWidth); + path.closeSubpath(); + ctx.setFillColor(new Color(color)); + ctx.addPath(path); + ctx.fillPath(); + const rate = 36; + const iw = size.width - rate; + const x = (size.width - iw) / 2; + ctx.drawImageInRect(iconImage, new Rect(x, x, iw, iw)); + return ctx.getImage(); + }; + + dismissLoading = (webView) => { + webView.evaluateJavaScript( + "window.dispatchEvent(new CustomEvent('JWeb', { detail: { code: 'finishLoading' } }))", + false + ); + }; + + insertTextByElementId = (webView, elementId, text) => { + const scripts = `document.getElementById("${elementId}_val").innerHTML=\`${text}\`;`; + webView.evaluateJavaScript(scripts, false); + }; + + loadSF2B64 = async ( + icon = 'square.grid.2x2', + color = '#56A8D6', + cornerWidth = 42 + ) => { + const sfImg = await this.drawTableIcon(icon, color, cornerWidth); + return `data:image/png;base64,${Data.fromPNG(sfImg).toBase64String()}`; + }; + + setUserInfo = async () => { + const baseOnClick = async (item, _, previewWebView) => { + const data = await this.setBaseAlertInput(item.title, item.desc, { + [item.val]: item.placeholder, + }); + if (!data) return; + this.insertTextByElementId(previewWebView, item.name, data[item.val]); + }; + + return this.renderAppView([ + { + title: '个性设置', + menu: [ + { + icon: { name: 'person', color: '#fa541c' }, + name: this.userConfigKey[0], + title: '首页头像', + type: 'img', + val: this.baseImage, + onClick: async (_, __, previewWebView) => { + const options = ['相册选择', '在线链接', '取消']; + const message = '设置个性化头像'; + const index = await this.generateAlert(message, options); + if (index === 2) return; + const cachePath = `${_.val}/${_.name}`; + switch (index) { + case 0: + const albumOptions = ['选择图片', '清空图片', '取消']; + + const albumIndex = await this.generateAlert('', albumOptions); + if (albumIndex === 2) return; + if (albumIndex === 1) { + await this.htmlChangeImage(false, cachePath, { + previewWebView, + id: _.name, + }); + return; + } + + const backImage = await this.chooseImg(); + if (backImage) { + await this.htmlChangeImage(backImage, cachePath, { + previewWebView, + id: _.name, + }); + } + + break; + case 1: + const data = await this.setBaseAlertInput( + '在线链接', + '首页头像在线链接', + { + avatar: '🔗请输入 URL 图片链接', + } + ); + if (!data) return; + + if (data[_.name] !== '') { + const backImage = await this.$request.get( + data[_.name], + 'IMG' + ); + await this.htmlChangeImage(backImage, cachePath, { + previewWebView, + id: _.name, + }); + } else { + await this.htmlChangeImage(false, cachePath, { + previewWebView, + id: _.name, + }); + } + + break; + default: + break; + } + }, + }, + { + icon: { name: 'pencil', color: '#fa8c16' }, + type: 'input', + title: '首页昵称', + desc: '个性化首页昵称', + placeholder: '👤请输入头像昵称', + val: this.userConfigKey[1], + name: this.userConfigKey[1], + defaultValue: this.baseSettings.nickname, + onClick: baseOnClick, + }, + { + icon: { name: 'lineweight', color: '#a0d911' }, + type: 'input', + title: '首页昵称描述', + desc: '个性化首页昵称描述', + placeholder: '请输入描述', + val: this.userConfigKey[2], + name: this.userConfigKey[2], + defaultValue: this.baseSettings.homePageDesc, + onClick: baseOnClick, + }, + ], + }, + { + menu: [ + { + icon: { name: 'shippingbox', color: '#f7bb10' }, + type: 'input', + title: 'BoxJS 域名', + desc: '设置BoxJS访问域名,如:boxjs.net 或 boxjs.com', + val: 'boxjsDomain', + name: 'boxjsDomain', + placeholder: 'boxjs.net', + defaultValue: this.baseSettings.boxjsDomain, + onClick: baseOnClick, + }, + { + icon: { name: 'clear', color: '#f5222d' }, + title: '恢复默认设置', + name: 'reset', + onClick: async () => { + const options = ['取消', '确定']; + const message = '确定要恢复当前所有配置吗?'; + const index = await this.generateAlert(message, options); + if (index === 1) { + this.settings = {}; + this.baseSettings = {}; + + this.FILE_MGR.remove(this.cacheImage); + + for (const item of this.cacheImageBgPath) { + await this.setBackgroundImage(false, item, false); + } + + this.saveSettings(false); + this.saveBaseSettings(); + await this.notify( + '重置成功', + '请关闭窗口之后,重新运行当前脚本' + ); + this.reopenScript(); + } + }, + }, + ], + }, + ]); + }; + + htmlChangeImage = async (image, path, { previewWebView, id }) => { + const base64Img = await this.setBackgroundImage(image, path, false); + console.log(path); + this.insertTextByElementId( + previewWebView, + id, + base64Img ? `<img src="${base64Img}"/>` : '' + ); + }; + + reopenScript = () => { + Safari.open(`scriptable:///run/${encodeURIComponent(Script.name())}`); + }; + + async renderAppView( + options = [], + renderAvatar = false, + previewWebView = new WebView() + ) { + const settingItemFontSize = 14, + authorNameFontSize = 20, + authorDescFontSize = 12; + // ================== 配置界面样式 =================== + const style = ` + :root { + --color-primary: #007aff; + --divider-color: rgba(60,60,67,0.16); + --card-background: #fff; + --card-radius: 8px; + --list-header-color: rgba(60,60,67,0.6); + } + * { + -webkit-user-select: none; + user-select: none; + } + body { + margin: 10px 0; + -webkit-font-smoothing: antialiased; + font-family: "SF Pro Display","SF Pro Icons","Helvetica Neue","Helvetica","Arial",sans-serif; + accent-color: var(--color-primary); + background: #f6f6f6; + } + .list { + margin: 15px; + } + .list__header { + margin: 0 18px; + color: var(--list-header-color); + font-size: 13px; + } + .list__body { + margin-top: 10px; + background: var(--card-background); + border-radius: var(--card-radius); + overflow: hidden; + } + .form-item-auth { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 4em; + padding: 0.5em 18px; + position: relative; + } + .form-item-auth-name { + margin: 0px 12px; + font-size: ${authorNameFontSize}px; + font-weight: 430; + } + .form-item-auth-desc { + margin: 0px 12px; + font-size: ${authorDescFontSize}px; + font-weight: 400; + } + .form-label-author-avatar { + width: 62px; + height: 62px; + border-radius:50%; + border: 1px solid #F6D377; + } + .form-item, .form-item-switch { + display: flex; + align-items: center; + justify-content: space-between; + font-size: ${settingItemFontSize}px; + font-weight: 400; + min-height: 2.2em; + padding: 0.5em 10px; + position: relative; + } + label > * { + pointer-events: none; + } + .form-label { + display: flex; + align-items: center; + flex-wrap:nowrap + } + .form-label-img { + height: 30px; + } + .form-label-title { + margin-left: 8px; + white-space: nowrap; + } + .bottom-bg { + margin: 30px 15px 15px 15px; + } + .form-item--link .icon-arrow-right { + color: #86868b; + } + + .form-item-right-desc { + font-size: 13px; + color: #86868b; + margin: 0 4px 0 auto; + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + display:flex; + align-items: center; + white-space: nowrap; + } + + .form-item-right-desc img{ + width:30px; + height:30px; + border-radius:3px; + } + + .form-item + .form-item::before, + .form-item + .form-item-switch::before, + .form-item-switch + .form-item::before, + .form-item-switch + .form-item-switch::before + { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + border-top: 0.5px solid var(--divider-color); + } + + .form-item input[type="checkbox"] { + width: 2em; + height: 2em; + } + input[type='input'],select,input[type='date'] { + width: 100%; + height: 2.3em; + outline-style: none; + text-align: right; + padding: 0px 10px; + border: 1px solid #ddd; + font-size: 14px; + color: #86868b; + border-radius:4px; + } + input[type='checkbox'][role='switch'] { + position: relative; + display: inline-block; + appearance: none; + width: 40px; + height: 24px; + border-radius: 24px; + background: #ccc; + transition: 0.3s ease-in-out; + } + input[type='checkbox'][role='switch']::before { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + transition: 0.3s ease-in-out; + } + input[type='checkbox'][role='switch']:checked { + background: var(--color-primary); + } + input[type='checkbox'][role='switch']:checked::before { + transform: translateX(16px); + } + .copyright { + display: flex; + align-items: center; + justify-content: space-between; + margin: 15px; + font-size: 10px; + color: #86868b; + } + .copyright a { + color: #515154; + text-decoration: none; + } + .preview.loading { + pointer-events: none; + } + .icon-loading { + display: inline-block; + animation: 1s linear infinite spin; + } + .normal-loading { + display: inline-block; + animation: 20s linear infinite spin; + } + @keyframes spin { + 0% { + transform: rotate(0); + } + 100% { + transform: rotate(1turn); + } + } + @media (prefers-color-scheme: dark) { + :root { + --divider-color: rgba(84,84,88,0.65); + --card-background: #1c1c1e; + --list-header-color: rgba(235,235,245,0.6); + } + body { + background: #000; + color: #fff; + } + }`; + + const js = ` + (() => { + + window.invoke = (code, data) => { + window.dispatchEvent( + new CustomEvent( + 'JBridge', + { detail: { code, data } } + ) + ) + } + + // 切换ico的loading效果 + const toggleIcoLoading = (e) => { + try{ + const target = e.currentTarget + target.classList.add('loading') + const icon = e.currentTarget.querySelector('.iconfont') + const className = icon.className + icon.className = 'iconfont icon-loading' + const listener = (event) => { + const { code } = event.detail + if (code === 'finishLoading') { + target.classList.remove('loading') + icon.className = className + window.removeEventListener('JWeb', listener); + } + } + window.addEventListener('JWeb', listener) + }catch(e){ + for (const loading of document.querySelectorAll('.icon-loading')) { + loading.classList.remove('loading'); + loading.className = "iconfont icon-arrow-right"; + } + } + }; + + for (const btn of document.querySelectorAll('.form-item')) { + btn.addEventListener('click', (e) => { + if(!e.target.id)return; + toggleIcoLoading(e); + invoke(e.target.id); + }) + } + + for (const btn of document.querySelectorAll('.form-item__input')) { + btn.addEventListener('change', (e) => { + if(!e.target.name)return; + invoke(e.target.name,e.target.type==="checkbox"?\`\${e.target.checked}\`: e.target.value); + }) + } + + if(${renderAvatar}){ + document.querySelectorAll('.form-item-auth')[0].addEventListener('click', (e) => { + toggleIcoLoading(e); + invoke("userInfo"); + }) + } + + })()`; + + let configList = ``; + let actionsConfig = []; + + for (const key in options) { + const item = options[key]; + actionsConfig = [...item.menu, ...actionsConfig]; + configList += ` + <div class="list"> + <div class="list__header">${item.title || ''}</div> + <form id="form_${key}" class="list__body" action="javascript:void(0);"> + `; + + for (const menuItem of item.menu) { + let iconBase64 = ``; + if (menuItem.children) { + menuItem.onClick = () => { + return this.renderAppView( + typeof menuItem.children === 'function' + ? menuItem.children() + : menuItem.children + ); + }; + } + if (menuItem.url) { + const imageIcon = await this.http( + { url: menuItem.url }, + 'IMG', + () => { + return this.drawTableIcon('gear'); + } + ); + + if (menuItem.url.indexOf('png') !== -1) { + iconBase64 = `data:image/png;base64,${Data.fromPNG( + imageIcon + ).toBase64String()}`; + } else { + iconBase64 = `data:image/png;base64,${Data.fromJPEG( + imageIcon + ).toBase64String()}`; + } + } else { + const icon = menuItem.icon || {}; + iconBase64 = await this.loadSF2B64(icon.name, icon.color); + } + const idName = menuItem.name || menuItem.val; + + let defaultHtml = ``; + menuItem.defaultValue = + this.settings[idName] || menuItem.defaultValue || ''; + + if (menuItem.type === 'input') { + defaultHtml = menuItem.defaultValue || ''; + } else if (menuItem.type === 'img') { + const cachePath = `${menuItem.val}/${menuItem.name}`; + if (await this.FILE_MGR.fileExistsExtra(cachePath)) { + const imageSrc = `data:image/png;base64,${Data.fromFile( + cachePath + ).toBase64String()}`; + defaultHtml = `<img src="${imageSrc}"/>`; + } + } else if (menuItem.type === 'select') { + let selectOptions = ''; + menuItem.options.forEach((option) => { + let selected = `selected="selected"`; + selectOptions += `<option value="${option}" ${ + menuItem.defaultValue == option ? selected : '' + }>${option}</option>`; + }); + defaultHtml = `<select class="form-item__input" name="${idName}">${selectOptions}</select>`; + } else if (menuItem.type === 'switch') { + const checked = + menuItem.defaultValue == 'true' ? `checked="checked"` : ''; + defaultHtml += `<input class="form-item__input" name="${idName}" role="switch" type="checkbox" value="true" ${checked} />`; + } else if (menuItem.type) { + defaultHtml = `<input class="form-item__input" placeholder="${ + menuItem.placeholder || '请输入' + }" name="${idName}" type="${ + menuItem.type + }" enterkeyhint="done" value="${menuItem.defaultValue || ''}">`; + } + + let addLable = ''; + if (menuItem.type === 'switch' || menuItem.type === 'checkbox') { + addLable = `<label id="${idName}" class="form-item-switch form-item--link">`; + } else { + addLable = `<label id="${idName}" class="form-item form-item--link">`; + } + + configList += ` + ${addLable} + <div class="form-label item-none"> + <img class="form-label-img" class="form-label-img" src="${iconBase64}"/> + <div class="form-label-title">${menuItem.title}</div> + </div> + <div id="${idName}_val" class="form-item-right-desc"> + ${defaultHtml} + </div> + <i id="iconfont-${idName}" class="iconfont icon-arrow-right"></i> + </label> + `; + } + configList += `</form></div>`; + } + + let avatarHtml = ''; + if (renderAvatar) { + const cachePath = `${this.baseImage}/${this.userConfigKey[0]}`; + const avatarConfig = { + avatar: `https://avatars.githubusercontent.com/u/23498579?v=4`, + nickname: this.baseSettings[this.userConfigKey[1]] || 'Dompling', + homPageDesc: + this.baseSettings[this.userConfigKey[2]] || + '18岁,来自九仙山的设计师', + }; + + if (await this.FILE_MGR.fileExistsExtra(cachePath)) { + avatarConfig.avatar = `data:image/png;base64,${Data.fromFile( + cachePath + ).toBase64String()}`; + } + + avatarHtml = ` + <div class="list"> + <form class="list__body" action="javascript:void(0);"> + <label id="userInfo" class="form-item-auth form-item--link"> + <div class="form-label"> + <img class="form-label-author-avatar" src="${avatarConfig.avatar}"/> + <div> + <div class="form-item-auth-name">${avatarConfig.nickname}</div> + <div class="form-item-auth-desc">${avatarConfig.homPageDesc}</div> + </div> + </div> + <div id="userInfo_val" class="form-item-right-desc"> + 个性化设置 + </div> + <i class="iconfont icon-arrow-right"></i> + </label> + </form> + </div> + `; + } + + const html = ` + <html> + <head> + <meta name='viewport' content='width=device-width, user-scalable=no'> + <link rel="stylesheet" href="https://at.alicdn.com/t/c/font_3791881_bf011w225k4.css" type="text/css"> + <style>${style}</style> + </head> + <body> + ${avatarHtml} + ${configList} + <footer> + <div class="copyright"><div> </div><div>© 界面样式参考 <a href="javascript:invoke('safari', 'https://www.imarkr.com');">@iMarkr.</a></div></div> + </footer> + <script>${js}</script> + </body> + </html>`; + + // 预览web + await previewWebView.loadHTML(html); + + const injectListener = async () => { + const event = await previewWebView.evaluateJavaScript( + `(() => { + try { + window.addEventListener( + 'JBridge', + (e)=>{ + completion(JSON.stringify(e.detail||{})) + } + ) + } catch (e) { + alert("预览界面出错:" + e); + throw new Error("界面处理出错: " + e); + return; + } + })()`, + true + ); + + const { code, data } = JSON.parse(event); + try { + const actionItem = actionsConfig.find( + (item) => (item.name || item.val) === code + ); + + if (code === 'userInfo') await this.setUserInfo(); + + if (actionItem) { + const idName = actionItem?.name || actionItem?.val; + if (actionItem?.onClick) { + await actionItem?.onClick?.(actionItem, data, previewWebView); + } else if (actionItem.type == 'input') { + if ( + await this.setLightAndDark( + actionItem['title'], + actionItem['desc'], + idName, + actionItem['placeholder'] + ) + ) + this.insertTextByElementId( + previewWebView, + idName, + this.settings[idName] || '' + ); + } else if (actionItem.type === 'img') { + const cachePath = `${actionItem.val}/${actionItem.name}`; + const options = ['相册选择', '清空图片', '取消']; + const message = '相册图片选择,请选择合适图片大小'; + const index = await this.generateAlert(message, options); + switch (index) { + case 0: + const backImage = await this.chooseImg(actionItem.verify); + if (backImage) { + const cachePath = `${actionItem.val}/${actionItem.name}`; + await this.htmlChangeImage(backImage, cachePath, { + previewWebView, + id: idName, + }); + } + break; + case 1: + await this.htmlChangeImage(false, cachePath, { + previewWebView, + id: idName, + }); + break; + default: + break; + } + } else { + if (data !== undefined) { + this.settings[idName] = data; + this.saveSettings(false); + } + } + } + } catch (error) { + console.log('异常操作:' + error); + } + this.dismissLoading(previewWebView); + injectListener(); + }; + + injectListener().catch((e) => { + console.error(e); + this.dismissLoading(previewWebView); + if (!config.runsInApp) { + this.notify('主界面', `🚫 ${e}`); + } + }); + + previewWebView.present(); + } + + initSFSymbol() { + const named = SFSymbol.named; + SFSymbol.named = (str) => { + const current = named(str); + if (!current) { + console.log(`图标异常,请在文中搜索并替换图标:${str}`); + return named('photo'); + } + return current; + }; + return SFSymbol; + } + + _init(widgetFamily = config.widgetFamily) { + this.initSFSymbol(); + // 组件大小:small,medium,large + this.widgetFamily = widgetFamily; + //用于配置所有的组件相关设置 + + // 文件管理器 + // 提示:缓存数据不要用这个操作,这个是操作源码目录的,缓存建议存放在local temp目录中 + this.FILE_MGR = + FileManager[ + module.filename.includes('Documents/iCloud~') ? 'iCloud' : 'local' + ](); + + this.FILE_MGR.fileExistsExtra = async (filePath) => { + const file = this.FILE_MGR.fileExists(filePath); + if (file) await this.FILE_MGR.downloadFileFromiCloud(filePath); + return file; + }; + + this.cacheImage = this.FILE_MGR.joinPath( + this.FILE_MGR.documentsDirectory(), + `/images/${Script.name()}` + ); + + this.baseImage = this.FILE_MGR.joinPath( + this.FILE_MGR.documentsDirectory(), + `/images/` + ); + + this.cacheImageBgPath = [ + `${this.cacheImage}/transparentBg`, + `${this.cacheImage}/dayBg`, + `${this.cacheImage}/nightBg`, + `${this.baseImage}/avatar`, + ]; + + if (!this.FILE_MGR.fileExists(this.cacheImage)) { + this.FILE_MGR.createDirectory(this.cacheImage, true); + } + + // 本地,用于存储图片等 + this.FILE_MGR_LOCAL = FileManager.local(); + + this.settings = this.getSettings(); + + this.baseSettings = this.getBaseSettings(); + + this.settings = { ...this.defaultSettings, ...this.settings }; + + this.settings.lightColor = this.settings.lightColor || '#000000'; + this.settings.darkColor = this.settings.darkColor || '#ffffff'; + this.settings.lightBgColor = this.settings.lightBgColor || '#ffffff'; + this.settings.darkBgColor = this.settings.darkBgColor || '#000000'; + this.settings.boxjsDomain = this.baseSettings.boxjsDomain || 'boxjs.net'; + this.settings.refreshAfterDate = this.settings.refreshAfterDate || '30'; + this.settings.lightOpacity = this.settings.lightOpacity || '0.4'; + this.settings.darkOpacity = this.settings.darkOpacity || '0.7'; + + this.prefix = this.settings.boxjsDomain; + + config.runsInApp && this.saveSettings(false); + + this.backGroundColor = Color.dynamic( + new Color(this.settings.lightBgColor), + new Color(this.settings.darkBgColor) + ); + + // const lightBgColor = this.getColors(this.settings.lightBgColor); + // const darkBgColor = this.getColors(this.settings.darkBgColor); + // if (lightBgColor.length > 1 || darkBgColor.length > 1) { + // this.backGroundColor = !Device.isUsingDarkAppearance() + // ? this.getBackgroundColor(lightBgColor) + // : this.getBackgroundColor(darkBgColor); + // } else if (lightBgColor.length > 0 && darkBgColor.length > 0) { + // this.backGroundColor = Color.dynamic( + // new Color(this.settings.lightBgColor), + // new Color(this.settings.darkBgColor) + // ); + // } + + this.widgetColor = Color.dynamic( + new Color(this.settings.lightColor), + new Color(this.settings.darkColor) + ); + } + + getColors = (color = '') => { + const colors = typeof color === 'string' ? color.split(',') : color; + return colors; + }; + + getBackgroundColor = (colors) => { + const locations = []; + const linearColor = new LinearGradient(); + const cLen = colors.length; + linearColor.colors = colors.map((item, index) => { + locations.push(Math.floor(((index + 1) / cLen) * 100) / 100); + return new Color(item, 1); + }); + linearColor.locations = locations; + return linearColor; + }; + + /** + * 注册点击操作菜单 + * @param {string} name 操作函数名 + * @param {func} func 点击后执行的函数 + */ + registerAction(name, func, icon = { name: 'gear', color: '#096dd9' }, type) { + if (typeof name === 'object' && !name.menu) return this._actions.push(name); + if (typeof name === 'object' && name.menu) + return this._menuActions.push(name); + + const action = { + name, + type, + title: name, + onClick: func?.bind(this), + }; + + if (typeof icon === 'string') { + action.url = icon; + } else { + action.icon = icon; + } + + this._actions.push(action); + } + + /** + * base64 编码字符串 + * @param {string} str 要编码的字符串 + */ + base64Encode(str) { + const data = Data.fromString(str); + return data.toBase64String(); + } + + /** + * base64解码数据 返回字符串 + * @param {string} b64 base64编码的数据 + */ + base64Decode(b64) { + const data = Data.fromBase64String(b64); + return data.toRawString(); + } + + /** + * md5 加密字符串 + * @param {string} str 要加密成md5的数据 + */ + // prettier-ignore + md5(str){function d(n,t){var r=(65535&n)+(65535&t);return(((n>>16)+(t>>16)+(r>>16))<<16)|(65535&r)}function f(n,t,r,e,o,u){return d(((c=d(d(t,n),d(e,u)))<<(f=o))|(c>>>(32-f)),r);var c,f}function l(n,t,r,e,o,u,c){return f((t&r)|(~t&e),n,t,o,u,c)}function v(n,t,r,e,o,u,c){return f((t&e)|(r&~e),n,t,o,u,c)}function g(n,t,r,e,o,u,c){return f(t^r^e,n,t,o,u,c)}function m(n,t,r,e,o,u,c){return f(r^(t|~e),n,t,o,u,c)}function i(n,t){var r,e,o,u;(n[t>>5]|=128<<t%32),(n[14+(((t+64)>>>9)<<4)]=t);for(var c=1732584193,f=-271733879,i=-1732584194,a=271733878,h=0;h<n.length;h+=16)(c=l((r=c),(e=f),(o=i),(u=a),n[h],7,-680876936)),(a=l(a,c,f,i,n[h+1],12,-389564586)),(i=l(i,a,c,f,n[h+2],17,606105819)),(f=l(f,i,a,c,n[h+3],22,-1044525330)),(c=l(c,f,i,a,n[h+4],7,-176418897)),(a=l(a,c,f,i,n[h+5],12,1200080426)),(i=l(i,a,c,f,n[h+6],17,-1473231341)),(f=l(f,i,a,c,n[h+7],22,-45705983)),(c=l(c,f,i,a,n[h+8],7,1770035416)),(a=l(a,c,f,i,n[h+9],12,-1958414417)),(i=l(i,a,c,f,n[h+10],17,-42063)),(f=l(f,i,a,c,n[h+11],22,-1990404162)),(c=l(c,f,i,a,n[h+12],7,1804603682)),(a=l(a,c,f,i,n[h+13],12,-40341101)),(i=l(i,a,c,f,n[h+14],17,-1502002290)),(c=v(c,(f=l(f,i,a,c,n[h+15],22,1236535329)),i,a,n[h+1],5,-165796510,)),(a=v(a,c,f,i,n[h+6],9,-1069501632)),(i=v(i,a,c,f,n[h+11],14,643717713)),(f=v(f,i,a,c,n[h],20,-373897302)),(c=v(c,f,i,a,n[h+5],5,-701558691)),(a=v(a,c,f,i,n[h+10],9,38016083)),(i=v(i,a,c,f,n[h+15],14,-660478335)),(f=v(f,i,a,c,n[h+4],20,-405537848)),(c=v(c,f,i,a,n[h+9],5,568446438)),(a=v(a,c,f,i,n[h+14],9,-1019803690)),(i=v(i,a,c,f,n[h+3],14,-187363961)),(f=v(f,i,a,c,n[h+8],20,1163531501)),(c=v(c,f,i,a,n[h+13],5,-1444681467)),(a=v(a,c,f,i,n[h+2],9,-51403784)),(i=v(i,a,c,f,n[h+7],14,1735328473)),(c=g(c,(f=v(f,i,a,c,n[h+12],20,-1926607734)),i,a,n[h+5],4,-378558,)),(a=g(a,c,f,i,n[h+8],11,-2022574463)),(i=g(i,a,c,f,n[h+11],16,1839030562)),(f=g(f,i,a,c,n[h+14],23,-35309556)),(c=g(c,f,i,a,n[h+1],4,-1530992060)),(a=g(a,c,f,i,n[h+4],11,1272893353)),(i=g(i,a,c,f,n[h+7],16,-155497632)),(f=g(f,i,a,c,n[h+10],23,-1094730640)),(c=g(c,f,i,a,n[h+13],4,681279174)),(a=g(a,c,f,i,n[h],11,-358537222)),(i=g(i,a,c,f,n[h+3],16,-722521979)),(f=g(f,i,a,c,n[h+6],23,76029189)),(c=g(c,f,i,a,n[h+9],4,-640364487)),(a=g(a,c,f,i,n[h+12],11,-421815835)),(i=g(i,a,c,f,n[h+15],16,530742520)),(c=m(c,(f=g(f,i,a,c,n[h+2],23,-995338651)),i,a,n[h],6,-198630844,)),(a=m(a,c,f,i,n[h+7],10,1126891415)),(i=m(i,a,c,f,n[h+14],15,-1416354905)),(f=m(f,i,a,c,n[h+5],21,-57434055)),(c=m(c,f,i,a,n[h+12],6,1700485571)),(a=m(a,c,f,i,n[h+3],10,-1894986606)),(i=m(i,a,c,f,n[h+10],15,-1051523)),(f=m(f,i,a,c,n[h+1],21,-2054922799)),(c=m(c,f,i,a,n[h+8],6,1873313359)),(a=m(a,c,f,i,n[h+15],10,-30611744)),(i=m(i,a,c,f,n[h+6],15,-1560198380)),(f=m(f,i,a,c,n[h+13],21,1309151649)),(c=m(c,f,i,a,n[h+4],6,-145523070)),(a=m(a,c,f,i,n[h+11],10,-1120210379)),(i=m(i,a,c,f,n[h+2],15,718787259)),(f=m(f,i,a,c,n[h+9],21,-343485551)),(c=d(c,r)),(f=d(f,e)),(i=d(i,o)),(a=d(a,u));return[c,f,i,a]}function a(n){for(var t='',r=32*n.length,e=0;e<r;e+=8)t+=String.fromCharCode((n[e>>5]>>>e%32)&255);return t}function h(n){var t=[];for(t[(n.length>>2)-1]=void 0,e=0;e<t.length;e+=1)t[e]=0;for(var r=8*n.length,e=0;e<r;e+=8)t[e>>5]|=(255&n.charCodeAt(e/8))<<e%32;return t}function e(n){for(var t,r='0123456789abcdef',e='',o=0;o<n.length;o+=1)(t=n.charCodeAt(o)),(e+=r.charAt((t>>>4)&15)+r.charAt(15&t));return e}function r(n){return unescape(encodeURIComponent(n))}function o(n){return a(i(h((t=r(n))),8*t.length));var t}function u(n,t){return(function(n,t){var r,e,o=h(n),u=[],c=[];for(u[15]=c[15]=void 0,16<o.length&&(o=i(o,8*n.length)),r=0;r<16;r+=1)(u[r]=909522486^o[r]),(c[r]=1549556828^o[r]);return((e=i(u.concat(h(t)),512+8*t.length)),a(i(c.concat(e),640)))})(r(n),r(t))}function t(n,t,r){return t?(r?u(t,n):e(u(t,n))):r?o(n):e(o(n))}return t(str)} + + /** + * 渲染标题内容 + * @param {object} widget 组件对象 + * @param {string} icon 图标地址 + * @param {string} title 标题内容 + * @param {bool|color} color 字体的颜色(自定义背景时使用,默认系统) + */ + async renderHeader(widget, icon, title, color = false) { + let header = widget.addStack(); + header.centerAlignContent(); + try { + const image = await this.$request.get(icon, 'IMG'); + let _icon = header.addImage(image); + _icon.imageSize = new Size(14, 14); + _icon.cornerRadius = 4; + } catch (e) { + console.log(e); + } + header.addSpacer(10); + let _title = header.addText(title); + if (color) _title.textColor = color; + _title.textOpacity = 0.7; + _title.font = Font.boldSystemFont(12); + _title.lineLimit = 1; + widget.addSpacer(15); + return widget; + } + + /** + * @param message 描述内容 + * @param options 按钮 + * @returns {Promise<number>} + */ + + async generateAlert(message, options) { + let alert = new Alert(); + alert.message = message; + + for (const option of options) { + alert.addAction(option); + } + return await alert.presentAlert(); + } + + /** + * 弹出一个通知 + * @param {string} title 通知标题 + * @param {string} body 通知内容 + * @param {string} url 点击后打开的URL + */ + async notify(title, body, url, opts = {}) { + let n = new Notification(); + n = Object.assign(n, opts); + n.title = title; + n.body = body; + if (url) n.openURL = url; + return await n.schedule(); + } + + /** + * 给图片加一层半透明遮罩 + * @param {Image} img 要处理的图片 + * @param {string} color 遮罩背景颜色 + * @param {float} opacity 透明度 + */ + async shadowImage(img, color = '#000000', opacity = 0.7) { + if (!img) return; + if (opacity === 0) return img; + let ctx = new DrawContext(); + // 获取图片的尺寸 + ctx.size = img.size; + + ctx.drawImageInRect( + img, + new Rect(0, 0, img.size['width'], img.size['height']) + ); + ctx.setFillColor(new Color(color, opacity)); + ctx.fillRect(new Rect(0, 0, img.size['width'], img.size['height'])); + return await ctx.getImage(); + } + + /** + * 获取当前插件的设置 + * @param {boolean} json 是否为json格式 + */ + getSettings(json = true) { + let res = json ? {} : ''; + let cache = ''; + if (Keychain.contains(this.SETTING_KEY)) { + cache = Keychain.get(this.SETTING_KEY); + } + + if (json) { + try { + res = JSON.parse(cache); + } catch (e) {} + } else { + res = cache; + } + + return res; + } + + getBaseSettings(json = true) { + let res = json ? {} : ''; + let cache = ''; + if (Keychain.contains(this.BaseCacheKey)) { + cache = Keychain.get(this.BaseCacheKey); + } + + if (json) { + try { + res = JSON.parse(cache); + } catch (e) {} + } else { + res = cache; + } + + return res; + } + + saveBaseSettings(res = {}, notify = true) { + const data = { ...(this.baseSettings || {}), ...res }; + this.baseSettings = data; + Keychain.set(this.BaseCacheKey, JSON.stringify(data)); + if (notify) this.notify('设置成功', '通用设置需重新运行脚本生效'); + return data; + } + + /** + * 存储当前设置 + * @param {bool} notify 是否通知提示 + */ + saveSettings(notify = true) { + let res = + typeof this.settings === 'object' + ? JSON.stringify(this.settings) + : String(this.settings); + Keychain.set(this.SETTING_KEY, res); + + if (notify) this.notify('设置成功', '桌面组件稍后将自动刷新'); + + return res; + } + + /** + * 获取当前插件是否有自定义背景图片 + * @reutrn img | false + */ + async getBackgroundImage() { + if (await this.FILE_MGR.fileExistsExtra(this.cacheImageBgPath[0])) + return Image.fromFile(this.cacheImageBgPath[0]); + + if (!this.isNight) + return (await this.FILE_MGR.fileExistsExtra(this.cacheImageBgPath[1])) + ? Image.fromFile(this.cacheImageBgPath[1]) + : undefined; + else + return (await this.FILE_MGR.fileExistsExtra(this.cacheImageBgPath[2])) + ? Image.fromFile(this.cacheImageBgPath[2]) + : undefined; + } + + /** + * 设置当前组件的背景图片 + * @param {Image} img + */ + async setBackgroundImage(img, filePath = this.baseImage, notify = true) { + const cacheKey = filePath; + if (!img) { + // 移除背景 + if (this.FILE_MGR.fileExists(cacheKey)) this.FILE_MGR.remove(cacheKey); + if (notify) this.notify('移除成功', '背景图片已移除,稍后刷新生效'); + } else { + // 设置背景 + this.FILE_MGR.writeImage(cacheKey, img); + + if (notify) this.notify('设置成功', '背景图片已设置!稍后刷新生效'); + return `data:image/png;base64,${Data.fromFile( + cacheKey + ).toBase64String()}`; + } + } + + getRandomArrayElements(arr, count) { + let shuffled = arr.slice(0), + i = arr.length, + min = i - count, + temp, + index; + min = min > 0 ? min : 0; + while (i-- > min) { + index = Math.floor((i + 1) * Math.random()); + temp = shuffled[index]; + shuffled[index] = shuffled[i]; + shuffled[i] = temp; + } + return shuffled.slice(min); + } + + textFormat = { + defaultText: { size: 14, font: 'regular', color: this.widgetColor }, + battery: { size: 10, font: 'bold', color: this.widgetColor }, + title: { size: 16, font: 'semibold', color: this.widgetColor }, + SFMono: { size: 12, font: 'SF Mono', color: this.widgetColor }, + }; + + provideFont = (fontName, fontSize) => { + const fontGenerator = { + ultralight: function () { + return Font.ultraLightSystemFont(fontSize); + }, + light: function () { + return Font.lightSystemFont(fontSize); + }, + regular: function () { + return Font.regularSystemFont(fontSize); + }, + medium: function () { + return Font.mediumSystemFont(fontSize); + }, + semibold: function () { + return Font.semiboldSystemFont(fontSize); + }, + bold: function () { + return Font.boldSystemFont(fontSize); + }, + heavy: function () { + return Font.heavySystemFont(fontSize); + }, + black: function () { + return Font.blackSystemFont(fontSize); + }, + italic: function () { + return Font.italicSystemFont(fontSize); + }, + }; + + const systemFont = fontGenerator[fontName]; + if (systemFont) { + return systemFont(); + } + return new Font(fontName, fontSize); + }; + + provideText = (string, container, format) => { + format = { + font: 'light', + size: 14, + color: this.widgetColor, + opacity: 1, + minimumScaleFactor: 1, + ...format, + }; + const textItem = container.addText(string); + const textFont = format.font; + const textSize = format.size; + const textColor = format.color; + + textItem.font = this.provideFont(textFont, textSize); + textItem.textColor = textColor; + textItem.textOpacity = format.opacity || 1; + textItem.minimumScaleFactor = format.minimumScaleFactor || 1; + return textItem; + }; +} + +// @base.end +const Runing = async (Widget, default_args = '', isDebug = true, extra) => { + let M = null; + // 判断hash是否和当前设备匹配 + if (config.runsInWidget) { + M = new Widget(args.widgetParameter || ''); + + if (extra) { + Object.keys(extra).forEach((key) => { + M[key] = extra[key]; + }); + } + const W = await M.render(); + try { + if (M.settings.refreshAfterDate) { + const refreshTime = parseInt(M.settings.refreshAfterDate) * 1000 * 60; + const timeStr = new Date().getTime() + refreshTime; + W.refreshAfterDate = new Date(timeStr); + } + } catch (e) { + console.log(e); + } + if (W) { + Script.setWidget(W); + Script.complete(); + } + } else { + let { act, __arg, __size } = args.queryParameters; + M = new Widget(__arg || default_args || ''); + if (extra) { + Object.keys(extra).forEach((key) => { + M[key] = extra[key]; + }); + } + if (__size) M._init(__size); + if (!act || !M['_actions']) { + // 弹出选择菜单 + const actions = M['_actions']; + const onClick = async (item) => { + M.widgetFamily = item.val; + try { + M._init(item.val); + } catch (error) { + console.log('初始化异常:' + error); + } + w = await M.render(); + const fnc = item.val + .toLowerCase() + .replace(/( |^)[a-z]/g, (L) => L.toUpperCase()); + if (w) return w[`present${fnc}`](); + }; + + const preview = [], + lockView = []; + if (M.renderSmall) { + preview.push({ + url: `https://raw.githubusercontent.com/dompling/Scriptable/master/images/small.png`, + title: '小尺寸', + val: 'small', + name: 'small', + dismissOnSelect: true, + onClick, + }); + } + + if (M.renderMedium) { + preview.push({ + url: `https://raw.githubusercontent.com/dompling/Scriptable/master/images/medium.png`, + title: '中尺寸', + val: 'medium', + name: 'medium', + dismissOnSelect: true, + onClick, + }); + } + + if (M.renderLarge) { + preview.push({ + url: `https://raw.githubusercontent.com/dompling/Scriptable/master/images/large.png`, + title: '大尺寸', + val: 'large', + name: 'large', + dismissOnSelect: true, + onClick, + }); + } + + if (M.renderAccessoryInline) { + lockView.push({ + icon: { + color: '#4676EE', + name: 'list.triangle', + }, + title: '锁屏列表', + val: 'accessoryInline', + name: 'accessoryInline', + dismissOnSelect: true, + onClick, + }); + } + + if (M.renderAccessoryRectangular) { + lockView.push({ + icon: { + color: '#4676EE', + name: 'arrow.rectanglepath', + }, + title: '锁屏 2x', + val: 'accessoryRectangular', + name: 'accessoryRectangular', + dismissOnSelect: true, + onClick, + }); + } + + if (M.renderAccessoryCircular) { + lockView.push({ + icon: { + color: '#4676EE', + name: 'circle.circle', + }, + title: '锁屏 1x', + val: 'accessoryCircular', + name: 'accessoryCircular', + dismissOnSelect: true, + onClick, + }); + } + + const menuConfig = [ + ...(preview ? [{ title: '预览组件', menu: preview }] : []), + ...(lockView.length ? [{ title: '锁屏组件', menu: lockView }] : []), + ...M['_menuActions'], + ]; + + if (actions.length) menuConfig.push({ title: '组件配置', menu: actions }); + + await M.renderAppView(menuConfig, true); + } + } +}; +// await new DmYY().setWidgetConfig(); +module.exports = { DmYY, Runing }; diff --git a/Scriptable/DmYY.scriptable b/Scriptable/DmYY.scriptable deleted file mode 100644 index 9943777d..00000000 --- a/Scriptable/DmYY.scriptable +++ /dev/null @@ -1,12 +0,0 @@ -{ - "always_run_in_app" : false, - "icon" : { - "color" : "teal", - "glyph" : "cogs" - }, - "name" : "DmYY", - "script" : "\n\/*\n * Author: 2Ya\n * Github: https:\/\/github.com\/dompling\n * UI 配置升级 感谢 @LSP 大佬提供代码\n *\/\nclass DmYY {\n constructor(arg, defaultSettings) {\n this.arg = arg;\n this.defaultSettings = defaultSettings || {};\n this._init();\n this.isNight = Device.isUsingDarkAppearance();\n }\n\n BaseCacheKey = 'DmYY';\n _actions = [];\n _menuActions = [];\n widgetColor;\n backGroundColor;\n isNight;\n\n userConfigKey = ['avatar', 'nickname', 'homePageDesc'];\n\n \/\/ 获取 Request 对象\n getRequest = (url = '') => {\n return new Request(url);\n };\n\n \/\/ 发起请求\n http = async (\n options = { headers: {}, url: '' },\n type = 'JSON',\n onError = () => {\n return SFSymbol.named('photo').image;\n }\n ) => {\n let request;\n try {\n if (type === 'IMG') {\n const fileName = `${this.cacheImage}\/${this.md5(options.url)}`;\n request = this.getRequest(options.url);\n let response;\n if (this.FILE_MGR.fileExists(fileName)) {\n request.loadImage().then((res) => {\n this.FILE_MGR.writeImage(fileName, res);\n });\n return Image.fromFile(fileName);\n } else {\n response = await request.loadImage();\n this.FILE_MGR.writeImage(fileName, response);\n }\n return response;\n }\n request = this.getRequest();\n Object.keys(options).forEach((key) => {\n request[key] = options[key];\n });\n request.headers = { ...this.defaultHeaders, ...options.headers };\n\n if (type === 'JSON') {\n return await request.loadJSON();\n }\n if (type === 'STRING') {\n return await request.loadString();\n }\n return await request.loadJSON();\n } catch (e) {\n console.log('error:' + e);\n if (type === 'IMG') return onError?.();\n }\n };\n\n \/\/request 接口请求\n $request = {\n get: (url = '', options = {}, type = 'JSON') => {\n let params = { ...options, method: 'GET' };\n if (typeof url === 'object') {\n params = { ...params, ...url };\n } else {\n params.url = url;\n }\n let _type = type;\n if (typeof options === 'string') _type = options;\n return this.http(params, _type);\n },\n post: (url = '', options = {}, type = 'JSON') => {\n let params = { ...options, method: 'POST' };\n if (typeof url === 'object') {\n params = { ...params, ...url };\n } else {\n params.url = url;\n }\n let _type = type;\n if (typeof options === 'string') _type = options;\n return this.http(params, _type);\n },\n };\n\n \/\/ 获取 boxJS 缓存\n getCache = async (key = '', notify = true) => {\n try {\n let url = 'http:\/\/' + this.prefix + '\/query\/boxdata';\n if (key) url = 'http:\/\/' + this.prefix + '\/query\/data\/' + key;\n const boxdata = await this.$request.get(\n url,\n key ? { timeoutInterval: 1 } : {}\n );\n if (key) {\n this.settings.BoxJSData = {\n ...this.settings.BoxJSData,\n [key]: boxdata.val,\n };\n this.saveSettings(false);\n }\n if (boxdata.val) return boxdata.val;\n\n return boxdata.datas;\n } catch (e) {\n if (key && this.settings.BoxJSData[key]) {\n return this.settings.BoxJSData[key];\n }\n if (notify)\n await this.notify(\n `${this.name} - BoxJS 数据读取失败`,\n '请检查 BoxJS 域名是否为代理复写的域名,如(boxjs.net 或 boxjs.com)。\\n若没有配置 BoxJS 相关模块,请点击通知查看教程',\n 'https:\/\/chavyleung.gitbook.io\/boxjs\/awesome\/videos'\n );\n return false;\n }\n };\n\n transforJSON = (str) => {\n if (typeof str == 'string') {\n try {\n return JSON.parse(str);\n } catch (e) {\n console.log(e);\n return str;\n }\n }\n console.log('It is not a string!');\n };\n\n \/\/ 选择图片并缓存\n chooseImg = async () => {\n return Photos.fromLibrary()\n .then(async (response) => {\n const bool = this.verifyImage(response);\n if (bool) return response;\n throw new Error('图片超过限制');\n })\n .catch((err) => {\n console.log('图片选择异常:' + err);\n });\n };\n\n \/\/ 设置 widget 背景图片\n getWidgetBackgroundImage = async (widget) => {\n const backgroundImage = this.getBackgroundImage();\n if (backgroundImage) {\n const opacity = Device.isUsingDarkAppearance()\n ? Number(this.settings.darkOpacity)\n : Number(this.settings.lightOpacity);\n widget.backgroundImage = await this.shadowImage(\n backgroundImage,\n '#000',\n opacity\n );\n return true;\n } else {\n if (this.backGroundColor.colors) {\n widget.backgroundGradient = this.backGroundColor;\n } else {\n widget.backgroundColor = this.backGroundColor;\n }\n return false;\n }\n };\n\n \/**\n * 验证图片尺寸: 图片像素超过 1000 左右的时候会导致背景无法加载\n * @param img Image\n *\/\n verifyImage = async (img) => {\n try {\n const { width, height } = img.size;\n const direct = true;\n if (width > 1000) {\n const options = ['取消', '打开图像处理'];\n const message =\n '您的图片像素为' +\n width +\n ' x ' +\n height +\n '\\n' +\n '请将图片' +\n (direct ? '宽度' : '高度') +\n '调整到 1000 以下\\n' +\n (!direct ? '宽度' : '高度') +\n '自动适应';\n const index = await this.generateAlert(message, options);\n if (index === 1)\n Safari.openInApp('https:\/\/www.sojson.com\/image\/change.html', false);\n return false;\n }\n return true;\n } catch (e) {\n return false;\n }\n };\n\n \/**\n * 获取截图中的组件剪裁图\n * 可用作透明背景\n * 返回图片image对象\n * 代码改自:https:\/\/gist.github.com\/mzeryck\/3a97ccd1e059b3afa3c6666d27a496c9\n * @param {string} title 开始处理前提示用户截图的信息,可选(适合用在组件自定义透明背景时提示)\n *\/\n async getWidgetScreenShot(title = null) {\n \/\/ Crop an image into the specified rect.\n function cropImage(img, rect) {\n let draw = new DrawContext();\n draw.size = new Size(rect.width, rect.height);\n\n draw.drawImageAtPoint(img, new Point(-rect.x, -rect.y));\n return draw.getImage();\n }\n\n \/\/ Pixel sizes and positions for widgets on all supported phones.\n function phoneSizes() {\n return {\n \/\/ 12 Pro Max\n 2778: {\n small: 510,\n medium: 1092,\n large: 1146,\n left: 96,\n right: 678,\n top: 246,\n middle: 882,\n bottom: 1518,\n },\n\n \/\/ 12 and 12 Pro\n 2532: {\n small: 474,\n medium: 1014,\n large: 1062,\n left: 78,\n right: 618,\n top: 231,\n middle: 819,\n bottom: 1407,\n },\n\n \/\/ 11 Pro Max, XS Max\n 2688: {\n small: 507,\n medium: 1080,\n large: 1137,\n left: 81,\n right: 654,\n top: 228,\n middle: 858,\n bottom: 1488,\n },\n\n \/\/ 11, XR\n 1792: {\n small: 338,\n medium: 720,\n large: 758,\n left: 54,\n right: 436,\n top: 160,\n middle: 580,\n bottom: 1000,\n },\n\n \/\/ 11 Pro, XS, X, 12 mini\n 2436: {\n x: {\n small: 465,\n medium: 987,\n large: 1035,\n left: 69,\n right: 591,\n top: 213,\n middle: 783,\n bottom: 1353,\n },\n\n mini: {\n small: 465,\n medium: 987,\n large: 1035,\n left: 69,\n right: 591,\n top: 231,\n middle: 801,\n bottom: 1371,\n },\n },\n\n \/\/ Plus phones\n 2208: {\n small: 471,\n medium: 1044,\n large: 1071,\n left: 99,\n right: 672,\n top: 114,\n middle: 696,\n bottom: 1278,\n },\n\n \/\/ SE2 and 6\/6S\/7\/8\n 1334: {\n small: 296,\n medium: 642,\n large: 648,\n left: 54,\n right: 400,\n top: 60,\n middle: 412,\n bottom: 764,\n },\n\n \/\/ SE1\n 1136: {\n small: 282,\n medium: 584,\n large: 622,\n left: 30,\n right: 332,\n top: 59,\n middle: 399,\n bottom: 399,\n },\n\n \/\/ 11 and XR in Display Zoom mode\n 1624: {\n small: 310,\n medium: 658,\n large: 690,\n left: 46,\n right: 394,\n top: 142,\n middle: 522,\n bottom: 902,\n },\n\n \/\/ Plus in Display Zoom mode\n 2001: {\n small: 444,\n medium: 963,\n large: 972,\n left: 81,\n right: 600,\n top: 90,\n middle: 618,\n bottom: 1146,\n },\n };\n }\n\n let message =\n title || '开始之前,请先前往桌面,截取空白界面的截图。然后回来继续';\n let exitOptions = ['我已截图', '前去截图 >'];\n let shouldExit = await this.generateAlert(message, exitOptions);\n if (shouldExit) return;\n\n \/\/ Get screenshot and determine phone size.\n let img = await Photos.fromLibrary();\n let height = img.size.height;\n let phone = phoneSizes()[height];\n if (!phone) {\n message = '好像您选择的照片不是正确的截图,请先前往桌面';\n await this.generateAlert(message, ['我已知晓']);\n return;\n }\n\n \/\/ Extra setup needed for 2436-sized phones.\n if (height === 2436) {\n const files = this.FILE_MGR_LOCAL;\n let cacheName = 'mz-phone-type';\n let cachePath = files.joinPath(files.libraryDirectory(), cacheName);\n\n \/\/ If we already cached the phone size, load it.\n if (files.fileExists(cachePath)) {\n let typeString = files.readString(cachePath);\n phone = phone[typeString];\n \/\/ Otherwise, prompt the user.\n } else {\n message = '您的📱型号是?';\n let types = ['iPhone 12 mini', 'iPhone 11 Pro, XS, or X'];\n let typeIndex = await this.generateAlert(message, types);\n let type = typeIndex === 0 ? 'mini' : 'x';\n phone = phone[type];\n files.writeString(cachePath, type);\n }\n }\n\n \/\/ Prompt for widget size and position.\n message = '截图中要设置透明背景组件的尺寸类型是?';\n let sizes = ['小尺寸', '中尺寸', '大尺寸'];\n let size = await this.generateAlert(message, sizes);\n let widgetSize = sizes[size];\n\n message = '要设置透明背景的小组件在哪个位置?';\n message +=\n height === 1136\n ? ' (备注:当前设备只支持两行小组件,所以下边选项中的「中间」和「底部」的选项是一致的)'\n : '';\n\n \/\/ Determine image crop based on phone size.\n let crop = { w: '', h: '', x: '', y: '' };\n if (widgetSize === '小尺寸') {\n crop.w = phone.small;\n crop.h = phone.small;\n let positions = [\n '左上角',\n '右上角',\n '中间左',\n '中间右',\n '左下角',\n '右下角',\n ];\n let _posotions = [\n 'Top left',\n 'Top right',\n 'Middle left',\n 'Middle right',\n 'Bottom left',\n 'Bottom right',\n ];\n let position = await this.generateAlert(message, positions);\n\n \/\/ Convert the two words into two keys for the phone size dictionary.\n let keys = _posotions[position].toLowerCase().split(' ');\n crop.y = phone[keys[0]];\n crop.x = phone[keys[1]];\n } else if (widgetSize === '中尺寸') {\n crop.w = phone.medium;\n crop.h = phone.small;\n\n \/\/ Medium and large widgets have a fixed x-value.\n crop.x = phone.left;\n let positions = ['顶部', '中间', '底部'];\n let _positions = ['Top', 'Middle', 'Bottom'];\n let position = await this.generateAlert(message, positions);\n let key = _positions[position].toLowerCase();\n crop.y = phone[key];\n } else if (widgetSize === '大尺寸') {\n crop.w = phone.medium;\n crop.h = phone.large;\n crop.x = phone.left;\n let positions = ['顶部', '底部'];\n let position = await this.generateAlert(message, positions);\n\n \/\/ Large widgets at the bottom have the \"middle\" y-value.\n crop.y = position ? phone.middle : phone.top;\n }\n\n \/\/ Crop image and finalize the widget.\n return cropImage(img, new Rect(crop.x, crop.y, crop.w, crop.h));\n }\n\n setLightAndDark = async (title, desc, val, placeholder = '') => {\n try {\n const a = new Alert();\n a.title = title;\n a.message = desc;\n a.addTextField(placeholder, `${this.settings[val] || ''}`);\n a.addAction('确定');\n a.addCancelAction('取消');\n const id = await a.presentAlert();\n if (id === -1) return false;\n this.settings[val] = a.textFieldValue(0) || '';\n this.saveSettings();\n return true;\n } catch (e) {\n console.log(e);\n }\n };\n\n \/**\n * 弹出输入框\n * @param title 标题\n * @param desc 描述\n * @param opt 属性\n * @returns {Promise<void>}\n *\/\n setAlertInput = async (title, desc, opt = {}, isSave = true) => {\n const a = new Alert();\n a.title = title;\n a.message = !desc ? '' : desc;\n Object.keys(opt).forEach((key) => {\n a.addTextField(opt[key], this.settings[key]);\n });\n a.addAction('确定');\n a.addCancelAction('取消');\n const id = await a.presentAlert();\n if (id === -1) return;\n const data = {};\n Object.keys(opt).forEach((key, index) => {\n data[key] = a.textFieldValue(index) || '';\n });\n \/\/ 保存到本地\n if (isSave) {\n this.settings = { ...this.settings, ...data };\n return this.saveSettings();\n }\n return data;\n };\n\n setBaseAlertInput = async (title, desc, opt = {}, isSave = true) => {\n const a = new Alert();\n a.title = title;\n a.message = !desc ? '' : desc;\n Object.keys(opt).forEach((key) => {\n a.addTextField(opt[key], this.baseSettings[key] || '');\n });\n a.addAction('确定');\n a.addCancelAction('取消');\n const id = await a.presentAlert();\n if (id === -1) return;\n const data = {};\n Object.keys(opt).forEach((key, index) => {\n data[key] = a.textFieldValue(index) || '';\n });\n \/\/ 保存到本地\n if (isSave) return this.saveBaseSettings(data);\n return data;\n };\n\n \/**\n * 设置当前项目的 boxJS 缓存\n * @param opt key value\n * @returns {Promise<void>}\n *\/\n setCacheBoxJSData = async (opt = {}) => {\n const options = ['取消', '确定'];\n const message = '代理缓存仅支持 BoxJS 相关的代理!';\n const index = await this.generateAlert(message, options);\n if (index === 0) return;\n try {\n const boxJSData = await this.getCache();\n Object.keys(opt).forEach((key) => {\n this.settings[key] = boxJSData[opt[key]] || '';\n });\n \/\/ 保存到本地\n this.saveSettings();\n } catch (e) {\n console.log(e);\n this.notify(\n this.name,\n 'BoxJS 缓存读取失败!点击查看相关教程',\n 'https:\/\/chavyleung.gitbook.io\/boxjs\/awesome\/videos'\n );\n }\n };\n\n \/**\n * 设置组件内容\n * @returns {Promise<void>}\n *\/\n setWidgetConfig = async () => {\n const basic = [\n {\n icon: { name: 'arrow.clockwise', color: '#1890ff' },\n type: 'input',\n title: '刷新时间',\n desc: '刷新时间仅供参考,具体刷新时间由系统判断,单位:分钟',\n val: 'refreshAfterDate',\n },\n {\n icon: { name: 'sun.max.fill', color: '#d48806' },\n type: 'color',\n title: '白天字体颜色',\n desc: '请自行去网站上搜寻颜色(Hex 颜色)',\n val: 'lightColor',\n },\n {\n icon: { name: 'moon.stars.fill', color: '#d4b106' },\n type: 'color',\n title: '晚上字体颜色',\n desc: '请自行去网站上搜寻颜色(Hex 颜色)',\n val: 'darkColor',\n },\n ];\n\n return this.renderAppView([\n { title: '基础设置', menu: basic },\n {\n title: '背景设置',\n menu: [\n {\n icon: { name: 'photo', color: '#13c2c2' },\n type: 'color',\n title: '白天背景颜色',\n desc: '请自行去网站上搜寻颜色(Hex 颜色)\\n支持渐变色,各颜色之间以英文逗号分隔',\n val: 'lightBgColor',\n },\n {\n icon: { name: 'photo.fill', color: '#52c41a' },\n type: 'color',\n title: '晚上背景颜色',\n desc: '请自行去网站上搜寻颜色(Hex 颜色)\\n支持渐变色,各颜色之间以英文逗号分隔',\n val: 'darkBgColor',\n },\n ],\n },\n {\n menu: [\n {\n icon: { name: 'photo.on.rectangle', color: '#fa8c16' },\n name: 'dayBg',\n type: 'img',\n title: '日间背景',\n val: this.cacheImage,\n },\n {\n icon: { name: 'photo.fill.on.rectangle.fill', color: '#fa541c' },\n name: 'nightBg',\n type: 'img',\n title: '夜间背景',\n val: this.cacheImage,\n },\n {\n icon: { name: 'text.below.photo', color: '#faad14' },\n type: 'img',\n name: 'transparentBg',\n title: '透明背景',\n val: this.cacheImage,\n onClick: async (item, __, previewWebView) => {\n const backImage = await this.getWidgetScreenShot();\n if (!backImage || !(await this.verifyImage(backImage))) return;\n const cachePath = `${item.val}\/${item.name}`;\n const base64Img = await this.setBackgroundImage(\n backImage,\n cachePath\n );\n this.insertTextByElementId(\n previewWebView,\n item.name,\n `<img src=\"${base64Img}\" \/>`\n );\n },\n },\n ],\n },\n {\n menu: [\n {\n icon: { name: 'record.circle', color: '#722ed1' },\n type: 'input',\n title: '日间蒙层',\n desc: '完全透明请设置为0',\n val: 'lightOpacity',\n },\n {\n icon: { name: 'record.circle.fill', color: '#eb2f96' },\n type: 'input',\n title: '夜间蒙层',\n desc: '完全透明请设置为0',\n val: 'darkOpacity',\n },\n ],\n },\n {\n menu: [\n {\n icon: { name: 'clear', color: '#f5222d' },\n name: 'removeBackground',\n title: '清空背景图片',\n val: `${this.cacheImage}\/`,\n onClick: async (_, __, previewWebView) => {\n const options = [\n '清空日间',\n '清空夜间',\n '清空透明',\n `清空全部`,\n '取消',\n ];\n const message = '该操作不可逆,会清空背景图片!';\n const index = await this.generateAlert(message, options);\n if (index === 4) return;\n switch (index) {\n case 0:\n await this.setBackgroundImage(false, _.val + 'dayBg');\n this.insertTextByElementId(previewWebView, 'dayBg', ``);\n return;\n case 1:\n await this.setBackgroundImage(false, _.val + 'nightBg');\n this.insertTextByElementId(previewWebView, 'nightBg', ``);\n return;\n case 2:\n await this.setBackgroundImage(false, _.val + 'transparentBg');\n this.insertTextByElementId(\n previewWebView,\n 'transparentBg',\n ``\n );\n return;\n default:\n await this.setBackgroundImage(false, _.val + 'dayBg', false);\n await this.setBackgroundImage(\n false,\n _.val + 'nightBg',\n false\n );\n await this.setBackgroundImage(false, _.val + 'transparentBg');\n this.insertTextByElementId(previewWebView, 'dayBg', ``);\n this.insertTextByElementId(previewWebView, 'nightBg', ``);\n this.insertTextByElementId(\n previewWebView,\n 'transparentBg',\n ``\n );\n break;\n }\n },\n },\n ],\n },\n ]).catch((e) => {\n console.log(e);\n });\n };\n\n drawTableIcon = async (\n icon = 'square.grid.2x2',\n color = '#504ED5',\n cornerWidth = 42\n ) => {\n const sfi = SFSymbol.named(icon);\n sfi.applyFont(Font.mediumSystemFont(30));\n const imgData = Data.fromPNG(sfi.image).toBase64String();\n const html = `\n <img id=\"sourceImg\" src=\"data:image\/png;base64,${imgData}\" \/>\n <img id=\"silhouetteImg\" src=\"\" \/>\n <canvas id=\"mainCanvas\" \/>\n `;\n const js = `\n var canvas = document.createElement(\"canvas\");\n var sourceImg = document.getElementById(\"sourceImg\");\n var silhouetteImg = document.getElementById(\"silhouetteImg\");\n var ctx = canvas.getContext('2d');\n var size = sourceImg.width > sourceImg.height ? sourceImg.width : sourceImg.height;\n canvas.width = size;\n canvas.height = size;\n ctx.drawImage(sourceImg, (canvas.width - sourceImg.width) \/ 2, (canvas.height - sourceImg.height) \/ 2);\n var imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);\n var pix = imgData.data;\n \/\/convert the image into a silhouette\n for (var i=0, n = pix.length; i < n; i+= 4){\n \/\/set red to 0\n pix[i] = 255;\n \/\/set green to 0\n pix[i+1] = 255;\n \/\/set blue to 0\n pix[i+2] = 255;\n \/\/retain the alpha value\n pix[i+3] = pix[i+3];\n }\n ctx.putImageData(imgData,0,0);\n silhouetteImg.src = canvas.toDataURL();\n output=canvas.toDataURL()\n `;\n\n let wv = new WebView();\n await wv.loadHTML(html);\n const base64Image = await wv.evaluateJavaScript(js);\n const iconImage = await new Request(base64Image).loadImage();\n const size = new Size(160, 160);\n const ctx = new DrawContext();\n ctx.opaque = false;\n ctx.respectScreenScale = true;\n ctx.size = size;\n const path = new Path();\n const rect = new Rect(0, 0, size.width, size.width);\n\n path.addRoundedRect(rect, cornerWidth, cornerWidth);\n path.closeSubpath();\n ctx.setFillColor(new Color(color));\n ctx.addPath(path);\n ctx.fillPath();\n const rate = 36;\n const iw = size.width - rate;\n const x = (size.width - iw) \/ 2;\n ctx.drawImageInRect(iconImage, new Rect(x, x, iw, iw));\n return ctx.getImage();\n };\n\n dismissLoading = (webView) => {\n webView.evaluateJavaScript(\n \"window.dispatchEvent(new CustomEvent('JWeb', { detail: { code: 'finishLoading' } }))\",\n false\n );\n };\n\n insertTextByElementId = (webView, elementId, text) => {\n const scripts = `document.getElementById(\"${elementId}_val\").innerHTML=\\`${text}\\`;`;\n webView.evaluateJavaScript(scripts, false);\n };\n\n loadSF2B64 = async (\n icon = 'square.grid.2x2',\n color = '#56A8D6',\n cornerWidth = 42\n ) => {\n const sfImg = await this.drawTableIcon(icon, color, cornerWidth);\n return `data:image\/png;base64,${Data.fromPNG(sfImg).toBase64String()}`;\n };\n\n setUserInfo = async () => {\n const baseOnClick = async (item, _, previewWebView) => {\n const data = await this.setBaseAlertInput(item.title, item.desc, {\n [item.val]: item.placeholder,\n });\n if (!data) return;\n this.insertTextByElementId(previewWebView, item.name, data[item.val]);\n };\n\n return this.renderAppView([\n {\n title: '个性设置',\n menu: [\n {\n icon: { name: 'person', color: '#fa541c' },\n name: this.userConfigKey[0],\n title: '首页头像',\n type: 'img',\n val: this.baseImage,\n onClick: async (_, __, previewWebView) => {\n const options = ['相册选择', '在线链接', '取消'];\n const message = '设置个性化头像';\n const index = await this.generateAlert(message, options);\n if (index === 2) return;\n const cachePath = `${_.val}\/${_.name}`;\n switch (index) {\n case 0:\n const albumOptions = ['选择图片', '清空图片', '取消'];\n\n const albumIndex = await this.generateAlert('', albumOptions);\n if (albumIndex === 2) return;\n if (albumIndex === 1) {\n await this.setBackgroundImage(false, _.name, false);\n this.insertTextByElementId(previewWebView, _.name, ``);\n return;\n }\n\n const backImage = await this.chooseImg();\n if (backImage) {\n const base64Img = await this.setBackgroundImage(\n backImage,\n cachePath\n );\n\n this.insertTextByElementId(\n previewWebView,\n _.name,\n `<img src=\"${base64Img}\"\/>`\n );\n }\n\n break;\n case 1:\n const data = await this.setBaseAlertInput(\n '在线链接',\n '首页头像在线链接',\n {\n avatar: '🔗请输入 URL 图片链接',\n }\n );\n if (!data) return;\n\n if (data[_.name] !== '') {\n const backImage = await this.$request.get(\n data[_.name],\n 'IMG'\n );\n const base64Img = await this.setBackgroundImage(\n backImage,\n cachePath\n );\n\n this.insertTextByElementId(\n previewWebView,\n _.name,\n `<img src=\"${base64Img}\"\/>`\n );\n } else {\n await this.setBackgroundImage(false, cachePath);\n this.insertTextByElementId(previewWebView, 'avatar');\n }\n\n break;\n default:\n break;\n }\n },\n },\n {\n icon: { name: 'pencil', color: '#fa8c16' },\n type: 'input',\n title: '首页昵称',\n desc: '个性化首页昵称',\n placeholder: '👤请输入头像昵称',\n val: this.userConfigKey[1],\n name: this.userConfigKey[1],\n defaultValue: this.baseSettings.nickname,\n onClick: baseOnClick,\n },\n {\n icon: { name: 'lineweight', color: '#a0d911' },\n type: 'input',\n title: '首页昵称描述',\n desc: '个性化首页昵称描述',\n placeholder: '请输入描述',\n val: this.userConfigKey[2],\n name: this.userConfigKey[2],\n defaultValue: this.baseSettings.homePageDesc,\n onClick: baseOnClick,\n },\n ],\n },\n {\n menu: [\n {\n icon: { name: 'shippingbox', color: '#f7bb10' },\n type: 'input',\n title: 'BoxJS 域名',\n desc: '设置BoxJS访问域名,如:boxjs.net 或 boxjs.com',\n val: 'boxjsDomain',\n name: 'boxjsDomain',\n placeholder: 'boxjs.net',\n defaultValue: this.baseSettings.boxjsDomain,\n onClick: baseOnClick,\n },\n {\n icon: { name: 'clear', color: '#f5222d' },\n title: '恢复默认设置',\n name: 'reset',\n onClick: async () => {\n const options = ['取消', '确定'];\n const message = '确定要恢复当前所有配置吗?';\n const index = await this.generateAlert(message, options);\n if (index === 1) {\n this.settings = {};\n this.baseSettings = {};\n for (const item of this.cacheImageBgPath) {\n await this.setBackgroundImage(false, item, false);\n }\n this.saveSettings(false);\n this.saveBaseSettings();\n await this.notify(\n '重置成功',\n '请关闭窗口之后,重新运行当前脚本'\n );\n this.reopenScript();\n }\n },\n },\n ],\n },\n ]);\n };\n\n reopenScript = () => {\n Safari.open(`scriptable:\/\/\/run\/${encodeURIComponent(Script.name())}`);\n };\n\n async renderAppView(\n options = [],\n renderAvatar = false,\n previewWebView = new WebView()\n ) {\n const settingItemFontSize = 14,\n authorNameFontSize = 20,\n authorDescFontSize = 12;\n \/\/ ================== 配置界面样式 ===================\n const style = `\n :root {\n --color-primary: #007aff;\n --divider-color: rgba(60,60,67,0.16);\n --card-background: #fff;\n --card-radius: 8px;\n --list-header-color: rgba(60,60,67,0.6);\n }\n * {\n -webkit-user-select: none;\n user-select: none;\n }\n body {\n margin: 10px 0;\n -webkit-font-smoothing: antialiased;\n font-family: \"SF Pro Display\",\"SF Pro Icons\",\"Helvetica Neue\",\"Helvetica\",\"Arial\",sans-serif;\n accent-color: var(--color-primary);\n background: #f6f6f6;\n }\n .list {\n margin: 15px;\n }\n .list__header {\n margin: 0 18px;\n color: var(--list-header-color);\n font-size: 13px;\n }\n .list__body {\n margin-top: 10px;\n background: var(--card-background);\n border-radius: var(--card-radius);\n overflow: hidden;\n }\n .form-item-auth {\n display: flex;\n align-items: center;\n justify-content: space-between;\n min-height: 4em;\n padding: 0.5em 18px;\n position: relative;\n }\n .form-item-auth-name {\n margin: 0px 12px;\n font-size: ${authorNameFontSize}px;\n font-weight: 430;\n }\n .form-item-auth-desc {\n margin: 0px 12px;\n font-size: ${authorDescFontSize}px;\n font-weight: 400;\n }\n .form-label-author-avatar {\n width: 62px;\n height: 62px;\n border-radius:50%;\n border: 1px solid #F6D377;\n }\n .form-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: ${settingItemFontSize}px;\n font-weight: 400;\n min-height: 2.2em;\n padding: 0.5em 18px;\n position: relative;\n }\n .form-label {\n display: flex;\n align-items: center;\n flex-wrap:nowrap\n }\n .form-label-img {\n height: 30px;\n }\n .form-label-title {\n margin-left: 8px;\n white-space: nowrap;\n }\n .bottom-bg {\n margin: 30px 15px 15px 15px;\n }\n .form-item--link .icon-arrow-right {\n color: #86868b;\n }\n\n .form-item-right-desc {\n font-size: 13px;\n color: #86868b;\n margin: 0 4px 0 auto; \n max-width: 100px;\n overflow: hidden;\n text-overflow: ellipsis;\n display:flex;\n align-items: center;\n }\n\n .form-item-right-desc img{\n width:30px;\n height:30px;\n border-radius:3px;\n }\n\n .form-item + .form-item::before {\n content: \"\";\n position: absolute;\n top: 0;\n left: 20px;\n right: 0;\n border-top: 0.5px solid var(--divider-color);\n }\n .form-item input[type=\"checkbox\"] {\n width: 2em;\n height: 2em;\n }\n input[type='input'],select {\n width: 100%;\n height: 2.3em;\n outline-style: none;\n text-align: right;\n padding: 0px 10px;\n border: 1px solid #ddd;\n font-size: 14px;\n color: #86868b;\n border-radius:4px;\n }\n input[type='checkbox'][role='switch'] {\n position: relative;\n display: inline-block;\n appearance: none;\n width: 40px;\n height: 24px;\n border-radius: 24px;\n background: #ccc;\n transition: 0.3s ease-in-out;\n }\n input[type='checkbox'][role='switch']::before {\n content: '';\n position: absolute;\n left: 2px;\n top: 2px;\n width: 20px;\n height: 20px;\n border-radius: 50%;\n background: #fff;\n transition: 0.3s ease-in-out;\n }\n input[type='checkbox'][role='switch']:checked {\n background: var(--color-primary);\n }\n input[type='checkbox'][role='switch']:checked::before {\n transform: translateX(16px);\n }\n .copyright {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin: 15px;\n font-size: 10px;\n color: #86868b;\n }\n .copyright a {\n color: #515154;\n text-decoration: none;\n }\n .preview.loading {\n pointer-events: none;\n }\n .icon-loading {\n display: inline-block;\n animation: 1s linear infinite spin;\n }\n .normal-loading {\n display: inline-block;\n animation: 20s linear infinite spin;\n }\n @keyframes spin {\n 0% {\n transform: rotate(0);\n }\n 100% {\n transform: rotate(1turn);\n }\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --divider-color: rgba(84,84,88,0.65);\n --card-background: #1c1c1e;\n --list-header-color: rgba(235,235,245,0.6);\n }\n body {\n background: #000;\n color: #fff;\n }\n }`;\n\n const js = `\n (() => {\n \n window.invoke = (code, data) => {\n window.dispatchEvent(\n new CustomEvent(\n 'JBridge',\n { detail: { code, data } }\n )\n )\n }\n \n \/\/ 切换ico的loading效果\n const toggleIcoLoading = (e) => {\n try{\n const target = e.currentTarget\n target.classList.add('loading')\n const icon = e.currentTarget.querySelector('.iconfont')\n const className = icon.className\n icon.className = 'iconfont icon-loading'\n const listener = (event) => {\n const { code } = event.detail\n if (code === 'finishLoading') {\n target.classList.remove('loading')\n icon.className = className\n window.removeEventListener('JWeb', listener);\n }\n }\n window.addEventListener('JWeb', listener)\n }catch(e){\n for (const loading of document.querySelectorAll('.icon-loading')) {\n loading.classList.remove('loading');\n loading.className = \"iconfont icon-arrow-right\";\n }\n }\n };\n \n for (const btn of document.querySelectorAll('.label-link')) {\n btn.addEventListener('click', (e) => {\n if(!e.target.id)return;\n toggleIcoLoading(e);\n invoke(e.target.id);\n })\n }\n \n for (const btn of document.querySelectorAll('.form-item__input')) {\n btn.addEventListener('change', (e) => {\n if(!e.target.name)return;\n invoke(e.target.name,e.target.type===\"checkbox\"?\\`\\${e.target.checked}\\`: e.target.value);\n })\n }\n\n if(${renderAvatar}){\n document.querySelectorAll('.form-item-auth')[0].addEventListener('click', (e) => {\n toggleIcoLoading(e);\n invoke(\"userInfo\");\n })\n }\n \n })()`;\n\n let configList = ``;\n let actionsConfig = [];\n\n for (const key in options) {\n const item = options[key];\n actionsConfig = [...item.menu, ...actionsConfig];\n configList += ` \n <div class=\"list\"> \n <div class=\"list__header\">${item.title || ''}<\/div>\n <form id=\"form_${key}\" class=\"list__body\" action=\"javascript:void(0);\">\n `;\n\n for (const menuItem of item.menu) {\n let iconBase64 = ``;\n if (menuItem.children) {\n menuItem.onClick = () => {\n return this.renderAppView(\n typeof menuItem.children === 'function'\n ? menuItem.children()\n : menuItem.children\n );\n };\n }\n if (menuItem.url) {\n const imageIcon = await this.http(\n { url: menuItem.url },\n 'IMG',\n () => {\n return this.drawTableIcon('gear');\n }\n );\n\n if (menuItem.url.indexOf('png') !== -1) {\n iconBase64 = `data:image\/png;base64,${Data.fromPNG(\n imageIcon\n ).toBase64String()}`;\n } else {\n iconBase64 = `data:image\/png;base64,${Data.fromJPEG(\n imageIcon\n ).toBase64String()}`;\n }\n } else {\n const icon = menuItem.icon || {};\n iconBase64 = await this.loadSF2B64(icon.name, icon.color);\n }\n const idName = menuItem.name || menuItem.val;\n\n let defaultHtml = ``;\n if (menuItem.val !== undefined && !menuItem.defaultValue)\n menuItem.defaultValue = this.settings[menuItem.val] || '';\n\n if (menuItem.type === 'input') {\n defaultHtml = menuItem.defaultValue || '';\n } else if (menuItem.type === 'img') {\n const cachePath = `${menuItem.val}\/${menuItem.name}`;\n if (this.FILE_MGR.fileExists(cachePath)) {\n const imageSrc = `data:image\/png;base64,${Data.fromFile(\n cachePath\n ).toBase64String()}`;\n defaultHtml = `<img src=\"${imageSrc}\"\/>`;\n }\n } else if (menuItem.type === 'select') {\n let selectOptions = '';\n\n menuItem.options.forEach((option) => {\n let selected = `selected=\"selected\"`;\n selectOptions += `<option value=\"${option}\" ${\n menuItem.defaultValue === option ? selected : ''\n }>${option}<\/option>`;\n });\n defaultHtml = `<select class=\"form-item__input\" name=\"${idName}\">${selectOptions}<\/select>`;\n } else if (menuItem.type === 'switch') {\n const checked =\n menuItem.defaultValue === 'true' ? `checked=\"checked\"` : '';\n defaultHtml += `<input class=\"form-item__input\" name=\"${idName}\" role=\"switch\" type=\"checkbox\" value=\"true\" ${checked} \/>`;\n } else if (menuItem.type) {\n defaultHtml = `<input class=\"form-item__input\" placeholder=\"${\n menuItem.placeholder || '请输入'\n }\" name=\"${idName}\" type=\"${\n menuItem.type\n }\" enterkeyhint=\"done\" value=\"${menuItem.defaultValue}\">`;\n }\n\n configList += ` \n <label id=\"${idName}\" class=\"form-item form-item--link ${\n !defaultHtml || menuItem.type === 'input' ? 'label-link' : ''\n }\">\n <div class=\"form-label item-none\">\n <img class=\"form-label-img\" class=\"form-label-img\" src=\"${iconBase64}\"\/>\n <div class=\"form-label-title\">${menuItem.title}<\/div>\n <\/div>\n <div id=\"${idName}_val\" class=\"form-item-right-desc\">\n ${defaultHtml}\n <\/div>\n <i id=\"iconfont-${idName}\" class=\"iconfont icon-arrow-right\"><\/i>\n <\/label>\n `;\n }\n configList += `<\/form><\/div>`;\n }\n\n let avatarHtml = '';\n if (renderAvatar) {\n const cachePath = `${this.baseImage}\/${this.userConfigKey[0]}`;\n const avatarConfig = {\n avatar: `https:\/\/avatars.githubusercontent.com\/u\/23498579?v=4`,\n nickname: this.baseSettings[this.userConfigKey[1]] || 'Dompling',\n homPageDesc:\n this.baseSettings[this.userConfigKey[2]] ||\n '18岁,来自九仙山的设计师',\n };\n\n if (this.FILE_MGR.fileExists(cachePath)) {\n avatarConfig.avatar = `data:image\/png;base64,${Data.fromFile(\n cachePath\n ).toBase64String()}`;\n }\n\n avatarHtml = `\n <div class=\"list\">\n <form class=\"list__body\" action=\"javascript:void(0);\">\n <label id=\"userInfo\" class=\"form-item-auth form-item--link\">\n <div class=\"form-label\">\n <img class=\"form-label-author-avatar\" src=\"${avatarConfig.avatar}\"\/>\n <div>\n <div class=\"form-item-auth-name\">${avatarConfig.nickname}<\/div>\n <div class=\"form-item-auth-desc\">${avatarConfig.homPageDesc}<\/div>\n <\/div>\n <\/div>\n <div id=\"userInfo_val\" class=\"form-item-right-desc\">\n 个性化设置\n <\/div>\n <i class=\"iconfont icon-arrow-right\"><\/i>\n <\/label>\n <\/form>\n <\/div>\n `;\n }\n\n const html = `\n <html>\n <head>\n <meta name='viewport' content='width=device-width, user-scalable=no'>\n <link rel=\"stylesheet\" href=\"https:\/\/at.alicdn.com\/t\/c\/font_3791881_bf011w225k4.css\" type=\"text\/css\">\n <style>${style}<\/style>\n <\/head>\n <body>\n ${avatarHtml}\n ${configList} \n <footer>\n <div class=\"copyright\"><div> <\/div><div>© 界面样式修改自 <a href=\"javascript:invoke('safari', 'https:\/\/www.imarkr.com');\">@iMarkr.<\/a><\/div><\/div>\n <\/footer>\n <script>${js}<\/script>\n <\/body>\n <\/html>`;\n\n \n \/\/ 预览web\n await previewWebView.loadHTML(html);\n\n const injectListener = async () => {\n const event = await previewWebView.evaluateJavaScript(\n `(() => {\n try {\n window.addEventListener(\n 'JBridge',\n (e)=>{\n completion(JSON.stringify(e.detail||{}))\n }\n )\n } catch (e) {\n alert(\"预览界面出错:\" + e);\n throw new Error(\"界面处理出错: \" + e);\n return;\n }\n })()`,\n true\n );\n\n const { code, data } = JSON.parse(event);\n try {\n const actionItem = actionsConfig.find(\n (item) => (item.name || item.val) === code\n );\n\n if (code === 'userInfo') await this.setUserInfo();\n\n if (actionItem) {\n const idName = actionItem?.name || actionItem?.val;\n if (actionItem?.onClick) {\n await actionItem?.onClick?.(actionItem, data, previewWebView);\n } else if (actionItem.type == 'input') {\n if (\n await this.setLightAndDark(\n actionItem['title'],\n actionItem['desc'],\n actionItem['val'],\n actionItem['placeholder']\n )\n )\n this.insertTextByElementId(\n previewWebView,\n idName,\n this.settings[actionItem.val] || ''\n );\n } else if (actionItem.type === 'img') {\n const backImage = await this.chooseImg();\n if (backImage) {\n const cachePath = `${actionItem.val}\/${actionItem.name}`;\n const base64Img = await this.setBackgroundImage(\n backImage,\n cachePath,\n false\n );\n this.insertTextByElementId(\n previewWebView,\n idName,\n `<img src=\"${base64Img}\"\/>`\n );\n }\n } else {\n if (data !== undefined) {\n this.settings[actionItem.val] = data;\n this.saveSettings(false);\n }\n }\n }\n } catch (error) {\n console.log('异常操作:' + error);\n }\n this.dismissLoading(previewWebView);\n injectListener();\n };\n\n injectListener().catch((e) => {\n console.error(e);\n this.dismissLoading(previewWebView);\n if (!config.runsInApp) {\n this.notify('主界面', `🚫 ${e}`);\n }\n });\n\n previewWebView.present();\n }\n\n _init(widgetFamily = config.widgetFamily) {\n \/\/ 组件大小:small,medium,large\n this.widgetFamily = widgetFamily;\n this.SETTING_KEY = this.md5(Script.name());\n \/\/用于配置所有的组件相关设置\n\n \/\/ 文件管理器\n \/\/ 提示:缓存数据不要用这个操作,这个是操作源码目录的,缓存建议存放在local temp目录中\n this.FILE_MGR =\n FileManager[\n module.filename.includes('Documents\/iCloud~') ? 'iCloud' : 'local'\n ]();\n\n this.cacheImage = this.FILE_MGR.joinPath(\n this.FILE_MGR.documentsDirectory(),\n `\/images\/${Script.name()}`\n );\n\n this.baseImage = this.FILE_MGR.joinPath(\n this.FILE_MGR.documentsDirectory(),\n `\/images\/`\n );\n\n this.cacheImageBgPath = [\n `${this.cacheImage}\/transparentBg`,\n `${this.cacheImage}\/dayBg`,\n `${this.cacheImage}\/nightBg`,\n `${this.baseImage}\/avatar`,\n ];\n\n if (!this.FILE_MGR.fileExists(this.cacheImage)) {\n this.FILE_MGR.createDirectory(this.cacheImage, true);\n }\n\n \/\/ 本地,用于存储图片等\n this.FILE_MGR_LOCAL = FileManager.local();\n\n this.settings = this.getSettings();\n\n this.baseSettings = this.getBaseSettings();\n\n this.settings = { ...this.defaultSettings, ...this.settings };\n\n this.settings.lightColor = this.settings.lightColor || '#000000';\n this.settings.darkColor = this.settings.darkColor || '#ffffff';\n this.settings.lightBgColor = this.settings.lightBgColor || '#ffffff';\n this.settings.darkBgColor = this.settings.darkBgColor || '#000000';\n this.settings.boxjsDomain = this.baseSettings.boxjsDomain || 'boxjs.net';\n this.settings.refreshAfterDate = this.settings.refreshAfterDate || '30';\n this.settings.lightOpacity = this.settings.lightOpacity || '0.4';\n this.settings.darkOpacity = this.settings.darkOpacity || '0.7';\n\n this.prefix = this.settings.boxjsDomain;\n\n config.runsInApp && this.saveSettings(false);\n\n this.backGroundColor = Color.dynamic(\n new Color(this.settings.lightBgColor),\n new Color(this.settings.darkBgColor)\n );\n\n \/\/ const lightBgColor = this.getColors(this.settings.lightBgColor);\n \/\/ const darkBgColor = this.getColors(this.settings.darkBgColor);\n \/\/ if (lightBgColor.length > 1 || darkBgColor.length > 1) {\n \/\/ this.backGroundColor = !Device.isUsingDarkAppearance()\n \/\/ ? this.getBackgroundColor(lightBgColor)\n \/\/ : this.getBackgroundColor(darkBgColor);\n \/\/ } else if (lightBgColor.length > 0 && darkBgColor.length > 0) {\n \/\/ this.backGroundColor = Color.dynamic(\n \/\/ new Color(this.settings.lightBgColor),\n \/\/ new Color(this.settings.darkBgColor)\n \/\/ );\n \/\/ }\n\n this.widgetColor = Color.dynamic(\n new Color(this.settings.lightColor),\n new Color(this.settings.darkColor)\n );\n }\n\n getColors = (color = '') => {\n const colors = typeof color === 'string' ? color.split(',') : color;\n return colors;\n };\n\n getBackgroundColor = (colors) => {\n const locations = [];\n const linearColor = new LinearGradient();\n const cLen = colors.length;\n linearColor.colors = colors.map((item, index) => {\n locations.push(Math.floor(((index + 1) \/ cLen) * 100) \/ 100);\n return new Color(item, 1);\n });\n linearColor.locations = locations;\n return linearColor;\n };\n\n \/**\n * 注册点击操作菜单\n * @param {string} name 操作函数名\n * @param {func} func 点击后执行的函数\n *\/\n registerAction(name, func, icon = { name: 'gear', color: '#096dd9' }, type) {\n if (typeof name === 'object' && !name.menu) return this._actions.push(name);\n if (typeof name === 'object' && name.menu)\n return this._menuActions.push(name);\n\n const action = {\n name,\n type,\n title: name,\n onClick: func.bind(this),\n };\n\n if (typeof icon === 'string') {\n action.url = icon;\n } else {\n action.icon = icon;\n }\n\n this._actions.push(action);\n }\n\n \/**\n * base64 编码字符串\n * @param {string} str 要编码的字符串\n *\/\n base64Encode(str) {\n const data = Data.fromString(str);\n return data.toBase64String();\n }\n\n \/**\n * base64解码数据 返回字符串\n * @param {string} b64 base64编码的数据\n *\/\n base64Decode(b64) {\n const data = Data.fromBase64String(b64);\n return data.toRawString();\n }\n\n \/**\n * md5 加密字符串\n * @param {string} str 要加密成md5的数据\n *\/\n \/\/ prettier-ignore\n md5(str){function d(n,t){var r=(65535&n)+(65535&t);return(((n>>16)+(t>>16)+(r>>16))<<16)|(65535&r)}function f(n,t,r,e,o,u){return d(((c=d(d(t,n),d(e,u)))<<(f=o))|(c>>>(32-f)),r);var c,f}function l(n,t,r,e,o,u,c){return f((t&r)|(~t&e),n,t,o,u,c)}function v(n,t,r,e,o,u,c){return f((t&e)|(r&~e),n,t,o,u,c)}function g(n,t,r,e,o,u,c){return f(t^r^e,n,t,o,u,c)}function m(n,t,r,e,o,u,c){return f(r^(t|~e),n,t,o,u,c)}function i(n,t){var r,e,o,u;(n[t>>5]|=128<<t%32),(n[14+(((t+64)>>>9)<<4)]=t);for(var c=1732584193,f=-271733879,i=-1732584194,a=271733878,h=0;h<n.length;h+=16)(c=l((r=c),(e=f),(o=i),(u=a),n[h],7,-680876936)),(a=l(a,c,f,i,n[h+1],12,-389564586)),(i=l(i,a,c,f,n[h+2],17,606105819)),(f=l(f,i,a,c,n[h+3],22,-1044525330)),(c=l(c,f,i,a,n[h+4],7,-176418897)),(a=l(a,c,f,i,n[h+5],12,1200080426)),(i=l(i,a,c,f,n[h+6],17,-1473231341)),(f=l(f,i,a,c,n[h+7],22,-45705983)),(c=l(c,f,i,a,n[h+8],7,1770035416)),(a=l(a,c,f,i,n[h+9],12,-1958414417)),(i=l(i,a,c,f,n[h+10],17,-42063)),(f=l(f,i,a,c,n[h+11],22,-1990404162)),(c=l(c,f,i,a,n[h+12],7,1804603682)),(a=l(a,c,f,i,n[h+13],12,-40341101)),(i=l(i,a,c,f,n[h+14],17,-1502002290)),(c=v(c,(f=l(f,i,a,c,n[h+15],22,1236535329)),i,a,n[h+1],5,-165796510)),(a=v(a,c,f,i,n[h+6],9,-1069501632)),(i=v(i,a,c,f,n[h+11],14,643717713)),(f=v(f,i,a,c,n[h],20,-373897302)),(c=v(c,f,i,a,n[h+5],5,-701558691)),(a=v(a,c,f,i,n[h+10],9,38016083)),(i=v(i,a,c,f,n[h+15],14,-660478335)),(f=v(f,i,a,c,n[h+4],20,-405537848)),(c=v(c,f,i,a,n[h+9],5,568446438)),(a=v(a,c,f,i,n[h+14],9,-1019803690)),(i=v(i,a,c,f,n[h+3],14,-187363961)),(f=v(f,i,a,c,n[h+8],20,1163531501)),(c=v(c,f,i,a,n[h+13],5,-1444681467)),(a=v(a,c,f,i,n[h+2],9,-51403784)),(i=v(i,a,c,f,n[h+7],14,1735328473)),(c=g(c,(f=v(f,i,a,c,n[h+12],20,-1926607734)),i,a,n[h+5],4,-378558)),(a=g(a,c,f,i,n[h+8],11,-2022574463)),(i=g(i,a,c,f,n[h+11],16,1839030562)),(f=g(f,i,a,c,n[h+14],23,-35309556)),(c=g(c,f,i,a,n[h+1],4,-1530992060)),(a=g(a,c,f,i,n[h+4],11,1272893353)),(i=g(i,a,c,f,n[h+7],16,-155497632)),(f=g(f,i,a,c,n[h+10],23,-1094730640)),(c=g(c,f,i,a,n[h+13],4,681279174)),(a=g(a,c,f,i,n[h],11,-358537222)),(i=g(i,a,c,f,n[h+3],16,-722521979)),(f=g(f,i,a,c,n[h+6],23,76029189)),(c=g(c,f,i,a,n[h+9],4,-640364487)),(a=g(a,c,f,i,n[h+12],11,-421815835)),(i=g(i,a,c,f,n[h+15],16,530742520)),(c=m(c,(f=g(f,i,a,c,n[h+2],23,-995338651)),i,a,n[h],6,-198630844)),(a=m(a,c,f,i,n[h+7],10,1126891415)),(i=m(i,a,c,f,n[h+14],15,-1416354905)),(f=m(f,i,a,c,n[h+5],21,-57434055)),(c=m(c,f,i,a,n[h+12],6,1700485571)),(a=m(a,c,f,i,n[h+3],10,-1894986606)),(i=m(i,a,c,f,n[h+10],15,-1051523)),(f=m(f,i,a,c,n[h+1],21,-2054922799)),(c=m(c,f,i,a,n[h+8],6,1873313359)),(a=m(a,c,f,i,n[h+15],10,-30611744)),(i=m(i,a,c,f,n[h+6],15,-1560198380)),(f=m(f,i,a,c,n[h+13],21,1309151649)),(c=m(c,f,i,a,n[h+4],6,-145523070)),(a=m(a,c,f,i,n[h+11],10,-1120210379)),(i=m(i,a,c,f,n[h+2],15,718787259)),(f=m(f,i,a,c,n[h+9],21,-343485551)),(c=d(c,r)),(f=d(f,e)),(i=d(i,o)),(a=d(a,u));return[c,f,i,a]}function a(n){for(var t='',r=32*n.length,e=0;e<r;e+=8)t+=String.fromCharCode((n[e>>5]>>>e%32)&255);return t}function h(n){var t=[];for(t[(n.length>>2)-1]=void 0,e=0;e<t.length;e+=1)t[e]=0;for(var r=8*n.length,e=0;e<r;e+=8)t[e>>5]|=(255&n.charCodeAt(e\/8))<<e%32;return t}function e(n){for(var t,r='0123456789abcdef',e='',o=0;o<n.length;o+=1)(t=n.charCodeAt(o)),(e+=r.charAt((t>>>4)&15)+r.charAt(15&t));return e}function r(n){return unescape(encodeURIComponent(n))}function o(n){return a(i(h((t=r(n))),8*t.length));var t}function u(n,t){return(function(n,t){var r,e,o=h(n),u=[],c=[];for(u[15]=c[15]=void 0,16<o.length&&(o=i(o,8*n.length)),r=0;r<16;r+=1)(u[r]=909522486^o[r]),(c[r]=1549556828^o[r]);return((e=i(u.concat(h(t)),512+8*t.length)),a(i(c.concat(e),640)))})(r(n),r(t))}function t(n,t,r){return t?(r?u(t,n):e(u(t,n))):r?o(n):e(o(n))}return t(str)}\n\n \/**\n * 渲染标题内容\n * @param {object} widget 组件对象\n * @param {string} icon 图标地址\n * @param {string} title 标题内容\n * @param {bool|color} color 字体的颜色(自定义背景时使用,默认系统)\n *\/\n async renderHeader(widget, icon, title, color = false) {\n let header = widget.addStack();\n header.centerAlignContent();\n try {\n const image = await this.$request.get(icon, 'IMG');\n let _icon = header.addImage(image);\n _icon.imageSize = new Size(14, 14);\n _icon.cornerRadius = 4;\n } catch (e) {\n console.log(e);\n }\n header.addSpacer(10);\n let _title = header.addText(title);\n if (color) _title.textColor = color;\n _title.textOpacity = 0.7;\n _title.font = Font.boldSystemFont(12);\n _title.lineLimit = 1;\n widget.addSpacer(15);\n return widget;\n }\n\n \/**\n * @param message 描述内容\n * @param options 按钮\n * @returns {Promise<number>}\n *\/\n\n async generateAlert(message, options) {\n let alert = new Alert();\n alert.message = message;\n\n for (const option of options) {\n alert.addAction(option);\n }\n return await alert.presentAlert();\n }\n\n \/**\n * 弹出一个通知\n * @param {string} title 通知标题\n * @param {string} body 通知内容\n * @param {string} url 点击后打开的URL\n *\/\n async notify(title, body, url, opts = {}) {\n let n = new Notification();\n n = Object.assign(n, opts);\n n.title = title;\n n.body = body;\n if (url) n.openURL = url;\n return await n.schedule();\n }\n\n \/**\n * 给图片加一层半透明遮罩\n * @param {Image} img 要处理的图片\n * @param {string} color 遮罩背景颜色\n * @param {float} opacity 透明度\n *\/\n async shadowImage(img, color = '#000000', opacity = 0.7) {\n if (!img) return;\n if (opacity === 0) return img;\n let ctx = new DrawContext();\n \/\/ 获取图片的尺寸\n ctx.size = img.size;\n\n ctx.drawImageInRect(\n img,\n new Rect(0, 0, img.size['width'], img.size['height'])\n );\n ctx.setFillColor(new Color(color, opacity));\n ctx.fillRect(new Rect(0, 0, img.size['width'], img.size['height']));\n return await ctx.getImage();\n }\n\n \/**\n * 获取当前插件的设置\n * @param {boolean} json 是否为json格式\n *\/\n getSettings(json = true) {\n let res = json ? {} : '';\n let cache = '';\n if (Keychain.contains(this.SETTING_KEY)) {\n cache = Keychain.get(this.SETTING_KEY);\n }\n\n if (json) {\n try {\n res = JSON.parse(cache);\n } catch (e) {}\n } else {\n res = cache;\n }\n\n return res;\n }\n\n getBaseSettings(json = true) {\n let res = json ? {} : '';\n let cache = '';\n if (Keychain.contains(this.BaseCacheKey)) {\n cache = Keychain.get(this.BaseCacheKey);\n }\n\n if (json) {\n try {\n res = JSON.parse(cache);\n } catch (e) {}\n } else {\n res = cache;\n }\n\n return res;\n }\n\n saveBaseSettings(res = {}, notify = true) {\n const data = { ...(this.baseSettings || {}), ...res };\n this.baseSettings = data;\n Keychain.set(this.BaseCacheKey, JSON.stringify(data));\n if (notify) this.notify('设置成功', '通用设置需重新运行脚本生效');\n return data;\n }\n\n \/**\n * 存储当前设置\n * @param {bool} notify 是否通知提示\n *\/\n saveSettings(notify = true) {\n let res =\n typeof this.settings === 'object'\n ? JSON.stringify(this.settings)\n : String(this.settings);\n Keychain.set(this.SETTING_KEY, res);\n\n if (notify) this.notify('设置成功', '桌面组件稍后将自动刷新');\n\n return res;\n }\n\n \/**\n * 获取当前插件是否有自定义背景图片\n * @reutrn img | false\n *\/\n getBackgroundImage() {\n if (this.FILE_MGR.fileExists(this.cacheImageBgPath[0]))\n return Image.fromFile(this.cacheImageBgPath[0]);\n\n if (!this.isNight)\n return this.FILE_MGR.fileExists(this.cacheImageBgPath[1])\n ? Image.fromFile(this.cacheImageBgPath[1])\n : undefined;\n else\n return this.FILE_MGR.fileExists(this.cacheImageBgPath[2])\n ? Image.fromFile(this.cacheImageBgPath[2])\n : undefined;\n }\n\n \/**\n * 设置当前组件的背景图片\n * @param {Image} img\n *\/\n setBackgroundImage(img, filePath = this.baseImage, notify = true) {\n const cacheKey = filePath;\n if (!img) {\n \/\/ 移除背景\n if (this.FILE_MGR.fileExists(cacheKey)) this.FILE_MGR.remove(cacheKey);\n if (notify) this.notify('移除成功', '背景图片已移除,稍后刷新生效');\n } else {\n \/\/ 设置背景\n this.FILE_MGR.writeImage(cacheKey, img);\n\n if (notify) this.notify('设置成功', '背景图片已设置!稍后刷新生效');\n return `data:image\/png;base64,${Data.fromFile(\n cacheKey\n ).toBase64String()}`;\n }\n }\n\n getRandomArrayElements(arr, count) {\n let shuffled = arr.slice(0),\n i = arr.length,\n min = i - count,\n temp,\n index;\n min = min > 0 ? min : 0;\n while (i-- > min) {\n index = Math.floor((i + 1) * Math.random());\n temp = shuffled[index];\n shuffled[index] = shuffled[i];\n shuffled[i] = temp;\n }\n return shuffled.slice(min);\n }\n\n textFormat = {\n defaultText: { size: 14, font: 'regular', color: this.widgetColor },\n battery: { size: 10, font: 'bold', color: this.widgetColor },\n title: { size: 16, font: 'semibold', color: this.widgetColor },\n SFMono: { size: 12, font: 'SF Mono', color: this.widgetColor },\n };\n\n provideFont = (fontName, fontSize) => {\n const fontGenerator = {\n ultralight: function () {\n return Font.ultraLightSystemFont(fontSize);\n },\n light: function () {\n return Font.lightSystemFont(fontSize);\n },\n regular: function () {\n return Font.regularSystemFont(fontSize);\n },\n medium: function () {\n return Font.mediumSystemFont(fontSize);\n },\n semibold: function () {\n return Font.semiboldSystemFont(fontSize);\n },\n bold: function () {\n return Font.boldSystemFont(fontSize);\n },\n heavy: function () {\n return Font.heavySystemFont(fontSize);\n },\n black: function () {\n return Font.blackSystemFont(fontSize);\n },\n italic: function () {\n return Font.italicSystemFont(fontSize);\n },\n };\n\n const systemFont = fontGenerator[fontName];\n if (systemFont) {\n return systemFont();\n }\n return new Font(fontName, fontSize);\n };\n\n provideText = (\n string,\n container,\n format = {\n font: 'light',\n size: 14,\n color: this.widgetColor,\n opacity: 1,\n minimumScaleFactor: 1,\n }\n ) => {\n const textItem = container.addText(string);\n const textFont = format.font;\n const textSize = format.size;\n const textColor = format.color;\n\n textItem.font = this.provideFont(textFont, textSize);\n textItem.textColor = textColor;\n textItem.textOpacity = format.opacity || 1;\n textItem.minimumScaleFactor = format.minimumScaleFactor || 1;\n return textItem;\n };\n}\n\n\/\/ @base.end\nconst Runing = async (Widget, default_args = '', isDebug = true, extra) => {\n let M = null;\n \/\/ 判断hash是否和当前设备匹配\n if (config.runsInWidget) {\n M = new Widget(args.widgetParameter || '');\n\n if (extra) {\n Object.keys(extra).forEach((key) => {\n M[key] = extra[key];\n });\n }\n const W = await M.render();\n try {\n if (M.settings.refreshAfterDate) {\n const refreshTime = parseInt(M.settings.refreshAfterDate) * 1000 * 60;\n const timeStr = new Date().getTime() + refreshTime;\n W.refreshAfterDate = new Date(timeStr);\n }\n } catch (e) {\n console.log(e);\n }\n if (W) {\n Script.setWidget(W);\n Script.complete();\n }\n } else {\n let { act, __arg, __size } = args.queryParameters;\n M = new Widget(__arg || default_args || '');\n if (extra) {\n Object.keys(extra).forEach((key) => {\n M[key] = extra[key];\n });\n }\n if (__size) M._init(__size);\n if (!act || !M['_actions']) {\n \/\/ 弹出选择菜单\n const actions = M['_actions'];\n const onClick = async (item) => {\n M.widgetFamily = item.val;\n try {\n M._init(item.val);\n } catch (error) {\n console.log('初始化异常:' + error);\n }\n w = await M.render();\n const fnc = item.val\n .toLowerCase()\n .replace(\/( |^)[a-z]\/g, (L) => L.toUpperCase());\n if (w) {\n return w[`present${fnc}`]();\n }\n };\n const preview = [\n {\n url: `https:\/\/raw.githubusercontent.com\/dompling\/Scriptable\/master\/images\/small.png`,\n title: '小尺寸',\n val: 'small',\n name: 'small',\n dismissOnSelect: true,\n onClick,\n },\n {\n url: `https:\/\/raw.githubusercontent.com\/dompling\/Scriptable\/master\/images\/medium.png`,\n title: '中尺寸',\n val: 'medium',\n name: 'medium',\n dismissOnSelect: true,\n onClick,\n },\n {\n url: `https:\/\/raw.githubusercontent.com\/dompling\/Scriptable\/master\/images\/large.png`,\n title: '大尺寸',\n val: 'large',\n name: 'large',\n dismissOnSelect: true,\n onClick,\n },\n ];\n\n const menuConfig = [\n { title: '预览组件', menu: preview },\n { title: '组件配置', menu: actions },\n ...M['_menuActions'],\n ];\n await M.renderAppView(menuConfig, true);\n }\n }\n};\n\/\/ await new DmYY().setWidgetConfig();\nmodule.exports = { DmYY, Runing };\n\n\/\/version:1.1.0", - "share_sheet_inputs" : [ - - ] -} \ No newline at end of file diff --git a/Scriptable/Photo.js b/Scriptable/Photo.js new file mode 100644 index 00000000..1e30c479 --- /dev/null +++ b/Scriptable/Photo.js @@ -0,0 +1,2320 @@ +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: deep-purple; icon-glyph: images; +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: deep-purple; icon-glyph: images; +/** + * 日历照片墙 + * 添加到桌面前需先在UI运行选择指定图片或在iCloud创建WidgetPhotos文件夹轮巡图片 + * 有特殊字体,可下载相关字体或者清空字体配置 + * + * @author Honye + * + * @version 1.2.3 更新时间 9/17 + */ + + +/** + * @returns {Record<'small'|'medium'|'large'|'extraLarge', number>} + */ +const widgetSize = () => { + const phones = { + /** 16 Pro Max */ + 956: { small: 170, medium: 364, large: 382 }, + /** 16 Pro */ + 874: { small: 162, medium: 344, large: 366 }, + /** 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max */ + 932: { small: 170, medium: 364, large: 382 }, + /** 13 Pro Max, 12 Pro Max */ + 926: { small: 170, medium: 364, large: 382 }, + /** 11 Pro Max, 11, XS Max, XR */ + 896: { small: 169, medium: 360, large: 379 }, + /** Plus phones */ + 736: { small: 157, medium: 348, large: 357 }, + /** 16, 15 Pro, 15, 14 Pro */ + 852: { small: 158, medium: 338, large: 354 }, + /** 13, 13 Pro, 12, 12 Pro */ + 844: { small: 158, medium: 338, large: 354 }, + /** 13 mini, 12 mini / 11 Pro, XS, X */ + 812: { small: 155, medium: 329, large: 345 }, + /** SE2 and 6/6S/7/8 */ + 667: { small: 148, medium: 321, large: 324 }, + /** iPad Pro 2 */ + 1194: { small: 155, medium: 342, large: 342, extraLarge: 715.5 }, + /** iPad 6 */ + 1024: { small: 141, medium: 305.5, large: 305.5, extraLarge: 634.5 } + }; + let { width, height } = Device.screenSize(); + if (width > height) height = width; + + if (phones[height]) return phones[height] + + if (config.runsInWidget) { + const pc = { small: 164, medium: 344, large: 344 }; + return pc + } + + // in app screen fixed 375x812 pt + return { small: 155, medium: 329, large: 329 } +}; + +/** + * @param {number} num + */ +const vmin = (num, widgetFamily) => { + const family = widgetFamily || config.widgetFamily; + if (!family) throw new Error('`vmin` only work in widget') + const size = widgetSize(); + const width = size[family === 'large' ? 'medium' : family]; + const height = family === 'medium' + ? size.small + : family === 'extraLarge' ? size.large : size[family]; + return num * Math.min(width, height) / 100 +}; + +/** + * 多语言国际化 + * @param {{[language: string]: string} | [en:string, zh:string]} langs + */ +const i18n = (langs) => { + const language = Device.language(); + if (Array.isArray(langs)) { + langs = { + en: langs[0], + zh: langs[1], + others: langs[0] + }; + } else { + langs.others = langs.others || langs.en; + } + return langs[language] || langs.others +}; + +/** + * @param {...string} paths + */ +const joinPath = (...paths) => { + const fm = FileManager.local(); + return paths.reduce((prev, curr) => { + return fm.joinPath(prev, curr) + }, '') +}; + +/** + * 规范使用 FileManager。每个脚本使用独立文件夹 + * + * 注意:桌面组件无法写入 cacheDirectory 和 temporaryDirectory + * @param {object} options + * @param {boolean} [options.useICloud] + * @param {string} [options.basePath] + */ +const useFileManager = (options = {}) => { + const { useICloud, basePath } = options; + const fm = useICloud ? FileManager.iCloud() : FileManager.local(); + const paths = [fm.documentsDirectory(), Script.name()]; + if (basePath) { + paths.push(basePath); + } + const cacheDirectory = joinPath(...paths); + /** + * 删除路径末尾所有的 / + * @param {string} filePath + */ + const safePath = (filePath) => { + return fm.joinPath(cacheDirectory, filePath).replace(/\/+$/, '') + }; + /** + * 如果上级文件夹不存在,则先创建文件夹 + * @param {string} filePath + */ + const preWrite = (filePath) => { + const i = filePath.lastIndexOf('/'); + const directory = filePath.substring(0, i); + if (!fm.fileExists(directory)) { + fm.createDirectory(directory, true); + } + }; + + const writeString = (filePath, content) => { + const nextPath = safePath(filePath); + preWrite(nextPath); + fm.writeString(nextPath, content); + }; + + /** + * @param {string} filePath + * @param {*} jsonData + */ + const writeJSON = (filePath, jsonData) => writeString(filePath, JSON.stringify(jsonData)); + /** + * @param {string} filePath + * @param {Image} image + */ + const writeImage = (filePath, image) => { + const nextPath = safePath(filePath); + preWrite(nextPath); + return fm.writeImage(nextPath, image) + }; + + /** + * 文件不存在时返回 null + * @param {string} filePath + * @returns {string|null} + */ + const readString = (filePath) => { + const fullPath = fm.joinPath(cacheDirectory, filePath); + if (fm.fileExists(fullPath)) { + return fm.readString( + fm.joinPath(cacheDirectory, filePath) + ) + } + return null + }; + + /** + * @param {string} filePath + */ + const readJSON = (filePath) => JSON.parse(readString(filePath)); + + /** + * @param {string} filePath + * @returns {Image|null} + */ + const readImage = (filePath) => { + const fullPath = safePath(filePath); + if (fm.fileExists(fullPath)) { + return fm.readImage(fullPath) + } + return null + }; + + return { + cacheDirectory, + writeString, + writeJSON, + writeImage, + readString, + readJSON, + readImage + } +}; + +/** 规范使用文件缓存。每个脚本使用独立文件夹 */ +const useCache = () => useFileManager({ basePath: 'cache' }); + +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: light-gray; icon-glyph: cube; +/* 公历转农历代码思路: +1、建立农历年份查询表 +2、计算输入公历日期与公历基准的相差天数 +3、从农历基准开始遍历农历查询表,计算自农历基准之后每一年的天数,并用相差天数依次相减,确定农历年份 +4、利用剩余相差天数以及农历每个月的天数确定农历月份 +5、利用剩余相差天数确定农历哪一天 */ + +// 农历1949-2100年查询表 +const lunarYearArr = [ + 0x0b557, // 1949 + 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959 + 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969 + 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979 + 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989 + 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999 + 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009 + 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019 + 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029 + 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039 + 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049 + 0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, // 2050-2059 + 0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069 + 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079 + 0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089 + 0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099 + 0x0d520 // 2100 +]; +const lunarMonth = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊']; +const lunarDay = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '初', '廿']; +const tianGan = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']; +const diZhi = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']; + +// 公历转农历函数 +function sloarToLunar (sy, sm, sd) { + // 输入的月份减1处理 + sm -= 1; + + // 计算与公历基准的相差天数 + // Date.UTC()返回的是距离公历1970年1月1日的毫秒数,传入的月份需要减1 + let daySpan = (Date.UTC(sy, sm, sd) - Date.UTC(1949, 0, 29)) / (24 * 60 * 60 * 1000) + 1; + let ly, lm, ld; + // 确定输出的农历年份 + for (let j = 0; j < lunarYearArr.length; j++) { + daySpan -= lunarYearDays(lunarYearArr[j]); + if (daySpan <= 0) { + ly = 1949 + j; + // 获取农历年份确定后的剩余天数 + daySpan += lunarYearDays(lunarYearArr[j]); + break + } + } + + // 确定输出的农历月份 + for (let k = 0; k < lunarYearMonths(lunarYearArr[ly - 1949]).length; k++) { + daySpan -= lunarYearMonths(lunarYearArr[ly - 1949])[k]; + if (daySpan <= 0) { + // 有闰月时,月份的数组长度会变成13,因此,当闰月月份小于等于k时,lm不需要加1 + if (hasLeapMonth(lunarYearArr[ly - 1949]) && hasLeapMonth(lunarYearArr[ly - 1949]) <= k) { + if (hasLeapMonth(lunarYearArr[ly - 1949]) < k) { + lm = k; + } else if (hasLeapMonth(lunarYearArr[ly - 1949]) === k) { + lm = '闰' + k; + } else { + lm = k + 1; + } + } else { + lm = k + 1; + } + // 获取农历月份确定后的剩余天数 + daySpan += lunarYearMonths(lunarYearArr[ly - 1949])[k]; + break + } + } + + // 确定输出农历哪一天 + ld = daySpan; + + // 将计算出来的农历月份转换成汉字月份,闰月需要在前面加上闰字 + if (hasLeapMonth(lunarYearArr[ly - 1949]) && (typeof (lm) === 'string' && lm.indexOf('闰') > -1)) { + lm = `闰${lunarMonth[/\d/.exec(lm) - 1]}`; + } else { + lm = lunarMonth[lm - 1]; + } + + // 将计算出来的农历年份转换为天干地支年 + ly = getTianGan(ly) + getDiZhi(ly); + + // 将计算出来的农历天数转换成汉字 + if (ld < 11) { + ld = `${lunarDay[10]}${lunarDay[ld - 1]}`; + } else if (ld > 10 && ld < 20) { + ld = `${lunarDay[9]}${lunarDay[ld - 11]}`; + } else if (ld === 20) { + ld = `${lunarDay[1]}${lunarDay[9]}`; + } else if (ld > 20 && ld < 30) { + ld = `${lunarDay[11]}${lunarDay[ld - 21]}`; + } else if (ld === 30) { + ld = `${lunarDay[2]}${lunarDay[9]}`; + } + + // console.log(ly, lm, ld); + + return { + lunarYear: ly, + lunarMonth: lm, + lunarDay: ld + } +} + +// 计算农历年是否有闰月,参数为存储农历年的16进制 +// 农历年份信息用16进制存储,其中16进制的最后1位可以用于判断是否有闰月 +function hasLeapMonth (ly) { + // 获取16进制的最后1位,需要用到&与运算符 + if (ly & 0xf) { + return ly & 0xf + } else { + return false + } +} + +// 如果有闰月,计算农历闰月天数,参数为存储农历年的16进制 +// 农历年份信息用16进制存储,其中16进制的第1位(0x除外)可以用于表示闰月是大月还是小月 +function leapMonthDays (ly) { + if (hasLeapMonth(ly)) { + // 获取16进制的第1位(0x除外) + return (ly & 0xf0000) ? 30 : 29 + } else { + return 0 + } +} + +// 计算农历一年的总天数,参数为存储农历年的16进制 +// 农历年份信息用16进制存储,其中16进制的第2-4位(0x除外)可以用于表示正常月是大月还是小月 +function lunarYearDays (ly) { + let totalDays = 0; + + // 获取正常月的天数,并累加 + // 获取16进制的第2-4位,需要用到>>移位运算符 + for (let i = 0x8000; i > 0x8; i >>= 1) { + const monthDays = (ly & i) ? 30 : 29; + totalDays += monthDays; + } + // 如果有闰月,需要把闰月的天数加上 + if (hasLeapMonth(ly)) { + totalDays += leapMonthDays(ly); + } + + return totalDays +} + +// 获取农历每个月的天数 +// 参数需传入16进制数值 +function lunarYearMonths (ly) { + const monthArr = []; + + // 获取正常月的天数,并添加到monthArr数组中 + // 获取16进制的第2-4位,需要用到>>移位运算符 + for (let i = 0x8000; i > 0x8; i >>= 1) { + monthArr.push((ly & i) ? 30 : 29); + } + // 如果有闰月,需要把闰月的天数加上 + if (hasLeapMonth(ly)) { + monthArr.splice(hasLeapMonth(ly), 0, leapMonthDays(ly)); + } + + return monthArr +} + +// 将农历年转换为天干,参数为农历年 +function getTianGan (ly) { + let tianGanKey = (ly - 3) % 10; + if (tianGanKey === 0) tianGanKey = 10; + return tianGan[tianGanKey - 1] +} + +// 将农历年转换为地支,参数为农历年 +function getDiZhi (ly) { + let diZhiKey = (ly - 3) % 12; + if (diZhiKey === 0) diZhiKey = 12; + return diZhi[diZhiKey - 1] +} + +/** + * @file Scriptable WebView JSBridge native SDK + * @version 1.0.3 + * @author Honye + */ + +/** + * @typedef Options + * @property {Record<string, () => void>} methods + */ + +const sendResult = (() => { + let sending = false; + /** @type {{ code: string; data: any }[]} */ + const list = []; + + /** + * @param {WebView} webView + * @param {string} code + * @param {any} data + */ + return async (webView, code, data) => { + if (sending) return + + sending = true; + list.push({ code, data }); + const arr = list.splice(0, list.length); + for (const { code, data } of arr) { + const eventName = `ScriptableBridge_${code}_Result`; + const res = data instanceof Error ? { err: data.message } : data; + await webView.evaluateJavaScript( + `window.dispatchEvent( + new CustomEvent( + '${eventName}', + { detail: ${JSON.stringify(res)} } + ) + )` + ); + } + if (list.length) { + const { code, data } = list.shift(); + sendResult(webView, code, data); + } else { + sending = false; + } + } +})(); + +/** + * @param {WebView} webView + * @param {Options} options + */ +const inject = async (webView, options) => { + const js = +`(() => { + const queue = window.__scriptable_bridge_queue + if (queue && queue.length) { + completion(queue) + } + window.__scriptable_bridge_queue = null + + if (!window.ScriptableBridge) { + window.ScriptableBridge = { + invoke(name, data, callback) { + const detail = { code: name, data } + + const eventName = \`ScriptableBridge_\${name}_Result\` + const controller = new AbortController() + window.addEventListener( + eventName, + (e) => { + callback && callback(e.detail) + controller.abort() + }, + { signal: controller.signal } + ) + + if (window.__scriptable_bridge_queue) { + window.__scriptable_bridge_queue.push(detail) + completion() + } else { + completion(detail) + window.__scriptable_bridge_queue = [] + } + } + } + window.dispatchEvent( + new CustomEvent('ScriptableBridgeReady') + ) + } +})()`; + + const res = await webView.evaluateJavaScript(js, true); + if (!res) return inject(webView, options) + + const methods = options.methods || {}; + const events = Array.isArray(res) ? res : [res]; + // 同时执行多次 webView.evaluateJavaScript Scriptable 存在问题 + // 可能是因为 JavaScript 是单线程导致的 + const sendTasks = events.map(({ code, data }) => { + return (() => { + try { + return Promise.resolve(methods[code](data)) + } catch (e) { + return Promise.reject(e) + } + })() + .then((res) => sendResult(webView, code, res)) + .catch((e) => { + console.error(e); + sendResult(webView, code, e instanceof Error ? e : new Error(e)); + }) + }); + await Promise.all(sendTasks); + inject(webView, options); +}; + +/** + * @param {WebView} webView + * @param {object} args + * @param {string} args.html + * @param {string} [args.baseURL] + * @param {Options} options + */ +const loadHTML = async (webView, args, options = {}) => { + const { html, baseURL } = args; + await webView.loadHTML(html, baseURL); + inject(webView, options).catch((err) => console.error(err)); +}; + +/** + * 轻松实现桌面组件可视化配置 + * + * - 颜色选择器及更多表单控件 + * - 快速预览 + * + * GitHub: https://github.com/honye + * + * @version 1.7.1 + * @author Honye + */ + +const fm = FileManager.local(); +const fileName = 'settings.json'; + +const toast = (message) => { + const notification = new Notification(); + notification.title = Script.name(); + notification.body = message; + notification.schedule(); +}; + +const isUseICloud = () => { + const ifm = useFileManager({ useICloud: true }); + const filePath = fm.joinPath(ifm.cacheDirectory, fileName); + return fm.fileExists(filePath) +}; + +/** + * @returns {Promise<Settings>} + */ +const readSettings = async () => { + const useICloud = isUseICloud(); + console.log(`[info] use ${useICloud ? 'iCloud' : 'local'} settings`); + const fm = useFileManager({ useICloud }); + const settings = fm.readJSON(fileName); + return settings +}; + +/** + * @param {Record<string, unknown>} data + * @param {{ useICloud: boolean; }} options + */ +const writeSettings = async (data, { useICloud }) => { + const fm = useFileManager({ useICloud }); + fm.writeJSON(fileName, data); +}; + +const removeSettings = async (settings) => { + const cache = useFileManager({ useICloud: settings.useICloud }); + fm.remove( + fm.joinPath(cache.cacheDirectory, fileName) + ); +}; + +const moveSettings = (useICloud, data) => { + const localFM = useFileManager(); + const iCloudFM = useFileManager({ useICloud: true }); + const [i, l] = [ + fm.joinPath(iCloudFM.cacheDirectory, fileName), + fm.joinPath(localFM.cacheDirectory, fileName) + ]; + try { + // 移动文件需要创建父文件夹,写入操作会自动创建文件夹 + writeSettings(data, { useICloud }); + if (useICloud) { + if (fm.fileExists(l)) fm.remove(l); + } else { + if (fm.fileExists(i)) fm.remove(i); + } + } catch (e) { + console.error(e); + } +}; + +/** + * @typedef {object} NormalFormItem + * @property {string} name + * @property {string} label + * @property {'text'|'number'|'color'|'select'|'date'|'cell'} [type] + * - HTML <input> type 属性 + * - `'cell'`: 可点击的 + * @property {'(prefers-color-scheme: light)'|'(prefers-color-scheme: dark)'} [media] + * @property {{ label: string; value: unknown }[]} [options] + * @property {unknown} [default] + */ +/** + * @typedef {Pick<NormalFormItem, 'label'|'name'> & { type: 'group', items: FormItem[] }} GroupFormItem + */ +/** + * @typedef {Omit<NormalFormItem, 'type'> & { type: 'page' } & Pick<Options, 'formItems'|'onItemClick'>} PageFormItem 单独的页面 + */ +/** + * @typedef {NormalFormItem|GroupFormItem|PageFormItem} FormItem + */ +/** + * @typedef {object} CommonSettings + * @property {boolean} useICloud + * @property {string} [backgroundImage] 背景图路径 + * @property {string} [backgroundColorLight] + * @property {string} [backgroundColorDark] + */ +/** + * @typedef {CommonSettings & Record<string, unknown>} Settings + */ +/** + * @typedef {object} Options + * @property {(data: { + * settings: Settings; + * family?: typeof config.widgetFamily; + * }) => ListWidget | Promise<ListWidget>} render + * @property {string} [head] 顶部插入 HTML + * @property {FormItem[]} [formItems] + * @property {(item: FormItem) => void} [onItemClick] + * @property {string} [homePage] 右上角分享菜单地址 + * @property {(data: any) => void} [onWebEvent] + */ +/** + * @template T + * @typedef {T extends infer O ? {[K in keyof O]: O[K]} : never} Expand + */ + +const previewsHTML = +`<div class="actions"> + <button class="preview" data-size="small"><i class="iconfont icon-yingyongzhongxin"></i>${i18n(['Small', '预览小号'])}</button> + <button class="preview" data-size="medium"><i class="iconfont icon-daliebiao"></i>${i18n(['Medium', '预览中号'])}</button> + <button class="preview" data-size="large"><i class="iconfont icon-dantupailie"></i>${i18n(['Large', '预览大号'])}</button> +</div>`; + +const copyrightHTML = +`<footer> + <div class="copyright">© UI powered by <a href="javascript:invoke('safari','https://www.imarkr.com');">iMarkr</a></div> +</footer>`; + +/** + * @param {Expand<Options>} options + * @param {boolean} [isFirstPage] + * @param {object} [others] + * @param {Settings} [others.settings] + * @returns {Promise<ListWidget|undefined>} 仅在 Widget 中运行时返回 ListWidget + */ +const present = async (options, isFirstPage, others = {}) => { + const { + formItems = [], + onItemClick, + render, + head, + homePage = 'https://www.imarkr.com', + onWebEvent + } = options; + const cache = useCache(); + + const settings = others.settings || await readSettings() || {}; + + /** + * @param {Parameters<Options['render']>[0]} param + */ + const getWidget = async (param) => { + const widget = await render(param); + const { backgroundImage, backgroundColorLight, backgroundColorDark } = settings; + if (backgroundImage && fm.fileExists(backgroundImage)) { + widget.backgroundImage = fm.readImage(backgroundImage); + } + if (!widget.backgroundColor || backgroundColorLight || backgroundColorDark) { + widget.backgroundColor = Color.dynamic( + new Color(backgroundColorLight || '#ffffff'), + new Color(backgroundColorDark || '#242426') + ); + } + return widget + }; + + if (config.runsInWidget) { + const widget = await getWidget({ settings }); + Script.setWidget(widget); + return widget + } + + // ====== web start ======= + const style = +`:root { + --color-primary: #007aff; + --text-color: #1e1f24; + --text-secondary: #8b8d98; + --divider-color: #eff0f3; + --card-background: #fff; + --card-radius: 10px; + --bg-input: #f9f9fb; +} +* { + -webkit-user-select: none; + user-select: none; +} +:focus-visible { + outline-width: 2px; +} +body { + margin: 10px 0; + -webkit-font-smoothing: antialiased; + font-family: "SF Pro Display","SF Pro Icons","Helvetica Neue","Helvetica","Arial",sans-serif; + accent-color: var(--color-primary); + color: var(--text-color); +} +input, textarea { + -webkit-user-select: auto; + user-select: auto; +} +input:where([type="date"], [type="time"], [type="datetime-local"], [type="month"], [type="week"]) { + accent-color: var(--text-color); + white-space: nowrap; +} +select { + accent-color: var(--text-color); +} +body { + background: #f2f2f7; +} +button { + font-size: 16px; + background: var(--card-background); + color: var(--text-color); + border-radius: 8px; + border: none; + padding: 0.5em; +} +button .iconfont { + margin-right: 6px; +} +.list { + margin: 15px; +} +.list__header { + margin: 0 20px; + color: var(--text-secondary); + font-size: 13px; +} +.list__body { + margin-top: 10px; + background: var(--card-background); + border-radius: var(--card-radius); + border-radius: 12px; + overflow: hidden; +} +.form-item { + display: flex; + align-items: center; + justify-content: space-between; + column-gap: 1em; + font-size: 16px; + min-height: 2em; + padding: 0.5em 20px; + position: relative; +} +.form-item[media*="prefers-color-scheme"] { + display: none; +} +.form-item--link .icon-arrow_right { + color: #86868b; +} +.form-item + .form-item::before { + content: ""; + position: absolute; + top: 0; + left: 20px; + right: 0; + border-top: 0.5px solid var(--divider-color); +} +.form-item__input-wrapper { + flex: 1; + text-align: right; + box-sizing: border-box; + padding: 2px; + margin-right: -2px; + overflow: hidden; +} +.form-item__input { + max-width: calc(100% - 4px); +} +.form-item .iconfont { + margin-right: 4px; +} +.form-item input, +.form-item textarea, +.form-item select { + font-size: 14px; + text-align: right; +} +.form-item input[type=text], +.form-item textarea { + width: 11em; +} +.form-item textarea { + text-align: start; +} +.form-item input:not([type=color]), +.form-item textarea, +.form-item select { + border-radius: 99px; + background-color: var(--bg-input); + border: none; + color: var(--text-color); +} +.form-item input[type="checkbox"] { + width: 1.25em; + height: 1.25em; +} +input[type="number"] { + width: 4em; +} +input[type="date"] { + min-width: 6.4em; +} +input[type='checkbox'][role='switch'] { + margin: 0; + position: relative; + display: inline-block; + appearance: none; + width: 40px; + height: 25px; + border-radius: 25px; + background: var(--bg-input); + transition: 0.3s ease-in-out; +} +input[type='checkbox'][role='switch']::before { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 21px; + height: 21px; + border-radius: 50%; + background: #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 2px 6px 0 rgba(0, 0, 0, 0.15), 0 2px 1px 0 rgba(0, 0, 0, 0.06); + transition: 0.3s ease-in-out; +} +input[type='checkbox'][role='switch']:checked { + background: var(--color-primary); +} +input[type='checkbox'][role='switch']:checked::before { + transform: translateX(16px); +} +.actions { + margin: 15px; + display: grid; + grid-template-columns: repeat(3, 1fr); + column-gap: 12px; +} +.copyright { + margin: 15px; + margin-inline: 18px; + font-size: 12px; + color: var(--text-secondary); +} +.copyright a { + color: var(--text-color); + text-decoration: none; +} +.preview.loading { + pointer-events: none; +} +.icon-loading { + display: inline-block; + animation: 1s linear infinite spin; +} +@keyframes spin { + 0% { + transform: rotate(0); + } + 100% { + transform: rotate(1turn); + } +} +@media (prefers-color-scheme: light) { + .form-item[media="(prefers-color-scheme: light)"] { + display: flex; + } +} +@media (prefers-color-scheme: dark) { + :root { + --text-color: #eeeef0; + --text-secondary: #6c6e79; + --divider-color: #222325; + --card-background: #19191b; + --bg-input: #303136; + } + body { + background: #111113; + } + input[type='checkbox'][role='switch']::before { + background-color: rgb(206, 206, 206); + } + .form-item[media="(prefers-color-scheme: dark)"] { + display: flex; + } +}`; + + const js = +`(() => { + const settings = ${JSON.stringify({ + ...settings, + useICloud: isUseICloud() + })} + const formItems = ${JSON.stringify(formItems)} + + window.invoke = (code, data, cb) => { + ScriptableBridge.invoke(code, data, cb) + } + + const formData = {} + + const createFormItem = (item) => { + const value = settings[item.name] ?? item.default ?? null + formData[item.name] = value; + const label = document.createElement("label"); + label.className = "form-item"; + if (item.media) { + label.setAttribute('media', item.media) + } + const div = document.createElement("div"); + div.innerText = item.label; + label.appendChild(div); + if (/^(select|multi-select)$/.test(item.type)) { + const wrapper = document.createElement('div') + wrapper.className = 'form-item__input-wrapper' + const select = document.createElement('select') + select.className = 'form-item__input' + select.name = item.name + select.multiple = item.type === 'multi-select' + const map = (options, parent) => { + for (const opt of (options || [])) { + if (opt.children?.length) { + const elGroup = document.createElement('optgroup') + elGroup.label = opt.label + map(opt.children, elGroup) + parent.appendChild(elGroup) + } else { + const option = document.createElement('option') + option.value = opt.value + option.innerText = opt.label + option.selected = Array.isArray(value) ? value.includes(opt.value) : (value === opt.value) + parent.appendChild(option) + } + } + } + map(item.options || [], select) + select.addEventListener('change', ({ target }) => { + let { value } = target + if (item.type === 'multi-select') { + value = Array.from(target.selectedOptions).map(({ value }) => value) + } + formData[item.name] = value + invoke('changeSettings', formData) + }) + wrapper.appendChild(select) + label.appendChild(wrapper) + } else if ( + item.type === 'cell' || + item.type === 'page' + ) { + label.classList.add('form-item--link') + const icon = document.createElement('i') + icon.className = 'iconfont icon-arrow_right' + label.appendChild(icon) + label.addEventListener('click', () => { + const { name } = item + switch (name) { + case 'backgroundImage': + invoke('chooseBgImg') + break + case 'clearBackgroundImage': + invoke('clearBgImg') + break + case 'reset': + reset() + break + default: + invoke('itemClick', item) + } + }) + } else { + const input = document.createElement(item.type ==='textarea' ? 'textarea' : "input") + input.className = 'form-item__input' + input.name = item.name + input.type = item.type || "text"; + input.enterKeyHint = item.type ==='textarea' ? 'enter' : 'done' + if (item.type === 'textarea') input.rows = '1' + input.value = value + // Switch + if (item.type === 'switch') { + input.type = 'checkbox' + input.role = 'switch' + input.checked = value + if (item.name === 'useICloud') { + input.addEventListener('change', (e) => { + invoke('moveSettings', e.target.checked) + }) + } + } + if (item.type === 'number') { + input.inputMode = 'decimal' + } + if (input.type === 'text' || input.type === 'textarea') { + input.size = 12 + } + input.addEventListener("change", (e) => { + formData[item.name] = + item.type === 'switch' + ? e.target.checked + : item.type === 'number' + ? Number(e.target.value) + : e.target.value; + invoke('changeSettings', formData) + }); + label.appendChild(input); + } + return label + } + + const createList = (list, title) => { + const fragment = document.createDocumentFragment() + + let elBody; + for (const item of list) { + if (item.type === 'group') { + const grouped = createList(item.items, item.label) + fragment.appendChild(grouped) + } else { + if (!elBody) { + const groupDiv = fragment.appendChild(document.createElement('div')) + groupDiv.className = 'list' + if (title) { + const elTitle = groupDiv.appendChild(document.createElement('div')) + elTitle.className = 'list__header' + elTitle.textContent = title + } + elBody = groupDiv.appendChild(document.createElement('div')) + elBody.className = 'list__body' + } + const label = createFormItem(item) + elBody.appendChild(label) + } + } + return fragment + } + + const fragment = createList(formItems) + document.getElementById('settings').appendChild(fragment) + + for (const btn of document.querySelectorAll('.preview')) { + btn.addEventListener('click', (e) => { + const target = e.currentTarget + target.classList.add('loading') + const icon = e.currentTarget.querySelector('.iconfont') + const className = icon.className + icon.className = 'iconfont icon-loading' + invoke( + 'preview', + e.currentTarget.dataset.size, + () => { + target.classList.remove('loading') + icon.className = className + } + ) + }) + } + + const setFieldValue = (name, value) => { + const input = document.querySelector(\`.form-item__input[name="\${name}"]\`) + if (!input) return + if (input.type === 'checkbox') { + input.checked = value + } else { + input.value = value + } + } + + const reset = (items = formItems) => { + for (const item of items) { + if (item.type === 'group') { + reset(item.items) + } else if (item.type === 'page') { + continue; + } else { + setFieldValue(item.name, item.default) + } + } + invoke('removeSettings', formData) + } +})()`; + + const html = +`<html> + <head> + <meta name='viewport' content='width=device-width, user-scalable=no'> + <link rel="stylesheet" href="//at.alicdn.com/t/c/font_3772663_kmo790s3yfq.css" type="text/css"> + <style>${style}</style> + </head> + <body> + ${head || ''} + <section id="settings"></section> + ${isFirstPage ? (previewsHTML + copyrightHTML) : ''} + <script>${js}</script> + </body> +</html>`; + + const webView = new WebView(); + const methods = { + async preview (data) { + const widget = await getWidget({ settings, family: data }); + widget[`present${data.replace(data[0], data[0].toUpperCase())}`](); + }, + safari (data) { + Safari.openInApp(data, true); + }, + changeSettings (data) { + Object.assign(settings, data); + writeSettings(settings, { useICloud: settings.useICloud }); + }, + moveSettings (data) { + settings.useICloud = data; + moveSettings(data, settings); + }, + removeSettings (data) { + Object.assign(settings, data); + clearBgImg(); + removeSettings(settings); + }, + chooseBgImg (data) { + chooseBgImg(); + }, + clearBgImg () { + clearBgImg(); + }, + async itemClick (data) { + if (data.type === 'page') { + // `data` 经传到 HTML 后丢失了不可序列化的数据,因为需要从源数据查找 + const item = (() => { + const find = (items) => { + for (const el of items) { + if (el.name === data.name) return el + + if (el.type === 'group') { + const r = find(el.items); + if (r) return r + } + } + return null + }; + return find(formItems) + })(); + await present(item, false, { settings }); + } else { + await onItemClick?.(data, { settings }); + } + }, + native (data) { + return onWebEvent?.(data) + } + }; + await loadHTML( + webView, + { html, baseURL: homePage }, + { methods } + ); + + const clearBgImg = () => { + const { backgroundImage } = settings; + delete settings.backgroundImage; + if (backgroundImage && fm.fileExists(backgroundImage)) { + fm.remove(backgroundImage); + } + writeSettings(settings, { useICloud: settings.useICloud }); + toast(i18n(['Cleared success!', '背景已清除'])); + }; + + const chooseBgImg = async () => { + try { + const image = await Photos.fromLibrary(); + cache.writeImage('bg.png', image); + const imgPath = fm.joinPath(cache.cacheDirectory, 'bg.png'); + settings.backgroundImage = imgPath; + writeSettings(settings, { useICloud: settings.useICloud }); + } catch (e) { + console.log('[info] 用户取消选择图片'); + } + }; + + webView.present(); + // ======= web end ========= +}; + +/** + * @param {Options} options + */ +const withSettings = async (options) => { + const { formItems, onItemClick, ...restOptions } = options; + return present({ + formItems: [ + { + label: i18n(['Common', '通用']), + type: 'group', + items: [ + { + label: i18n(['Sync with iCloud', '云盘同步']), + type: 'switch', + name: 'useICloud', + default: false + }, + { + label: i18n(['Background', '个性背景']), + type: 'page', + name: 'background', + formItems: [ + { + label: i18n(['Background', '个性背景']), + type: 'group', + items: [ + { + name: 'backgroundColorLight', + type: 'color', + label: i18n(['Background color', '背景色']), + media: '(prefers-color-scheme: light)', + default: '#ffffff' + }, + { + name: 'backgroundColorDark', + type: 'color', + label: i18n(['Background color', '背景色']), + media: '(prefers-color-scheme: dark)', + default: '#242426' + }, + { + label: i18n(['Background image', '背景图']), + type: 'cell', + name: 'backgroundImage' + } + ] + }, + { + type: 'group', + items: [ + { + label: i18n(['Clear background image', '清除背景图']), + type: 'cell', + name: 'clearBackgroundImage' + } + ] + } + ] + }, + { + label: i18n(['Reset', '恢复背景']), + type: 'cell', + name: 'reset' + } + ] + }, + { + label: i18n(['Settings', '设置']), + type: 'group', + items: formItems + } + ], + onItemClick: (item, ...args) => { + onItemClick?.(item, ...args); + }, + ...restOptions + }, true) +}; + +const preference = { + titleFont: 'DFPKanTingLiuW9-GB', + text: 'MOMO\nMIANMIAN', + textColor: '#1e1f24', + textColorDark: '#ffffff', + secondaryColor: '#80828d', + secondaryColorDark: '#b3b3bd', + imageRadius: 6, + useSlideshow: false, + photoFolder: 'WidgetPhotos', + randomLayout: false, + faceFocus: true +}; + +// 扫描 iCloud 文档目录下的子文件夹列表 +const getICloudFolders = () => { + try { + const fm = FileManager.iCloud(); + const root = fm.documentsDirectory(); + const files = fm.listContents(root); + const dirs = files.filter(name => !name.startsWith('.') && fm.isDirectory(fm.joinPath(root, name))); + if (!dirs.includes('WidgetPhotos')) { + dirs.unshift('WidgetPhotos'); + } + return dirs.map(d => ({ label: `📁 ${d}`, value: d })); + } catch (e) { + return [{ label: '📁 WidgetPhotos', value: 'WidgetPhotos' }]; + } +}; + +const rpt = (n) => vmin(n * 100 / 329, config.widgetFamily); +const cache = useCache(); + +const getPhoto = async (filename) => { + const image = cache.readImage(filename); + return image +}; + +const $12Animals = { + 子: '鼠', + 丑: '牛', + 寅: '虎', + 卯: '兔', + 辰: '龙', + 巳: '蛇', + 午: '马', + 未: '羊', + 申: '猴', + 酉: '鸡', + 戌: '狗', + 亥: '猪' +}; + +// 获取图片文件夹路径 +function getPhotosDirectory() { + const fm = FileManager.iCloud(); + const folderName = preference.photoFolder || 'WidgetPhotos'; + const dir = fm.joinPath(fm.documentsDirectory(), folderName); + if (!fm.fileExists(dir)) fm.createDirectory(dir, true); + return dir; +} + +// 随机获取指定数量的图片 +async function getRandomPhotos(count) { + const fm = FileManager.iCloud(); + const dir = getPhotosDirectory(); + const validExtensions = ['.jpg', '.JPG', '.png', '.PNG']; + + // 过滤有效的图片文件 + const files = fm.listContents(dir).filter(file => + validExtensions.some(ext => file.endsWith(ext)) + ); + + if (files.length < count) { + console.log("图片数量不足,请上传更多图片!"); + return []; + } + + // 随机选择不重复的图片 + const selected = new Set(); + while (selected.size < count) { + const randomIndex = Math.floor(Math.random() * files.length); + selected.add(files[randomIndex]); + } + + return Array.from(selected).map(file => fm.readImage(`${dir}/${file}`)); +} + +/** + * @param {number} index + */ +const choosePhoto = async (index) => { + const image = await Photos.fromLibrary(); + const filename = `photo_${index}`; + cache.writeImage(filename, image); +}; + +// ==================== 安全获取图片辅助函数 ==================== +const getSafePhotos = async (count) => { + const { useSlideshow } = preference; + const list = []; + if (useSlideshow) { + try { + const randoms = await getRandomPhotos(count); + if (randoms && randoms.length > 0) { + list.push(...randoms); + } + } catch (e) { + console.log('获取轮巡图片失败: ' + e); + } + } + + // 补齐指定图 + for (let i = 1; i <= 4; i++) { + try { + const img = await getPhoto(`photo_${i}`); + if (img) list.push(img); + } catch (e) {} + } + + // 如果完全没有任何图,生成优雅的占位图防止崩溃 + if (list.length === 0) { + const draw = new DrawContext(); + draw.size = new Size(100, 100); + draw.setFillColor(new Color('#3a3a3c')); + draw.fill(new Rect(0, 0, 100, 100)); + list.push(draw.getImage()); + } + + // 循环填满所需数量 + const result = []; + for (let i = 0; i < count; i++) { + result.push(list[i % list.length]); + } + return result; +}; + + +// ==================== 自适应面容高亮识别与智能聚焦裁剪引擎 ==================== +// ==================== 自适应视觉重心阻尼与智能多维聚焦引擎 ==================== +/** + * 高级智能视觉重心与长宽比自适应聚焦裁剪 + * 彻底解决特写削下巴、远景抓天空、极端比例走样的痛点 + * + * @param {Image} image 原始图片 + * @param {number} targetW 目标展示宽 + * @param {number} targetH 目标展示高 + * @param {boolean} enableFocus 是否开启智能聚焦 + * @returns {Image} 完美对齐的超高清图片 + */ +const smartCropImage = (image, targetW, targetH, enableFocus = true) => { + if (!image) return image; + try { + const imgW = image.size.width; + const imgH = image.size.height; + const scale = Math.max(targetW / imgW, targetH / imgH); + const scaledW = imgW * scale; + const scaledH = imgH * scale; + + let offsetX = (targetW - scaledW) / 2; + let offsetY = (targetH - scaledH) / 2; + + if (enableFocus) { + // 1. 竖向裁剪余量分析 + if (scaledH > targetH) { + const excessH = scaledH - targetH; // 被裁切掉的多余高度 + const imgAspect = imgW / imgH; // 原图宽高比(<0.75 为极窄长图,~1.0 为正方,>1.2 为宽横图) + const slotAspect = targetW / targetH; // 当前展示框的宽高比 + + // 2. 动态视觉重心锚点推算(取代过去固定死板的 0.28) + // - 横图/宽幅(imgAspect >= 1.2):主体通常偏向画面中部偏下(0.38 ~ 0.42) + // - 标准半身(0.75 <= imgAspect < 1.2):经典人像黄金分割位(0.32 ~ 0.35) + // - 狭长全身照(imgAspect < 0.75):头顶通常在画面的更靠上位置(0.24 ~ 0.28) + let anchorRatio = 0.33; + if (imgAspect >= 1.25) { + anchorRatio = 0.40; + } else if (imgAspect <= 0.65) { + anchorRatio = 0.26; + } else { + anchorRatio = 0.26 + (imgAspect - 0.65) * (0.14 / 0.60); + } + + // 3. 裁剪剧烈程度阻尼衰减(Damping) + // 如果当前是特写近景(裁剪余量占整图高度比例很小,说明几乎是原比例微裁), + // 或者展示框本身也是宽扁横框(slotAspect > 1.1), + // 此时若强制向上拉升会极易"切掉下巴和锁骨"! + // 引入阻尼因子:当裁剪余量占总高度比例较小时,让重心自然趋向正中央! + const cropSeverity = excessH / scaledH; // 0 (无裁剪) ~ 0.8 (极度深裁) + const dampingFactor = Math.min(1.0, Math.max(0.15, cropSeverity * 1.8)); + + // 计算理想偏移(中心加权阻尼) + const naturalCenterY = (targetH - scaledH) / 2; + const targetAnchorY = targetH * (slotAspect < 0.8 ? 0.36 : 0.42); // 目标视口中的面部最佳停留高度 + const idealOffsetY = targetAnchorY - (scaledH * anchorRatio); + + // 混合阻尼:在自然居中与黄金锚点之间做柔和插值过渡 + offsetY = naturalCenterY * (1 - dampingFactor) + idealOffsetY * dampingFactor; + + // 4. 物理防露底硬性约束 + offsetY = Math.min(0, Math.max(targetH - scaledH, offsetY)); + } + + // 横向有裁剪空间时,保持居中对齐 + if (scaledW > targetW) { + offsetX = (targetW - scaledW) / 2; + } + } + + const ctx = new DrawContext(); + ctx.opaque = false; + ctx.respectScreenScale = true; // @3x 视网膜高清输出 + ctx.size = new Size(targetW, targetH); + ctx.drawImageInRect(image, new Rect(offsetX, offsetY, scaledW, scaledH)); + return ctx.getImage(); + } catch (e) { + console.log('智能裁切失败,降级原图: ' + e); + return image; + } +}; + + +// ==================== 智能画幅与槽位最佳几何匹配引擎 ==================== +/** + * 自动根据图片与卡槽的长宽比进行最优化分配 + * 保证横屏大片自动进入横向/方框展示位,长竖自拍自动进入纵向长条,彻底根除横图被切成牙签细条的尴尬 + */ +const matchImagesToSlots = (images, slotSizes) => { + if (!images || images.length <= 1 || !slotSizes || slotSizes.length <= 1) { + return images; + } + const n = Math.min(images.length, slotSizes.length); + const imgAspects = images.slice(0, n).map(img => { + if (!img || !img.size || !img.size.height) return 1.0; + return img.size.width / img.size.height; + }); + const slotAspects = slotSizes.slice(0, n).map(s => { + if (!s || !s.height) return 1.0; + return s.width / s.height; + }); + + // 生成 0..n-1 的全排列 + const permute = (arr) => { + if (arr.length <= 1) return [arr]; + const res = []; + for (let i = 0; i < arr.length; i++) { + const rest = [...arr.slice(0, i), ...arr.slice(i + 1)]; + for (const p of permute(rest)) { + res.push([arr[i], ...p]); + } + } + return res; + }; + + const perms = permute(Array.from({ length: n }, (_, i) => i)); + let bestPerm = perms[0]; + let minCost = Infinity; + + for (const perm of perms) { + let cost = 0; + for (let slotIdx = 0; slotIdx < n; slotIdx++) { + const imgIdx = perm[slotIdx]; + const ia = imgAspects[imgIdx]; + const sa = slotAspects[slotIdx]; + let diff = Math.abs(ia - sa); + // 如果横图塞进极窄竖框,或细长竖图塞进极扁横框,施加重惩罚 + if ((ia > 1.1 && sa < 0.65) || (ia < 0.65 && sa > 1.1)) { + diff *= 3.5; + } + cost += diff; + } + if (cost < minCost) { + minCost = cost; + bestPerm = perm; + } + } + + // 按最优匹配重排图片返回 + const matched = bestPerm.map(i => images[i]); + // 补全多余未参与匹配的图片 + for (let i = n; i < images.length; i++) { + matched.push(images[i]); + } + return matched; +}; + +// ==================== 多风格照片墙排版引擎 ==================== + +/** + * 渲染照片单元 + * @param {WidgetStack} container + * @param {Image[]} images + * @param {number} groupWidth + * @param {number} groupHeight + * @param {number} cornerRadius + * @param {number} gap + * @param {boolean} forceRandom 是否启用随机排版 + */ +const renderPhotoGroup = (container, images, groupWidth, groupHeight, cornerRadius, gap = 4, forceRandom = false) => { + const group = container.addStack(); + group.size = new Size(groupWidth, groupHeight); + group.centerAlignContent(); + + const addImg = (stack, img, w, h, skipCrop = false) => { + stack.size = new Size(w, h); + stack.cornerRadius = cornerRadius; + // 百叶窗切片等已有绝对像素画面的图跳过二次裁切;普通完整单图进行智能面容聚焦裁切 + const finalImg = (!skipCrop && preference.faceFocus) ? smartCropImage(img, w, h, true) : img; + const item = stack.addImage(finalImg); + item.imageSize = new Size(w, h); + item.applyFillingContentMode(); + return item; + }; + + // 风格 0: 经典排版(双竖条 + 上下双框,支持列随机,已集成智能画幅匹配) + const renderStyle0 = () => { + group.layoutHorizontally(); + const gapX = gap; + const gapY = gap; + const availableW = groupWidth - gapX * 2; + const w1 = Math.floor(availableW * (80 / 285)); + const w2 = w1; + const w3 = Math.max(1, availableW - w1 - w2); + + const availableH = groupHeight - gapY; + let hTopRatio = 121 / 221; + if (forceRandom && Math.random() > 0.5) hTopRatio = 100 / 221; + const hTop = Math.floor(availableH * hTopRatio); + const hBottom = Math.max(1, availableH - hTop); + + // 智能画幅分配:Slot 0,1 为竖窄框,Slot 2,3 为横/方框 + // 自动将横屏大片(如落日人物照)分到 Slot 2/3,长身竖拍自拍照分到 Slot 0/1! + const slotSlots = [ + { width: w1, height: groupHeight }, + { width: w2, height: groupHeight }, + { width: w3, height: hTop }, + { width: w3, height: hBottom } + ]; + const orderedImages = matchImagesToSlots(images, slotSlots); + + let pattern = 0; + if (forceRandom) pattern = Math.floor(Math.random() * 3); + + const drawSingle = (img, w) => { + const s = group.addStack(); + addImg(s, img, w, groupHeight); + }; + + const drawDouble = (imgT, imgB, w) => { + const col = group.addStack(); + col.layoutVertically(); + col.size = new Size(w, groupHeight); + const sT = col.addStack(); + addImg(sT, imgT, w, hTop); + col.addSpacer(gapY); + const sB = col.addStack(); + addImg(sB, imgB, w, hBottom); + }; + + if (pattern === 1) { + drawDouble(orderedImages[2], orderedImages[3], w3); + group.addSpacer(gapX); + drawSingle(orderedImages[0], w1); + group.addSpacer(gapX); + drawSingle(orderedImages[1], w2); + } else if (pattern === 2) { + drawSingle(orderedImages[0], w1); + group.addSpacer(gapX); + drawDouble(orderedImages[2], orderedImages[3], w3); + group.addSpacer(gapX); + drawSingle(orderedImages[1], w2); + } else { + drawSingle(orderedImages[0], w1); + group.addSpacer(gapX); + drawSingle(orderedImages[1], w2); + group.addSpacer(gapX); + drawDouble(orderedImages[2], orderedImages[3], w3); + } + }; + + // 风格 1: 大焦点主图 + 双竖条画廊(主次分明,已集成智能画幅匹配) + const renderStyle1 = () => { + group.layoutHorizontally(); + const gapX = gap; + const availableW = groupWidth - gapX * 2; + const mainW = Math.floor(availableW * 0.52); + const subW = Math.floor((availableW - mainW) / 2); + const isMainLeft = Math.random() > 0.5; + + const slotSlots = [ + { width: mainW, height: groupHeight }, + { width: subW, height: groupHeight }, + { width: subW, height: groupHeight } + ]; + const orderedImages = matchImagesToSlots(images, slotSlots); + + const drawMain = (img) => { + const s = group.addStack(); + addImg(s, img, mainW, groupHeight); + }; + const drawSub = (img) => { + const s = group.addStack(); + addImg(s, img, subW, groupHeight); + }; + + if (isMainLeft) { + drawMain(orderedImages[0]); + group.addSpacer(gapX); + drawSub(orderedImages[1]); + group.addSpacer(gapX); + drawSub(orderedImages[2]); + } else { + drawSub(orderedImages[1]); + group.addSpacer(gapX); + drawSub(orderedImages[2]); + group.addSpacer(gapX); + drawMain(orderedImages[0]); + } + }; + + // 风格 2: 黄金四宫格 / 错落田字格 + const renderStyle2 = () => { + group.layoutHorizontally(); + const gapX = gap; + const gapY = gap; + const colW = Math.floor((groupWidth - gapX) / 2); + const availableH = groupHeight - gapY; + + const ratio1 = Math.random() > 0.5 ? 0.58 : 0.5; + const ratio2 = ratio1 === 0.5 ? 0.5 : (1 - ratio1); + + const hLTop = Math.floor(availableH * ratio1); + const hLBottom = availableH - hLTop; + const hRTop = Math.floor(availableH * ratio2); + const hRBottom = availableH - hRTop; + + const colL = group.addStack(); + colL.layoutVertically(); + colL.size = new Size(colW, groupHeight); + const sLT = colL.addStack(); + addImg(sLT, images[0], colW, hLTop); + colL.addSpacer(gapY); + const sLB = colL.addStack(); + addImg(sLB, images[1], colW, hLBottom); + + group.addSpacer(gapX); + + const colR = group.addStack(); + colR.layoutVertically(); + colR.size = new Size(colW, groupHeight); + const sRT = colR.addStack(); + addImg(sRT, images[2], colW, hRTop); + colR.addSpacer(gapY); + const sRB = colR.addStack(); + addImg(sRB, images[3], colW, hRBottom); + }; + + // 风格 3: 杂志封面画册风(全宽大横条 + 三并排竖图) + const renderStyle3 = () => { + group.layoutVertically(); + const gapY = gap; + const gapX = gap; + const bannerH = Math.floor((groupHeight - gapY) * 0.42); + const rowH = groupHeight - gapY - bannerH; + const subW = Math.floor((groupWidth - gapX * 2) / 3); + const isBannerTop = Math.random() > 0.5; + + const drawBanner = (img) => { + const s = group.addStack(); + addImg(s, img, groupWidth, bannerH); + }; + + const drawRow = (img1, img2, img3) => { + const row = group.addStack(); + row.layoutHorizontally(); + row.size = new Size(groupWidth, rowH); + const s1 = row.addStack(); + addImg(s1, img1, subW, rowH); + row.addSpacer(gapX); + const s2 = row.addStack(); + addImg(s2, img2, subW, rowH); + row.addSpacer(gapX); + const s3 = row.addStack(); + addImg(s3, img3, subW, rowH); + }; + + if (isBannerTop) { + drawBanner(images[0]); + group.addSpacer(gapY); + drawRow(images[1], images[2], images[3]); + } else { + drawRow(images[1], images[2], images[3]); + group.addSpacer(gapY); + drawBanner(images[0]); + } + }; + + // 风格 4: 瀑布流双列三图(单长竖图 + 双横图,已集成智能画幅匹配) + const renderStyle4 = () => { + group.layoutHorizontally(); + const gapX = gap; + const gapY = gap; + const leftW = Math.floor((groupWidth - gapX) * 0.42); + const rightW = groupWidth - gapX - leftW; + const halfH = Math.floor((groupHeight - gapY) / 2); + const otherH = groupHeight - gapY - halfH; + const isLeftSingle = Math.random() > 0.5; + + const singleW = isLeftSingle ? leftW : rightW; + const doubleW = isLeftSingle ? rightW : leftW; + const slotSlots = [ + { width: singleW, height: groupHeight }, // 长竖 + { width: doubleW, height: halfH }, // 上横 + { width: doubleW, height: otherH } // 下横 + ]; + const orderedImages = matchImagesToSlots(images, slotSlots); + + const drawSingle = (img, w) => { + const s = group.addStack(); + addImg(s, img, w, groupHeight); + }; + + const drawDouble = (img1, img2, w) => { + const col = group.addStack(); + col.layoutVertically(); + col.size = new Size(w, groupHeight); + const s1 = col.addStack(); + addImg(s1, img1, w, halfH); + col.addSpacer(gapY); + const s2 = col.addStack(); + addImg(s2, img2, w, otherH); + }; + + if (isLeftSingle) { + drawSingle(orderedImages[0], leftW); + group.addSpacer(gapX); + drawDouble(orderedImages[1], orderedImages[2], rightW); + } else { + drawDouble(orderedImages[1], orderedImages[2], rightW); + group.addSpacer(gapX); + drawSingle(orderedImages[0], leftW); + } + }; + + // 特效渲染辅助:为切片赋予折面立体阴影、虚面磨砂或浮雕高光 + const applyBlindsEffect = (ctx, w, h, effectType, index) => { + if (effectType === 'fold') { + // 折面立面光影(模拟百叶窗折角 15°立体屏风感) + // 在单侧绘制细腻柔和的微阴影 + const shadowW = Math.min(16, Math.floor(w * 0.28)); + const steps = 8; + for (let s = 0; s < steps; s++) { + const alpha = 0.28 * (1 - s / steps); + ctx.setFillColor(new Color('#000000', alpha)); + // 交替向左折或向右折 + const xPos = (index % 2 === 0) ? (w - shadowW + s * (shadowW / steps)) : (s * (shadowW / steps)); + ctx.fill(new Rect(xPos, 0, shadowW / steps + 1, h)); + } + } else if (effectType === 'frosted' && index === 1) { + // 虚面磨砂(挑选一根副条加上柔和的朦胧磨砂雾化质感) + ctx.setFillColor(new Color('#ffffff', 0.38)); + ctx.fill(new Rect(0, 0, w, h)); + } else if (effectType === 'emboss') { + // 浮雕微立体(边缘高光与投影) + ctx.setFillColor(new Color('#ffffff', 0.25)); + ctx.fill(new Rect(0, 0, 2, h)); // 左侧柔白微高光 + ctx.setFillColor(new Color('#000000', 0.20)); + ctx.fill(new Rect(w - 2, 0, 2, h)); // 右侧微柔阴影 + } + }; + + // 风格 5: 垂直百叶窗(支持粗细混搭、折面立体、虚面磨砂、浮雕高光) + const renderStyle5 = () => { + group.layoutHorizontally(); + const gapX = gap; + + const rhythms = [ + [0.50, 0.25, 0.25], + [0.22, 0.56, 0.22], + [0.20, 0.32, 0.48], + [0.48, 0.32, 0.20], + [1/3, 1/3, 1/3] + ]; + const selectedRhythm = rhythms[Math.floor(Math.random() * rhythms.length)]; + const sliceCount = selectedRhythm.length; + const availableW = groupWidth - gapX * (sliceCount - 1); + + const widths = selectedRhythm.map(r => Math.floor(availableW * r)); + const diffW = availableW - widths.reduce((a, b) => a + b, 0); + widths[widths.length - 1] += diffW; + + // 特效选择:'fold'(折面) | 'frosted'(虚面) | 'emboss'(浮雕) | 'none'(平整),绝不错位 + const effects = ['fold', 'frosted', 'emboss', 'none']; + const currentEffect = effects[Math.floor(Math.random() * effects.length)]; + + const baseImg = images[0]; + const imgW = baseImg.size.width; + const imgH = baseImg.size.height; + + const scale = Math.max(groupWidth / imgW, groupHeight / imgH); + const scaledW = imgW * scale; + const scaledH = imgH * scale; + const baseOffsetX = (groupWidth - scaledW) / 2; + let baseOffsetY = (groupHeight - scaledH) / 2; + if (preference.faceFocus && scaledH > groupHeight) { + baseOffsetY = Math.min(0, Math.max(groupHeight - scaledH, groupHeight * 0.35 - scaledH * 0.28)); + } + + let currentX = 0; + for (let i = 0; i < sliceCount; i++) { + const w = widths[i]; + const s = group.addStack(); + + const ctx = new DrawContext(); + ctx.opaque = false; + ctx.respectScreenScale = true; + ctx.size = new Size(w, groupHeight); + // 水平基准完全对齐,保证画面严丝合缝 + ctx.drawImageInRect(baseImg, new Rect(baseOffsetX - currentX, baseOffsetY, scaledW, scaledH)); + + // 叠加特效光影/虚面/浮雕 + applyBlindsEffect(ctx, w, groupHeight, currentEffect, i); + + const slicedImg = ctx.getImage(); + addImg(s, slicedImg, w, groupHeight, true); // true: 跳过二次智能裁切 + currentX += w + gapX; + + if (i < sliceCount - 1) { + group.addSpacer(gapX); + } + } + }; + + // 风格 6: 水平百叶窗(支持粗细混搭、折面横条阴影、浮雕立体、虚面磨砂) + const renderStyle6 = () => { + group.layoutVertically(); + const gapY = gap; + + const rhythms = [ + [0.52, 0.24, 0.24], + [0.22, 0.56, 0.22], + [0.24, 0.24, 0.52], + [1/3, 1/3, 1/3] + ]; + const selectedRhythm = rhythms[Math.floor(Math.random() * rhythms.length)]; + const sliceCount = selectedRhythm.length; + const availableH = groupHeight - gapY * (sliceCount - 1); + + const heights = selectedRhythm.map(r => Math.floor(availableH * r)); + const diffH = availableH - heights.reduce((a, b) => a + b, 0); + heights[heights.length - 1] += diffH; + + const effects = ['fold', 'frosted', 'emboss', 'none']; + const currentEffect = effects[Math.floor(Math.random() * effects.length)]; + + const baseImg = images[0]; + const imgW = baseImg.size.width; + const imgH = baseImg.size.height; + + const scale = Math.max(groupWidth / imgW, groupHeight / imgH); + const scaledW = imgW * scale; + const scaledH = imgH * scale; + const baseOffsetX = (groupWidth - scaledW) / 2; + let baseOffsetY = (groupHeight - scaledH) / 2; + if (preference.faceFocus && scaledH > groupHeight) { + baseOffsetY = Math.min(0, Math.max(groupHeight - scaledH, groupHeight * 0.35 - scaledH * 0.28)); + } + + let currentY = 0; + for (let i = 0; i < sliceCount; i++) { + const h = heights[i]; + const s = group.addStack(); + + const ctx = new DrawContext(); + ctx.opaque = false; + ctx.respectScreenScale = true; + ctx.size = new Size(groupWidth, h); + ctx.drawImageInRect(baseImg, new Rect(baseOffsetX, baseOffsetY - currentY, scaledW, scaledH)); + + // 水平特效 + if (currentEffect === 'fold') { + const shadowH = Math.min(12, Math.floor(h * 0.3)); + for (let step = 0; step < 6; step++) { + const alpha = 0.26 * (1 - step / 6); + ctx.setFillColor(new Color('#000000', alpha)); + ctx.fill(new Rect(0, h - shadowH + step * (shadowH / 6), groupWidth, shadowH / 6 + 1)); + } + } else if (currentEffect === 'frosted' && i === 1) { + ctx.setFillColor(new Color('#ffffff', 0.35)); + ctx.fill(new Rect(0, 0, groupWidth, h)); + } else if (currentEffect === 'emboss') { + ctx.setFillColor(new Color('#ffffff', 0.22)); + ctx.fill(new Rect(0, 0, groupWidth, 2)); + ctx.setFillColor(new Color('#000000', 0.18)); + ctx.fill(new Rect(0, h - 2, groupWidth, 2)); + } + + const slicedImg = ctx.getImage(); + addImg(s, slicedImg, groupWidth, h, true); // true: 跳过二次智能裁切 + currentY += h + gapY; + + if (i < sliceCount - 1) { + group.addSpacer(gapY); + } + } + }; + + // 未启用随机排版:100% 走经典原版风格 + if (!forceRandom) { + return renderStyle0(); + } + + // 开启随机排版:从 7 种排版形态(含垂直与水平百叶窗)中随机抽取 + const styleChoice = Math.floor(Math.random() * 7); + switch (styleChoice) { + case 0: + renderStyle0(); + break; + case 1: + renderStyle1(); + break; + case 2: + renderStyle2(); + break; + case 3: + renderStyle3(); + break; + case 4: + renderStyle4(); + break; + case 5: + renderStyle5(); + break; + case 6: + renderStyle6(); + break; + default: + renderStyle0(); + } +}; + +// ==================== 小号组件 ==================== +const createSmallWidget = async () => { + const radius = preference.imageRadius > 0 ? preference.imageRadius : 6; + const widget = new ListWidget(); + widget.backgroundColor = Color.dynamic(new Color('#ffffff'), new Color('#19191b')); + + const padding = 10; + widget.setPadding(padding, padding, padding, padding); + + const size = widgetSize(); + const w = size.small || 155; + const contentW = w - padding * 2; + const contentH = w - padding * 2; + + const images = await getSafePhotos(4); + renderPhotoGroup(widget, images, contentW, contentH, radius, 3, preference.randomLayout); + return widget; +}; + +// ==================== 中号组件 ==================== +const createMediumWidget = async () => { + const radius = preference.imageRadius > 0 ? preference.imageRadius : 6; + const widget = new ListWidget(); + widget.backgroundColor = Color.dynamic(new Color('#ffffff'), new Color('#19191b')); + + const size = widgetSize(); + const w = size.medium || 329; + const h = size.small || 155; + + const paddingX = 10; + const paddingY = 8; + widget.setPadding(paddingY, paddingX, paddingY, paddingX); + + const contentW = w - paddingX * 2; + const contentH = h - paddingY * 2; + + const groupGap = 4; + const singleGroupW = Math.floor((contentW - groupGap) / 2); + + const mainStack = widget.addStack(); + mainStack.layoutHorizontally(); + mainStack.centerAlignContent(); + + const images = await getSafePhotos(8); + + // 第 1 组照片墙 + renderPhotoGroup(mainStack, images.slice(0, 4), singleGroupW, contentH, radius, 4, preference.randomLayout); + + mainStack.addSpacer(groupGap); + + // 第 2 组照片墙 + renderPhotoGroup(mainStack, images.slice(4, 8), singleGroupW, contentH, radius, 4, preference.randomLayout); + + return widget; +}; +// ==================== 大号组件 ==================== +const createLargeWidgetOriginal = async () => { + const { text, titleFont, imageRadius, useSlideshow } = preference; + const textColor = Color.dynamic( + new Color(preference.textColor), + new Color(preference.textColorDark) + ); + const secondaryColor = Color.dynamic( + new Color(preference.secondaryColor), + new Color(preference.secondaryColorDark) + ); + const widget = new ListWidget(); + widget.backgroundColor = Color.dynamic(new Color('#fff'), new Color('#19191b')); + widget.setPadding(0, rpt(16), 0, 0); + + const head = widget.addStack(); + const title = head.addText(text); + title.font = titleFont ? new Font(titleFont, rpt(28)) : Font.boldSystemFont(rpt(28)); + title.textColor = textColor; + + const now = new Date(); + head.addSpacer(); + head.centerAlignContent(); + const rightWrap = head.addStack(); + // rightWrap 宽需和 right 的宽一致 + rightWrap.size = new Size(rpt(138), -1); + // 此处大小是下面 cornerRadius 的两倍 + rightWrap.addSpacer(rpt(24)); + const right = rightWrap.addStack(); + right.size = new Size(rpt(138), -1); + right.backgroundColor = Color.dynamic(new Color('#F3F1F8'), new Color('#282829')); + right.cornerRadius = rpt(12); + right.centerAlignContent(); + right.setPadding(0, 0, 0, rpt(24)) + + const date = right.addText(`${now.getDate()}`.padStart(2, '0')); + date.font = new Font('DIN Alternate', rpt(38)); + date.textColor = textColor; + right.addSpacer(rpt(6)); + const rr = right.addStack(); + rr.layoutVertically(); + + const secondaryTextSize = rpt(10); + const { lunarYear, lunarMonth, lunarDay } = sloarToLunar( + now.getFullYear(), + now.getMonth() + 1, + now.getDate() + ); + const monthDf = new DateFormatter(); + monthDf.locale = 'zh-CN'; + monthDf.dateFormat = 'MMMM'; + const weekDf = new DateFormatter(); + weekDf.locale = 'zh-CN'; + weekDf.dateFormat = 'E'; + const monthWeek = rr.addText(`${monthDf.string(now)}|${weekDf.string(now)}`); + monthWeek.font = Font.systemFont(secondaryTextSize); + monthWeek.textColor = secondaryColor; + rr.addSpacer(rpt(4)); + const l = rr.addText(`${$12Animals[lunarYear[1]]}年${lunarMonth}月${lunarDay}`); + l.font = Font.systemFont(secondaryTextSize); + l.textColor = secondaryColor; + + widget.addSpacer(rpt(12)); + const photos = widget.addStack(); + photos.layoutHorizontally(); + photos.centerAlignContent(); + + const addOne = async (filename) => { + const size = new Size(rpt(80), rpt(152)); + const stack = photos.addStack(); + stack.size = size; + stack.cornerRadius = imageRadius; + + const image = stack.addImage(useSlideshow ? filename : await getPhoto(filename)); + image.imageSize = size; + image.applyFillingContentMode(); + return image + }; + + const photo = await getRandomPhotos(4); + + await addOne(useSlideshow ? photo[0] : 'photo_1'); + + photos.addSpacer(rpt(4)); + await addOne(useSlideshow ? photo[1] : 'photo_2'); + + photos.addSpacer(rpt(8)); + const photosRight = photos.addStack(); + photosRight.layoutVertically(); + + const third = photosRight.addStack(); + const thirdSize = new Size(rpt(125), rpt(121)); + third.size = thirdSize; + third.cornerRadius = imageRadius; + const thirdImage = third.addImage(useSlideshow ? photo[2] : await getPhoto('photo_3')); + thirdImage.imageSize = thirdSize; + thirdImage.applyFillingContentMode(); + + photosRight.addSpacer(rpt(4)); + const fourth = photosRight.addStack(); + const fourthSize = new Size(rpt(125), rpt(100)); + fourth.size = fourthSize; + fourth.cornerRadius = imageRadius; + const fourthImage = fourth.addImage(useSlideshow ? photo[3] : await getPhoto('photo_4')); + fourthImage.imageSize = fourthSize; + fourthImage.applyFillingContentMode(); + return widget +}; + +// 调度分流入口 +const createWidget = async () => { + const family = config.widgetFamily; + if (family === 'small') { + return await createSmallWidget(); + } + if (family === 'medium') { + return await createMediumWidget(); + } + // 大号及默认环境完全走原版逻辑 + return await createLargeWidgetOriginal(); +}; + +await withSettings({ + formItems: [ + { + label: i18n(['Title', '标题文本']), + name: 'text', + type: 'textarea', + default: preference.text + }, + { + label: i18n(['Title Font', '标题字体']), + name: 'titleFont', + default: preference.titleFont + }, + { + label: i18n(['Text Color', '标题颜色']), + name: 'textColor', + type: 'color', + media: '(prefers-color-scheme: light)', + default: preference.textColor + }, + { + label: i18n(['Text Color', '标题颜色']), + name: 'textColorDark', + type: 'color', + media: '(prefers-color-scheme: dark)', + default: preference.textColorDark + }, + { + label: i18n(['Secondary Color', '副文颜色']), + name: 'secondaryColor', + type: 'color', + media: '(prefers-color-scheme: light)', + default: preference.secondaryColor + }, + { + label: i18n(['Secondary Color', '副文颜色']), + name: 'secondaryColorDark', + type: 'color', + media: '(prefers-color-scheme: dark)', + default: preference.secondaryColorDark + }, + { + label: i18n(['Image Radius', '图片圆角']), + name: 'imageRadius', + type: 'number', + default: preference.imageRadius + }, + { + label: i18n(['Image Slideshow', '图片轮循']), + type: 'switch', + name: 'useSlideshow', + default: false + }, + { + label: i18n(['Photo Folder', '相册文件']), + name: 'photoFolder', + type: 'select', + options: getICloudFolders(), + default: preference.photoFolder + }, + { + label: i18n(['Random Layout', '随机排版']), + type: 'switch', + name: 'randomLayout', + default: false + }, + { + label: i18n(['Face Focus', '面容聚焦']), + type: 'switch', + name: 'faceFocus', + default: true + }, + { + label: i18n(['Choose Photo', '图片']), + name: 'photos', + type: 'group', + items: [ + { + label: i18n(['Photo 1', '指定图一']), + name: 'photo1', + type: 'cell' + }, + { + label: i18n(['Photo 2', '指定图二']), + name: 'photo2', + type: 'cell' + }, + { + label: i18n(['Photo 3', '指定图三']), + name: 'photo3', + type: 'cell' + }, + { + label: i18n(['Photo 4', '指定图四']), + name: 'photo4', + type: 'cell' + } + ] + } + ], + onItemClick: ({ name }) => { + const match = name.match(/photo(\d+)$/); + if (match) { + choosePhoto(Number(match[1])); + } + }, + render: async ({ family, settings }) => { + if (family) config.widgetFamily = family; + Object.assign(preference, settings); + const widget = await createWidget(); + return widget + } +}); diff --git a/Scriptable/PriceWidgets.js b/Scriptable/PriceWidgets.js new file mode 100644 index 00000000..a3ce17cd --- /dev/null +++ b/Scriptable/PriceWidgets.js @@ -0,0 +1,1578 @@ + +/** + * ===================================================================== + * 【资产看板 PriceWidgets】 + * 版本:v2.6.1 + * 日期:2026-09-21 + * + * 核心功能: + * 【全品类资产覆盖】 + * - 加密货币:BTC, ETH, SOL 等主流代币 + * - 美股外盘:AAPL, TSLA, NVDA 等知名上市公司 + * - 中国A股:600519(茅台), 000001, sh000001(上证指数) 等 + * - 公募基金:001186, 161725 等6位基金代码 + * - 贵金属:国内上海金(AU9999/AUTD)、上海银(AGTD)、现货黄金(XAU)、现货白银(XAG) + * - 全国油价:支持 92、95、98 汽油与 0 号柴油 + * ===================================================================== + */ + +if (typeof require === 'undefined') require = importModule; +const { DmYY, Runing } = require('./DmYY'); + +// @组件代码开始 +class Widget extends DmYY { + constructor(arg) { + super(arg); + this.en = ' btc'; + this.name = '资产看板'; + config.runsInApp && + this.registerAction( + '关注种类', + async () => { + return this.setAlertInput( + '关注种类', + '按资产类别分栏填入代码(逗号隔开)', + { + cryptoSymbols: '虚拟币 (如: BTC, ETH, SOL)', + usStockSymbols: '美股 (如: AAPL, TSLA, NVDA)', + cnStockSymbols: 'A股/港股 (如: 600519, 000951, 00700)', + metalSymbols: '贵金属 (如: AU9999, AG9999, XAU, XAG)', + fundSymbols: '公募基金 (如: 001186, 161725)', + oilSymbols: '国内油价 (如: 92, 95, 98, 0)', + } + ); + }, + { name: 'centsign.circle', color: '#feda31' } + ); + config.runsInApp && + this.registerAction( + '油价设置', + async () => { + return this.setAlertInput( + '油价设置', + '设置天行数据 APIKEY 与所在省份:\n申请地址:https://www.tianapi.com/apiview/104 (普通会员每日赠送100次)\n省份如:广东、北京、上海、浙江、江苏等', + { + oilKey: '', + oilProvince: '广东', + } + ); + }, + { name: 'fuelpump.circle', color: '#f97316' } + ); + + config.runsInApp && + this.registerAction( + '随机展示', + async () => { + return this.setAlertInput( + '随机展示', + '开启后每次刷新在已关注资产中随机轮播\n输入 1 开启,0 关闭', + { + randomDisplay: '0', + } + ); + }, + { name: 'shuffle', color: '#38bdf8' } + ); + config.runsInApp && + this.registerAction( + '运行日志', + async () => { + if (!this.lastReportText) { + await this.init(); + } + const alert = new Alert(); + alert.title = '资产看板运行日志'; + alert.message = this.lastReportText || '暂无日志记录'; + alert.addAction('拷贝报告'); + alert.addCancelAction('关闭'); + const idx = await alert.presentAlert(); + if (idx === 0 && this.lastReportText) { + Pasteboard.copy(this.lastReportText); + } + }, + { name: 'doc.text.magnifyingglass', color: '#10B981' } + ); + config.runsInApp && this.registerAction('基础设置', this.setWidgetConfig); + } + + format = (str) => { + return parseInt(str) >= 10 ? str : `0${str}`; + }; + + formatPrice = (num, minDec, maxDec) => { + const val = Number(num); + if (isNaN(val)) return '' + (num || '0'); + if (minDec !== undefined && maxDec !== undefined) { + return val.toLocaleString('en-US', { + minimumFractionDigits: minDec, + maximumFractionDigits: maxDec, + }); + } + if (val >= 1000) { + const isInteger = (val % 1 === 0); + return val.toLocaleString('en-US', { + minimumFractionDigits: isInteger ? 0 : 2, + maximumFractionDigits: 2, + }); + } + if (val >= 1) { + return val.toFixed(2); + } + if (val >= 0.01) { + return val.toFixed(4); + } + return val.toFixed(6); + }; + + endpoint = 'https://api.coingecko.com/api/v3'; + nomicsEndpoint = 'https://api.nomics.com/v1'; + + dataSource = []; + + provincePinyinMap = { + 北京: 'beijing', 天津: 'tianjin', 河北: 'hebei', 山西: 'shanxi', + 内蒙古: 'neimenggu', 辽宁: 'liaoning', 吉林: 'jilin', 黑龙江: 'heilongjiang', + 上海: 'shanghai', 江苏: 'jiangsu', 浙江: 'zhejiang', 安徽: 'anhui', + 福建: 'fujian', 江西: 'jiangxi', 山东: 'shandong', 河南: 'henan', + 湖北: 'hubei', 湖南: 'hunan', 广东: 'guangdong', 广西: 'guangxi', + 海南: 'hainan', 重庆: 'chongqing', 四川: 'sichuan', 贵州: 'guizhou', + 云南: 'yunnan', 西藏: 'xizang', 陕西: 'shaanxi', 甘肃: 'gansu', + 青海: 'qinghai', 宁夏: 'ningxia', 新疆: 'xinjiang', + }; + + formatTime = (timestamp) => { + if (!timestamp) return '无历史记录'; + const d = new Date(Number(timestamp)); + const pad = (n) => (n < 10 ? `0${n}` : n); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + }; + + log = (msg, type = 'INFO') => {}; + + printDiagnosticReport = () => { + const d = this.logDetails || {}; + const divider = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; + const lines = [ + '', + divider, + '【资产看板 PriceWidgets 运行诊断日志】', + divider, + `运行环境: ${d.runMode || (config.runsInWidget ? '桌面小组件 (Widget)' : 'Scriptable App 内部')}`, + `刷新间隔: 设置为 ${d.dataCacheMinutes || 30} 分钟 (refreshAfterDate)`, + `历史数据: 上次成功更新于 ${d.lastUpdatedStr || '无'} (距今 ${d.elapsedMinutes || '0'} 分钟)`, + `读取策略: ${d.dataSourceType || '未知'}`, + `呈现资产: 成功装载 ${d.finalCount || 0} 个监控标的`, + `总耗时: ${d.totalDuration || 0} ms`, + ]; + + if (d.networkTasks && d.networkTasks.length > 0) { + lines.push('────────────────────────────────────'); + lines.push('网络并发请求明细:'); + d.networkTasks.forEach((t) => { + lines.push(` • [${t.name}] 耗时 ${t.cost}ms ➔ ${t.status}`); + }); + if (d.networkTotalTime > 0) { + lines.push(` 并发总网络耗时: ${d.networkTotalTime} ms (并行执行耗时由最慢单项决定)`); + } + } + + lines.push('────────────────────────────────────'); + if (d.errors && d.errors.length > 0) { + lines.push('⚠️ 异常与报错提示:'); + d.errors.forEach((err) => { + lines.push(` ❌ ${err}`); + }); + } else { + lines.push('✅ 运行状态: 完美执行,全链路无任何报错'); + } + lines.push(divider); + lines.push(''); + + const fullReport = lines.join('\n'); + this.lastReportText = fullReport; + console.log(fullReport); + }; + + init = async () => { + const startTime = Date.now(); + const lastTime = this.settings.lastUpdatedTime || 0; + const dataCacheMinutes = parseInt(this.settings.refreshAfterDate) || 30; + const timeDiffMs = lastTime ? Date.now() - lastTime : 0; + const elapsedMinutes = lastTime ? (timeDiffMs / (60 * 1000)).toFixed(1) : '初次运行'; + const isExpired = !lastTime || timeDiffMs > dataCacheMinutes * 60 * 1000; + const runMode = config.runsInWidget ? '桌面小组件 (WidgetKit)' : 'Scriptable App 内部运行'; + + this.logDetails = { + runMode, + dataCacheMinutes, + lastUpdatedStr: this.formatTime(lastTime), + elapsedMinutes, + isExpired, + dataSourceType: '', + networkTotalTime: 0, + networkTasks: [], + errors: [], + finalCount: 0, + totalDuration: 0, + }; + + // 桌面小组件且缓存未过期时,直接读取本地缓存秒开 + if (this.settings.dataSource && this.settings.dataSource.length && !isExpired && !config.runsInApp) { + this.dataSource = this.settings.dataSource; + this.logDetails.dataSourceType = `读取本地有效缓存数据 (距更新 ${elapsedMinutes} 分钟前,跳过网络秒开)`; + this.logDetails.finalCount = this.dataSource.length; + this.logDetails.totalDuration = Date.now() - startTime; + this.printDiagnosticReport(); + return; + } + + try { + await this.cacheData(); + } catch (e) { + const errMsg = `cacheData 异常: ${e.message || e}`; + if (this.logDetails) this.logDetails.errors.push(errMsg); + } + + // 核心保底机制:若因网络波动/超时未获取到最新数据,坚决使用旧缓存展示,杜绝小组件超时红字 + if ((!this.dataSource || !this.dataSource.length) && this.settings.dataSource && this.settings.dataSource.length) { + this.dataSource = this.settings.dataSource; + this.logDetails.dataSourceType = `⚠️ 网络请求无有效数据,自动回退读取上次本地有效缓存 (${this.logDetails.lastUpdatedStr})`; + } else if (this.dataSource && this.dataSource.length) { + if (!this.logDetails.dataSourceType) { + this.logDetails.dataSourceType = '网络并发拉取最新实时数据'; + } + } + + this.logDetails.finalCount = this.dataSource ? this.dataSource.length : 0; + this.logDetails.totalDuration = Date.now() - startTime; + this.printDiagnosticReport(); + }; + + getTrendColor = (market, isBackground = false) => { + const change = Number(market.price_change_percentage_24h) || 0; + const isUp = change >= 0; + const isCN = market.region === 'cn'; + + const redColor = isBackground ? new Color('#EF4444', 0.82) : new Color('#EF4444'); + const greenColor = isBackground ? new Color('#10B981', 0.82) : new Color('#10B981'); + + if (change === 0 && market.type === 'oil') { + return isBackground ? new Color('#64748B', 0.82) : new Color('#64748B'); + } + + if (isCN) { + return isUp ? redColor : greenColor; + } else { + return isUp ? greenColor : redColor; + } + }; + + // 雪球官方 Logo(用于未收录股票) + xueqiuLogoBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpFNjIyMEU4ODQzNzIxMUUyQTQxRUMzRTA5MkEzOEYzQSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpFNjIyMEU4OTQzNzIxMUUyQTQxRUMzRTA5MkEzOEYzQSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkU2MjIwRTg2NDM3MjExRTJBNDFFQzNFMDkyQTM4RjNBIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkU2MjIwRTg3NDM3MjExRTJBNDFFQzNFMDkyQTM4RjNBIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+LePrjgAACD1JREFUeNrMW2tsFUUUnl5AsBbKMz54akAQJEAAG1BEpaJUYgIWA4iCiKA8QmIM8gMTfEQhyg9RYxRBWgyKRY0vUBDFFqotLw2KKRR5iIClPC6lFJCC53i/C7fLzpnZ3btXTvIlNzu7c2fOnnPmO2dm09SIfBWyNCH0JfQhdCN0ILTD9XRCQ8JJQpRwiFBG2E7YRigi/Bnm4NJCUsDVhEcJQwj9CfUD9MXK+JawDAo5f7kroDdhFaF5CIrdSVhMeIdQcbkqYB3h1pDd6hThbcLLhL8vJwVcRTihUiccN54nzCec9dNBJMkDqiFUp1ABmYR5hBJCFz8d1FPdhnl9pheCWxdE6NMJbecR4fsY+jhAKCfsQtQ/AuVl/Dcm73ItYRxhL2Grlwe9RGderhYQHk64dpgwnFCYcO0pQgMMKAIz5Si+hlBK+NWhNOd4rif0JNxByCbcaDk+Vt77iD/TCLXJjAHc+eeEO13aTsIiCl3MszneSm0AM78ZS+p4QlPLZ5YTxgiK9qQAJiwrDJG9GkooCtHfGxMmE2ZaKmI1YSjhTJAgeAXhU4tljaP/SsKAEBVQRZhL6ARXNBGiu+ES9YIogNfauzwsgWwpt4Uc+SsJEwn3Eg4a7h1BeM2vAqYikHmRDAt3SZasAussNdw3hTDKawzgyLsFyYofOY6YUJwCRfAYCwg5BvdhZe2wsQD2mXzD5DcYmFc8cPZLgQJ4FRqGGCQF0CVu8cBNAdMJWUJnJVijRxuUkIlBZaVACRzpczE2nWRhFRFdgAf9h5DJMYPrgbw9HmQ+METaKCLyhhQoog1ct6Wm/TDc+4jOAmYIkz8PFngo4VoBLKHWYAmrLehxMmQfArduiWwBC3e1ANbaHsH33wDFdJORFmvuMcLtXrm6T+GawVhN21FCewTGOhbwhDB5XnufFf7wQ8IjBktg9vaZBzprkj4JfMDNko9pnmtGmOR0gTTCY8KfzRU6jMtSmJ+kBE505gSceANE9A0gahxof3K4bgXSZJ1MwpwvuMBAwlrBdNt6KHSwJSwS3OEcoTPSYa9SH0E316VtIWGCw+J2Iwa5CZO14rgFDBf+dKHHKk8+BlIrsM8nfdUuYlaWq2kf6vLi8oT+Hkh0gSHCjXk+g9BsoT3X5+RHCPf843Hs2XEFtEGG5Sa/B4jac8Ap3KQd/td28rzCPGi4r8Dl2mZUnNykO6FVxEBXvwkQrM6iMKGTTh4mP9Jw33rCLE3bV9o8iGJfBDU+nfwQMGLvMKTPpsnnW0y+GInQSU17ofBs14ih5rYpCVUcKYmRJv8eWKbN5I8L9/wstHWOYG3WDXBfQAUMFNoOCKb5rqpbfHWTHzH5qOE+rlzX6NwwIqyTe1SwfbibCPdp2rhYuUvT9pJFIaYEK1fUYhznwAfcpHUEubsuc/IrTEKWKX3Znf3ylMt1prXPGPpmBniP5eQT+b9r3UJSgN8trnj21124Z4mG4r4ep6ga2YjUOupxTFWa6xmsgIaCL/qdvJT67oR1uGWUHQ2Tz/YxeSW5ckTQTmMfk2fe0NcwkGnKvVYvBb2tPt/8hTetswxJAc18TN5U/npFudfuGin9nsJZWMexADGphc7NWQH7NY3tld3usW3tj9f1mZq2LlCCjsltCzD5NMzFdSnmCW7XNKYjDZbEtvrLjO5xwRevE54tDMhF2giFnt0RIVlg6WFgeTz5/oYBMJcfbyiUpPmI4LbSW2grYwX85oPJpSu7HSCbKhGLdKiiY4hsdEtEybX0HIGtmfYAbeqENklTrgp2ymywxCgj4PtlQnDqpaumCPKRiu3P254L+AvU201uEIKnSZiMddW0ca1ibzzKfy10MlbD2qTCxGjl/VDEx0LbbFiTV5GeWakSlrkC4cZx6tJS9peae5f7nDzLW8JznB4vssgQneRngqmCFEnIq8uFdX6q49oMUFNntDftF0pSjjTYVCMYY9nfRKXfg+DssCi2/FzcGXoaTM1NmIVxKbvCMaDB4ApcdChVwSUTL6OrcE8tTHupgfmVCQyQN3ledCqgMQKRjgLnKe8HJvxIR6S8TQ1KGIOVxk14G2+Kpo2LI1yUrVQOqluFdFQKKDkpUEA5LCtqcAddpXiQctkGd8SayosMrO72eHNQY53pVGJZ3JcCRWQhwco0WMKohCDeSsW2x1tr7ufaYadEV3YmO7xv/pzwhy0R6dNToIASi8pP4m4Rj+kLYfIKfl9Rl4NfekaIO11vyO6YBg9ThjN4SZJ+WLMlS+Bdoc2GMZcib6k1KUDBTDYZiiIroPmaFCihP5TQxOfzNXDdMreKkI6bTzJ0ygHxe4PJJUvi9X+/meEMHd2XCh68Df2mRaDaYkg4kiXroQSvxdo8LIvKqwJYpit5fy8eeTmXWIzfYco6KMH2m4TvwAiVXwVwwHhIxY67m8pOY2FmswwBK6gUKXkvMNFthquAh6UVOrgfQc8kzCJfAKN8VcU+kwsivCJ1cFFooUEJa1Vsk8VYRfbyzVB9xISJHifBVrEG5viLim2J6bI+3qPg7wNuAaPLxuRr4WKTHW+Uqz2fqLrng3jTheuPp20G5+ejKVYAn8Bu5POtnoGFRJFkMYG5knCNin36Isl85Tjnh5whB+nvZpcsNekKiFda+MGeKrVyAhZxLlkd+v1qjHdq+iKFrkqhAjJgLer/VoBC4WMeWCO7xKkUKGCjSvJneX4+m3NKNXjAAvxuq8L5bPYoMr/9yew0jE9nmRMMwGAHKbvDUJKVFSMP4FXgYPIHG/7n822hEC5zdQaaAJmYZDVydT7OslvFvi3chAzueJiD+1eAAQAr79K3PHeQswAAAABJRU5ErkJggg=='; + + getXueqiuLogo = () => { + try { + return Image.fromData(Data.fromBase64String(this.xueqiuLogoBase64)); + } catch (e) { + return null; + } + }; + + // 股票/指数 -> STK + // 虚拟币 -> CRY + // 黄金 -> GLD + // 白银 -> SLV + // 油价 -> OIL + // 基金 -> FND + getCategoryTag = (market) => { + const type = market.type || 'stock'; + const sym = (market.symbol || '').toUpperCase(); + const name = market.name || ''; + + if (type === 'oil') return 'OIL'; + if (type === 'fund') return 'FND'; + if (type === 'crypto') return 'CRY'; + if (type === 'metal') { + const isSilver = sym.includes('AG') || sym.includes('SILVER') || name.includes('银'); + return isSilver ? 'SLV' : 'GLD'; + } + if (type === 'stock') return 'STK'; + return 'STK'; + }; + + // 获取分类徽章的主题颜色 + getCategoryTagColor = (tag) => { + switch (tag) { + case 'STK': + return { bg: new Color('#EF4444', 0.12), text: new Color('#DC2626', 0.88) }; + case 'CRY': + return { bg: new Color('#F59E0B', 0.12), text: new Color('#D97706', 0.88) }; + case 'GLD': + return { bg: new Color('#EAB308', 0.15), text: new Color('#CA8A04', 0.90) }; + case 'SLV': + return { bg: new Color('#64748B', 0.14), text: new Color('#475569', 0.90) }; + case 'OIL': + return { bg: new Color('#EA580C', 0.12), text: new Color('#C2410C', 0.88) }; + case 'FND': + return { bg: new Color('#3B82F6', 0.12), text: new Color('#2563EB', 0.88) }; + default: + return { bg: new Color('#0284C7', 0.12), text: new Color('#0284C7', 0.88) }; + } + }; + + // 判断是否处于深色/暗黑模式 + isDarkModeActive = () => { + try { + if (typeof Device !== 'undefined' && Device.isUsingDarkAppearance()) return true; + if (typeof Color !== 'undefined' && Color.dynamic) { + const testColor = Color.dynamic(new Color('#FFFFFF'), new Color('#000000')); + if (testColor.hex && testColor.hex.toLowerCase() === '000000') return true; + } + } catch (e) {} + return false; + }; + + // 图标规则: + // 1. 自动扫描去除任何图源周围自带的多余透明留白(Bounding Box) + // 2. 严格按最长边等比缩放并居中投影至 targetSize (28x28),保证所有品牌 Logo 大小绝对规整统一 + // 3. 暗黑模式下,自动识别纯黑/极深色剪影(如 Apple Logo),无损反转为纯净银白 #FFFFFF,浅色模式保持深黑 + processAndNormalizeIcon = async (img, targetSize = 28) => { + try { + if (!img) return img; + const isDark = this.isDarkModeActive(); + const webview = new WebView(); + const js = `const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const w = img.width; + const h = img.height; + canvas.width = w; + canvas.height = h; + ctx.drawImage(img, 0, 0); + const imgData = ctx.getImageData(0, 0, w, h); + const d = imgData.data; + + let minX = w, maxX = 0, minY = h, maxY = 0; + let hasVisible = false; + let darkCount = 0; + let visibleCount = 0; + + // 1. 扫描可见像素,精确获取真实图形物理边界与深色占比 + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const idx = (y * w + x) * 4; + const a = d[idx + 3]; + if (a > 15) { + hasVisible = true; + visibleCount++; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + const lum = 0.299 * d[idx] + 0.587 * d[idx + 1] + 0.114 * d[idx + 2]; + if (lum < 80) darkCount++; + } + } + } + + if (!hasVisible) { + minX = 0; maxX = w - 1; minY = 0; maxY = h - 1; + } + + const cropW = maxX - minX + 1; + const cropH = maxY - minY + 1; + const target = ${targetSize} * 3; // 3x 高清视网膜渲染 (84x84) + const outCanvas = document.createElement('canvas'); + outCanvas.width = target; + outCanvas.height = target; + const outCtx = outCanvas.getContext('2d'); + + // 2. 严格按有效图案最长边等比缩放并完全居中 + const scale = Math.min(target / cropW, target / cropH); + const drawW = cropW * scale; + const drawH = cropH * scale; + const offsetX = (target - drawW) / 2; + const offsetY = (target - drawH) / 2; + + outCtx.drawImage(img, minX, minY, cropW, cropH, offsetX, offsetY, drawW, drawH); + + // 3. 暗黑模式下纯黑/深色剪影反白(深色像素占比 > 60% 时自动转白) + if (${isDark} && visibleCount > 0 && (darkCount / visibleCount) > 0.6) { + const finalData = outCtx.getImageData(0, 0, target, target); + const fd = finalData.data; + for (let i = 0; i < fd.length; i += 4) { + if (fd[i + 3] > 15) { + const lum = 0.299 * fd[i] + 0.587 * fd[i + 1] + 0.114 * fd[i + 2]; + if (lum < 80) { + fd[i] = 255; + fd[i + 1] = 255; + fd[i + 2] = 255; + } + } + } + outCtx.putImageData(finalData, 0, 0); + } + + completion(outCanvas.toDataURL()); + }; + img.src = 'data:image/png;base64,' + '${Data.fromPNG(img).toBase64String()}';`; + let res = await webview.evaluateJavaScript(js, true); + res = res.replace(/^data:image\/[a-zA-Z0-9]+;base64,/, ''); + return Image.fromData(Data.fromBase64String(res)); + } catch (e) { + return img; + } + }; + + // 本地文件缓存加载,图标加载 + getNormalizedItemIcon = async (market, targetSize = 28) => { + try { + if (!this.FILE_MGR.fileExists(this.cacheImage)) { + this.FILE_MGR.createDirectory(this.cacheImage, true); + } + const sym = (market.symbol || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); + const type = market.type || 'stock'; + const isDark = this.isDarkModeActive(); + const safeKey = `norm_${type}_${sym}_${targetSize}_${isDark ? 'dark' : 'light'}`; + const filePath = this.FILE_MGR.joinPath(this.cacheImage, `${safeKey}.png`); + + if (this.FILE_MGR.fileExists(filePath)) { + return Image.fromFile(filePath); + } + + const rawImg = await this.getItemImage(market); + if (!rawImg) return null; + const normalized = await this.processAndNormalizeIcon(rawImg, targetSize); + if (normalized) { + this.FILE_MGR.writeImage(filePath, normalized); + return normalized; + } + return rawImg; + } catch (e) { + return await this.getItemImage(market); + } + }; + + loadIconWithCache = async (cacheKey, iconUrl) => { + try { + if (!this.FILE_MGR.fileExists(this.cacheImage)) { + this.FILE_MGR.createDirectory(this.cacheImage, true); + } + const safeKey = cacheKey.replace(/[^a-zA-Z0-9_\-\.]/g, '_'); + const filePath = this.FILE_MGR.joinPath(this.cacheImage, `${safeKey}.png`); + if (this.FILE_MGR.fileExists(filePath)) { + return Image.fromFile(filePath); + } + if (!iconUrl) return null; + const req = new Request(iconUrl); + req.timeoutInterval = 4; + const img = await req.loadImage(); + if (img) { + this.FILE_MGR.writeImage(filePath, img); + return img; + } + } catch (e) {} + return null; + }; + + createBadgeImage = (symbolName, tintColorHex, targetSize = 250) => { + const ctx = new DrawContext(); + ctx.size = new Size(targetSize, targetSize); + ctx.opaque = false; + ctx.respectScreenScale = false; + try { + const sym = SFSymbol.named(symbolName); + if (sym) { + sym.applyFont(Font.systemFont(targetSize * 0.85)); + const icon = sym.image; + ctx.tintColor = new Color(tintColorHex, 1); + const origW = icon.size.width; + const origH = icon.size.height; + const scale = Math.min(targetSize / origW, targetSize / origH); + const drawW = origW * scale; + const drawH = origH * scale; + const x = (targetSize - drawW) / 2; + const y = (targetSize - drawH) / 2; + ctx.drawImageInRect(icon, new Rect(x, y, drawW, drawH)); + return ctx.getImage(); + } + } catch (e) {} + return ctx.getImage(); + }; + + getItemImage = async (market) => { + const sym = (market.symbol || '').toUpperCase(); + const cleanSym = sym.replace(/[^A-Z0-9]/g, ''); + const type = market.type; + + // 1. 加密货币:CoinGecko 原生纯透明图片(BTC, ETH, SOL 等) + if (market.image && typeof market.image === 'string' && market.image.startsWith('http')) { + const cached = await this.loadIconWithCache(`crypto_${cleanSym}`, market.image); + if (cached) return cached; + } + + // 2. 全国油价:壳牌(Shell 经典彩色贝壳)官方标准 64x64 纯透明 Logo + if (type === 'oil') { + const cached = await this.loadIconWithCache( + 'oil_shell_official', + 'https://companiesmarketcap.com/img/company-logos/64/SHEL.png' + ); + if (cached) return cached; + return this.createBadgeImage('fuelpump.fill', '#EA580C'); + } + + // 3. 贵金属:Tether Gold 纯金币与 Kinesis Silver 纯银币(均为原生纯透明无底色) + if (type === 'metal') { + const isSilver = sym.includes('AG') || (market.name || '').includes('银'); + const url = isSilver + ? 'https://coin-images.coingecko.com/coins/images/29789/large/kag-currency-ticker.png' + : 'https://coin-images.coingecko.com/coins/images/10481/large/Tether_Gold.png'; + const cached = await this.loadIconWithCache(isSilver ? 'metal_silver_kag' : 'metal_gold_xaut', url); + if (cached) return cached; + return this.createBadgeImage('sparkles', isSilver ? '#94A3B8' : '#D97706'); + } + + // 4. 股票与指数: + if (type === 'stock') { + const idStr = String(market.id || '').trim(); + const symStr = String(market.symbol || '').trim(); + let stockUrl = ''; + let stockKey = ''; + + // (1) 美股:所有美股品牌(AAPL, TSLA, NVDA, MSFT, META, GOOG, AMZN 等)直连官方 Logo + if (idStr.toLowerCase().startsWith('us') || market.currency === '$' || sym === 'AAPL' || sym === 'TSLA' || sym === 'NVDA' || sym === 'MSFT' || sym === 'META') { + let ticker = idStr.replace(/^us/i, '').replace(/\..*$/, ''); + if (!ticker) ticker = symStr.replace(/\..*$/, ''); + ticker = ticker.replace(/[^a-zA-Z]/g, '').toUpperCase(); + if (ticker.length >= 1 && ticker.length <= 5) { + stockKey = `stock_us_${ticker}`; + stockUrl = `https://companiesmarketcap.com/img/company-logos/64/${ticker}.png`; + } + } + + // (2) A 股与港股:所有知名品牌直连 CompaniesMarketCap 官方透明 Logo + if (!stockUrl) { + const numCode = idStr.replace(/^[a-zA-Z_]+/g, '') || symStr.replace(/^[a-zA-Z_]+/g, ''); + if (/^\d{6}$/.test(numCode)) { + const suffix = numCode.startsWith('6') ? 'SS' : 'SZ'; + stockKey = `stock_a_${numCode}`; + stockUrl = `https://companiesmarketcap.com/img/company-logos/64/${numCode}.${suffix}.png`; + } else if (/^\d{4,5}$/.test(numCode) || idStr.toLowerCase().startsWith('hk')) { + const hkNum = numCode.replace(/^0+/, '').padStart(4, '0'); + if (hkNum === '0700') { + stockKey = 'stock_hk_0700_tcehy'; + stockUrl = 'https://companiesmarketcap.com/img/company-logos/64/TCEHY.png'; + } else if (hkNum === '9988') { + stockKey = 'stock_hk_9988_baba'; + stockUrl = 'https://companiesmarketcap.com/img/company-logos/64/BABA.png'; + } else { + stockKey = `stock_hk_${hkNum}`; + stockUrl = `https://companiesmarketcap.com/img/company-logos/64/${hkNum}.HK.png`; + } + } + } + + if (stockUrl) { + const cached = await this.loadIconWithCache(stockKey, stockUrl); + if (cached) return cached; + } + + // (3) 未被 CMC 收入的股票采用雪球官方高清透明 Logo + const xqLogo = this.getXueqiuLogo(); + if (xqLogo) return xqLogo; + } + + // 5. 公募基金:天天基金/理财纯透明图腾 + if (type === 'fund') { + return this.createBadgeImage('chart.pie.fill', '#3B82F6'); + } + + // 6. 离线/保底:原生 SFSymbol 纯透明无底色 + return this.createBadgeImage('bitcoinsign.circle', '#F59E0B'); + }; + + fetchOilData = async (provName, oilKey) => { + let p0 = '', p89 = '', p92 = '', p95 = '', p98 = ''; + let adjustDate = '', changeAmount = 0; + const cleanProv = (provName || '广东').replace(/省|市/g, ''); + + if (oilKey) { + try { + const url = `https://apis.tianapi.com/oilprice/index?key=${oilKey}&prov=${encodeURIComponent(cleanProv)}`; + const req = new Request(url); + req.timeoutInterval = 4; + const res = await req.loadJSON(); + if (res && res.code === 200 && res.result) { + p0 = res.result.p0 || ''; + p89 = res.result.p89 || ''; + p92 = res.result.p92 || ''; + p95 = res.result.p95 || ''; + p98 = res.result.p98 || ''; + } + } catch (e) {} + } + + try { + const pinyin = this.provincePinyinMap[cleanProv] || 'guangdong'; + const webUrl = `http://m.qiyoujiage.com/${pinyin}.shtml`; + const req = new Request(webUrl); + req.timeoutInterval = 4; + req.headers = { 'User-Agent': 'Mozilla/5.0' }; + const webRes = await req.loadString(); + if (webRes) { + const match92 = webRes.match(/92号汽油<\/dt>\s*<dd>([\d\.]+)/); + const match95 = webRes.match(/95号汽油<\/dt>\s*<dd>([\d\.]+)/); + const match98 = webRes.match(/98号汽油<\/dt>\s*<dd>([\d\.]+)/); + const match0 = webRes.match(/0号柴油<\/dt>\s*<dd>([\d\.]+)/); + + if (match92 && !p92) p92 = match92[1]; + if (match95 && !p95) p95 = match95[1]; + if (match98 && !p98) p98 = match98[1]; + if (match0 && !p0) p0 = match0[1]; + + let rawTip = ''; + const varMatch = webRes.match(/var\s+tishiContent\s*=\s*["']([^"']+)["']/); + if (varMatch) { + rawTip = varMatch[1]; + } else { + const divMatch = webRes.match(/class=["']tishi["'][^>]*>([\s\S]*?)<\/div>/); + if (divMatch) rawTip = divMatch[1]; + } + + if (rawTip) { + const cleanText = rawTip.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' '); + const dateMatch = cleanText.match(/(\d+月\d+日(?:\d+时)?)/); + if (dateMatch) adjustDate = dateMatch[1]; + + let isUp = true; + if (cleanText.includes('下调') || cleanText.includes('跌')) isUp = false; + + const rangeMatch = cleanText.match(/([\d\.]+)元\/升(?:-([\d\.]+)元\/升)?/); + if (rangeMatch) { + const low = parseFloat(rangeMatch[1]) || 0; + const high = rangeMatch[2] ? parseFloat(rangeMatch[2]) : low; + const avg = (low + high) / 2; + changeAmount = isUp ? avg : -avg; + } + } + } + } catch (e) {} + + return { + prov: cleanProv, + p0, p89, p92, p95, p98, + adjustDate: adjustDate ? `${adjustDate}调价` : '待发改委公布', + changeAmount, + }; + }; + + cacheData = async (params) => { + try { + const s = this.settings || {}; + const c_crypto = (s.cryptoSymbols || '').split(',').map((x) => x.trim()).filter(Boolean); + const c_us = (s.usStockSymbols || '').split(',').map((x) => x.trim()).filter(Boolean); + const c_cn = (s.cnStockSymbols || '').split(',').map((x) => x.trim()).filter(Boolean); + const c_metal = (s.metalSymbols || '').split(',').map((x) => x.trim()).filter(Boolean); + const c_fund = (s.fundSymbols || '').split(',').map((x) => x.trim()).filter(Boolean); + const c_oil = (s.oilSymbols || '').split(',').map((x) => x.trim()).filter(Boolean); + + const hasCategorySettings = ( + c_crypto.length > 0 || c_us.length > 0 || c_cn.length > 0 || + c_metal.length > 0 || c_fund.length > 0 || c_oil.length > 0 + ); + + const isOilFiltered = c_oil.length > 0; + + const cryptoKeys = []; + const tencentKeys = []; + const sgeKeys = []; + const oilKeys = []; + let orderedItems = []; + + if (hasCategorySettings) { + // 分栏设置优先 + for (const item of c_crypto) { + cryptoKeys.push(item); + orderedItems.push({ key: item, type: 'crypto' }); + } + for (const item of c_us) { + const up = item.toUpperCase(); + const qKey = up.startsWith('US') ? up : `us${up}`; + tencentKeys.push(qKey); + orderedItems.push({ key: qKey, origin: item, type: 'usStock' }); + } + for (const item of c_cn) { + const up = item.toUpperCase(); + const clean = up.replace(/[^A-Z0-9]/g, ''); + let qKey = item; + if (/^(SH|SZ|HK|BJ)/.test(clean)) { + qKey = item; + } else if ((clean.length === 4 || clean.length === 5) && /^\d+$/.test(clean)) { + qKey = `hk${clean.padStart(5, '0')}`; + } else if (clean.length === 6 && /^\d+$/.test(clean)) { + if (/^(60|68|90|11)/.test(clean)) qKey = `sh${clean}`; + else if (/^(00|30|20|12)/.test(clean)) qKey = `sz${clean}`; + else if (/^(8|4)/.test(clean)) qKey = `bj${clean}`; + else qKey = `sh${clean}`; + } + tencentKeys.push(qKey); + orderedItems.push({ key: qKey, origin: item, type: 'cnStock' }); + } + for (const item of c_metal) { + const up = item.toUpperCase(); + const clean = up.replace(/[^A-Z0-9]/g, ''); + if (clean.includes('AG9999') || clean.includes('AG99') || up.includes('白银9999')) { + sgeKeys.push('SGE_AG9999'); + orderedItems.push({ key: 'SGE_AG9999', origin: item, type: 'metal' }); + } else if (clean.includes('AU9999') || clean.includes('AU99') || clean.includes('9999') || up.includes('国内黄金') || up.includes('上海金')) { + sgeKeys.push('SGE_AU9999'); + orderedItems.push({ key: 'SGE_AU9999', origin: item, type: 'metal' }); + } else if (clean.includes('AGTD') || clean.includes('AG') || up.includes('白银延期') || up.includes('国内白银')) { + sgeKeys.push('SGE_AGTD'); + orderedItems.push({ key: 'SGE_AGTD', origin: item, type: 'metal' }); + } else if (clean.includes('AUTD') || clean.includes('TD') || up.includes('黄金延期')) { + sgeKeys.push('SGE_AUTD'); + orderedItems.push({ key: 'SGE_AUTD', origin: item, type: 'metal' }); + } else if (clean === 'XAU' || clean === 'GOLD' || up.includes('现货黄金') || up.includes('伦敦金')) { + tencentKeys.push('hf_XAU'); + orderedItems.push({ key: 'hf_XAU', origin: item, type: 'metal' }); + } else if (clean === 'XAG' || clean === 'SILVER' || up.includes('现货白银') || up.includes('伦敦银')) { + tencentKeys.push('hf_XAG'); + orderedItems.push({ key: 'hf_XAG', origin: item, type: 'metal' }); + } else { + sgeKeys.push(item); + orderedItems.push({ key: item, origin: item, type: 'metal' }); + } + } + for (const item of c_fund) { + const clean = item.replace(/[^0-9]/g, ''); + if (clean) { + tencentKeys.push(`s_jj${clean}`); + orderedItems.push({ key: clean, qKey: `s_jj${clean}`, origin: item, type: 'fund' }); + } + } + for (const item of c_oil) { + oilKeys.push(item); + orderedItems.push({ key: item, type: 'oil' }); + } + } else { + // 若用户完全未作任何设置(全新状态),仅兜底原版纯粹的 'BTC,ETH,BNB' + const rawList = (params || s.btcType || 'BTC,ETH,BNB') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + const cryptoSet = new Set([ + 'BTC', 'ETH', 'USDT', 'BNB', 'SOL', 'USDC', 'XRP', 'DOGE', 'TON', 'ADA', + 'AVAX', 'TRX', 'LINK', 'DOT', 'MATIC', 'NEAR', 'APT', 'SUI', 'PEPE', + 'SHIB', 'LTC', 'BCH', 'UNI', 'FIL', 'OKB', 'CRO', 'ATOM', 'XLM', 'XMR' + ]); + + for (const item of rawList) { + const upper = item.toUpperCase(); + const cleanCode = upper.replace(/[^A-Z0-9]/g, ''); + + const isOil = /^(92|95|98|0|89)$/.test(cleanCode) || + /^(92#|95#|98#|0#|89#|92号|95号|98号|0号|89号|0号柴油|柴油|汽油|OIL92|OIL95|OIL98|OIL0)$/i.test(item); + + const isSgeMetal = /^(AU9999|AU99\.99|AUTD|AU\(T\+D\)|AGTD|AG\(T\+D\)|AG9999|AG99\.99)$/i.test(item) || + cleanCode === 'AU9999' || cleanCode === 'AUTD' || cleanCode === 'AGTD' || cleanCode === 'AG9999' || + ['上海金', '国内黄金', '国内白银', '白银9999', '沪金', '沪银'].includes(upper); + + if (isOil) { + oilKeys.push(item); + orderedItems.push({ key: item, type: 'oil' }); + } else if (isSgeMetal) { + if (cleanCode.includes('AG9999') || cleanCode.includes('AG99') || upper.includes('白银9999')) { + sgeKeys.push('SGE_AG9999'); + orderedItems.push({ key: 'SGE_AG9999', origin: item, type: 'metal' }); + } else if (cleanCode.includes('AU9999') || cleanCode.includes('AU99') || cleanCode.includes('9999') || upper.includes('国内黄金') || upper.includes('上海金')) { + sgeKeys.push('SGE_AU9999'); + orderedItems.push({ key: 'SGE_AU9999', origin: item, type: 'metal' }); + } else if (cleanCode.includes('AG')) { + sgeKeys.push('SGE_AGTD'); + orderedItems.push({ key: 'SGE_AGTD', origin: item, type: 'metal' }); + } else { + sgeKeys.push('SGE_AUTD'); + orderedItems.push({ key: 'SGE_AUTD', origin: item, type: 'metal' }); + } + } else if (/^(SH|SZ|HK|BJ)/i.test(item)) { + tencentKeys.push(item); + orderedItems.push({ key: item, type: 'tencent' }); + } else if (/^(S_JJ)/i.test(item)) { + tencentKeys.push(item); + orderedItems.push({ key: item.replace(/^s_jj/i, ''), qKey: item, type: 'fund' }); + } else if ((cleanCode.length === 4 || cleanCode.length === 5) && /^\d+$/.test(cleanCode)) { + const hkCode = `hk${cleanCode.padStart(5, '0')}`; + tencentKeys.push(hkCode); + orderedItems.push({ key: hkCode, origin: item, type: 'cnStock' }); + } else if (/^\d{6}$/.test(item)) { + if (/^(60|68|90|11)/.test(item)) { + tencentKeys.push(`sh${item}`); + orderedItems.push({ key: `sh${item}`, origin: item, type: 'cnStock' }); + } else if (/^(00|30|20|12)/.test(item)) { + tencentKeys.push(`sz${item}`); + orderedItems.push({ key: `sz${item}`, origin: item, type: 'cnStock' }); + } else if (/^(8|4)/.test(item)) { + tencentKeys.push(`bj${item}`); + orderedItems.push({ key: `bj${item}`, origin: item, type: 'cnStock' }); + } else { + tencentKeys.push(`s_jj${item}`); + orderedItems.push({ key: item, qKey: `s_jj${item}`, origin: item, type: 'fund' }); + } + } else if (['GOLD', 'XAU', '现货黄金', '伦敦金'].includes(upper)) { + tencentKeys.push('hf_XAU'); + orderedItems.push({ key: 'hf_XAU', origin: item, type: 'metal' }); + } else if (['SILVER', 'XAG', '现货白银', '伦敦银'].includes(upper)) { + tencentKeys.push('hf_XAG'); + orderedItems.push({ key: 'hf_XAG', origin: item, type: 'metal' }); + } else if (cryptoSet.has(upper)) { + cryptoKeys.push(item); + orderedItems.push({ key: item, type: 'crypto' }); + } else if (/^[A-Za-z]{1,5}$/.test(item)) { + tencentKeys.push(`us${upper}`); + orderedItems.push({ key: `us${upper}`, origin: item, type: 'usStock' }); + } else { + cryptoKeys.push(item); + orderedItems.push({ key: item, type: 'crypto' }); + } + } + } + + // 并发网络请求:使用 Promise.allSettled 同时拉取油价、金银、股票与币圈数据,消除串行排队延迟! + const oilMap = {}; + const sgeMap = {}; + const tencentMap = {}; + const cryptoMap = {}; + const tasks = []; + + // 1. 国内油价任务 (超时 4s) + const shouldFetchOil = oilKeys.length > 0 || isOilFiltered; + if (shouldFetchOil) { + tasks.push( + (async () => { + const t0 = Date.now(); + try { + const finalKey = (s.oilKey || '').trim(); + const finalProv = (s.oilProvince || '广东').trim(); + const oilRes = await this.fetchOilData(finalProv, finalKey); + + const makeOilItem = (subCode, subName, priceStr) => { + const priceVal = parseFloat(priceStr) || 0; + const amt = oilRes.changeAmount || 0; + const pct = priceVal > 0 ? (amt / priceVal) * 100 : 0; + const changeStr = amt !== 0 ? (amt > 0 ? `+${amt.toFixed(2)}` : `${amt.toFixed(2)}`) : '0.00'; + const nextPrice = priceVal > 0 && amt !== 0 ? (priceVal + amt).toFixed(2) : priceVal.toFixed(2); + + return { + id: `oil_${subCode}`, + name: `${finalProv}${subName}`, + symbol: `${subCode}#`, + current_price: this.formatPrice(priceStr, 2, 2), + high_24h: nextPrice, + low_24h: priceVal.toFixed(2), + adjust_date: oilRes.adjustDate, + price_change_percentage_24h: pct, + expected_change_amount: changeStr, + last_updated: '', + currency: '¥', + region: 'cn', + type: 'oil', + url: `http://m.qiyoujiage.com/${this.provincePinyinMap[finalProv] || 'guangdong'}.shtml`, + }; + }; + + let gotP = []; + if (oilRes.p92) { oilMap['92'] = makeOilItem('92', '92号汽油', oilRes.p92); gotP.push(`92: ${oilRes.p92}`); } + if (oilRes.p95) { oilMap['95'] = makeOilItem('95', '95号汽油', oilRes.p95); gotP.push(`95: ${oilRes.p95}`); } + if (oilRes.p98) { oilMap['98'] = makeOilItem('98', '98号汽油', oilRes.p98); gotP.push(`98: ${oilRes.p98}`); } + if (oilRes.p0) { oilMap['0'] = makeOilItem('0', '0号柴油', oilRes.p0); gotP.push(`0号: ${oilRes.p0}`); } + + const cost = Date.now() - t0; + const statusStr = gotP.length ? `成功 (${gotP.join(', ')} | ${oilRes.adjustDate})` : '未解析到价格'; + if (this.logDetails) this.logDetails.networkTasks.push({ name: '国内油价', cost, status: statusStr }); + } catch (e) { + const cost = Date.now() - t0; + const errStr = `油价获取失败: ${e.message || e}`; + if (this.logDetails) { + this.logDetails.networkTasks.push({ name: '国内油价', cost, status: `❌ ${errStr}` }); + this.logDetails.errors.push(errStr); + } + } + })() + ); + } + + // 2. 国内金银任务 (新浪 SGE,超时 4s) + if (sgeKeys.length) { + tasks.push( + (async () => { + const t0 = Date.now(); + try { + const reqUrl = `http://hq.sinajs.cn/list=${sgeKeys.join(',')}`; + const sgeReq = new Request(reqUrl); + sgeReq.timeoutInterval = 4; + sgeReq.headers = { 'Referer': 'https://finance.sina.com.cn' }; + const sgeRes = await sgeReq.loadString(); + const lines = sgeRes.split(';').map((s) => s.trim()).filter(Boolean); + let parsedCount = 0; + for (const line of lines) { + const [k, v] = line.split('='); + if (!v) continue; + const content = v.replace(/^"/, '').replace(/"$/, ''); + if (!content) continue; + const arr = content.split(','); + const code = arr[0] || ''; + const name = arr[1] || arr[2] || '贵金属'; + let price = parseFloat(arr[3]) || 0; + let prevClose = parseFloat(arr[4]) || 0; + if (!price) price = parseFloat(arr[5]) || parseFloat(arr[9]) || 0; + if (!prevClose) prevClose = parseFloat(arr[9]) || parseFloat(arr[5]) || price; + const pct = prevClose ? ((price - prevClose) / prevClose) * 100 : 0; + + let sgeUrl = 'https://wap.eastmoney.com/quote/stock/118.AU9999.html'; + const upperCode = code.toUpperCase(); + if (upperCode.includes('AG9999') || upperCode.includes('AG99')) { + sgeUrl = 'https://wap.eastmoney.com/quote/stock/118.AGTD.html'; + } else if (upperCode.includes('AG')) { + sgeUrl = 'https://wap.eastmoney.com/quote/stock/118.AGTD.html'; + } else if (upperCode.includes('TD')) { + sgeUrl = 'https://wap.eastmoney.com/quote/stock/118.AUTD.html'; + } + const itemObj = { + id: code, + name: name.replace(/\s+/g, ''), + symbol: code.toUpperCase(), + current_price: this.formatPrice(price), + high_24h: this.formatPrice(arr[5] || price), + low_24h: this.formatPrice(arr[6] || price), + price_change_percentage_24h: pct, + last_updated: '', + currency: '¥', + region: 'cn', + type: 'metal', + url: sgeUrl, + }; + sgeMap[code.toUpperCase()] = itemObj; + sgeMap[`SGE_${code.toUpperCase()}`] = itemObj; + parsedCount++; + } + const cost = Date.now() - t0; + if (this.logDetails) this.logDetails.networkTasks.push({ name: '新浪贵金属(SGE)', cost, status: `成功解析 ${parsedCount} 个品种` }); + } catch (e) { + const cost = Date.now() - t0; + const errStr = `贵金属请求失败: ${e.message || e}`; + if (this.logDetails) { + this.logDetails.networkTasks.push({ name: '新浪贵金属(SGE)', cost, status: `❌ ${errStr}` }); + this.logDetails.errors.push(errStr); + } + } + })() + ); + } + + // 3. 腾讯财经任务 (A股/港股/美股/基金/现货金银,超时 4s) + if (tencentKeys.length) { + tasks.push( + (async () => { + const t0 = Date.now(); + try { + const tencentUrl = `http://qt.gtimg.cn/utf8/q=${tencentKeys.join(',')}`; + const req = new Request(tencentUrl); + req.timeoutInterval = 4; + const tencentRes = await req.loadString(); + const lines = tencentRes.split(';').map((s) => s.trim()).filter(Boolean); + let parsedCount = 0; + for (const line of lines) { + const [k, v] = line.split('='); + if (!v) continue; + const key = k.replace('v_', ''); + const content = v.replace(/^"/, '').replace(/"$/, ''); + if (key.startsWith('s_jj')) { + const arr = content.split('~'); + const code = (arr[0] || key.replace('s_jj', '')).toUpperCase(); + tencentMap[code] = { + id: code, + name: arr[1] || '基金', + symbol: code, + current_price: this.formatPrice(arr[3], 4, 4), + high_24h: this.formatPrice(arr[4], 4, 4), + low_24h: '-', + price_change_percentage_24h: parseFloat(arr[5]) || 0, + last_updated: arr[2] || '', + currency: '¥', + region: 'cn', + type: 'fund', + url: `https://fund.eastmoney.com/${code}.html`, + }; + tencentMap[`s_jj${code}`] = tencentMap[code]; + parsedCount++; + } else if (key.startsWith('hf_')) { + const arr = content.split(','); + const cleanCode = key.replace('hf_', '').toUpperCase(); + let metalUrl = 'https://wap.eastmoney.com/quote/stock/122.XAU.html'; + if (cleanCode.includes('XAG') || cleanCode.includes('SILVER') || cleanCode.includes('银')) { + metalUrl = 'https://wap.eastmoney.com/quote/stock/122.XAG.html'; + } + tencentMap[key] = { + id: key, + name: arr[13] || '现货贵金属', + symbol: cleanCode, + current_price: this.formatPrice(arr[0]), + high_24h: this.formatPrice(arr[4]), + low_24h: this.formatPrice(arr[5]), + price_change_percentage_24h: parseFloat(arr[1]) || 0, + last_updated: arr[6] || '', + currency: '$', + region: 'intl', + type: 'metal', + url: metalUrl, + }; + parsedCount++; + } else { + const arr = content.split('~'); + const lowerKey = key.toLowerCase(); + const isCN = lowerKey.startsWith('sh') || lowerKey.startsWith('sz') || lowerKey.startsWith('bj'); + const isHK = lowerKey.startsWith('hk'); + const codeOnly = arr[2] || key; + const displaySymbol = codeOnly.replace(/^US/i, '').replace(/\..*$/, '').toUpperCase(); + const stockItem = { + id: key, + name: arr[1] || key, + symbol: displaySymbol, + current_price: this.formatPrice(arr[3]), + high_24h: this.formatPrice(arr[33] || arr[4]), + low_24h: this.formatPrice(arr[34] || arr[5]), + price_change_percentage_24h: parseFloat(arr[32]) || 0, + last_updated: arr[30] || '', + currency: isCN ? '¥' : isHK ? 'HK$' : '$', + region: (isCN || isHK) ? 'cn' : 'intl', + type: 'stock', + url: `https://gu.qq.com/${key}`, + }; + tencentMap[key] = stockItem; + tencentMap[lowerKey] = stockItem; + tencentMap[key.toUpperCase()] = stockItem; + tencentMap[codeOnly] = stockItem; + parsedCount++; + } + } + const cost = Date.now() - t0; + if (this.logDetails) this.logDetails.networkTasks.push({ name: '腾讯财经(股票/基金)', cost, status: `成功解析 ${parsedCount} 个标的` }); + } catch (e) { + const cost = Date.now() - t0; + const errStr = `腾讯财经请求失败: ${e.message || e}`; + if (this.logDetails) { + this.logDetails.networkTasks.push({ name: '腾讯财经(股票/基金)', cost, status: `❌ ${errStr}` }); + this.logDetails.errors.push(errStr); + } + } + })() + ); + } + + // 4. 加密货币任务 (CoinGecko,超时 5s,带安全容错) + if (cryptoKeys.length) { + tasks.push( + (async () => { + const t0 = Date.now(); + try { + const ids = await this.transforBtcType(cryptoKeys.join(',')); + let response; + try { + const req = new Request(`${this.endpoint}/coins/markets?vs_currency=usd&ids=${ids}`); + req.timeoutInterval = 5; + response = await req.loadJSON(); + } catch (err) { + response = null; + } + if (!Array.isArray(response) || !response.length) { + response = await this.getAllJson(); + } + let parsedCount = 0; + if (Array.isArray(response)) { + response.forEach((it) => { + const sym = (it.symbol || '').toUpperCase(); + const cryptoItem = { + id: it.id, + name: it.name, + image: it.image, + symbol: sym, + current_price: this.formatPrice(it.current_price), + high_24h: this.formatPrice(it.high_24h), + low_24h: this.formatPrice(it.low_24h), + price_change_percentage_24h: it.price_change_percentage_24h || 0, + last_updated: it.last_updated, + currency: '$', + region: 'intl', + type: 'crypto', + url: `https://www.coingecko.com/zh/${encodeURIComponent('数字货币')}/${it.id}`, + }; + cryptoMap[sym] = cryptoItem; + cryptoMap[it.id] = cryptoItem; + cryptoMap[(it.symbol || '').toLowerCase()] = cryptoItem; + parsedCount++; + }); + } + const cost = Date.now() - t0; + if (this.logDetails) this.logDetails.networkTasks.push({ name: 'CoinGecko虚拟币', cost, status: `成功解析 ${parsedCount} 个币种` }); + } catch (e) { + const cost = Date.now() - t0; + const errStr = `虚拟币请求失败: ${e.message || e}`; + if (this.logDetails) { + this.logDetails.networkTasks.push({ name: 'CoinGecko虚拟币', cost, status: `❌ ${errStr}` }); + this.logDetails.errors.push(errStr); + } + } + })() + ); + } + + // 并行等待所有任务完成 + if (tasks.length) { + const netStart = Date.now(); + await Promise.allSettled(tasks); + if (this.logDetails) { + this.logDetails.networkTotalTime = Date.now() - netStart; + } + } + + const list = []; + const seenIds = new Set(); + + for (const it of orderedItems) { + let match = null; + const key = it.key; + const up = (it.origin || key).toUpperCase(); + const clean = up.replace(/[^A-Z0-9]/g, ''); + + if (it.type === 'oil') { + if (clean === '98' || key.includes('98')) match = oilMap['98']; + else if (clean === '95' || key.includes('95')) match = oilMap['95']; + else if (clean === '92' || key.includes('92')) match = oilMap['92']; + else if (clean === '0' || key.includes('0') || key.includes('柴油')) match = oilMap['0']; + } else if (it.type === 'crypto') { + match = cryptoMap[key.toUpperCase()] || cryptoMap[key.toLowerCase()] || cryptoMap[key]; + } else if (it.type === 'metal') { + match = sgeMap[key] || sgeMap[key.toUpperCase()] || tencentMap[key]; + } else if (it.type === 'fund') { + match = tencentMap[it.qKey] || tencentMap[key]; + } else { + match = tencentMap[key] || tencentMap[key.toLowerCase()] || tencentMap[key.toUpperCase()] || + sgeMap[key] || sgeMap[key.toUpperCase()] || cryptoMap[key.toUpperCase()]; + } + + if (match && !seenIds.has(match.id)) { + seenIds.add(match.id); + list.push(match); + } + } + + const finalDataSource = list; + if (finalDataSource && finalDataSource.length > 0) { + this.dataSource = finalDataSource; + this.settings.dataSource = finalDataSource; + this.settings.lastUpdatedTime = Date.now(); + this.saveSettings(false); + } else if (this.settings.dataSource && this.settings.dataSource.length) { + // 容错兜底:若本次所有接口均异常,保留旧数据展示,不覆盖 + this.dataSource = this.settings.dataSource; + } + return this.dataSource; + } catch (e) { + console.log(e); + return this.dataSource || []; + } + }; + + transforBtcType = async (params) => { + let btcType; + if (params) btcType = params.split(','); + + const btcAll = await this.getAllJson(); + if (!Array.isArray(btcAll)) return ''; + + if (!btcType) { + return btcAll + .slice(0, 8) + .map((item) => item.id) + .join(','); + } + + return btcType + .map((item) => { + const target = item.trim().toUpperCase(); + const result = + btcAll.find( + (btc) => + (btc.symbol && btc.symbol.toUpperCase() === target) || + (btc.id && btc.id.toUpperCase() === target) + ) || {}; + return result.id; + }) + .filter((item) => !!item) + .join(','); + }; + + getAllJson = async () => { + const cachePath = this.FILE_MGR.joinPath( + this.FILE_MGR.libraryDirectory(), + `${Script.name()}/datas` + ); + const filename = `${cachePath}/BTC.json`; + if (!this.FILE_MGR.fileExists(cachePath)) + this.FILE_MGR.createDirectory(cachePath, true); + + let needFetch = true; + if (this.FILE_MGR.fileExists(filename)) { + const modDate = this.FILE_MGR.modificationDate(filename); + if (modDate && Date.now() - modDate.getTime() < 24 * 60 * 60 * 1000) { + needFetch = false; + } + } + + if (!needFetch) { + try { + const data = Data.fromFile(filename).toRawString(); + return JSON.parse(data); + } catch (e) { + needFetch = true; + } + } + + try { + const req = new Request(`${this.endpoint}/coins/markets?vs_currency=usd&ids=`); + req.timeoutInterval = 4; + const response = await req.loadJSON(); + if (Array.isArray(response) && response.length) { + const data = Data.fromString(JSON.stringify(response)); + this.FILE_MGR.write(filename, data); + return response; + } + } catch (e) { + console.log(e); + } + + if (this.FILE_MGR.fileExists(filename)) { + const data = Data.fromFile(filename).toRawString(); + return JSON.parse(data); + } + return []; + }; + + renderImage = async (uri) => { + return this.$request.get(uri, 'IMG'); + }; + + notSupport(w) { + const stack = w.addStack(); + stack.addText('暂无对应数据'); + return w; + } + + getSmallBg = async (rawImg, idOrSym = 'default') => { + try { + if (!rawImg) return null; + if (!this.FILE_MGR.fileExists(this.cacheImage)) { + this.FILE_MGR.createDirectory(this.cacheImage, true); + } + const safeKey = String(idOrSym).replace(/[^a-zA-Z0-9_\-\.]/g, '_'); + const bgFilePath = this.FILE_MGR.joinPath(this.cacheImage, `bg_v2_${safeKey}.png`); + + if (this.FILE_MGR.fileExists(bgFilePath)) { + return Image.fromFile(bgFilePath); + } + + const webview = new WebView(); + let js = `const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const canvasSize = 300; + canvas.width = canvasSize; + canvas.height = canvasSize; + ctx.globalAlpha = 0.3; + + const drawSize = 275; + const offsetX = -58; + const offsetY = -78; + ctx.drawImage( + img, + offsetX, + offsetY, + drawSize, + drawSize + ); + const uri = canvas.toDataURL(); + completion(uri); + }; + img.src = 'data:image/png;base64,${Data.fromPNG(rawImg).toBase64String()}';`; + let image = await webview.evaluateJavaScript(js, true); + image = image.replace(/^data\:image\/\w+;base64,/, ''); + const finalImg = Image.fromData(Data.fromBase64String(image)); + if (finalImg) { + this.FILE_MGR.writeImage(bgFilePath, finalImg); + return finalImg; + } + return rawImg; + } catch (e) { + return rawImg; + } + }; + + shuffle = (array) => { + const arr = [...array]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; + }; + + getFilteredDataSource = () => { + if (!this.dataSource || !this.dataSource.length) return []; + if (this.settings.randomDisplay === '1' && this.dataSource.length > 1) { + return this.shuffle(this.dataSource); + } + return this.dataSource; + }; + + renderSmall = async (widget) => { + const list = this.getFilteredDataSource(); + if (!list || !list.length) { + widget.setPadding(16, 16, 16, 16); + const tip = widget.addText('未找到对应资产\n请检查关注种类与范围'); + tip.font = Font.systemFont(12); + tip.textColor = Color.gray(); + return widget; + } + + const market = list[0] || {}; + widget.url = market.url || 'https://www.coingecko.com/zh'; + + const rawImage = await this.getItemImage(market); + const backgroundImg = await this.getSmallBg(rawImage, market.id || market.symbol); + widget.backgroundColor = this.backGroundColor; + widget.backgroundImage = backgroundImg; + widget.setPadding(12, 12, 12, 12); + + const topHeader = widget.addStack(); + topHeader.layoutHorizontally(); + topHeader.centerAlignContent(); + topHeader.addSpacer(); + + const categoryTag = this.getCategoryTag(market); + const tagStyle = this.getCategoryTagColor(categoryTag); + const tagStack = topHeader.addStack(); + tagStack.setPadding(1, 3.5, 1, 3.5); + tagStack.cornerRadius = 3; + tagStack.backgroundColor = tagStyle.bg; + const tagText = tagStack.addText(categoryTag); + tagText.textColor = tagStyle.text; + tagText.font = Font.boldSystemFont(8); + + topHeader.addSpacer(5); + + const smallTitleColor = Color.dynamic(new Color('#2C2C2E'), Color.white()); + + const coin = topHeader.addText(market.symbol ? market.symbol.toUpperCase() : ''); + coin.font = Font.boldSystemFont(22); + coin.textColor = smallTitleColor; + coin.lineLimit = 1; + coin.minimumScaleFactor = 0.5; + + const isOil = market.type === 'oil'; + const name = widget.addText(market.name || ''); + name.font = Font.systemFont(10); + name.textColor = Color.gray(); + name.rightAlignText(); + name.lineLimit = 1; + widget.addSpacer(); + + const changeVal = Number(market.price_change_percentage_24h) || 0; + let trendSign = ''; + if (changeVal > 0) { + trendSign = '+'; + } else if (changeVal < 0) { + trendSign = ''; + } + const trendTextStr = isOil + ? (market.expected_change_amount && market.expected_change_amount !== '0.00' + ? `预计${market.expected_change_amount}` + : '预计调价0.00') + : `${trendSign}${changeVal.toFixed(2)}%`; + + const trend = widget.addText(trendTextStr); + trend.font = Font.semiboldSystemFont(15); + trend.textColor = this.getTrendColor(market, false); + trend.rightAlignText(); + trend.lineLimit = 1; + + const curSym = market.currency || '$'; + const price = widget.addText(`${curSym} ${market.current_price || '0'}`); + price.font = Font.boldSystemFont(24); + price.textColor = smallTitleColor; + price.rightAlignText(); + price.lineLimit = 1; + price.minimumScaleFactor = 0.1; + + const history = widget.addText( + isOil + ? (market.adjust_date || '发改委定价') + : `H: ${market.high_24h || '0'}, L: ${market.low_24h || '0'}` + ); + history.font = Font.systemFont(9.5); + history.textColor = Color.gray(); + history.rightAlignText(); + history.lineLimit = 1; + history.minimumScaleFactor = 0.1; + return widget; + }; + + rowCell = async (rowStack, market) => { + rowStack.url = market.url || 'https://www.coingecko.com/zh'; + rowStack.layoutHorizontally(); + const image = await this.getNormalizedItemIcon(market, 28); + const iconImage = rowStack.addImage(image); + iconImage.imageSize = new Size(28, 28); + + rowStack.addSpacer(10); + + const centerStack = rowStack.addStack(); + centerStack.layoutVertically(); + + const topCenterStack = centerStack.addStack(); + topCenterStack.layoutHorizontally(); + topCenterStack.centerAlignContent(); + + const titleText = topCenterStack.addText((market.symbol || '').toUpperCase()); + titleText.textColor = this.widgetColor; + titleText.font = this.provideFont('semibold', 15); + titleText.lineLimit = 1; + + topCenterStack.addSpacer(6); + + const categoryTag = this.getCategoryTag(market); + const tagStyle = this.getCategoryTagColor(categoryTag); + const tagStack = topCenterStack.addStack(); + tagStack.setPadding(1.5, 4, 1.5, 4); + tagStack.cornerRadius = 3; + tagStack.backgroundColor = tagStyle.bg; + const tagText = tagStack.addText(categoryTag); + tagText.textColor = tagStyle.text; + tagText.font = Font.boldSystemFont(8); + + topCenterStack.addSpacer(); + + const curSym = market.currency || '$'; + const priceText = topCenterStack.addText(`${curSym} ${market.current_price || '0'}`); + priceText.textColor = this.widgetColor; + priceText.font = this.provideFont('medium', 14); + priceText.rightAlignText(); + priceText.lineLimit = 1; + + const bottomCenterStack = centerStack.addStack(); + bottomCenterStack.layoutHorizontally(); + + const isOil = market.type === 'oil'; + const subText = bottomCenterStack.addText(market.name || ''); + subText.textColor = Color.gray(); + subText.font = this.provideFont('regular', 10); + subText.lineLimit = 1; + + bottomCenterStack.addSpacer(); + + const historyText = bottomCenterStack.addText( + isOil + ? (market.adjust_date || '发改委定价') + : `H: ${market.high_24h || '0'}, L: ${market.low_24h || '0'}` + ); + historyText.textColor = Color.gray(); + historyText.font = this.provideFont('regular', 10); + historyText.rightAlignText(); + historyText.lineLimit = 1; + + rowStack.addSpacer(8); + + const rateStack = rowStack.addStack(); + rateStack.size = new Size(72, 28); + rateStack.centerAlignContent(); + rateStack.cornerRadius = 4; + const changeVal = Number(market.price_change_percentage_24h) || 0; + rateStack.backgroundColor = this.getTrendColor(market, true); + + const btnText = isOil + ? (market.expected_change_amount || '0.00') + : ((changeVal >= 0 ? '+' : '') + changeVal.toFixed(2) + '%'); + + const rateText = rateStack.addText(btnText); + rateText.textColor = new Color('#fff', 0.95); + rateText.font = this.provideFont('medium', 13); + rateText.minimumScaleFactor = 0.01; + rateText.lineLimit = 1; + }; + + renderLarge = async (widget) => { + widget.setPadding(12, 12, 12, 12); + const containerStack = widget.addStack(); + containerStack.layoutVertically(); + const list = this.getFilteredDataSource(); + if (!list.length) { + const tip = containerStack.addText('未找到对应资产,请检查关注种类与范围'); + tip.font = Font.systemFont(12); + tip.textColor = Color.gray(); + return widget; + } + const maxLen = Math.min(list.length, 6); + for (let index = 0; index < maxLen; index++) { + const item = list[index]; + const rowCellStack = containerStack.addStack(); + await this.rowCell(rowCellStack, item); + if (index !== maxLen - 1) containerStack.addSpacer(); + } + return widget; + }; + + renderMedium = async (widget) => { + widget.setPadding(12, 12, 12, 12); + const containerStack = widget.addStack(); + containerStack.layoutVertically(); + const list = this.getFilteredDataSource(); + if (!list || !list.length) { + const tip = containerStack.addText('未找到对应资产,请检查关注种类与范围'); + tip.font = Font.systemFont(12); + tip.textColor = Color.gray(); + return widget; + } + const maxLen = Math.min(list.length, 3); + for (let index = 0; index < maxLen; index++) { + const item = list[index]; + if (!item) continue; + const rowCellStack = containerStack.addStack(); + await this.rowCell(rowCellStack, item); + if (index < maxLen - 1) containerStack.addSpacer(); + } + return widget; + }; + + async render() { + await this.init(); + const widget = new ListWidget(); + if (this.widgetFamily === 'small') await this.renderSmall(widget); + await this.getWidgetBackgroundImage(widget); + if (this.widgetFamily === 'medium') await this.renderMedium(widget); + if (this.widgetFamily === 'large') await this.renderLarge(widget); + + + return widget; + } +} + +// @组件代码结束 +await Runing(Widget, '', false); //远程开发环境 + +//version:1.0.0 \ No newline at end of file diff --git a/Scriptable/PriceWidgets.scriptable b/Scriptable/PriceWidgets.scriptable deleted file mode 100644 index f9ffdafa..00000000 --- a/Scriptable/PriceWidgets.scriptable +++ /dev/null @@ -1,12 +0,0 @@ -{ - "always_run_in_app" : false, - "icon" : { - "color" : "deep-green", - "glyph" : "hand-holding-usd" - }, - "name" : "PriceWidgets", - "script" : "\n\/\/ 添加require,是为了vscode中可以正确引入包,以获得自动补全等功能\nif (typeof require === 'undefined') require = importModule;\nconst { DmYY, Runing } = require('.\/DmYY');\n\n\/\/ @组件代码开始\nclass Widget extends DmYY {\n constructor(arg) {\n super(arg);\n this.en = ' btc';\n this.name = '比特币';\n config.runsInApp &&\n this.registerAction(\n '关注种类',\n async () => {\n return this.setAlertInput('比特币种类', '设置关注种类', {\n btcType: 'BTC,ETH,BNB',\n });\n },\n { name: 'centsign.circle', color: '#feda31' }\n );\n config.runsInApp && this.registerAction('基础设置', this.setWidgetConfig);\n }\n\n format = (str) => {\n return parseInt(str) >= 10 ? str : `0${str}`;\n };\n\n endpoint = 'https:\/\/api.coingecko.com\/api\/v3';\n nomicsEndpoint = 'https:\/\/api.nomics.com\/v1';\n\n dataSource = [];\n\n init = async () => {\n if (this.settings.dataSource && !config.runsInApp) {\n this.dataSource = this.settings.dataSource;\n } else {\n await this.cacheData(this.settings.btcType);\n }\n this.cacheData(this.settings.btcType);\n };\n\n cacheData = async (params) => {\n try {\n const ids = await this.transforBtcType(params);\n let response = await this.$request.get(\n `${this.endpoint}\/coins\/markets?vs_currency=usd&ids=${ids}`,\n 'STRING'\n );\n this.dataSource = [];\n response = JSON.parse(response);\n if (!response.length) response = await this.getAllJson();\n if (ids) {\n const idsData = ids.split(',');\n idsData.forEach((id) => {\n const it = response.find((item) => item.id === id);\n if (it && this.dataSource.length < 6) {\n this.dataSource.push({\n id: it.id,\n name: it.name,\n image: it.image,\n symbol: it.symbol.toUpperCase(),\n current_price: '' + it.current_price,\n high_24h: it.high_24h,\n low_24h: it.low_24h,\n price_change_percentage_24h: it.price_change_percentage_24h,\n last_updated: it.last_updated,\n });\n }\n });\n } else {\n response.forEach((it, index) => {\n if (index > 5) return;\n this.dataSource.push({\n id: it.id,\n name: it.name,\n image: it.image,\n symbol: it.symbol.toUpperCase(),\n current_price: '' + it.current_price,\n high_24h: it.high_24h,\n low_24h: it.low_24h,\n price_change_percentage_24h: it.price_change_percentage_24h,\n last_updated: it.last_updated,\n });\n });\n }\n\n this.settings.dataSource = this.dataSource;\n this.saveSettings(false);\n } catch (e) {\n console.log(e);\n return [];\n }\n };\n\n transforBtcType = async (params) => {\n let btcType;\n if (params) btcType = params.split(',');\n\n const btcAll = await this.getAllJson();\n\n if (!btcType)\n return btcAll\n .filter((item, index) => index < 6)\n .map((item) => item.id)\n .join(',');\n\n return btcType\n .map((item) => {\n const result =\n btcAll.find((btc) => btc.symbol.toUpperCase() === item) || {};\n return result.id;\n })\n .filter((item) => !!item)\n .join(',');\n };\n\n getAllJson = async () => {\n const cachePath = this.FILE_MGR.joinPath(\n this.FILE_MGR.libraryDirectory(),\n `${Script.name()}\/datas`\n );\n const filename = `${cachePath}\/BTC.json`;\n if (!this.FILE_MGR.fileExists(cachePath))\n this.FILE_MGR.createDirectory(cachePath, true);\n\n if (this.FILE_MGR.fileExists(filename)) {\n const data = Data.fromFile(`${cachePath}\/BTC.json`).toRawString();\n return JSON.parse(data);\n } else {\n const response = await this.$request.get(\n `${this.endpoint}\/coins\/markets?vs_currency=usd&ids=`\n );\n const data = Data.fromString(JSON.stringify(response));\n this.FILE_MGR.write(filename, data);\n return response;\n }\n };\n\n renderImage = async (uri) => {\n return this.$request.get(uri, 'IMG');\n };\n\n notSupport(w) {\n const stack = w.addStack();\n stack.addText('暂不支持');\n return w;\n }\n\n getSmallBg = async (url) => {\n const webview = new WebView();\n let js = `const canvas = document.createElement('canvas');\n const ctx = canvas.getContext('2d');\n const img = new Image();\n img.crossOrigin = 'anonymous';\n img.onload = () => {\n const { width, height } = img\n canvas.width = width\n canvas.height = height\n ctx.globalAlpha = 0.3\n ctx.drawImage(\n img,\n -width \/ 2 + 50,\n -height \/ 2 + 50,\n width,\n height\n )\n const uri = canvas.toDataURL()\n completion(uri);\n };\n img.src = 'data:image\/png;base64,${Data.fromPNG(url).toBase64String()}'`;\n let image = await webview.evaluateJavaScript(js, true);\n image = image.replace(\/^data\\:image\\\/\\w+;base64,\/, '');\n return Image.fromData(Data.fromBase64String(image));\n };\n\n renderSmall = async (widget) => {\n const market = this.dataSource[0] || {};\n\n widget.url = `https:\/\/www.coingecko.com\/zh\/${encodeURIComponent('数字货币')}\/${market.id}`;\n\n const image = await this.renderImage(market.image);\n const backgroundImg = await this.getSmallBg(image);\n widget.backgroundColor = this.backGroundColor;\n widget.backgroundImage = backgroundImg;\n widget.setPadding(12, 12, 12, 12);\n const coin = widget.addText(market.symbol.toUpperCase());\n coin.font = Font.heavySystemFont(24);\n coin.textColor = this.widgetColor;\n\n coin.rightAlignText();\n const name = widget.addText(market.name);\n name.font = Font.systemFont(10);\n name.textColor = Color.gray();\n name.rightAlignText();\n widget.addSpacer();\n\n const trend = widget.addText(\n `${market.price_change_percentage_24h.toFixed(2)}%`\n );\n trend.font = Font.semiboldSystemFont(16);\n trend.textColor =\n market.price_change_percentage_24h >= 0 ? Color.green() : Color.red();\n\n trend.rightAlignText();\n const price = widget.addText(`$ ${market.current_price}`);\n price.font = Font.boldSystemFont(28);\n price.textColor = this.widgetColor;\n price.rightAlignText();\n price.lineLimit = 1;\n price.minimumScaleFactor = 0.1;\n const history = widget.addText(\n `H: ${market.high_24h}, L: ${market.low_24h}`\n );\n history.font = Font.systemFont(10);\n history.textColor = Color.gray();\n history.rightAlignText();\n history.lineLimit = 1;\n history.minimumScaleFactor = 0.1;\n return widget;\n };\n\n rowCell = async (rowStack, market) => {\n rowStack.url = `https:\/\/www.coingecko.com\/zh\/${encodeURIComponent('数字货币')}\/${market.id}`;\n rowStack.layoutHorizontally();\n const image = await this.renderImage(market.image);\n const iconImage = rowStack.addImage(image);\n iconImage.imageSize = new Size(28, 28);\n iconImage.cornerRadius = 14;\n\n rowStack.addSpacer(10);\n\n const centerStack = rowStack.addStack();\n centerStack.layoutVertically();\n\n const topCenterStack = centerStack.addStack();\n topCenterStack.layoutHorizontally();\n\n const titleText = topCenterStack.addText(market.symbol);\n titleText.textColor = this.widgetColor;\n titleText.font = this.provideFont('semibold', 16);\n\n topCenterStack.addSpacer();\n\n const priceText = topCenterStack.addText(`$ ${market.current_price}`);\n priceText.textColor = this.widgetColor;\n priceText.font = this.provideFont('semibold', 15);\n priceText.rightAlignText();\n\n const bottomCenterStack = centerStack.addStack();\n bottomCenterStack.layoutHorizontally();\n\n const subText = bottomCenterStack.addText(market.name);\n subText.textColor = Color.gray();\n subText.font = this.provideFont('semibold', 10);\n\n bottomCenterStack.addSpacer();\n\n const historyText = bottomCenterStack.addText(\n `H: ${market.high_24h}, L: ${market.low_24h}`\n );\n historyText.textColor = Color.gray();\n historyText.font = this.provideFont('semibold', 10);\n historyText.rightAlignText();\n\n rowStack.addSpacer(8);\n\n const rateStack = rowStack.addStack();\n rateStack.size = new Size(72, 28);\n rateStack.centerAlignContent();\n rateStack.cornerRadius = 4;\n rateStack.backgroundColor =\n market.price_change_percentage_24h >= 0 ? Color.green() : Color.red();\n const rateText = rateStack.addText(\n (market.price_change_percentage_24h >= 0 ? '+' : '') +\n market.price_change_percentage_24h.toFixed(2) +\n '%'\n );\n rateText.textColor = new Color('#fff', 0.9);\n rateText.font = this.provideFont('semibold', 14);\n rateText.minimumScaleFactor = 0.01;\n rateText.lineLimit = 1;\n };\n\n renderLarge = async (widget) => {\n widget.setPadding(12, 12, 12, 12);\n const containerStack = widget.addStack();\n containerStack.layoutVertically();\n for (let index = 0; index < this.dataSource.length; index++) {\n const item = this.dataSource[index];\n const rowCellStack = containerStack.addStack();\n await this.rowCell(rowCellStack, item);\n if (index !== this.dataSource.length - 1) containerStack.addSpacer();\n }\n return widget;\n };\n\n renderMedium = async (widget) => {\n widget.setPadding(12, 12, 12, 12);\n const containerStack = widget.addStack();\n containerStack.layoutVertically();\n for (let index = 0; index < this.dataSource.length; index++) {\n if (index > 2) return;\n const item = this.dataSource[index];\n const rowCellStack = containerStack.addStack();\n await this.rowCell(rowCellStack, item);\n if (index !== 2) containerStack.addSpacer();\n }\n return widget;\n };\n\n \/**\n * 渲染函数,函数名固定\n * 可以根据 this.widgetFamily 来判断小组件尺寸,以返回不同大小的内容\n *\/\n async render() {\n await this.init();\n const widget = new ListWidget();\n if (this.widgetFamily === 'small') await this.renderSmall(widget);\n await this.getWidgetBackgroundImage(widget);\n if (this.widgetFamily === 'medium') await this.renderMedium(widget);\n if (this.widgetFamily === 'large') await this.renderLarge(widget);\n return widget;\n }\n}\n\n\/\/ @组件代码结束\nawait Runing(Widget, '', false); \/\/远程开发环境\n\n\/\/version:1.0.0", - "share_sheet_inputs" : [ - - ] -} \ No newline at end of file diff --git a/Surge/Revenuecat.sgmodule b/Surge/Revenuecat.sgmodule index fd485d97..aa4e3571 100644 --- a/Surge/Revenuecat.sgmodule +++ b/Surge/Revenuecat.sgmodule @@ -8,7 +8,7 @@ ^https:\/\/(api\.revenuecat|isi\.csan\.goodnotes)\.com\/.+ header-del x-revenuecat-etag [URL Rewrite] -^https:\/\/(api\.revenuecat|isi\.csan\.goodnotes)\.com\/.+\/(receipts$|subscribers\/[^/]+$|offers$) https://api.langkhach89.workers.dev header +^https:\/\/(api\.revenuecat|isi\.csan\.goodnotes)\.com\/.+\/(receipts$|subscribers\/[^/]+$|offers$) https://rc-backup.lovebabyforever.workers.dev header [MITM] hostname = %APPEND% api.revenuecat.com, isi.csan.goodnotes.com \ No newline at end of file