feat: 悬挂ICP备案号,修复验证码、500登录、/admin Failed to Fetch,优化首页编辑器100%全屏平铺
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# 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
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
+25
-5
@@ -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,9 +10,28 @@ 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 (
|
return (
|
||||||
<>
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<MD2Doc />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其它分页面采用 Flex 自适应贴底页脚布局
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Navbar />
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
@@ -19,9 +39,9 @@ export default function App() {
|
|||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/issues" element={<Issues />} />
|
<Route path="/issues" element={<Issues />} />
|
||||||
<Route path="/issues/:id" element={<IssueDetail />} />
|
<Route path="/issues/:id" element={<IssueDetail />} />
|
||||||
{/* 取消拦截,将转换工具设为首页 */}
|
|
||||||
<Route path="/" element={<MD2Doc />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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>
|
||||||
|
<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'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+40
-17
@@ -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) {
|
||||||
@@ -452,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 })
|
||||||
@@ -499,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 ? (
|
||||||
<>
|
<>
|
||||||
@@ -545,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)}
|
||||||
@@ -680,6 +689,7 @@ export default function MD2Doc() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div style={styles.bottomBar}>
|
<div style={styles.bottomBar}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||||
<button
|
<button
|
||||||
style={styles.mainExportBtn}
|
style={styles.mainExportBtn}
|
||||||
onClick={handleExportWord}
|
onClick={handleExportWord}
|
||||||
@@ -687,6 +697,19 @@ export default function MD2Doc() {
|
|||||||
>
|
>
|
||||||
{exporting ? '正在生成文档...' : '一键导出 Word 文档 (.docx)'}
|
{exporting ? '正在生成文档...' : '一键导出 Word 文档 (.docx)'}
|
||||||
</button>
|
</button>
|
||||||
|
<div style={{ fontSize: '12px', marginTop: '4px' }}>
|
||||||
|
<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>
|
||||||
|
|
||||||
@@ -816,22 +839,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 样式
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
+13
-7
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user