3 Commits

Author SHA1 Message Date
237899745
8095b2391e fix: unify compact navigation breakpoint
Some checks failed
Build and tests / test (push) Has been cancelled
2026-08-03 22:25:55 +08:00
237899745
c529e6c51b feat: streamline mobile file workspace 2026-08-03 22:21:09 +08:00
237899745
bd46227e49 fix: harden one-click installer
Some checks failed
Build and tests / test (push) Has been cancelled
2026-07-27 21:04:12 +08:00
5 changed files with 1227 additions and 208 deletions

View File

@@ -140,6 +140,43 @@ test('PM2 部署没有启用 cluster 或多实例参数', () => {
assert.strictEqual(/pm2\s+start[^\n]*(?:\s-i\s|--instances)/.test(installer), false); assert.strictEqual(/pm2\s+start[^\n]*(?:\s-i\s|--instances)/.test(installer), false);
}); });
test('install.sh 可在最小化系统完成预检且不会吞掉 NodeSource 下载错误', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
assert.ok(installer.includes("/dev/tcp/git.workyai.cn/443"));
assert.strictEqual(installer.includes('ping -c 1 git.workyai.cn'), false);
assert.strictEqual(/^\s*clear\s*$/m.test(installer), false);
assert.ok(installer.includes('clear_screen'));
assert.ok(installer.includes('main "$@"'));
assert.ok(installer.includes('/etc/apt/sources.list.d/ubuntu.sources'));
for (const dependency of ['ca-certificates', 'iproute2', 'dnsutils', 'procps']) {
assert.ok(installer.includes(dependency), dependency);
}
assert.ok(installer.includes('download_and_run_setup_script'));
assert.strictEqual(/curl[^\n]+\|\s*(?:ba)?sh/.test(installer), false);
assert.ok(installer.includes('verify_nodejs_installation'));
});
test('install.sh 生成可用且受保护的生产环境配置', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
assert.ok(installer.includes('PUBLIC_BASE_URL=${PUBLIC_BASE_URL_VALUE}'));
assert.ok(installer.includes('ALLOWED_HOSTS=${ALLOWED_HOSTS_VALUE}'));
assert.ok(installer.includes('ALLOWED_ORIGINS=${ALLOWED_ORIGINS_VALUE}'));
assert.ok(installer.includes('chmod 600 "${PROJECT_DIR}/backend/.env"'));
assert.ok(installer.includes('dotenv_quote'));
assert.strictEqual(installer.includes('ALLOWED_ORIGINS_VALUE=""'), false);
assert.strictEqual(installer.includes('type_count++'), false);
assert.ok(installer.includes('/api/health'));
});
test('install.sh 只展示已实现的 SSL 方案且 Certbot 分支可达', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
assert.ok(installer.includes('deploy_certbot || ssl_fallback "1"'));
assert.ok(installer.includes('download_and_run_setup_script "https://get.acme.sh"'));
assert.strictEqual(installer.includes('证书申请功能开发中'), false);
assert.strictEqual(installer.includes('阿里云AccessKey ID'), false);
assert.strictEqual(installer.includes('腾讯云SecretId'), false);
});
console.log('\n========================================'); console.log('\n========================================');
console.log('测试总结'); console.log('测试总结');
console.log('========================================'); console.log('========================================');

View File

