feat: 新增4个工具(imgcompress/LamaCleaner/webp2jpg/Overleaf/LaTeX公式编辑器) + 应用广场布局优化

新增工具部署:
- imgcompress (端口18087) - 图片压缩、格式转换、AI抠图
- Lama Cleaner (端口18088) - AI图像擦除/去水印
- webp2jpg-online (端口18089) - 图片格式批量互转
- Overleaf (端口18090) - 在线LaTeX论文编辑器(docker-compose + MongoDB 8.0)
- LaTeX公式编辑器 (端口18091) - 纯前端KaTeX公式编辑

应用广场优化:
- 去掉tab切换,所有分类平铺展示
- CSS Grid自适应布局,一行可排3-4个卡片
- 重新分为4个分类:文档处理、图片处理、创作绘图、科研写作

其他:
- 更新 CLAUDE.md 项目配置文档
- PPTist AI后端优化prompt和流式输出格式

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:23:06 +08:00
parent 108022cebd
commit 6337af9481
5 changed files with 482 additions and 78 deletions

View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LaTeX 公式编辑器</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, "Microsoft YaHei", sans-serif; background: #f0f2f5; color: #333; }
.header { background: #004EA0; color: #fff; padding: 16px 32px; font-size: 18px; font-weight: bold; }
.container { max-width: 1200px; margin: 24px auto; padding: 0 24px; display: flex; gap: 20px; }
.panel { background: #fff; border-radius: 10px; padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); flex: 1; }
.panel h3 { font-size: 15px; color: #333; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
textarea { width: 100%; height: 200px; border: 1px solid #ddd; border-radius: 8px; padding: 14px; font-family: 'Courier New', monospace; font-size: 15px; resize: vertical; outline: none; }
textarea:focus { border-color: #004EA0; }
.preview { min-height: 200px; border: 1px solid #eee; border-radius: 8px; padding: 20px; display: flex; align-items: center; justify-content: center; background: #fafbfc; overflow: auto; }
.preview .katex { font-size: 24px; }
.error { color: #dc2626; font-size: 13px; text-align: center; }
.toolbar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.toolbar button { padding: 4px 10px; border: 1px solid #ddd; border-radius: 6px; background: #f5f7fa; cursor: pointer; font-size: 13px; transition: all 0.15s; }
.toolbar button:hover { border-color: #004EA0; color: #004EA0; background: #e8f0fe; }
.symbols { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 16px; }
.symbols button { width: 42px; height: 36px; border: 1px solid #eee; border-radius: 6px; background: #fafbfc; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; }
.symbols button:hover { background: #e8f0fe; border-color: #004EA0; }
.actions { margin-top: 14px; display: flex; gap: 10px; }
.actions button { padding: 8px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }
.btn-primary { background: #004EA0; color: #fff; }
.btn-primary:hover { background: #003a7a; }
.btn-secondary { background: #f0f2f5; color: #333; border: 1px solid #ddd !important; }
.btn-secondary:hover { background: #e5e7eb; }
.templates { margin-top: 20px; }
.templates h4 { font-size: 14px; color: #666; margin-bottom: 8px; }
.tpl-list { display: flex; flex-wrap: wrap; gap: 6px; }
.tpl-list button { padding: 4px 12px; border: 1px solid #e0e0e0; border-radius: 6px; background: #fff; cursor: pointer; font-size: 12px; color: #555; }
.tpl-list button:hover { border-color: #004EA0; color: #004EA0; }
.copied { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: rgba(0,0,0,0.7); color: #fff; padding: 10px 24px; border-radius: 8px; font-size: 14px; display: none; z-index: 999; }
</style>
</head>
<body>
<div class="header">LaTeX 公式编辑器</div>
<div class="container">
<div class="panel" style="flex:1.2">
<h3>输入公式</h3>
<div class="toolbar">
<button onclick="ins('\\frac{a}{b}')">分数</button>
<button onclick="ins('\\sqrt{x}')">根号</button>
<button onclick="ins('\\sum_{i=1}^{n}')">求和</button>
<button onclick="ins('\\int_{a}^{b}')">积分</button>
<button onclick="ins('\\lim_{x \\to \\infty}')">极限</button>
<button onclick="ins('\\prod_{i=1}^{n}')">连乘</button>
<button onclick="ins('x^{2}')">上标</button>
<button onclick="ins('x_{i}')">下标</button>
<button onclick="ins('\\binom{n}{k}')">组合</button>
<button onclick="ins('\\vec{a}')">向量</button>
<button onclick="ins('\\hat{x}')"></button>
<button onclick="ins('\\bar{x}')">横线</button>
<button onclick="ins('\\dot{x}')"></button>
<button onclick="ins('\\overline{AB}')">上划线</button>
<button onclick="ins('\\begin{matrix} a & b \\\\ c & d \\end{matrix}')">矩阵</button>
<button onclick="ins('\\begin{cases} x & y \\\\ a & b \\end{cases}')">分段</button>
<button onclick="ins('\\log')">log</button>
<button onclick="ins('\\ln')">ln</button>
</div>
<textarea id="input" placeholder="在此输入 LaTeX 公式E = mc^{2}" oninput="render()">E = mc^{2}</textarea>
<div class="symbols" id="symbolBar"></div>
<div class="actions">
<button class="btn-primary" onclick="copyLatex()">复制公式</button>
<button class="btn-secondary" onclick="copyImg()">复制为图片</button>
<button class="btn-secondary" onclick="document.getElementById('input').value='';render()">清空</button>
</div>
<div class="templates">
<h4>常用公式模板</h4>
<div class="tpl-list">
<button onclick="setTpl('E = mc^{2}')">质能方程</button>
<button onclick="setTpl('e^{i\\pi} + 1 = 0')">欧拉公式</button>
<button onclick="setTpl('a^2 + b^2 = c^2')">勾股定理</button>
<button onclick="setTpl('x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}')">求根公式</button>
<button onclick="setTpl('f\'(x) = \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h}')">导数定义</button>
<button onclick="setTpl('\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}')">高斯积分</button>
<button onclick="setTpl('\\nabla \\times \\vec{E} = -\\frac{\\partial \\vec{B}}{\\partial t}')">麦克斯韦</button>
<button onclick="setTpl('\\sigma = \\sqrt{\\frac{1}{N}\\sum_{i=1}^{N}(x_i - \\mu)^2}')">标准差</button>
<button onclick="setTpl('P(A|B) = \\frac{P(B|A) \\cdot P(A)}{P(B)}')">贝叶斯</button>
<button onclick="setTpl('\\mathbf{A} = \\begin{pmatrix} a_{11} & a_{12} \\\\ a_{21} & a_{22} \\end{pmatrix}')">矩阵</button>
</div>
</div>
</div>
<div class="panel">
<h3>实时预览 <span style="font-weight:normal;font-size:12px;color:#999">公式渲染由 KaTeX 提供</span></h3>
<div class="preview" id="preview"></div>
</div>
</div>
<div class="copied" id="toast">已复制!</div>
<script>
const symbols = ['\\alpha','\\beta','\\gamma','\\delta','\\epsilon','\\zeta','\\eta','\\theta','\\lambda','\\mu','\\nu','\\xi','\\pi','\\rho','\\sigma','\\tau','\\phi','\\psi','\\omega','\\Gamma','\\Delta','\\Theta','\\Lambda','\\Pi','\\Sigma','\\Phi','\\Psi','\\Omega','\\infty','\\partial','\\nabla','\\forall','\\exists','\\in','\\notin','\\subset','\\cup','\\cap','\\times','\\cdot','\\div','\\pm','\\mp','\\leq','\\geq','\\neq','\\approx','\\equiv','\\sim','\\rightarrow','\\leftarrow','\\Rightarrow','\\Leftarrow'];
const bar = document.getElementById('symbolBar');
symbols.forEach(s => {
const b = document.createElement('button');
try { katex.render(s, b, {throwOnError:false}); } catch(e) { b.textContent = s; }
b.onclick = () => ins(s);
bar.appendChild(b);
});
function render() {
const input = document.getElementById('input').value.trim();
const el = document.getElementById('preview');
if (!input) { el.innerHTML = '<span style="color:#bbb">在左侧输入公式</span>'; return; }
try { katex.render(input, el, { displayMode: true, throwOnError: true }); }
catch(e) { el.innerHTML = '<span class="error">' + e.message + '</span>'; }
}
function ins(s) {
const t = document.getElementById('input');
const start = t.selectionStart, end = t.selectionEnd;
t.value = t.value.slice(0, start) + s + t.value.slice(end);
t.focus();
t.selectionStart = t.selectionEnd = start + s.length;
render();
}
function setTpl(s) { document.getElementById('input').value = s; render(); }
function copyLatex() {
navigator.clipboard.writeText(document.getElementById('input').value);
showToast('公式已复制!');
}
function copyImg() {
const el = document.getElementById('preview');
const svg = el.querySelector('svg') || el.querySelector('.katex');
if (!svg) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const data = new XMLSerializer().serializeToString(el);
const img = new Image();
const blob = new Blob(['<svg xmlns="http://www.w3.org/2000/svg" width="'+el.scrollWidth+'" height="'+el.scrollHeight+'"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" style="background:#fff;padding:20px">'+el.innerHTML+'</div></foreignObject></svg>'], {type:'image/svg+xml'});
const url = URL.createObjectURL(blob);
img.onload = () => {
canvas.width = img.width * 2; canvas.height = img.height * 2;
ctx.scale(2,2); ctx.drawImage(img, 0, 0);
canvas.toBlob(b => { navigator.clipboard.write([new ClipboardItem({'image/png': b})]); showToast('图片已复制!'); });
URL.revokeObjectURL(url);
};
img.src = url;
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.style.display = 'block';
setTimeout(() => t.style.display = 'none', 1500);
}
render();
</script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
version: '3'
services:
sharelatex:
image: docker.1ms.run/sharelatex/sharelatex:latest
container_name: overleaf
restart: always
depends_on:
- mongo
- redis
ports:
- 18090:80
volumes:
- overleaf-data:/var/lib/overleaf
environment:
OVERLEAF_APP_NAME: "LaTeX 论文编辑器"
OVERLEAF_MONGO_URL: "mongodb://mongo/overleaf?replicaSet=overleaf"
OVERLEAF_REDIS_HOST: "redis"
REDIS_HOST: "redis"
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file"
ENABLE_CONVERSIONS: "true"
EMAIL_CONFIRMATION_DISABLED: "true"
OVERLEAF_SITE_URL: "http://localhost:18090"
OVERLEAF_LEFT_OPEN_REGISTRATION: "true"
mongo:
image: docker.1ms.run/mongo:8.0
container_name: overleaf-mongo
restart: always
command: --replSet overleaf
volumes:
- overleaf-mongo:/data/db
healthcheck:
test: >
mongosh --eval "try { rs.status().ok } catch(e) { rs.initiate({_id:'overleaf',members:[{_id:0,host:'mongo:27017'}]}).ok }" --quiet
interval: 10s
timeout: 10s
retries: 5
redis:
image: docker.1ms.run/redis:7-alpine
container_name: overleaf-redis
restart: always
volumes:
- overleaf-redis:/data
volumes:
overleaf-data:
overleaf-mongo:
overleaf-redis:

View File

@@ -0,0 +1,128 @@
"""
Overleaf 自助注册服务
提供简单的注册页面,用户输入邮箱和密码即可注册
内部通过管理员账号调用 Overleaf API 创建用户
"""
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
import requests
import os
app = FastAPI()
OL_URL = os.getenv("OL_INSTANCE", "http://overleaf")
OL_ADMIN_EMAIL = os.getenv("OL_ADMIN_EMAIL", "")
OL_ADMIN_PASSWORD = os.getenv("OL_ADMIN_PASSWORD", "")
REGISTER_HTML = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>注册 - LaTeX 论文编辑器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.card { background: #fff; border-radius: 12px; padding: 40px; width: 400px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
h2 { text-align: center; margin-bottom: 8px; color: #333; }
.sub { text-align: center; color: #999; font-size: 14px; margin-bottom: 30px; }
label { display: block; margin-bottom: 6px; color: #555; font-size: 14px; }
input { width: 100%; padding: 10px 14px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; margin-bottom: 16px; }
input:focus { outline: none; border-color: #004EA0; }
button { width: 100%; padding: 12px; background: #004EA0; color: #fff; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; }
button:hover { background: #003a7a; }
.msg { text-align: center; margin-top: 16px; font-size: 14px; }
.msg.ok { color: #16a34a; }
.msg.err { color: #dc2626; }
a { color: #004EA0; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<h2>注册账号</h2>
<p class="sub">LaTeX 论文编辑器</p>
<form method="POST" action="/register">
<label>邮箱</label>
<input type="email" name="email" required placeholder="请输入邮箱">
<button type="submit">注册</button>
</form>
<p class="sub" style="margin-top:20px">已有账号?<a href="OLURL">去登录</a></p>
MSG_PLACEHOLDER
</div>
</body>
</html>
""".replace("OLURL", OL_URL)
class OverleafAdmin:
def __init__(self):
self.session = requests.Session()
self.logged_in = False
def _get_csrf(self, path="/login"):
resp = self.session.get(f"{OL_URL}{path}")
# 从页面中提取 CSRF token
import re
match = re.search(r'name="_csrf"[^>]*value="([^"]+)"', resp.text)
if not match:
match = re.search(r'ol-csrfToken["\s]*content="([^"]+)"', resp.text)
if not match:
match = re.search(r'csrfToken["\s]*:\s*"([^"]+)"', resp.text)
return match.group(1) if match else ""
def login(self):
csrf = self._get_csrf("/login")
resp = self.session.post(f"{OL_URL}/login", data={
"_csrf": csrf,
"email": OL_ADMIN_EMAIL,
"password": OL_ADMIN_PASSWORD,
}, allow_redirects=False)
self.logged_in = resp.status_code in (200, 302)
return self.logged_in
def register_user(self, email):
if not self.logged_in:
self.login()
csrf = self._get_csrf("/admin/register")
resp = self.session.post(f"{OL_URL}/admin/register", data={
"_csrf": csrf,
"email": email,
})
# 从响应中提取设置密码的链接
import re
set_password_url = ""
match = re.search(r'(https?://[^\s"<>]*user/password/set\?[^\s"<>]+)', resp.text)
if match:
set_password_url = match.group(1)
return resp.status_code == 200, set_password_url
admin = OverleafAdmin()
@app.get("/", response_class=HTMLResponse)
async def index():
return REGISTER_HTML.replace("MSG_PLACEHOLDER", "")
@app.post("/register", response_class=HTMLResponse)
async def register(email: str = Form(...)):
try:
ok, set_url = admin.register_user(email)
if ok and set_url:
# 把链接中的 localhost 替换为实际访问地址
import re
set_url = re.sub(r'https?://[^/]+', OL_URL, set_url)
msg = f'<p class="msg ok">注册成功!请点击下方链接设置密码:</p><p style="text-align:center;margin-top:10px"><a href="{set_url}" target="_blank" style="background:#004EA0;color:#fff;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px">设置密码</a></p>'
elif ok:
msg = f'<p class="msg ok">注册成功!该邮箱可能已注册。请前往 <a href="{OL_URL}/login">登录页面</a> 登录。</p>'
else:
msg = f'<p class="msg err">注册失败,请稍后重试。</p>'
except Exception as e:
msg = f'<p class="msg err">注册失败:{str(e)[:100]}</p>'
return REGISTER_HTML.replace("MSG_PLACEHOLDER", msg)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=18091)