update: 2026-03-28 20:59
This commit is contained in:
136
InfoGenie-frontend/public/toolbox/实用工具/JavaScript编译器/app.js
Normal file
136
InfoGenie-frontend/public/toolbox/实用工具/JavaScript编译器/app.js
Normal file
@@ -0,0 +1,136 @@
|
||||
(function () {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const codeEl = $("code");
|
||||
const runBtn = $("runBtn");
|
||||
const copyBtn = $("copyBtn");
|
||||
const pasteBtn = $("pasteBtn");
|
||||
const clearBtn = $("clearBtn");
|
||||
const outputEl = $("output");
|
||||
const sandboxEl = $("sandbox");
|
||||
|
||||
// 以JS方式设置带换行的占位符,避免HTML属性中的 \n 无效
|
||||
codeEl.placeholder = "在此编写或粘贴 JavaScript 代码…\n例如:\nconsole.log('Hello, InfoGenie!');";
|
||||
|
||||
let sandboxReady = false;
|
||||
|
||||
// 沙箱页面(srcdoc)内容:拦截 console、收集错误、支持 async/await
|
||||
const sandboxHtml = `<!doctype html><html><head><meta charset=\"utf-8\"></head><body>
|
||||
<script>
|
||||
(function(){
|
||||
function serialize(value){
|
||||
try {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'function') return value.toString();
|
||||
if (value === undefined) return 'undefined';
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
} catch (e) {
|
||||
try { return String(value); } catch(_){ return Object.prototype.toString.call(value); }
|
||||
}
|
||||
}
|
||||
|
||||
['log','info','warn','error'].forEach(level => {
|
||||
const orig = console[level];
|
||||
console[level] = (...args) => {
|
||||
try { parent.postMessage({type:'console', level, args: args.map(serialize)}, '*'); } catch(_){ }
|
||||
try { orig && orig.apply(console, args); } catch(_){ }
|
||||
};
|
||||
});
|
||||
|
||||
window.onerror = function(message, source, lineno, colno, error){
|
||||
var stack = error && error.stack ? error.stack : (source + ':' + lineno + ':' + colno);
|
||||
parent.postMessage({type:'error', message: String(message), stack}, '*');
|
||||
};
|
||||
|
||||
parent.postMessage({type:'ready'}, '*');
|
||||
|
||||
window.addEventListener('message', async (e) => {
|
||||
const data = e.data;
|
||||
if (!data || data.type !== 'code') return;
|
||||
try {
|
||||
// 用 async IIFE 包裹,支持顶层 await
|
||||
await (async () => { eval(data.code); })();
|
||||
parent.postMessage({type:'done'}, '*');
|
||||
} catch (err) {
|
||||
parent.postMessage({type:'error', message: (err && err.message) || String(err), stack: err && err.stack}, '*');
|
||||
}
|
||||
}, false);
|
||||
})();
|
||||
<\/script>
|
||||
</body></html>`;
|
||||
|
||||
// 初始化沙箱
|
||||
sandboxEl.srcdoc = sandboxHtml;
|
||||
sandboxEl.addEventListener('load', () => {
|
||||
// 等待 ready 消息
|
||||
});
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const data = e.data;
|
||||
if (!data) return;
|
||||
switch (data.type) {
|
||||
case 'ready':
|
||||
sandboxReady = true;
|
||||
break;
|
||||
case 'console':
|
||||
(data.args || []).forEach((text) => appendLine(text, data.level));
|
||||
break;
|
||||
case 'error':
|
||||
appendLine('[错误] ' + data.message, 'error');
|
||||
if (data.stack) appendLine(String(data.stack), 'error');
|
||||
break;
|
||||
case 'done':
|
||||
tip('执行完成。');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
runBtn.addEventListener('click', () => {
|
||||
const code = codeEl.value || '';
|
||||
if (!code.trim()) { tip('请先输入要执行的代码。'); return; }
|
||||
outputEl.textContent = '';
|
||||
if (!sandboxReady) tip('沙箱初始化中,稍候执行…');
|
||||
// 发送代码到沙箱执行
|
||||
try {
|
||||
sandboxEl.contentWindow.postMessage({ type: 'code', code }, '*');
|
||||
} catch (err) {
|
||||
appendLine('[错误] ' + ((err && err.message) || String(err)), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeEl.value);
|
||||
tip('已复制到剪贴板。');
|
||||
} catch (err) {
|
||||
tip('复制失败,请检查剪贴板权限。');
|
||||
}
|
||||
});
|
||||
|
||||
pasteBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const txt = await navigator.clipboard.readText();
|
||||
if (txt) codeEl.value = txt;
|
||||
tip('已粘贴剪贴板内容。');
|
||||
} catch (err) {
|
||||
tip('粘贴失败,请允许访问剪贴板。');
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
outputEl.textContent = '';
|
||||
});
|
||||
|
||||
function appendLine(text, level) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'line ' + (level || 'log');
|
||||
span.textContent = String(text);
|
||||
outputEl.appendChild(span);
|
||||
outputEl.appendChild(document.createTextNode('\n'));
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
}
|
||||
function tip(text){ appendLine('[提示] ' + text, 'info'); }
|
||||
})();
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#e8f8e4" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>JavaScript在线执行器</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="page" role="main">
|
||||
<header class="header">
|
||||
<h1 class="title">JavaScript在线执行器</h1>
|
||||
</header>
|
||||
|
||||
<section class="editor-section">
|
||||
<label class="label" for="code">代码编辑区</label>
|
||||
<textarea id="code" class="editor" placeholder="在此编写或粘贴 JavaScript 代码… \n 例如:\n console.log('Hello, InfoGenie!');" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"></textarea>
|
||||
<div class="toolbar" role="group" aria-label="编辑操作">
|
||||
<button id="pasteBtn" class="btn" type="button" title="从剪贴板粘贴">粘贴代码</button>
|
||||
<button id="copyBtn" class="btn" type="button" title="复制到剪贴板">复制代码</button>
|
||||
<button id="runBtn" class="btn primary" type="button" title="执行当前代码">执行代码</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="output-section">
|
||||
<div class="output-header">
|
||||
<span class="label">结果显示区</span>
|
||||
<button id="clearBtn" class="btn ghost" type="button" title="清空结果">清空结果</button>
|
||||
</div>
|
||||
<pre id="output" class="output" aria-live="polite" aria-atomic="false"></pre>
|
||||
</section>
|
||||
|
||||
<!-- 隐藏的沙箱 iframe,用于安全执行 JS 代码 -->
|
||||
<iframe id="sandbox" class="sandbox" sandbox="allow-scripts" title="执行沙箱" aria-hidden="true"></iframe>
|
||||
</main>
|
||||
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
176
InfoGenie-frontend/public/toolbox/实用工具/JavaScript编译器/styles.css
Normal file
176
InfoGenie-frontend/public/toolbox/实用工具/JavaScript编译器/styles.css
Normal file
@@ -0,0 +1,176 @@
|
||||
/* 全局与主题 */
|
||||
:root {
|
||||
--bg-1: #eaf9e8; /* 淡绿色 */
|
||||
--bg-2: #f4ffd9; /* 淡黄绿色 */
|
||||
--panel: rgba(255, 255, 255, 0.78);
|
||||
--text: #1d2a1d;
|
||||
--muted: #486a48;
|
||||
--accent: #5bb271;
|
||||
--accent-2: #93d18f;
|
||||
--border: rgba(93, 160, 93, 0.25);
|
||||
--code-bg: rgba(255, 255, 255, 0.88);
|
||||
--error: #b00020;
|
||||
--warn: #8a6d3b;
|
||||
--info: #2f6f3a;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留滚动 */
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
-ms-overflow-style: none; /* IE 10+ */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
html::-webkit-scrollbar, body::-webkit-scrollbar { width: 0; height: 0; }
|
||||
|
||||
/* 背景与排版 */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: linear-gradient(180deg, var(--bg-1), var(--bg-2));
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: calc(env(safe-area-inset-top, 12px) + 8px) 14px calc(env(safe-area-inset-bottom, 12px) + 14px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor-section, .output-section {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 20px rgba(64, 129, 64, 0.06);
|
||||
backdrop-filter: saturate(1.2) blur(8px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 36vh;
|
||||
max-height: 48vh;
|
||||
resize: vertical;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
outline: none;
|
||||
background: var(--code-bg);
|
||||
color: #192519;
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.editor::-webkit-scrollbar { width: 0; height: 0; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: #ffffffd6;
|
||||
color: #204220;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { filter: brightness(1.02) saturate(1.02); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary {
|
||||
background: linear-gradient(180deg, var(--accent-2), var(--accent));
|
||||
color: #fff;
|
||||
border-color: rgba(0,0,0,0.06);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.output-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.output {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 28vh;
|
||||
max-height: 40vh;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--code-bg);
|
||||
color: #192519;
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.output::-webkit-scrollbar { width: 0; height: 0; }
|
||||
|
||||
.sandbox { display: none; width: 0; height: 0; border: 0; }
|
||||
|
||||
/* 控制不同日志级别颜色 */
|
||||
.line.log { color: #1f2a1f; }
|
||||
.line.info { color: var(--info); }
|
||||
.line.warn { color: var(--warn); }
|
||||
.line.error { color: var(--error); }
|
||||
.line.tip { color: #507a58; font-style: italic; }
|
||||
|
||||
/* 竖屏优化 */
|
||||
@media (orientation: portrait) {
|
||||
.page { max-width: 640px; }
|
||||
.editor { min-height: 40vh; }
|
||||
.output { min-height: 30vh; }
|
||||
}
|
||||
|
||||
/* 小屏进一步优化 */
|
||||
@media (max-width: 380px) {
|
||||
.btn { padding: 9px 12px; font-size: 13px; }
|
||||
.title { font-size: 20px; }
|
||||
}
|
||||
339
InfoGenie-frontend/public/toolbox/实用工具/Json编辑器/index.html
Normal file
339
InfoGenie-frontend/public/toolbox/实用工具/Json编辑器/index.html
Normal file
@@ -0,0 +1,339 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>📒JSON编辑器</title>
|
||||
|
||||
<!-- CodeMirror 样式与 Lint 样式 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5/lib/codemirror.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5/theme/neo.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5/addon/lint/lint.css" />
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--toolbar-h: 64px;
|
||||
--radius: 14px;
|
||||
--bg-start: #dff7e0; /* 淡绿色 */
|
||||
--bg-end: #f2ffd2; /* 淡黄绿色 */
|
||||
--brand: #4caf50; /* 绿色主题色 */
|
||||
--brand-weak: #7bd07f;
|
||||
--text: #103510;
|
||||
--muted: #366b36;
|
||||
--danger: #e53935;
|
||||
--shadow: 0 10px 25px rgba(16, 53, 16, 0.08);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--bg-start) 0%, var(--bg-end) 100%);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, PingFang SC, Microsoft YaHei, "Noto Sans", Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: auto; /* 保留滚动 */
|
||||
}
|
||||
/* 隐藏滚动条同时保留滚动效果 */
|
||||
html, body { scrollbar-width: none; }
|
||||
html::-webkit-scrollbar, body::-webkit-scrollbar { width: 0; height: 0; }
|
||||
|
||||
.page {
|
||||
min-height: calc(var(--vh, 1vh) * 100);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: var(--toolbar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.title .dot {
|
||||
width: 8px; height: 8px; border-radius: 99px; background: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, .18);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f3210;
|
||||
background: linear-gradient(180deg, #e6ffe6, #d8ffd8);
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform .06s ease, box-shadow .2s ease;
|
||||
}
|
||||
.btn:active { transform: scale(.98); }
|
||||
.btn.primary { background: linear-gradient(180deg, #d7ffda, #caffcf); color: #0c2b0d; }
|
||||
.btn.danger { background: linear-gradient(180deg, #ffe6e6, #ffdcdc); color: #5f1b1b; }
|
||||
|
||||
.editor-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,.55);
|
||||
backdrop-filter: saturate(150%) blur(6px);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* CodeMirror 基本样式与滚动条隐藏 */
|
||||
.CodeMirror {
|
||||
height: calc(var(--vh, 1vh) * 100 - var(--toolbar-h) - 40px);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
padding: 12px 0;
|
||||
background: transparent;
|
||||
}
|
||||
.CodeMirror-scroll { scrollbar-width: none; }
|
||||
.CodeMirror-scroll::-webkit-scrollbar { width: 0; height: 0; }
|
||||
.CodeMirror-gutters { background: transparent; border-right: none; }
|
||||
|
||||
.cm-error-line { background: rgba(229, 57, 53, .08); }
|
||||
|
||||
.statusbar {
|
||||
position: absolute; right: 12px; bottom: 12px;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: rgba(255,255,255,.75);
|
||||
border-radius: 999px; padding: 8px 12px; box-shadow: var(--shadow);
|
||||
font-size: 13px; color: var(--muted);
|
||||
}
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 99px; background: #a0a0a0; box-shadow: 0 0 0 3px rgba(0,0,0,.08); }
|
||||
.statusbar.good .status-dot { background: #27ae60; box-shadow: 0 0 0 3px rgba(39,174,96,.18); }
|
||||
.statusbar.bad .status-dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(229,57,53,.18); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:root { --toolbar-h: 76px; }
|
||||
.title { font-size: 16px; }
|
||||
.btn { padding: 11px 14px; font-size: 15px; }
|
||||
.actions { gap: 6px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- CodeMirror 与 JSONLint 脚本 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/codemirror@5/lib/codemirror.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/codemirror@5/mode/javascript/javascript.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/codemirror@5/addon/lint/lint.js"></script>
|
||||
<!-- JSONLint 提供更完善的错误定位(行/列) -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsonlint/1.6.0/jsonlint.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/codemirror@5/addon/lint/json-lint.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<div class="title">
|
||||
<span class="dot"></span>
|
||||
<span>📒JSON 编辑器</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="pasteBtn" title="从剪贴板粘贴">粘贴</button>
|
||||
<button class="btn" id="copyBtn" title="复制到剪贴板">复制</button>
|
||||
<button class="btn primary" id="formatBtn" title="格式化为缩进">格式化</button>
|
||||
<button class="btn" id="minifyBtn" title="压缩为单行">压缩</button>
|
||||
<button class="btn danger" id="clearBtn" title="清空内容">清空</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="editor-wrap">
|
||||
<textarea id="jsonInput" spellcheck="false">{
|
||||
"hello": "world",
|
||||
"list": [1, 2, 3],
|
||||
"nested": { "a": true, "b": null }
|
||||
}</textarea>
|
||||
<div id="statusbar" class="statusbar" aria-live="polite">
|
||||
<span class="status-dot"></span>
|
||||
<span id="statusText">就绪</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 解决移动端 100vh 问题,保证竖屏高度正确
|
||||
function setVH() {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
}
|
||||
setVH();
|
||||
window.addEventListener('resize', setVH);
|
||||
window.addEventListener('orientationchange', setVH);
|
||||
|
||||
// 初始化 CodeMirror
|
||||
const cm = CodeMirror.fromTextArea(document.getElementById('jsonInput'), {
|
||||
mode: { name: 'javascript', json: true },
|
||||
theme: 'neo',
|
||||
lineNumbers: true,
|
||||
lint: true,
|
||||
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
||||
tabSize: 2,
|
||||
indentUnit: 2,
|
||||
lineWrapping: false,
|
||||
autofocus: true,
|
||||
});
|
||||
|
||||
const statusbar = document.getElementById('statusbar');
|
||||
const statusText = document.getElementById('statusText');
|
||||
let lastErrorLine = null;
|
||||
|
||||
function updateStatusOK() {
|
||||
statusbar.classList.add('good');
|
||||
statusbar.classList.remove('bad');
|
||||
statusText.textContent = 'JSON 有效';
|
||||
}
|
||||
function updateStatusError(msg, line, col) {
|
||||
statusbar.classList.remove('good');
|
||||
statusbar.classList.add('bad');
|
||||
statusText.textContent = msg;
|
||||
if (typeof line === 'number') {
|
||||
if (lastErrorLine != null) {
|
||||
cm.removeLineClass(lastErrorLine - 1, 'background', 'cm-error-line');
|
||||
}
|
||||
lastErrorLine = line;
|
||||
cm.addLineClass(line - 1, 'background', 'cm-error-line');
|
||||
}
|
||||
}
|
||||
|
||||
function clearErrorHighlight() {
|
||||
if (lastErrorLine != null) {
|
||||
cm.removeLineClass(lastErrorLine - 1, 'background', 'cm-error-line');
|
||||
lastErrorLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
const debounce = (fn, wait = 300) => {
|
||||
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const txt = cm.getValue();
|
||||
if (!txt.trim()) {
|
||||
clearErrorHighlight();
|
||||
statusbar.classList.remove('good','bad');
|
||||
statusText.textContent = '空内容';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 使用 jsonlint 以获得行列信息
|
||||
window.jsonlint.parse(txt);
|
||||
clearErrorHighlight();
|
||||
updateStatusOK();
|
||||
} catch (e) {
|
||||
// e.message 形如:"Parse error on line 3:\n...\nExpected 'STRING', got 'undefined'"
|
||||
let line, col;
|
||||
const lineMatch = /line\s+(\d+)/i.exec(e.message || '');
|
||||
if (lineMatch) line = parseInt(lineMatch[1], 10);
|
||||
const colMatch = /column\s+(\d+)/i.exec(e.message || '');
|
||||
if (colMatch) col = parseInt(colMatch[1], 10);
|
||||
const msg = (e.message || 'JSON 解析错误').split('\n')[0];
|
||||
updateStatusError(msg, line, col);
|
||||
}
|
||||
};
|
||||
|
||||
cm.on('change', debounce(validate, 250));
|
||||
// 首次校验
|
||||
validate();
|
||||
|
||||
// 按钮逻辑
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const pasteBtn = document.getElementById('pasteBtn');
|
||||
const formatBtn = document.getElementById('formatBtn');
|
||||
const minifyBtn = document.getElementById('minifyBtn');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
statusText.textContent = '已复制到剪贴板';
|
||||
statusbar.classList.add('good'); statusbar.classList.remove('bad');
|
||||
} catch (err) {
|
||||
// 作为回退方案
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; document.body.appendChild(ta);
|
||||
ta.select(); document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
statusText.textContent = '复制完成(回退方案)';
|
||||
statusbar.classList.add('good'); statusbar.classList.remove('bad');
|
||||
}
|
||||
}
|
||||
|
||||
async function readFromClipboard() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
return text;
|
||||
} catch (err) {
|
||||
const text = window.prompt('浏览器限制了读取剪贴板,请粘贴到此:');
|
||||
return text || '';
|
||||
}
|
||||
}
|
||||
|
||||
copyBtn.addEventListener('click', () => copyToClipboard(cm.getValue()));
|
||||
|
||||
pasteBtn.addEventListener('click', async () => {
|
||||
const text = await readFromClipboard();
|
||||
if (text) {
|
||||
cm.setValue(text);
|
||||
cm.focus();
|
||||
}
|
||||
});
|
||||
|
||||
formatBtn.addEventListener('click', () => {
|
||||
const txt = cm.getValue();
|
||||
try {
|
||||
const obj = JSON.parse(txt);
|
||||
cm.setValue(JSON.stringify(obj, null, 2));
|
||||
cm.focus();
|
||||
updateStatusOK();
|
||||
} catch (e) {
|
||||
updateStatusError('格式化失败:内容不是有效 JSON');
|
||||
}
|
||||
});
|
||||
|
||||
minifyBtn.addEventListener('click', () => {
|
||||
const txt = cm.getValue();
|
||||
try {
|
||||
const obj = JSON.parse(txt);
|
||||
cm.setValue(JSON.stringify(obj));
|
||||
cm.focus();
|
||||
updateStatusOK();
|
||||
} catch (e) {
|
||||
updateStatusError('压缩失败:内容不是有效 JSON');
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
cm.setValue('');
|
||||
statusbar.classList.remove('good','bad');
|
||||
statusText.textContent = '已清空';
|
||||
clearErrorHighlight();
|
||||
cm.focus();
|
||||
});
|
||||
|
||||
// 触控体验优化:增大点击区域与取消按钮文本选择
|
||||
document.querySelectorAll('.btn').forEach(btn => {
|
||||
btn.style.touchAction = 'manipulation';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
337
InfoGenie-frontend/public/toolbox/实用工具/Markdown解析器/index.html
Normal file
337
InfoGenie-frontend/public/toolbox/实用工具/Markdown解析器/index.html
Normal file
@@ -0,0 +1,337 @@
|
||||
<!doctype html>
|
||||
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#dff5d0" />
|
||||
<title>Markdown解析器</title>
|
||||
<!-- Markdown 解析与安全清洗 -->
|
||||
<script src="./marked.min.js"></script>
|
||||
<script src="./purify.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-start: #e9f9e4; /* 淡绿色 */
|
||||
--bg-end: #f3ffdf; /* 淡黄绿色 */
|
||||
--card: rgba(255, 255, 255, 0.66);
|
||||
--card-border: rgba(108, 170, 92, 0.25);
|
||||
--text: #2b3a2e;
|
||||
--muted: #5c745a;
|
||||
--accent: #69b36d;
|
||||
--accent-2: #9adf76;
|
||||
--shadow: 0 10px 30px rgba(67, 125, 67, 0.15);
|
||||
--radius: 18px;
|
||||
--radius-sm: 12px;
|
||||
--maxw: min(96vw, 1600px);
|
||||
}* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
|
||||
color: var(--text);
|
||||
background: linear-gradient(160deg, var(--bg-start) 0%, var(--bg-end) 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
backdrop-filter: saturate(120%) blur(8px);
|
||||
background: linear-gradient(160deg, rgba(233,249,228,0.75) 0%, rgba(243,255,223,0.75) 100%);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, var(--accent-2), var(--accent));
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--card-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
h1 { font-size: 18px; margin: 0; font-weight: 700; letter-spacing: .4px; }
|
||||
.sub { color: var(--muted); font-size: 12px; }
|
||||
|
||||
main { max-width: var(--maxw); margin: 20px auto; padding: 0 16px 36px; }
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor, .preview-box { padding: 14px; }
|
||||
|
||||
.label {
|
||||
font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 38vh; /* 适配手机竖屏 */
|
||||
resize: vertical;
|
||||
padding: 14px 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
color: #1f3024;
|
||||
transition: box-shadow .2s ease, border-color .2s ease;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(105, 179, 109, 0.2);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none; border: none; cursor: pointer;
|
||||
padding: 10px 14px; font-weight: 600; font-size: 14px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--accent-2), var(--accent));
|
||||
color: #083610; box-shadow: var(--shadow);
|
||||
transition: transform .04s ease, filter .2s ease;
|
||||
}
|
||||
|
||||
button:active { transform: translateY(1px) scale(0.99); }
|
||||
button.secondary { background: #ffffffb3; color: #2b3a2e; border: 1px solid var(--card-border); }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
padding: 12px 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 基础 Markdown 样式(简化版) */
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3,
|
||||
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
||||
margin: 14px 0 8px; font-weight: 700; line-height: 1.25; color: #17361f;
|
||||
}
|
||||
.markdown-body h1 { font-size: 22px; }
|
||||
.markdown-body h2 { font-size: 20px; }
|
||||
.markdown-body h3 { font-size: 18px; }
|
||||
.markdown-body p, .markdown-body ul, .markdown-body ol { margin: 10px 0; }
|
||||
.markdown-body a { color: #0d6f3a; text-decoration: underline; }
|
||||
.markdown-body blockquote { border-left: 4px solid var(--accent); padding: 8px 10px; margin: 10px 0; background: #f6fff0; }
|
||||
.markdown-body code { background: #f1f7ea; padding: 2px 6px; border-radius: 6px; }
|
||||
.markdown-body pre { background: #f1f7ea; padding: 10px; border-radius: 10px; overflow: auto; }
|
||||
.markdown-body table { border-collapse: collapse; width: 100%; }
|
||||
.markdown-body th, .markdown-body td { border: 1px solid #cfe6c8; padding: 8px; }
|
||||
.markdown-body th { background: #e8f6df; }
|
||||
|
||||
/* 全屏预览覆盖层 */
|
||||
.overlay {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
background: linear-gradient(160deg, rgba(233,249,228,0.96), rgba(243,255,223,0.96));
|
||||
display: none; flex-direction: column;
|
||||
}
|
||||
.overlay[aria-hidden="false"] { display: flex; }
|
||||
|
||||
.overlay-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px; gap: 10px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
background: rgba(255,255,255,0.55);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
}
|
||||
|
||||
.overlay-title { font-weight: 700; color: #164926; font-size: 15px; }
|
||||
|
||||
.overlay-content {
|
||||
padding: 14px; overflow: auto; height: 100%;
|
||||
}
|
||||
|
||||
.tip { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||||
|
||||
/* 适配更大屏幕时的布局 */
|
||||
@media (min-width: 840px) {
|
||||
.grid { grid-template-columns: 1fr 1fr; }
|
||||
textarea { min-height: 55vh; }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<div class="logo" aria-hidden="true"></div>
|
||||
<div>
|
||||
<h1>Markdown解析器</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header> <main>
|
||||
<div class="grid">
|
||||
<section class="panel editor" aria-label="编辑器">
|
||||
<div class="label">Markdown 输入</div>
|
||||
<textarea id="md-input" placeholder="在此输入 Markdown 文本"></textarea>
|
||||
<div class="toolbar">
|
||||
<button id="btn-preview">预览</button>
|
||||
<button class="secondary" id="btn-clear" title="清空输入">清空</button>
|
||||
</div>
|
||||
</section><section class="panel preview-box" aria-label="预览">
|
||||
<div class="label">实时预览</div>
|
||||
<article id="preview" class="markdown-body" aria-live="polite"></article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</main> <!-- 全屏预览覆盖层 --> <div id="overlay" class="overlay" aria-hidden="true" role="dialog" aria-modal="true">
|
||||
<div class="overlay-toolbar">
|
||||
<div class="overlay-title">预览</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<button class="secondary" id="btn-exit">退出预览</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay-content">
|
||||
<article id="overlay-preview" class="markdown-body"></article>
|
||||
</div>
|
||||
</div> <script>
|
||||
// Marked 基础配置
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
headerIds: true,
|
||||
mangle: false
|
||||
});
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const input = $('#md-input');
|
||||
const preview = $('#preview');
|
||||
const overlay = $('#overlay');
|
||||
const overlayPreview = $('#overlay-preview');
|
||||
const btnPreview = $('#btn-preview');
|
||||
const btnExit = $('#btn-exit');
|
||||
const btnClear = $('#btn-clear');
|
||||
|
||||
const STORAGE_KEY = 'md-editor-content-v1';
|
||||
|
||||
// 桌面端自动扩展输入框高度
|
||||
const MQ_DESKTOP = window.matchMedia('(min-width: 840px)');
|
||||
function autoResizeTextarea(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}
|
||||
function applyTextareaMode() {
|
||||
if (MQ_DESKTOP.matches) {
|
||||
input.style.overflowY = 'hidden';
|
||||
input.style.resize = 'none';
|
||||
autoResizeTextarea(input);
|
||||
} else {
|
||||
input.style.overflowY = '';
|
||||
input.style.resize = '';
|
||||
input.style.height = '';
|
||||
}
|
||||
}
|
||||
MQ_DESKTOP.addEventListener('change', applyTextareaMode);
|
||||
|
||||
function renderMarkdown(targetEl, srcText) {
|
||||
try {
|
||||
const html = marked.parse(srcText ?? '');
|
||||
// 使用 DOMPurify 进行安全清洗
|
||||
targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||
} catch (e) {
|
||||
targetEl.textContent = '解析出错:' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function syncRender() {
|
||||
const text = input.value;
|
||||
// 保存到本地
|
||||
try { localStorage.setItem(STORAGE_KEY, text); } catch (e) {}
|
||||
renderMarkdown(preview, text);
|
||||
if (MQ_DESKTOP.matches) { autoResizeTextarea(input); }
|
||||
}
|
||||
|
||||
// 初始载入:从本地存储恢复
|
||||
(function init() {
|
||||
let initial = '';
|
||||
try { initial = localStorage.getItem(STORAGE_KEY) || ''; } catch (e) {}
|
||||
if (!initial) {
|
||||
initial = `# 🌿 欢迎使用Markdown解析器\n\n在左侧/上方输入 Markdown,右侧/下方会 **实时预览**。\n\n- 支持 GFM、自动换行、代码块\n- 点击右上方的 **全屏预览** 按钮\n- 内容会保存在本地浏览器中\n\n> 小提示:支持表格、引用、链接等常见语法~\n\n| 功能 | 状态 |\n| ---- | ---- |\n| 实时预览 | ✅ |\n| 全屏预览 | ✅ |\n| 本地保存 | ✅ |\n`;
|
||||
}
|
||||
input.value = initial;
|
||||
syncRender();
|
||||
applyTextareaMode();
|
||||
})();
|
||||
|
||||
// 输入实时渲染
|
||||
input.addEventListener('input', syncRender);
|
||||
|
||||
// 清空
|
||||
btnClear.addEventListener('click', () => {
|
||||
input.value = '';
|
||||
syncRender();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// 打开全屏预览
|
||||
btnPreview.addEventListener('click', async () => {
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
overlayPreview.innerHTML = preview.innerHTML;
|
||||
// 尝试调用原生全屏 API(兼容性降级到覆盖层)
|
||||
try {
|
||||
if (!document.fullscreenElement && overlay.requestFullscreen) {
|
||||
await overlay.requestFullscreen();
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误,覆盖层已显示
|
||||
}
|
||||
});
|
||||
|
||||
// 退出全屏预览
|
||||
function exitOverlay() {
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
if (document.fullscreenElement && document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
btnExit.addEventListener('click', exitOverlay);
|
||||
|
||||
// Esc 键退出
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && overlay.getAttribute('aria-hidden') === 'false') {
|
||||
exitOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
// 当内容变化时,若处于全屏预览,则同步内容
|
||||
const obs = new MutationObserver(() => {
|
||||
if (overlay.getAttribute('aria-hidden') === 'false') {
|
||||
overlayPreview.innerHTML = preview.innerHTML;
|
||||
}
|
||||
});
|
||||
obs.observe(preview, { childList: true, subtree: true });
|
||||
</script></body>
|
||||
</html>
|
||||
69
InfoGenie-frontend/public/toolbox/实用工具/Markdown解析器/marked.min.js
vendored
Normal file
69
InfoGenie-frontend/public/toolbox/实用工具/Markdown解析器/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
InfoGenie-frontend/public/toolbox/实用工具/Markdown解析器/purify.min.js
vendored
Normal file
3
InfoGenie-frontend/public/toolbox/实用工具/Markdown解析器/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
262
InfoGenie-frontend/public/toolbox/实用工具/做决定转盘/index.html
Normal file
262
InfoGenie-frontend/public/toolbox/实用工具/做决定转盘/index.html
Normal file
@@ -0,0 +1,262 @@
|
||||
<!DOCTYPE html><html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<title>做决定转盘</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg1:#e9f7e9; /* 淡绿 */
|
||||
--bg2:#f7f9e3; /* 淡黄绿 */
|
||||
--card:#ffffffcc;
|
||||
--text:#1b3a2a;
|
||||
--muted:#3a6b4a;
|
||||
--accent:#6ccf6e;
|
||||
--accent-2:#f1f7cf;
|
||||
--shadow:0 10px 30px rgba(43,96,73,.12);
|
||||
--radius:18px;
|
||||
}
|
||||
html,body{height:100%;}
|
||||
body{
|
||||
margin:0; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Inter, 'Helvetica Neue', Arial, 'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', "Microsoft YaHei", sans-serif;
|
||||
color:var(--text);
|
||||
background: linear-gradient(160deg, var(--bg1), var(--bg2));
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.app{ width:min(720px,100%); padding:14px; }
|
||||
.title{ text-align:center; margin:6px 0 12px; font-weight:800; letter-spacing:.5px; }
|
||||
.badge{ font-size:12px; background: var(--accent-2); padding: 4px 8px; border-radius:999px; }/* 单列移动端优先 */
|
||||
.grid{ display:grid; gap:12px; grid-template-columns: 1fr; }
|
||||
|
||||
.card{ background:var(--card); backdrop-filter: blur(6px); border-radius:var(--radius); box-shadow:var(--shadow); padding:14px; }
|
||||
|
||||
/* 轮盘区域 */
|
||||
.wheel-wrap{ display:grid; place-items:center; padding:8px 0 2px; }
|
||||
.wheel{ width:min(92vw, 440px); height:min(92vw, 440px); max-width:480px; max-height:480px; position:relative; }
|
||||
canvas{ width:100%; height:100%; display:block; }
|
||||
.pointer{ position:absolute; left:50%; top:-8px; transform:translateX(-50%); width:0; height:0; border-left:12px solid transparent; border-right:12px solid transparent; border-bottom:18px solid var(--accent); filter: drop-shadow(0 2px 3px rgba(0,0,0,.15)); }
|
||||
|
||||
/* 行为区:大按钮,便于拇指操作 */
|
||||
.actions{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px; }
|
||||
button{ width:100%; box-sizing:border-box; border-radius:14px; border:1px solid #d8ebd8; padding: 12px 14px; min-height:48px; font-size:16px; outline:none; background:#ffffffdd; }
|
||||
button:active{ transform: translateY(1px); }
|
||||
button.primary{ background: linear-gradient(180deg, #bff0c4, #98e09d); border:none; color:#0f3b23; font-weight:800; letter-spacing:.3px; box-shadow:0 8px 18px rgba(88,167,110,.25); }
|
||||
|
||||
.result{ text-align:center; font-weight:800; font-size:20px; margin:10px 0 4px; }
|
||||
.hint{ text-align:center; font-size:12px; color:#2f6a45; opacity:.85; }
|
||||
|
||||
/* 选项编辑:折叠,默认收起,减少滚动 */
|
||||
details.options{ margin-top:8px; }
|
||||
details.options[open]{ background:#ffffffb8; border-radius:14px; padding:8px; }
|
||||
details.options > summary{ list-style:none; cursor:pointer; padding:10px 12px; border-radius:12px; background:#ffffff; border:1px solid #d8ebd8; font-weight:700; }
|
||||
details.options > summary::-webkit-details-marker { display:none; }
|
||||
|
||||
.mini{ font-size:12px; opacity:.9; margin:6px 2px; }
|
||||
.controls{ display:grid; grid-template-columns: 1fr 1fr; gap:10px; align-items:end; margin-top:6px; }
|
||||
label{ font-size:13px; opacity:.9; }
|
||||
input[type="number"], input[type="text"]{ width:100%; box-sizing:border-box; border-radius:12px; border:1px solid #d8ebd8; padding:12px 12px; font-size:16px; background:#ffffffdd; }
|
||||
.opt-row{ display:grid; grid-template-columns: 1fr 44px; gap:8px; }
|
||||
.opt-list{ display:grid; gap:8px; max-height: 38vh; overflow:auto; padding-right:4px; }
|
||||
|
||||
.row{ display:flex; gap:8px; align-items:center; }
|
||||
|
||||
@media (min-width: 900px){
|
||||
/* 桌面端给一点并排,但默认单列 */
|
||||
.grid{ grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<h2 class="title">做决定转盘 </h2><div class="grid">
|
||||
<!-- 先展示转盘,便于手机端操作 -->
|
||||
<div class="card">
|
||||
<div class="wheel-wrap">
|
||||
<div class="wheel">
|
||||
<div class="pointer" aria-hidden="true"></div>
|
||||
<canvas id="wheel" width="600" height="600" aria-label="做决定转盘画布"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="decide" class="primary" type="button">🎲 做决定</button>
|
||||
<button id="saveImg" type="button">🖼️ 保存为图片</button>
|
||||
</div>
|
||||
<div class="result" id="result"></div>
|
||||
<div class="hint">提示:点击“做决定”抽取后,可“保存结果为图片”分享到聊天/相册。</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置/编辑选项(折叠) -->
|
||||
<div class="card" aria-label="配置面板">
|
||||
<details class="options">
|
||||
<summary>✏️ 编辑选项(点我展开/收起)</summary>
|
||||
<div class="controls">
|
||||
<div>
|
||||
<label for="count">选项数量</label>
|
||||
<input id="count" type="number" min="2" max="24" value="4" />
|
||||
</div>
|
||||
<div>
|
||||
<label> </label>
|
||||
<button id="fillDemo" type="button">填入示例</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini">每行一个选项(会自动同步到转盘)</div>
|
||||
<div id="optList" class="opt-list" role="list"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <script>
|
||||
// --------- 元素引用 ---------
|
||||
const optListEl = document.getElementById('optList');
|
||||
const countEl = document.getElementById('count');
|
||||
const canvas = document.getElementById('wheel');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const decideBtn = document.getElementById('decide');
|
||||
const resultEl = document.getElementById('result');
|
||||
const saveBtn = document.getElementById('saveImg');
|
||||
|
||||
// --------- 配色(固定柔和绿黄,无“柔和配色”按钮) ---------
|
||||
function softPalette(n){
|
||||
const bases = ['#dff6d7','#e8fad6','#f3fed5','#e6f7c9','#d8f3c9','#ccf1c6','#f2f9dd','#e0f7e7','#e8f9e4','#f6ffe1','#e9f7e9','#f7f9e3'];
|
||||
const colors=[]; for(let i=0;i<n;i++){ if(i<bases.length) colors.push(bases[i]); else { const h = 90 + (i*11)%40; const s = 50 + (i*3)%16; const l = 80 - (i*2)%12; colors.push(`hsl(${h} ${s}% ${l}%)`);} }
|
||||
return colors;
|
||||
}
|
||||
let colors = softPalette(24);
|
||||
|
||||
// --------- 选项编辑 ---------
|
||||
function createInputRow(idx, value=''){
|
||||
const wrap = document.createElement('div'); wrap.className='opt-row'; wrap.setAttribute('role','listitem');
|
||||
const input = document.createElement('input'); input.type='text'; input.placeholder=`选项 ${idx+1}`; input.value=value; input.addEventListener('input', drawWheel);
|
||||
const del = document.createElement('button'); del.type='button'; del.textContent='✕'; del.addEventListener('click', ()=>{ wrap.remove(); countEl.value = Math.max(2, optListEl.querySelectorAll('input').length); drawWheel(); });
|
||||
wrap.appendChild(input); wrap.appendChild(del); return wrap;
|
||||
}
|
||||
|
||||
function syncCount(){
|
||||
let target = parseInt(countEl.value||'2',10); target = Math.min(24, Math.max(2, target)); countEl.value = target;
|
||||
const current = optListEl.querySelectorAll('input').length;
|
||||
if(target>current){ for(let i=current;i<target;i++) optListEl.appendChild(createInputRow(i)); }
|
||||
else if(target<current){ for(let i=current;i>target;i--) optListEl.lastElementChild?.remove(); }
|
||||
drawWheel();
|
||||
}
|
||||
countEl.addEventListener('change', syncCount);
|
||||
|
||||
document.getElementById('fillDemo').addEventListener('click', ()=>{
|
||||
const demo=['吃饭','睡觉','学习','打游戏','看电影','散步'];
|
||||
countEl.value = demo.length; syncCount();
|
||||
[...optListEl.querySelectorAll('input')].forEach((el,i)=> el.value = demo[i]||'');
|
||||
drawWheel();
|
||||
});
|
||||
|
||||
function getOptions(){
|
||||
return [...optListEl.querySelectorAll('input')].map(i=>i.value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// --------- 绘制与渲染 ---------
|
||||
let rotation = 0; // 当前弧度
|
||||
let lastChoice = null; // 上次结果
|
||||
|
||||
function renderWheel(drawCtx, w, h, opts, rot, palette){
|
||||
const n = Math.max(2, opts.length);
|
||||
const cx = w/2, cy = h/2; const r = Math.min(w,h)/2 - 8;
|
||||
drawCtx.clearRect(0,0,w,h);
|
||||
const slice = Math.PI*2 / n;
|
||||
for(let i=0;i<n;i++){
|
||||
const start = rot + i*slice - Math.PI/2; const end = start + slice;
|
||||
drawCtx.beginPath(); drawCtx.moveTo(cx,cy); drawCtx.arc(cx,cy,r,start,end); drawCtx.closePath();
|
||||
drawCtx.fillStyle = palette[i%palette.length]; drawCtx.fill();
|
||||
drawCtx.strokeStyle = '#cfead5'; drawCtx.lineWidth=2; drawCtx.stroke();
|
||||
// 文本
|
||||
const mid=(start+end)/2; drawCtx.save();
|
||||
drawCtx.translate(cx + Math.cos(mid)*(r*0.7), cy + Math.sin(mid)*(r*0.7));
|
||||
drawCtx.rotate(mid + Math.PI/2); drawCtx.fillStyle = '#1b3a2a';
|
||||
drawCtx.font='600 22px system-ui, -apple-system, Segoe UI, Roboto';
|
||||
drawCtx.textAlign='center'; drawCtx.textBaseline='middle';
|
||||
const label = opts[i % opts.length] || `选项${i+1}`;
|
||||
shrinkTextToWidth(drawCtx,label,r*0.95);
|
||||
drawCtx.fillText(label,0,0); drawCtx.restore();
|
||||
}
|
||||
// 中心圆
|
||||
drawCtx.beginPath(); drawCtx.arc(cx,cy, r*0.14, 0, Math.PI*2); drawCtx.fillStyle='#ffffffee'; drawCtx.fill();
|
||||
drawCtx.strokeStyle='#bfe7c8'; drawCtx.lineWidth=3; drawCtx.stroke();
|
||||
}
|
||||
|
||||
function drawWheel(){
|
||||
const opts = getOptions();
|
||||
renderWheel(ctx, canvas.width, canvas.height, opts, rotation, colors);
|
||||
}
|
||||
|
||||
function shrinkTextToWidth(c, text, maxWidth){
|
||||
let size=22; while(size>12){ c.font=`600 ${size}px system-ui, -apple-system, Segoe UI, Roboto`; if(c.measureText(text).width<=maxWidth) return; size--; }
|
||||
}
|
||||
|
||||
// --------- 动画与随机选择 ---------
|
||||
let spinning=false;
|
||||
function spinToIndex(index){
|
||||
const opts = getOptions();
|
||||
const n = Math.max(2, opts.length);
|
||||
const slice = Math.PI*2 / n;
|
||||
const desired = - (index*slice + slice/2);
|
||||
let delta = desired - rotation; delta = ((delta % (Math.PI*2)) + Math.PI*2) % (Math.PI*2);
|
||||
const extraTurns = 5 + Math.floor(Math.random()*3); // 5~7 圈
|
||||
const finalRotation = rotation + delta + extraTurns*Math.PI*2;
|
||||
|
||||
animateRotation(rotation, finalRotation, 1600 + Math.random()*700, ()=>{
|
||||
rotation = ((finalRotation % (Math.PI*2)) + Math.PI*2) % (Math.PI*2); // 归一
|
||||
drawWheel();
|
||||
lastChoice = opts[index];
|
||||
resultEl.textContent = `结果:${lastChoice}`;
|
||||
spinning=false;
|
||||
});
|
||||
}
|
||||
|
||||
function easeOutCubic(t){ return 1 - Math.pow(1-t,3); }
|
||||
function animateRotation(from, to, duration, done){
|
||||
const start = performance.now(); spinning=true; resultEl.textContent='';
|
||||
function frame(now){ const t=Math.min(1,(now-start)/duration); const eased=easeOutCubic(t); rotation=from+(to-from)*eased; drawWheel(); if(t<1) requestAnimationFrame(frame); else done(); }
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
decideBtn.addEventListener('click', ()=>{
|
||||
if(spinning) return;
|
||||
const opts = getOptions(); if(opts.length<2){ alert('请至少输入两个选项'); return; }
|
||||
const index = Math.floor(Math.random()*opts.length); spinToIndex(index);
|
||||
});
|
||||
|
||||
// --------- 保存结果为图片 ---------
|
||||
function roundRect(c, x,y,w,h,r){
|
||||
const rr=Math.min(r, w/2, h/2); c.beginPath();
|
||||
c.moveTo(x+rr,y); c.arcTo(x+w,y,x+w,y+h,rr); c.arcTo(x+w,y+h,x,y+h,rr); c.arcTo(x,y+h,x,y,rr); c.arcTo(x,y,x+w,y,rr); c.closePath();
|
||||
}
|
||||
|
||||
function saveImage(){
|
||||
const opts = getOptions();
|
||||
if(!lastChoice){ alert('请先点击“做决定”抽取结果'); return; }
|
||||
const size = 1024; const off=document.createElement('canvas'); off.width=size; off.height=size; const octx=off.getContext('2d');
|
||||
// 背景
|
||||
const g=octx.createLinearGradient(0,0,size,size); g.addColorStop(0,'#e9f7e9'); g.addColorStop(1,'#f7f9e3'); octx.fillStyle=g; octx.fillRect(0,0,size,size);
|
||||
// 轮盘
|
||||
renderWheel(octx, size, size, opts, rotation, colors);
|
||||
// 指针
|
||||
const triH=size*0.05, triW=size*0.09; octx.fillStyle='#6ccf6e'; octx.beginPath(); octx.moveTo(size/2, size*0.02); octx.lineTo(size/2 - triW/2, size*0.02 + triH); octx.lineTo(size/2 + triW/2, size*0.02 + triH); octx.closePath(); octx.fill();
|
||||
// 结果胶囊
|
||||
const text=`结果:${lastChoice}`; octx.font='800 52px system-ui, -apple-system, Segoe UI, Roboto'; octx.textBaseline='middle'; octx.textAlign='center';
|
||||
const padX=34, padY=22; const tw=octx.measureText(text).width; const boxW=tw+padX*2; const boxH=92;
|
||||
const bx=(size-boxW)/2, by=size*0.82; octx.fillStyle='#ffffffee'; roundRect(octx,bx,by,boxW,boxH,24); octx.fill();
|
||||
octx.lineWidth=3; octx.strokeStyle='#cfead5'; octx.stroke();
|
||||
octx.fillStyle='#1b3a2a'; octx.fillText(text, size/2, by+boxH/2);
|
||||
// 下载
|
||||
const a=document.createElement('a'); a.href=off.toDataURL('image/png'); a.download=`转盘结果_${Date.now()}.png`; document.body.appendChild(a); a.click(); a.remove();
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', saveImage);
|
||||
|
||||
// --------- 初始化 ---------
|
||||
(function init(){
|
||||
const demo=['吃饭','睡觉','学习','打游戏','敲代码'];
|
||||
countEl.value=demo.length; syncCount();
|
||||
[...optListEl.querySelectorAll('input')].forEach((el,i)=> el.value=demo[i]||'');
|
||||
drawWheel();
|
||||
window.addEventListener('resize', drawWheel);
|
||||
})();
|
||||
</script></body>
|
||||
</html>
|
||||
278
InfoGenie-frontend/public/toolbox/实用工具/白板/index.html
Normal file
278
InfoGenie-frontend/public/toolbox/实用工具/白板/index.html
Normal file
@@ -0,0 +1,278 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<title>白板</title>
|
||||
<style>
|
||||
:root {
|
||||
--grad-start: #dff5e7; /* 淡绿色 */
|
||||
--grad-end: #e8f7d4; /* 淡黄绿色 */
|
||||
--accent: #78c6a3; /* 清新绿 */
|
||||
--accent-2: #a9dba8; /* 柔和绿 */
|
||||
--text: #2c3e3b;
|
||||
--soft: rgba(120, 198, 163, 0.15);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--grad-start), var(--grad-end));
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
#app { height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
.toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: linear-gradient(135deg, rgba(223,245,231,0.8), rgba(232,247,212,0.8));
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid rgba(120,198,163,0.25);
|
||||
box-shadow: 0 6px 16px var(--soft);
|
||||
}
|
||||
|
||||
.group { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.label { font-size: 14px; opacity: 0.9; }
|
||||
.value { min-width: 36px; text-align: center; font-size: 13px; opacity: 0.85; }
|
||||
|
||||
input[type="color"] {
|
||||
width: 36px; height: 36px; padding: 0; border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 8px; background: white; box-shadow: 0 2px 6px var(--soft);
|
||||
}
|
||||
|
||||
input[type="range"] { width: 140px; }
|
||||
|
||||
.segmented {
|
||||
display: inline-flex; border: 1px solid rgba(120,198,163,0.35); border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 2px 6px var(--soft);
|
||||
}
|
||||
.segmented button {
|
||||
padding: 8px 12px; font-size: 14px; border: none; background: rgba(255,255,255,0.8); color: var(--text); cursor: pointer;
|
||||
}
|
||||
.segmented button + button { border-left: 1px solid rgba(120,198,163,0.25); }
|
||||
.segmented button.active { background: var(--accent-2); color: #0f3b2f; }
|
||||
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.btn {
|
||||
padding: 8px 14px; font-size: 14px; border-radius: 10px; border: 1px solid rgba(120,198,163,0.35);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(240,255,245,0.9));
|
||||
color: var(--text); cursor: pointer; box-shadow: 0 2px 6px var(--soft);
|
||||
}
|
||||
.btn.primary { background: linear-gradient(180deg, var(--accent-2), #d9f4d5); border-color: rgba(120,198,163,0.5); }
|
||||
|
||||
.canvas-wrap { flex: 1 1 auto; position: relative; }
|
||||
canvas#board {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
background: #ffffff; /* 全屏白色背景 */
|
||||
touch-action: none; display: block;
|
||||
}
|
||||
|
||||
/* 手机竖屏优化 */
|
||||
@media (max-width: 480px) {
|
||||
.toolbar { grid-template-columns: 1fr; }
|
||||
input[type="range"] { width: 100%; }
|
||||
.actions { justify-content: flex-start; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="toolbar">
|
||||
<div class="group">
|
||||
<span class="label">颜色</span>
|
||||
<input id="color" type="color" value="#2c3e3b" />
|
||||
<span class="label">画笔粗细</span>
|
||||
<input id="brushSize" type="range" min="1" max="64" value="8" />
|
||||
<span id="brushVal" class="value">8px</span>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="segmented" role="tablist" aria-label="绘制模式">
|
||||
<button id="modeBrush" class="active" role="tab" aria-selected="true">画笔</button>
|
||||
<button id="modeEraser" role="tab" aria-selected="false">橡皮擦</button>
|
||||
</div>
|
||||
<span class="label">橡皮粗细</span>
|
||||
<input id="eraserSize" type="range" min="4" max="128" value="20" />
|
||||
<span id="eraserVal" class="value">20px</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="saveBtn" class="btn primary">保存为图片</button>
|
||||
<button id="clearBtn" class="btn">清空画布</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-wrap">
|
||||
<canvas id="board"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('board');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const colorInput = document.getElementById('color');
|
||||
const brushSizeInput = document.getElementById('brushSize');
|
||||
const brushVal = document.getElementById('brushVal');
|
||||
const eraserSizeInput = document.getElementById('eraserSize');
|
||||
const eraserVal = document.getElementById('eraserVal');
|
||||
const modeBrushBtn = document.getElementById('modeBrush');
|
||||
const modeEraserBtn = document.getElementById('modeEraser');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
|
||||
let dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
let drawing = false;
|
||||
let last = { x: 0, y: 0 };
|
||||
let mode = 'brush'; // 'brush' | 'eraser'
|
||||
|
||||
function setActiveMode(newMode) {
|
||||
mode = newMode;
|
||||
modeBrushBtn.classList.toggle('active', mode === 'brush');
|
||||
modeEraserBtn.classList.toggle('active', mode === 'eraser');
|
||||
modeBrushBtn.setAttribute('aria-selected', mode === 'brush');
|
||||
modeEraserBtn.setAttribute('aria-selected', mode === 'eraser');
|
||||
}
|
||||
|
||||
function cssSize() {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return { w: Math.round(r.width), h: Math.round(r.height) };
|
||||
}
|
||||
|
||||
function resizeCanvas(preserve = true) {
|
||||
const { w, h } = cssSize();
|
||||
const snapshot = preserve ? canvas.toDataURL('image/png') : null;
|
||||
dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
if (snapshot) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 先铺白底,保证保存图片有白色背景
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
};
|
||||
img.src = snapshot;
|
||||
} else {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
function pos(e) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
}
|
||||
|
||||
function stroke(from, to) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
if (mode === 'eraser') {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = parseInt(eraserSizeInput.value, 10);
|
||||
} else {
|
||||
ctx.strokeStyle = colorInput.value;
|
||||
ctx.lineWidth = parseInt(brushSizeInput.value, 10);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
drawing = true;
|
||||
last = pos(e);
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
if (!drawing) return;
|
||||
const p = pos(e);
|
||||
stroke(last, p);
|
||||
last = p;
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
|
||||
function endDraw(e) {
|
||||
drawing = false;
|
||||
e && e.preventDefault();
|
||||
}
|
||||
canvas.addEventListener('pointerup', endDraw);
|
||||
canvas.addEventListener('pointercancel', endDraw);
|
||||
canvas.addEventListener('pointerleave', endDraw);
|
||||
|
||||
// UI 交互
|
||||
modeBrushBtn.addEventListener('click', () => setActiveMode('brush'));
|
||||
modeEraserBtn.addEventListener('click', () => setActiveMode('eraser'));
|
||||
|
||||
brushSizeInput.addEventListener('input', () => {
|
||||
brushVal.textContent = brushSizeInput.value + 'px';
|
||||
});
|
||||
eraserSizeInput.addEventListener('input', () => {
|
||||
eraserVal.textContent = eraserSizeInput.value + 'px';
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
const { w, h } = cssSize();
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', () => {
|
||||
// 确保白底
|
||||
const { w, h } = cssSize();
|
||||
const altCanvas = document.createElement('canvas');
|
||||
const altCtx = altCanvas.getContext('2d');
|
||||
altCanvas.width = w * dpr;
|
||||
altCanvas.height = h * dpr;
|
||||
altCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
altCtx.fillStyle = '#ffffff';
|
||||
altCtx.fillRect(0, 0, w, h);
|
||||
altCtx.drawImage(canvas, 0, 0, w, h);
|
||||
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const filename = `白板_${y}${m}${d}_${hh}${mm}.png`;
|
||||
|
||||
altCanvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
// 初始化与自适应
|
||||
function init() {
|
||||
resizeCanvas(false);
|
||||
brushVal.textContent = brushSizeInput.value + 'px';
|
||||
eraserVal.textContent = eraserSizeInput.value + 'px';
|
||||
}
|
||||
window.addEventListener('resize', () => resizeCanvas(true));
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) resizeCanvas(true);
|
||||
});
|
||||
|
||||
// 禁用默认触控滚动/双击缩放
|
||||
canvas.style.touchAction = 'none';
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
InfoGenie-frontend/public/toolbox/实用工具/视频播放器/hls.min.js
vendored
Normal file
2
InfoGenie-frontend/public/toolbox/实用工具/视频播放器/hls.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
412
InfoGenie-frontend/public/toolbox/实用工具/视频播放器/index.html
Normal file
412
InfoGenie-frontend/public/toolbox/实用工具/视频播放器/index.html
Normal file
@@ -0,0 +1,412 @@
|
||||
<!DOCTYPE html><html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>▶️视频播放器</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg-from:#e9fbb6; /* 淡黄绿色 */
|
||||
--bg-to:#bff2cf; /* 淡绿色 */
|
||||
--accent:#6bbf7a; /* 主色 */
|
||||
--accent-2:#4aa36b; /* 主色深 */
|
||||
--text:#18412a; /* 文本深色 */
|
||||
--muted:#2a6b47; /* 次要文本 */
|
||||
--card:#ffffffcc; /* 半透明卡片 */
|
||||
--edge:#e3f0e6; /* 边框 */
|
||||
--shadow: 0 12px 24px rgba(40,120,80,.15);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
color:var(--text);
|
||||
background: linear-gradient(160deg,var(--bg-from),var(--bg-to));
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.wrap{
|
||||
min-height:100dvh;
|
||||
display:flex;flex-direction:column;gap:16px;
|
||||
padding:16px; padding-bottom:24px;
|
||||
max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
header{
|
||||
display:flex; align-items:center; justify-content:space-between; gap:12px;
|
||||
}
|
||||
.title{
|
||||
font-weight:700; letter-spacing:.2px; font-size:18px;
|
||||
}
|
||||
.uploader{
|
||||
display:grid; grid-template-columns:1fr; gap:10px;
|
||||
background:var(--card);
|
||||
border:1px solid var(--edge);
|
||||
box-shadow:var(--shadow);
|
||||
border-radius:18px; padding:12px;
|
||||
}
|
||||
.row{display:flex; gap:8px; align-items:center; flex-wrap:wrap;}
|
||||
input[type="url"], .btn, input[type="file"], .speed-select, .range{
|
||||
border:1px solid var(--edge); border-radius:14px;
|
||||
background:#fff; color:var(--text);
|
||||
padding:10px 12px; font-size:14px;
|
||||
}
|
||||
input[type="url"]{ flex:1; min-width:0; }
|
||||
.btn{ background:linear-gradient(180deg,#ffffff,#f7fff5); cursor:pointer; user-select:none; }
|
||||
.btn.primary{ background:linear-gradient(180deg,#d9fbd8,#bcf2c9); border-color:#b4e6be; }
|
||||
.btn:hover{ filter:saturate(1.02); }
|
||||
.btn:active{ transform:translateY(1px); }
|
||||
.btn[disabled]{ opacity:.5; cursor:not-allowed; }.player-card{
|
||||
background:var(--card); border:1px solid var(--edge); box-shadow:var(--shadow);
|
||||
border-radius:22px; overflow:hidden;
|
||||
}
|
||||
.video-box{ position:relative; background:#000; aspect-ratio:16/9; }
|
||||
video{ width:100%; height:auto; display:block; background:#000; }
|
||||
.overlay-center{
|
||||
position:absolute; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none;
|
||||
}
|
||||
.big-btn{
|
||||
width:64px; height:64px; border-radius:50%;
|
||||
background:radial-gradient(circle at 30% 30%, #ffffff, #eaffea);
|
||||
display:grid; place-items:center; box-shadow:0 8px 24px rgba(0,0,0,.25);
|
||||
opacity:0; transform:scale(.9); transition:.25s;
|
||||
}
|
||||
.overlay-center.show .big-btn{ opacity:1; transform:scale(1); }
|
||||
|
||||
.controls{
|
||||
display:flex; flex-direction:column; gap:8px; padding:12px; background:linear-gradient(180deg,#f3fff3cc,#eafff2cc);
|
||||
border-top:1px solid var(--edge);
|
||||
}
|
||||
.progress-row{ display:flex; align-items:center; gap:10px; }
|
||||
.range{ -webkit-appearance:none; appearance:none; width:100%; height:14px; padding:0 0; background:transparent; }
|
||||
.range::-webkit-slider-runnable-track{ height:6px; background:linear-gradient(90deg,#c9efcf,#aee7bb); border-radius:999px; }
|
||||
.range::-moz-range-track{ height:6px; background:linear-gradient(90deg,#c9efcf,#aee7bb); border-radius:999px; }
|
||||
.range::-webkit-slider-thumb{ -webkit-appearance:none; margin-top:-6px; width:18px; height:18px; border-radius:50%; background:#fff; border:1px solid #cde6d5; box-shadow:0 2px 8px rgba(51,117,84,.3); }
|
||||
.range::-moz-range-thumb{ width:18px; height:18px; border:none; border-radius:50%; background:#fff; box-shadow:0 2px 8px rgba(51,117,84,.3); }
|
||||
.time{ font-variant-tabular-nums:tabular-nums; font-size:12px; color:var(--muted); min-width:96px; text-align:center; }
|
||||
|
||||
.bar{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.icon-btn{ border:none; background:#fff; border:1px solid var(--edge); border-radius:12px; width:40px; height:40px; display:grid; place-items:center; cursor:pointer; }
|
||||
.icon-btn:active{ transform:translateY(1px); }
|
||||
|
||||
.speed-select{ padding-right:28px; }
|
||||
.vol{ display:flex; align-items:center; gap:6px; }
|
||||
.vol input{ width:110px; }
|
||||
|
||||
.name{ font-size:12px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
/* 手机竖屏优先 */
|
||||
@media (min-width:720px){
|
||||
.controls{ padding:14px 16px; }
|
||||
.icon-btn{ width:42px; height:42px; }
|
||||
.vol input{ width:140px; }
|
||||
}
|
||||
@media (max-width:420px){
|
||||
.vol input{ width:96px; }
|
||||
.time{ min-width:70px; }
|
||||
.uploader .row > .btn,
|
||||
.uploader .row > input[type="url"] { flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="title">▶️视频播放器</div>
|
||||
<button class="btn" id="resetBtn" title="重置播放器">重置</button>
|
||||
</header><section class="uploader" aria-label="选择视频源">
|
||||
<div class="row">
|
||||
<input aria-label="视频链接" type="url" id="urlInput" placeholder="粘贴视频直链(.mp4 / .webm / .ogg / .m3u8)后回车…" />
|
||||
<button class="btn primary" id="loadUrlBtn">加载链接</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input aria-label="选择本地视频文件" type="file" id="fileInput" class="file-input" accept="video/*" />
|
||||
<label for="fileInput" class="btn" id="fileInputLabel">选择本地文件</label>
|
||||
<span class="name" id="fileName">未选择文件</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="player-card" aria-label="视频播放器">
|
||||
<div class="video-box" id="videoBox">
|
||||
<video id="video" playsinline preload="metadata"></video>
|
||||
<div class="overlay-center" id="overlay">
|
||||
<div class="big-btn" aria-hidden="true">▶︎</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls" role="group" aria-label="播放控制">
|
||||
<div class="progress-row">
|
||||
<span class="time" id="timeNow">00:00</span>
|
||||
<input type="range" class="range" id="progress" min="0" max="1000" value="0" step="1" aria-label="进度条" />
|
||||
<span class="time" id="timeTotal">--:--</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<button class="icon-btn" id="playBtn" title="播放/暂停 (空格)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M8 5v14l11-7-11-7z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="backwardBtn" title="后退10秒 (←)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M11 5V2L6 7l5 5V9c3.86 0 7 3.14 7 7 0 1.05-.22 2.05-.62 2.95l1.48 1.48A8.96 8.96 0 0020 16c0-4.97-4.03-9-9-9z" fill="currentColor"/><text x="3" y="21" font-size="8" fill="currentColor">10</text></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="forwardBtn" title="快进10秒 (→)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 5V2l5 5-5 5V9c-3.86 0-7 3.14-7 7 0 1.05.22 2.05.62 2.95L5.14 20.4A8.96 8.96 0 014 16c0-4.97 4.03-9 9-9z" fill="currentColor"/><text x="14" y="21" font-size="8" fill="currentColor">10</text></svg>
|
||||
</button>
|
||||
|
||||
<label class="vol" title="音量 (↑/↓)">
|
||||
<button class="icon-btn" id="muteBtn" aria-label="静音切换">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<input type="range" class="range" id="volume" min="0" max="1" step="0.01" value="1" aria-label="音量" />
|
||||
</label>
|
||||
|
||||
<select id="speed" class="speed-select" title="倍速 ( , / . )" aria-label="倍速">
|
||||
<option value="0.5">0.5×</option>
|
||||
<option value="0.75">0.75×</option>
|
||||
<option value="1" selected>1.0×</option>
|
||||
<option value="1.25">1.25×</option>
|
||||
<option value="1.5">1.5×</option>
|
||||
<option value="2">2.0×</option>
|
||||
</select>
|
||||
|
||||
<button class="icon-btn" id="pipBtn" title="画中画">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor"/><rect x="12.5" y="10" width="7" height="5" rx="1" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="fsBtn" title="全屏 (F)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M8 3H3v5h2V5h3V3zm10 0h-5v2h3v3h2V3zM5 14H3v7h7v-2H5v-5zm16 0h-2v5h-5v2h7v-7z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<div class="name" id="sourceName" title="当前来源">未加载视频</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div> <!-- 可选:HLS 支持(仅当加载 .m3u8 时使用)。静态网页可直接引用 CDN。--> <script src="hls.min.js" integrity="sha384-N7Pzv6j4n0O3+zJVwCyO0n2A7bgb1z47Z4+Z1fH2E0KXpKj9c3n2U6xJQ8P9yq3s" crossorigin="anonymous"></script> <script>
|
||||
const $ = sel => document.querySelector(sel);
|
||||
const video = $('#video');
|
||||
const progress = $('#progress');
|
||||
const timeNow = $('#timeNow');
|
||||
const timeTotal = $('#timeTotal');
|
||||
const playBtn = $('#playBtn');
|
||||
const backwardBtn = $('#backwardBtn');
|
||||
const forwardBtn = $('#forwardBtn');
|
||||
const muteBtn = $('#muteBtn');
|
||||
const volume = $('#volume');
|
||||
const speed = $('#speed');
|
||||
const fsBtn = $('#fsBtn');
|
||||
const pipBtn = $('#pipBtn');
|
||||
const overlay = $('#overlay');
|
||||
const fileInput = $('#fileInput');
|
||||
const urlInput = $('#urlInput');
|
||||
const loadUrlBtn = $('#loadUrlBtn');
|
||||
const sourceName = $('#sourceName');
|
||||
const fileName = $('#fileName');
|
||||
const fileInputLabel = $('#fileInputLabel');
|
||||
const resetBtn = $('#resetBtn');
|
||||
const videoBox = $('#videoBox');
|
||||
|
||||
const fmt = s => {
|
||||
if (!isFinite(s)) return '--:--';
|
||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60);
|
||||
return h>0 ? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}` : `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
|
||||
};
|
||||
|
||||
// 恢复偏好
|
||||
try{
|
||||
const vol = localStorage.getItem('vp_volume');
|
||||
if (vol !== null) video.volume = Number(vol);
|
||||
volume.value = video.volume;
|
||||
const spd = localStorage.getItem('vp_speed');
|
||||
if (spd) { video.playbackRate = Number(spd); speed.value = spd; }
|
||||
}catch{}
|
||||
|
||||
// 事件绑定
|
||||
const togglePlay = () => {
|
||||
if (video.paused || video.ended) { video.play(); showOverlay(); } else { video.pause(); showOverlay(); }
|
||||
updatePlayIcon();
|
||||
};
|
||||
const updatePlayIcon = () => {
|
||||
playBtn.innerHTML = video.paused ?
|
||||
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M8 5v14l11-7-11-7z" fill="currentColor"/></svg>' :
|
||||
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z" fill="currentColor"/></svg>';
|
||||
};
|
||||
|
||||
const showOverlay = () => {
|
||||
overlay.classList.add('show');
|
||||
clearTimeout(showOverlay._t);
|
||||
showOverlay._t = setTimeout(()=>overlay.classList.remove('show'), 350);
|
||||
};
|
||||
|
||||
const step = (sec) => { video.currentTime = Math.max(0, Math.min(video.duration || 0, video.currentTime + sec)); showOverlay(); };
|
||||
|
||||
const loadFile = (file) => {
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
attachSource(url, {name: file.name});
|
||||
sourceName.textContent = file.name;
|
||||
fileName.textContent = file.name;
|
||||
if (fileInputLabel) fileInputLabel.textContent = '已选择:' + file.name;
|
||||
};
|
||||
|
||||
const isHls = (url) => /\.m3u8(\?.*)?$/i.test(url);
|
||||
const isDirect = (url) => /(\.mp4|\.webm|\.ogg)(\?.*)?$/i.test(url) || url.startsWith('blob:') || url.startsWith('data:');
|
||||
|
||||
let hls; // hls.js 实例
|
||||
|
||||
const destroyHls = () => { if (hls) { hls.destroy(); hls = null; } };
|
||||
|
||||
function attachSource(url, {name='外部链接'}={}){
|
||||
destroyHls();
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
|
||||
if (isHls(url)){
|
||||
if (video.canPlayType('application/vnd.apple.mpegURL')){
|
||||
video.src = url; // Safari 原生
|
||||
} else if (window.Hls && window.Hls.isSupported()){
|
||||
hls = new Hls({ maxBufferLength: 30, enableWorker: true });
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
} else {
|
||||
alert('该浏览器不支持 HLS 播放(.m3u8)。请使用 Safari 或现代 Chromium 浏览器。');
|
||||
return;
|
||||
}
|
||||
} else if (isDirect(url)) {
|
||||
video.src = url;
|
||||
} else {
|
||||
alert('请提供视频“直链”地址(.mp4 / .webm / .ogg / .m3u8)。普通网页链接无法直接播放。');
|
||||
return;
|
||||
}
|
||||
|
||||
sourceName.textContent = name || url;
|
||||
video.playbackRate = Number(speed.value || 1);
|
||||
video.volume = Number(volume.value);
|
||||
updatePlayIcon();
|
||||
video.addEventListener('loadedmetadata', ()=>{
|
||||
timeTotal.textContent = fmt(video.duration);
|
||||
progress.value = 0; timeNow.textContent = '00:00';
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// 选择文件
|
||||
fileInput.addEventListener('change', e=>{
|
||||
const file = e.target.files && e.target.files[0];
|
||||
loadFile(file);
|
||||
});
|
||||
|
||||
// 通过 URL 加载
|
||||
const loadFromInput = () => {
|
||||
const url = (urlInput.value || '').trim();
|
||||
if (!url) return;
|
||||
attachSource(url, {name: url});
|
||||
};
|
||||
loadUrlBtn.addEventListener('click', loadFromInput);
|
||||
urlInput.addEventListener('keydown', e=>{ if (e.key === 'Enter') loadFromInput(); });
|
||||
|
||||
// 播放控制
|
||||
playBtn.addEventListener('click', togglePlay);
|
||||
backwardBtn.addEventListener('click', ()=>step(-10));
|
||||
forwardBtn.addEventListener('click', ()=>step(10));
|
||||
|
||||
// 音量与静音
|
||||
volume.addEventListener('input', ()=>{ video.volume = Number(volume.value); try{localStorage.setItem('vp_volume', volume.value);}catch{} });
|
||||
muteBtn.addEventListener('click', ()=>{ video.muted = !video.muted; muteBtn.style.opacity = video.muted? .6:1; });
|
||||
|
||||
// 倍速
|
||||
speed.addEventListener('change', ()=>{ video.playbackRate = Number(speed.value); try{localStorage.setItem('vp_speed', speed.value);}catch{} });
|
||||
|
||||
// 画中画
|
||||
pipBtn.addEventListener('click', async ()=>{
|
||||
try{
|
||||
if (document.pictureInPictureElement) { await document.exitPictureInPicture(); }
|
||||
else if (document.pictureInPictureEnabled && !video.disablePictureInPicture) { await video.requestPictureInPicture(); }
|
||||
}catch(err){ console.warn(err); }
|
||||
});
|
||||
|
||||
// 全屏
|
||||
const isFullscreen = () => document.fullscreenElement || document.webkitFullscreenElement;
|
||||
fsBtn.addEventListener('click', ()=>{
|
||||
if (!isFullscreen()) {
|
||||
(videoBox.requestFullscreen || videoBox.webkitRequestFullscreen || videoBox.requestFullScreen)?.call(videoBox);
|
||||
} else {
|
||||
(document.exitFullscreen || document.webkitExitFullscreen)?.call(document);
|
||||
}
|
||||
});
|
||||
|
||||
// 进度显示 & 拖动
|
||||
const syncProgress = () => {
|
||||
if (!video.duration) return;
|
||||
const fraction = video.currentTime / video.duration;
|
||||
progress.value = Math.round(fraction * 1000);
|
||||
timeNow.textContent = fmt(video.currentTime);
|
||||
timeTotal.textContent = fmt(video.duration);
|
||||
};
|
||||
|
||||
let rafId;
|
||||
const loop = () => { syncProgress(); rafId = requestAnimationFrame(loop); };
|
||||
video.addEventListener('play', ()=>{ updatePlayIcon(); cancelAnimationFrame(rafId); loop(); });
|
||||
video.addEventListener('pause', ()=>{ updatePlayIcon(); cancelAnimationFrame(rafId); syncProgress(); });
|
||||
video.addEventListener('ended', ()=>{ updatePlayIcon(); showOverlay(); });
|
||||
|
||||
let seeking = false;
|
||||
const seekTo = (val)=>{
|
||||
if (!video.duration) return;
|
||||
const target = (val/1000) * video.duration;
|
||||
video.currentTime = target;
|
||||
syncProgress();
|
||||
};
|
||||
['input','change'].forEach(evt=>progress.addEventListener(evt, e=>{ seeking = evt==='input'; seekTo(Number(progress.value)); }));
|
||||
|
||||
// 键盘快捷键
|
||||
document.addEventListener('keydown', (e)=>{
|
||||
if (/input|textarea|select/i.test(document.activeElement.tagName)) return;
|
||||
switch(e.key){
|
||||
case ' ': e.preventDefault(); togglePlay(); break;
|
||||
case 'ArrowLeft': step(-5); break;
|
||||
case 'ArrowRight': step(5); break;
|
||||
case 'ArrowUp': video.volume = Math.min(1, video.volume + .05); volume.value = video.volume; break;
|
||||
case 'ArrowDown': video.volume = Math.max(0, video.volume - .05); volume.value = video.volume; break;
|
||||
case 'f': case 'F': fsBtn.click(); break;
|
||||
case 'm': case 'M': video.muted = !video.muted; muteBtn.style.opacity = video.muted? .6:1; break;
|
||||
case ',': video.playbackRate = Math.max(.25, Math.round((video.playbackRate - .25)*4)/4); speed.value = String(video.playbackRate); break;
|
||||
case '.': video.playbackRate = Math.min(2, Math.round((video.playbackRate + .25)*4)/4); speed.value = String(video.playbackRate); break;
|
||||
default:
|
||||
if (/^[0-9]$/.test(e.key) && video.duration){
|
||||
const pct = Number(e.key) / 10; video.currentTime = pct * video.duration;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 双击左右快进/后退(移动端友好)
|
||||
let lastTap = 0;
|
||||
videoBox.addEventListener('touchend', (e)=>{
|
||||
const now = Date.now();
|
||||
const dt = now - lastTap; lastTap = now;
|
||||
if (dt>300) return; // 双击窗口
|
||||
const x = e.changedTouches[0].clientX;
|
||||
const rect = videoBox.getBoundingClientRect();
|
||||
if (x - rect.left < rect.width/2) step(-10); else step(10);
|
||||
});
|
||||
|
||||
// 点击视频区域也可播放/暂停
|
||||
videoBox.addEventListener('click', (e)=>{
|
||||
// 避免点击控制条触发
|
||||
if (e.target.closest('.controls')) return;
|
||||
togglePlay();
|
||||
});
|
||||
|
||||
// 重置
|
||||
resetBtn.addEventListener('click', ()=>{
|
||||
try{ localStorage.removeItem('vp_volume'); localStorage.removeItem('vp_speed'); }catch{}
|
||||
destroyHls();
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
sourceName.textContent = '未加载视频';
|
||||
fileName.textContent = '未选择文件';
|
||||
urlInput.value = '';
|
||||
progress.value = 0; timeNow.textContent = '00:00'; timeTotal.textContent = '--:--';
|
||||
volume.value = 1; video.volume = 1; speed.value = '1'; video.playbackRate = 1;
|
||||
updatePlayIcon();
|
||||
});
|
||||
|
||||
// 初始图标
|
||||
updatePlayIcon();
|
||||
</script></body>
|
||||
</html>
|
||||
225
InfoGenie-frontend/public/toolbox/实用工具/计算器/app.js
Normal file
225
InfoGenie-frontend/public/toolbox/实用工具/计算器/app.js
Normal file
@@ -0,0 +1,225 @@
|
||||
(() => {
|
||||
const { createApp, ref, computed, watch } = Vue;
|
||||
|
||||
// 检测是否可用 Math.js
|
||||
const hasMath = typeof math !== 'undefined';
|
||||
if (hasMath) {
|
||||
math.config({ number: 'BigNumber', precision: 64 });
|
||||
}
|
||||
|
||||
// 保存原始三角函数以便覆盖时调用
|
||||
const originalSin = hasMath ? math.sin : null;
|
||||
const originalCos = hasMath ? math.cos : null;
|
||||
const originalTan = hasMath ? math.tan : null;
|
||||
|
||||
// 角度转换因子(deg -> rad)
|
||||
const RAD_FACTOR = hasMath ? math.divide(math.pi, math.bignumber(180)) : (Math.PI / 180);
|
||||
|
||||
// 动态角度模式变量供三角函数使用
|
||||
let angleModeVar = 'deg';
|
||||
|
||||
function sinWrapper(x) {
|
||||
try {
|
||||
if (angleModeVar === 'deg') {
|
||||
const xr = hasMath ? math.multiply(x, RAD_FACTOR) : (Number(x) * RAD_FACTOR);
|
||||
return hasMath ? originalSin(xr) : Math.sin(xr);
|
||||
}
|
||||
return hasMath ? originalSin(x) : Math.sin(Number(x));
|
||||
} catch (e) { throw e; }
|
||||
}
|
||||
function cosWrapper(x) {
|
||||
try {
|
||||
if (angleModeVar === 'deg') {
|
||||
const xr = hasMath ? math.multiply(x, RAD_FACTOR) : (Number(x) * RAD_FACTOR);
|
||||
return hasMath ? originalCos(xr) : Math.cos(xr);
|
||||
}
|
||||
return hasMath ? originalCos(x) : Math.cos(Number(x));
|
||||
} catch (e) { throw e; }
|
||||
}
|
||||
function tanWrapper(x) {
|
||||
try {
|
||||
if (angleModeVar === 'deg') {
|
||||
const xr = hasMath ? math.multiply(x, RAD_FACTOR) : (Number(x) * RAD_FACTOR);
|
||||
return hasMath ? originalTan(xr) : Math.tan(xr);
|
||||
}
|
||||
return hasMath ? originalTan(x) : Math.tan(Number(x));
|
||||
} catch (e) { throw e; }
|
||||
}
|
||||
|
||||
// 覆盖三角函数以支持角度模式(Math.js 可用时)
|
||||
if (hasMath) {
|
||||
math.import({ sin: sinWrapper, cos: cosWrapper, tan: tanWrapper }, { override: true });
|
||||
}
|
||||
|
||||
function formatBig(value) {
|
||||
try {
|
||||
if (value == null) return '';
|
||||
if (hasMath) {
|
||||
return math.format(value, {
|
||||
notation: 'auto',
|
||||
precision: 14,
|
||||
lowerExp: -6,
|
||||
upperExp: 15,
|
||||
});
|
||||
} else {
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
if (!isFinite(num)) return '错误';
|
||||
const str = num.toFixed(12);
|
||||
return str.replace(/\.0+$/, '').replace(/(\.[0-9]*?)0+$/, '$1');
|
||||
}
|
||||
} catch (e) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(exp) {
|
||||
// 将显示符号标准化为计算符号,保留原字符不做删除
|
||||
return exp
|
||||
.replace(/×/g, '*')
|
||||
.replace(/÷/g, '/')
|
||||
.replace(/√/g, 'sqrt');
|
||||
}
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const expression = ref('');
|
||||
const result = ref(hasMath ? math.bignumber(0) : 0);
|
||||
const errorMsg = ref('');
|
||||
const lastAns = ref(hasMath ? math.bignumber(0) : 0);
|
||||
const angleMode = ref('deg');
|
||||
|
||||
watch(angleMode, (val) => { angleModeVar = val; });
|
||||
|
||||
const formattedExpression = computed(() => expression.value || '0');
|
||||
const formattedResult = computed(() => errorMsg.value ? '' : formatBig(result.value));
|
||||
|
||||
function isParenthesesBalanced(s) {
|
||||
let count = 0;
|
||||
for (const ch of s) {
|
||||
if (ch === '(') count++;
|
||||
else if (ch === ')') count--;
|
||||
if (count < 0) return false;
|
||||
}
|
||||
return count === 0;
|
||||
}
|
||||
|
||||
function safeEvaluate(exp) {
|
||||
errorMsg.value = '';
|
||||
try {
|
||||
const s = normalize(exp);
|
||||
if (!s) { result.value = hasMath ? math.bignumber(0) : 0; return result.value; }
|
||||
// 检测非法字符:仅允许数字、运算符、括号、字母(用于函数和ANS),以及空白
|
||||
if (/[^0-9\.\+\-\*\/\^\(\)a-zA-Z\s]/.test(s)) { throw new Error('错误'); }
|
||||
if (!isParenthesesBalanced(s)) throw new Error('错误');
|
||||
if (hasMath) {
|
||||
const scope = { ANS: lastAns.value };
|
||||
const res = math.evaluate(s, scope);
|
||||
// 防止除以零等无效情况
|
||||
if (res && res.isFinite && !res.isFinite()) { throw new Error('错误'); }
|
||||
result.value = res;
|
||||
return res;
|
||||
} else {
|
||||
// 原生回退:将表达式映射到安全本地函数
|
||||
let expr = s
|
||||
.replace(/\^/g, '**')
|
||||
.replace(/sin\(/g, '__sin(')
|
||||
.replace(/cos\(/g, '__cos(')
|
||||
.replace(/tan\(/g, '__tan(')
|
||||
.replace(/sqrt\(/g, '__sqrt(')
|
||||
.replace(/\bANS\b/g, String(lastAns.value));
|
||||
// 严格校验(只允许安全字符)
|
||||
if (/[^0-9+\-*/()._^a-zA-Z\s]/.test(expr)) throw new Error('错误');
|
||||
// 定义本地安全函数
|
||||
const __sqrt = (x) => Math.sqrt(Number(x));
|
||||
const __sin = (x) => angleModeVar === 'deg' ? Math.sin(Number(x) * Math.PI / 180) : Math.sin(Number(x));
|
||||
const __cos = (x) => angleModeVar === 'deg' ? Math.cos(Number(x) * Math.PI / 180) : Math.cos(Number(x));
|
||||
const __tan = (x) => angleModeVar === 'deg' ? Math.tan(Number(x) * Math.PI / 180) : Math.tan(Number(x));
|
||||
const res = Function('__sqrt','__sin','__cos','__tan', `"use strict"; return (${expr});`)(__sqrt,__sin,__cos,__tan);
|
||||
if (!isFinite(res)) throw new Error('错误');
|
||||
result.value = res;
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.value = '错误';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(expression, (exp) => { safeEvaluate(exp); });
|
||||
|
||||
function press(token) {
|
||||
// 避免连续两个小数点
|
||||
if (token === '.' && expression.value.slice(-1) === '.') return;
|
||||
expression.value += token;
|
||||
}
|
||||
|
||||
function op(opSymbol) {
|
||||
const last = expression.value.slice(-1);
|
||||
if (/[\+\-×÷\*\/\^]/.test(last)) {
|
||||
expression.value = expression.value.slice(0, -1) + opSymbol;
|
||||
} else {
|
||||
expression.value += opSymbol;
|
||||
}
|
||||
}
|
||||
|
||||
function func(fn) {
|
||||
const map = { sqrt: 'sqrt', sin: 'sin', cos: 'cos', tan: 'tan' };
|
||||
const f = map[fn] || fn;
|
||||
expression.value += f + '(';
|
||||
}
|
||||
|
||||
function square() {
|
||||
expression.value += '^2';
|
||||
}
|
||||
|
||||
function backspace() {
|
||||
if (!expression.value) return;
|
||||
expression.value = expression.value.slice(0, -1);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
expression.value = '';
|
||||
result.value = math.bignumber(0);
|
||||
errorMsg.value = '';
|
||||
}
|
||||
|
||||
function equals() {
|
||||
const res = safeEvaluate(expression.value);
|
||||
if (res != null) {
|
||||
lastAns.value = res;
|
||||
expression.value = formatBig(res);
|
||||
result.value = res;
|
||||
}
|
||||
}
|
||||
|
||||
function ans() {
|
||||
expression.value += 'ANS';
|
||||
}
|
||||
|
||||
function setAngle(mode) {
|
||||
angleMode.value = mode;
|
||||
}
|
||||
|
||||
// 初始计算
|
||||
safeEvaluate(expression.value);
|
||||
|
||||
return {
|
||||
expression,
|
||||
result,
|
||||
errorMsg,
|
||||
formattedExpression,
|
||||
formattedResult,
|
||||
angleMode,
|
||||
setAngle,
|
||||
press,
|
||||
op,
|
||||
func,
|
||||
clear,
|
||||
backspace,
|
||||
equals,
|
||||
square,
|
||||
ans,
|
||||
};
|
||||
},
|
||||
}).mount('#app');
|
||||
})();
|
||||
63
InfoGenie-frontend/public/toolbox/实用工具/计算器/index.html
Normal file
63
InfoGenie-frontend/public/toolbox/实用工具/计算器/index.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<title>🖩网页计算器</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="calculator">
|
||||
<header class="topbar">
|
||||
<div class="brand">🖩网页计算器</div>
|
||||
</header>
|
||||
|
||||
<section class="display">
|
||||
<div class="expression" :title="expression">{{ formattedExpression }}</div>
|
||||
<div class="result" :class="{ error: !!errorMsg }">{{ errorMsg || formattedResult }}</div>
|
||||
</section>
|
||||
|
||||
<section class="keypad">
|
||||
<button @click="press('(')">(</button>
|
||||
<button @click="press(')')">)</button>
|
||||
<button @click="func('sqrt')">√</button>
|
||||
<button @click="clear()">AC</button>
|
||||
|
||||
<button @click="func('sin')">sin</button>
|
||||
<button @click="func('cos')">cos</button>
|
||||
<button @click="func('tan')">tan</button>
|
||||
<button @click="backspace()">⌫</button>
|
||||
|
||||
<button @click="press('7')">7</button>
|
||||
<button @click="press('8')">8</button>
|
||||
<button @click="press('9')">9</button>
|
||||
<button @click="op('÷')">÷</button>
|
||||
|
||||
<button @click="press('4')">4</button>
|
||||
<button @click="press('5')">5</button>
|
||||
<button @click="press('6')">6</button>
|
||||
<button @click="op('×')">×</button>
|
||||
|
||||
<button @click="press('1')">1</button>
|
||||
<button @click="press('2')">2</button>
|
||||
<button @click="press('3')">3</button>
|
||||
<button @click="op('-')">-</button>
|
||||
|
||||
<button @click="press('0')">0</button>
|
||||
<button @click="press('.')">.</button>
|
||||
<button @click="square()">x²</button>
|
||||
<button @click="op('+')">+</button>
|
||||
|
||||
<button class="span-2" @click="ans()">ANS</button>
|
||||
<button class="span-2 action" @click="equals()">=</button>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Frameworks -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mathjs@11/dist/math.min.js"></script>
|
||||
<!-- App -->
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
138
InfoGenie-frontend/public/toolbox/实用工具/计算器/styles.css
Normal file
138
InfoGenie-frontend/public/toolbox/实用工具/计算器/styles.css
Normal file
@@ -0,0 +1,138 @@
|
||||
:root {
|
||||
--bg-start: #d9f7d9;
|
||||
--bg-end: #e9fbd7;
|
||||
--btn-bg-1: #f7fff0;
|
||||
--btn-bg-2: #efffe6;
|
||||
--accent-1: #a6e3a1;
|
||||
--accent-2: #8fd68b;
|
||||
--text: #173b2b;
|
||||
--text-soft: #406a53;
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif;
|
||||
background: linear-gradient(135deg, var(--bg-start), var(--bg-end));
|
||||
color: var(--text);
|
||||
overflow: auto; /* 保留滚动效果 */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条 */
|
||||
html::-webkit-scrollbar, body::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
html, body { scrollbar-width: none; }
|
||||
|
||||
.calculator {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 12px env(safe-area-inset-right) 24px env(safe-area-inset-left);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.angle-toggle {
|
||||
display: inline-flex;
|
||||
background: rgba(255,255,255,0.35);
|
||||
border-radius: 999px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.angle-toggle button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
color: #24543a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.angle-toggle button.active {
|
||||
background: #b8e2b1;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.08) inset;
|
||||
}
|
||||
|
||||
.display {
|
||||
background: rgba(255,255,255,0.6);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.expression {
|
||||
min-height: 28px;
|
||||
font-size: 18px;
|
||||
color: var(--text-soft);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.result {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
color: var(--text);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.result.error { color: #d35454; }
|
||||
|
||||
.keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.keypad button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 14px 0;
|
||||
min-height: 58px; /* 移动端友好触控 */
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: linear-gradient(180deg, var(--btn-bg-1), var(--btn-bg-2));
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.keypad button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.10);
|
||||
}
|
||||
|
||||
.keypad .action {
|
||||
background: linear-gradient(180deg, var(--accent-1), var(--accent-2));
|
||||
color: #0f2a1f;
|
||||
}
|
||||
|
||||
.keypad .span-2 { grid-column: span 2; }
|
||||
|
||||
.tips {
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
margin-top: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.keypad button { min-height: 52px; font-size: 16px; }
|
||||
.result { font-size: 28px; }
|
||||
}
|
||||
260
InfoGenie-frontend/public/toolbox/实用工具/记事本/index.html
Normal file
260
InfoGenie-frontend/public/toolbox/实用工具/记事本/index.html
Normal file
@@ -0,0 +1,260 @@
|
||||
<!doctype html>
|
||||
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>记事本</title>
|
||||
<style>
|
||||
:root{
|
||||
/* 柔和淡绿—黄绿色系 */
|
||||
--g1:#effae6; /* very light green */
|
||||
--g2:#e7f7dc; /* pale spring green */
|
||||
--g3:#f3f9e1; /* pale yellow-green */
|
||||
--ink:#1b3a2a; /* 深绿色文字 */
|
||||
--muted:#2e5a43a8;
|
||||
--accent:#bfe7b8; /* 轻微按钮底色 */
|
||||
--accent-press:#a9d7a2;
|
||||
--ring:#9fd79a88;
|
||||
}html,body{
|
||||
height:100%;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
body{
|
||||
margin:0;
|
||||
color:var(--ink);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "PingFang SC", "Noto Sans CJK SC", "Hiragino Sans GB", "Microsoft YaHei", Helvetica, Arial, sans-serif;
|
||||
background:
|
||||
radial-gradient(1200px 800px at 10% -10%, var(--g1), transparent 70%),
|
||||
radial-gradient(1000px 700px at 100% 20%, var(--g2), transparent 60%),
|
||||
linear-gradient(135deg, var(--g2), var(--g3));
|
||||
min-height: 100svh;
|
||||
min-height: 100dvh;
|
||||
display:flex; flex-direction:column;
|
||||
}
|
||||
|
||||
.app{ display:flex; flex-direction:column; min-height:100vh; min-height:100svh; min-height:100dvh; }
|
||||
|
||||
.toolbar{
|
||||
position:sticky; top:0; z-index:10;
|
||||
display:flex; gap:.5rem; align-items:center; justify-content:flex-end;
|
||||
padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) 12px max(12px, env(safe-area-inset-left));
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.55), rgba(255,255,255,.25));
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid #00000010;
|
||||
}
|
||||
|
||||
.btn{
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
appearance:none; border:none; cursor:pointer;
|
||||
padding: .6rem .9rem; border-radius: 14px;
|
||||
background: var(--accent);
|
||||
color: var(--ink); font-weight: 600; letter-spacing:.2px;
|
||||
box-shadow: 0 1px 0 #00000010, inset 0 0 0 1px #00000010;
|
||||
transition: transform .05s ease, background-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.btn:active{ transform: translateY(1px); background: var(--accent-press); }
|
||||
.btn:focus-visible{ outline: none; box-shadow: 0 0 0 3px var(--ring); }
|
||||
|
||||
.main{
|
||||
/* 使编辑区纵向充满可视区 */
|
||||
flex: 1 1 auto;
|
||||
display:grid;
|
||||
grid-template-rows: 1fr;
|
||||
padding: 0 max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
/* 编辑器外壳 */
|
||||
.editor{
|
||||
align-self: stretch; justify-self: stretch;
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,.55);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 30px #00000010, inset 0 0 0 1px #00000010;
|
||||
display:grid;
|
||||
grid-template-columns: 1fr;
|
||||
overflow: visible; /* 因为 textarea 自适应高度,整体由页面滚动 */
|
||||
}
|
||||
|
||||
/* 行号栏 */
|
||||
.gutter{
|
||||
--digits: 2; /* 通过 JS 动态更新 */
|
||||
padding: 14px 10px 14px 14px;
|
||||
min-width: calc(var(--digits) * 0.75ch + 22px);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
color: var(--muted);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.65), rgba(255,255,255,.35));
|
||||
border-top-left-radius: 18px; border-bottom-left-radius: 18px;
|
||||
border-right: 1px dashed #0000001a;
|
||||
font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 记事本文本域 */
|
||||
.pad{
|
||||
display:block; resize:none;
|
||||
padding: 14px 14px 14px 12px; /* 左侧稍小以贴近行号 */
|
||||
margin:0; border:none; outline:none; background: transparent;
|
||||
width: 100%; height: auto; min-height: 40vh; /* 初始高度,随后由 JS 自适应 */
|
||||
font: 15px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
color: var(--ink);
|
||||
caret-color: #2c7a4b;
|
||||
tab-size: 2; /* 在移动端保持更短缩进 */
|
||||
}
|
||||
.pad::placeholder{ color:#2e5a4377; }
|
||||
.pad:focus-visible{ outline:none; }
|
||||
|
||||
/* 复制成功提示 */
|
||||
.toast{
|
||||
position: fixed; left: 50%; bottom: calc(16px + env(safe-area-inset-bottom)); transform: translateX(-50%) translateY(20px);
|
||||
background: rgba(46, 90, 67, .95);
|
||||
color: white; padding: 10px 14px; border-radius: 12px; font-size: 14px;
|
||||
box-shadow: 0 8px 24px #00000030;
|
||||
opacity: 0; pointer-events:none;
|
||||
transition: opacity .25s ease, transform .25s ease;
|
||||
}
|
||||
.toast.show{ opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
html::-webkit-scrollbar, body::-webkit-scrollbar{ width: 0; height: 0; }
|
||||
/* 小屏优化 */
|
||||
@media (max-width: 700px){
|
||||
.toolbar{ justify-content: space-between; }
|
||||
.btn{ padding:.55rem .8rem; border-radius:12px; }
|
||||
.gutter{ font-size: 13px; }
|
||||
.pad{ font-size: 15px; line-height: 1.7; }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" role="application" aria-label="淡绿记事本">
|
||||
<div class="toolbar" aria-label="工具栏">
|
||||
<div style="margin-right:auto;font-weight:700;letter-spacing:.3px;opacity:.8">📝记事本</div>
|
||||
<button id="copyBtn" class="btn" type="button" aria-label="一键复制">复制</button>
|
||||
<button id="clearBtn" class="btn" type="button" aria-label="一键清空">清空</button>
|
||||
<button id="exportBtn" class="btn" type="button" aria-label="导出为TXT">下载</button>
|
||||
</div><main class="main">
|
||||
<section class="editor" aria-label="编辑器">
|
||||
|
||||
<textarea id="pad" class="pad" spellcheck="false" placeholder="在这里开始记笔记"></textarea>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</div> <div id="toast" class="toast" role="status" aria-live="polite">已复制到剪贴板</div> <script>
|
||||
(function(){
|
||||
const pad = document.getElementById('pad');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const exportBtn = document.getElementById('exportBtn');
|
||||
const toast = document.getElementById('toast');
|
||||
|
||||
// 自动增高:根据内容调整 textarea 高度
|
||||
function autoResize(){
|
||||
pad.style.height = 'auto';
|
||||
// 在移动端加上一点冗余,避免光标被遮挡
|
||||
const extra = 8;
|
||||
pad.style.height = (pad.scrollHeight + extra) + 'px';
|
||||
}
|
||||
|
||||
// 更新行号(以换行符为准,不计算软换行)
|
||||
function updateLineNumbers(){
|
||||
const lines = pad.value.split('\n').length || 1;
|
||||
// 生成 "1\n2\n3..." 的字符串
|
||||
let nums = '';
|
||||
// 使用较快的构造方式避免频繁拼接开销
|
||||
const arr = new Array(lines);
|
||||
for (let i=0;i<lines;i++){ arr[i] = String(i+1); }
|
||||
nums = arr.join('\n');
|
||||
gutter.textContent = nums;
|
||||
// 动态调整行号栏宽度(按位数估算)
|
||||
const digits = String(lines).length;
|
||||
gutter.style.setProperty('--digits', Math.max(2, digits));
|
||||
}
|
||||
|
||||
function refresh(){
|
||||
autoResize();
|
||||
}
|
||||
|
||||
// 一键清空(确认)
|
||||
clearBtn.addEventListener('click', () => {
|
||||
const ok = confirm('确认要清空全部内容吗?');
|
||||
if(!ok) return;
|
||||
pad.value = '';
|
||||
refresh();
|
||||
pad.focus();
|
||||
});
|
||||
|
||||
// 一键复制
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try{
|
||||
await navigator.clipboard.writeText(pad.value);
|
||||
showToast('已复制到剪贴板');
|
||||
}catch(err){
|
||||
// 兼容旧环境的回退方案
|
||||
const sel = document.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(pad);
|
||||
sel.removeAllRanges(); sel.addRange(range);
|
||||
const ok = document.execCommand('copy');
|
||||
sel.removeAllRanges();
|
||||
showToast(ok ? '已复制到剪贴板' : '复制失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 导出为TXT
|
||||
function exportText(){
|
||||
try{
|
||||
const text = pad.value || '';
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const now = new Date();
|
||||
const two = n => String(n).padStart(2, '0');
|
||||
const filename = `笔记_${now.getFullYear()}${two(now.getMonth()+1)}${two(now.getDate())}_${two(now.getHours())}${two(now.getMinutes())}${two(now.getSeconds())}.txt`;
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('已导出 TXT 文件');
|
||||
}catch(err){
|
||||
showToast('导出失败');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
exportBtn.addEventListener('click', exportText);
|
||||
|
||||
// 提示小气泡
|
||||
let toastTimer;
|
||||
function showToast(text){
|
||||
toast.textContent = text;
|
||||
toast.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(()=> toast.classList.remove('show'), 1600);
|
||||
}
|
||||
|
||||
// 输入时联动
|
||||
pad.addEventListener('input', refresh);
|
||||
// 初次渲染
|
||||
window.addEventListener('DOMContentLoaded', ()=>{
|
||||
refresh();
|
||||
// 让首次点击更丝滑
|
||||
pad.focus({preventScroll:true});
|
||||
// iOS 软键盘适配:避免工具栏挡住
|
||||
window.scrollTo(0,0);
|
||||
});
|
||||
|
||||
// 处理粘贴等突变
|
||||
pad.addEventListener('paste', ()=> setTimeout(refresh, 0));
|
||||
|
||||
// 可选:窗口尺寸变化时保持高度合理
|
||||
window.addEventListener('resize', () => {
|
||||
// 仅在可见时更新,避免布局抖动
|
||||
requestAnimationFrame(refresh);
|
||||
});
|
||||
})();
|
||||
</script></body>
|
||||
</html>
|
||||
242
InfoGenie-frontend/public/toolbox/实用工具/随机数生成器/index.html
Normal file
242
InfoGenie-frontend/public/toolbox/实用工具/随机数生成器/index.html
Normal file
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html><html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<title>随机数生成器</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg-start:#e8f8e3; /* 淡绿 */
|
||||
--bg-end:#f3fbe6; /* 淡黄绿 */
|
||||
--primary:#2f9e44; /* 主题绿 */
|
||||
--primary-weak:#94d3a2;
|
||||
--text:#204030;
|
||||
--muted:#5d7a67;
|
||||
--card:#ffffffa6; /* 半透明卡片 */
|
||||
--shadow:0 8px 20px rgba(34, 139, 34, 0.08);
|
||||
--radius:18px;
|
||||
}
|
||||
html,body{
|
||||
height:100%;
|
||||
margin:0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, PingFang SC, Noto Sans CJK SC, "Helvetica Neue", Arial, "Noto Sans", "Hiragino Sans GB", sans-serif;
|
||||
color:var(--text);
|
||||
background: linear-gradient(160deg, var(--bg-start), var(--bg-end));
|
||||
}
|
||||
.app{
|
||||
max-width: 540px; /* 适配手机竖屏 */
|
||||
margin: 0 auto;
|
||||
padding: 20px 16px 36px;
|
||||
}
|
||||
header{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
margin-bottom:14px;
|
||||
}
|
||||
h1{
|
||||
font-size: clamp(20px, 4.8vw, 26px);
|
||||
margin:12px 0 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.subtitle{
|
||||
font-size: 13px;
|
||||
color:var(--muted);
|
||||
}.card{
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(6px);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.grid{ display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.field{ display:flex; flex-direction:column; gap:6px; }
|
||||
label{ font-size: 13px; color: var(--muted);}
|
||||
input[type="number"]{
|
||||
-webkit-appearance: none; appearance:none;
|
||||
width:100%;
|
||||
padding: 12px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #d7ead9;
|
||||
background:#ffffff;
|
||||
outline:none;
|
||||
font-size:16px;
|
||||
}
|
||||
input[type="number"]:focus{ border-color: var(--primary-weak); box-shadow: 0 0 0 3px #2f9e4415; }
|
||||
|
||||
.count-row{ display:flex; align-items:center; gap:10px; }
|
||||
.count-row input[type="range"]{ flex:1; }
|
||||
input[type="range"]{
|
||||
width:100%; height: 34px; background:transparent;
|
||||
}
|
||||
/* 自定义滑块 */
|
||||
input[type="range"]::-webkit-slider-runnable-track{ height: 6px; border-radius: 6px; background: linear-gradient(90deg, var(--primary-weak), #d5efcf); }
|
||||
input[type="range"]::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none; width:22px; height:22px; border-radius:50%; background: #fff; border:2px solid var(--primary); margin-top:-8px; box-shadow: 0 1px 4px rgba(0,0,0,.15); }
|
||||
input[type="range"]::-moz-range-track{ height: 6px; border-radius:6px; background: linear-gradient(90deg, var(--primary-weak), #d5efcf);}
|
||||
input[type="range"]::-moz-range-thumb{ width:22px; height:22px; border-radius:50%; background:#fff; border:2px solid var(--primary); }
|
||||
|
||||
.btns{ display:grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top:8px; }
|
||||
button{
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
padding: 14px 16px;
|
||||
border-radius: 15px;
|
||||
border:none; outline:none;
|
||||
font-size:16px; font-weight: 600; letter-spacing:.4px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.btn-primary{ background: linear-gradient(180deg, #9fe3b0, #62c27b); color:#0b3318; }
|
||||
.btn-ghost{ background:#ffffff; color:#2f6f3f; border:1px solid #dcefe0; }
|
||||
|
||||
.hint{font-size:12px; color:var(--muted); margin-top:6px;}
|
||||
|
||||
.results{ margin-top: 14px; display:grid; grid-template-columns: repeat(2, 1fr); gap:10px; }
|
||||
@media (min-width:380px){ .results{ grid-template-columns: repeat(3, 1fr);} }
|
||||
@media (min-width:480px){ .results{ grid-template-columns: repeat(4, 1fr);} }
|
||||
|
||||
.pill{
|
||||
background:#ffffff;
|
||||
border:1px solid #e3f3e6;
|
||||
border-radius: 14px;
|
||||
padding: 14px 0;
|
||||
text-align:center;
|
||||
font-size:18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: transform .08s ease;
|
||||
user-select: text;
|
||||
}
|
||||
.pill:active{ transform: scale(.98); }
|
||||
|
||||
.toolbar{ display:flex; gap:10px; margin-top:10px; }
|
||||
.toolbar button{ flex:1; }
|
||||
.small{ font-size:13px; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header>
|
||||
<div>
|
||||
<h1>随机数生成器</h1>
|
||||
<div class="subtitle">设置范围与数量,一键生成(整数,包含最小值与最大值)。</div>
|
||||
</div>
|
||||
</header><section class="card">
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="min">最小值</label>
|
||||
<input type="number" id="min" inputmode="numeric" value="0" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="max">最大值</label>
|
||||
<input type="number" id="max" inputmode="numeric" value="100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top:10px;">
|
||||
<label for="countRange">生成个数</label>
|
||||
<div class="count-row">
|
||||
<input id="countRange" type="range" min="1" max="100" value="10" />
|
||||
<input id="countNum" type="number" min="1" max="100" value="10" style="width:94px"/>
|
||||
</div>
|
||||
<div class="hint">最多一次生成 100 个。若最小值大于最大值,将自动互换。</div>
|
||||
</div>
|
||||
|
||||
<div class="btns">
|
||||
<button class="btn-ghost" id="clearBtn">清空</button>
|
||||
<button class="btn-primary" id="genBtn">生成</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn-ghost small" id="copyBtn">复制结果</button>
|
||||
<button class="btn-ghost small" id="downloadBtn">下载为TXT</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="resultCard" style="display:none;">
|
||||
<div class="results" id="results"></div>
|
||||
</section>
|
||||
|
||||
</div> <script>
|
||||
const minEl = document.getElementById('min');
|
||||
const maxEl = document.getElementById('max');
|
||||
const rangeEl = document.getElementById('countRange');
|
||||
const countEl = document.getElementById('countNum');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const resultCard = document.getElementById('resultCard');
|
||||
|
||||
// 双向同步 个数 输入
|
||||
const sync = (fromRange) => {
|
||||
if (fromRange) countEl.value = rangeEl.value; else rangeEl.value = Math.min(Math.max(1, Number(countEl.value||1)), Number(countEl.max));
|
||||
};
|
||||
rangeEl.addEventListener('input', () => sync(true));
|
||||
countEl.addEventListener('input', () => sync(false));
|
||||
|
||||
const clampInt = (v, def=0) => Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : def;
|
||||
|
||||
const randInt = (min, max) => {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min; // 含两端
|
||||
};
|
||||
|
||||
function generate(){
|
||||
let min = clampInt(minEl.value, 0);
|
||||
let max = clampInt(maxEl.value, 100);
|
||||
if (min > max) [min, max] = [max, min];
|
||||
minEl.value = min; maxEl.value = max;
|
||||
|
||||
let n = clampInt(countEl.value, 10);
|
||||
n = Math.max(1, Math.min(100, n));
|
||||
countEl.value = n; rangeEl.value = n;
|
||||
|
||||
const out = Array.from({length:n}, () => randInt(min, max));
|
||||
// 渲染
|
||||
resultsEl.innerHTML = '';
|
||||
out.forEach((num, i) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'pill';
|
||||
d.textContent = num;
|
||||
d.style.opacity = 0;
|
||||
resultsEl.appendChild(d);
|
||||
// 简单入场动画
|
||||
requestAnimationFrame(() => {
|
||||
d.style.transition = 'opacity .18s ease';
|
||||
d.style.opacity = 1;
|
||||
});
|
||||
});
|
||||
resultCard.style.display = 'block';
|
||||
// 保存到剪贴板友好字符串
|
||||
resultCard.dataset.text = out.join(', ');
|
||||
}
|
||||
|
||||
function clearAll(){
|
||||
resultsEl.innerHTML = '';
|
||||
resultCard.style.display = 'none';
|
||||
}
|
||||
|
||||
async function copyResults(){
|
||||
const text = resultCard.dataset.text || '';
|
||||
if (!text) return;
|
||||
try{ await navigator.clipboard.writeText(text); alert('已复制到剪贴板'); }catch(e){
|
||||
// 兼容不支持的环境
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
|
||||
alert('已复制');
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTxt(){
|
||||
const text = resultCard.dataset.text || '';
|
||||
if (!text) return;
|
||||
const blob = new Blob([text + '\n'], {type:'text/plain;charset=utf-8'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = '随机数.txt'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
document.getElementById('genBtn').addEventListener('click', generate);
|
||||
document.getElementById('clearBtn').addEventListener('click', clearAll);
|
||||
document.getElementById('copyBtn').addEventListener('click', copyResults);
|
||||
document.getElementById('downloadBtn').addEventListener('click', downloadTxt);
|
||||
|
||||
// 方便初次预览
|
||||
generate();
|
||||
</script></body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user