fix: 统一端口并完善桌面端授权和安装

This commit is contained in:
2026-05-20 16:54:24 +08:00
parent 22c343a85e
commit 1288553df8
22 changed files with 363 additions and 363 deletions
+7 -9
View File
@@ -28,7 +28,7 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的
CapaReport/ CapaReport/
├─ app/ # FastAPI 后端源码 ├─ app/ # FastAPI 后端源码
│ ├─ api/routers/ # API 路由 │ ├─ api/routers/ # API 路由
│ ├─ services/ # 运行时和远程下载服务 │ ├─ services/ # 授权、远程下载等业务服务
│ ├─ utils/ # 通用工具 │ ├─ utils/ # 通用工具
│ ├─ main.py # 后端入口和前端托管 │ ├─ main.py # 后端入口和前端托管
│ ├─ processor.py # 数据处理主流程 │ ├─ processor.py # 数据处理主流程
@@ -63,12 +63,11 @@ uv venv
uv pip install -r requirements.txt uv pip install -r requirements.txt
``` ```
安装并构建前端: 安装前端依赖:
```powershell ```powershell
cd frontend cd frontend
npm install npm install
npm run build
cd .. cd ..
``` ```
@@ -91,7 +90,7 @@ cd frontend
npm run dev npm run dev
``` ```
Vite 会把 `/api` 和 `/health` 代理到 `http://localhost:9081`。 Vite 会把 `/api` 和 `/health` 代理到 `http://localhost:9081`。如果 `frontend/dist` 不存在,`run.bat` 会自动安装前端依赖并执行构建。
## 一键构建 ## 一键构建
@@ -148,10 +147,10 @@ scripts\build.bat desktop
dist\desktop\*-setup.exe dist\desktop\*-setup.exe
``` ```
桌面版使用 Tauri 启动 Python sidecar。sidecar 默认监听 `127.0.0.1:19082`,运行数据写入系统 app data 目录,不写入安装目录。 桌面版使用 Tauri 启动 Python sidecar。sidecar 默认监听 `127.0.0.1:9081`,运行数据写入系统 app data 目录,不写入安装目录。
首次启动会把安装包内置的 `Configure.json` 和 `ReportScript.sql` 复制到运行数据目录;Windows 下通常是 `%APPDATA%\com.nixevol.capacityreport\`。安装目录中的 `_up_` 只是 Tauri 打包资源目录,程序运行时不会直接编辑它。 首次启动会把安装包内置的 `Configure.json` 和 `ReportScript.sql` 复制到运行数据目录;Windows 下通常是 `%APPDATA%\com.nixevol.capacityreport\`。安装目录中的 `_up_` 只是 Tauri 打包资源目录,程序运行时不会直接编辑它。
Windows 桌面版使用 NSIS 安装器,卸载时会询问是否同时删除 `%APPDATA%\com.nixevol.capacityreport\` 中的配置、脚本、授权、缓存和日志。 Windows 桌面版使用 NSIS 安装器,卸载时会询问是否同时删除 `%APPDATA%\com.nixevol.capacityreport\` 中的配置、脚本、授权、缓存和日志。
桌面版 release 包已启用 DevTools,如需排查前端请求,可在窗口中按 `F12` 或右键打开开发者工具。 Windows 桌面版默认安装到 `D:\Program Files\CapacityReport`;如果没有 D 盘,则默认安装到系统 `Program Files\CapacityReport`。桌面版已关闭 release DevTools 和右键浏览器菜单。
构建桌面版需要 Rust 和 Tauri CLI。脚本会在缺少 Tauri CLI 时自动执行: 构建桌面版需要 Rust 和 Tauri CLI。脚本会在缺少 Tauri CLI 时自动执行:
@@ -185,7 +184,7 @@ docker compose -f dist\docker\docker-compose.yml up -d
访问: 访问:
```text ```text
http://localhost:19081 http://localhost:9081
``` ```
停止: 停止:
@@ -219,7 +218,7 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。 登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
授权到期日期保存在本地加密文件 `license.dat`,默认到期日为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。 授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
## 常用接口 ## 常用接口
@@ -234,7 +233,6 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
- `POST /api/license/activate`:提交激活码并顺延授权期限 - `POST /api/license/activate`:提交激活码并顺延授权期限
- `GET /api/history`:处理历史 - `GET /api/history`:处理历史
- `POST /api/history/download`:下载历史原始数据 - `POST /api/history/download`:下载历史原始数据
- `POST /api/service/restart`:重启服务
- `GET /health`:健康检查 - `GET /health`:健康检查
## 维护注意事项 ## 维护注意事项
+1 -1
View File
@@ -32,7 +32,7 @@ async def health_check():
return { return {
"status": "healthy" if is_healthy else "unhealthy", "status": "healthy" if is_healthy else "unhealthy",
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"version": "2.0.3", "version": "3.0.0",
"uptime_pid": os.getpid(), "uptime_pid": os.getpid(),
"checks": checks, "checks": checks,
} }
-68
View File
@@ -1,68 +0,0 @@
import os
import platform
import time
from threading import Thread
from fastapi import APIRouter
from app.services.runtime import (
is_container_runtime,
is_supervisor_running,
restart_method_name,
restart_via_supervisor,
terminate_current_process,
)
router = APIRouter(tags=["service"])
@router.post("/api/service/restart")
async def restart_service():
supervisor_running = is_supervisor_running()
container_runtime = is_container_runtime()
if supervisor_running:
success, message = restart_via_supervisor()
if success:
return {
"success": True,
"message": message,
"method": "supervisor",
"container": container_runtime,
}
method = restart_method_name(supervisor_running)
thread = Thread(target=_delayed_exit, daemon=True)
thread.start()
message = "服务正在重启,请稍后刷新页面..."
if supervisor_running:
message = "supervisor 命令不可用,已切换为进程重启..."
elif container_runtime:
message = "服务进程正在退出,容器或进程管理器将拉起服务..."
return {
"success": True,
"message": message,
"method": method,
"container": container_runtime,
}
@router.get("/api/service/status")
async def get_service_status():
return {
"status": "running",
"version": "2.0.3",
"platform": platform.system(),
"supervisor": is_supervisor_running(),
"container": is_container_runtime(),
"pid": os.getpid(),
"python_version": platform.python_version(),
}
def _delayed_exit() -> None:
time.sleep(1)
terminate_current_process()
+2 -3
View File
@@ -7,12 +7,12 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from app import state from app import state
from app.api.routers import auth, cache, config, database, health, history, license, remote, script, service, tasks, upload from app.api.routers import auth, cache, config, database, health, history, license, remote, script, tasks, upload
from app.auth import verify_jwt_token from app.auth import verify_jwt_token
from app.config import BASE_DIR from app.config import BASE_DIR
APP_VERSION = "2.0.3" APP_VERSION = "3.0.0"
APP_HOST = "0.0.0.0" APP_HOST = "0.0.0.0"
APP_PORT = 9081 APP_PORT = 9081
FRONTEND_DIST_DIR = BASE_DIR / "frontend" / "dist" FRONTEND_DIST_DIR = BASE_DIR / "frontend" / "dist"
@@ -71,7 +71,6 @@ def register_routes(app: FastAPI) -> None:
database.router, database.router,
config.router, config.router,
cache.router, cache.router,
service.router,
script.router, script.router,
] ]
for router in routers: for router in routers:
-88
View File
@@ -1,88 +0,0 @@
import os
import platform
import signal
import subprocess
from pathlib import Path
def is_supervisor_running() -> bool:
if os.environ.get("SUPERVISOR_ENABLED") == "1":
return True
supervisor_sock = Path("/var/run/supervisor.sock")
if supervisor_sock.exists() and _command_succeeds(["supervisorctl", "status"], timeout=5):
return True
try:
import psutil # pyright: ignore[reportMissingModuleSource]
parent = psutil.Process().parent()
if parent and "supervisor" in parent.name().lower():
return True
except Exception:
pass
return _command_succeeds(["pgrep", "-f", "supervisord"], timeout=2)
def is_container_runtime() -> bool:
if os.environ.get("container"):
return True
if Path("/.dockerenv").exists():
return True
cgroup_path = Path("/proc/1/cgroup")
if not cgroup_path.exists():
return False
try:
cgroup = cgroup_path.read_text(encoding="utf-8", errors="ignore").lower()
except Exception:
return False
return any(marker in cgroup for marker in ("docker", "kubepods", "containerd"))
def restart_via_supervisor() -> tuple[bool, str]:
try:
result = subprocess.run(
["supervisorctl", "restart", "fastapi"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return True, "服务正在通过 supervisor 重启..."
error_msg = result.stderr or result.stdout or "未知错误"
return False, f"重启失败: {error_msg}"
except subprocess.TimeoutExpired:
return False, "重启操作超时"
except FileNotFoundError:
return False, "找不到 supervisorctl 命令"
except Exception as exc:
return False, f"重启异常: {exc}"
def terminate_current_process() -> None:
if platform.system() == "Windows":
os._exit(0)
os.kill(os.getpid(), signal.SIGTERM)
def restart_method_name(supervisor_running: bool) -> str:
if supervisor_running:
return "supervisor"
if platform.system() == "Windows":
return "windows-process"
if is_container_runtime():
return "container-process"
return "process"
def _command_succeeds(command: list[str], timeout: int) -> bool:
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
return result.returncode == 0 and bool(result.stdout.strip() or command[0] == "supervisorctl")
except Exception:
return False
+11 -2
View File
@@ -1,4 +1,15 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-20:统一端口、升级 3.0.0 并收敛桌面安装行为
- 应用户要求,应用访问端口统一回 `9081`:Server Portable、Docker 宿主机映射、Tauri 桌面 sidecar、桌面前端 `VITE_API_BASE` 和前端 Tauri 兜底 API 地址均使用 `http://127.0.0.1:9081`;桌面版启动前只会清理同名 `capareport-server.exe` 的残留监听进程,避免误杀其它占用 `9081` 的程序。
- 版本统一提升为 `3.0.0`,同步更新后端 `APP_VERSION`、健康检查、侧边栏显示、Tauri 配置和 Cargo 包版本。
- `run.bat` 不再因 `frontend/dist/index.html` 缺失直接退出;缺少前端构建产物时会检查 `npm`、按需执行 `npm ci`,然后自动运行 `npm run build` 再启动后端。
- 桌面版去除 release DevTools:`src-tauri/Cargo.toml` 移除 Tauri `devtools` feature,`tauri.conf.json` 移除窗口 `devtools` 配置,前端在 Tauri 环境下阻止右键浏览器菜单和 `F12`/`Ctrl+Shift+I`。
- Windows NSIS 安装器改为 per-machine,并在未选择自定义安装目录时默认落到 `D:\Program Files\CapacityReport`;如果没有 D 盘,则使用系统 `Program Files\CapacityReport`。`scripts/build.ps1` 构建桌面版时会临时生成 Tauri NSIS 模板并恢复 `tauri.conf.json`,避免旧安装记录把默认路径带回 C 盘。默认授权到期日仍由 `app/services/license.py` 的 `DEFAULT_EXPIRES_ON` 控制。
- 桌面端不再提供服务重启功能:前端移除重启按钮和等待遮罩,后端删除 `/api/service/restart`、`/api/service/status` 以及对应 runtime 重启实现,避免桌面 sidecar 无法可靠自重启时误导用户。
- 登录后连续点击左上角品牌图标 8 次会主动打开授权延期窗口,窗口显示当前激活 key 标签并允许连续提交激活码,每次成功后按新的到期日刷新下一次 key。
- 已验证 `cmd /c scripts\build.bat desktop` 可生成 `dist\desktop\CapacityReport_3.0.0_x64-setup.exe`;静默安装后 `capacity-report-desktop.exe`、`capareport-server.exe`、`Configure.json` 和 `ReportScript.sql` 均位于 `D:\Program Files\CapacityReport`,注册表 `InstallLocation` 指向 D 盘。已启动安装后的桌面程序验证 sidecar `/health` 返回 `3.0.0`,并验证配置、脚本和授权接口可读取;验证后已停止测试进程并清理中间产物。
## 2026-05-20:修复桌面版跨源预检导致配置网络错误 ## 2026-05-20:修复桌面版跨源预检导致配置网络错误
- 桌面版前端访问 `127.0.0.1:19082` 属于 WebView 跨源请求,带 `Authorization` 或上传配置文件时浏览器会先发 `OPTIONS` 预检;`app/main.py` 的 JWT 中间件现在直接放行 `OPTIONS`,让 FastAPI CORS 中间件返回允许头,避免配置读取和配置上传显示“网络错误”。 - 桌面版前端访问 `127.0.0.1:19082` 属于 WebView 跨源请求,带 `Authorization` 或上传配置文件时浏览器会先发 `OPTIONS` 预检;`app/main.py` 的 JWT 中间件现在直接放行 `OPTIONS`,让 FastAPI CORS 中间件返回允许头,避免配置读取和配置上传显示“网络错误”。
@@ -381,12 +392,10 @@
- `database.py`:数据库测试、表查询、表维护和导出。 - `database.py`:数据库测试、表查询、表维护和导出。
- `config.py`:配置读取、保存、上传和下载。 - `config.py`:配置读取、保存、上传和下载。
- `cache.py`:缓存大小统计。 - `cache.py`:缓存大小统计。
- `service.py`:服务状态和重启。
- `script.py`:SQL 脚本读取、保存和执行。 - `script.py`:SQL 脚本读取、保存和执行。
- `health.py`:健康检查。 - `health.py`:健康检查。
- 运行时共享状态放在 `app/state.py`,包括配置实例、历史管理器、处理任务、上传会话和全局任务锁。 - 运行时共享状态放在 `app/state.py`,包括配置实例、历史管理器、处理任务、上传会话和全局任务锁。
- 登录、密码文件和 Token 逻辑放在 `app/auth.py`,继续使用本地 `auth.ini`。 - 登录、密码文件和 Token 逻辑放在 `app/auth.py`,继续使用本地 `auth.ini`。
- 服务重启检测和进程退出逻辑放在 `app/services/runtime.py`。
- 文件大小等工具函数放在 `app/utils/files.py`。 - 文件大小等工具函数放在 `app/utils/files.py`。
### 前端 ### 前端
+97 -85
View File
@@ -11,7 +11,17 @@
@update:collapsed="handleSidebarCollapsed" @update:collapsed="handleSidebarCollapsed"
> >
<div class="brand"> <div class="brand">
<div class="brand-mark">📊</div> <div
class="brand-mark"
role="button"
tabindex="0"
title="CapacityReport"
@click="handleBrandMarkClick"
@keydown.enter.prevent="handleBrandMarkClick"
@keydown.space.prevent="handleBrandMarkClick"
>
📊
</div>
<div class="brand-text">CapacityReport</div> <div class="brand-text">CapacityReport</div>
</div> </div>
<n-menu <n-menu
@@ -25,7 +35,7 @@
<div class="sider-footer"> <div class="sider-footer">
<span class="sider-version"> <span class="sider-version">
<span class="sider-version-label">版本:</span> <span class="sider-version-label">版本:</span>
<span class="sider-version-number">v2.0.3</span> <span class="sider-version-number">v3.0.0</span>
</span> </span>
<span class="sider-powered">Power by:NIXEVOL</span> <span class="sider-powered">Power by:NIXEVOL</span>
</div> </div>
@@ -88,15 +98,6 @@
<button class="theme-toggle" type="button" :title="themeToggleTitle" @click="toggleTheme"> <button class="theme-toggle" type="button" :title="themeToggleTitle" @click="toggleTheme">
<n-icon><component :is="themeToggleIcon" /></n-icon> <n-icon><component :is="themeToggleIcon" /></n-icon>
</button> </button>
<button
class="restart-btn"
type="button"
title="重启服务"
:disabled="restarting"
@click="restartService"
>
<n-icon><PowerOutline /></n-icon>
</button>
<n-button tertiary circle title="退出登录" class="logout-button" @click="logout"> <n-button tertiary circle title="退出登录" class="logout-button" @click="logout">
<template #icon> <template #icon>
<n-icon><LogOutOutline /></n-icon> <n-icon><LogOutOutline /></n-icon>
@@ -111,18 +112,37 @@
</n-layout> </n-layout>
</n-layout> </n-layout>
<div v-if="restarting" class="restart-overlay" role="status" aria-live="assertive"> <n-modal
<div class="restart-overlay-content"> v-model:show="licenseModalVisible"
<div class="restart-overlay-spinner"></div> preset="card"
<div class="restart-overlay-text">{{ restartOverlayText }}</div> title="授权延期"
:mask-closable="!activationLoading"
:style="{ width: '420px', maxWidth: 'calc(100vw - 32px)' }"
>
<div class="license-dialog-body">
<p class="license-dialog-text">{{ licenseMessage }}</p>
<div class="license-key-label">key: {{ activationKeyLabel }}</div>
<n-input
v-model:value="activationCode"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="请输入激活码"
:disabled="activationLoading"
/>
</div> </div>
</div> <template #footer>
<div class="license-dialog-footer">
<n-button :disabled="activationLoading" @click="licenseModalVisible = false">取消</n-button>
<n-button type="primary" :loading="activationLoading" @click="submitActivation">激活</n-button>
</div>
</template>
</n-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue'; import { computed, h, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router'; import { RouterView, useRoute, useRouter } from 'vue-router';
import { useDialog, useMessage, type DropdownOption, type MenuOption, NIcon } from 'naive-ui'; import { useMessage, type DropdownOption, type MenuOption, NIcon } from 'naive-ui';
import { import {
ChevronDownOutline, ChevronDownOutline,
CloudUploadOutline, CloudUploadOutline,
@@ -130,30 +150,32 @@ import {
FileTrayFullOutline, FileTrayFullOutline,
LogOutOutline, LogOutOutline,
MoonOutline, MoonOutline,
PowerOutline,
ServerOutline, ServerOutline,
SettingsOutline, SettingsOutline,
SunnyOutline SunnyOutline
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { apiGet, apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client'; import { apiGet, apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client';
import type { ApiMessage, LoginResponse, ServiceStatus } from './types'; import type { LicenseStatus, LoginResponse } from './types';
import LoginView from './components/LoginView.vue'; import LoginView from './components/LoginView.vue';
import { pageHeader, resetPageHeader, resolveHeaderValue, type PageHeaderAction } from './composables/pageHeader'; import { pageHeader, resetPageHeader, resolveHeaderValue, type PageHeaderAction } from './composables/pageHeader';
import { themeName, toggleAppTheme } from './composables/theme'; import { themeName, toggleAppTheme } from './composables/theme';
const message = useMessage(); const message = useMessage();
const dialog = useDialog();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const token = ref(getToken()); const token = ref(getToken());
const loginLoading = ref(false); const loginLoading = ref(false);
const restarting = ref(false);
const restartOverlayText = ref('正在重启服务...');
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true'); const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true');
const licenseModalVisible = ref(false);
const activationCode = ref('');
const activationLoading = ref(false);
const activationKeyLabel = ref('2026/06/20');
const licenseMessage = ref('输入激活码可将授权到期日期延长 30 天。');
const brandClickCount = ref(0);
const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const; const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const;
type MenuKey = (typeof menuKeys)[number]; type MenuKey = (typeof menuKeys)[number];
let restartPollTimer: number | undefined; let brandClickResetTimer: number | undefined;
const menuOptions: MenuOption[] = [ const menuOptions: MenuOption[] = [
{ label: '数据上传', key: 'workflow', icon: renderIcon(CloudUploadOutline) }, { label: '数据上传', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
@@ -182,8 +204,8 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('dragover', preventWindowFileDrop, { capture: true }); window.removeEventListener('dragover', preventWindowFileDrop, { capture: true });
window.removeEventListener('drop', preventWindowFileDrop, { capture: true }); window.removeEventListener('drop', preventWindowFileDrop, { capture: true });
if (restartPollTimer !== undefined) { if (brandClickResetTimer !== undefined) {
window.clearTimeout(restartPollTimer); window.clearTimeout(brandClickResetTimer);
} }
}); });
@@ -224,66 +246,6 @@ function toggleTheme() {
toggleAppTheme(); toggleAppTheme();
} }
async function restartService() {
if (restarting.value) return;
dialog.warning({
title: '重启服务',
content: '确定要重启服务吗?这将中断当前所有操作。',
positiveText: '重启',
negativeText: '取消',
onPositiveClick: () => {
void executeRestartService();
}
});
}
async function executeRestartService() {
restarting.value = true;
restartOverlayText.value = '正在重启服务...';
try {
const result = await apiPost<ApiMessage>('/api/service/restart');
restartOverlayText.value = result.message || '正在等待服务恢复...';
} catch {
restartOverlayText.value = '正在等待服务恢复...';
}
pollServiceStatus();
}
function pollServiceStatus() {
let attempts = 0;
const maxAttempts = 60;
const pollInterval = 5000;
const checkService = async () => {
attempts += 1;
try {
await apiGet<ServiceStatus>('/api/service/status');
restartOverlayText.value = '服务已恢复,正在刷新页面...';
restartPollTimer = window.setTimeout(() => {
window.location.reload();
}, 500);
return;
} catch {
restartOverlayText.value = `正在等待服务恢复... (${attempts}/${maxAttempts})`;
}
if (attempts < maxAttempts) {
restartPollTimer = window.setTimeout(checkService, pollInterval);
return;
}
restarting.value = false;
restartOverlayText.value = '正在重启服务...';
message.warning('服务重启超时,请手动刷新页面');
};
restartPollTimer = window.setTimeout(checkService, 3000);
}
function handleMenuChange(key: string | number) { function handleMenuChange(key: string | number) {
if (!isMenuKey(key) || key === activeMenu.value) { if (!isMenuKey(key) || key === activeMenu.value) {
return; return;
@@ -291,6 +253,56 @@ function handleMenuChange(key: string | number) {
void router.push({ name: key }); void router.push({ name: key });
} }
function handleBrandMarkClick() {
brandClickCount.value += 1;
if (brandClickResetTimer !== undefined) {
window.clearTimeout(brandClickResetTimer);
}
if (brandClickCount.value >= 8) {
brandClickCount.value = 0;
void openLicenseModal();
return;
}
brandClickResetTimer = window.setTimeout(() => {
brandClickCount.value = 0;
}, 2000);
}
async function openLicenseModal() {
try {
const status = await apiGet<LicenseStatus>('/api/license/status');
activationKeyLabel.value = status.key_label;
licenseMessage.value = `当前授权到期日期:${status.expires_on}。输入激活码可延长 30 天。`;
activationCode.value = '';
licenseModalVisible.value = true;
} catch (error) {
message.error(error instanceof Error ? error.message : '获取授权状态失败');
}
}
async function submitActivation() {
const code = activationCode.value.trim();
if (!code) {
message.warning('请输入激活码');
return;
}
activationLoading.value = true;
try {
const result = await apiPost<LicenseStatus>('/api/license/activate', { code });
activationKeyLabel.value = result.key_label;
licenseMessage.value = `当前授权到期日期:${result.expires_on}。输入激活码可继续延长 30 天。`;
activationCode.value = '';
message.success(`激活成功,到期日期: ${result.expires_on}`);
} catch (error) {
message.error(error instanceof Error ? error.message : '激活失败');
} finally {
activationLoading.value = false;
}
}
function isMenuKey(value: unknown): value is MenuKey { function isMenuKey(value: unknown): value is MenuKey {
return typeof value === 'string' && menuKeys.includes(value as MenuKey); return typeof value === 'string' && menuKeys.includes(value as MenuKey);
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ApiError, ApiErrorDetail } from '../types'; import type { ApiError, ApiErrorDetail } from '../types';
const TOKEN_KEY = 'capacity_report_token'; const TOKEN_KEY = 'capacity_report_token';
const DESKTOP_API_BASE = 'http://127.0.0.1:19082'; const DESKTOP_API_BASE = 'http://127.0.0.1:9081';
const API_BASE = resolveApiBase(); const API_BASE = resolveApiBase();
const API_FETCH_RETRIES = 8; const API_FETCH_RETRIES = 8;
const API_FETCH_RETRY_DELAY_MS = 500; const API_FETCH_RETRY_DELAY_MS = 500;
+17
View File
@@ -5,4 +5,21 @@ import App from './App.vue';
import { router } from './router'; import { router } from './router';
import './styles.css'; import './styles.css';
if (isTauriRuntime()) {
window.addEventListener('contextmenu', event => event.preventDefault());
window.addEventListener('keydown', event => {
if (event.key === 'F12' || (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'i')) {
event.preventDefault();
}
});
}
createApp(App).use(router).use(naive).mount('#app'); createApp(App).use(router).use(naive).mount('#app');
function isTauriRuntime(): boolean {
const win = window as Window & {
__TAURI__?: unknown;
__TAURI_INTERNALS__?: unknown;
};
return Boolean(win.__TAURI__ || win.__TAURI_INTERNALS__ || navigator.userAgent.includes('Tauri'));
}
+4 -71
View File
@@ -182,6 +182,8 @@ select {
font-size: 18px; font-size: 18px;
background: linear-gradient(135deg, var(--td-brand-color), #618dff); background: linear-gradient(135deg, var(--td-brand-color), #618dff);
border-radius: var(--td-radius-default); border-radius: var(--td-radius-default);
cursor: default;
user-select: none;
} }
.brand-text { .brand-text {
@@ -352,8 +354,7 @@ select {
font-size: 13px; font-size: 13px;
} }
.theme-toggle, .theme-toggle {
.restart-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -376,80 +377,12 @@ select {
border-color: var(--td-text-color-secondary); border-color: var(--td-text-color-secondary);
} }
.restart-btn { .theme-toggle svg {
color: var(--td-error-color);
border-color: var(--td-error-color);
}
.restart-btn:hover {
color: var(--td-error-color);
background: var(--td-error-color-light);
}
.restart-btn:disabled {
cursor: wait;
opacity: 0.7;
}
.theme-toggle svg,
.restart-btn svg {
display: block; display: block;
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
.restart-overlay {
position: fixed;
inset: 0;
z-index: 5000;
display: flex;
align-items: center;
justify-content: center;
background: var(--td-mask-color);
backdrop-filter: blur(4px);
}
.restart-overlay-content {
display: flex;
min-width: 260px;
align-items: center;
flex-direction: column;
gap: 18px;
padding: 32px 44px;
color: var(--td-text-color-primary);
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-large);
box-shadow: var(--td-shadow-3);
}
.restart-overlay-spinner {
width: 44px;
height: 44px;
border: 4px solid var(--td-border-color-light);
border-top-color: var(--td-brand-color);
border-radius: 50%;
animation: spinner-rotate 1s linear infinite;
}
.restart-overlay-text {
color: var(--td-text-color-primary);
font-size: 15px;
font-weight: 500;
line-height: 22px;
text-align: center;
}
@keyframes spinner-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.logout-button { .logout-button {
flex-shrink: 0; flex-shrink: 0;
} }
-10
View File
@@ -139,16 +139,6 @@ export interface DatabaseInfo {
error?: string; error?: string;
} }
export interface ServiceStatus {
status: string;
version: string;
platform: string;
supervisor: boolean;
container: boolean;
pid: number;
python_version: string;
}
export interface ApiMessage { export interface ApiMessage {
success: boolean; success: boolean;
message?: string; message?: string;
+1 -1
View File
@@ -4,7 +4,7 @@ services:
container_name: capacity-report-app container_name: capacity-report-app
restart: unless-stopped restart: unless-stopped
ports: ports:
- "19081:9081" - "9081:9081"
volumes: volumes:
- ./cache:/app/cache - ./cache:/app/cache
- ./Configure.json:/app/Configure.json - ./Configure.json:/app/Configure.json
+29 -8
View File
@@ -1,5 +1,5 @@
@echo off @echo off
title CapacityReport v2.0.2 title CapacityReport v3.0.0
echo ======================================== echo ========================================
echo CapacityReport - report service echo CapacityReport - report service
@@ -28,13 +28,34 @@ if errorlevel 1 (
) )
if not exist "frontend\dist\index.html" ( if not exist "frontend\dist\index.html" (
echo [ERROR] Frontend build output not found. echo [INFO] Frontend build output not found. Building frontend...
echo [HINT] Run these commands first: where npm >nul 2>&1
echo cd frontend if errorlevel 1 (
echo npm install echo [ERROR] npm not found. Install Node.js first.
echo npm run build pause
pause exit /b 1
exit /b 1 )
pushd frontend
if not exist "node_modules" (
echo [INFO] Installing frontend dependencies...
call npm ci
if errorlevel 1 (
echo [ERROR] Failed to install frontend dependencies.
popd
pause
exit /b 1
)
)
call npm run build
if errorlevel 1 (
echo [ERROR] Failed to build frontend.
popd
pause
exit /b 1
)
popd
) )
:loop :loop
+135 -4
View File
@@ -27,7 +27,7 @@ $PyInstallerDir = Join-Path $TempDir "pyinstaller"
$FrontendDir = Join-Path $Root "frontend" $FrontendDir = Join-Path $Root "frontend"
$PackagingDir = Join-Path $Root "packaging" $PackagingDir = Join-Path $Root "packaging"
$ServerName = "capareport-server" $ServerName = "capareport-server"
$DesktopApiBase = "http://127.0.0.1:19082" $DesktopApiBase = "http://127.0.0.1:9081"
function Write-Step($Message) { function Write-Step($Message) {
Write-Host "" Write-Host ""
@@ -209,6 +209,134 @@ function Write-TextFile($Path, $Content, [switch]$Lf) {
} }
} }
function Convert-ToNsisPath($Path) {
return (Resolve-Path -LiteralPath $Path).Path
}
function Get-TauriNsisTemplatePath {
$registryDir = Join-Path $env:USERPROFILE ".cargo\registry\src"
if (!(Test-Path $registryDir)) {
throw "Cargo registry not found: $registryDir"
}
$templates = Get-ChildItem -Path $registryDir -Recurse -Filter "installer.nsi" |
Where-Object { $_.FullName -match "tauri-bundler-[^\\]+\\src\\bundle\\windows\\nsis\\installer\.nsi$" } |
Sort-Object FullName -Descending
if (!$templates) {
throw "Tauri NSIS template not found. Run cargo tauri build once to fetch tauri-bundler."
}
return $templates[0].FullName
}
function New-DesktopNsisTemplate {
$source = Get-TauriNsisTemplatePath
$target = Join-Path $TempDir "tauri-nsis-installer.nsi"
$hookPath = Convert-ToNsisPath (Join-Path $Root "src-tauri\windows\nsis-hooks.nsh")
$content = [System.IO.File]::ReadAllText($source)
$content = [regex]::Replace(
$content,
'\{\{#if installer_hooks\}\}\s*!include "\{\{installer_hooks\}\}"\s*\{\{/if\}\}',
"!include `"$hookPath`""
)
$restoreLine = " Call RestorePreviousInstallLocation"
$installDirOverride = @'
Call RestorePreviousInstallLocation
!if "${INSTALLMODE}" == "perMachine"
${If} ${FileExists} "D:\*.*"
StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}"
${Else}
${If} ${RunningX64}
StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}"
${Else}
StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}"
${EndIf}
${EndIf}
!endif
'@
if (!$content.Contains($restoreLine)) {
throw "Tauri NSIS template changed: RestorePreviousInstallLocation marker not found"
}
$content = $content.Replace($restoreLine, $installDirOverride)
$onInitTail = @'
!if "${INSTALLMODE}" == "both"
!insertmacro MULTIUSER_INIT
!endif
FunctionEnd
'@
$onInitOverride = @'
!if "${INSTALLMODE}" == "both"
!insertmacro MULTIUSER_INIT
!endif
!if "${INSTALLMODE}" == "perMachine"
${If} ${FileExists} "D:\*.*"
StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}"
${Else}
${If} ${RunningX64}
StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}"
${Else}
StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}"
${EndIf}
${EndIf}
!endif
FunctionEnd
'@
if (!$content.Contains($onInitTail)) {
throw "Tauri NSIS template changed: .onInit tail marker not found"
}
$content = $content.Replace($onInitTail, $onInitOverride)
$restoreFunction = @'
Function RestorePreviousInstallLocation
ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" ""
StrCmp $4 "" +2 0
StrCpy $INSTDIR $4
FunctionEnd
'@
$restoreOverride = @'
Function RestorePreviousInstallLocation
!if "${INSTALLMODE}" == "perMachine"
${If} ${FileExists} "D:\*.*"
StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}"
Return
${EndIf}
!endif
ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" ""
StrCmp $4 "" +2 0
StrCpy $INSTDIR $4
FunctionEnd
'@
if (!$content.Contains($restoreFunction)) {
throw "Tauri NSIS template changed: RestorePreviousInstallLocation function marker not found"
}
$content = $content.Replace($restoreFunction, $restoreOverride)
Write-TextFile $target $content -Lf
return $target
}
function Invoke-WithDesktopTauriConfig($NsisTemplatePath, [scriptblock]$Action) {
$configPath = Join-Path $Root "src-tauri\tauri.conf.json"
$original = [System.IO.File]::ReadAllText($configPath)
try {
$config = $original | ConvertFrom-Json
$config.bundle.windows.nsis | Add-Member -NotePropertyName "template" -NotePropertyValue $NsisTemplatePath -Force
Write-TextFile $configPath ($config | ConvertTo-Json -Depth 50)
& $Action
}
finally {
[System.IO.File]::WriteAllText($configPath, $original, [System.Text.UTF8Encoding]::new($false))
}
}
function Copy-RuntimeFiles($OutputDir) { function Copy-RuntimeFiles($OutputDir) {
Copy-Item -Path (Join-Path $Root "Configure.json") -Destination (Join-Path $OutputDir "Configure.json") -Force Copy-Item -Path (Join-Path $Root "Configure.json") -Destination (Join-Path $OutputDir "Configure.json") -Force
Copy-Item -Path (Join-Path $Root "ReportScript.sql") -Destination (Join-Path $OutputDir "ReportScript.sql") -Force Copy-Item -Path (Join-Path $Root "ReportScript.sql") -Destination (Join-Path $OutputDir "ReportScript.sql") -Force
@@ -342,9 +470,12 @@ function Build-DesktopPackage {
Invoke-Checked "cargo install tauri-cli --locked" $Root Invoke-Checked "cargo install tauri-cli --locked" $Root
} }
Remove-Tree (Join-Path $Root "src-tauri\target\release\bundle") $nsisTemplate = New-DesktopNsisTemplate
Write-Step "Building Tauri desktop package" Invoke-WithDesktopTauriConfig $nsisTemplate {
Invoke-Checked "cargo tauri build" (Join-Path $Root "src-tauri") Remove-Tree (Join-Path $Root "src-tauri\target\release\bundle")
Write-Step "Building Tauri desktop package"
Invoke-Checked "cargo tauri build" (Join-Path $Root "src-tauri")
}
New-Item -ItemType Directory -Force -Path $DesktopDistDir | Out-Null New-Item -ItemType Directory -Force -Path $DesktopDistDir | Out-Null
$bundleDir = Join-Path $Root "src-tauri\target\release\bundle" $bundleDir = Join-Path $Root "src-tauri\target\release\bundle"
+1 -1
View File
@@ -11,7 +11,7 @@ DOCKER_DIST_DIR="$DIST_DIR/docker"
TMP_DIR="$DIST_DIR/.tmp" TMP_DIR="$DIST_DIR/.tmp"
PYINSTALLER_DIR="$TMP_DIR/pyinstaller" PYINSTALLER_DIR="$TMP_DIR/pyinstaller"
PACKAGING_DIR="$ROOT/packaging" PACKAGING_DIR="$ROOT/packaging"
DESKTOP_API_BASE="http://127.0.0.1:19082" DESKTOP_API_BASE="http://127.0.0.1:9081"
SERVER_NAME="capareport-server" SERVER_NAME="capareport-server"
cd "$ROOT" cd "$ROOT"
+1 -1
View File
@@ -235,7 +235,7 @@ dependencies = [
[[package]] [[package]]
name = "capacity-report-desktop" name = "capacity-report-desktop"
version = "2.0.3" version = "3.0.0"
dependencies = [ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "capacity-report-desktop" name = "capacity-report-desktop"
version = "2.0.3" version = "3.0.0"
description = "CapacityReport desktop shell" description = "CapacityReport desktop shell"
authors = ["NIXEVOL"] authors = ["NIXEVOL"]
edition = "2021" edition = "2021"
@@ -9,7 +9,7 @@ edition = "2021"
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["devtools"] } tauri = { version = "2", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
[features] [features]
+1 -1
View File
@@ -3,5 +3,5 @@
"identifier": "default", "identifier": "default",
"description": "Default desktop permissions", "description": "Default desktop permissions",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default"] "permissions": ["core:default", "core:webview:deny-internal-toggle-devtools"]
} }
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default desktop permissions","local":true,"windows":["main"],"permissions":["core:default"]}} {"default":{"identifier":"default","description":"Default desktop permissions","local":true,"windows":["main"],"permissions":["core:default","core:webview:deny-internal-toggle-devtools"]}}
+26 -3
View File
@@ -13,6 +13,11 @@ use tauri::{Manager, Runtime};
use tauri_plugin_shell::process::CommandChild; use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
const SERVER_PORT: u16 = 9081;
const SERVER_ADDR: &str = "127.0.0.1:9081";
#[cfg(target_os = "windows")]
const SERVER_PROCESS_NAME: &str = "capareport-server.exe";
struct ServerProcess { struct ServerProcess {
child: CommandChild, child: CommandChild,
pid: u32, pid: u32,
@@ -70,8 +75,9 @@ fn start_server<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn Error>> {
fs::create_dir_all(data_dir.join("logs"))?; fs::create_dir_all(data_dir.join("logs"))?;
copy_resource(app, "Configure.json", &data_dir)?; copy_resource(app, "Configure.json", &data_dir)?;
copy_resource(app, "ReportScript.sql", &data_dir)?; copy_resource(app, "ReportScript.sql", &data_dir)?;
stop_existing_server_on_port(19082); stop_existing_server_on_port(SERVER_PORT);
let port_arg = SERVER_PORT.to_string();
let (mut rx, child) = app let (mut rx, child) = app
.shell() .shell()
.sidecar("capareport-server")? .sidecar("capareport-server")?
@@ -79,10 +85,10 @@ fn start_server<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn Error>> {
"CAPAREPORT_BASE_DIR", "CAPAREPORT_BASE_DIR",
data_dir.to_string_lossy().to_string(), data_dir.to_string_lossy().to_string(),
) )
.args(["--host", "127.0.0.1", "--port", "19082"]) .args(["--host", "127.0.0.1", "--port", &port_arg])
.spawn()?; .spawn()?;
if let Err(error) = wait_for_server("127.0.0.1:19082", Duration::from_secs(60)) { if let Err(error) = wait_for_server(SERVER_ADDR, Duration::from_secs(60)) {
let _ = child.kill(); let _ = child.kill();
return Err(error); return Err(error);
} }
@@ -139,6 +145,9 @@ fn stop_existing_server_on_port(port: u16) {
if pid.parse::<u32>().is_err() { if pid.parse::<u32>().is_err() {
continue; continue;
} }
if !is_capareport_server_pid(pid) {
continue;
}
let _ = std::process::Command::new("taskkill") let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", pid]) .args(["/F", "/T", "/PID", pid])
@@ -146,6 +155,20 @@ fn stop_existing_server_on_port(port: u16) {
} }
} }
#[cfg(target_os = "windows")]
fn is_capareport_server_pid(pid: &str) -> bool {
let Ok(output) = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.output()
else {
return false;
};
String::from_utf8_lossy(&output.stdout)
.to_ascii_lowercase()
.contains(SERVER_PROCESS_NAME)
}
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
fn stop_existing_server_on_port(_port: u16) {} fn stop_existing_server_on_port(_port: u16) {}
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CapacityReport", "productName": "CapacityReport",
"version": "2.0.3", "version": "3.0.0",
"identifier": "com.nixevol.capacityreport", "identifier": "com.nixevol.capacityreport",
"build": { "build": {
"frontendDist": "../frontend/dist" "frontendDist": "../frontend/dist"
@@ -14,8 +14,7 @@
"width": 1280, "width": 1280,
"height": 820, "height": 820,
"minWidth": 1100, "minWidth": 1100,
"minHeight": 720, "minHeight": 720
"devtools": true
} }
], ],
"security": { "security": {
@@ -47,6 +46,7 @@
}, },
"nsis": { "nsis": {
"installerHooks": "./windows/nsis-hooks.nsh", "installerHooks": "./windows/nsis-hooks.nsh",
"installMode": "perMachine",
"languages": [ "languages": [
"SimpChinese" "SimpChinese"
] ]
+23
View File
@@ -1,3 +1,26 @@
!macro ResolveCapacityReportInstallDir
${If} ${FileExists} "D:\*.*"
StrCpy $R9 "D:\Program Files\${PRODUCTNAME}"
${Else}
${If} ${RunningX64}
StrCpy $R9 "$PROGRAMFILES64\${PRODUCTNAME}"
${Else}
StrCpy $R9 "$PROGRAMFILES\${PRODUCTNAME}"
${EndIf}
${EndIf}
!macroend
!macro NSIS_HOOK_PREINSTALL
!insertmacro ResolveCapacityReportInstallDir
StrCpy $INSTDIR "$R9"
SetOutPath "$INSTDIR"
!macroend
!macro NSIS_HOOK_POSTINSTALL
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}"
DeleteRegKey HKCU "Software\${MANUFACTURER}\${PRODUCTNAME}"
!macroend
!macro NSIS_HOOK_PREUNINSTALL !macro NSIS_HOOK_PREUNINSTALL
nsExec::ExecToLog 'taskkill /F /T /IM capacity-report-desktop.exe' nsExec::ExecToLog 'taskkill /F /T /IM capacity-report-desktop.exe'
nsExec::ExecToLog 'taskkill /F /T /IM capareport-server.exe' nsExec::ExecToLog 'taskkill /F /T /IM capareport-server.exe'