@@ -1802,7 +1802,7 @@
</div> </div>
<!-- 导航栏 --> <!-- 导航栏 -->
<div class="navbar" v-if="isLoggedIn"> <nav class="navbar" v-if="isLoggedIn" aria-label="主导航">
<div class="navbar-brand"> <div class="navbar-brand">
<span class="brand-mark"><i class="fas fa-cloud"></i></span> <span class="brand-mark"><i class="fas fa-cloud"></i></span>
<span class="brand-copy"> <span class="brand-copy">
@@ -1812,19 +1812,19 @@
</div> </div>
<div class="navbar-menu"> <div class="navbar-menu">
<div class="nav-section-label">工作台</div> <div class="nav-section-label">工作台</div>
<div v-if="user && !user.is_admin" class="nav-item" :class="{active: currentView === 'files'}" @click="switchView('files')"> <button v-if="user && !user.is_admin" type="button" class="nav-item" :class="{active: currentView === 'files'}" :aria-current="currentView === 'files' ? 'page' : null" @click="switchView('files')">
<i class="fas fa-folder"></i> 我的文件 <i class="fas fa-folder"></i><span class="nav-item-label">文件</span>
</div> </button>
<div v-if="user && !user.is_admin" class="nav-item" :class="{active: currentView === 'shares'}" @click="switchView('shares')"> <button v-if="user && !user.is_admin" type="button" class="nav-item" :class="{active: currentView === 'shares'}" :aria-current="currentView === 'shares' ? 'page' : null" @click="switchView('shares')">
<i class="fas fa-share-alt"></i> 我的分享 <i class="fas fa-share-alt"></i><span class="nav-item-label">分享</span>
</div> </button>
<div v-if="user && user.is_admin" class="nav-item" :class="{active: currentView === 'admin'}" @click="switchView('admin')"> <button v-if="user && user.is_admin" type="button" class="nav-item" :class="{active: currentView === 'admin'}" :aria-current="currentView === 'admin' ? 'page' : null" @click="switchView('admin')">
<i class="fas fa-user-shield"></i> 管理员 <i class="fas fa-user-shield"></i><span class="nav-item-label">管理</span>
</div> </button>
<div class="nav-section-label nav-service-label">服务</div> <div class="nav-section-label nav-service-label">服务</div>
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="switchView('settings')"> <button type="button" class="nav-item" :class="{active: currentView === 'settings'}" :aria-current="currentView === 'settings' ? 'page' : null" @click="switchView('settings')">
<i class="fas fa-cog"></i> 设置 <i class="fas fa-cog"></i><span class="nav-item-label">设置</span>
</div> </button>
<button class="btn btn-secondary navbar-download-btn" :disabled="desktopClientDownloading" @click="downloadDesktopClient"> <button class="btn btn-secondary navbar-download-btn" :disabled="desktopClientDownloading" @click="downloadDesktopClient">
<i :class="desktopClientDownloading ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i> <i :class="desktopClientDownloading ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i>
{{ desktopClientDownloading ? '准备中...' : '下载客户端' }} {{ desktopClientDownloading ? '准备中...' : '下载客户端' }}
@@ -1837,15 +1837,15 @@
<strong>{{ user.username }}</strong> <strong>{{ user.username }}</strong>
</span> </span>
</div> </div>
<button class="btn btn-danger navbar-logout-btn" @click="logout" title="退出登录"> <button class="btn btn-danger navbar-logout-btn" @click="logout" title="退出登录" aria-label="退出登录">
<i class="fas fa-power-off"></i> 退出 <i class="fas fa-power-off"></i><span class="nav-item-label">退出</span>
</button> </button>
</div> </div>
</div> </div>
</div> </nav>
<!-- 文件视图 --> <!-- 文件视图 -->
<div v-if="isLoggedIn && currentView === 'files'" class="main-container"> <div v-if="isLoggedIn && currentView === 'files'" class="main-container files-main-container">
<div class="card files-view-card"> <div class="card files-view-card">
<header class="workspace-page-header files-page-header"> <header class="workspace-page-header files-page-header">
@@ -1909,19 +1909,22 @@
<!-- 文件列表 --> <!-- 文件列表 -->
<div v-else class="files-content-shell"> <div v-else class="files-content-shell">
<div class="files-command-bar"> <div class="files-command-bar">
<div class="files-command-primary"> <div class="files-command-primary files-command-primary-desktop">
<button class="btn btn-primary" @click="$refs.fileUploadInput.click()"> <button class="btn btn-primary" @click="$refs.fileUploadInput.click()">
<i class="fas fa-upload"></i> 上传文件 <i class="fas fa-upload"></i><span class="btn-label">上传文件</span>
</button> </button>
<button class="btn btn-secondary" @click="showCreateFolderModal = true"> <button class="btn btn-secondary" @click="showCreateFolderModal = true">
<i class="fas fa-folder-plus"></i> 新建文件夹 <i class="fas fa-folder-plus"></i><span class="btn-label">新建文件夹</span>
</button> </button>
</div> </div>
<button type="button" class="btn btn-primary mobile-file-create-trigger" @click="openMobileCreateSheet($event)" aria-label="新建或上传">
<i class="fas fa-plus"></i><span>新建</span>
</button>
<div class="view-toggle-group files-view-toggle" aria-label="文件视图切换"> <div class="view-toggle-group files-view-toggle" aria-label="文件视图切换">
<button class="btn" :class="fileViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="fileViewMode = 'grid'" title="大图标视图"> <button class="btn" :class="fileViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="setFileViewMode('grid')" :aria-pressed="fileViewMode === 'grid'" aria-label="大图标视图" title="大图标视图">
<i class="fas fa-th-large"></i><span>大图标</span> <i class="fas fa-th-large"></i><span>大图标</span>
</button> </button>
<button class="btn" :class="fileViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="fileViewMode = 'list'" title="列表视图"> <button class="btn" :class="fileViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="setFileViewMode('list')" :aria-pressed="fileViewMode === 'list'" aria-label="列表视图" title="列表视图">
<i class="fas fa-list"></i><span>列表</span> <i class="fas fa-list"></i><span>列表</span>
</button> </button>
</div> </div>
@@ -1932,14 +1935,15 @@
<div class="files-search-field"> <div class="files-search-field">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
<input <input
type="text" type="search"
class="form-input" class="form-input"
v-model="globalSearchKeyword" v-model="globalSearchKeyword"
@input="triggerGlobalSearch" @input="triggerGlobalSearch"
@focus="globalSearchVisible = !!globalSearchKeyword" @focus="globalSearchVisible = !!globalSearchKeyword"
placeholder="搜索全部目录中的文件或文件夹"> aria-label="搜索文件或文件夹"
placeholder="搜索文件或文件夹">
</div> </div>
<select class="form-input files-search-type" v-model="globalSearchType" @change="runGlobalSearch"> <select class="form-input files-search-type" v-model="globalSearchType" @change="runGlobalSearch" aria-label="搜索类型">
<option value="all">全部</option> <option value="all">全部</option>
<option value="file">仅文件</option> <option value="file">仅文件</option>
<option value="directory">仅文件夹</option> <option value="directory">仅文件夹</option>
@@ -2114,6 +2118,7 @@
@touchstart.stop @touchstart.stop
@touchmove.stop @touchmove.stop
@touchend.stop @touchend.stop
aria-label="更多操作"
title="更多操作"> title="更多操作">
<i class="fas fa-ellipsis-h"></i> <i class="fas fa-ellipsis-h"></i>
</button> </button>
@@ -4474,9 +4479,30 @@
<i class="fas fa-trash"></i> 删除 <i class="fas fa-trash"></i> 删除
</div> </div>
</div> </div>
<!-- 移动端新建面板 -->
<div v-if="showMobileCreateSheet" class="modal-overlay mobile-file-sheet-overlay mobile-create-sheet-overlay" @click="closeMobileCreateSheet">
<section class="mobile-file-sheet mobile-create-sheet" role="dialog" aria-modal="true" aria-labelledby="mobile-create-title" @click.stop>
<div class="mobile-file-sheet-handle"></div>
<div id="mobile-create-title" class="mobile-file-sheet-title mobile-create-sheet-title">新建</div>
<div class="mobile-create-sheet-actions">
<button type="button" class="mobile-create-option" @click="mobileCreateAction('upload')">
<span class="mobile-create-option-icon upload"><i class="fas fa-cloud-upload-alt"></i></span>
<span class="mobile-create-option-copy"><strong>上传文件</strong><small>从手机选择一个或多个文件</small></span>
<i class="fas fa-chevron-right mobile-create-option-arrow"></i>
</button>
<button type="button" class="mobile-create-option" @click="mobileCreateAction('folder')">
<span class="mobile-create-option-icon folder"><i class="fas fa-folder-plus"></i></span>
<span class="mobile-create-option-copy"><strong>新建文件夹</strong><small>创建目录并整理云端文件</small></span>
<i class="fas fa-chevron-right mobile-create-option-arrow"></i>
</button>
</div>
<button type="button" class="mobile-file-sheet-btn cancel mobile-create-cancel" @click="closeMobileCreateSheet">取消</button>
</section>
</div>
<!-- 移动端文件操作面板 --> <!-- 移动端文件操作面板 -->
<div v-if="showMobileFileActionSheet && mobileActionFile" class="modal-overlay mobile-file-sheet-overlay" @click="closeMobileFileActionSheet"> <div v-if="showMobileFileActionSheet && mobileActionFile" class="modal-overlay mobile-file-sheet-overlay" @click="closeMobileFileActionSheet">
<div class="mobile-file-sheet" @click.stop> <section class="mobile-file-sheet" role="dialog" aria-modal="true" aria-label="文件操作" @click.stop>
<div class="mobile-file-sheet-handle"></div> <div class="mobile-file-sheet-handle"></div>
<div class="mobile-file-sheet-title" :title="getFileDisplayName(mobileActionFile)"> <div class="mobile-file-sheet-title" :title="getFileDisplayName(mobileActionFile)">
{{ getFileDisplayName(mobileActionFile) }} {{ getFileDisplayName(mobileActionFile) }}
@@ -4508,7 +4534,7 @@
<i class="fas fa-times"></i> 取消 <i class="fas fa-times"></i> 取消
</button> </button>
</div> </div>
</div> </section>
</div> </div>
<!-- 管理员:编辑用户存储权限模态框 --> <!-- 管理员:编辑用户存储权限模态框 -->
@@ -4915,7 +4941,7 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
@media (min-width: 769px) { @media (min-width: 901px) {
.mobile-file-sheet-overlay { .mobile-file-sheet-overlay {
display: none !important; display: none !important;
} }

View File

@@ -12,6 +12,19 @@ createApp({
return (saved && ['overview', 'settings', 'monitor', 'users'].includes(saved)) ? saved : 'overview'; return (saved && ['overview', 'settings', 'monitor', 'users'].includes(saved)) ? saved : 'overview';
})(); })();
const initialFileViewMode = (() => {
try {
const saved = localStorage.getItem('fileViewMode');
if (saved && ['grid', 'list'].includes(saved)) {
return saved;
}
} catch (error) {
console.warn('[视图] 无法读取文件视图偏好:', error);
}
return window.matchMedia('(max-width: 900px)').matches ? 'list' : 'grid';
})();
return { return {
// API配置 // API配置
// API配置 - 通过nginx代理访问 // API配置 - 通过nginx代理访问
@@ -29,7 +42,7 @@ createApp({
// 视图状态 // 视图状态
currentView: 'files', currentView: 'files',
isLogin: true, isLogin: true,
fileViewMode: 'grid', // 文件显示模式: grid 大图标, list 列表 fileViewMode: initialFileViewMode, // 移动端默认列表视图,并记住用户选择
shareViewMode: 'list', // 分享显示模式: grid 大图标, list 列表 shareViewMode: 'list', // 分享显示模式: grid 大图标, list 列表
shareResourceTab: 'shares', // 分享中心资源标签: shares / directLinks shareResourceTab: 'shares', // 分享中心资源标签: shares / directLinks
settingsTab: 'storage', // 设置中心标签: storage / appearance / security settingsTab: 'storage', // 设置中心标签: storage / appearance / security
@@ -349,6 +362,7 @@ createApp({
contextMenuX: 0, contextMenuX: 0,
contextMenuY: 0, contextMenuY: 0,
contextMenuFile: null, contextMenuFile: null,
showMobileCreateSheet: false,
showMobileFileActionSheet: false, showMobileFileActionSheet: false,
mobileActionFile: null, mobileActionFile: null,
@@ -2237,7 +2251,7 @@ handleDragLeave(e) {
}, },
isMobileViewport() { isMobileViewport() {
return window.matchMedia('(max-width: 768px)').matches; return window.matchMedia('(max-width: 900px)').matches;
}, },
setMobileSheetScrollLock(locked) { setMobileSheetScrollLock(locked) {
@@ -2245,6 +2259,46 @@ handleDragLeave(e) {
document.body.style.overflow = locked ? 'hidden' : ''; document.body.style.overflow = locked ? 'hidden' : '';
}, },
setFileViewMode(mode) {
if (!['grid', 'list'].includes(mode)) return;
this.fileViewMode = mode;
try {
localStorage.setItem('fileViewMode', mode);
} catch (error) {
console.warn('[视图] 无法保存文件视图偏好:', error);
}
},
openMobileCreateSheet(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
this.closeMobileFileActionSheet();
this.showMobileCreateSheet = true;
this.setMobileSheetScrollLock(true);
},
closeMobileCreateSheet() {
this.showMobileCreateSheet = false;
this.setMobileSheetScrollLock(false);
},
mobileCreateAction(action) {
this.closeMobileCreateSheet();
if (action === 'upload') {
this.$nextTick(() => this.$refs.fileUploadInput?.click());
return;
}
if (action === 'folder') {
this.showCreateFolderModal = true;
}
},
openMobileFileActionSheet(file, event) { openMobileFileActionSheet(file, event) {
if (event) { if (event) {
event.preventDefault(); event.preventDefault();
@@ -5176,11 +5230,12 @@ handleDragLeave(e) {
this.isDragging = false; this.isDragging = false;
}); });
// 添加 ESC 键监听(按 ESC 关闭拖拽覆盖层) // 添加 ESC 键监听(按 ESC 关闭当前浮层)
window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.isDragging) { if (e.key !== "Escape") return;
this.isDragging = false; if (this.isDragging) this.isDragging = false;
} if (this.showMobileCreateSheet) this.closeMobileCreateSheet();
if (this.showMobileFileActionSheet) this.closeMobileFileActionSheet();
}); });
// 设置axios响应拦截器处理401错误token过期/失效) // 设置axios响应拦截器处理401错误token过期/失效)

View File

@@ -2692,3 +2692,712 @@ body.enterprise-netdisk .settings-account-panel {
display: none; display: none;
} }
} }
/* Mobile file workspace: bottom navigation and a single-layer command surface. */
body.enterprise-netdisk .mobile-file-create-trigger {
display: none;
}
@keyframes ww-mobile-sheet-enter {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 900px) {
body.enterprise-netdisk {
min-width: 0;
min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden;
background: #fff;
}
body.enterprise-netdisk #app {
min-height: 100vh;
min-height: 100dvh;
}
body.enterprise-netdisk .navbar {
position: fixed !important;
inset: auto 0 0 !important;
z-index: 80;
width: 100% !important;
height: calc(66px + env(safe-area-inset-bottom));
min-height: calc(66px + env(safe-area-inset-bottom));
padding: 5px 8px calc(5px + env(safe-area-inset-bottom));
display: flex;
flex-direction: row;
align-items: center;
gap: 0;
border: 0 !important;
border-top: 1px solid rgba(214, 222, 235, 0.92) !important;
background: rgba(255, 255, 255, 0.97) !important;
box-shadow: 0 -8px 28px rgba(28, 46, 75, 0.09);
backdrop-filter: blur(18px) !important;
}
body.enterprise-netdisk .navbar-brand,
body.enterprise-netdisk .nav-section-label,
body.enterprise-netdisk .navbar-download-btn,
body.enterprise-netdisk .user-info {
display: none !important;
}
body.enterprise-netdisk .navbar-menu {
width: 100%;
min-width: 0;
height: 56px;
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
gap: 4px;
overflow: visible;
}
body.enterprise-netdisk .navbar-account {
min-width: 0;
flex: 1 1 0;
margin: 0;
padding: 0;
display: flex;
border: 0;
}
body.enterprise-netdisk .nav-item,
body.enterprise-netdisk .navbar-logout-btn {
width: auto;
min-width: 0;
min-height: 52px;
flex: 1 1 0;
padding: 5px 4px 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border: 0;
border-radius: 12px;
color: #718099;
background: transparent;
box-shadow: none;
font-size: 10px;
font-weight: 700;
line-height: 1.15;
transform: none;
}
body.enterprise-netdisk .nav-item i,
body.enterprise-netdisk .navbar-logout-btn i {
width: auto;
color: #718099;
font-size: 18px;
line-height: 1;
}
body.enterprise-netdisk .nav-item-label {
display: block;
font-size: 10px;
line-height: 1.15;
}
body.enterprise-netdisk .nav-item.active {
color: var(--ww-blue);
background: var(--ww-blue-soft);
box-shadow: none;
}
body.enterprise-netdisk .nav-item.active i {
color: var(--ww-blue);
}
body.enterprise-netdisk .nav-item:hover,
body.enterprise-netdisk .navbar-logout-btn:hover {
color: #718099;
background: #f5f7fa;
transform: none;
}
body.enterprise-netdisk .navbar-logout-btn,
body.enterprise-netdisk .navbar-logout-btn:hover {
width: 100%;
margin: 0;
color: #9a6570;
border-color: transparent;
}
body.enterprise-netdisk .navbar-logout-btn i,
body.enterprise-netdisk .navbar-logout-btn:hover i {
color: #9a6570;
}
body.enterprise-netdisk .main-container {
margin-left: 0 !important;
padding-bottom: calc(82px + env(safe-area-inset-bottom));
}
body.enterprise-netdisk .files-main-container {
padding: 0 0 calc(66px + env(safe-area-inset-bottom));
}
body.enterprise-netdisk .files-view-card {
height: auto;
min-height: calc(100vh - 66px);
min-height: calc(100dvh - 66px - env(safe-area-inset-bottom));
border: 0;
border-radius: 0;
box-shadow: none;
}
body.enterprise-netdisk .files-page-header {
padding: 16px 16px 14px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 11px;
border-bottom: 1px solid #edf1f6;
background:
radial-gradient(circle at 100% 0%, rgba(40, 120, 255, 0.08), transparent 14rem),
#fff;
}
body.enterprise-netdisk .files-page-header .workspace-kicker,
body.enterprise-netdisk .files-page-header .workspace-page-heading p {
display: none;
}
body.enterprise-netdisk .files-page-header .workspace-page-heading h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
letter-spacing: -0.02em;
}
body.enterprise-netdisk .storage-meter-card {
width: 100%;
min-width: 0;
padding: 10px 12px 9px;
border-color: #e1e9f5;
border-radius: 12px;
background: rgba(247, 250, 255, 0.94);
}
body.enterprise-netdisk .storage-meter-head {
gap: 9px;
}
body.enterprise-netdisk .storage-meter-head strong {
min-width: 0;
flex: 1;
font-size: 11px;
text-align: right;
}
body.enterprise-netdisk .files-storage-badge {
width: auto;
max-width: 58%;
flex: 0 0 auto;
padding: 3px 7px;
justify-content: flex-start;
font-size: 10px;
}
body.enterprise-netdisk .storage-meter-track {
height: 4px;
margin: 7px 0 5px;
}
body.enterprise-netdisk .storage-meter-foot {
font-size: 9px;
}
body.enterprise-netdisk .breadcrumb-bar {
min-height: 40px;
margin: 10px 14px 0;
padding: 4px 6px;
gap: 5px;
overflow-x: auto;
border-radius: 10px;
white-space: nowrap;
scrollbar-width: none;
}
body.enterprise-netdisk .breadcrumb-bar::-webkit-scrollbar {
display: none;
}
body.enterprise-netdisk .breadcrumb-up {
position: sticky;
right: 0;
min-width: 34px;
background: var(--ww-surface-soft);
}
body.enterprise-netdisk .files-content-shell {
min-height: 0;
padding: 12px 14px 18px;
display: flex;
flex-direction: column;
}
body.enterprise-netdisk .files-command-bar {
min-height: 44px;
display: flex;
flex-direction: row;
align-items: center;
gap: 9px;
}
body.enterprise-netdisk .files-command-primary-desktop {
display: none;
}
body.enterprise-netdisk .mobile-file-create-trigger {
min-width: 0;
min-height: 44px;
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 11px;
font-size: 14px;
font-weight: 750;
box-shadow: 0 8px 18px rgba(40, 120, 255, 0.2);
}
body.enterprise-netdisk .files-view-toggle {
width: auto;
flex: 0 0 auto;
display: flex;
grid-template-columns: none;
gap: 2px;
padding: 3px;
border-radius: 11px;
}
body.enterprise-netdisk .files-view-toggle .btn {
width: 38px;
min-width: 38px;
min-height: 36px;
padding: 0;
display: grid;
place-items: center;
border-radius: 8px;
}
body.enterprise-netdisk .files-view-toggle .btn span {
display: none;
}
body.enterprise-netdisk .files-view-toggle .btn i {
margin: 0;
font-size: 14px;
}
body.enterprise-netdisk .files-search-wrap {
margin-top: 10px;
}
body.enterprise-netdisk .files-search-row {
padding: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 86px;
gap: 8px;
border: 0;
border-radius: 0;
background: transparent;
}
body.enterprise-netdisk .files-search-field {
min-width: 0;
min-height: 44px;
grid-column: auto;
padding: 0 12px;
gap: 9px;
border-color: #dce4ef;
border-radius: 11px;
background: #f7f9fc;
}
body.enterprise-netdisk .files-search-field .form-input {
min-height: 40px;
font-size: 13px;
}
body.enterprise-netdisk .files-search-field > i {
font-size: 13px;
}
body.enterprise-netdisk .files-search-type {
width: 86px;
min-height: 44px;
padding: 0 9px;
border-color: #dce4ef;
border-radius: 11px;
background-color: #f7f9fc;
font-size: 12px;
}
body.enterprise-netdisk .files-search-button,
body.enterprise-netdisk .files-search-clear {
display: none;
}
body.enterprise-netdisk .files-search-results {
inset: calc(100% + 7px) 0 auto;
max-height: min(52vh, 360px);
border-radius: 12px;
}
body.enterprise-netdisk .files-search-result {
min-height: 56px;
padding: 10px 12px;
}
body.enterprise-netdisk .files-list-heading {
min-height: 44px;
padding: 0 2px;
}
body.enterprise-netdisk .files-list-heading > div {
gap: 8px;
}
body.enterprise-netdisk .files-list-heading strong {
font-size: 15px;
}
body.enterprise-netdisk .files-list-hint,
body.enterprise-netdisk .files-drop-hint {
display: none;
}
body.enterprise-netdisk .files-container {
min-height: calc(100vh - 322px);
min-height: calc(100dvh - 322px - env(safe-area-inset-bottom));
flex: 1;
padding: 0;
overflow: visible;
border: 0;
border-radius: 0;
background: #fff;
}
body.enterprise-netdisk .files-content-shell .file-list {
overflow: visible;
border-radius: 0;
}
body.enterprise-netdisk .file-list-table thead {
display: none;
}
body.enterprise-netdisk .file-list-table td {
height: 66px;
padding: 8px 2px !important;
border-bottom-color: #edf1f6;
}
body.enterprise-netdisk .file-list-row:last-child td {
border-bottom: 1px solid #edf1f6;
}
body.enterprise-netdisk .file-list-row:active {
background: #f3f7fd;
}
body.enterprise-netdisk .file-col-size,
body.enterprise-netdisk .file-col-time,
body.enterprise-netdisk .file-list-size-cell,
body.enterprise-netdisk .file-list-time-cell {
display: none;
}
body.enterprise-netdisk .file-list-name-cell {
width: auto;
max-width: 0;
}
body.enterprise-netdisk .file-list-name-wrap {
gap: 11px;
}
body.enterprise-netdisk .file-list-name-info {
gap: 3px;
}
body.enterprise-netdisk .file-list-name-text {
font-size: 14px;
font-weight: 700;
}
body.enterprise-netdisk .file-list-name-meta {
display: block;
font-size: 10px;
}
body.enterprise-netdisk .file-list-thumb,
body.enterprise-netdisk .file-list-video-icon {
width: 40px;
height: 40px;
border-radius: 9px;
}
body.enterprise-netdisk .file-list-type-icon {
min-width: 40px;
font-size: 27px;
}
body.enterprise-netdisk .file-list-action-col {
width: 48px;
padding-left: 4px !important;
display: table-cell !important;
}
body.enterprise-netdisk .file-row-mobile-more {
width: 42px;
min-width: 42px;
min-height: 42px;
display: inline-flex !important;
align-items: center;
justify-content: center;
border-radius: 11px;
}
body.enterprise-netdisk .files-content-shell .file-grid {
min-height: 0;
padding: 2px 0 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: auto;
gap: 8px;
}
body.enterprise-netdisk .files-content-shell .file-grid-item {
width: auto;
min-height: 124px;
padding: 12px 8px 10px;
justify-content: center;
border: 1px solid #e7edf5;
border-radius: 12px;
background: #fbfcfe;
}
body.enterprise-netdisk .file-icon {
min-height: 56px;
margin-bottom: 4px;
}
body.enterprise-netdisk .file-grid-type-icon {
font-size: 45px;
}
body.enterprise-netdisk .file-thumbnail,
body.enterprise-netdisk .file-grid-video-icon {
width: 58px;
height: 54px;
}
body.enterprise-netdisk .files-content-shell .file-name {
font-size: 12px;
}
body.enterprise-netdisk .files-content-shell .file-size {
margin-top: 1px;
font-size: 10px;
}
body.enterprise-netdisk .files-empty-state,
body.enterprise-netdisk .files-content-shell .empty-hint {
min-height: 260px;
padding: 42px 12px;
}
body.enterprise-netdisk .files-empty-state > i {
font-size: 34px;
}
body.enterprise-netdisk .files-empty-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
body.enterprise-netdisk .files-empty-actions .btn {
min-width: 0;
}
body.enterprise-netdisk .mobile-file-sheet-overlay {
z-index: 1100;
padding: 0;
align-items: flex-end;
background: rgba(18, 31, 50, 0.38);
backdrop-filter: blur(2px);
}
body.enterprise-netdisk .mobile-file-sheet {
width: 100%;
max-width: 560px;
max-height: 82vh;
padding: 8px 14px calc(14px + env(safe-area-inset-bottom));
overflow-y: auto;
border: 0;
border-radius: 20px 20px 0 0;
color: var(--ww-ink);
background: #fff;
box-shadow: 0 -18px 50px rgba(21, 38, 65, 0.18);
animation: ww-mobile-sheet-enter 180ms ease-out both;
}
body.enterprise-netdisk .mobile-file-sheet-handle {
width: 38px;
height: 4px;
margin: 2px auto 10px;
background: #d7deea;
}
body.enterprise-netdisk .mobile-file-sheet-title {
margin-bottom: 10px;
color: var(--ww-ink);
font-size: 15px;
font-weight: 750;
}
body.enterprise-netdisk .mobile-file-sheet-actions {
gap: 7px;
}
body.enterprise-netdisk .mobile-file-sheet-btn {
min-height: 48px;
border-color: var(--ww-line);
color: var(--ww-text);
background: var(--ww-surface-soft);
}
body.enterprise-netdisk .mobile-file-sheet-btn.cancel {
margin-top: 4px;
color: var(--ww-text);
background: #fff;
}
body.enterprise-netdisk .mobile-create-sheet-title {
margin-bottom: 5px;
text-align: left;
}
body.enterprise-netdisk .mobile-create-sheet-actions {
display: grid;
gap: 4px;
}
body.enterprise-netdisk .mobile-create-option {
width: 100%;
min-height: 70px;
padding: 9px 4px;
display: flex;
align-items: center;
gap: 12px;
border: 0;
border-bottom: 1px solid #edf1f6;
color: var(--ww-ink);
text-align: left;
background: transparent;
}
body.enterprise-netdisk .mobile-create-option:active {
background: #f4f8ff;
}
body.enterprise-netdisk .mobile-create-option-icon {
width: 42px;
height: 42px;
flex: 0 0 42px;
display: grid;
place-items: center;
border-radius: 12px;
font-size: 17px;
}
body.enterprise-netdisk .mobile-create-option-icon.upload {
color: #1763e6;
background: #eaf2ff;
}
body.enterprise-netdisk .mobile-create-option-icon.folder {
color: #aa6d05;
background: #fff4da;
}
body.enterprise-netdisk .mobile-create-option-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
body.enterprise-netdisk .mobile-create-option-copy strong {
font-size: 14px;
}
body.enterprise-netdisk .mobile-create-option-copy small {
overflow: hidden;
color: var(--ww-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
body.enterprise-netdisk .mobile-create-option-arrow {
color: #a5b0c1;
font-size: 11px;
}
body.enterprise-netdisk .mobile-create-cancel {
width: 100%;
min-height: 46px;
margin-top: 10px;
border: 0;
border-radius: 11px;
font-weight: 700;
}
body.enterprise-netdisk .upload-progress-panel {
right: 12px;
bottom: calc(78px + env(safe-area-inset-bottom));
width: calc(100vw - 24px);
max-width: 360px;
}
}
@media (max-width: 380px) {
body.enterprise-netdisk .files-page-header {
padding-inline: 13px;
}
body.enterprise-netdisk .files-content-shell {
padding-inline: 11px;
}
body.enterprise-netdisk .files-search-row {
grid-template-columns: minmax(0, 1fr) 78px;
gap: 6px;
}
body.enterprise-netdisk .files-search-type {
width: 78px;
padding-inline: 6px;
}
body.enterprise-netdisk .nav-item,
body.enterprise-netdisk .navbar-logout-btn {
padding-inline: 2px;
}
}

View File

@@ -27,7 +27,7 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
PURPLE='\033[0;35m' PURPLE='\033[0;35m'
CYAN='\033[0;36m' CYAN='\033[0;36m'
WHITE='\033[1;37m' GRAY='\033[0;37m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# 全局变量 # 全局变量
@@ -50,7 +50,7 @@ BACKEND_PORT="40001"
################################################################################ ################################################################################
print_banner() { print_banner() {
clear clear_screen
echo -e "${CYAN}" echo -e "${CYAN}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -82,6 +82,129 @@ print_info() {
echo -e "${CYAN} $1${NC}" echo -e "${CYAN} $1${NC}"
} }
# 非交互环境没有 TERM 时clear 会在 set -e 下直接终止安装。
clear_screen() {
if [[ -t 1 ]] && [[ -n "${TERM:-}" ]] && command -v clear &> /dev/null; then
clear 2>/dev/null || true
fi
}
has_controlling_terminal() {
if [[ -t 0 ]]; then
return 0
fi
if [[ -e /dev/tty ]] && (exec 3</dev/tty) 2>/dev/null; then
return 0
fi
return 1
}
check_network_connectivity() {
if command -v timeout &> /dev/null; then
timeout 10 bash -c 'exec 3<>/dev/tcp/git.workyai.cn/443' 2>/dev/null
return $?
fi
if command -v curl &> /dev/null; then
curl -fsS --connect-timeout 5 --max-time 10 -o /dev/null https://git.workyai.cn/
return $?
fi
if command -v wget &> /dev/null; then
wget -q --spider --timeout=10 https://git.workyai.cn/
return $?
fi
return 2
}
is_ip_address() {
local value="$1"
local part
local -a ipv4_parts
if [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
IFS='.' read -r -a ipv4_parts <<< "$value"
for part in "${ipv4_parts[@]}"; do
if ((10#$part > 255)); then
return 1
fi
done
return 0
fi
if [[ "$value" == *:* ]] && [[ "$value" =~ ^[0-9a-fA-F:]+$ ]]; then
return 0
fi
return 1
}
get_public_ip() {
local endpoint
local ip=""
for endpoint in "https://api.ipify.org" "https://ifconfig.me/ip" "https://icanhazip.com"; do
if command -v curl &> /dev/null; then
ip=$(curl -4fsS --connect-timeout 3 --max-time 5 "$endpoint" 2>/dev/null || true)
elif command -v wget &> /dev/null; then
ip=$(wget -qO- --timeout=5 "$endpoint" 2>/dev/null || true)
else
break
fi
ip=${ip//$'\r'/}
ip=${ip//$'\n'/}
if is_ip_address "$ip"; then
printf '%s' "$ip"
return 0
fi
done
return 1
}
format_url_host() {
local host="$1"
if [[ "$host" == *:* ]] && [[ "$host" != \[*\] ]]; then
printf '[%s]' "$host"
else
printf '%s' "$host"
fi
}
dotenv_quote() {
local value="$1"
value=${value//\\/\\\\}
value=${value//\"/\\\"}
value=${value//$'\r'/\\r}
value=${value//$'\n'/\\n}
printf '"%s"' "$value"
}
download_and_run_setup_script() {
local url="$1"
shift
local setup_script
setup_script=$(mktemp)
if ! curl -fsSL --connect-timeout 10 --max-time 120 "$url" -o "$setup_script"; then
rm -f "$setup_script"
print_error "下载远程安装脚本失败: $url"
return 1
fi
if ! bash "$setup_script" "$@"; then
rm -f "$setup_script"
print_error "执行远程安装脚本失败: $url"
return 1
fi
rm -f "$setup_script"
}
# 检测操作系统 # 检测操作系统
detect_os() { detect_os() {
if [[ -f /etc/os-release ]]; then if [[ -f /etc/os-release ]]; then
@@ -190,18 +313,25 @@ system_check() {
# 检测操作系统 # 检测操作系统
detect_os detect_os
print_success "操作系统: $OS $OS_VERSION" print_success "操作系统: ${OS_NAME:-$OS} $OS_VERSION"
# 检测架构 # 检测架构
detect_arch detect_arch
print_success "系统架构: $ARCH" print_success "系统架构: $ARCH"
# 检测内存 # 检测内存;最小化系统可能尚未安装 procps/free。
if command -v free &> /dev/null; then
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}') TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
if [[ $TOTAL_MEM -lt 512 ]]; then else
TOTAL_MEM=$(awk '/^MemTotal:/{printf "%d", $2 / 1024}' /proc/meminfo 2>/dev/null || true)
fi
TOTAL_MEM=${TOTAL_MEM:-0}
if [[ $TOTAL_MEM -eq 0 ]]; then
print_warning "无法检测系统内存"
elif [[ $TOTAL_MEM -lt 512 ]]; then
print_warning "内存不足512MB可能影响性能" print_warning "内存不足512MB可能影响性能"
else else
print_success "可用内存: ${TOTAL_MEM}MB" print_success "系统内存: ${TOTAL_MEM}MB"
fi fi
# 检测磁盘空间 # 检测磁盘空间
@@ -212,17 +342,22 @@ system_check() {
print_success "可用磁盘: ${DISK_AVAIL}MB" print_success "可用磁盘: ${DISK_AVAIL}MB"
fi fi
# 检测网络 # 直接检查实际需要访问的 Git HTTPS 服务,不依赖最小系统通常缺失的 ping。
if ping -c 1 git.workyai.cn &> /dev/null; then if check_network_connectivity; then
print_success "网络连接正常" print_success "网络连接正常"
else else
print_error "无法连接到网络" network_status=$?
if [[ $network_status -eq 2 ]]; then
print_warning "缺少网络探测工具,将由软件包安装与 Git 下载继续验证网络"
else
print_error "无法连接到 git.workyai.cn:443"
exit 1 exit 1
fi fi
fi
# 检测公网IP # 检测公网IP
PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "未知") PUBLIC_IP=$(get_public_ip || true)
print_info "公网IP: $PUBLIC_IP" print_info "公网IP: ${PUBLIC_IP:-未知}"
echo "" echo ""
} }
@@ -266,33 +401,67 @@ configure_aliyun_mirror() {
case $OS in case $OS in
ubuntu) ubuntu)
# 备份原有源 local ubuntu_codename="${VERSION_CODENAME:-}"
if [[ ! -f /etc/apt/sources.list.bak ]]; then if [[ -z "$ubuntu_codename" ]] && command -v lsb_release &> /dev/null; then
cp /etc/apt/sources.list /etc/apt/sources.list.bak ubuntu_codename=$(lsb_release -cs)
fi
if [[ -z "$ubuntu_codename" ]]; then
print_error "无法确定 Ubuntu 版本代号,未修改软件源"
return 1
fi fi
# 配置Ubuntu阿里云源 # Ubuntu 24.04+ 默认使用 deb822 格式,旧版本仍使用 sources.list。
if [[ -f /etc/apt/sources.list.d/ubuntu.sources ]]; then
if [[ ! -f /etc/apt/sources.list.d/ubuntu.sources.bak ]]; then
cp /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources.bak
fi
sed -Ei \
-e 's|https?://([^/]+\.)?archive\.ubuntu\.com/ubuntu/?|https://mirrors.aliyun.com/ubuntu/|g' \
-e 's|https?://security\.ubuntu\.com/ubuntu/?|https://mirrors.aliyun.com/ubuntu/|g' \
-e 's|https?://ports\.ubuntu\.com/ubuntu-ports/?|https://mirrors.aliyun.com/ubuntu-ports/|g' \
/etc/apt/sources.list.d/ubuntu.sources
else
if [[ -f /etc/apt/sources.list ]] && [[ ! -f /etc/apt/sources.list.bak ]]; then
cp /etc/apt/sources.list /etc/apt/sources.list.bak
fi
cat > /etc/apt/sources.list << EOF cat > /etc/apt/sources.list << EOF
deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs) main restricted universe multiverse deb https://mirrors.aliyun.com/ubuntu/ ${ubuntu_codename} main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-updates main restricted universe multiverse deb https://mirrors.aliyun.com/ubuntu/ ${ubuntu_codename}-updates main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-backports main restricted universe multiverse deb https://mirrors.aliyun.com/ubuntu/ ${ubuntu_codename}-backports main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-security main restricted universe multiverse deb https://mirrors.aliyun.com/ubuntu/ ${ubuntu_codename}-security main restricted universe multiverse
EOF EOF
fi
print_success "阿里云源配置完成" print_success "阿里云源配置完成"
;; ;;
debian) debian)
# 备份原有源 local debian_codename="${VERSION_CODENAME:-}"
if [[ ! -f /etc/apt/sources.list.bak ]]; then if [[ -z "$debian_codename" ]] && command -v lsb_release &> /dev/null; then
cp /etc/apt/sources.list /etc/apt/sources.list.bak debian_codename=$(lsb_release -cs)
fi
if [[ -z "$debian_codename" ]]; then
print_error "无法确定 Debian 版本代号,未修改软件源"
return 1
fi fi
# 配置Debian阿里云源 if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
if [[ ! -f /etc/apt/sources.list.d/debian.sources.bak ]]; then
cp /etc/apt/sources.list.d/debian.sources /etc/apt/sources.list.d/debian.sources.bak
fi
sed -Ei \
-e 's|https?://deb\.debian\.org/debian/?|https://mirrors.aliyun.com/debian/|g' \
-e 's|https?://(security\.debian\.org|deb\.debian\.org)/debian-security/?|https://mirrors.aliyun.com/debian-security/|g' \
/etc/apt/sources.list.d/debian.sources
else
if [[ -f /etc/apt/sources.list ]] && [[ ! -f /etc/apt/sources.list.bak ]]; then
cp /etc/apt/sources.list /etc/apt/sources.list.bak
fi
cat > /etc/apt/sources.list << EOF cat > /etc/apt/sources.list << EOF
deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs) main contrib non-free non-free-firmware deb https://mirrors.aliyun.com/debian/ ${debian_codename} main contrib non-free non-free-firmware
deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs)-updates main contrib non-free non-free-firmware deb https://mirrors.aliyun.com/debian/ ${debian_codename}-updates main contrib non-free non-free-firmware
deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs)-backports main contrib non-free non-free-firmware deb https://mirrors.aliyun.com/debian/ ${debian_codename}-backports main contrib non-free non-free-firmware
deb http://mirrors.aliyun.com/debian-security $(lsb_release -cs)-security main contrib non-free non-free-firmware deb https://mirrors.aliyun.com/debian-security/ ${debian_codename}-security main contrib non-free non-free-firmware
EOF EOF
fi
print_success "阿里云源配置完成" print_success "阿里云源配置完成"
;; ;;
centos) centos)
@@ -497,22 +666,31 @@ install_dependencies() {
case $PKG_MANAGER in case $PKG_MANAGER in
apt) apt)
apt-get update apt-get update
apt-get install -y curl wget git unzip lsb-release build-essential python3 apt-get install -y \
ca-certificates curl wget git unzip lsb-release gnupg openssl \
build-essential software-properties-common python3 \
iproute2 iputils-ping dnsutils procps cron
install_nodejs_apt install_nodejs_apt
install_nginx_apt install_nginx_apt
;; ;;
yum) yum)
yum install -y curl wget git unzip redhat-lsb-core gcc-c++ make python3 yum install -y \
ca-certificates curl wget git unzip redhat-lsb-core openssl \
gcc-c++ make python3 iproute bind-utils procps-ng cronie
install_nodejs_yum install_nodejs_yum
install_nginx_yum install_nginx_yum
;; ;;
dnf) dnf)
dnf install -y curl wget git unzip redhat-lsb-core gcc-c++ make python3 dnf install -y \
ca-certificates curl wget git unzip redhat-lsb-core openssl \
gcc-c++ make python3 iproute bind-utils procps-ng cronie
install_nodejs_dnf install_nodejs_dnf
install_nginx_dnf install_nginx_dnf
;; ;;
zypper) zypper)
zypper install -y curl wget git unzip lsb-release gcc-c++ make python3 zypper install -y \
ca-certificates curl wget git unzip lsb-release openssl \
gcc-c++ make python3 iproute2 bind-utils procps cron
install_nodejs_zypper install_nodejs_zypper
install_nginx_zypper install_nginx_zypper
;; ;;
@@ -534,6 +712,14 @@ node_version_supported() {
node -e "const [major, minor] = process.versions.node.split('.').map(Number); process.exit((major > 20 && major < 25) || (major === 20 && minor >= 19) ? 0 : 1)" 2>/dev/null node -e "const [major, minor] = process.versions.node.split('.').map(Number); process.exit((major > 20 && major < 25) || (major === 20 && minor >= 19) ? 0 : 1)" 2>/dev/null
} }
verify_nodejs_installation() {
if ! command -v node &> /dev/null || ! node_version_supported; then
print_error "Node.js 安装结果不满足版本要求(需要 ${NODE_MIN_VERSION} 至 24.x"
return 1
fi
print_success "Node.js 安装完成: $(node -v)"
}
install_nodejs_apt() { install_nodejs_apt() {
if command -v node &> /dev/null; then if command -v node &> /dev/null; then
if node_version_supported; then if node_version_supported; then
@@ -544,9 +730,9 @@ install_nodejs_apt() {
fi fi
print_info "正在安装 Node.js ${NODE_VERSION}.x..." print_info "正在安装 Node.js ${NODE_VERSION}.x..."
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - download_and_run_setup_script "https://deb.nodesource.com/setup_${NODE_VERSION}.x"
apt-get install -y nodejs apt-get install -y nodejs
print_success "Node.js 安装完成: $(node -v)" verify_nodejs_installation
} }
install_nodejs_yum() { install_nodejs_yum() {
@@ -559,9 +745,9 @@ install_nodejs_yum() {
fi fi
print_info "正在安装 Node.js ${NODE_VERSION}.x..." print_info "正在安装 Node.js ${NODE_VERSION}.x..."
curl -fsSL https://rpm.nodesource.com/setup_${NODE_VERSION}.x | bash - download_and_run_setup_script "https://rpm.nodesource.com/setup_${NODE_VERSION}.x"
yum install -y nodejs yum install -y nodejs
print_success "Node.js 安装完成: $(node -v)" verify_nodejs_installation
} }
install_nginx_apt() { install_nginx_apt() {
@@ -598,9 +784,9 @@ install_nodejs_dnf() {
fi fi
print_info "正在安装 Node.js ${NODE_VERSION}.x..." print_info "正在安装 Node.js ${NODE_VERSION}.x..."
curl -fsSL https://rpm.nodesource.com/setup_${NODE_VERSION}.x | bash - download_and_run_setup_script "https://rpm.nodesource.com/setup_${NODE_VERSION}.x"
dnf install -y nodejs dnf install -y nodejs
print_success "Node.js 安装完成: $(node -v)" verify_nodejs_installation
} }
install_nginx_dnf() { install_nginx_dnf() {
@@ -627,7 +813,7 @@ install_nodejs_zypper() {
print_info "正在安装 Node.js ${NODE_VERSION}.x..." print_info "正在安装 Node.js ${NODE_VERSION}.x..."
# openSUSE使用官方仓库的Node.js # openSUSE使用官方仓库的Node.js
zypper install -y nodejs${NODE_VERSION} zypper install -y nodejs${NODE_VERSION}
print_success "Node.js 安装完成: $(node -v)" verify_nodejs_installation
} }
install_nginx_zypper() { install_nginx_zypper() {
@@ -645,12 +831,16 @@ install_nginx_zypper() {
install_pm2() { install_pm2() {
if command -v pm2 &> /dev/null; then if command -v pm2 &> /dev/null; then
print_success "PM2 已安装: $(pm2 -v)" print_success "PM2 已安装: $(pm2 -v)"
return else
fi
print_info "正在安装 PM2..." print_info "正在安装 PM2..."
npm install -g pm2 npm install -g pm2
pm2 startup fi
if command -v systemctl &> /dev/null && [[ -d /run/systemd/system ]]; then
pm2 startup systemd -u root --hp /root
else
print_warning "未检测到 systemd无法自动配置 PM2 开机启动"
fi
print_success "PM2 安装完成" print_success "PM2 安装完成"
} }
@@ -1041,8 +1231,18 @@ choose_access_mode() {
;; ;;
2) 2)
USE_DOMAIN=false USE_DOMAIN=false
PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "未知") PUBLIC_IP=$(get_public_ip || true)
print_info "将使用 IP 模式访问: http://${PUBLIC_IP}" if [[ -z "$PUBLIC_IP" ]]; then
while true; do
read -p "无法自动获取公网 IP请手动输入服务器 IP: " PUBLIC_IP < /dev/tty
if is_ip_address "$PUBLIC_IP"; then
break
fi
print_error "IP 地址格式不正确"
done
fi
PUBLIC_URL_HOST=$(format_url_host "$PUBLIC_IP")
print_info "将使用 IP 模式访问: http://${PUBLIC_URL_HOST}"
echo "" echo ""
break break
;; ;;
@@ -1071,7 +1271,7 @@ configure_domain() {
# 验证域名解析 # 验证域名解析
print_info "正在验证域名解析..." print_info "正在验证域名解析..."
DOMAIN_IP=$(dig +short "$DOMAIN" | tail -n1) DOMAIN_IP=$(dig +short "$DOMAIN" | tail -n1)
PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com) PUBLIC_IP=$(get_public_ip || true)
if [[ "$DOMAIN_IP" == "$PUBLIC_IP" ]]; then if [[ "$DOMAIN_IP" == "$PUBLIC_IP" ]]; then
print_success "域名已正确解析到当前服务器IP" print_success "域名已正确解析到当前服务器IP"
@@ -1152,53 +1352,47 @@ choose_ssl_method() {
print_step "选择SSL证书部署方式" print_step "选择SSL证书部署方式"
echo "" echo ""
echo -e "${YELLOW}【推荐方案】${NC}" echo -e "${YELLOW}【推荐方案】${NC}"
echo -e "${GREEN}[1]${NC} acme.sh + Let's Encrypt" echo -e "${GREEN}[1]${NC} Certbot + Let's Encrypt"
echo " - 纯Shell脚本轻量级稳定" echo " - 官方客户端,自动续期"
echo " - 自动续期,无需手动操作" echo " - 自动续期,无需手动操作"
echo "" echo ""
echo -e "${YELLOW}【备选方案】${NC}" echo -e "${YELLOW}【备选方案】${NC}"
echo -e "${GREEN}[2]${NC} acme.sh + ZeroSSL" echo -e "${GREEN}[2]${NC} acme.sh + Let's Encrypt"
echo " - 纯Shell脚本轻量级稳定"
echo -e "${GREEN}[3]${NC} acme.sh + ZeroSSL"
echo " - Let's Encrypt 的免费替代品" echo " - Let's Encrypt 的免费替代品"
echo -e "${GREEN}[3]${NC} acme.sh + Buypass" echo -e "${GREEN}[4]${NC} acme.sh + Buypass"
echo " - 挪威免费CA有效期180天" echo " - 挪威免费CA有效期180天"
echo "" echo ""
echo -e "${YELLOW}【云服务商证书】${NC}"
echo -e "${GREEN}[4]${NC} 阿里云免费证书 (需提供AccessKey)"
echo -e "${GREEN}[5]${NC} 腾讯云免费证书 (需提供SecretKey)"
echo ""
echo -e "${YELLOW}【其他选项】${NC}" echo -e "${YELLOW}【其他选项】${NC}"
echo -e "${GREEN}[6]${NC} 使用已有证书 (手动上传)" echo -e "${GREEN}[5]${NC} 使用已有证书 (手动上传)"
echo -e "${GREEN}[7]${NC} 暂不配置HTTPS (可后续配置)" echo -e "${GREEN}[6]${NC} 暂不配置HTTPS (可后续配置)"
echo "" echo ""
while true; do while true; do
read -p "请输入选项 [1-7]: " ssl_choice < /dev/tty read -p "请输入选项 [1-6]: " ssl_choice < /dev/tty
case $ssl_choice in case $ssl_choice in
1) 1)
SSL_METHOD="2" # acme.sh + Let's Encrypt SSL_METHOD="1" # Certbot + Let's Encrypt
break break
;; ;;
2) 2)
SSL_METHOD="3" # acme.sh + ZeroSSL SSL_METHOD="2" # acme.sh + Let's Encrypt
break break
;; ;;
3) 3)
SSL_METHOD="5" # acme.sh + Buypass SSL_METHOD="3" # acme.sh + ZeroSSL
break break
;; ;;
4) 4)
SSL_METHOD="4" # 阿里云 SSL_METHOD="5" # acme.sh + Buypass
break break
;; ;;
5) 5)
SSL_METHOD="6" # 腾讯云
break
;;
6)
SSL_METHOD="7" # 手动上传 SSL_METHOD="7" # 手动上传
break break
;; ;;
7) 6)
SSL_METHOD="8" # 不配置HTTPS SSL_METHOD="8" # 不配置HTTPS
break break
;; ;;
@@ -1216,21 +1410,18 @@ deploy_ssl() {
fi fi
case $SSL_METHOD in case $SSL_METHOD in
1)
deploy_certbot || ssl_fallback "1"
;;
2) 2)
deploy_acme_letsencrypt || ssl_fallback "2" deploy_acme_letsencrypt || ssl_fallback "2"
;; ;;
3) 3)
deploy_acme_zerossl || ssl_fallback "3" deploy_acme_zerossl || ssl_fallback "3"
;; ;;
4)
deploy_aliyun_ssl || ssl_fallback "4"
;;
5) 5)
deploy_acme_buypass || ssl_fallback "5" deploy_acme_buypass || ssl_fallback "5"
;; ;;
6)
deploy_tencent_ssl || ssl_fallback "6"
;;
7) 7)
deploy_manual_ssl deploy_manual_ssl
;; ;;
@@ -1252,6 +1443,12 @@ ssl_fallback() {
# 动态显示可用选项(排除已失败的) # 动态显示可用选项(排除已失败的)
local available_options=() local available_options=()
# 方案1: Certbot + Let's Encrypt
if [[ "$failed_method" != "1" ]]; then
echo -e "${GREEN}[1]${NC} Certbot + Let's Encrypt"
available_options+=("1")
fi
# 方案2: acme.sh + Let's Encrypt # 方案2: acme.sh + Let's Encrypt
if [[ "$failed_method" != "2" ]]; then if [[ "$failed_method" != "2" ]]; then
echo -e "${GREEN}[2]${NC} acme.sh + Let's Encrypt" echo -e "${GREEN}[2]${NC} acme.sh + Let's Encrypt"
@@ -1282,12 +1479,17 @@ ssl_fallback() {
read -p "请选择备选方案: " retry_choice < /dev/tty read -p "请选择备选方案: " retry_choice < /dev/tty
# 检查输入是否在可用选项中 # 检查输入是否在可用选项中
if [[ ! " ${available_options[@]} " =~ " ${retry_choice} " ]]; then if [[ " ${available_options[*]} " != *" ${retry_choice} "* ]]; then
print_error "无效选项或该方案已失败" print_error "无效选项或该方案已失败"
continue continue
fi fi
case $retry_choice in case $retry_choice in
1)
deploy_certbot && return 0
ssl_fallback "1"
return $?
;;
2) 2)
deploy_acme_letsencrypt && return 0 deploy_acme_letsencrypt && return 0
# 如果再次失败继续调用fallback但排除方案2 # 如果再次失败继续调用fallback但排除方案2
@@ -1334,25 +1536,17 @@ deploy_certbot() {
print_success "Certbot (snap版) 安装成功" print_success "Certbot (snap版) 安装成功"
else else
print_warning "snap安装失败尝试apt安装..." print_warning "snap安装失败尝试apt安装..."
# 修复urllib3依赖问题
apt-get remove -y python3-urllib3 2>/dev/null || true
apt-get install -y certbot python3-certbot-nginx apt-get install -y certbot python3-certbot-nginx
fi fi
else else
print_info "snap不可用使用apt安装..." print_info "snap不可用使用apt安装..."
# 修复urllib3依赖问题
apt-get remove -y python3-urllib3 2>/dev/null || true
apt-get install -y certbot python3-certbot-nginx apt-get install -y certbot python3-certbot-nginx
fi fi
;; ;;
yum) yum)
# 修复urllib3依赖问题
yum remove -y python3-urllib3 2>/dev/null || true
yum install -y certbot python3-certbot-nginx yum install -y certbot python3-certbot-nginx
;; ;;
dnf) dnf)
# 修复urllib3依赖问题
dnf remove -y python3-urllib3 2>/dev/null || true
dnf install -y certbot python3-certbot-nginx dnf install -y certbot python3-certbot-nginx
;; ;;
zypper) zypper)
@@ -1374,11 +1568,9 @@ deploy_certbot() {
print_warning "检测到Certbot依赖问题正在修复..." print_warning "检测到Certbot依赖问题正在修复..."
case $PKG_MANAGER in case $PKG_MANAGER in
apt) apt)
apt-get remove -y python3-urllib3 2>/dev/null || true
apt-get install --reinstall -y certbot python3-certbot-nginx apt-get install --reinstall -y certbot python3-certbot-nginx
;; ;;
yum|dnf) yum|dnf)
$PKG_MANAGER remove -y python3-urllib3 2>/dev/null || true
$PKG_MANAGER reinstall -y certbot python3-certbot-nginx $PKG_MANAGER reinstall -y certbot python3-certbot-nginx
;; ;;
esac esac
@@ -1445,10 +1637,9 @@ deploy_acme_letsencrypt() {
print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)"
# 使用官方安装方法直接通过curl管道执行
print_info "正在下载并安装..." print_info "正在下载并安装..."
if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then if download_and_run_setup_script "https://get.acme.sh" "email=admin@example.com"; then
install_result=$? install_result=$?
print_info "安装脚本执行完成,退出码: $install_result" print_info "安装脚本执行完成,退出码: $install_result"
else else
@@ -1456,9 +1647,6 @@ deploy_acme_letsencrypt() {
print_error "安装脚本执行失败,退出码: $install_result" print_error "安装脚本执行失败,退出码: $install_result"
fi fi
# 重新加载环境变量
source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true
# 等待文件系统同步 # 等待文件系统同步
print_info "等待安装完成..." print_info "等待安装完成..."
sleep 3 sleep 3
@@ -1478,7 +1666,7 @@ deploy_acme_letsencrypt() {
echo "" echo ""
if [[ -d ~/.acme.sh ]]; then if [[ -d ~/.acme.sh ]]; then
print_info "~/.acme.sh 目录内容:" print_info "${HOME}/.acme.sh 目录内容:"
ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录"
echo "" echo ""
fi fi
@@ -1626,10 +1814,9 @@ deploy_acme_zerossl() {
print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)"
# 使用官方安装方法直接通过curl管道执行
print_info "正在下载并安装..." print_info "正在下载并安装..."
if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then if download_and_run_setup_script "https://get.acme.sh" "email=admin@example.com"; then
install_result=$? install_result=$?
print_info "安装脚本执行完成,退出码: $install_result" print_info "安装脚本执行完成,退出码: $install_result"
else else
@@ -1637,9 +1824,6 @@ deploy_acme_zerossl() {
print_error "安装脚本执行失败,退出码: $install_result" print_error "安装脚本执行失败,退出码: $install_result"
fi fi
# 重新加载环境变量
source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true
# 等待文件系统同步 # 等待文件系统同步
print_info "等待安装完成..." print_info "等待安装完成..."
sleep 3 sleep 3
@@ -1657,7 +1841,7 @@ deploy_acme_zerossl() {
echo "" echo ""
if [[ -d ~/.acme.sh ]]; then if [[ -d ~/.acme.sh ]]; then
print_info "~/.acme.sh 目录内容:" print_info "${HOME}/.acme.sh 目录内容:"
ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录"
echo "" echo ""
fi fi
@@ -1785,10 +1969,9 @@ deploy_acme_buypass() {
print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)"
# 使用官方安装方法直接通过curl管道执行
print_info "正在下载并安装..." print_info "正在下载并安装..."
if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then if download_and_run_setup_script "https://get.acme.sh" "email=admin@example.com"; then
install_result=$? install_result=$?
print_info "安装脚本执行完成,退出码: $install_result" print_info "安装脚本执行完成,退出码: $install_result"
else else
@@ -1796,9 +1979,6 @@ deploy_acme_buypass() {
print_error "安装脚本执行失败,退出码: $install_result" print_error "安装脚本执行失败,退出码: $install_result"
fi fi
# 重新加载环境变量
source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true
# 等待文件系统同步 # 等待文件系统同步
print_info "等待安装完成..." print_info "等待安装完成..."
sleep 3 sleep 3
@@ -1816,7 +1996,7 @@ deploy_acme_buypass() {
echo "" echo ""
if [[ -d ~/.acme.sh ]]; then if [[ -d ~/.acme.sh ]]; then
print_info "~/.acme.sh 目录内容:" print_info "${HOME}/.acme.sh 目录内容:"
ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录"
echo "" echo ""
fi fi
@@ -1928,34 +2108,6 @@ deploy_acme_buypass() {
fi fi
} }
deploy_aliyun_ssl() {
print_step "使用阿里云免费证书..."
print_warning "此功能需要您提供阿里云AccessKey"
echo ""
read -p "阿里云AccessKey ID: " ALIYUN_ACCESS_KEY_ID < /dev/tty
read -p "阿里云AccessKey Secret: " ALIYUN_ACCESS_KEY_SECRET < /dev/tty
# 这里需要调用阿里云API申请证书
# 暂时返回失败,提示用户使用其他方案
print_error "阿里云证书申请功能开发中,请选择其他方案"
return 1
}
deploy_tencent_ssl() {
print_step "使用腾讯云免费证书..."
print_warning "此功能需要您提供腾讯云SecretKey"
echo ""
read -p "腾讯云SecretId: " TENCENT_SECRET_ID < /dev/tty
read -p "腾讯云SecretKey: " TENCENT_SECRET_KEY < /dev/tty
# 这里需要调用腾讯云API申请证书
# 暂时返回失败,提示用户使用其他方案
print_error "腾讯云证书申请功能开发中,请选择其他方案"
return 1
}
deploy_manual_ssl() { deploy_manual_ssl() {
print_step "使用已有证书..." print_step "使用已有证书..."
@@ -2063,9 +2215,9 @@ configure_admin_account() {
continue continue
fi fi
local type_count=0 local type_count=0
[[ "$ADMIN_PASSWORD" =~ [A-Za-z] ]] && ((type_count++)) [[ "$ADMIN_PASSWORD" =~ [A-Za-z] ]] && ((type_count += 1))
[[ "$ADMIN_PASSWORD" =~ [0-9] ]] && ((type_count++)) [[ "$ADMIN_PASSWORD" =~ [0-9] ]] && ((type_count += 1))
[[ "$ADMIN_PASSWORD" =~ [^A-Za-z0-9] ]] && ((type_count++)) [[ "$ADMIN_PASSWORD" =~ [^A-Za-z0-9] ]] && ((type_count += 1))
if [[ ${type_count} -lt 2 ]]; then if [[ ${type_count} -lt 2 ]]; then
print_error "密码必须包含字母、数字、特殊字符中的至少两种" print_error "密码必须包含字母、数字、特殊字符中的至少两种"
continue continue
@@ -2191,21 +2343,39 @@ create_env_file() {
ALLOWED_ORIGINS_VALUE="${PROTOCOL}://${DOMAIN}:${PORT_VALUE}" ALLOWED_ORIGINS_VALUE="${PROTOCOL}://${DOMAIN}:${PORT_VALUE}"
fi fi
print_info "CORS 配置: ${ALLOWED_ORIGINS_VALUE}" PUBLIC_BASE_URL_VALUE="$ALLOWED_ORIGINS_VALUE"
ALLOWED_HOSTS_VALUE="$DOMAIN"
PUBLIC_PORT_VALUE="$PORT_VALUE"
print_info "公开访问地址: ${PUBLIC_BASE_URL_VALUE}"
else else
# IP 模式(开发/测试环境) # IP 模式也必须生成明确的生产来源白名单,否则浏览器 API 请求会被 CORS 拒绝。
# 留空,后端默认允许所有来源(适合开发环境) PUBLIC_IP=${PUBLIC_IP:-$(get_public_ip || true)}
ALLOWED_ORIGINS_VALUE="" if [[ -z "$PUBLIC_IP" ]]; then
print_error "无法确定公开访问 IP不能生成安全的生产配置"
return 1
fi
PUBLIC_URL_HOST=$(format_url_host "$PUBLIC_IP")
PORT_VALUE=${HTTP_PORT:-80}
if [[ "$PORT_VALUE" == "80" ]]; then
PUBLIC_BASE_URL_VALUE="http://${PUBLIC_URL_HOST}"
else
PUBLIC_BASE_URL_VALUE="http://${PUBLIC_URL_HOST}:${PORT_VALUE}"
fi
ALLOWED_ORIGINS_VALUE="$PUBLIC_BASE_URL_VALUE"
ALLOWED_HOSTS_VALUE="$PUBLIC_IP"
PUBLIC_PORT_VALUE="$PORT_VALUE"
COOKIE_SECURE_VALUE="false" COOKIE_SECURE_VALUE="false"
ENFORCE_HTTPS_VALUE="false" ENFORCE_HTTPS_VALUE="false"
print_warning "IP 模式下 CORS 将允许所有来源(仅适合开发环境)" print_info "公开访问地址: ${PUBLIC_BASE_URL_VALUE}"
print_info "生产环境建议使用域名模式"
fi fi
ADMIN_USERNAME_ENV=$(dotenv_quote "$ADMIN_USERNAME")
ADMIN_PASSWORD_ENV=$(dotenv_quote "$ADMIN_PASSWORD")
cat > "${PROJECT_DIR}/backend/.env" << EOF cat > "${PROJECT_DIR}/backend/.env" << EOF
# 管理员账号 # 管理员账号
ADMIN_USERNAME=${ADMIN_USERNAME} ADMIN_USERNAME=${ADMIN_USERNAME_ENV}
ADMIN_PASSWORD=${ADMIN_PASSWORD} ADMIN_PASSWORD=${ADMIN_PASSWORD_ENV}
# JWT密钥 # JWT密钥
JWT_SECRET=${JWT_SECRET} JWT_SECRET=${JWT_SECRET}
@@ -2226,6 +2396,10 @@ PORT=${BACKEND_PORT}
# 环境 # 环境
NODE_ENV=production NODE_ENV=production
# 公开访问地址(用于邮件、分享和直链)
PUBLIC_BASE_URL=${PUBLIC_BASE_URL_VALUE}
ALLOWED_HOSTS=${ALLOWED_HOSTS_VALUE}
# 强制HTTPS生产环境建议开启 # 强制HTTPS生产环境建议开启
ENFORCE_HTTPS=${ENFORCE_HTTPS_VALUE} ENFORCE_HTTPS=${ENFORCE_HTTPS_VALUE}
@@ -2248,9 +2422,9 @@ COOKIE_SECURE=${COOKIE_SECURE_VALUE}
# 警告:不要设置为 true这会信任所有代理存在 IP/协议伪造风险! # 警告:不要设置为 true这会信任所有代理存在 IP/协议伪造风险!
TRUST_PROXY=1 TRUST_PROXY=1
# 公开端口(nginx监听端口,用于生成分享链接 # 公开端口(Nginx 监听端口)
# 如果使用标准端口(80/443)或未配置,分享链接将不包含端口号 # 如果使用标准端口(80/443)或未配置,分享链接将不包含端口号
PUBLIC_PORT=${HTTP_PORT} PUBLIC_PORT=${PUBLIC_PORT_VALUE}
# CSRF 保护(生产环境强烈建议开启) # CSRF 保护(生产环境强烈建议开启)
# 使用 Double Submit Cookie 模式防止跨站请求伪造攻击 # 使用 Double Submit Cookie 模式防止跨站请求伪造攻击
@@ -2266,15 +2440,16 @@ LOG_LEVEL=info
LOG_FORMAT=json LOG_FORMAT=json
EOF EOF
chmod 600 "${PROJECT_DIR}/backend/.env"
print_success "配置文件创建完成" print_success "配置文件创建完成"
# 显示安全提示 # 显示安全提示
if [[ -z "$ALLOWED_ORIGINS_VALUE" ]]; then if [[ "$USE_DOMAIN" != "true" ]]; then
echo "" echo ""
print_warning "⚠️ 安全提示:" print_warning "⚠️ 安全提示:"
print_info " 当前配置允许所有域名访问CORS: *" print_info " IP 模式使用明文 HTTP管理员密码和会话不受 TLS 保护"
print_info " 这仅适合开发环境,生产环境存在安全风险" print_info " 正式对外服务建议改用域名 + HTTPS 模式"
print_info " 建议在生产环境使用域名模式部署"
echo "" echo ""
fi fi
echo "" echo ""
@@ -3049,6 +3224,16 @@ health_check() {
fi fi
fi fi
# 进程在线和端口监听并不代表应用已完成初始化,继续验证真实健康接口。
if curl -fsS --connect-timeout 3 --max-time 10 \
"http://127.0.0.1:${BACKEND_PORT}/api/health" > /dev/null; then
print_success "后端健康接口响应正常"
else
print_error "后端健康接口响应异常"
print_info "查看日志: pm2 logs ${PROJECT_NAME}-backend"
return 1
fi
# 检查数据库 # 检查数据库
if [[ -f "${PROJECT_DIR}/backend/data/database.db" ]]; then if [[ -f "${PROJECT_DIR}/backend/data/database.db" ]]; then
print_success "数据库初始化成功" print_success "数据库初始化成功"
@@ -3072,7 +3257,7 @@ health_check() {
################################################################################ ################################################################################
print_completion() { print_completion() {
clear clear_screen
echo -e "${GREEN}" echo -e "${GREEN}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -3098,11 +3283,13 @@ print_completion() {
fi fi
fi fi
else else
PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "服务器IP") PUBLIC_IP=$(get_public_ip || true)
PUBLIC_IP=${PUBLIC_IP:-服务器IP}
PUBLIC_URL_HOST=$(format_url_host "$PUBLIC_IP")
if [[ "$HTTP_PORT" == "80" ]]; then if [[ "$HTTP_PORT" == "80" ]]; then
echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}" echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_URL_HOST}"
else else
echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}:${HTTP_PORT}" echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_URL_HOST}:${HTTP_PORT}"
fi fi
fi fi
@@ -3158,7 +3345,7 @@ print_completion() {
################################################################################ ################################################################################
print_uninstall_banner() { print_uninstall_banner() {
clear clear_screen
echo -e "${RED}" echo -e "${RED}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -3395,7 +3582,7 @@ uninstall_check_residual() {
} }
print_uninstall_completion() { print_uninstall_completion() {
clear clear_screen
echo -e "${GREEN}" echo -e "${GREEN}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -3434,7 +3621,7 @@ print_uninstall_completion() {
################################################################################ ################################################################################
print_update_banner() { print_update_banner() {
clear clear_screen
echo -e "${BLUE}" echo -e "${BLUE}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -3870,7 +4057,7 @@ update_migrate_database() {
if [[ $REPLY =~ ^[Yy]$ ]]; then if [[ $REPLY =~ ^[Yy]$ ]]; then
# 备份原配置 # 备份原配置
cp .env .env.backup.$(date +%Y%m%d_%H%M%S) cp .env ".env.backup.$(date +%Y%m%d_%H%M%S)"
# 修复CORS配置 # 修复CORS配置
sed -i "s|^ALLOWED_ORIGINS=.*|ALLOWED_ORIGINS=${FIXED_CORS}|" .env sed -i "s|^ALLOWED_ORIGINS=.*|ALLOWED_ORIGINS=${FIXED_CORS}|" .env
@@ -3946,7 +4133,7 @@ update_check_version() {
} }
print_update_completion() { print_update_completion() {
clear clear_screen
echo -e "${GREEN}" echo -e "${GREEN}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -4152,10 +4339,17 @@ main() {
# 检查root权限 # 检查root权限
check_root check_root
# 安装过程需要读取软件源、端口和管理员密码,必须有真实控制终端。
if ! has_controlling_terminal; then
print_error "安装需要交互终端,当前环境无法读取 /dev/tty"
print_info "请先下载脚本,再在 SSH 终端中执行: bash install.sh"
exit 1
fi
# 如果没有通过命令行参数指定模式,则显示交互式选择 # 如果没有通过命令行参数指定模式,则显示交互式选择
if [[ "$MODE" == "install" ]] && [[ "$1" != "--skip-mode-select" ]]; then if [[ "$MODE" == "install" ]] && [[ "$1" != "--skip-mode-select" ]]; then
# 检测是否可以使用交互式输入 # 检测是否可以使用交互式输入
if [[ -t 0 ]] || [[ -c /dev/tty ]]; then if has_controlling_terminal; then
print_step "请选择操作模式" print_step "请选择操作模式"
echo "" echo ""
echo -e "${GREEN}[1]${NC} 安装/部署 玩玩云" echo -e "${GREEN}[1]${NC} 安装/部署 玩玩云"
@@ -4324,7 +4518,7 @@ uninstall_main() {
################################################################################ ################################################################################
print_repair_banner() { print_repair_banner() {
clear clear_screen
echo -e "${BLUE}" echo -e "${BLUE}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -4525,7 +4719,7 @@ repair_verify_services() {
} }
print_repair_completion() { print_repair_completion() {
clear clear_screen
echo -e "${GREEN}" echo -e "${GREEN}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -4563,11 +4757,13 @@ print_repair_completion() {
fi fi
fi fi
else else
PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "服务器IP") PUBLIC_IP=$(get_public_ip || true)
PUBLIC_IP=${PUBLIC_IP:-服务器IP}
PUBLIC_URL_HOST=$(format_url_host "$PUBLIC_IP")
if [[ "$HTTP_PORT" == "80" ]]; then if [[ "$HTTP_PORT" == "80" ]]; then
echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}" echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_URL_HOST}"
else else
echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}:${HTTP_PORT}" echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_URL_HOST}:${HTTP_PORT}"
fi fi
fi fi
echo "" echo ""
@@ -4628,7 +4824,7 @@ repair_main() {
################################################################################ ################################################################################
print_ssl_banner() { print_ssl_banner() {
clear clear_screen
echo -e "${GREEN}" echo -e "${GREEN}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -4782,7 +4978,7 @@ ssl_configure_domain() {
# 验证域名解析 # 验证域名解析
print_info "正在验证域名解析..." print_info "正在验证域名解析..."
DOMAIN_IP=$(dig +short "$DOMAIN" 2>/dev/null | tail -n1 || nslookup "$DOMAIN" 2>/dev/null | grep -A1 "Name:" | tail -1 | awk '{print $2}') DOMAIN_IP=$(dig +short "$DOMAIN" 2>/dev/null | tail -n1 || nslookup "$DOMAIN" 2>/dev/null | grep -A1 "Name:" | tail -1 | awk '{print $2}')
PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "") PUBLIC_IP=$(get_public_ip || true)
if [[ -n "$DOMAIN_IP" ]] && [[ "$DOMAIN_IP" == "$PUBLIC_IP" ]]; then if [[ -n "$DOMAIN_IP" ]] && [[ "$DOMAIN_IP" == "$PUBLIC_IP" ]]; then
print_success "域名已正确解析到当前服务器IP" print_success "域名已正确解析到当前服务器IP"
@@ -4818,10 +5014,6 @@ ssl_choose_method() {
echo -e "${GREEN}[5]${NC} acme.sh + Buypass" echo -e "${GREEN}[5]${NC} acme.sh + Buypass"
echo " - 挪威免费CA有效期180天" echo " - 挪威免费CA有效期180天"
echo "" echo ""
echo -e "${YELLOW}【云服务商证书】${NC}"
echo -e "${GREEN}[4]${NC} 阿里云免费证书 (需提供AccessKey)"
echo -e "${GREEN}[6]${NC} 腾讯云免费证书 (需提供SecretKey)"
echo ""
echo -e "${YELLOW}【其他选项】${NC}" echo -e "${YELLOW}【其他选项】${NC}"
echo -e "${GREEN}[7]${NC} 使用已有证书 (手动上传)" echo -e "${GREEN}[7]${NC} 使用已有证书 (手动上传)"
echo -e "${GREEN}[8]${NC} 移除HTTPS配置 (改回HTTP)" echo -e "${GREEN}[8]${NC} 移除HTTPS配置 (改回HTTP)"
@@ -4831,7 +5023,7 @@ ssl_choose_method() {
while true; do while true; do
read -p "请输入选项 [0-8]: " ssl_choice < /dev/tty read -p "请输入选项 [0-8]: " ssl_choice < /dev/tty
case $ssl_choice in case $ssl_choice in
1|2|3|4|5|6|7) 1|2|3|5|7)
SSL_METHOD=$ssl_choice SSL_METHOD=$ssl_choice
break break
;; ;;
@@ -4971,7 +5163,7 @@ ssl_verify_deployment() {
} }
print_ssl_completion() { print_ssl_completion() {
clear clear_screen
echo -e "${GREEN}" echo -e "${GREEN}"
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ║" echo "║ ║"
@@ -5089,5 +5281,5 @@ elif [[ "$MODE" == "repair" ]]; then
elif [[ "$MODE" == "ssl" ]]; then elif [[ "$MODE" == "ssl" ]]; then
ssl_main ssl_main
else else
main main "$@"
fi fi