From 90023663fd4f3b63401e82db4b6b40ca80f264fb Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 20 May 2026 11:27:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=9C=9F=E9=99=90=E6=BF=80=E6=B4=BB=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 7 +- app/api/routers/license.py | 26 ++++ app/api/routers/remote.py | 23 ++- app/api/routers/tasks.py | 26 +++- app/main.py | 3 +- app/services/license.py | 190 +++++++++++++++++++++++ docs/project_context.md | 9 ++ frontend/src/api/client.ts | 48 ++++-- frontend/src/components/FileWorkflow.vue | 147 +++++++++++++++++- frontend/src/styles.css | 29 ++++ frontend/src/types.ts | 21 ++- 12 files changed, 508 insertions(+), 22 deletions(-) create mode 100644 app/api/routers/license.py create mode 100644 app/services/license.py diff --git a/.gitignore b/.gitignore index f7766b1..2a25a00 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ CapacityReportData/ .env .env.local auth.ini +license.dat # Logs *.log diff --git a/README.md b/README.md index 6ac8298..71ab196 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的 - MySQL 数据表查看、清空、删除、CSV/XLSX 导出。 - SQL 脚本在线查看、保存和执行。 - 处理历史、日志查看、历史原始数据打包下载。 +- 按 ZIP 文件名数据日期校验本地授权期限,过期后可输入激活码顺延。 - 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。 - 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。 @@ -216,6 +217,8 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W 登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。 +授权到期日期保存在本地加密文件 `license.dat`,默认到期日为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。 + ## 常用接口 - `POST /api/login`:登录 @@ -225,6 +228,8 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W - `POST /api/remote/start`:远程下载并处理 - `POST /api/process/start`:启动本地处理 - `POST /api/process/status`:查询处理状态 +- `GET /api/license/status`:查询授权状态 +- `POST /api/license/activate`:提交激活码并顺延授权期限 - `GET /api/history`:处理历史 - `POST /api/history/download`:下载历史原始数据 - `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 会引用它。 - `ReportScript.sql` 是业务处理链路的一部分,修改前需要确认 SQL 语义和字段映射兼容。 diff --git a/app/api/routers/license.py b/app/api/routers/license.py new file mode 100644 index 0000000..83e1d53 --- /dev/null +++ b/app/api/routers/license.py @@ -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(), + } diff --git a/app/api/routers/remote.py b/app/api/routers/remote.py index 50c1a15..264ad69 100644 --- a/app/api/routers/remote.py +++ b/app/api/routers/remote.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Body, HTTPException from app import state from app.config import AppConfig, CACHE_DIR, RemoteDataConfig from app.processor import DataProcessor, ProcessLogger +from app.services.license import LicenseError, LicenseInfo, check_processing_allowed from app.services.remote_download import RemoteDataDownloader @@ -115,11 +116,13 @@ def _run_remote_processing( raise RuntimeError("远程目录中未下载到任何文件") 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) result = processor.process() status = "completed" if result.get("success") else "failed" + error = result.get("error") if status == "completed" and remote_config.auto_delete_source: try: deleted_count = downloader.delete_source_files(download_result.remote_files) @@ -131,21 +134,25 @@ def _run_remote_processing( task_id, status=status, elapsed_time=result.get("elapsed_time", 0), - error=result.get("error"), + error=error, result_tables=["4G_结果表", "5G_结果表"], ) state.processing_tasks[task_id] = { "logs": state.history_manager.get_logs(task_id), "status": status, "stage": status, + "error": error, } except Exception as exc: + error_detail = exc.to_detail() if isinstance(exc, LicenseError) else None logger.error(f"远程自动化任务失败: {exc}") state.history_manager.update(task_id, status="failed", error=str(exc)) state.processing_tasks[task_id] = { "logs": state.history_manager.get_logs(task_id), "status": "failed", "stage": "failed", + "error": str(exc), + "error_detail": error_detail, } finally: try: @@ -162,3 +169,15 @@ def _format_bytes(size: int) -> str: return f"{value:.1f} {unit}" value /= 1024 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 文件,已跳过授权日期比对") diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index d459107..32c5a92 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Body, HTTPException from app import state from app.config import AppConfig from app.processor import DataProcessor, ProcessLogger +from app.services.license import LicenseError, LicenseInfo, check_processing_allowed router = APIRouter(tags=["tasks"]) @@ -133,6 +134,8 @@ async def get_processing_status(task_id: str = Body(..., embed=True)): "status": task_info["status"], "stage": task_info.get("stage"), "logs": logs, + "error": task_info.get("error"), + "error_detail": task_info.get("error_detail"), } 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: try: + logger.set_stage("license") + _log_license_check(logger, check_processing_allowed(work_dir)) + processor = DataProcessor(app_config, work_dir, logger) result = processor.process() status = "completed" if result.get("success") else "failed" + error = result.get("error") state.history_manager.update( task_id, status=status, elapsed_time=result.get("elapsed_time", 0), - error=result.get("error"), + error=error, result_tables=["4G_结果表", "5G_结果表"], ) state.processing_tasks[task_id] = { "logs": state.history_manager.get_logs(task_id), "status": status, "stage": status, + "error": error, } 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.processing_tasks[task_id] = { "logs": state.history_manager.get_logs(task_id), "status": "failed", "stage": "failed", + "error": str(exc), + "error_detail": error_detail, } finally: try: @@ -198,3 +210,15 @@ def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_con except Exception as exc: print(f"自动清理处理历史失败: {exc}") 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 文件,已跳过授权日期比对") diff --git a/app/main.py b/app/main.py index 4b0c38c..9c5757c 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse 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.config import BASE_DIR @@ -64,6 +64,7 @@ def register_routes(app: FastAPI) -> None: remote.router, tasks.router, history.router, + license.router, database.router, config.router, cache.router, diff --git a/app/services/license.py b/app/services/license.py new file mode 100644 index 0000000..8db96b5 --- /dev/null +++ b/app/services/license.py @@ -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"(? 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)) diff --git a/docs/project_context.md b/docs/project_context.md index ec42693..5a89461 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -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:配置按请求实时重载 - `app/state.py` 新增 `reload_config()` 和 `current_config()`,后端接口不再长期依赖启动时的 `state.config` 快照;读取配置、下载配置、数据库接口、健康检查、本地处理、远程处理和脚本执行入口都会从 `Configure.json` 重新加载最新配置。 - 配置保存类接口会先重载当前文件再修改对应配置块并保存,避免用户手工更新 `Configure.json` 后,被某个单项保存接口用旧内存配置覆盖。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 87ed013..28f42f9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,9 +1,23 @@ -import type { ApiError } from '../types'; +import type { ApiError, ApiErrorDetail } from '../types'; const TOKEN_KEY = 'capacity_report_token'; const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); 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 { return localStorage.getItem(TOKEN_KEY) || ''; } @@ -49,8 +63,8 @@ export async function request(url: string, init: RequestInit = {}): Promise; @@ -111,7 +125,8 @@ export async function download(url: string, body: unknown, filename: string): Pr }); 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); @@ -132,31 +147,46 @@ export async function downloadGet(url: string, fallbackFilename: string): Promis } 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; await saveBlobResponse(response, filename); } -async function readError(response: Response): Promise { +async function readError(response: Response): Promise<{ message: string; detail?: ApiErrorDetail }> { try { const data = (await response.json()) as ApiError; - return data.detail || data.error || data.message || response.statusText; + return parseApiError(data, response.statusText); } catch { - return response.statusText || '请求失败'; + return { message: response.statusText || '请求失败' }; } } function parseXhrError(text: string): string { try { const data = JSON.parse(text) as ApiError; - return data.detail || data.error || data.message || '请求失败'; + return parseApiError(data, '请求失败').message; } catch { 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 { const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); diff --git a/frontend/src/components/FileWorkflow.vue b/frontend/src/components/FileWorkflow.vue index 32520df..567e676 100644 --- a/frontend/src/components/FileWorkflow.vue +++ b/frontend/src/components/FileWorkflow.vue @@ -146,6 +146,32 @@ + + +
+

