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:
@@ -1,6 +1,6 @@
|
|||||||
# 钢研院智能问答平台(gangyan)
|
# 钢研院智能问答平台(战知)
|
||||||
|
|
||||||
面向冶金行业的 LLM 智能问答系统,提供智能对话、文档撰写、知识库管理、文献检索、翻译等功能。
|
面向科研单位的 LLM 智能问答系统,提供智能对话、文档撰写、知识库管理、文献研读、实用工具广场等功能。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -20,14 +20,104 @@
|
|||||||
| MinIO | 9002(console)/9003(API) | 对象存储(Milvus 依赖) |
|
| MinIO | 9002(console)/9003(API) | 对象存储(Milvus 依赖) |
|
||||||
| Embedding API | 10.102.24.75:3000 | bge-m3 向量模型 + LLM 网关 |
|
| Embedding API | 10.102.24.75:3000 | bge-m3 向量模型 + LLM 网关 |
|
||||||
|
|
||||||
|
## LLM 配置
|
||||||
|
|
||||||
|
- 网关: `http://10.102.24.75:3000/v1/chat/completions`
|
||||||
|
- API Key: `sk-BlQIGRrotbVDWE5mXCPBFjVWIvJ83hldzz67xInNwzVo7pPb`
|
||||||
|
- 主力模型: `deepseek-v3`(所有场景统一使用,r1 已弃用)
|
||||||
|
- Embedding: `bge-m3`
|
||||||
|
- 上下文限制: 32K tokens
|
||||||
|
|
||||||
|
## 服务器访问
|
||||||
|
|
||||||
|
- SSH 直连: `ssh target-203-8`(已配公钥,自动通过 jump-203-17 跳转)
|
||||||
|
- 跳板机: `huawei@192.168.203.17`
|
||||||
|
- 目标机: `hawei@192.168.203.8`
|
||||||
|
- 项目路径: `/opt/download/oss_files/gangyan-deploy/gangyan/`
|
||||||
|
- Python: `/opt/software/miniconda3/envs/langchain-chat/bin/python`
|
||||||
|
- 网络代理: `http://219.234.197.247:53128`
|
||||||
|
- Docker 镜像源: `docker.1ms.run`(Docker Hub 直连不通,必须用镜像源)
|
||||||
|
|
||||||
|
## 工具广场 - 已部署工具
|
||||||
|
|
||||||
|
### 文档处理
|
||||||
|
| 工具 | 端口 | 容器名 | 镜像 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| Stirling PDF | 18080 | stirling-pdf | `docker.1ms.run/frooodle/s-pdf` |
|
||||||
|
| TrWebOCR (中文OCR) | 18083 | trwebocr | `docker.1ms.run/mmmz/trwebocr` |
|
||||||
|
| LibreTranslate (翻译) | 18084 | libretranslate | `docker.1ms.run/libretranslate/libretranslate` |
|
||||||
|
|
||||||
|
LibreTranslate 配置: `LT_LOAD_ONLY=en,zh`、`LT_HIDE_API=true`,需设 HTTP_PROXY 代理下载语言包。
|
||||||
|
|
||||||
|
### 图片处理
|
||||||
|
| 工具 | 端口 | 容器名 | 镜像 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| imgcompress (压缩/转换/抠图) | 18087 | imgcompress | `imgcompress-nofooter:latest`(commit 版,隐藏了 footer) |
|
||||||
|
| Lama Cleaner (AI擦除) | 18088 | lama-cleaner | `docker.1ms.run/cwq1913/lama-cleaner:cpu-0.33.0` |
|
||||||
|
| webp2jpg-online (格式转换) | 18089 | webp2jpg | `docker.1ms.run/wbsu2003/webp2jpg-online:v1` |
|
||||||
|
|
||||||
|
Lama Cleaner 启动命令: `lama-cleaner --model=lama --device=cpu --host=0.0.0.0 --port=8080`,需代理下载模型(约196MB),模型缓存在 volume `lama-models`。
|
||||||
|
|
||||||
|
### 创作绘图
|
||||||
|
| 工具 | 端口 | 容器名 | 镜像 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| Excalidraw (白板) | 18081 | excalidraw | `docker.1ms.run/excalidraw/excalidraw` |
|
||||||
|
| PPTist (AI PPT) | 18085 | pptist | `pptist:latest`(自建镜像) |
|
||||||
|
|
||||||
|
PPTist 自建镜像: Dockerfile 在 `scripts/pptist-deploy/`,构建时需代理 + npm 淘宝镜像源。容器内 nginx 反代 `/pptapi/` 到宿主机 18086 端口的 AI 后端。
|
||||||
|
|
||||||
|
### 科研写作
|
||||||
|
| 工具 | 端口 | 容器名 | 镜像 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| Overleaf (LaTeX论文) | 18090 | overleaf | `docker.1ms.run/sharelatex/sharelatex` |
|
||||||
|
| LaTeX 公式编辑器 | 18091 | latex-editor | `docker.1ms.run/nginx:alpine`(纯前端) |
|
||||||
|
|
||||||
|
Overleaf 配置:
|
||||||
|
- docker-compose 在 `scripts/overleaf-deploy/docker-compose.yml`
|
||||||
|
- 依赖 MongoDB 8.0(需 replica set)+ Redis
|
||||||
|
- 管理员: `admin@company.com` / `qwerQWER1234`
|
||||||
|
- 注册: 管理员在 `/admin/register` 手动添加,不支持自助注册
|
||||||
|
- 没有邮件服务,新用户通过管理员生成的链接设置密码
|
||||||
|
|
||||||
|
## AI 代理服务
|
||||||
|
|
||||||
|
以 screen 会话运行,重启服务器后需手动恢复。
|
||||||
|
|
||||||
|
| 服务 | 端口 | screen 名 | 文件 | 功能 |
|
||||||
|
|------|------|-----------|------|------|
|
||||||
|
| Excalidraw AI | 18082 | aiproxy | `scripts/excalidraw-ai-proxy.py` | text-to-diagram(Mermaid)、wireframe-to-code |
|
||||||
|
| PPTist AI | 18086 | pptist-ai | `scripts/pptist-ai-backend.py` | 大纲生成、PPT 生成(JSONL流式)、AI 写作 |
|
||||||
|
|
||||||
|
启动方式:
|
||||||
|
```bash
|
||||||
|
PYTHON=/opt/software/miniconda3/envs/langchain-chat/bin/python
|
||||||
|
screen -dmS aiproxy $PYTHON scripts/excalidraw-ai-proxy.py
|
||||||
|
screen -dmS pptist-ai $PYTHON scripts/pptist-ai-backend.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端应用广场
|
||||||
|
|
||||||
|
- 文件: `chat_web_front/src/views/applications/index.vue`
|
||||||
|
- 布局: 所有分类平铺展示,CSS Grid 自适应 `repeat(auto-fill, minmax(280px, 1fr))`
|
||||||
|
- 工具配置是前端静态数据,不走后端数据库
|
||||||
|
- 点击工具卡片 `window.open` 新标签打开 `${protocol}//${hostname}:${port}`
|
||||||
|
|
||||||
|
## 品牌
|
||||||
|
|
||||||
|
- 名称: 战知(原"知冶")
|
||||||
|
- 标语: 聚尖端之力,创多维平台
|
||||||
|
- 副标语: 聚合科技动能,扩展创新疆界,引领行业跃迁升级
|
||||||
|
|
||||||
## 项目位置
|
## 项目位置
|
||||||
|
|
||||||
- 服务器路径:`/opt/download/oss_files/gangyan-deploy/gangyan`
|
- Git 仓库: `http://123.57.146.97:3000/liuguancen/gangyan.git`
|
||||||
- Git 仓库:`http://123.57.146.97:3000/liuguancen/gangyan.git`
|
- 分支: `main`
|
||||||
- 分支:`main`
|
|
||||||
|
|
||||||
## 详细文档索引
|
## 开发注意事项
|
||||||
|
|
||||||
- [系统架构与运行配置](architecture.md) — 服务拓扑、各模块详细配置、包结构、API 分组
|
- 功能未完全验证通过前不要 commit,避免无用提交
|
||||||
- [开发构建指南](development-guide.md) — 启动顺序、构建命令、环境要求、日志与健康检查
|
- 前端用 Vite dev server 跑,scp 文件到服务器后自动热更新
|
||||||
- [Agent 协同规则](agent-coordination.md) — 多 Agent 并行开发的分支、接口变更、提交规范
|
- Docker Hub 不通,拉镜像必须加 `docker.1ms.run/` 前缀
|
||||||
|
- 容器内需要网络时(下载模型/语言包)必须设 `HTTP_PROXY` 和 `HTTPS_PROXY` 环境变量
|
||||||
|
- 跳板机 SSH 经常断连,保持命令简短,避免长时间占用连接
|
||||||
|
- screen 会话管理 AI 代理服务,`screen -ls` 查看,`screen -r 名称` 进入
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="tools-page">
|
<div class="tools-page">
|
||||||
<!-- 工具列表视图 -->
|
|
||||||
<div class="tools-container">
|
<div class="tools-container">
|
||||||
<div class="tools-header">
|
<div class="tools-header">
|
||||||
<span class="tools-title">实用工具</span>
|
<span class="tools-title">实用工具</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div v-for="category in toolCategories" :key="category.name" class="category-section">
|
||||||
<div v-for="(category, index) in toolCategories" :key="category.name"
|
<div class="category-name">{{ category.name }}</div>
|
||||||
class="tab-item" :class="{ active: activeTab === index }" @click="activeTab = index">
|
|
||||||
{{ category.name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tools-list">
|
<div class="tools-list">
|
||||||
<div v-for="tool in toolCategories[activeTab].tools" :key="tool.id" class="tool-item"
|
<div v-for="tool in category.tools" :key="tool.id" class="tool-item"
|
||||||
:class="{ disabled: !tool.enabled }" @click="openTool(tool)">
|
:class="{ disabled: !tool.enabled }" @click="openTool(tool)">
|
||||||
<div class="tool-icon">
|
<div class="tool-icon">
|
||||||
<img v-if="tool.logoImg" :src="tool.logoImg" class="tool-logo-img" alt="">
|
<img v-if="tool.logoImg" :src="tool.logoImg" class="tool-logo-img" alt="">
|
||||||
@@ -31,12 +25,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from "vue";
|
import { reactive } from "vue";
|
||||||
import stirlingLogo from "@/assets/images/applications/stirling-pdf.svg";
|
import stirlingLogo from "@/assets/images/applications/stirling-pdf.svg";
|
||||||
import excalidrawLogo from "@/assets/images/applications/excalidraw.svg";
|
import excalidrawLogo from "@/assets/images/applications/excalidraw.svg";
|
||||||
import trwebocrLogo from "@/assets/images/applications/trwebocr.png";
|
import trwebocrLogo from "@/assets/images/applications/trwebocr.png";
|
||||||
@@ -61,31 +55,35 @@ interface Category {
|
|||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = ref(0);
|
|
||||||
|
|
||||||
const toolCategories = reactive<Category[]>([
|
const toolCategories = reactive<Category[]>([
|
||||||
{
|
{
|
||||||
name: '📄 文档处理',
|
name: '📄 文档处理',
|
||||||
tools: [
|
tools: [
|
||||||
{ id: 'stirling-pdf', name: 'Stirling PDF', icon: '', logoImg: stirlingLogo, description: 'PDF 合并、拆分、压缩、转换、加水印、OCR 识别等 30+ 功能', port: 18080, enabled: true, type: 'newtab' },
|
{ id: 'stirling-pdf', name: 'Stirling PDF', icon: '', logoImg: stirlingLogo, description: 'PDF 合并、拆分、压缩、转换、加水印、OCR 识别等 30+ 功能', port: 18080, enabled: true, type: 'newtab' },
|
||||||
{ id: 'trwebocr', name: '图片转文字 OCR', icon: '', logoImg: trwebocrLogo, description: '中文离线 OCR,识别图片中的文字和表格', port: 18083, enabled: true, type: 'newtab' },
|
{ id: 'trwebocr', name: '图片转文字 OCR', icon: '', logoImg: trwebocrLogo, description: '中文离线 OCR,识别图片中的文字和表格', port: 18083, enabled: true, type: 'newtab' },
|
||||||
|
{ id: 'libretranslate', name: '智能翻译', icon: '', logoImg: libretranslateLogo, description: '中英互译,支持文本和文件翻译,数据不出内网', port: 18084, enabled: true, type: 'newtab' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '🖼️ 图片处理',
|
||||||
|
tools: [
|
||||||
|
{ id: 'imgcompress', name: '图片压缩转换', icon: '🗜️', description: '图片压缩、格式转换、批量处理、AI 智能抠图去背景', port: 18087, enabled: true, type: 'newtab' },
|
||||||
|
{ id: 'lama-cleaner', name: 'AI 图像擦除', icon: '🧹', description: 'AI 智能擦除图片中的水印、路人、瑕疵,自动修复画面', port: 18088, enabled: true, type: 'newtab' },
|
||||||
|
{ id: 'webp2jpg', name: '图片格式转换', icon: '🔄', description: '支持 PSD/HEIC/WebP/PNG/JPG 等格式批量互转,纯浏览器处理不上传', port: 18089, enabled: true, type: 'newtab' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '🎨 创作绘图',
|
name: '🎨 创作绘图',
|
||||||
tools: [
|
tools: [
|
||||||
{ id: 'excalidraw', name: 'Excalidraw', icon: '', logoImg: excalidrawLogo, description: '手绘风格白板,绘制流程图、架构图、示意图', port: 18081, enabled: true, type: 'newtab' },
|
{ id: 'excalidraw', name: 'Excalidraw', icon: '', logoImg: excalidrawLogo, description: '手绘风格白板,绘制流程图、架构图、示意图', port: 18081, enabled: true, type: 'newtab' },
|
||||||
{ id: 'mindmap', name: '思维导图', icon: '🧠', description: '创建和编辑思维导图,支持导出图片和 PDF', url: '', enabled: false, type: 'newtab' },
|
{ id: 'pptist', name: 'AI PPT 编辑器', icon: '', logoImg: pptistLogo, description: 'AI 生成 PPT 大纲和演示文稿,在线编辑演示', port: 18085, enabled: true, type: 'newtab' },
|
||||||
{ id: 'image-editor', name: '图片编辑', icon: '🖼️', description: '裁剪、滤镜、标注、调色,在线图片编辑器', url: '', enabled: false, type: 'newtab' },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '📊 数据与写作',
|
name: '📝 科研写作',
|
||||||
tools: [
|
tools: [
|
||||||
{ id: 'pptist', name: 'AI PPT 编辑器', icon: '', logoImg: pptistLogo, description: 'AI 生成 PPT 大纲和演示文稿,在线编辑演示', port: 18085, enabled: true, type: 'newtab' },
|
{ id: 'overleaf', name: 'LaTeX 论文编辑器', icon: '📐', description: '在线 LaTeX 编辑器,支持多人协作撰写论文,实时编译预览', port: 18090, enabled: true, type: 'newtab' },
|
||||||
{ id: 'libretranslate', name: '智能翻译', icon: '', logoImg: libretranslateLogo, description: '中英互译,支持文本和文件翻译,数据不出内网', port: 18084, enabled: true, type: 'newtab' },
|
{ id: 'latex-formula', name: 'LaTeX 公式编辑器', icon: '∑', description: '在线编辑数学公式,支持希腊字母、矩阵、积分等,可复制公式或图片', port: 18091, enabled: true, type: 'newtab' },
|
||||||
{ id: 'spreadsheet', name: '在线表格', icon: '📊', description: '在线 Excel,支持公式计算、图表生成', url: '', enabled: false, type: 'newtab' },
|
|
||||||
{ id: 'markdown', name: 'Markdown 编辑器', icon: '📝', description: '所见即所得编辑,支持数学公式、流程图、导出', url: '', enabled: false, type: 'newtab' },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -107,7 +105,7 @@ const openTool = (tool: Tool) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tools-container {
|
.tools-container {
|
||||||
padding: 30px 50px;
|
padding: 24px 40px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
@@ -115,40 +113,30 @@ const openTool = (tool: Tool) => {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
.tools-title { font-size: 20px; font-weight: bold; color: #000; }
|
.tools-title { font-size: 20px; font-weight: bold; color: #000; }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tab-bar {
|
.category-section {
|
||||||
display: flex;
|
margin-bottom: 24px;
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 20px;
|
.category-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
.tab-item {
|
|
||||||
padding: 6px 20px;
|
|
||||||
background: rgba(133, 138, 150, 0.1);
|
|
||||||
border-radius: 18px;
|
|
||||||
border: 1px solid #E6EDFF;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #606771;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover { border-color: #004EA0; color: #004EA0; }
|
|
||||||
&.active { background: #004EA0; color: #fff; border-color: #004EA0; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tools-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-item {
|
.tool-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 340px;
|
padding: 14px;
|
||||||
padding: 16px;
|
|
||||||
background: #E6EDFF;
|
background: #E6EDFF;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #E6EDFF;
|
border: 1px solid #E6EDFF;
|
||||||
@@ -166,33 +154,33 @@ const openTool = (tool: Tool) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-icon {
|
.tool-icon {
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
width: 50px;
|
width: 42px;
|
||||||
height: 50px;
|
height: 42px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.tool-logo-img { width: 36px; height: 36px; object-fit: contain; }
|
.tool-logo-img { width: 30px; height: 30px; object-fit: contain; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-info {
|
.tool-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 12px;
|
margin-left: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
.tool-top {
|
.tool-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-name {
|
.tool-name {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
@@ -208,7 +196,7 @@ const openTool = (tool: Tool) => {
|
|||||||
.tool-desc {
|
.tool-desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #858A94;
|
color: #858A94;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -219,9 +207,8 @@ const openTool = (tool: Tool) => {
|
|||||||
.tool-arrow {
|
.tool-arrow {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
margin-left: 12px;
|
margin-left: 10px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
150
scripts/latex-editor/index.html
Normal file
150
scripts/latex-editor/index.html
Normal 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>
|
||||||
49
scripts/overleaf-deploy/docker-compose.yml
Normal file
49
scripts/overleaf-deploy/docker-compose.yml
Normal 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:
|
||||||
128
scripts/overleaf-deploy/overleaf-register.py
Normal file
128
scripts/overleaf-deploy/overleaf-register.py
Normal 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)
|
||||||
Reference in New Issue
Block a user