feat: 更新项目代码
This commit is contained in:
@@ -1,27 +1,127 @@
|
||||
/**
|
||||
* 统一调用后端 /api/aimodelapp/chat(提示词由前端 ai-prompts.js 组装)
|
||||
* 统一调用后端 /api/aimodelapp/chat,优先流式 /api/aimodelapp/chat/stream 实时反馈进度
|
||||
*/
|
||||
(function (global) {
|
||||
global.AiChat = {
|
||||
/**
|
||||
* @param {{ role: string, content: string }[]} messages
|
||||
* @param {{ provider?: string, model?: string }} [opts]
|
||||
* @returns {Promise<string>} assistant 文本
|
||||
*/
|
||||
async complete(messages, opts) {
|
||||
opts = opts || {};
|
||||
const token =
|
||||
(global.AUTH_CONFIG && typeof global.AUTH_CONFIG.getToken === 'function'
|
||||
? global.AUTH_CONFIG.getToken()
|
||||
: null) || global.localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('未登录,请先登录后使用AI功能');
|
||||
const DEFAULT_BAR_ID = '__ai_stream_status_bar';
|
||||
|
||||
function getToken() {
|
||||
return (
|
||||
(global.AUTH_CONFIG && typeof global.AUTH_CONFIG.getToken === 'function'
|
||||
? global.AUTH_CONFIG.getToken()
|
||||
: null) || global.localStorage.getItem('token')
|
||||
);
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
return (global.API_CONFIG && global.API_CONFIG.baseUrl) || '';
|
||||
}
|
||||
|
||||
function ensureDefaultStreamBar() {
|
||||
if (typeof document === 'undefined') return null;
|
||||
let el = document.getElementById(DEFAULT_BAR_ID);
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = DEFAULT_BAR_ID;
|
||||
el.setAttribute('role', 'status');
|
||||
el.style.cssText = [
|
||||
'position:fixed',
|
||||
'bottom:0',
|
||||
'left:0',
|
||||
'right:0',
|
||||
'z-index:2147483646',
|
||||
'padding:10px 14px',
|
||||
'font-size:13px',
|
||||
'line-height:1.4',
|
||||
'background:rgba(17,24,39,.94)',
|
||||
'color:#e5e7eb',
|
||||
'border-top:1px solid #374151',
|
||||
'box-shadow:0 -4px 20px rgba(0,0,0,.15)',
|
||||
].join(';');
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function hideDefaultStreamBar() {
|
||||
if (typeof document === 'undefined') return;
|
||||
const el = document.getElementById(DEFAULT_BAR_ID);
|
||||
if (el) {
|
||||
el.style.display = 'none';
|
||||
el.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function showDefaultProgress(text) {
|
||||
const el = ensureDefaultStreamBar();
|
||||
if (!el) return;
|
||||
el.style.display = 'block';
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
async function nonStreamComplete(messages, opts) {
|
||||
opts = opts || {};
|
||||
const token = getToken();
|
||||
if (!token) throw new Error('未登录,请先登录后使用AI功能');
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(base + '/api/aimodelapp/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
provider: opts.provider || 'deepseek',
|
||||
model: opts.model || 'deepseek-chat',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errMsg = res.statusText;
|
||||
try {
|
||||
const errData = await res.json();
|
||||
errMsg = errData.error || errMsg;
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
const base = (global.API_CONFIG && global.API_CONFIG.baseUrl) || '';
|
||||
const res = await fetch(base + '/api/aimodelapp/chat', {
|
||||
throw new Error(errMsg || 'API 请求失败');
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success && data.content != null) {
|
||||
return typeof data.content === 'string' ? data.content : String(data.content);
|
||||
}
|
||||
throw new Error(data.error || 'API 响应异常');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ role: string, content: string }[]} messages
|
||||
* @param {{ provider?: string, model?: string }} [opts]
|
||||
* @param {{ onStatus?: (s: string) => void, onDelta?: (chunk: string, full: string) => void } | null} [progress]
|
||||
*/
|
||||
async function tryStreamThenFallback(messages, opts, progress) {
|
||||
opts = opts || {};
|
||||
const token = getToken();
|
||||
if (!token) throw new Error('未登录,请先登录后使用AI功能');
|
||||
const base = getBaseUrl();
|
||||
const useExternal = progress && typeof progress === 'object';
|
||||
const onStatus = useExternal && typeof progress.onStatus === 'function' ? progress.onStatus : showDefaultProgress;
|
||||
const onDelta = useExternal && typeof progress.onDelta === 'function' ? progress.onDelta : null;
|
||||
|
||||
const fireStatus = (s) => {
|
||||
try {
|
||||
onStatus(s);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
let res;
|
||||
try {
|
||||
fireStatus('正在连接模型…');
|
||||
res = await fetch(base + '/api/aimodelapp/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
Authorization: 'Bearer ' + token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -30,19 +130,86 @@
|
||||
model: opts.model || 'deepseek-chat',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errMsg = res.statusText;
|
||||
} catch (e) {
|
||||
fireStatus('网络异常,改用普通请求…');
|
||||
return nonStreamComplete(messages, opts);
|
||||
}
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
fireStatus('流式通道不可用,改用普通请求…');
|
||||
return nonStreamComplete(messages, opts);
|
||||
}
|
||||
|
||||
fireStatus('正在生成…');
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let full = '';
|
||||
let gotContent = false;
|
||||
let tick = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (payload === '[DONE]') continue;
|
||||
let json;
|
||||
try {
|
||||
const errData = await res.json();
|
||||
errMsg = errData.error || errMsg;
|
||||
} catch (e) { /* ignore */ }
|
||||
throw new Error(errMsg || 'API 请求失败');
|
||||
json = JSON.parse(payload);
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
const choice = json.choices && json.choices[0];
|
||||
const delta = choice && choice.delta && choice.delta.content;
|
||||
if (typeof delta === 'string' && delta.length) {
|
||||
gotContent = true;
|
||||
full += delta;
|
||||
if (onDelta) {
|
||||
try {
|
||||
onDelta(delta, full);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
tick += 1;
|
||||
if (tick % 8 === 0) {
|
||||
fireStatus('正在生成… 已输出 ' + full.length + ' 字');
|
||||
}
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success && data.content != null) {
|
||||
return typeof data.content === 'string' ? data.content : String(data.content);
|
||||
}
|
||||
|
||||
if (!full.trim() && !gotContent) {
|
||||
fireStatus('未收到流式内容,改用普通请求…');
|
||||
return nonStreamComplete(messages, opts);
|
||||
}
|
||||
|
||||
fireStatus('生成完成');
|
||||
return full;
|
||||
}
|
||||
|
||||
global.AiChat = {
|
||||
/**
|
||||
* @param {{ role: string, content: string }[]} messages
|
||||
* @param {{ provider?: string, model?: string }} [opts]
|
||||
* @param {{ onStatus?: (s: string) => void, onDelta?: (chunk: string, full: string) => void } | null} [progress] 可选;不传则使用底部默认状态条
|
||||
* @returns {Promise<string>} assistant 文本
|
||||
*/
|
||||
async complete(messages, opts, progress) {
|
||||
try {
|
||||
return await tryStreamThenFallback(messages, opts, progress);
|
||||
} finally {
|
||||
hideDefaultStreamBar();
|
||||
}
|
||||
throw new Error(data.error || 'API 响应异常');
|
||||
},
|
||||
|
||||
/** 仅非流式(兼容旧逻辑) */
|
||||
completeNonStream: nonStreamComplete,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#81c784" />
|
||||
<meta name="description" content="万象口袋 - 一个多功能的聚合软件应用,提供聚合应用、小游戏、AI模型工具等丰富功能" />
|
||||
<meta name="keywords" content="聚合应用,热搜榜单,小游戏,AI模型,实时资讯,工具集合" />
|
||||
<meta name="author" content="万象口袋" />
|
||||
<meta name="application-name" content="万象口袋" />
|
||||
|
||||
<!-- PWA / App meta -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="万象口袋" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="msapplication-TileColor" content="#81c784" />
|
||||
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon-192.png" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="万象口袋" />
|
||||
<meta property="og:description" content="🎨一个跨平台的多功能聚合软件应用" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content="万象口袋" />
|
||||
<meta property="twitter:description" content="🎨一个跨平台的多功能聚合软件应用" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/favicon-16.png" />
|
||||
|
||||
<!-- Apple Touch Icon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/icons/apple-touch-icon.png" />
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
|
||||
<!-- Preload fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
<title>万象口袋</title>
|
||||
|
||||
<style>
|
||||
#splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #a8e6cf 0%, #dcedc1 50%, #ffd3a5 100%);
|
||||
font-family: 'KaiTi', '楷体', 'STKaiti', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景脉冲光晕 */
|
||||
#splash::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 400px; height: 400px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(129,199,132,0.35) 0%, transparent 70%);
|
||||
animation: bgPulse 4s ease-in-out infinite;
|
||||
}
|
||||
#splash::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 600px; height: 600px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,211,165,0.2) 0%, transparent 70%);
|
||||
animation: bgPulse 4s ease-in-out infinite 2s;
|
||||
}
|
||||
|
||||
/* Logo 区域 */
|
||||
.splash-logo-wrap {
|
||||
position: relative;
|
||||
width: 120px; height: 120px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.splash-logo {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%; height: 100%;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 8px 32px rgba(46,125,50,0.25);
|
||||
animation: logoFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 三层扩散环 */
|
||||
.splash-ring {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(129,199,132,0.5);
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
opacity: 0;
|
||||
animation: ringExpand 3s ease-out infinite;
|
||||
}
|
||||
.splash-ring:nth-child(2) { animation-delay: 0s; }
|
||||
.splash-ring:nth-child(3) { animation-delay: 1s; }
|
||||
.splash-ring:nth-child(4) { animation-delay: 2s; }
|
||||
|
||||
/* 标题 */
|
||||
.splash-title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #2e7d32;
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 2px 8px rgba(46,125,50,0.15);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.splash-subtitle {
|
||||
font-size: 15px;
|
||||
color: rgba(46,125,50,0.7);
|
||||
margin-bottom: 36px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
/* 三点加载指示器 */
|
||||
.splash-dots {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.splash-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #81c784;
|
||||
animation: dotBounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
.splash-dot:nth-child(1) { animation-delay: 0s; }
|
||||
.splash-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.splash-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
/* ——— Animations ——— */
|
||||
@keyframes bgPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.4; }
|
||||
50% { transform: scale(1.15); opacity: 0.7; }
|
||||
}
|
||||
@keyframes logoFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
@keyframes ringExpand {
|
||||
0% { width: 100px; height: 100px; opacity: 0.6; transform: translate(-50%,-50%) scale(0.8); }
|
||||
100% { width: 260px; height: 260px; opacity: 0; transform: translate(-50%,-50%) scale(1); }
|
||||
}
|
||||
@keyframes dotBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1.2); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 退出动画 */
|
||||
#splash.splash-exit {
|
||||
animation: splashFadeOut 0.6s ease-in forwards;
|
||||
}
|
||||
@keyframes splashFadeOut {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.splash-logo-wrap { width: 96px; height: 96px; margin-bottom: 22px; }
|
||||
.splash-logo { border-radius: 20px; }
|
||||
.splash-title { font-size: 26px; }
|
||||
.splash-subtitle { font-size: 13px; margin-bottom: 28px; }
|
||||
@keyframes ringExpand {
|
||||
0% { width: 80px; height: 80px; opacity: 0.6; transform: translate(-50%,-50%) scale(0.8); }
|
||||
100% { width: 200px; height: 200px; opacity: 0; transform: translate(-50%,-50%) scale(1); }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>您需要启用 JavaScript 才能运行此应用。</noscript>
|
||||
|
||||
<!-- PWA 启动动画 -->
|
||||
<div id="splash">
|
||||
<div class="splash-logo-wrap">
|
||||
<img class="splash-logo" src="%PUBLIC_URL%/assets/logo.png" alt="万象口袋" />
|
||||
<div class="splash-ring"></div>
|
||||
<div class="splash-ring"></div>
|
||||
<div class="splash-ring"></div>
|
||||
</div>
|
||||
<div class="splash-title">万象口袋</div>
|
||||
<div class="splash-subtitle">加载中</div>
|
||||
<div class="splash-dots">
|
||||
<div class="splash-dot"></div>
|
||||
<div class="splash-dot"></div>
|
||||
<div class="splash-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
var s = document.getElementById('splash');
|
||||
if (s) {
|
||||
s.classList.add('splash-exit');
|
||||
setTimeout(function() { s.remove(); }, 600);
|
||||
}
|
||||
}, 800);
|
||||
});
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('应用加载错误:', e.error);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,342 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PNG ↔ JPG 图片转换器</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
|
||||
body { font-family: 'Noto Sans SC
|
||||
|
||||
|
||||
', system-ui, sans-serif; }
|
||||
.drop-zone {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.drop-zone.dragover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
canvas { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-50 to-indigo-50 min-h-screen py-12">
|
||||
<div class="max-w-6xl mx-auto px-6">
|
||||
<!-- 头部 -->
|
||||
<div class="text-center mb-10">
|
||||
<div class="inline-flex items-center gap-3 bg-white shadow-md rounded-3xl px-8 py-4">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center text-white text-3xl">🖼️</div>
|
||||
<h1 class="text-4xl font-bold text-gray-900 tracking-tight">PNG ↔ JPG 转换器</h1>
|
||||
</div>
|
||||
<p class="mt-4 text-gray-600 max-w-md mx-auto">纯浏览器本地转换 • 零上传 • 安全隐私 • 秒级处理</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-3xl shadow-2xl overflow-hidden">
|
||||
<div class="p-8">
|
||||
<!-- 上传区域 -->
|
||||
<div id="drop-zone"
|
||||
class="drop-zone border-4 border-dashed border-gray-300 rounded-3xl p-16 text-center cursor-pointer hover:border-blue-500">
|
||||
<input type="file" id="file-input" accept="image/png,image/jpeg" class="hidden">
|
||||
<div class="mx-auto w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v-4m0 0l4 4m-4-4l4-4m12 4v4m0 0l-4-4m4 4l-4 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold text-gray-700">点击或拖拽图片到此区域</p>
|
||||
<p class="text-sm text-gray-500 mt-3">支持 PNG / JPG 格式,最大 50MB</p>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div id="main-content" class="hidden mt-12 grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<!-- 原图 -->
|
||||
<div class="lg:col-span-5 bg-gray-50 rounded-3xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-lg text-gray-800 flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
原图
|
||||
</h3>
|
||||
<span id="original-info" class="text-xs text-gray-500 font-mono"></span>
|
||||
</div>
|
||||
<div class="aspect-video bg-white rounded-2xl overflow-hidden border border-gray-200 flex items-center justify-center">
|
||||
<img id="original-img" class="max-h-full max-w-full object-contain" alt="原图预览">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="lg:col-span-2 flex flex-col justify-center gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">转换目标格式</label>
|
||||
<div class="flex gap-2">
|
||||
<button id="btn-jpg"
|
||||
class="flex-1 py-3 px-6 rounded-2xl font-medium transition-all active:scale-95 border-2 border-transparent bg-white shadow-sm hover:shadow-md data-[active=true]:border-blue-600 data-[active=true]:bg-blue-50">
|
||||
转为 JPG
|
||||
</button>
|
||||
<button id="btn-png"
|
||||
class="flex-1 py-3 px-6 rounded-2xl font-medium transition-all active:scale-95 border-2 border-transparent bg-white shadow-sm hover:shadow-md data-[active=true]:border-blue-600 data-[active=true]:bg-blue-50">
|
||||
转为 PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JPG 选项 -->
|
||||
<div id="jpg-options" class="hidden space-y-5">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1.5">
|
||||
<span class="font-medium text-gray-700">JPG 质量</span>
|
||||
<span id="quality-value" class="font-mono text-blue-600">92%</span>
|
||||
</div>
|
||||
<input type="range" id="quality-slider" min="10" max="100" value="92"
|
||||
class="w-full accent-blue-600">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">透明背景填充颜色(PNG→JPG)</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
<input type="color" id="bg-color" value="#ffffff"
|
||||
class="w-12 h-10 rounded-xl border border-gray-300 cursor-pointer">
|
||||
<div id="bg-color-hex" class="font-mono text-sm text-gray-500">#ffffff</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="convertImage()"
|
||||
class="mt-4 w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-semibold py-4 rounded-3xl shadow-lg transition-all active:scale-95 flex items-center justify-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7-7 7" />
|
||||
</svg>
|
||||
开始转换
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 转换后 -->
|
||||
<div class="lg:col-span-5 bg-gray-50 rounded-3xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-lg text-gray-800 flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-purple-500 rounded-full"></span>
|
||||
转换结果
|
||||
</h3>
|
||||
<span id="converted-info" class="text-xs text-gray-500 font-mono"></span>
|
||||
</div>
|
||||
<div class="aspect-video bg-white rounded-2xl overflow-hidden border border-gray-200 flex items-center justify-center relative">
|
||||
<img id="converted-img" class="max-h-full max-w-full object-contain" alt="转换后预览">
|
||||
<div id="converted-placeholder"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center text-gray-400">
|
||||
<div class="w-16 h-16 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin mb-4"></div>
|
||||
<p class="text-sm">转换后图片将显示在这里</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下载栏 -->
|
||||
<div id="download-bar" class="hidden border-t bg-gray-50 px-8 py-5 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-600">
|
||||
文件已转换完成 • <span id="file-size" class="font-mono"></span>
|
||||
</div>
|
||||
<button onclick="downloadImage()"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white px-8 py-3 rounded-2xl font-semibold flex items-center gap-3 transition-all active:scale-95">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v-4m0 0l4 4m-4-4l4-4m12 4v4m0 0l-4-4m4 4l-4 4" />
|
||||
</svg>
|
||||
下载转换后的图片
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-8 text-xs text-gray-400">
|
||||
完全本地运行 • 无需联网 • 图片不会离开您的浏览器
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas id="canvas" class="hidden"></canvas>
|
||||
|
||||
<script>
|
||||
// 全局变量
|
||||
let originalFile = null;
|
||||
let originalDataURL = null;
|
||||
let convertedDataURL = null;
|
||||
let outputFormat = 'image/jpeg';
|
||||
let currentQuality = 0.92;
|
||||
let canvas = document.getElementById('canvas');
|
||||
let ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
// Tailwind 脚本已加载,无需额外初始化
|
||||
|
||||
// 拖拽与点击上传
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) handleFile(e.target.files[0]);
|
||||
});
|
||||
|
||||
// 拖拽事件
|
||||
['dragover', 'dragenter'].forEach(evt => {
|
||||
dropZone.addEventListener(evt, (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'dragend'].forEach(evt => {
|
||||
dropZone.addEventListener(evt, () => dropZone.classList.remove('dragover'));
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
function handleFile(file) {
|
||||
if (!file.type.startsWith('image/') || !['image/png', 'image/jpeg'].includes(file.type)) {
|
||||
alert('❌ 请上传 PNG 或 JPG 格式的图片!');
|
||||
return;
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
alert('❌ 文件过大!请上传小于 50MB 的图片。');
|
||||
return;
|
||||
}
|
||||
|
||||
originalFile = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
originalDataURL = e.target.result;
|
||||
document.getElementById('original-img').src = originalDataURL;
|
||||
|
||||
// 显示信息
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const sizeKB = (file.size / 1024).toFixed(1);
|
||||
document.getElementById('original-info').textContent =
|
||||
`${img.width}×${img.height} • ${sizeKB} KB`;
|
||||
};
|
||||
img.src = originalDataURL;
|
||||
|
||||
// 显示主界面
|
||||
document.getElementById('main-content').classList.remove('hidden');
|
||||
document.getElementById('download-bar').classList.add('hidden');
|
||||
document.getElementById('converted-img').src = '';
|
||||
document.getElementById('converted-placeholder').style.display = 'flex';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// 格式切换
|
||||
const btnJPG = document.getElementById('btn-jpg');
|
||||
const btnPNG = document.getElementById('btn-png');
|
||||
const jpgOptions = document.getElementById('jpg-options');
|
||||
|
||||
btnJPG.addEventListener('click', () => {
|
||||
outputFormat = 'image/jpeg';
|
||||
btnJPG.dataset.active = 'true';
|
||||
btnPNG.dataset.active = 'false';
|
||||
jpgOptions.classList.remove('hidden');
|
||||
});
|
||||
|
||||
btnPNG.addEventListener('click', () => {
|
||||
outputFormat = 'image/png';
|
||||
btnPNG.dataset.active = 'true';
|
||||
btnJPG.dataset.active = 'false';
|
||||
jpgOptions.classList.add('hidden');
|
||||
});
|
||||
|
||||
// 默认选择 JPG
|
||||
btnJPG.click();
|
||||
|
||||
// 质量滑块
|
||||
const qualitySlider = document.getElementById('quality-slider');
|
||||
const qualityValue = document.getElementById('quality-value');
|
||||
qualitySlider.addEventListener('input', () => {
|
||||
currentQuality = qualitySlider.value / 100;
|
||||
qualityValue.textContent = qualitySlider.value + '%';
|
||||
});
|
||||
|
||||
// 背景颜色
|
||||
const bgColorInput = document.getElementById('bg-color');
|
||||
const bgColorHex = document.getElementById('bg-color-hex');
|
||||
bgColorInput.addEventListener('input', () => {
|
||||
bgColorHex.textContent = bgColorInput.value.toUpperCase();
|
||||
});
|
||||
|
||||
// 转换函数
|
||||
window.convertImage = function() {
|
||||
if (!originalDataURL) {
|
||||
alert('请先上传图片!');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingPlaceholder = document.getElementById('converted-placeholder');
|
||||
loadingPlaceholder.style.display = 'none';
|
||||
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (outputFormat === 'image/jpeg') {
|
||||
// PNG→JPG 时填充背景色
|
||||
ctx.fillStyle = bgColorInput.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// 绘制图片
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 生成新 DataURL
|
||||
convertedDataURL = canvas.toDataURL(outputFormat, currentQuality);
|
||||
|
||||
// 显示转换结果
|
||||
document.getElementById('converted-img').src = convertedDataURL;
|
||||
|
||||
// 计算新大小
|
||||
const newSize = Math.round((convertedDataURL.length * 3 / 4) / 1024);
|
||||
document.getElementById('converted-info').textContent =
|
||||
`${img.width}×${img.height} • ${newSize} KB`;
|
||||
|
||||
// 显示下载栏
|
||||
document.getElementById('download-bar').classList.remove('hidden');
|
||||
document.getElementById('file-size').textContent = `${newSize} KB`;
|
||||
};
|
||||
|
||||
img.src = originalDataURL;
|
||||
};
|
||||
|
||||
// 下载函数
|
||||
window.downloadImage = function() {
|
||||
if (!convertedDataURL) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = convertedDataURL;
|
||||
|
||||
// 生成新文件名
|
||||
let originalName = originalFile.name;
|
||||
const ext = outputFormat === 'image/jpeg' ? '.jpg' : '.png';
|
||||
const baseName = originalName.substring(0, originalName.lastIndexOf('.')) || originalName;
|
||||
link.download = baseName + ext;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// 键盘快捷键
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && document.getElementById('main-content').classList.contains('hidden')) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
728
InfoGenie-frontend/public/toolbox/图片处理/图片加文字水印/图片加文字水印.html
Normal file
728
InfoGenie-frontend/public/toolbox/图片处理/图片加文字水印/图片加文字水印.html
Normal file
@@ -0,0 +1,728 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>水印工具 · WaterMark</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0e0e0f;
|
||||
--surface: #18181b;
|
||||
--surface2: #222226;
|
||||
--border: #2e2e34;
|
||||
--accent: #f0a500;
|
||||
--accent2: #e07b00;
|
||||
--text: #e8e8e8;
|
||||
--muted: #888;
|
||||
--danger: #e05555;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
font-weight: 300;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
header svg { flex-shrink: 0; }
|
||||
.brand {
|
||||
font-family: 'Bebas Neue', cursive;
|
||||
font-size: 1.7rem;
|
||||
letter-spacing: 2px;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
.brand span { color: var(--text); }
|
||||
.subtitle {
|
||||
font-size: .7rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr;
|
||||
gap: 0;
|
||||
height: calc(100vh - 65px);
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
aside {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.panel-title {
|
||||
font-size: .65rem;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Upload zone */
|
||||
.upload-zone {
|
||||
border: 1.5px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all .25s;
|
||||
position: relative;
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.drag { border-color: var(--accent); background: rgba(240,165,0,.05); }
|
||||
.upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
|
||||
.upload-icon { font-size: 2rem; margin-bottom: 8px; }
|
||||
.upload-zone p { font-size: .82rem; color: var(--muted); line-height: 1.6; }
|
||||
.upload-zone strong { color: var(--accent); }
|
||||
|
||||
/* Controls */
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: .72rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.field input[type="text"],
|
||||
.field input[type="number"],
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
font-size: .85rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
|
||||
.field textarea { resize: vertical; min-height: 64px; }
|
||||
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||
|
||||
/* Slider */
|
||||
.slider-wrap { display: flex; align-items: center; gap: 10px; }
|
||||
.slider-wrap input[type="range"] {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slider-wrap input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.slider-val {
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-size: .8rem;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Color input */
|
||||
.color-wrap { display: flex; align-items: center; gap: 10px; }
|
||||
.color-wrap input[type="color"] {
|
||||
width: 40px; height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 2px;
|
||||
background: var(--surface2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.color-wrap input[type="text"] { flex: 1; }
|
||||
|
||||
/* Checkbox toggle */
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.toggle-label { font-size: .82rem; color: var(--text); }
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 42px; height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-track {
|
||||
position: absolute; inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 24px;
|
||||
cursor: pointer;
|
||||
transition: background .2s;
|
||||
}
|
||||
.toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px; height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
top: 3px; left: 3px;
|
||||
transition: transform .2s;
|
||||
}
|
||||
.toggle input:checked + .toggle-track { background: var(--accent); }
|
||||
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 11px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: .85rem;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all .2s;
|
||||
width: 100%;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent2); }
|
||||
.btn-primary:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.actions { padding: 16px 20px; margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
/* ── Canvas area ── */
|
||||
.canvas-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.canvas-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--border) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--border) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
opacity: .35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.empty-state .big-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 16px;
|
||||
opacity: .3;
|
||||
}
|
||||
.empty-state p { color: var(--muted); font-size: .9rem; line-height: 1.7; }
|
||||
|
||||
#previewWrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: none;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.6);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 140px);
|
||||
}
|
||||
#preview {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 140px);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* info bar */
|
||||
.info-bar {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,.7);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: .7rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.info-bar span { color: var(--text); }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
aside {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.canvas-area {
|
||||
min-height: 55vw;
|
||||
padding: 16px;
|
||||
}
|
||||
header { padding: 14px 16px; }
|
||||
.brand { font-size: 1.4rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#f0a500" opacity=".15"/>
|
||||
<rect x="4" y="8" width="24" height="16" rx="3" stroke="#f0a500" stroke-width="1.5" fill="none"/>
|
||||
<path d="M4 13h24" stroke="#f0a500" stroke-width="1.2" stroke-dasharray="3 2"/>
|
||||
<text x="16" y="23" text-anchor="middle" font-size="7" fill="#f0a500" font-family="serif" font-style="italic">WM</text>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="brand">Water<span>Mark</span></div>
|
||||
<div class="subtitle">图片文字水印工具</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<!-- Upload -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">01 · 上传图片</div>
|
||||
<div class="upload-zone" id="dropZone">
|
||||
<input type="file" id="fileInput" accept="image/*">
|
||||
<div class="upload-icon">🖼️</div>
|
||||
<p><strong>点击选择</strong>或拖拽图片到此处<br>支持 JPG · PNG · WEBP · GIF</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">02 · 水印文字</div>
|
||||
<div class="field">
|
||||
<label>水印内容</label>
|
||||
<textarea id="wmText" placeholder="输入水印文字…">© 版权所有</textarea>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>字号 (px)</label>
|
||||
<input type="number" id="wmSize" value="36" min="8" max="300">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>字体</label>
|
||||
<select id="wmFont">
|
||||
<option value="'Noto Sans SC', sans-serif">思源黑体</option>
|
||||
<option value="serif">宋体</option>
|
||||
<option value="'Noto Serif SC', serif">思源宋体</option>
|
||||
<option value="monospace">等宽体</option>
|
||||
<option value="cursive">手写体</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>颜色</label>
|
||||
<div class="color-wrap">
|
||||
<input type="color" id="wmColorPicker" value="#ffffff">
|
||||
<input type="text" id="wmColorHex" value="#ffffff" maxlength="9" placeholder="#ffffff">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>透明度</label>
|
||||
<div class="slider-wrap">
|
||||
<input type="range" id="wmOpacity" min="1" max="100" value="40">
|
||||
<div class="slider-val" id="opacityVal">40%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Position -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">03 · 位置 & 旋转</div>
|
||||
<div class="field">
|
||||
<label>预设位置</label>
|
||||
<select id="wmPosition">
|
||||
<option value="center">居中</option>
|
||||
<option value="top-left">左上角</option>
|
||||
<option value="top-right">右上角</option>
|
||||
<option value="bottom-left">左下角</option>
|
||||
<option value="bottom-right" selected>右下角</option>
|
||||
<option value="tile">平铺</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>旋转角度 (°)</label>
|
||||
<div class="slider-wrap">
|
||||
<input type="range" id="wmAngle" min="-180" max="180" value="-30">
|
||||
<div class="slider-val" id="angleVal">-30°</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>边距 (px)</label>
|
||||
<input type="number" id="wmPadding" value="30" min="0" max="500">
|
||||
</div>
|
||||
|
||||
<!-- Tile spacing (only when tile) -->
|
||||
<div class="field" id="tileField" style="display:none">
|
||||
<label>平铺间距 (px)</label>
|
||||
<div class="row2">
|
||||
<input type="number" id="tileX" value="200" min="20">
|
||||
<input type="number" id="tileY" value="160" min="20">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top:10px">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">描边效果</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="wmStroke">
|
||||
<div class="toggle-track"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="strokeField" style="display:none">
|
||||
<label>描边颜色</label>
|
||||
<div class="color-wrap">
|
||||
<input type="color" id="strokeColorPicker" value="#000000">
|
||||
<input type="text" id="strokeColorHex" value="#000000" maxlength="9">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">阴影效果</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="wmShadow" checked>
|
||||
<div class="toggle-track"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">04 · 输出设置</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>格式</label>
|
||||
<select id="outFormat">
|
||||
<option value="image/jpeg">JPEG</option>
|
||||
<option value="image/png">PNG</option>
|
||||
<option value="image/webp">WEBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>质量</label>
|
||||
<div class="slider-wrap">
|
||||
<input type="range" id="outQuality" min="10" max="100" value="92">
|
||||
<div class="slider-val" id="qualityVal">92</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="applyBtn" disabled>
|
||||
✦ 生成水印预览
|
||||
</button>
|
||||
<button class="btn btn-ghost" id="downloadBtn" style="display:none">
|
||||
↓ 下载图片
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="canvas-area" id="canvasArea">
|
||||
<div class="canvas-bg"></div>
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="big-icon">🖼️</div>
|
||||
<p>上传图片后<br>在左侧调整参数<br>即可预览水印效果</p>
|
||||
</div>
|
||||
<div id="previewWrap">
|
||||
<img id="preview" alt="预览">
|
||||
</div>
|
||||
<canvas id="canvas" style="display:none"></canvas>
|
||||
<div class="info-bar" id="infoBar">尺寸:<span id="dimInfo">—</span> | 文件:<span id="sizeInfo">—</span></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const fileInput = $('fileInput');
|
||||
const dropZone = $('dropZone');
|
||||
const applyBtn = $('applyBtn');
|
||||
const downloadBtn = $('downloadBtn');
|
||||
const preview = $('preview');
|
||||
const previewWrap = $('previewWrap');
|
||||
const emptyState = $('emptyState');
|
||||
const canvas = $('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const infoBar = $('infoBar');
|
||||
|
||||
let originalImage = null;
|
||||
let currentBlob = null;
|
||||
|
||||
// ── Sliders ──────────────────────────────────────
|
||||
function linkSlider(sliderId, valId, suffix) {
|
||||
const s = $(sliderId), v = $(valId);
|
||||
s.addEventListener('input', () => { v.textContent = s.value + suffix; debounceRender(); });
|
||||
}
|
||||
linkSlider('wmOpacity', 'opacityVal', '%');
|
||||
linkSlider('wmAngle', 'angleVal', '°');
|
||||
linkSlider('outQuality','qualityVal', '');
|
||||
|
||||
// ── Color sync ───────────────────────────────────
|
||||
function syncColor(pickerId, hexId) {
|
||||
const picker = $(pickerId), hex = $(hexId);
|
||||
picker.addEventListener('input', () => { hex.value = picker.value; debounceRender(); });
|
||||
hex.addEventListener('input', () => {
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; debounceRender(); }
|
||||
});
|
||||
}
|
||||
syncColor('wmColorPicker', 'wmColorHex');
|
||||
syncColor('strokeColorPicker', 'strokeColorHex');
|
||||
|
||||
// ── Toggle stroke ─────────────────────────────────
|
||||
$('wmStroke').addEventListener('change', e => {
|
||||
$('strokeField').style.display = e.target.checked ? 'block' : 'none';
|
||||
debounceRender();
|
||||
});
|
||||
|
||||
// ── Toggle tile ───────────────────────────────────
|
||||
$('wmPosition').addEventListener('change', e => {
|
||||
$('tileField').style.display = e.target.value === 'tile' ? 'block' : 'none';
|
||||
debounceRender();
|
||||
});
|
||||
|
||||
// ── Auto render on any change ─────────────────────
|
||||
let renderTimer;
|
||||
function debounceRender() {
|
||||
if (!originalImage) return;
|
||||
clearTimeout(renderTimer);
|
||||
renderTimer = setTimeout(render, 200);
|
||||
}
|
||||
document.querySelectorAll('input,select,textarea').forEach(el => {
|
||||
if (el.type !== 'file') el.addEventListener('input', debounceRender);
|
||||
});
|
||||
|
||||
// ── File handling ─────────────────────────────────
|
||||
function loadFile(file) {
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
originalImage = img;
|
||||
applyBtn.disabled = false;
|
||||
render();
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', e => loadFile(e.target.files[0]));
|
||||
|
||||
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag');
|
||||
loadFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
// ── Paste image ───────────────────────────────────
|
||||
document.addEventListener('paste', e => {
|
||||
const item = [...e.clipboardData.items].find(i => i.type.startsWith('image/'));
|
||||
if (item) loadFile(item.getAsFile());
|
||||
});
|
||||
|
||||
// ── Render ────────────────────────────────────────
|
||||
applyBtn.addEventListener('click', render);
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
const r = parseInt(hex.slice(1,3),16);
|
||||
const g = parseInt(hex.slice(3,5),16);
|
||||
const b = parseInt(hex.slice(5,7),16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!originalImage) return;
|
||||
applyBtn.disabled = true;
|
||||
applyBtn.textContent = '⟳ 渲染中…';
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
canvas.width = originalImage.naturalWidth;
|
||||
canvas.height = originalImage.naturalHeight;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(originalImage, 0, 0);
|
||||
|
||||
const text = $('wmText').value || '水印';
|
||||
const size = parseInt($('wmSize').value) || 36;
|
||||
const font = $('wmFont').value;
|
||||
const color = $('wmColorHex').value;
|
||||
const opacity = parseInt($('wmOpacity').value) / 100;
|
||||
const angle = parseInt($('wmAngle').value) * Math.PI / 180;
|
||||
const pos = $('wmPosition').value;
|
||||
const pad = parseInt($('wmPadding').value) || 30;
|
||||
const stroke = $('wmStroke').checked;
|
||||
const strokeC = $('strokeColorHex').value;
|
||||
const shadow = $('wmShadow').checked;
|
||||
|
||||
ctx.font = `${size}px ${font}`;
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
const tw = ctx.measureText(text).width;
|
||||
const th = size;
|
||||
|
||||
if (shadow) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||
ctx.shadowBlur = size * 0.2;
|
||||
ctx.shadowOffsetX = size * 0.05;
|
||||
ctx.shadowOffsetY = size * 0.05;
|
||||
} else {
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
function drawText(x, y) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(angle);
|
||||
ctx.fillStyle = hexToRgba(color, opacity);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (shadow) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||
ctx.shadowBlur = size * 0.2;
|
||||
ctx.shadowOffsetX = size * 0.05;
|
||||
ctx.shadowOffsetY = size * 0.05;
|
||||
}
|
||||
|
||||
if (stroke) {
|
||||
ctx.strokeStyle = hexToRgba(strokeC, opacity);
|
||||
ctx.lineWidth = size * 0.06;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeText(text, 0, 0);
|
||||
}
|
||||
|
||||
ctx.fillStyle = hexToRgba(color, opacity);
|
||||
ctx.fillText(text, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const W = canvas.width, H = canvas.height;
|
||||
|
||||
if (pos === 'tile') {
|
||||
const gx = parseInt($('tileX').value) || 200;
|
||||
const gy = parseInt($('tileY').value) || 160;
|
||||
for (let y = gy/2; y < H + gy; y += gy) {
|
||||
for (let x = gx/2; x < W + gx; x += gx) {
|
||||
drawText(x, y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let x, y;
|
||||
if (pos === 'center') { x = W/2 - tw/2; y = H/2; }
|
||||
else if (pos === 'top-left') { x = pad; y = pad + th/2; }
|
||||
else if (pos === 'top-right') { x = W - tw - pad; y = pad + th/2; }
|
||||
else if (pos === 'bottom-left') { x = pad; y = H - pad - th/2; }
|
||||
else { x = W - tw - pad; y = H - pad - th/2; }
|
||||
drawText(x, y);
|
||||
}
|
||||
|
||||
// export
|
||||
const fmt = $('outFormat').value;
|
||||
const quality = parseInt($('outQuality').value) / 100;
|
||||
canvas.toBlob(blob => {
|
||||
currentBlob = blob;
|
||||
const url = URL.createObjectURL(blob);
|
||||
preview.src = url;
|
||||
previewWrap.style.display = 'block';
|
||||
emptyState.style.display = 'none';
|
||||
downloadBtn.style.display = 'block';
|
||||
infoBar.style.display = 'block';
|
||||
$('dimInfo').textContent = `${W} × ${H}`;
|
||||
$('sizeInfo').textContent = formatBytes(blob.size);
|
||||
applyBtn.disabled = false;
|
||||
applyBtn.textContent = '✦ 重新渲染';
|
||||
}, fmt, quality);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Download ──────────────────────────────────────
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
if (!currentBlob) return;
|
||||
const fmt = $('outFormat').value;
|
||||
const ext = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }[fmt] || 'jpg';
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(currentBlob);
|
||||
a.download = `watermarked.${ext}`;
|
||||
a.click();
|
||||
});
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
|
||||
return (b/1024/1024).toFixed(2) + ' MB';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
359
InfoGenie-frontend/public/toolbox/图片处理/图片压缩处理/图片压缩处理.html
Normal file
359
InfoGenie-frontend/public/toolbox/图片处理/图片压缩处理/图片压缩处理.html
Normal file
@@ -0,0 +1,359 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>在线图片压缩工具</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #007aff;
|
||||
--bg-color: #f5f5f7;
|
||||
--card-bg: #ffffff;
|
||||
--text-main: #333333;
|
||||
--text-muted: #888888;
|
||||
--border-color: #e5e5ea;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.upload-area:hover, .upload-area.dragover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(0, 122, 255, 0.05);
|
||||
}
|
||||
|
||||
#fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
display: none; /* 默认隐藏,上传后显示 */
|
||||
}
|
||||
|
||||
.control-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
display: none; /* 默认隐藏 */
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.preview-area {
|
||||
display: flex; /* 电脑端并排 */
|
||||
}
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
flex: 1;
|
||||
background: #fafafa;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px; /* 手机端间距 */
|
||||
}
|
||||
|
||||
.preview-box img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.info {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.3s;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #005bb5;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#downloadBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<h1>图片压缩工具</h1>
|
||||
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<p>点击这里或将图片拖拽到此处上传</p>
|
||||
<p style="font-size: 12px; color: #888; margin-top: 8px;">支持 JPG, PNG, WebP 等常见格式</p>
|
||||
<input type="file" id="fileInput" accept="image/*">
|
||||
</div>
|
||||
|
||||
<div class="controls" id="controls">
|
||||
<div class="control-group">
|
||||
<label for="qualitySlider">压缩质量: <span id="qualityValue">0.8</span></label>
|
||||
<input type="range" id="qualitySlider" min="0.1" max="1" step="0.1" value="0.8">
|
||||
<div style="font-size: 12px; color: #888;">数值越小,体积越小,画质越低</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="formatSelect">输出格式:</label>
|
||||
<select id="formatSelect">
|
||||
<option value="image/jpeg">JPEG (体积较小)</option>
|
||||
<option value="image/webp">WebP (推荐,体积最小)</option>
|
||||
<option value="image/png">PNG (保留透明度,体积较大)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-area" id="previewArea">
|
||||
<div class="preview-box">
|
||||
<h3>原图</h3>
|
||||
<div class="info" id="originalInfo">等待上传...</div>
|
||||
<img id="originalImg" src="" alt="原图预览" style="display: none;">
|
||||
</div>
|
||||
<div class="preview-box">
|
||||
<h3>压缩后</h3>
|
||||
<div class="info" id="compressedInfo">等待处理...</div>
|
||||
<img id="compressedImg" src="" alt="压缩后预览" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a id="downloadBtn" class="btn" download="compressed_image.jpg">下载压缩后的图片</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const uploadArea = document.getElementById('uploadArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const controls = document.getElementById('controls');
|
||||
const previewArea = document.getElementById('previewArea');
|
||||
const originalImg = document.getElementById('originalImg');
|
||||
const compressedImg = document.getElementById('compressedImg');
|
||||
const originalInfo = document.getElementById('originalInfo');
|
||||
const compressedInfo = document.getElementById('compressedInfo');
|
||||
const qualitySlider = document.getElementById('qualitySlider');
|
||||
const qualityValue = document.getElementById('qualityValue');
|
||||
const formatSelect = document.getElementById('formatSelect');
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
|
||||
let currentFile = null;
|
||||
|
||||
// 格式化文件大小
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 处理上传点击和拖拽
|
||||
uploadArea.addEventListener('click', () => fileInput.click());
|
||||
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// 接收文件并读取
|
||||
function handleFile(file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('请上传图片文件!');
|
||||
return;
|
||||
}
|
||||
currentFile = file;
|
||||
|
||||
// 显示原图信息
|
||||
originalInfo.innerText = `大小: ${formatBytes(file.size)} \n格式: ${file.type.split('/')[1].toUpperCase()}`;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
originalImg.src = e.target.result;
|
||||
originalImg.style.display = 'inline-block';
|
||||
|
||||
// 显示控制面板和预览区
|
||||
controls.style.display = 'flex';
|
||||
previewArea.style.display = 'flex';
|
||||
|
||||
// 默认选中 WebP,如果原图是 PNG 且用户可能需要透明度,可以在这里做额外判断
|
||||
if(file.type === 'image/png') {
|
||||
formatSelect.value = 'image/png';
|
||||
} else {
|
||||
formatSelect.value = 'image/jpeg';
|
||||
}
|
||||
|
||||
compressImage();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// 监听调节事件
|
||||
qualitySlider.addEventListener('input', (e) => {
|
||||
qualityValue.innerText = e.target.value;
|
||||
});
|
||||
|
||||
qualitySlider.addEventListener('change', compressImage);
|
||||
formatSelect.addEventListener('change', compressImage);
|
||||
|
||||
// 核心压缩逻辑 (利用 Canvas)
|
||||
function compressImage() {
|
||||
if (!currentFile || !originalImg.src) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 创建一个新的 Image 对象用来绘制
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 保持原始宽高
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// 如果导出为 JPEG,将透明背景填充为白色
|
||||
if (formatSelect.value === 'image/jpeg') {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const quality = parseFloat(qualitySlider.value);
|
||||
const outputFormat = formatSelect.value;
|
||||
|
||||
// 将 canvas 转为 Blob
|
||||
canvas.toBlob((blob) => {
|
||||
if(!blob) return;
|
||||
|
||||
// 显示压缩后信息
|
||||
compressedInfo.innerText = `大小: ${formatBytes(blob.size)} \n格式: ${outputFormat.split('/')[1].toUpperCase()}`;
|
||||
|
||||
// 计算压缩率
|
||||
const ratio = ((1 - (blob.size / currentFile.size)) * 100).toFixed(1);
|
||||
if(ratio > 0) {
|
||||
compressedInfo.innerText += `\n节省了 ${ratio}% 的空间`;
|
||||
}
|
||||
|
||||
// 创建预览 URL 和下载链接
|
||||
const compressUrl = URL.createObjectURL(blob);
|
||||
compressedImg.src = compressUrl;
|
||||
compressedImg.style.display = 'inline-block';
|
||||
|
||||
downloadBtn.style.display = 'block';
|
||||
downloadBtn.href = compressUrl;
|
||||
|
||||
// 设置下载文件名
|
||||
const ext = outputFormat.split('/')[1];
|
||||
const originalName = currentFile.name.split('.')[0];
|
||||
downloadBtn.download = `${originalName}_compressed.${ext}`;
|
||||
|
||||
}, outputFormat, quality);
|
||||
};
|
||||
img.src = originalImg.src;
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
46
InfoGenie-frontend/public/toolbox/实用工具/C语言编译器/网页C语言编译器.html
Normal file
46
InfoGenie-frontend/public/toolbox/实用工具/C语言编译器/网页C语言编译器.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Wasmer Clang in Browser</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>浏览器里直接编译 C → WASM</h1>
|
||||
<textarea id="code" rows="10" cols="80">#include <stdio.h>
|
||||
int main() {
|
||||
printf("Hello from Clang in WASM!\n");
|
||||
return 0;
|
||||
}</textarea>
|
||||
<br>
|
||||
<button onclick="compileC()">编译并运行</button>
|
||||
<pre id="output"></pre>
|
||||
|
||||
<script type="module">
|
||||
import { init, Wasmer, Directory } from "https://unpkg.com/@wasmer/sdk@latest/dist/index.mjs";
|
||||
|
||||
async function compileC() {
|
||||
await init(); // 加载 Wasmer SDK
|
||||
const clang = await Wasmer.fromRegistry("clang/clang");
|
||||
const project = new Directory();
|
||||
|
||||
const source = document.getElementById("code").value;
|
||||
await project.writeFile("hello.c", source);
|
||||
|
||||
// 调用 Clang 编译成 WASM
|
||||
const compileResult = await clang.entrypoint.run({
|
||||
args: ["hello.c", "-o", "hello.wasm"],
|
||||
mount: { "/project": project },
|
||||
});
|
||||
await compileResult.wait();
|
||||
|
||||
// 读取生成的 WASM 并运行
|
||||
const wasmBytes = await project.readFile("hello.wasm");
|
||||
const instance = await Wasmer.fromFile(wasmBytes);
|
||||
const runResult = await instance.entrypoint.run();
|
||||
const output = await runResult.wait();
|
||||
|
||||
document.getElementById("output").textContent = output.stdout || "编译成功!";
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user