{{ licenseErrorMessage }}

+
key: {{ activationKeyLabel }}
+ +
+ +
@@ -161,8 +187,8 @@ import { TrashOutline } from '@vicons/ionicons5'; -import { apiGet, apiPost, upload } from '../api/client'; -import type { ActiveTask, TaskStatus } from '../types'; +import { ApiRequestError, apiGet, apiPost, upload } from '../api/client'; +import type { ActiveTask, ApiErrorDetail, LicenseStatus, TaskStatus } from '../types'; type FileStatus = 'pending' | 'uploading' | 'uploaded' | 'error'; @@ -199,9 +225,17 @@ const taskStatus = ref(null); const activeTask = ref(null); const keepLatestLog = ref(false); const logContainer = ref(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) | null = null; let timer: number | undefined; const stageLabels: Record = { + license: '授权校验中...', downloading: '远程下载中...', uploading: '上传文件中...', processing: '处理中...', @@ -454,16 +488,16 @@ async function uploadAndStart() { item.progress = 100; }); message.success(`上传完成:${result.file_count ?? files.value.length} 个文件`); - await apiPost('/api/process/start', { task_id: result.task_id }); - taskStatus.value = { task_id: result.task_id, status: 'processing', stage: 'processing', logs: ['任务已提交,等待处理日志...'] }; - startPolling(result.task_id); + await startUploadedTask(result.task_id); } catch (error) { files.value.forEach(item => { if (item.status === 'uploading') { item.status = 'error'; } }); - message.error(error instanceof Error ? error.message : '上传或启动任务失败'); + if (!handleApiLicenseError(error)) { + message.error(error instanceof Error ? error.message : '上传或启动任务失败'); + } } finally { working.value = false; } @@ -490,14 +524,29 @@ async function startRemoteProcessing() { stage: result.stage || 'downloading', logs: ['远程下载任务已提交,等待处理日志...'] }; + taskMode.value = 'remote'; startPolling(result.task_id); } catch (error) { - message.error(error instanceof Error ? error.message : '启动远程自动化任务失败'); + if (!handleApiLicenseError(error)) { + message.error(error instanceof Error ? error.message : '启动远程自动化任务失败'); + } } finally { 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) { files.value.forEach(item => { if (item.status === 'uploading') { @@ -510,6 +559,7 @@ async function checkActiveTask() { try { activeTask.value = await apiGet('/api/task/status'); if (activeTask.value.has_active && activeTask.value.task_id) { + taskMode.value = 'unknown'; startPolling(activeTask.value.task_id); } } catch (error) { @@ -539,6 +589,9 @@ async function poll(taskId: string) { if (['completed', 'failed'].includes(taskStatus.value.status)) { stopPolling(); await checkActiveTask(); + if (taskStatus.value.status === 'failed') { + handleTaskLicenseError(taskStatus.value); + } } } catch (error) { 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) | 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('/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 { if (!stage) return '处理中...'; return stageLabels[stage] || '处理中...'; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 06f5037..d917140 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -619,6 +619,35 @@ select { 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 { margin-top: 20px; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 23e0f56..a390cc4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4,9 +4,27 @@ export interface LoginResponse { } export interface ApiError { - detail?: string; + detail?: string | ApiErrorDetail; error?: 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 { @@ -16,6 +34,7 @@ export interface TaskStatus { logs: string[]; elapsed_time?: number; error?: string; + error_detail?: ApiErrorDetail; } export interface ActiveTask {