feat: 增加使用期限激活限制

This commit is contained in:
2026-05-20 11:27:07 +08:00
parent fcac708814
commit 90023663fd
12 changed files with 508 additions and 22 deletions
+1
View File
@@ -44,6 +44,7 @@ CapacityReportData/
.env .env
.env.local .env.local
auth.ini auth.ini
license.dat
# Logs # Logs
*.log *.log
+6 -1
View File
@@ -9,6 +9,7 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的
- MySQL 数据表查看、清空、删除、CSV/XLSX 导出。 - MySQL 数据表查看、清空、删除、CSV/XLSX 导出。
- SQL 脚本在线查看、保存和执行。 - SQL 脚本在线查看、保存和执行。
- 处理历史、日志查看、历史原始数据打包下载。 - 处理历史、日志查看、历史原始数据打包下载。
- 按 ZIP 文件名数据日期校验本地授权期限,过期后可输入激活码顺延。
- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。 - 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。
- 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。 - 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。
@@ -216,6 +217,8 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。 登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
授权到期日期保存在本地加密文件 `license.dat`,默认到期日为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。
## 常用接口 ## 常用接口
- `POST /api/login`:登录 - `POST /api/login`:登录
@@ -225,6 +228,8 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
- `POST /api/remote/start`:远程下载并处理 - `POST /api/remote/start`:远程下载并处理
- `POST /api/process/start`:启动本地处理 - `POST /api/process/start`:启动本地处理
- `POST /api/process/status`:查询处理状态 - `POST /api/process/status`:查询处理状态
- `GET /api/license/status`:查询授权状态
- `POST /api/license/activate`:提交激活码并顺延授权期限
- `GET /api/history`:处理历史 - `GET /api/history`:处理历史
- `POST /api/history/download`:下载历史原始数据 - `POST /api/history/download`:下载历史原始数据
- `POST /api/service/restart`:重启服务 - `POST /api/service/restart`:重启服务
@@ -232,6 +237,6 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
## 维护注意事项 ## 维护注意事项
- 不要提交 `auth.ini`、`cache/`、`logs/`、`dist/`、`frontend/dist/`、`src-tauri/target/`、`src-tauri/binaries/`。 - 不要提交 `auth.ini`、`license.dat`、`cache/`、`logs/`、`dist/`、`frontend/dist/`、`src-tauri/target/`、`src-tauri/binaries/`。
- `src-tauri/gen/schemas/` 需要保留并提交,`src-tauri/capabilities/default.json` 的 JSON schema 会引用它。 - `src-tauri/gen/schemas/` 需要保留并提交,`src-tauri/capabilities/default.json` 的 JSON schema 会引用它。
- `ReportScript.sql` 是业务处理链路的一部分,修改前需要确认 SQL 语义和字段映射兼容。 - `ReportScript.sql` 是业务处理链路的一部分,修改前需要确认 SQL 语义和字段映射兼容。
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Body, HTTPException
from app.services.license import InvalidActivationCodeError, activate, get_license_info
router = APIRouter(tags=["license"])
@router.get("/api/license/status")
async def get_license_status():
info = get_license_info()
return {"success": True, **info.to_dict()}
@router.post("/api/license/activate")
async def activate_license(code: str = Body(..., embed=True)):
try:
info = activate(code)
except InvalidActivationCodeError as exc:
raise HTTPException(status_code=400, detail=exc.to_detail()) from exc
return {
"success": True,
"message": "激活成功,到期日期已延长 30 天",
**info.to_dict(),
}
+21 -2
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Body, HTTPException
from app import state from app import state
from app.config import AppConfig, CACHE_DIR, RemoteDataConfig from app.config import AppConfig, CACHE_DIR, RemoteDataConfig
from app.processor import DataProcessor, ProcessLogger from app.processor import DataProcessor, ProcessLogger
from app.services.license import LicenseError, LicenseInfo, check_processing_allowed
from app.services.remote_download import RemoteDataDownloader from app.services.remote_download import RemoteDataDownloader
@@ -115,11 +116,13 @@ def _run_remote_processing(
raise RuntimeError("远程目录中未下载到任何文件") raise RuntimeError("远程目录中未下载到任何文件")
state.history_manager.update(task_id, file_count=download_result.file_count) state.history_manager.update(task_id, file_count=download_result.file_count)
logger.set_stage("extracting") logger.set_stage("license")
_log_license_check(logger, check_processing_allowed(work_dir))
processor = DataProcessor(app_config, work_dir, logger) processor = DataProcessor(app_config, work_dir, logger)
result = processor.process() result = processor.process()
status = "completed" if result.get("success") else "failed" status = "completed" if result.get("success") else "failed"
error = result.get("error")
if status == "completed" and remote_config.auto_delete_source: if status == "completed" and remote_config.auto_delete_source:
try: try:
deleted_count = downloader.delete_source_files(download_result.remote_files) deleted_count = downloader.delete_source_files(download_result.remote_files)
@@ -131,21 +134,25 @@ def _run_remote_processing(
task_id, task_id,
status=status, status=status,
elapsed_time=result.get("elapsed_time", 0), elapsed_time=result.get("elapsed_time", 0),
error=result.get("error"), error=error,
result_tables=["4G_结果表", "5G_结果表"], result_tables=["4G_结果表", "5G_结果表"],
) )
state.processing_tasks[task_id] = { state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id), "logs": state.history_manager.get_logs(task_id),
"status": status, "status": status,
"stage": status, "stage": status,
"error": error,
} }
except Exception as exc: except Exception as exc:
error_detail = exc.to_detail() if isinstance(exc, LicenseError) else None
logger.error(f"远程自动化任务失败: {exc}") logger.error(f"远程自动化任务失败: {exc}")
state.history_manager.update(task_id, status="failed", error=str(exc)) state.history_manager.update(task_id, status="failed", error=str(exc))
state.processing_tasks[task_id] = { state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id), "logs": state.history_manager.get_logs(task_id),
"status": "failed", "status": "failed",
"stage": "failed", "stage": "failed",
"error": str(exc),
"error_detail": error_detail,
} }
finally: finally:
try: try:
@@ -162,3 +169,15 @@ def _format_bytes(size: int) -> str:
return f"{value:.1f} {unit}" return f"{value:.1f} {unit}"
value /= 1024 value /= 1024
return f"{value:.1f} TB" return f"{value:.1f} TB"
def _log_license_check(logger: ProcessLogger, info: LicenseInfo) -> None:
if info.current_date:
logger.info(
f"授权校验通过,数据日期: {info.current_date.isoformat()},"
f"到期日期: {info.expires_on.isoformat()}"
)
elif info.zip_count:
logger.warning("未从 ZIP 文件名识别到日期,已跳过授权日期比对")
else:
logger.warning("未找到 ZIP 文件,已跳过授权日期比对")
+25 -1
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Body, HTTPException
from app import state from app import state
from app.config import AppConfig from app.config import AppConfig
from app.processor import DataProcessor, ProcessLogger from app.processor import DataProcessor, ProcessLogger
from app.services.license import LicenseError, LicenseInfo, check_processing_allowed
router = APIRouter(tags=["tasks"]) router = APIRouter(tags=["tasks"])
@@ -133,6 +134,8 @@ async def get_processing_status(task_id: str = Body(..., embed=True)):
"status": task_info["status"], "status": task_info["status"],
"stage": task_info.get("stage"), "stage": task_info.get("stage"),
"logs": logs, "logs": logs,
"error": task_info.get("error"),
"error_detail": task_info.get("error_detail"),
} }
record = state.history_manager.get(task_id) record = state.history_manager.get(task_id)
@@ -170,27 +173,36 @@ def _set_task_stage(task_id: str, stage: str, logs: list[str], status: str = "pr
def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None: def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None:
try: try:
logger.set_stage("license")
_log_license_check(logger, check_processing_allowed(work_dir))
processor = DataProcessor(app_config, work_dir, logger) processor = DataProcessor(app_config, work_dir, logger)
result = processor.process() result = processor.process()
status = "completed" if result.get("success") else "failed" status = "completed" if result.get("success") else "failed"
error = result.get("error")
state.history_manager.update( state.history_manager.update(
task_id, task_id,
status=status, status=status,
elapsed_time=result.get("elapsed_time", 0), elapsed_time=result.get("elapsed_time", 0),
error=result.get("error"), error=error,
result_tables=["4G_结果表", "5G_结果表"], result_tables=["4G_结果表", "5G_结果表"],
) )
state.processing_tasks[task_id] = { state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id), "logs": state.history_manager.get_logs(task_id),
"status": status, "status": status,
"stage": status, "stage": status,
"error": error,
} }
except Exception as exc: except Exception as exc:
error_detail = exc.to_detail() if isinstance(exc, LicenseError) else None
logger.error(str(exc))
state.history_manager.update(task_id, status="failed", error=str(exc)) state.history_manager.update(task_id, status="failed", error=str(exc))
state.processing_tasks[task_id] = { state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id), "logs": state.history_manager.get_logs(task_id),
"status": "failed", "status": "failed",
"stage": "failed", "stage": "failed",
"error": str(exc),
"error_detail": error_detail,
} }
finally: finally:
try: try:
@@ -198,3 +210,15 @@ def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_con
except Exception as exc: except Exception as exc:
print(f"自动清理处理历史失败: {exc}") print(f"自动清理处理历史失败: {exc}")
state.reset_task_lock() state.reset_task_lock()
def _log_license_check(logger: ProcessLogger, info: LicenseInfo) -> None:
if info.current_date:
logger.info(
f"授权校验通过,数据日期: {info.current_date.isoformat()},"
f"到期日期: {info.expires_on.isoformat()}"
)
elif info.zip_count:
logger.warning("未从 ZIP 文件名识别到日期,已跳过授权日期比对")
else:
logger.warning("未找到 ZIP 文件,已跳过授权日期比对")
+2 -1
View File
@@ -7,7 +7,7 @@ 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, remote, script, service, tasks, upload from app.api.routers import auth, cache, config, database, health, history, license, remote, script, service, 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
@@ -64,6 +64,7 @@ def register_routes(app: FastAPI) -> None:
remote.router, remote.router,
tasks.router, tasks.router,
history.router, history.router,
license.router,
database.router, database.router,
config.router, config.router,
cache.router, cache.router,
+190
View File
@@ -0,0 +1,190 @@
"""本地授权期限校验。"""
import base64
import hashlib
import hmac
import json
import re
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
from app.config import BASE_DIR
DEFAULT_EXPIRES_ON = date(2026, 6, 20)
EXTEND_DAYS = 30
LICENSE_FILE = BASE_DIR / "license.dat"
_SECRET = b"CapacityReport local license v1"
_ZIP_DATE_RE = re.compile(r"(?<!\d)(20\d{10}(?:\d{2})?)(?!\d)")
class LicenseError(Exception):
"""授权校验错误。"""
code = "LICENSE_ERROR"
def to_detail(self) -> dict[str, Any]:
return {"code": self.code, "message": str(self)}
class LicenseExpiredError(LicenseError):
"""数据日期超过授权到期日期。"""
code = "LICENSE_EXPIRED"
def __init__(self, expires_on: date, current_date: date):
self.expires_on = expires_on
self.current_date = current_date
super().__init__(
f"授权已过期:数据日期 {current_date.isoformat()} 已超过到期日期 {expires_on.isoformat()}"
)
def to_detail(self) -> dict[str, Any]:
return {
"code": self.code,
"message": str(self),
"expires_on": self.expires_on.isoformat(),
"current_date": self.current_date.isoformat(),
"key_label": format_key_label(self.expires_on),
}
class InvalidActivationCodeError(LicenseError):
"""激活码错误。"""
code = "LICENSE_INVALID"
def __init__(self, expires_on: date):
self.expires_on = expires_on
super().__init__("激活码无效,请按当前 key 重新计算后输入")
def to_detail(self) -> dict[str, Any]:
return {
"code": self.code,
"message": str(self),
"expires_on": self.expires_on.isoformat(),
"key_label": format_key_label(self.expires_on),
}
@dataclass(frozen=True)
class LicenseInfo:
expires_on: date
current_date: date | None = None
zip_count: int = 0
@property
def key_label(self) -> str:
return format_key_label(self.expires_on)
def to_dict(self) -> dict[str, Any]:
return {
"expires_on": self.expires_on.isoformat(),
"key_label": self.key_label,
"current_date": self.current_date.isoformat() if self.current_date else None,
"zip_count": self.zip_count,
}
def get_license_info() -> LicenseInfo:
return LicenseInfo(expires_on=read_expires_on())
def activate(code: str) -> LicenseInfo:
expires_on = read_expires_on()
expected = activation_hash(expires_on)
normalized_code = (code or "").strip().lower()
if not hmac.compare_digest(normalized_code, expected):
raise InvalidActivationCodeError(expires_on)
new_expires_on = expires_on + timedelta(days=EXTEND_DAYS)
write_expires_on(new_expires_on)
return LicenseInfo(expires_on=new_expires_on)
def check_processing_allowed(work_dir: Path) -> LicenseInfo:
expires_on = read_expires_on()
zip_count, current_date = extract_max_zip_date(work_dir)
info = LicenseInfo(expires_on=expires_on, current_date=current_date, zip_count=zip_count)
if current_date and current_date > expires_on:
raise LicenseExpiredError(expires_on, current_date)
return info
def extract_max_zip_date(work_dir: Path) -> tuple[int, date | None]:
max_date: date | None = None
zip_count = 0
for zip_file in work_dir.rglob("*.zip"):
zip_count += 1
for raw_value in _ZIP_DATE_RE.findall(zip_file.name):
parsed_date = _parse_zip_timestamp(raw_value)
if parsed_date and (max_date is None or parsed_date > max_date):
max_date = parsed_date
return zip_count, max_date
def activation_hash(expires_on: date) -> str:
return hashlib.sha256(format_key_label(expires_on).encode("utf-8")).hexdigest()
def format_key_label(value: date) -> str:
return value.strftime("%Y/%m/%d")
def read_expires_on() -> date:
if not LICENSE_FILE.exists():
write_expires_on(DEFAULT_EXPIRES_ON)
return DEFAULT_EXPIRES_ON
try:
encrypted = base64.urlsafe_b64decode(LICENSE_FILE.read_text(encoding="utf-8").encode("ascii"))
raw = _xor_bytes(encrypted)
data = json.loads(raw.decode("utf-8"))
payload = data["payload"]
signature = data["signature"]
payload_raw = _dump_json(payload)
expected_signature = hmac.new(_SECRET, payload_raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
raise ValueError("signature mismatch")
return date.fromisoformat(str(payload["expires_on"]))
except Exception:
write_expires_on(DEFAULT_EXPIRES_ON)
return DEFAULT_EXPIRES_ON
def write_expires_on(expires_on: date) -> None:
payload = {"expires_on": expires_on.isoformat()}
payload_raw = _dump_json(payload)
data = {
"payload": payload,
"signature": hmac.new(_SECRET, payload_raw, hashlib.sha256).hexdigest(),
}
encrypted = _xor_bytes(_dump_json(data))
LICENSE_FILE.write_text(base64.urlsafe_b64encode(encrypted).decode("ascii"), encoding="utf-8")
def _parse_zip_timestamp(value: str) -> date | None:
fmt = "%Y%m%d%H%M%S" if len(value) == 14 else "%Y%m%d%H%M"
try:
return datetime.strptime(value, fmt).date()
except ValueError:
return None
def _dump_json(data: dict[str, Any]) -> bytes:
return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
def _xor_bytes(data: bytes) -> bytes:
output = bytearray()
counter = 0
while len(output) < len(data):
block = hashlib.sha256(_SECRET + counter.to_bytes(4, "big")).digest()
output.extend(block)
counter += 1
return bytes(value ^ key for value, key in zip(data, output))
+9
View File
@@ -1,4 +1,13 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-20:增加按 ZIP 数据日期校验的使用期限限制
- 新增 `app/services/license.py` 和 `/api/license/status`、`/api/license/activate`:本地 `license.dat` 用 XOR+HMAC 方式加密保存到期日期,缺失时自动初始化为 `2026-06-20`,文件已加入 `.gitignore`。
- 授权校验不读取系统日期;本地上传处理和远程下载完成后的处理入口会遍历任务目录下 ZIP 文件名,提取 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳并取最大日期作为数据日期,超过授权到期日则任务失败并返回 `LICENSE_EXPIRED` 详情。
- 激活码为当前到期日期 `YYYY/MM/DD` 字符串的 SHA-256 hex;每次激活只按当前加密文件里的到期日校验,成功后顺延 30 天,因此旧激活码不能重复顺延。
- `frontend/src/components/FileWorkflow.vue` 在任务因授权过期失败时弹出激活框,显示 `key: YYYY/MM/DD`,输入激活码成功后本地上传任务会继续处理,远程任务会重新发起远程下载处理。
- 如果任务中没有 ZIP,或 ZIP 文件名没有可识别时间戳,当前实现会写入警告并跳过授权日期比对,避免误伤直接 CSV/Excel 上传流程;如需强制所有数据都必须带 ZIP 日期,可在 `check_processing_allowed()` 中收紧该策略。
- 已执行授权逻辑临时目录验证、`.venv\Scripts\python.exe -m compileall app`、`uvx --offline ruff check .` 和 `npm run build`,均通过;前端构建仅保留 Vite 大 chunk 提示,生成产物已清理。
## 2026-05-19:配置按请求实时重载 ## 2026-05-19:配置按请求实时重载
- `app/state.py` 新增 `reload_config()` 和 `current_config()`,后端接口不再长期依赖启动时的 `state.config` 快照;读取配置、下载配置、数据库接口、健康检查、本地处理、远程处理和脚本执行入口都会从 `Configure.json` 重新加载最新配置。 - `app/state.py` 新增 `reload_config()` 和 `current_config()`,后端接口不再长期依赖启动时的 `state.config` 快照;读取配置、下载配置、数据库接口、健康检查、本地处理、远程处理和脚本执行入口都会从 `Configure.json` 重新加载最新配置。
- 配置保存类接口会先重载当前文件再修改对应配置块并保存,避免用户手工更新 `Configure.json` 后,被某个单项保存接口用旧内存配置覆盖。 - 配置保存类接口会先重载当前文件再修改对应配置块并保存,避免用户手工更新 `Configure.json` 后,被某个单项保存接口用旧内存配置覆盖。
+39 -9
View File
@@ -1,9 +1,23 @@
import type { ApiError } from '../types'; import type { ApiError, ApiErrorDetail } from '../types';
const TOKEN_KEY = 'capacity_report_token'; const TOKEN_KEY = 'capacity_report_token';
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
let onUnauthorized: (() => void) | null = null; let onUnauthorized: (() => void) | null = null;
export class ApiRequestError extends Error {
status: number;
code?: string;
detail?: ApiErrorDetail;
constructor(message: string, status: number, detail?: ApiErrorDetail) {
super(message);
this.name = 'ApiRequestError';
this.status = status;
this.code = detail?.code;
this.detail = detail;
}
}
export function getToken(): string { export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) || ''; return localStorage.getItem(TOKEN_KEY) || '';
} }
@@ -49,8 +63,8 @@ export async function request<T>(url: string, init: RequestInit = {}): Promise<T
} }
if (!response.ok) { if (!response.ok) {
const message = await readError(response); const error = await readError(response);
throw new Error(message); throw new ApiRequestError(error.message, response.status, error.detail);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
@@ -111,7 +125,8 @@ export async function download(url: string, body: unknown, filename: string): Pr
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(await readError(response)); const error = await readError(response);
throw new ApiRequestError(error.message, response.status, error.detail);
} }
await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename); await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename);
@@ -132,31 +147,46 @@ export async function downloadGet(url: string, fallbackFilename: string): Promis
} }
if (!response.ok) { if (!response.ok) {
throw new Error(await readError(response)); const error = await readError(response);
throw new ApiRequestError(error.message, response.status, error.detail);
} }
const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename; const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename;
await saveBlobResponse(response, filename); await saveBlobResponse(response, filename);
} }
async function readError(response: Response): Promise<string> { async function readError(response: Response): Promise<{ message: string; detail?: ApiErrorDetail }> {
try { try {
const data = (await response.json()) as ApiError; const data = (await response.json()) as ApiError;
return data.detail || data.error || data.message || response.statusText; return parseApiError(data, response.statusText);
} catch { } catch {
return response.statusText || '请求失败'; return { message: response.statusText || '请求失败' };
} }
} }
function parseXhrError(text: string): string { function parseXhrError(text: string): string {
try { try {
const data = JSON.parse(text) as ApiError; const data = JSON.parse(text) as ApiError;
return data.detail || data.error || data.message || '请求失败'; return parseApiError(data, '请求失败').message;
} catch { } catch {
return text || '请求失败'; return text || '请求失败';
} }
} }
function parseApiError(data: ApiError, fallback: string): { message: string; detail?: ApiErrorDetail } {
if (typeof data.detail === 'object' && data.detail !== null) {
return {
message: data.detail.message || data.message || data.error || fallback,
detail: data.detail
};
}
return {
message: data.detail || data.error || data.message || fallback,
detail: data.code ? { code: data.code, message: data.message || data.error } : undefined
};
}
async function saveBlobResponse(response: Response, filename: string): Promise<void> { async function saveBlobResponse(response: Response, filename: string): Promise<void> {
const blob = await response.blob(); const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
+140 -7
View File
@@ -146,6 +146,32 @@
</div> </div>
</div> </div>
</div> </div>
<n-modal
v-model:show="licenseModalVisible"
preset="card"
title="授权已过期"
:mask-closable="!activationLoading"
:style="{ width: '420px', maxWidth: 'calc(100vw - 32px)' }"
>
<div class="license-dialog-body">
<p class="license-dialog-text">{{ licenseErrorMessage }}</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>
<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>
</div> </div>
</template> </template>
@@ -161,8 +187,8 @@ import {
TrashOutline TrashOutline
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { apiGet, apiPost, upload } from '../api/client'; import { ApiRequestError, apiGet, apiPost, upload } from '../api/client';
import type { ActiveTask, TaskStatus } from '../types'; import type { ActiveTask, ApiErrorDetail, LicenseStatus, TaskStatus } from '../types';
type FileStatus = 'pending' | 'uploading' | 'uploaded' | 'error'; type FileStatus = 'pending' | 'uploading' | 'uploaded' | 'error';
@@ -199,9 +225,17 @@ const taskStatus = ref<TaskStatus | null>(null);
const activeTask = ref<ActiveTask | null>(null); const activeTask = ref<ActiveTask | null>(null);
const keepLatestLog = ref(false); const keepLatestLog = ref(false);
const logContainer = ref<HTMLElement | null>(null); const logContainer = ref<HTMLElement | null>(null);
const licenseModalVisible = ref(false);
const activationCode = ref('');
const activationLoading = ref(false);
const activationKeyLabel = ref('2026/06/20');
const licenseErrorMessage = ref('当前数据日期已超过授权到期日期,请输入激活码延长 30 天。');
const taskMode = ref<'local' | 'remote' | 'unknown'>('unknown');
let activationRetry: (() => Promise<void>) | null = null;
let timer: number | undefined; let timer: number | undefined;
const stageLabels: Record<string, string> = { const stageLabels: Record<string, string> = {
license: '授权校验中...',
downloading: '远程下载中...', downloading: '远程下载中...',
uploading: '上传文件中...', uploading: '上传文件中...',
processing: '处理中...', processing: '处理中...',
@@ -454,16 +488,16 @@ async function uploadAndStart() {
item.progress = 100; item.progress = 100;
}); });
message.success(`上传完成:${result.file_count ?? files.value.length} 个文件`); message.success(`上传完成:${result.file_count ?? files.value.length} 个文件`);
await apiPost('/api/process/start', { task_id: result.task_id }); await startUploadedTask(result.task_id);
taskStatus.value = { task_id: result.task_id, status: 'processing', stage: 'processing', logs: ['任务已提交,等待处理日志...'] };
startPolling(result.task_id);
} catch (error) { } catch (error) {
files.value.forEach(item => { files.value.forEach(item => {
if (item.status === 'uploading') { if (item.status === 'uploading') {
item.status = 'error'; item.status = 'error';
} }
}); });
message.error(error instanceof Error ? error.message : '上传或启动任务失败'); if (!handleApiLicenseError(error)) {
message.error(error instanceof Error ? error.message : '上传或启动任务失败');
}
} finally { } finally {
working.value = false; working.value = false;
} }
@@ -490,14 +524,29 @@ async function startRemoteProcessing() {
stage: result.stage || 'downloading', stage: result.stage || 'downloading',
logs: ['远程下载任务已提交,等待处理日志...'] logs: ['远程下载任务已提交,等待处理日志...']
}; };
taskMode.value = 'remote';
startPolling(result.task_id); startPolling(result.task_id);
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '启动远程自动化任务失败'); if (!handleApiLicenseError(error)) {
message.error(error instanceof Error ? error.message : '启动远程自动化任务失败');
}
} finally { } finally {
remoteStarting.value = false; remoteStarting.value = false;
} }
} }
async function startUploadedTask(taskId: string) {
await apiPost('/api/process/start', { task_id: taskId });
taskMode.value = 'local';
taskStatus.value = {
task_id: taskId,
status: 'processing',
stage: 'license',
logs: ['任务已提交,等待处理日志...']
};
startPolling(taskId);
}
function updateUploadingFiles(progress: number) { function updateUploadingFiles(progress: number) {
files.value.forEach(item => { files.value.forEach(item => {
if (item.status === 'uploading') { if (item.status === 'uploading') {
@@ -510,6 +559,7 @@ async function checkActiveTask() {
try { try {
activeTask.value = await apiGet<ActiveTask>('/api/task/status'); activeTask.value = await apiGet<ActiveTask>('/api/task/status');
if (activeTask.value.has_active && activeTask.value.task_id) { if (activeTask.value.has_active && activeTask.value.task_id) {
taskMode.value = 'unknown';
startPolling(activeTask.value.task_id); startPolling(activeTask.value.task_id);
} }
} catch (error) { } catch (error) {
@@ -539,6 +589,9 @@ async function poll(taskId: string) {
if (['completed', 'failed'].includes(taskStatus.value.status)) { if (['completed', 'failed'].includes(taskStatus.value.status)) {
stopPolling(); stopPolling();
await checkActiveTask(); await checkActiveTask();
if (taskStatus.value.status === 'failed') {
handleTaskLicenseError(taskStatus.value);
}
} }
} catch (error) { } catch (error) {
stopPolling(); stopPolling();
@@ -546,6 +599,86 @@ async function poll(taskId: string) {
} }
} }
function handleTaskLicenseError(status: TaskStatus): boolean {
const detail = status.error_detail || parseLicenseDetail(status.error || '');
if (detail?.code !== 'LICENSE_EXPIRED') {
return false;
}
const taskId = status.task_id;
const retry =
taskMode.value === 'local'
? () => startUploadedTask(taskId)
: taskMode.value === 'remote'
? () => startRemoteProcessing()
: null;
openLicenseModal(detail, retry);
return true;
}
function handleApiLicenseError(error: unknown): boolean {
if (!(error instanceof ApiRequestError) || error.code !== 'LICENSE_EXPIRED') {
return false;
}
openLicenseModal(error.detail);
return true;
}
function openLicenseModal(detail?: ApiErrorDetail, retry?: (() => Promise<void>) | null) {
activationKeyLabel.value = detail?.key_label || activationKeyLabel.value;
licenseErrorMessage.value =
detail?.message || '当前数据日期已超过授权到期日期,请输入激活码延长 30 天。';
activationCode.value = '';
activationRetry = retry || null;
licenseModalVisible.value = true;
}
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;
licenseModalVisible.value = false;
message.success(`激活成功,到期日期: ${result.expires_on}`);
if (activationRetry) {
const retry = activationRetry;
activationRetry = null;
await retry();
}
} catch (error) {
if (error instanceof ApiRequestError && error.detail?.key_label) {
activationKeyLabel.value = error.detail.key_label;
}
message.error(error instanceof Error ? error.message : '激活失败');
} finally {
activationLoading.value = false;
}
}
function parseLicenseDetail(text: string): ApiErrorDetail | null {
if (!text.includes('授权已过期')) {
return null;
}
const expiresMatch = text.match(/到期日期\s+(\d{4}-\d{2}-\d{2})/);
const currentMatch = text.match(/数据日期\s+(\d{4}-\d{2}-\d{2})/);
const expiresOn = expiresMatch?.[1];
return {
code: 'LICENSE_EXPIRED',
message: text,
expires_on: expiresOn,
current_date: currentMatch?.[1],
key_label: expiresOn ? expiresOn.replace(/-/g, '/') : activationKeyLabel.value
};
}
function stageText(stage?: string | null): string { function stageText(stage?: string | null): string {
if (!stage) return '处理中...'; if (!stage) return '处理中...';
return stageLabels[stage] || '处理中...'; return stageLabels[stage] || '处理中...';
+29
View File
@@ -619,6 +619,35 @@ select {
display: inline-flex; display: inline-flex;
} }
.license-dialog-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.license-dialog-text {
margin: 0;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.license-key-label {
padding: 8px 10px;
color: var(--td-brand-color);
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
background: var(--td-brand-color-light);
border: 1px solid var(--td-brand-color-focus);
border-radius: var(--td-radius-default);
}
.license-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.file-list { .file-list {
margin-top: 20px; margin-top: 20px;
} }
+20 -1
View File
@@ -4,9 +4,27 @@ export interface LoginResponse {
} }
export interface ApiError { export interface ApiError {
detail?: string; detail?: string | ApiErrorDetail;
error?: string; error?: string;
message?: string; message?: string;
code?: string;
}
export interface ApiErrorDetail {
code?: string;
message?: string;
expires_on?: string;
current_date?: string;
key_label?: string;
[key: string]: unknown;
}
export interface LicenseStatus {
success: boolean;
expires_on: string;
key_label: string;
current_date?: string | null;
zip_count?: number;
} }
export interface TaskStatus { export interface TaskStatus {
@@ -16,6 +34,7 @@ export interface TaskStatus {
logs: string[]; logs: string[];
elapsed_time?: number; elapsed_time?: number;
error?: string; error?: string;
error_detail?: ApiErrorDetail;
} }
export interface ActiveTask { export interface ActiveTask {