Compare commits

...
10 Commits
17 changed files with 761 additions and 62 deletions
+20
View File
@@ -0,0 +1,20 @@
# Dependency directories
node_modules/
client/node_modules/
# Build outputs
client/dist/
dist/
# Python cache
__pycache__/
*.pyc
# Local backups and configs
app.ini.bak
database.json
settings.json
ip_username_password.txt
.idea/
.vscode/
+5 -1
View File
@@ -3,7 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeepSeek转Word/AI公式转Word/Markdown转Word</title> <meta name="baidu-site-verification" content="codeva-K8vOtnFGYh" />
<meta name="msvalidate.01" content="574162A5B8C1188854E2A6B64CDC8E82" />
<meta name="description" content="AIFormat 是一款专业的 DeepSeek 公式转 Word、AI 数学公式与 Markdown 一键导出 Word 工具。支持 LaTeX、Katex 格式完美转换,排版精美,科研论文与工作报告导出的得力助手。" />
<meta name="keywords" content="DeepSeek转Word,公式转Word,Markdown转Word,LaTeX转Word,Katex公式转换,AI格式化,Word导出" />
<title>DeepSeek转Word / AI公式转Word / Markdown转Word - AIFormat在线转换工具</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+33 -13
View File
@@ -1,5 +1,6 @@
import { Routes, Route } from 'react-router-dom'; import { Routes, Route, useLocation } from 'react-router-dom';
import Navbar from './components/Navbar'; import Navbar from './components/Navbar';
import Footer from './components/Footer';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import MD2Doc from './pages/MD2Doc'; import MD2Doc from './pages/MD2Doc';
@@ -9,19 +10,38 @@ import Issues from './pages/Issues';
import IssueDetail from './pages/IssueDetail'; import IssueDetail from './pages/IssueDetail';
export default function App() { export default function App() {
const location = useLocation();
const isHomePage = location.pathname === '/';
if (isHomePage) {
// 首页(MD2Doc)完全平铺屏幕,配合 flex 容器,使 MD2Doc 高度自动填充 Navbar 下方剩余空间,不产生页面滚动条
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Navbar />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Routes>
<Route path="/" element={<MD2Doc />} />
</Routes>
</div>
</div>
);
}
// 其它分页面采用 Flex 自适应贴底页脚布局
return ( return (
<> <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<Navbar /> <Navbar />
<Routes> <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Route path="/login" element={<Login />} /> <Routes>
<Route path="/register" element={<Register />} /> <Route path="/login" element={<Login />} />
<Route path="/admin" element={<Admin />} /> <Route path="/register" element={<Register />} />
<Route path="/profile" element={<Profile />} /> <Route path="/admin" element={<Admin />} />
<Route path="/issues" element={<Issues />} /> <Route path="/profile" element={<Profile />} />
<Route path="/issues/:id" element={<IssueDetail />} /> <Route path="/issues" element={<Issues />} />
{/* 取消拦截,将转换工具设为首页 */} <Route path="/issues/:id" element={<IssueDetail />} />
<Route path="/" element={<MD2Doc />} /> </Routes>
</Routes> </div>
</> <Footer />
</div>
); );
} }
+60
View File
@@ -0,0 +1,60 @@
import React from 'react';
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer style={styles.footer}>
<div style={styles.container}>
<span style={styles.copyright}>
© {currentYear} To Docx. 保留所有权利
</span>
<span style={{ color: '#9ca3af' }}>
联系邮箱: <a href="mailto:abigwc@163.com" style={styles.link} onMouseOver={(e) => e.target.style.color = '#3b82f6'} onMouseOut={(e) => e.target.style.color = '#9ca3af'}>abigwc@163.com</a>
</span>
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noreferrer"
style={styles.link}
onMouseOver={(e) => e.target.style.color = '#3b82f6'}
onMouseOut={(e) => e.target.style.color = '#9ca3af'}
>
闽ICP备2026017655号-1
</a>
</div>
</footer>
);
}
const styles = {
footer: {
padding: '20px 0',
backgroundColor: '#f9fafb',
borderTop: '1px solid #e5e7eb',
width: '100%',
boxSizing: 'border-box',
marginTop: 'auto'
},
container: {
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap',
fontSize: '13px',
color: '#9ca3af'
},
copyright: {
fontWeight: '400'
},
link: {
color: '#9ca3af',
textDecoration: 'none',
transition: 'color 0.2s ease',
fontWeight: '500'
}
};
+1 -1
View File
@@ -5,7 +5,7 @@
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
background-color: #f0efe6; background-color: #f0efe6;
min-height: 100vh; min-height: 100vh;
} }
+7 -7
View File
@@ -48,9 +48,9 @@ export default function Admin() {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const [usersRes, settingsRes, issuesRes] = await Promise.all([ const [usersRes, settingsRes, issuesRes] = await Promise.all([
fetch('http://localhost:3001/api/admin/users', { headers: { Authorization: `Bearer ${token}` } }), fetch('/api/admin/users', { headers: { Authorization: `Bearer ${token}` } }),
fetch('http://localhost:3001/api/admin/settings', { headers: { Authorization: `Bearer ${token}` } }), fetch('/api/admin/settings', { headers: { Authorization: `Bearer ${token}` } }),
fetch('http://localhost:3001/api/admin/issues', { headers: { Authorization: `Bearer ${token}` } }) fetch('/api/admin/issues', { headers: { Authorization: `Bearer ${token}` } })
]); ]);
if (!usersRes.ok || !settingsRes.ok) { if (!usersRes.ok || !settingsRes.ok) {
@@ -82,7 +82,7 @@ export default function Admin() {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch('http://localhost:3001/api/admin/settings', { const res = await fetch('/api/admin/settings', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -105,7 +105,7 @@ export default function Admin() {
const fetchIssueDetail = async (issueId) => { const fetchIssueDetail = async (issueId) => {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch(`http://localhost:3001/api/issues/${issueId}`, { const res = await fetch(`/api/issues/${issueId}`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
const data = await res.json(); const data = await res.json();
@@ -125,7 +125,7 @@ export default function Admin() {
setReplyMsg(''); setReplyMsg('');
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch(`http://localhost:3001/api/admin/issues/${selectedIssue.id}/reply`, { const res = await fetch(`/api/admin/issues/${selectedIssue.id}/reply`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -150,7 +150,7 @@ export default function Admin() {
const handleStatusChange = async (issueId, newStatus) => { const handleStatusChange = async (issueId, newStatus) => {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch(`http://localhost:3001/api/admin/issues/${issueId}/status`, { const res = await fetch(`/api/admin/issues/${issueId}/status`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+2 -2
View File
@@ -35,7 +35,7 @@ export default function IssueDetail() {
const fetchIssue = async () => { const fetchIssue = async () => {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch(`http://localhost:3001/api/issues/${id}`, { const res = await fetch(`/api/issues/${id}`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
const data = await res.json(); const data = await res.json();
@@ -56,7 +56,7 @@ export default function IssueDetail() {
setReplyMsg(''); setReplyMsg('');
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch(`http://localhost:3001/api/issues/${id}/reply`, { const res = await fetch(`/api/issues/${id}/reply`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+2 -2
View File
@@ -51,7 +51,7 @@ export default function Issues() {
const fetchMyIssues = async () => { const fetchMyIssues = async () => {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch('http://localhost:3001/api/issues/my', { const res = await fetch('/api/issues/my', {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
if (res.ok) { if (res.ok) {
@@ -82,7 +82,7 @@ export default function Issues() {
description: description.trim(), description: description.trim(),
markdown_sample: attachMarkdown ? getEditorMarkdown() : '' markdown_sample: attachMarkdown ? getEditorMarkdown() : ''
}; };
const res = await fetch('http://localhost:3001/api/issues', { const res = await fetch('/api/issues', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+54 -25
View File
@@ -268,7 +268,7 @@ export default function MD2Doc() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
try { try {
const res = await fetch('http://localhost:3001/api/history', { const res = await fetch('/api/history', {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (res.ok) { if (res.ok) {
@@ -372,7 +372,9 @@ export default function MD2Doc() {
result = result.replace(/\\\(|\\\)/g, '$'); result = result.replace(/\\\(|\\\)/g, '$');
// 步骤4\[ ... \] → $$ ... $$ // 步骤4\[ ... \] → $$ ... $$
result = result.replace(/\\\[|\\\]/g, '$$'); // 注意:在 JS 的 replace 中,'$$' 是特殊转义序列(表示插入字面量 '$'),
// 必须写成 '$$$$' 才能正确替换为两个 $ 符号
result = result.replace(/\\\[|\\\]/g, '$$$$');
return result; return result;
}; };
@@ -450,7 +452,7 @@ export default function MD2Doc() {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
const response = await fetch('http://localhost:3001/api/convert', { const response = await fetch('/api/convert', {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ markdown, styleOptions }) body: JSON.stringify({ markdown, styleOptions })
@@ -497,14 +499,11 @@ export default function MD2Doc() {
{user && ( {user && (
<aside style={{ <aside style={{
...styles.sidebar, ...styles.sidebar,
width: showHistory ? '260px' : '40px', width: showHistory ? '260px' : '0px',
padding: showHistory ? '16px' : '16px 4px', padding: showHistory ? '16px' : '0px',
alignItems: showHistory ? 'stretch' : 'center', borderRight: showHistory ? '1px solid #d1d5db' : 'none',
cursor: showHistory ? 'default' : 'pointer', transition: 'all 0.2s ease-in-out'
transition: 'width 0.2s ease-in-out'
}} }}
onClick={() => { if (!showHistory) setShowHistory(true); }}
title={!showHistory ? "点击展开转换记录" : ""}
> >
{showHistory ? ( {showHistory ? (
<> <>
@@ -543,6 +542,18 @@ export default function MD2Doc() {
<main style={styles.main}> <main style={styles.main}>
<section style={styles.inputSection}> <section style={styles.inputSection}>
<div style={styles.toolbar}> <div style={styles.toolbar}>
{user && (
<>
<button
style={{...styles.btn, backgroundColor: showHistory ? '#3b82f6' : '#6b7280'}}
onClick={() => setShowHistory(!showHistory)}
title={showHistory ? "隐藏历史记录" : "显示历史记录"}
>
{showHistory ? "📋 隐藏历史" : "📋 历史记录"}
</button>
<div style={{width: '1px', backgroundColor: '#d1d5db', margin: '0 4px'}}></div>
</>
)}
<button <button
style={{...styles.btn, backgroundColor: isSyncScroll ? '#10b981' : '#6b7280', padding: '4px 8px'}} style={{...styles.btn, backgroundColor: isSyncScroll ? '#10b981' : '#6b7280', padding: '4px 8px'}}
onClick={() => setIsSyncScroll(!isSyncScroll)} onClick={() => setIsSyncScroll(!isSyncScroll)}
@@ -678,13 +689,31 @@ export default function MD2Doc() {
</main> </main>
<div style={styles.bottomBar}> <div style={styles.bottomBar}>
<button <div style={{ position: 'relative', width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
style={styles.mainExportBtn} <button
onClick={handleExportWord} style={styles.mainExportBtn}
disabled={exporting || !markdown.trim()} onClick={handleExportWord}
> disabled={exporting || !markdown.trim()}
{exporting ? '正在生成文档...' : '一键导出 Word 文档 (.docx)'} >
</button> {exporting ? '正在生成文档...' : '一键导出 Word 文档 (.docx)'}
</button>
<div style={{ position: 'absolute', right: '24px', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px', fontSize: '12px' }}>
<span style={{ color: '#9ca3af' }}>
联系邮箱: <a href="mailto:abigwc@163.com" style={{ color: '#9ca3af', textDecoration: 'none', transition: 'color 0.2s' }} onMouseOver={(e) => e.target.style.color = '#3b82f6'} onMouseOut={(e) => e.target.style.color = '#9ca3af'}>abigwc@163.com</a>
</span>
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noreferrer"
style={{ color: '#9ca3af', textDecoration: 'none', transition: 'color 0.2s' }}
onMouseOver={(e) => e.target.style.color = '#3b82f6'}
onMouseOut={(e) => e.target.style.color = '#9ca3af'}
>
闽ICP备2026017655号-1
</a>
</div>
</div>
</div> </div>
</div> </div>
@@ -814,22 +843,22 @@ export default function MD2Doc() {
} }
const styles = { const styles = {
container: { maxWidth: '1600px', margin: '0 auto', padding: '24px', height: '100vh', display: 'flex', gap: '20px', backgroundColor: '#f9fafb', boxSizing: 'border-box' }, container: { width: '100%', maxWidth: '100%', margin: '0', padding: '0', height: '100%', display: 'flex', gap: '0', backgroundColor: '#f9fafb', boxSizing: 'border-box' },
sidebar: { width: '260px', backgroundColor: 'white', borderRadius: '8px', padding: '16px', border: '1px solid #d1d5db', display: 'flex', flexDirection: 'column', height: '100%', boxSizing: 'border-box' }, sidebar: { backgroundColor: 'white', display: 'flex', flexDirection: 'column', height: '100%', boxSizing: 'border-box', overflow: 'hidden' },
sidebarTitle: { fontSize: '18px', fontWeight: 'bold', margin: '0 0 4px 0' }, sidebarTitle: { fontSize: '18px', fontWeight: 'bold', margin: '0 0 4px 0' },
historyList: { flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '10px' }, historyList: { flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '10px' },
historyItem: { padding: '10px', backgroundColor: '#f3f4f6', borderRadius: '6px', cursor: 'pointer', transition: 'background 0.2s' }, historyItem: { padding: '10px', backgroundColor: '#f3f4f6', borderRadius: '6px', cursor: 'pointer', transition: 'background 0.2s' },
historyTime: { fontSize: '12px', color: '#6b7280', marginBottom: '4px' }, historyTime: { fontSize: '12px', color: '#6b7280', marginBottom: '4px' },
historySummary: { fontSize: '14px', color: '#374151', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, historySummary: { fontSize: '14px', color: '#374151', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
mainContentWrapper: { flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }, mainContentWrapper: { flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' },
main: { display: 'flex', gap: '8px', flex: 1, overflow: 'hidden' }, main: { display: 'flex', gap: '0', flex: 1, overflow: 'hidden' },
inputSection: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }, inputSection: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 },
textarea: { flex: 1, padding: '40px 20px 20px 20px', border: '1px solid #d1d5db', borderRadius: '8px', fontFamily: 'monospace', fontSize: '15px', resize: 'none', boxSizing: 'border-box' }, textarea: { flex: 1, padding: '45px 24px 24px 24px', border: 'none', borderRight: '1px solid #d1d5db', fontFamily: 'monospace', fontSize: '15px', resize: 'none', boxSizing: 'border-box', outline: 'none' },
toolbar: { position: 'absolute', top: '10px', right: '10px', display: 'flex', gap: '6px', zIndex: 10 }, toolbar: { position: 'absolute', top: '10px', right: '15px', display: 'flex', gap: '6px', zIndex: 10 },
btn: { padding: '4px 10px', fontSize: '12px', backgroundColor: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', opacity: 0.85, transition: 'opacity 0.2s' }, btn: { padding: '4px 10px', fontSize: '12px', backgroundColor: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', opacity: 0.85, transition: 'opacity 0.2s' },
previewSection: { flex: 1, position: 'relative', padding: '0', backgroundColor: 'white', border: '1px solid #d1d5db', borderRadius: '8px', overflowY: 'auto', height: '100%', boxSizing: 'border-box', minWidth: 0 }, previewSection: { flex: 1, position: 'relative', padding: '0', backgroundColor: 'white', border: 'none', overflowY: 'auto', height: '100%', boxSizing: 'border-box', minWidth: 0 },
previewContent: { backgroundColor: 'white', width: '100%', minHeight: '100%', padding: '40px', lineHeight: 1.6, boxSizing: 'border-box' }, previewContent: { backgroundColor: 'white', width: '100%', minHeight: '100%', padding: '40px 48px', lineHeight: 1.6, boxSizing: 'border-box' },
bottomBar: { marginTop: '20px', display: 'flex', justifyContent: 'center' }, bottomBar: { padding: '12px 0', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'center', backgroundColor: '#fff' },
mainExportBtn: { padding: '12px 32px', backgroundColor: '#10b981', color: 'white', border: 'none', borderRadius: '8px', fontSize: '16px', fontWeight: 'bold', cursor: 'pointer' }, mainExportBtn: { padding: '12px 32px', backgroundColor: '#10b981', color: 'white', border: 'none', borderRadius: '8px', fontSize: '16px', fontWeight: 'bold', cursor: 'pointer' },
// Modal 样式 // Modal 样式
+3 -3
View File
@@ -16,7 +16,7 @@ export default function Profile() {
const [smtpEnabled, setSmtpEnabled] = useState(false); const [smtpEnabled, setSmtpEnabled] = useState(false);
useEffect(() => { useEffect(() => {
fetch('http://localhost:3001/api/sys/config') fetch('/api/sys/config')
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data && data.smtp_enabled !== undefined) { if (data && data.smtp_enabled !== undefined) {
@@ -37,7 +37,7 @@ export default function Profile() {
setLoadingCode(true); setLoadingCode(true);
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch('http://localhost:3001/api/auth/send-code', { const res = await fetch('/api/auth/send-code', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
@@ -69,7 +69,7 @@ export default function Profile() {
setLoadingSubmit(true); setLoadingSubmit(true);
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch('http://localhost:3001/api/auth/update-profile', { const res = await fetch('/api/auth/update-profile', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+1 -1
View File
@@ -22,7 +22,7 @@ export default function Register() {
const fetchCaptcha = async () => { const fetchCaptcha = async () => {
try { try {
const res = await fetch('http://localhost:3001/api/captcha'); const res = await fetch('/api/captcha');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setCaptchaId(data.captcha_id); setCaptchaId(data.captcha_id);
+349
View File
@@ -0,0 +1,349 @@
import os
import sys
import subprocess
import paramiko
from stat import S_ISDIR
# 强制配置标准输出为 UTF-8 编码,防止 Windows 控制台因 GBK 导致 Unicode 编码报错
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
# ==========================================
# To_Docx 自动化部署脚本 (deploy.py)
# ==========================================
# 远程服务器配置
HOST = "175.178.162.18"
PORT = 22
USERNAME = "root"
PASSWORD = "sp-cc123"
REMOTE_DIR = "/root/to_docx"
# 排除上传的文件和目录列表
EXCLUDE_DIRS = {
".git",
"node_modules",
"venv",
"__pycache__",
".idea",
".vscode"
}
EXCLUDE_FILES = {
"deploy.py",
".DS_Store",
"database.json",
"settings.json"
}
def run_local_build():
"""在本地构建前端静态文件"""
print("=== [1/5] 开始在本地构建前端静态资源 ===")
client_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "client")
# 检查本地 node_modules 是否存在,若不存在则先安装依赖
if not os.path.exists(os.path.join(client_dir, "node_modules")):
print("[INFO] 本地未检测到 node_modules,正在执行 npm install...")
subprocess.run("npm install", shell=True, cwd=client_dir, check=True)
print("[INFO] 正在执行 npm run build...")
try:
subprocess.run("npm run build", shell=True, cwd=client_dir, check=True)
except subprocess.CalledProcessError as e:
# 兼容 Windows 系统下 Node.js 在编译完成后进程退出时偶尔出现的 Libuv 崩溃报错 (如 3221226505)
# 只要检测到 dist 目录下的入口 index.html 已正常生成,我们就允许继续同步和部署流程
dist_index = os.path.join(client_dir, "dist", "index.html")
if os.path.exists(dist_index):
print("[WARN] npm run build 虽然返回了异常退出码,但检测到目标 dist/index.html 已存在,判定构建正常,继续部署...")
else:
raise e
print("[SUCCESS] 本地前端构建成功!\n")
def connect_ssh():
"""建立 SSH 连接"""
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
print(f"[INFO] 正在连接远程服务器 {HOST}...")
ssh.connect(HOST, port=PORT, username=USERNAME, password=PASSWORD, timeout=10)
print("[SUCCESS] SSH 连接成功!\n")
return ssh
def execute_remote_cmd(ssh, cmd):
"""在远程服务器执行命令并打印输出"""
print(f"[CMD] 远程执行: {cmd}")
stdin, stdout, stderr = ssh.exec_command(cmd)
# 获取输出
out = stdout.read().decode('utf-8').strip()
err = stderr.read().decode('utf-8').strip()
if out:
print(f"[STDOUT]:\n{out}")
if err:
print(f"[STDERR]:\n{err}")
# 返回执行的状态码
exit_status = stdout.channel.recv_exit_status()
return exit_status, out, err
def sftp_upload_dir(sftp, local_dir, remote_dir):
"""递归上传目录并过滤不需要的文件"""
# 确保远程父目录存在
try:
sftp.mkdir(remote_dir)
except IOError:
pass
for item in os.listdir(local_dir):
# 排除过滤文件与文件夹
if item in EXCLUDE_FILES or item in EXCLUDE_DIRS:
continue
local_path = os.path.join(local_dir, item)
remote_path = os.path.join(remote_dir, item).replace('\\', '/')
if os.path.isdir(local_path):
sftp_upload_dir(sftp, local_path, remote_path)
else:
print(f"[UPLOAD] 上传文件: {item} -> {remote_path}")
sftp.put(local_path, remote_path)
def upload_project_files(ssh):
"""同步本地文件到远程服务器"""
print("=== [2/5] 开始增量同步文件到远程服务器 ===")
transport = ssh.get_transport()
sftp = paramiko.SFTPClient.from_transport(transport)
local_root = os.path.dirname(os.path.abspath(__file__))
# 确保远程项目根目录存在
try:
sftp.mkdir(REMOTE_DIR)
except IOError:
pass
# 同步根目录下的脚本文件
for item in os.listdir(local_root):
local_path = os.path.join(local_root, item)
remote_path = os.path.join(REMOTE_DIR, item).replace('\\', '/')
if os.path.isdir(local_path):
if item in EXCLUDE_DIRS:
continue
sftp_upload_dir(sftp, local_path, remote_path)
else:
if item in EXCLUDE_FILES:
continue
print(f"[UPLOAD] 上传根目录文件: {item}")
sftp.put(local_path, remote_path)
print("[SUCCESS] 文件同步完成!\n")
def setup_remote_environment(ssh):
"""安装系统依赖如 Nginx, Pandoc 等"""
print("=== [3/5] 检查并配置服务器基础系统依赖 ===")
# 检查并安装 Nginx 和 Pandoc
print("[INFO] 正在检查并安装 Nginx 和 Pandoc...")
cmd_install = "apt-get update && apt-get install -y nginx pandoc"
execute_remote_cmd(ssh, cmd_install)
# 寻找服务器上的 Conda 可执行路径
print("[INFO] 正在定位服务器 Conda 路径...")
_, out_conda, _ = execute_remote_cmd(ssh, "which conda")
conda_path = out_conda.strip()
if not conda_path:
# 尝试常见路径
common_paths = [
"/root/miniconda3/bin/conda",
"/root/anaconda3/bin/conda",
"/usr/bin/conda"
]
for path in common_paths:
status, _, _ = execute_remote_cmd(ssh, f"test -f {path}")
if status == 0:
conda_path = path
break
if not conda_path:
print("[ERROR] 远程服务器上未找到 Conda。请先在服务器上安装 Miniconda 并在远程配置好环境。")
sys.exit(1)
print(f"[SUCCESS] 找到远程 Conda 路径: {conda_path}")
return conda_path
def configure_nginx(ssh):
"""写入 Nginx 配置并重新加载"""
print("=== [4/5] 正在配置域名 Nginx 反向代理 ===")
# 远程创建可被 Nginx (www-data) 正常访问的目录
remote_web_dir = "/var/www/to_docx"
print(f"[INFO] 远程创建静态文件托管目录并设置权限: {remote_web_dir}")
execute_remote_cmd(ssh, f"mkdir -p {remote_web_dir}")
execute_remote_cmd(ssh, f"rm -rf {remote_web_dir}/*")
execute_remote_cmd(ssh, f"cp -r /root/to_docx/client/dist/* {remote_web_dir}/")
execute_remote_cmd(ssh, f"chown -R www-data:www-data {remote_web_dir}")
execute_remote_cmd(ssh, f"chmod -R 755 {remote_web_dir}")
nginx_conf = """server {
server_name aiformat.cn www.aiformat.cn;
root /var/www/to_docx;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /code/ {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 512M;
proxy_connect_timeout 600s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
location /code {
return 301 $scheme://$host$request_uri/;
}
# 兼容 Git Credential Manager (GCM) 客户端在非根路径下克隆时丢失子路径前缀的 OAuth2 认证 Bug
location /login/oauth/ {
proxy_pass http://127.0.0.1:3000/login/oauth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/www.aiformat.cn/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/www.aiformat.cn/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.aiformat.cn) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = aiformat.cn) {
return 301 https://$host$request_uri;
}
listen 80;
server_name aiformat.cn www.aiformat.cn;
return 404; # managed by Certbot
}
"""
# 临时写入远程文件
remote_conf_path = "/etc/nginx/sites-available/to-docx"
print(f"[INFO] 写入 Nginx 配置文件: {remote_conf_path}")
# 使用 SFTP 写入
transport = ssh.get_transport()
sftp = paramiko.SFTPClient.from_transport(transport)
with sftp.file(remote_conf_path, 'w') as f:
f.write(nginx_conf)
# 启用配置并删除默认配置
execute_remote_cmd(ssh, "ln -sf /etc/nginx/sites-available/to-docx /etc/nginx/sites-enabled/")
execute_remote_cmd(ssh, "rm -f /etc/nginx/sites-enabled/default")
# 检查并重启 Nginx
status, _, _ = execute_remote_cmd(ssh, "nginx -t")
if status == 0:
execute_remote_cmd(ssh, "systemctl reload nginx")
print("[SUCCESS] Nginx 配置完成并成功重载!\n")
else:
print("[ERROR] Nginx 配置文件格式错误,请检查!\n")
def configure_systemd(ssh, conda_path):
"""配置 Systemd 后端服务并启动"""
print("=== [5/5] 正在配置后端为 Systemd 服务 ===")
service_content = f"""[Unit]
Description=To_Docx Backend Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/to_docx/server
ExecStart={conda_path} run --no-capture-output -n to_docx uvicorn main:app --host 127.0.0.1 --port 3001
Restart=always
RestartSec=5
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[Install]
WantedBy=multi-user.target
"""
service_path = "/etc/systemd/system/to-docx-backend.service"
print(f"[INFO] 写入 Systemd 配置文件: {service_path}")
transport = ssh.get_transport()
sftp = paramiko.SFTPClient.from_transport(transport)
with sftp.file(service_path, 'w') as f:
f.write(service_content)
# 远程更新 Python 依赖包 (如果有新增)
print("[INFO] 正在在服务器上更新 Python 依赖...")
execute_remote_cmd(ssh, f"{conda_path} run -n to_docx pip install -r {REMOTE_DIR}/server/requirements.txt")
# 重启并启用 Systemd 服务
execute_remote_cmd(ssh, "systemctl daemon-reload")
execute_remote_cmd(ssh, "systemctl enable to-docx-backend")
execute_remote_cmd(ssh, "systemctl restart to-docx-backend")
# 检查服务状态
execute_remote_cmd(ssh, "systemctl status to-docx-backend --no-pager")
print("[SUCCESS] Systemd 后端服务部署并重启成功!\n")
def main():
try:
# 1. 本地前端构建
run_local_build()
# 2. 建立 SSH 连接
ssh = connect_ssh()
# 3. 上传最新代码
upload_project_files(ssh)
# 4. 配置远程基础依赖及定位 Conda
conda_path = setup_remote_environment(ssh)
# 5. 部署 Nginx 域名反向代理
configure_nginx(ssh)
# 6. 部署 Systemd 服务并启动后端
configure_systemd(ssh, conda_path)
print("[SUCCESS] To_Docx 已经成功一键部署!")
print(f"URL: https://www.aiformat.cn")
except Exception as e:
print(f"[ERROR] 部署失败,错误信息: {str(e)}")
sys.exit(1)
finally:
if 'ssh' in locals():
ssh.close()
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# ==========================================
# To_Docx Debian 启动脚本 (run_dev.sh)
# 用于在 Debian 13 环境下一键启动前后端
# ==========================================
# 获取脚本所在的绝对路径
ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
CONDA_ENV="to_docx"
echo "------------------------------------------"
echo "🚀 正在启动 To_Docx 开发环境..."
echo "项目根目录: $ROOT_DIR"
echo "------------------------------------------"
# 1. 基础环境检查
if ! command -v conda &> /dev/null; then
echo "❌ 错误: 未检测到 conda 命令,请先安装 Miniconda 或 Anaconda。"
exit 1
fi
if ! command -v node &> /dev/null; then
echo "❌ 错误: 未检测到 node 命令,请先安装 Node.js。"
exit 1
fi
# 2. 检查并提示 Conda 环境
if ! conda info --envs | grep -q "$CONDA_ENV"; then
echo "⚠️ 警告: 未找到 Conda 环境 '$CONDA_ENV'。"
echo "提示: 请按照以下步骤搭建环境:"
echo " 1. conda create -n $CONDA_ENV python=3.10 -y"
echo " 2. conda activate $CONDA_ENV"
echo " 3. pip install fastapi uvicorn pypandoc python-docx pydantic \"python-jose[cryptography]\" \"passlib[bcrypt]\" bcrypt"
exit 1
fi
# 3. 定义清理函数 (处理 Ctrl+C)
cleanup() {
echo ""
echo "🛑 正在停止所有服务..."
[ -n "$BACKEND_PID" ] && kill $BACKEND_PID 2>/dev/null
[ -n "$FRONTEND_PID" ] && kill $FRONTEND_PID 2>/dev/null
echo "✅ 服务已关闭。"
exit 0
}
# 捕获中断信号
trap cleanup SIGINT
# 4. 启动后端 (FastAPI)
echo "[1/2] 正在启动后端服务器 (端口: 3001)..."
# 使用 conda run 确保在正确的环境下执行,无需手动 activate
conda run --no-capture-output -n $CONDA_ENV python "$ROOT_DIR/server/main.py" &
BACKEND_PID=$!
# 5. 启动前端 (Vite)
echo "[2/2] 正在启动前端开发服务器 (端口: 5173)..."
cd "$ROOT_DIR/client"
# 自动修复 Vite 的执行权限问题 (针对 Permission denied 报错)
if [ -f "node_modules/.bin/vite" ]; then
chmod +x node_modules/.bin/vite
fi
# 使用 --host 允许外部通过服务器 IP 访问
npm run dev -- --host &
FRONTEND_PID=$!
echo "------------------------------------------"
echo "✅ 所有服务已尝试启动!"
echo "后端 PID: $BACKEND_PID"
echo "前端 PID: $FRONTEND_PID"
echo "👉 访问地址: http://服务器IP:5173"
echo "💡 提示: 按下 [Ctrl+C] 停止所有服务。"
echo "------------------------------------------"
# 阻塞主进程以保持后台进程运行并输出日志
wait
+13 -7
View File
@@ -1,8 +1,7 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from fastapi import HTTPException, Depends
from fastapi import HTTPException, Security, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import db import db
@@ -10,15 +9,22 @@ SECRET_KEY = "your-secret-key-change-in-production"
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer() security = HTTPBearer()
optional_security = HTTPBearer(auto_error=False) optional_security = HTTPBearer(auto_error=False)
def verify_password(plain_password, hashed_password): import bcrypt
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password): # 使用原生 bcrypt 进行密码校验,解决 passlib 与 bcrypt 5.x 的兼容性问题
return pwd_context.hash(password) def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
except Exception:
return False
# 使用原生 bcrypt 生成密码哈希
def get_password_hash(password: str) -> str:
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def create_access_token(data: dict): def create_access_token(data: dict):
to_encode = data.copy() to_encode = data.copy()
+9
View File
@@ -0,0 +1,9 @@
fastapi
uvicorn
pypandoc
python-docx
pydantic
python-jose[cryptography]
passlib[bcrypt]
bcrypt
captcha
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# ==========================================
# To_Docx 环境安装脚本 (setup_env.sh)
# 用于自动化创建 Conda 环境并安装依赖
# ==========================================
CONDA_ENV="to_docx"
ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
echo "------------------------------------------"
echo "🛠️ 开始配置 To_Docx 运行环境..."
echo "------------------------------------------"
# 1. 检查 Conda 是否安装
if ! command -v conda &> /dev/null; then
echo "❌ 错误: 未检测到 conda,请先安装 Miniconda 或 Anaconda。"
exit 1
fi
# 2. 创建 Conda 环境 (如果不存在)
if conda info --envs | grep -q "$CONDA_ENV"; then
echo "️ 环境 '$CONDA_ENV' 已存在,跳过创建步骤。"
else
echo "📦 正在创建 Conda 环境: $CONDA_ENV (Python 3.10)..."
conda create -n $CONDA_ENV python=3.10 -y
fi
# 3. 安装后端 Python 依赖
echo "🐍 正在安装后端依赖..."
if [ -f "$ROOT_DIR/server/requirements.txt" ]; then
conda run -n $CONDA_ENV pip install -r "$ROOT_DIR/server/requirements.txt"
else
# 回退方案:手动安装
conda run -n $CONDA_ENV pip install fastapi uvicorn pypandoc python-docx pydantic "python-jose[cryptography]" "passlib[bcrypt]" bcrypt
fi
# 4. 检查系统依赖提示
echo "------------------------------------------"
echo "✅ Python 环境配置完成!"
echo ""
echo "💡 额外提醒:"
echo "1. 请确保系统已安装 Pandoc (Debian: sudo apt install pandoc)"
echo "2. 如果需要运行前端,请确保已安装 Node.js 并在 client 目录执行 npm install"
echo "3. 现在您可以运行 ./run_dev.sh 启动项目了。"
echo "------------------------------------------"
+77
View File
@@ -0,0 +1,77 @@
import paramiko
import sys
# 强制配置标准输出为 UTF-8 编码,防止 Windows 控制台因 GBK 导致 Unicode 编码报错
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
# 远程服务器配置
HOST = "175.178.162.18"
PORT = 22
USERNAME = "root"
PASSWORD = "sp-cc123"
def execute_remote_cmd(ssh, cmd):
print(f"[CMD] 远程执行: {cmd}")
stdin, stdout, stderr = ssh.exec_command(cmd)
# 阻塞式读取以实时输出日志
while not stdout.channel.exit_status_ready():
if stdout.channel.recv_ready():
out = stdout.channel.recv(1024).decode('utf-8', errors='ignore')
sys.stdout.write(out)
sys.stdout.flush()
if stderr.channel.recv_stderr_ready():
err = stderr.channel.recv_stderr(1024).decode('utf-8', errors='ignore')
sys.stderr.write(err)
sys.stderr.flush()
# 读取剩余输出
out = stdout.read().decode('utf-8', errors='ignore')
err = stderr.read().decode('utf-8', errors='ignore')
if out: sys.stdout.write(out)
if err: sys.stderr.write(err)
exit_status = stdout.channel.recv_exit_status()
print(f"\n[INFO] 命令结束状态码: {exit_status}\n")
return exit_status
def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
print(f"正在连接远程服务器 {HOST}...")
ssh.connect(HOST, port=PORT, username=USERNAME, password=PASSWORD, timeout=10)
print("SSH 连接成功!\n")
# 1. 更新包源并安装 certbot 和 python3-certbot-nginx
print("=== [1/2] 正在更新软件源并安装 Certbot & Nginx 插件 ===")
cmd_install = "export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -y certbot python3-certbot-nginx"
status = execute_remote_cmd(ssh, cmd_install)
if status != 0:
print("[ERROR] 安装 Certbot 失败,请检查服务器网络与 apt 源状态!")
ssh.close()
sys.exit(1)
# 2. 运行 certbot
print("=== [2/2] 正在通过 Certbot 申请 SSL 证书并配置 Nginx ===")
# --nginx 表示自动读写 nginx 配置,-d 指定域名
# --non-interactive 表示非交互模式
# --agree-tos 表示同意服务条款
# -m 指定管理员邮箱
# --redirect 表示自动添加 HTTP 301 重定向到 HTTPS 规则
cmd_cert = "certbot --nginx -d www.aiformat.cn --non-interactive --agree-tos -m admin@aiformat.cn --redirect"
status = execute_remote_cmd(ssh, cmd_cert)
if status != 0:
print("[ERROR] Certbot 申请证书失败!")
print("\n友情提示:此错误通常是由于域名 DNS 解析还未生效引起的。")
print("请您进行以下检查:")
print("1. 域名 aiformat.cn 是否已经在腾讯云 DNS 控制台中正确添加了 A 记录(解析到 IP 175.178.162.18)。")
print("2. 腾讯云安全组中是否已成功放行 80 (HTTP) 和 443 (HTTPS) 端口。")
ssh.close()
sys.exit(1)
print("[SUCCESS] HTTPS SSL 证书已成功应用到 Nginx 配置,并已开启自动续期服务!")
ssh.close()
if __name__ == "__main__":
main()