From 89cca70430b134988332c07d7457b49995af57e1 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Thu, 24 Sep 2026 06:16:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=BC=E5=85=A5=20CapacityReport=20?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=BA=90=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 37 + .gitignore | 88 + CellData.sql | 84 + Configure.json | 610 ++ DEVELOPMENT.md | 170 + Dockerfile | 27 + README.md | 115 + ReportScript.sql | 797 +++ USAGE.md | 153 + app/__init__.py | 2 + app/api/__init__.py | 2 + app/api/routers/__init__.py | 2 + app/api/routers/auth.py | 36 + app/api/routers/cache.py | 44 + app/api/routers/cell_data.py | 197 + app/api/routers/config.py | 365 ++ app/api/routers/dashboard.py | 339 ++ app/api/routers/database.py | 430 ++ app/api/routers/health.py | 38 + app/api/routers/history.py | 243 + app/api/routers/license.py | 26 + app/api/routers/remote.py | 244 + app/api/routers/script.py | 141 + app/api/routers/task_runtime.py | 36 + app/api/routers/tasks.py | 241 + app/api/routers/upload.py | 112 + app/auth.py | 140 + app/config.py | 657 +++ app/database.py | 562 ++ app/db_init.py | 155 + app/history.py | 251 + app/main.py | 192 + app/processor.py | 1093 ++++ app/services/__init__.py | 2 + app/services/auto_scheduler.py | 669 +++ app/services/cell_data.py | 648 +++ app/services/csv_processor.py | 412 ++ app/services/license.py | 190 + app/services/pipeline.py | 103 + app/services/platform.py | 312 ++ app/services/remote_download.py | 488 ++ app/state.py | 43 + app/utils/__init__.py | 2 + app/utils/file_dates.py | 174 + app/utils/files.py | 46 + app/warehouse.py | 229 + db_init/README.md | 53 + db_init/celldata.cellinfo.sql | 17 + db_init/celldata.sector.sql | 14 + db_init/celldata.sector_band_ref.sql | 24 + docker/apply_env_config.py | 72 + docker/entrypoint.sh | 21 + docs/auto_scheduler_plan.md | 366 ++ docs/platform_architecture_plan.md | 333 ++ docs/project_context.md | 1122 ++++ docs/sector_inference_research.md | 142 + docs/高负荷小区判定_调研记录.md | 196 + frontend/index.html | 13 + frontend/package-lock.json | 2393 ++++++++ frontend/package.json | 34 + frontend/src/App.vue | 19 + frontend/src/AppShell.vue | 318 ++ frontend/src/api/client.ts | 394 ++ frontend/src/components/CapacityDashboard.vue | 980 ++++ frontend/src/components/DatabasePanel.vue | 1564 ++++++ frontend/src/components/EChart.vue | 43 + frontend/src/components/FileWorkflow.vue | 1006 ++++ frontend/src/components/HistoryPanel.vue | 470 ++ .../src/components/LicenseActivationModal.vue | 124 + frontend/src/components/LoginView.vue | 44 + frontend/src/components/ScriptPanel.vue | 403 ++ frontend/src/components/SettingsPanel.vue | 2021 +++++++ frontend/src/composables/clipboard.ts | 23 + frontend/src/composables/downloadFeedback.ts | 32 + frontend/src/composables/logLines.ts | 28 + frontend/src/composables/metrixEnabled.ts | 3 + frontend/src/composables/pageHeader.ts | 46 + frontend/src/composables/theme.ts | 40 + frontend/src/main.ts | 24 + frontend/src/router.ts | 24 + frontend/src/styles.css | 2376 ++++++++ frontend/src/types.ts | 271 + frontend/src/vite-env.d.ts | 3 + frontend/tsconfig.json | 22 + frontend/tsconfig.node.json | 12 + frontend/vite.config.ts | 27 + packaging/README.md | 124 + packaging/capareport-server.spec | 99 + packaging/docker-compose.capacityrepost.yml | 67 + packaging/docker-compose.yml | 105 + packaging/mysql/conf.d/custom.cnf | 29 + packaging/mysql/init/01-init-db.sql | 14 + packaging/sftpgo/sftpgo-init.json | 13 + requirements.txt | 17 + scripts/_env.py | 342 ++ scripts/build_docker.py | 92 + scripts/build_server.py | 105 + scripts/build_tauri.py | 255 + scripts/clean.py | 91 + scripts/dev.py | 150 + scripts/dev_tauri.py | 54 + scripts/gen_license_code.py | 72 + scripts/run.py | 49 + src-tauri/Cargo.lock | 4943 +++++++++++++++++ src-tauri/Cargo.toml | 21 + src-tauri/build.rs | 3 + src-tauri/capabilities/default.json | 7 + src-tauri/gen/schemas/acl-manifests.json | 1 + src-tauri/gen/schemas/capabilities.json | 1 + src-tauri/gen/schemas/desktop-schema.json | 2678 +++++++++ src-tauri/gen/schemas/windows-schema.json | 2678 +++++++++ src-tauri/icons/128x128.png | Bin 0 -> 6124 bytes src-tauri/icons/128x128@2x.png | Bin 0 -> 11112 bytes src-tauri/icons/32x32.png | Bin 0 -> 1377 bytes src-tauri/icons/64x64.png | Bin 0 -> 2859 bytes src-tauri/icons/Square107x107Logo.png | Bin 0 -> 5452 bytes src-tauri/icons/Square142x142Logo.png | Bin 0 -> 6886 bytes src-tauri/icons/Square150x150Logo.png | Bin 0 -> 7087 bytes src-tauri/icons/Square284x284Logo.png | Bin 0 -> 12399 bytes src-tauri/icons/Square30x30Logo.png | Bin 0 -> 1309 bytes src-tauri/icons/Square310x310Logo.png | Bin 0 -> 13587 bytes src-tauri/icons/Square44x44Logo.png | Bin 0 -> 1972 bytes src-tauri/icons/Square71x71Logo.png | Bin 0 -> 3389 bytes src-tauri/icons/Square89x89Logo.png | Bin 0 -> 4319 bytes src-tauri/icons/StoreLogo.png | Bin 0 -> 2381 bytes src-tauri/icons/app-icon.png | Bin 0 -> 103835 bytes src-tauri/icons/icon.icns | Bin 0 -> 110073 bytes src-tauri/icons/icon.ico | Bin 0 -> 23493 bytes src-tauri/icons/icon.png | Bin 0 -> 21548 bytes src-tauri/src/main.rs | 366 ++ src-tauri/tauri.conf.json | 60 + src-tauri/windows/nsis-hooks.nsh | 34 + tests/test_database_import.py | 33 + tests/test_database_import_mysql.py | 232 + 134 files changed, 38799 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CellData.sql create mode 100644 Configure.json create mode 100644 DEVELOPMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 ReportScript.sql create mode 100644 USAGE.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/routers/__init__.py create mode 100644 app/api/routers/auth.py create mode 100644 app/api/routers/cache.py create mode 100644 app/api/routers/cell_data.py create mode 100644 app/api/routers/config.py create mode 100644 app/api/routers/dashboard.py create mode 100644 app/api/routers/database.py create mode 100644 app/api/routers/health.py create mode 100644 app/api/routers/history.py create mode 100644 app/api/routers/license.py create mode 100644 app/api/routers/remote.py create mode 100644 app/api/routers/script.py create mode 100644 app/api/routers/task_runtime.py create mode 100644 app/api/routers/tasks.py create mode 100644 app/api/routers/upload.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/db_init.py create mode 100644 app/history.py create mode 100644 app/main.py create mode 100644 app/processor.py create mode 100644 app/services/__init__.py create mode 100644 app/services/auto_scheduler.py create mode 100644 app/services/cell_data.py create mode 100644 app/services/csv_processor.py create mode 100644 app/services/license.py create mode 100644 app/services/pipeline.py create mode 100644 app/services/platform.py create mode 100644 app/services/remote_download.py create mode 100644 app/state.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/file_dates.py create mode 100644 app/utils/files.py create mode 100644 app/warehouse.py create mode 100644 db_init/README.md create mode 100644 db_init/celldata.cellinfo.sql create mode 100644 db_init/celldata.sector.sql create mode 100644 db_init/celldata.sector_band_ref.sql create mode 100644 docker/apply_env_config.py create mode 100644 docker/entrypoint.sh create mode 100644 docs/auto_scheduler_plan.md create mode 100644 docs/platform_architecture_plan.md create mode 100644 docs/project_context.md create mode 100644 docs/sector_inference_research.md create mode 100644 docs/高负荷小区判定_调研记录.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/AppShell.vue create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/CapacityDashboard.vue create mode 100644 frontend/src/components/DatabasePanel.vue create mode 100644 frontend/src/components/EChart.vue create mode 100644 frontend/src/components/FileWorkflow.vue create mode 100644 frontend/src/components/HistoryPanel.vue create mode 100644 frontend/src/components/LicenseActivationModal.vue create mode 100644 frontend/src/components/LoginView.vue create mode 100644 frontend/src/components/ScriptPanel.vue create mode 100644 frontend/src/components/SettingsPanel.vue create mode 100644 frontend/src/composables/clipboard.ts create mode 100644 frontend/src/composables/downloadFeedback.ts create mode 100644 frontend/src/composables/logLines.ts create mode 100644 frontend/src/composables/metrixEnabled.ts create mode 100644 frontend/src/composables/pageHeader.ts create mode 100644 frontend/src/composables/theme.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 packaging/README.md create mode 100644 packaging/capareport-server.spec create mode 100644 packaging/docker-compose.capacityrepost.yml create mode 100644 packaging/docker-compose.yml create mode 100644 packaging/mysql/conf.d/custom.cnf create mode 100644 packaging/mysql/init/01-init-db.sql create mode 100644 packaging/sftpgo/sftpgo-init.json create mode 100644 requirements.txt create mode 100644 scripts/_env.py create mode 100644 scripts/build_docker.py create mode 100644 scripts/build_server.py create mode 100644 scripts/build_tauri.py create mode 100644 scripts/clean.py create mode 100644 scripts/dev.py create mode 100644 scripts/dev_tauri.py create mode 100644 scripts/gen_license_code.py create mode 100644 scripts/run.py create mode 100644 src-tauri/Cargo.lock create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/gen/schemas/acl-manifests.json create mode 100644 src-tauri/gen/schemas/capabilities.json create mode 100644 src-tauri/gen/schemas/desktop-schema.json create mode 100644 src-tauri/gen/schemas/windows-schema.json create mode 100644 src-tauri/icons/128x128.png create mode 100644 src-tauri/icons/128x128@2x.png create mode 100644 src-tauri/icons/32x32.png create mode 100644 src-tauri/icons/64x64.png create mode 100644 src-tauri/icons/Square107x107Logo.png create mode 100644 src-tauri/icons/Square142x142Logo.png create mode 100644 src-tauri/icons/Square150x150Logo.png create mode 100644 src-tauri/icons/Square284x284Logo.png create mode 100644 src-tauri/icons/Square30x30Logo.png create mode 100644 src-tauri/icons/Square310x310Logo.png create mode 100644 src-tauri/icons/Square44x44Logo.png create mode 100644 src-tauri/icons/Square71x71Logo.png create mode 100644 src-tauri/icons/Square89x89Logo.png create mode 100644 src-tauri/icons/StoreLogo.png create mode 100644 src-tauri/icons/app-icon.png create mode 100644 src-tauri/icons/icon.icns create mode 100644 src-tauri/icons/icon.ico create mode 100644 src-tauri/icons/icon.png create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src-tauri/windows/nsis-hooks.nsh create mode 100644 tests/test_database_import.py create mode 100644 tests/test_database_import_mysql.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1fe5cb8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +.git +.gitignore +.venv +venv +ENV +env +__pycache__ +*.pyc +.ruff_cache + +frontend/node_modules +frontend/.vite + +cache +logs +uploads +CapacityReportData +auth.ini +license.dat +api_tokens.json +*.log +*.tmp +*.temp +*.bak + +target +src-tauri +packaging +scripts +docs +platform +*.bat + +.env +.env.local +.DS_Store +Thumbs.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6027863 --- /dev/null +++ b/.gitignore @@ -0,0 +1,88 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ +.ENV/ + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# Build output and runtime data +dist/ +cache/ +logs/ +uploads/ +platform/ +src-tauri/target/ +src-tauri/binaries/ +CapacityReportData/ +.ruff_cache/ + +# Local configuration and secrets +aidocs/ +.env +.env.local +auth.ini +license.dat +api_tokens.json +.mcp.json + +# Logs +*.log + +# Databases +*.db +*.sqlite +*.sqlite3 + +# Temporary files +*.tmp +*.temp +*.bak +*.cache + +# Data files uploaded or generated during weekly processing +*.zip +*.xlsx +*.xls +*.csv + +# Test data tooling and generated test data (local only) +test/ +temp/ + +# IDE and OS +.claude/ +.vscode/ +.idea/ +*.swp +*.swo +*~ +nul +.DS_Store +Thumbs.db +desktop.ini +docs/upload/ diff --git a/CellData.sql b/CellData.sql new file mode 100644 index 0000000..125f0ce --- /dev/null +++ b/CellData.sql @@ -0,0 +1,84 @@ +-- ============================================================================= +-- CellData 数据处理脚本(在 celldata 库执行) +-- 1) 规范化 cellinfo.带宽 +-- 2) 由 cellinfo 逆推 sector(扇区/物理站/制式/频段/带宽/站型/网络) +-- —— 不清空 sector,仅补充缺失 CGI,已有行(含人工修正)保留不覆盖 +-- 依赖的结构表(cellinfo / sector / 频段特征库 sector_band_ref)由程序前置检查 +-- (app/db_init.py + db_init/ 目录)保证存在,缺表会自动按预设建好;sector_band_ref +-- 一经创建即持久保留,可在数据库中自行增改频段规则,本脚本不再重建以免冲掉自定义。 +-- 逆推规则与准确率见 docs/sector_inference_research.md +-- ============================================================================= + +-- 1. 带宽规范化:空值补 0M,统一大写并以 M 结尾(如 20M) +UPDATE cellinfo SET `带宽` = IF(`带宽` IS NULL OR `带宽` = '', '0M', CONCAT(REPLACE(UPPER(`带宽`),'M',''), 'M')); + +-- 2. 逆推暂存表:预处理小区名称(全角括号->半角、【】->()、去空格/制表符) +DROP TABLE IF EXISTS _sector_infer; +CREATE TABLE _sector_infer AS +SELECT + c.CGI, c.`网络`, c.`带宽`, c.PLMN, c.`频点`, c.`制式` AS ci_zs, + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + c.`小区名称`,' ',''),CHAR(9),''),' ',''),'(','('),')',')'),'【','('),'】',')') AS name1 +FROM cellinfo c; + +ALTER TABLE _sector_infer + ADD COLUMN `制式` varchar(20), + ADD COLUMN `频段` varchar(20), + ADD COLUMN `站型` varchar(10), + ADD COLUMN numstr varchar(20), + ADD COLUMN base varchar(220), + ADD COLUMN `物理站` varchar(200), + ADD COLUMN `扇区` varchar(210); + +-- 2.1 设备码([频率前缀]-Z[xx]-[扇区号])剥离得到 base;尾部数字串;站型判定 +-- 站型:设备码尾字母 W=室分;名称含"微小"=微站;其余=宏站 +UPDATE _sector_infer SET + base = REGEXP_REPLACE(name1,'[A-Z0-9]+-Z[A-Z0-9]{2}-[0-9]+$',''), + numstr = REGEXP_SUBSTR(name1,'[0-9]+$'), + `站型` = CASE + WHEN name1 REGEXP 'Z[A-Z]W-[0-9]+$' THEN '室分' + WHEN name1 LIKE '%微小%' THEN '微站' + ELSE '宏站' END; + +-- 2.2 制式/频段:按特征库匹配;未命中回落 cellinfo 原制式 +UPDATE _sector_infer t +JOIN sector_band_ref r + ON r.`网络` COLLATE utf8mb4_unicode_ci = t.`网络` COLLATE utf8mb4_unicode_ci + AND CAST(NULLIF(t.`频点`,'') AS DECIMAL(10,2)) >= r.`频点下限` + AND CAST(NULLIF(t.`频点`,'') AS DECIMAL(10,2)) < r.`频点上限` + AND (r.`PLMN` IS NULL OR r.`PLMN` COLLATE utf8mb4_unicode_ci = t.PLMN COLLATE utf8mb4_unicode_ci) +SET t.`制式` = r.`制式`, t.`频段` = r.`频段`; +UPDATE _sector_infer SET `制式` = ci_zs, `频段` = ci_zs WHERE `制式` IS NULL; + +-- 2.3 物理站(非 700M/广电):去码后删全部括号注记,保留(微小X)并回贴; +-- 再去掉不闭合的悬空开括号与孤立右括号 +UPDATE _sector_infer SET `物理站` = CONCAT( + TRIM(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE( + base,'\\([^()]*\\)',''),'\\([^()]*\\)',''),'\\([^()]*\\)',''),'\\([^()]*$',''),'[)]+$',''),'^[)]+','')), + IFNULL(REGEXP_SUBSTR(name1,'[(]微小[A-Z]?[)]'),'')) +WHERE `制式` IS NULL OR `制式` NOT IN ('700M','广电'); + +-- 2.4 物理站(700M/广电):真实站址在括号内,且与外层同「地市」前缀(小区名开头即地市,通用不限江门)。 +-- 取外层名(去 CBN-)的前 2 个字作为地市前缀,匹配以该前缀开头的括号内容为物理站; +-- 无匹配则取外层(共建/从…拉远等注记括号因不以地市开头,自然落到外层)。 +UPDATE _sector_infer SET `物理站` = + CASE WHEN REGEXP_SUBSTR(base, CONCAT('\\(', LEFT(REGEXP_REPLACE(REGEXP_REPLACE(base,'\\(.*$',''),'^CBN-?',''), 2), '[^()]*')) <> '' + THEN TRIM(SUBSTRING(REGEXP_SUBSTR(base, CONCAT('\\(', LEFT(REGEXP_REPLACE(REGEXP_REPLACE(base,'\\(.*$',''),'^CBN-?',''), 2), '[^()]*')), 2)) + ELSE TRIM(REGEXP_REPLACE(REGEXP_REPLACE(base,'\\(.*$',''),'^CBN-?','')) END +WHERE `制式` IN ('700M','广电'); + +-- 2.5 扇区 = 物理站 + 扇区号(700M/广电取末位数字;其余取数字串 % 100) +UPDATE _sector_infer SET `扇区` = CONCAT(`物理站`, + CASE WHEN numstr IS NULL OR numstr = '' THEN '' + WHEN `制式` IN ('700M','广电') THEN RIGHT(numstr,1) + ELSE CAST(CAST(numstr AS UNSIGNED) % 100 AS CHAR) END); + +-- 3. 补缺写回 sector:仅插入 sector 中尚不存在的 CGI +-- 已存在的 CGI 一律保留,避免人工修正(如 3DMM、人工纠正的物理站/带宽)被覆盖 +INSERT INTO sector (CGI,`扇区`,`物理站`,`制式`,`频段`,`带宽`,`站型`,`网络`) +SELECT i.CGI, i.`扇区`, i.`物理站`, i.`制式`, i.`频段`, i.`带宽`, i.`站型`, i.`网络` +FROM _sector_infer i +LEFT JOIN sector s ON s.CGI = i.CGI +WHERE s.CGI IS NULL; + +DROP TABLE _sector_infer; diff --git a/Configure.json b/Configure.json new file mode 100644 index 0000000..e2e7b72 --- /dev/null +++ b/Configure.json @@ -0,0 +1,610 @@ +{ + "Update": "2026/07/13 12:02:36", + "MetrixEnabled": true, + "SourceType": "external", + "WarehouseType": "mysql", + "MySQL_DBInfo": { + "host": "127.0.0.1", + "port": 3306, + "user": "root", + "dbname": "CapacityReport", + "passwd": "123456" + }, + "Metrix": { + "base_url": "http://host.docker.internal:8000", + "storage_id": "", + "database_conn_id": "", + "target_database": "", + "recent_days": 7, + "token": "" + }, + "DataMappings": { + "directories": [ + { + "path": "4G", + "table": "4G_UD", + "ready_rule": "daily" + }, + { + "path": "5G", + "table": "5G_UD", + "ready_rule": "daily" + }, + { + "path": "RJ/2.6G/2.6RJGD", + "table": "2_6GRJGD", + "ready_rule": "auto" + }, + { + "path": "RJ/2.6G/2.6RJYD", + "table": "2_6GRJYD", + "ready_rule": "auto" + }, + { + "path": "RJ/700M/700RJGD", + "table": "700MRJGD", + "ready_rule": "auto" + }, + { + "path": "RJ/700M/700RJYD", + "table": "700MRJYD", + "ready_rule": "auto" + } + ], + "table_field_mappings": { + "2_6GRJGD": [ + { + "Source": "开始时间", + "Target": "开始时间", + "Type": "datetime" + }, + { + "Source": "结束时间", + "Target": "结束时间", + "Type": "datetime" + }, + { + "Source": "gNBId", + "Target": "gNBId", + "Type": "string" + }, + { + "Source": "cellId", + "Target": "cellId", + "Type": "string" + }, + { + "Source": "gNBplmn", + "Target": "gNBplmn", + "Type": "string" + }, + { + "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-GD(GB)", + "Target": "上下行总流量_GB", + "Type": "float" + } + ], + "2_6GRJYD": [ + { + "Source": "开始时间", + "Target": "开始时间", + "Type": "datetime" + }, + { + "Source": "结束时间", + "Target": "结束时间", + "Type": "datetime" + }, + { + "Source": "gNBId", + "Target": "gNBId", + "Type": "string" + }, + { + "Source": "cellId", + "Target": "cellId", + "Type": "string" + }, + { + "Source": "gNBplmn", + "Target": "gNBplmn", + "Type": "string" + }, + { + "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-YD(GB)", + "Target": "上下行总流量_GB", + "Type": "float" + } + ], + "700MRJGD": [ + { + "Source": "开始时间", + "Target": "开始时间", + "Type": "datetime" + }, + { + "Source": "结束时间", + "Target": "结束时间", + "Type": "datetime" + }, + { + "Source": "gNBId", + "Target": "gNBId", + "Type": "string" + }, + { + "Source": "cellId", + "Target": "cellId", + "Type": "string" + }, + { + "Source": "gNBplmn", + "Target": "gNBplmn", + "Type": "string" + }, + { + "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-GD(GB)", + "Target": "上下行总流量_GB", + "Type": "float" + } + ], + "700MRJYD": [ + { + "Source": "开始时间", + "Target": "开始时间", + "Type": "datetime" + }, + { + "Source": "结束时间", + "Target": "结束时间", + "Type": "datetime" + }, + { + "Source": "gNBId", + "Target": "gNBId", + "Type": "string" + }, + { + "Source": "cellId", + "Target": "cellId", + "Type": "string" + }, + { + "Source": "gNBplmn", + "Target": "gNBplmn", + "Type": "string" + }, + { + "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-YD(GB)", + "Target": "上下行总流量_GB", + "Type": "float" + }, + { + "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)-YD(GB)", + "Target": "上下行总流量_GB", + "Type": "float" + } + ] + } + }, + "RemoteData": { + "enabled": true, + "protocol": "sftp", + "host": "127.0.0.1", + "port": 2022, + "user": "user1", + "remote_dir": "/CapacityReportData", + "passive": true, + "timeout": 30, + "auto_delete_source": true, + "auto_scheduler": { + "enabled": false, + "check_interval_hours": 1, + "expected_directories": [], + "week_offset": 0 + }, + "passwd": "123456" + }, + "CellData": { + "remote_data": { + "enabled": true, + "protocol": "sftp", + "host": "127.0.0.1", + "port": 2022, + "user": "user1", + "remote_dir": "/", + "passive": true, + "timeout": 30, + "auto_delete_source": false, + "auto_scheduler": { + "enabled": false, + "check_interval_hours": 1, + "expected_directories": [], + "week_offset": 0 + }, + "passwd": "123456" + }, + "mysql": { + "host": "127.0.0.1", + "port": 3306, + "user": "root", + "dbname": "celldata", + "passwd": "123456" + }, + "scan_paths": [ + "/网优日常优化数据文档/日常性能报表/{maxyear}年/300表" + ], + "year_dir_regex": "(?P\\d{4})年", + "month_dir_regex": "(?P\\d{1,2})月", + "day_dir_regex": "(?P\\d{1,2})日", + "file_name_regex": "^Result_300_.*\\.zip$", + "file_time_regex": "(?P\\d{14})(?=\\.zip$)", + "mapping": { + "target_table": "cellinfo", + "key": { + "field": "CGI", + "expr": "{PLMN}-{eNodeBID}-{CellID}" + }, + "sources": [ + { + "band": "2.6G", + "file_prefix": "LTE_ITBBU_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "CellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": { + "value": "4G" + } + } + }, + { + "band": "2.6G", + "file_prefix": "LTE_SDR_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "cellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": { + "value": "4G" + } + } + }, + { + "band": "2.6G", + "file_prefix": "NR_CellInfo", + "fields": { + "eNodeBID": "gNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "gNBName", + "小区名称": "CellName", + "频点": "ssbFrequency", + "带宽": "carrierBandwidth", + "制式": { + "value": "2.6G" + }, + "功率": "powerPerRERef", + "网络": { + "value": "5G" + } + } + }, + { + "band": "700M", + "file_prefix": "LTE_ITBBU_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "CellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": { + "value": "4G" + } + } + }, + { + "band": "700M", + "file_prefix": "NR_CellInfo", + "fields": { + "eNodeBID": "gNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "gNBName", + "小区名称": "CellName", + "频点": "ssbFrequency", + "带宽": "carrierBandwidth", + "制式": { + "value": "700M" + }, + "功率": "powerPerRERef", + "网络": { + "value": "5G" + } + } + } + ] + } + }, + "HistoryRetention": { + "enabled": true, + "keep_count": 1 + }, + "SheetFilter": [ + "指标(计数器)", + "Template" + ], + "ExtractField": [ + { + "Field": "日期时间", + "Extract": [ + "开始时间" + ], + "Type": "datetime" + }, + { + "Field": "网元ID", + "Extract": [ + "网元ID", + "网元" + ] + }, + { + "Field": "eNodeBId", + "Extract": [ + "eNodeBId", + "eNodeB" + ] + }, + { + "Field": "cellLocalId", + "Extract": [ + "cellId", + "小区" + ] + }, + { + "Field": "基站名称", + "Extract": [ + "eNodeB CU名称", + "eNodeB名称", + "ENBFunction名称" + ] + }, + { + "Field": "小区名称", + "Extract": [ + "小区名称", + "E-UTRAN FDD小区名称", + "E-UTRAN TDD小区名称" + ] + }, + { + "Field": "上下行总流量_GB", + "Extract": [ + "集团-上下行总业务量(GB)", + "业务流量上下行(GB)", + "YJ_业务流量上下行(GB)", + "FDD总流量(G)" + ], + "Type": "float" + }, + { + "Field": "ERAB流量", + "Extract": [ + "ERAB流量(新高负荷)", + "ERAB流量", + "ERAB流量(新高负荷)", + "ERAB流量(新高负荷)-FDD", + "ERAB流量(新高负荷)_1538186901014-7-0" + ], + "Type": "float" + }, + { + "Field": "上行流量_GB", + "Extract": [ + "上行流量(新高负荷GB)", + "上行流量(新高负荷GB)", + "高负荷-上行流量(GB)", + "FDD上行流量(G)", + "上行流量(GB)" + ], + "Type": "float" + }, + { + "Field": "下行流量_GB", + "Extract": [ + "下行流量(新高负荷GB)", + "下行流量(新高负荷GB)", + "高负荷-下行流量(GB)", + "FDD下行流量(G)", + "下行流量(GB)" + ], + "Type": "float" + }, + { + "Field": "用户面平均激活UE数", + "Extract": [ + "用户面平均激活UE数", + "YY-用户面平均激活UE数(个)", + "用户面平均激活UE数(unit)" + ], + "Type": "float" + }, + { + "Field": "RRC连接建立最大用户数", + "Extract": [ + "YY-RRC连接建立最大用户数(个)", + "RRC连接建立最大用户数(unit)", + "RRC连接建立最大用户数" + ], + "Type": "int" + }, + { + "Field": "上行PUSCH利用率", + "Extract": [ + "上行PUSCH利用率", + "YY-上行PUSCH利用率", + "上行PUSCH利用率-FDD" + ], + "Type": "float" + }, + { + "Field": "下行PDSCH利用率", + "Extract": [ + "下行PDSCH利用率", + "YY-下行PDSCH利用率", + "下行PDSCH利用率-FDD" + ], + "Type": "float" + }, + { + "Field": "PDCCH资源利用率", + "Extract": [ + "PDCCH资源利用率", + "PDCCH资源利用率_1548034713403-33-2", + "PDCCH资源使用率(%)" + ], + "Type": "float" + }, + { + "Field": "用户面最大激活UE数", + "Extract": [ + "开发者大会保障--最大激活UE数", + "用户面最大激活UE数", + "YY-用户面最大激活UE数(个)", + "用户面最大激活UE数(unit)" + ], + "Type": "int" + }, + { + "Field": "CU小区配置名称", + "Extract": [ + "CU小区配置名称" + ] + }, + { + "Field": "gNBId", + "Extract": [ + "gNBId" + ] + }, + { + "Field": "gNBplmn", + "Extract": [ + "gNBplmn" + ] + }, + { + "Field": "5G上下行总流量_GB", + "Extract": [ + "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)" + ], + "Type": "float" + }, + { + "Field": "5G上行流量_GB", + "Extract": [ + "5G上行流量(上行PDCP PDU数据量)_1603677232210(GB)", + "5G上行流量(上行PDCP PDU数据量)(GB)" + ], + "Type": "float" + }, + { + "Field": "5G下行流量_GB", + "Extract": [ + "5G下行流量(下行PDCP成功发送数据量)_1603677232245(GB)", + "5G下行流量(下行PDCP成功发送数据量)(GB)" + ], + "Type": "float" + }, + { + "Field": "小区平均激活UE数", + "Extract": [ + "小区平均激活UE数" + ], + "Type": "float" + }, + { + "Field": "RRC连接最大连接用户数", + "Extract": [ + "RRC连接最大连接用户数" + ], + "Type": "int" + }, + { + "Field": "RRC连接平均连接用户数", + "Extract": [ + "RRC连接平均连接用户数" + ], + "Type": "float" + }, + { + "Field": "小区最大激活UE数", + "Extract": [ + "小区最大激活UE数" + ], + "Type": "int" + }, + { + "Field": "上行PRB平均利用率", + "Extract": [ + "上行PRB平均利用率" + ], + "Type": "float" + }, + { + "Field": "下行PRB平均利用率", + "Extract": [ + "下行PRB平均利用率" + ], + "Type": "float" + }, + { + "Field": "小区下行RLC_SDU字节数_MByte", + "Extract": [ + "小区下行RLC SDU字节数(MByte)" + ], + "Type": "float" + }, + { + "Field": "小区上行RLC_SDU字节数_MByte", + "Extract": [ + "小区上行RLC SDU字节数(MByte)" + ], + "Type": "float" + }, + { + "Field": "PDCCH信道动态CCE占用率", + "Extract": [ + "PDCCH信道CCE占用率(动态)(%)" + ], + "Type": "float" + }, + { + "Field": "平均单Flow流量_MB", + "Extract": [ + "平均单Flow流量(MB)", + "平均每flow流量(流量/flow(MB/flow))" + ], + "Type": "float" + } + ] +} \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..a84f516 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,170 @@ +# CapacityReport 开发文档 + +面向二次开发者,介绍项目实际用到的技术栈、目录结构,以及导航栏每个功能对应的代码文件,方便快速定位要修改的位置。 + +## 技术栈与主要库 + +### 后端(Python) + +| 库 | 用途 | +| --- | --- | +| fastapi / uvicorn | Web 框架与 ASGI 服务器 | +| python-multipart | 文件上传表单解析 | +| pandas / openpyxl | Excel/CSV 数据读取与处理 | +| chardet | CSV 编码探测 | +| pymysql | 直连 MySQL 读写 | +| cryptography | 本地授权文件加密 | +| paramiko | SFTP 远程下载 | + +### 前端(Vue 3) + +| 库 | 用途 | +| --- | --- | +| vue / vue-router | 框架与路由 | +| naive-ui | UI 组件库 | +| @vicons/ionicons5 | 图标 | +| echarts | 容量看板图表 | +| monaco-editor | 脚本编辑页的 SQL 编辑器 | +| @tauri-apps/api | 桌面端与原生能力交互 | +| vite / vue-tsc / typescript | 构建与类型检查 | +| unplugin-auto-import / unplugin-vue-components | Naive UI 组件按需自动导入 | + +### 桌面端(Tauri 2 / Rust) + +| 库 | 用途 | +| --- | --- | +| tauri | 桌面应用框架 | +| tauri-plugin-shell | 启动并管理后端 sidecar 进程 | +| tauri-plugin-dialog | 文件保存对话框 | +| reqwest | 下载文件到本地 | +| sysinfo | 跨平台进程检测与清理 | +| open | 调用系统文件管理器打开目录 | +| serde | 序列化 | + +## 目录结构 + +```text +CapacityReport/ +├─ app/ # 后端(FastAPI) +│ ├─ main.py # 入口:创建应用、鉴权中间件、注册路由、托管前端 +│ ├─ state.py # 运行时共享状态(配置、历史、任务锁、上传会话) +│ ├─ config.py # 配置读写(AppConfig 与各配置块) +│ ├─ auth.py # 登录账号(auth.ini)、JWT、密码修改 +│ ├─ db_init.py # 启动时确保必要数据表存在 +│ ├─ database.py # DatabaseManager:直连 MySQL 表操作 +│ ├─ warehouse.py # 数据库访问的统一封装 +│ ├─ processor.py # DataProcessor:解压/转换/入库/执行报表 SQL;ProcessLogger +│ ├─ history.py # HistoryManager:处理历史记录与日志 +│ ├─ api/routers/ # 各业务接口(按模块拆分,见下表) +│ ├─ services/ # 业务服务(远程下载、调度、CellData、授权等) +│ └─ utils/ # 通用工具(文件、文件名日期解析) +├─ frontend/ # 前端(Vue 3 + Vite) +│ └─ src/ +│ ├─ AppShell.vue # 整体框架:登录、侧边导航、顶栏、主题切换 +│ ├─ App.vue / main.ts # 应用根与入口 +│ ├─ router.ts # 路由表(6 个功能页) +│ ├─ types.ts # 前端类型定义 +│ ├─ api/client.ts # 请求封装(token、错误处理、下载) +│ ├─ components/ # 各功能页面与公共组件 +│ └─ composables/ # 可复用逻辑(页头、主题、剪贴板、日志等) +├─ src-tauri/ # Tauri 桌面壳(Rust) +│ ├─ src/main.rs # 启动/停止 Python sidecar、下载、打开目录 +│ └─ tauri.conf.json # 桌面打包配置(WebView2、NSIS 等) +├─ scripts/ # 运行/编译 Python 脚本 +├─ packaging/ # docker-compose、PyInstaller 配置、MySQL 初始化 +├─ docker/entrypoint.sh # 容器启动脚本 +├─ docs/ # 项目维护记录(project_context.md) +├─ README.md # 使用说明 +├─ DEVELOPMENT.md # 开发文档(本文件) +├─ Configure.json # 应用配置 +├─ ReportScript.sql # 容量报表处理 SQL +├─ CellData.sql # CellData 处理 SQL +├─ Dockerfile # 服务端镜像 +└─ requirements.txt # Python 依赖 +``` + +运行时数据(不进版本库):`cache/`(任务工作目录、历史记录 `history.json`)、`logs/`、`auth.ini`(账号)、`license.dat`(授权)。 + +## 功能与代码对照 + +后端接口按模块拆分在 `app/api/routers/`,前端页面在 `frontend/src/components/`。下面按导航栏 6 个功能逐一说明涉及的代码文件。 + +公共代码(多数功能都会用到): + +- 前端框架与导航:`frontend/src/AppShell.vue`、`router.ts`、`api/client.ts`、`composables/` +- 后端入口与共享:`app/main.py`、`app/state.py`、`app/config.py`、`app/api/routers/task_runtime.py`(任务阶段、授权日志、历史保留清理) + +### 数据处理 + +页面路由 `/upload`。负责本地上传或远程下载数据,清洗入库并执行报表 SQL;若启用 CellData 数据源,会在容量处理前先更新 CellData。 + +| 类型 | 文件 | 职责 | +| --- | --- | --- | +| 前端 | `frontend/src/components/FileWorkflow.vue` | 上传/远程下载入口、处理进度与日志、CellData 卡片 | +| 接口 | `app/api/routers/upload.py` | 创建上传会话、接收文件 | +| 接口 | `app/api/routers/tasks.py` | 任务锁、启动容量处理、查询状态 | +| 接口 | `app/api/routers/remote.py` | 远程下载并处理、连接测试、自动调度状态/触发 | +| 接口 | `app/api/routers/cell_data.py` | CellData 单独处理(远程刷新 / 本地上传) | +| 服务 | `app/processor.py` | 直连 MySQL:解压、Excel/CSV 转换、入库、执行 `ReportScript.sql` | +| 服务 | `app/services/remote_download.py` | FTP/SFTP 远程下载 | +| 服务 | `app/services/auto_scheduler.py` | 后台自动调度(按目标周检查远程数据就绪) | +| 服务 | `app/services/cell_data.py` | CellData 解析入库、执行 `CellData.sql`、跨库复制 | +| 服务 | `app/services/license.py` | 按数据日期校验授权期限 | +| 工具 | `app/utils/file_dates.py` | 文件名日期解析、按目录筛选最近 7 天文件 | + +### 容量看板 + +页面路由 `/dashboard`。基于主仓库的 4G/5G 结果表做高负荷分析。 + +| 类型 | 文件 | 职责 | +| --- | --- | --- | +| 前端 | `frontend/src/components/CapacityDashboard.vue` | 看板大屏:汇总卡片、图表、问题小区清单、详情抽屉 | +| 前端 | `frontend/src/components/EChart.vue` | ECharts 封装组件 | +| 接口 | `app/api/routers/dashboard.py` | 状态、汇总概览、问题小区清单、单小区详情、清单导出 | +| 依赖 | `app/warehouse.py` | 读取数据库中的结果表 | + +### 处理历史 + +页面路由 `/history`。查看历史任务、日志,浏览/下载历史原始数据。 + +| 类型 | 文件 | 职责 | +| --- | --- | --- | +| 前端 | `frontend/src/components/HistoryPanel.vue` | 历史列表、详情、日志、文件浏览与下载 | +| 接口 | `app/api/routers/history.py` | 历史记录、日志、占用计算、文件浏览/下载、删除 | +| 服务 | `app/history.py` | HistoryManager:记录存于 `cache/history.json` 与 `cache/` | + +### 数据管理 + +页面路由 `/database`。浏览、编辑、导入导出数据库表,可切换主数据库与 CellData 数据库。 + +| 类型 | 文件 | 职责 | +| --- | --- | --- | +| 前端 | `frontend/src/components/DatabasePanel.vue` | 表列表、表数据分页、行编辑/删除、导入导出、执行 SQL | +| 接口 | `app/api/routers/database.py` | 表列表/结构/分页、清空/删除、行增改删、CSV/XLSX 导出、模板/导入、执行 SQL(`database_source` 选主库或 CellData 库) | +| 服务 | `app/database.py` | DatabaseManager:直连 MySQL 表操作 | +| 服务 | `app/warehouse.py` | 数据库访问的统一封装 | + +### 脚本编辑 + +页面路由 `/script`。在线编辑并执行报表 SQL 与 CellData SQL。 + +| 类型 | 文件 | 职责 | +| --- | --- | --- | +| 前端 | `frontend/src/components/ScriptPanel.vue` | Monaco SQL 编辑器、保存、运行、切换脚本类型 | +| 接口 | `app/api/routers/script.py` | 读取/保存/执行(`report` → `ReportScript.sql`,`celldata` → `CellData.sql`) | +| 服务 | `app/processor.py`、`app/services/cell_data.py` | 在对应数据库上下文执行脚本 | + +### 系统设置 + +页面路由 `/settings`。配置数据库、远程数据源、字段映射、自动调度等,并提供修改密码。 + +| 类型 | 文件 | 职责 | +| --- | --- | --- | +| 前端 | `frontend/src/components/SettingsPanel.vue` | 各类配置表单、连接测试、配置导入导出 | +| 前端 | `frontend/src/components/LicenseActivationModal.vue` | 授权延期窗口(连点品牌图标 8 次打开) | +| 接口 | `app/api/routers/config.py` | 各配置块读写、配置上传/下载、MySQL/远程/CellData 连接测试、CellData 规则校验 | +| 接口 | `app/api/routers/auth.py` | 登录、修改密码 | +| 接口 | `app/api/routers/license.py` | 授权状态查询与激活 | +| 服务 | `app/config.py` | AppConfig 及各配置块的解析与保存 | +| 服务 | `app/auth.py` | 账号(`auth.ini`)、JWT | +| 服务 | `app/services/license.py` | 授权加密文件读写、激活码校验 | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1c860f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# CapacityReport 平台容器版(单阶段)。 +# 前端先在构建机用 node 构建:cd frontend && npm ci && npm run build(产出 frontend/dist)。 +# 然后构建镜像:docker build -t capacity-report-app:latest . +# 基础镜像用 python:3.13.11-slim(内网无 dockerhub 时需本地已有该镜像),依赖从 PyPI 安装。 +FROM python:3.13.11-slim +WORKDIR /app +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + CAPAREPORT_BASE_DIR=/data \ + CAPAREPORT_FRONTEND_DIR=/app/frontend/dist + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY app/ ./app/ +COPY frontend/dist ./frontend/dist +# 默认配置/脚本/特征库:首次启动由 entrypoint 播种到数据卷 /data(已存在则保留用户修改)。 +COPY Configure.json ReportScript.sql CellData.sql ./defaults/ +COPY db_init/ ./defaults/db_init/ +COPY docker/ ./docker/ +RUN sed -i 's/\r$//' /app/docker/entrypoint.sh \ + && cp /app/docker/entrypoint.sh /usr/local/bin/entrypoint.sh \ + && chmod +x /usr/local/bin/entrypoint.sh + +VOLUME ["/data"] +EXPOSE 9081 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5118df8 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# CapacityReport · 容量报表处理系统 + +CapacityReport 用于处理每周的网络容量报表数据:从本地上传或 FTP/SFTP 远程目录获取 Excel/CSV/ZIP 数据,按字段映射清洗、入库到 MySQL,再执行业务 SQL 生成 4G/5G 容量结果表,并通过容量看板做高负荷小区分析。 + +## 技术栈 + +- 后端:Python + FastAPI + Uvicorn +- 前端:Vue 3 + TypeScript + Vite + Naive UI +- 桌面端:Tauri 2(Rust)+ Python sidecar +- 数据库:MySQL 8.0+ +- 数据处理:Pandas + OpenPyXL;图表:ECharts;SQL 编辑器:Monaco Editor + +## 功能模块 + +系统左侧导航分为 6 个模块: + +| 模块 | 作用 | +| --- | --- | +| 数据处理 | 本地上传或远程下载数据,清洗后入库并执行报表 SQL | +| 容量看板 | 4G/5G 高负荷小区分析大屏(汇总、图表、问题清单、单小区详情) | +| 处理历史 | 查看历史任务、处理日志,浏览/下载历史原始数据 | +| 数据管理 | 浏览、编辑、导入导出数据库表,在线执行 SQL | +| 脚本编辑 | 在线编辑并执行容量报表 SQL / CellData SQL | +| 系统设置 | 数据库、远程数据源、字段映射、自动调度等配置 | + +## 初次运行 + +- 默认登录账号:用户名 `root`,密码 `Capacity`(登录后可在「系统设置 → 修改密码」中修改)。 +- 系统需要配套使用: + - **一个 FTP/SFTP 数据源**:存放每周容量报表数据,在「系统设置 → 远程数据源」中配置,可手动下载或开启自动调度。 + - **一个 MySQL 8.0+ 数据库**:作为数据仓库,在「系统设置 → 数据库」中配置连接信息。 +- 启动后访问 `http://localhost:9081`。 +- 也可以直接在「数据处理」页拖拽上传数据文件,无需远程数据源。 + +详细的 Web 端操作方法与注意事项见 [使用说明 USAGE.md](USAGE.md)。 + +## 运行与编译 + +所有运行 / 编译都统一为 `scripts/` 下的 Python 脚本,直接用系统 Python 运行即可。脚本会自动创建 `.venv` 并安装依赖、自动安装前端依赖;缺少 Node.js / Rust / Docker 时会给出安装引导。 + +环境要求: + +- Python 3.10+(加入 PATH) +- Node.js 18+ +- MySQL 8.0+ +- Rust 工具链(仅编译 Tauri 桌面版时需要) +- Docker(仅编译镜像时需要) + +| 命令 | 用途 | +| --- | --- | +| `python scripts/dev.py` | 开发模式:后端自动重载 + 前端热更新 | +| `python scripts/run.py` | 本地运行:构建前端(如缺失)并启动服务 | +| `python scripts/dev_tauri.py` | Tauri 桌面端测试运行 | +| `python scripts/build_server.py` | 编译 Server 便携版 | +| `python scripts/build_tauri.py` | 编译 Tauri 桌面版 | +| `python scripts/build_docker.py` | 编译 / 更新 Docker 镜像 | +| `python scripts/clean.py` | 清理缓存与编译产物 | + +### 本地运行 + +```bash +python scripts/run.py # 默认 0.0.0.0:9081 +python scripts/run.py --port 8080 +``` + +### 开发模式 + +```bash +python scripts/dev.py +``` + +后端 `http://127.0.0.1:9081`(自动重载),前端 `http://127.0.0.1:5174`(Vite 热更新)。 + +### 编译发布版 + +```bash +python scripts/build_server.py # Server 便携版(dist/server/) +python scripts/build_tauri.py # Tauri 桌面版(dist/desktop/) +python scripts/build_docker.py # Docker 镜像 + 离线包(dist/docker/) +``` + +桌面版需在目标系统本机构建(Windows 包在 Windows、Linux 包在 Linux、macOS 包在 macOS);Windows 安装包内置 WebView2 离线安装器。Docker 离线包部署: + +```bash +docker load -i dist/docker/capacity-report-app-latest.tar +docker compose -f dist/docker/docker-compose.yml up -d +``` + +### Docker 编排(应用 + MySQL + sftpgo) + +整套系统(应用、MySQL、sftpgo 文件服务)可用 `packaging/docker-compose.yml` 一键构建运行: + +```bash +cd frontend && npm ci && npm run build && cd ../packaging +docker compose build && docker compose up -d +``` + +完整的编排说明(在线构建、内网离线部署、默认账号、上传数据、数据卷、运维命令)见 [packaging/README.md](packaging/README.md)。 + +## 文档 + +- [使用说明 USAGE.md](USAGE.md):Web 端各功能的操作方法与注意事项。 +- [开发文档 DEVELOPMENT.md](DEVELOPMENT.md):技术栈、目录结构与「功能 ↔ 代码」对照。 + +开发文档锚点: + +- [技术栈与主要库](DEVELOPMENT.md#技术栈与主要库) +- [目录结构](DEVELOPMENT.md#目录结构) +- [功能与代码对照](DEVELOPMENT.md#功能与代码对照) + - [数据处理](DEVELOPMENT.md#数据处理) + - [容量看板](DEVELOPMENT.md#容量看板) + - [处理历史](DEVELOPMENT.md#处理历史) + - [数据管理](DEVELOPMENT.md#数据管理) + - [脚本编辑](DEVELOPMENT.md#脚本编辑) + - [系统设置](DEVELOPMENT.md#系统设置) diff --git a/ReportScript.sql b/ReportScript.sql new file mode 100644 index 0000000..c9a839c --- /dev/null +++ b/ReportScript.sql @@ -0,0 +1,797 @@ +# Update: 2026/06/25 15:33:00 + +SET GLOBAL wait_timeout = 86400; +SET GLOBAL innodb_buffer_pool_size = 1073741824; +DROP TABLE IF EXISTS `4G`; +DROP TABLE IF EXISTS `5G`; +DROP TABLE IF EXISTS `4G_Date`; +DROP TABLE IF EXISTS `4G_MAX`; +DROP TABLE IF EXISTS `CCE_MAX`; +DROP TABLE IF EXISTS `ULPRB_MAX`; +DROP TABLE IF EXISTS `DLPRB_MAX`; +DROP TABLE IF EXISTS `4G_MAX_IDX`; +DROP TABLE IF EXISTS `CCE_MAX_IDX`; +DROP TABLE IF EXISTS `ULPRB_MAX_IDX`; +DROP TABLE IF EXISTS `DLPRB_MAX_IDX`; +DROP TABLE IF EXISTS `日流量`; +DROP TABLE IF EXISTS `4G日均流量`; +DROP TABLE IF EXISTS `4G_结果表`; + +DROP TABLE IF EXISTS `5G_Date`; +DROP TABLE IF EXISTS `5G_MAX`; +DROP TABLE IF EXISTS `5G_MAX_IDX`; +DROP TABLE IF EXISTS `5G日均流量`; +DROP TABLE IF EXISTS `5G_结果表`; +DROP TABLE IF EXISTS `_rj_yd`; +DROP TABLE IF EXISTS `_rj_gd`; +DROP TABLE IF EXISTS `rj`; + +UPDATE `4G_UD` SET + `上下行总流量_GB` = IFNULL(`上下行总流量_GB`,0), + `ERAB流量` = IFNULL(`ERAB流量`,0), + `上行流量_GB` = IFNULL(`上行流量_GB`,0), + `下行流量_GB` = IFNULL(`下行流量_GB`,0), + `用户面平均激活UE数` = IFNULL(`用户面平均激活UE数`,0), + `RRC连接建立最大用户数` = IFNULL(`RRC连接建立最大用户数`,0), + `上行PUSCH利用率` = IFNULL(`上行PUSCH利用率`,0), + `下行PDSCH利用率` = IFNULL(`下行PDSCH利用率`,0), + `PDCCH资源利用率` = IFNULL(`PDCCH资源利用率`,0), + `用户面最大激活UE数` = IFNULL(`用户面最大激活UE数`,0); + +UPDATE `5G_UD` SET + `5G上下行总流量_GB` = IFNULL(`5G上下行总流量_GB`,0), + `5G上行流量_GB` = IFNULL(`5G上行流量_GB`,0), + `5G下行流量_GB` = IFNULL(`5G下行流量_GB`,0), + `小区平均激活UE数` = IFNULL(`小区平均激活UE数`,0), + `RRC连接最大连接用户数` = IFNULL(`RRC连接最大连接用户数`,0), + `RRC连接平均连接用户数` = IFNULL(`RRC连接平均连接用户数`,0), + `小区最大激活UE数` = IFNULL(`小区最大激活UE数`,0), + `上行PRB平均利用率` = IFNULL(`上行PRB平均利用率`,0), + `下行PRB平均利用率` = IFNULL(`下行PRB平均利用率`,0), + `小区下行RLC_SDU字节数_MByte` = IFNULL(`小区下行RLC_SDU字节数_MByte`,0), + `小区上行RLC_SDU字节数_MByte` = IFNULL(`小区上行RLC_SDU字节数_MByte`,0), + `PDCCH信道动态CCE占用率` = IFNULL(`PDCCH信道动态CCE占用率`,0), + `平均单Flow流量_MB` = IFNULL(`平均单Flow流量_MB`,0); + +CREATE TABLE `4G` AS SELECT * FROM `4G_UD`; +CREATE TABLE `5G` AS SELECT * FROM `5G_UD`; + +ALTER TABLE `4G` +ADD COLUMN `日期` date NULL FIRST, +MODIFY COLUMN `日期时间` datetime, +ADD COLUMN `CGI` varchar(100) NULL AFTER `日期时间`, +MODIFY COLUMN `网元ID` int, +MODIFY COLUMN `基站名称` varchar(200), +MODIFY COLUMN `小区名称` varchar(200), +MODIFY COLUMN `cellLocalId` int, +MODIFY COLUMN `eNodeBId` int, +MODIFY COLUMN `上下行总流量_GB` float, +MODIFY COLUMN `ERAB流量` float, +MODIFY COLUMN `上行流量_GB` float, +MODIFY COLUMN `下行流量_GB` float, +MODIFY COLUMN `用户面平均激活UE数` float, +MODIFY COLUMN `RRC连接建立最大用户数` int, +MODIFY COLUMN `上行PUSCH利用率` float, +MODIFY COLUMN `下行PDSCH利用率` float, +MODIFY COLUMN `PDCCH资源利用率` float, +MODIFY COLUMN `用户面最大激活UE数` int; + +ALTER TABLE `5G` +ADD COLUMN `日期` date NULL FIRST, +MODIFY COLUMN `日期时间` datetime, +ADD COLUMN `NCGI` varchar(100) NULL AFTER `日期时间`, +MODIFY COLUMN `网元ID` int, +MODIFY COLUMN `CU小区配置名称` varchar(200), +MODIFY COLUMN `cellLocalId` int, +MODIFY COLUMN `gNBId` int, +MODIFY COLUMN `gNBplmn` varchar(200), +MODIFY COLUMN `5G上下行总流量_GB` float, +MODIFY COLUMN `5G上行流量_GB` float, +MODIFY COLUMN `5G下行流量_GB` float, +MODIFY COLUMN `小区平均激活UE数` float, +MODIFY COLUMN `RRC连接最大连接用户数` int, +MODIFY COLUMN `RRC连接平均连接用户数` float, +MODIFY COLUMN `小区最大激活UE数` float, +MODIFY COLUMN `上行PRB平均利用率` float, +MODIFY COLUMN `下行PRB平均利用率` float, +MODIFY COLUMN `小区下行RLC_SDU字节数_MByte` float, +MODIFY COLUMN `小区上行RLC_SDU字节数_MByte` float, +MODIFY COLUMN `PDCCH信道动态CCE占用率` float, +MODIFY COLUMN `平均单Flow流量_MB` float; + +UPDATE `4G` SET `日期` = DATE_FORMAT(`日期时间`, '%Y-%m-%d'), `CGI` = CONCAT("460-00-",`eNodeBId`,"-",`cellLocalId`); +UPDATE `5G` SET `gNBplmn` = "460-00" WHERE `gNBplmn` IS NULL; +UPDATE `5G` SET `日期` = DATE_FORMAT(`日期时间`, '%Y-%m-%d'), `NCGI` = CONCAT(`gNBplmn`,"-",`gNBId`,"-",`cellLocalId`); + +ALTER TABLE `4G` +ADD INDEX(`日期`), +ADD INDEX(`日期时间`), +ADD INDEX(`CGI`), +ADD INDEX `TC`(`日期时间`, `CGI`), +ADD INDEX `DC`(`日期`, `CGI`); + +ALTER TABLE `5G` +ADD INDEX(`日期`), +ADD INDEX(`日期时间`), +ADD INDEX(`NCGI`), +ADD INDEX `TC`(`日期时间`, `NCGI`), +ADD INDEX `DC`(`日期`, `NCGI`); + + +CREATE TEMPORARY TABLE IF NOT EXISTS `CCE_MAX_IDX`( + SELECT + `日期`, + `CGI`, + MAX(`PDCCH资源利用率`) AS `PDCCH资源利用率_忙时` + FROM `4G` + GROUP BY `日期`, `CGI` +); + +ALTER TABLE `CCE_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `CGI`, `PDCCH资源利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `CGI`); + +UPDATE `CCE_MAX_IDX` INNER JOIN `4G` +ON + `CCE_MAX_IDX`.`日期` = `4G`.`日期` AND + `CCE_MAX_IDX`.`CGI` = `4G`.`CGI` AND + `CCE_MAX_IDX`.`PDCCH资源利用率_忙时` = `4G`.`PDCCH资源利用率` +SET + `CCE_MAX_IDX`.`日期时间` = `4G`.`日期时间`; + + +CREATE TEMPORARY TABLE IF NOT EXISTS `CCE_MAX` ( + SELECT `4G`.* + FROM `CCE_MAX_IDX` LEFT JOIN `4G` + ON `CCE_MAX_IDX`.`CGI` = `4G`.`CGI` AND + `CCE_MAX_IDX`.`日期时间` = `4G`.`日期时间` +); + +DROP TABLE IF EXISTS `CCE_MAX_IDX`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `ULPRB_MAX_IDX`( + SELECT + `日期`, + `CGI`, + MAX(`上行PUSCH利用率`) AS `上行PUSCH利用率_忙时` + FROM `4G` + GROUP BY `日期`, `CGI` +); + +ALTER TABLE `ULPRB_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `CGI`, `上行PUSCH利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `CGI`); + +UPDATE `ULPRB_MAX_IDX` INNER JOIN `4G` +ON + `ULPRB_MAX_IDX`.`日期` = `4G`.`日期` AND + `ULPRB_MAX_IDX`.`CGI` = `4G`.`CGI` AND + `ULPRB_MAX_IDX`.`上行PUSCH利用率_忙时` = `4G`.`上行PUSCH利用率` +SET + `ULPRB_MAX_IDX`.`日期时间` = `4G`.`日期时间`; + +CREATE TABLE IF NOT EXISTS `ULPRB_MAX` ( + SELECT `4G`.* + FROM `ULPRB_MAX_IDX` LEFT JOIN `4G` + ON `ULPRB_MAX_IDX`.`CGI` = `4G`.`CGI` AND + `ULPRB_MAX_IDX`.`日期时间` = `4G`.`日期时间` +); + +DROP TABLE IF EXISTS `ULPRB_MAX_IDX`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `DLPRB_MAX_IDX`( + SELECT `日期`, `CGI`, MAX(`下行PDSCH利用率`) AS `下行PDSCH利用率_忙时` + FROM `4G` GROUP BY `日期`, `CGI` +); + +ALTER TABLE `DLPRB_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `CGI`, `下行PDSCH利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `CGI`); + +UPDATE `DLPRB_MAX_IDX` INNER JOIN `4G` +ON + `DLPRB_MAX_IDX`.`日期` = `4G`.`日期` AND + `DLPRB_MAX_IDX`.`CGI` = `4G`.`CGI` AND + `DLPRB_MAX_IDX`.`下行PDSCH利用率_忙时` = `4G`.`下行PDSCH利用率` +SET + `DLPRB_MAX_IDX`.`日期时间` = `4G`.`日期时间`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `DLPRB_MAX` ( + SELECT `4G`.* + FROM `DLPRB_MAX_IDX` LEFT JOIN `4G` + ON `DLPRB_MAX_IDX`.`CGI` = `4G`.`CGI` AND + `DLPRB_MAX_IDX`.`日期时间` = `4G`.`日期时间` +); + +DROP TABLE IF EXISTS `DLPRB_MAX_IDX`; + +ALTER TABLE `CCE_MAX` ADD INDEX `DC`(`日期`, `CGI`); +ALTER TABLE `DLPRB_MAX` ADD INDEX `DC`(`日期`, `CGI`); +ALTER TABLE `ULPRB_MAX` ADD INDEX `DC`(`日期`, `CGI`); + + +CREATE TEMPORARY TABLE IF NOT EXISTS `4G_Date` (SELECT `日期`, `CGI` FROM `4G` GROUP BY `日期`, `CGI`); +ALTER TABLE `4G_Date` ADD INDEX `DC`(`日期`, `CGI`); + + +CREATE TEMPORARY TABLE IF NOT EXISTS `4G_MAX_IDX` ( + SELECT + `4G_Date`.`日期`, + `4G_Date`.`CGI`, + GREATEST(`ULPRB_MAX`.`上行PUSCH利用率`, `DLPRB_MAX`.`下行PDSCH利用率`, `CCE_MAX`.`PDCCH资源利用率`) AS `最大值` + FROM + `4G_Date` + INNER JOIN + `DLPRB_MAX` + ON + `4G_Date`.`日期` = `DLPRB_MAX`.`日期` AND + `4G_Date`.`CGI` = `DLPRB_MAX`.`CGI` + INNER JOIN + `CCE_MAX` + ON + `4G_Date`.`日期` = `CCE_MAX`.`日期` AND + `4G_Date`.`CGI` = `CCE_MAX`.`CGI` + INNER JOIN + `ULPRB_MAX` + ON + `4G_Date`.`日期` = `ULPRB_MAX`.`日期` AND + `4G_Date`.`CGI` = `ULPRB_MAX`.`CGI` +); +DROP TABLE IF EXISTS `4G_Date`; +ALTER TABLE `4G_MAX_IDX` ADD INDEX `DC`(`日期`, `CGI`), ADD INDEX (`最大值`); + +CREATE TABLE IF NOT EXISTS `4G_MAX` ( + SELECT * FROM `4G` LIMIT 0 +); + +ALTER TABLE `4G_MAX` ADD INDEX (`日期`), ADD INDEX (`CGI`), ADD INDEX `DC` (`日期`, `CGI`), ADD INDEX `idx`(`CGI`, `基站名称`, `小区名称`); + +TRUNCATE `4G_MAX`; +INSERT INTO `4G_MAX` + SELECT `ULPRB_MAX`.* FROM `ULPRB_MAX` INNER JOIN `4G_MAX_IDX` + ON + `ULPRB_MAX`.`日期` = `4G_MAX_IDX`.`日期` AND + `ULPRB_MAX`.`CGI` = `4G_MAX_IDX`.`CGI` AND + `ULPRB_MAX`.`上行PUSCH利用率` = `4G_MAX_IDX`.`最大值`; + +DELETE FROM `DLPRB_MAX` WHERE `日期` IS NULL; +INSERT INTO `4G_MAX` + SELECT `DLPRB_MAX`.* FROM `DLPRB_MAX` INNER JOIN `4G_MAX_IDX` + ON + `DLPRB_MAX`.`日期` = `4G_MAX_IDX`.`日期` AND + `DLPRB_MAX`.`CGI` = `4G_MAX_IDX`.`CGI` AND + `DLPRB_MAX`.`下行PDSCH利用率` = `4G_MAX_IDX`.`最大值` + WHERE NOT EXISTS ( + SELECT 1 FROM `4G_MAX` + WHERE `4G_MAX`.`日期` = `DLPRB_MAX`.`日期` + AND `4G_MAX`.`CGI` = `DLPRB_MAX`.`CGI` + ); + +DELETE FROM `CCE_MAX` WHERE `日期` IS NULL; +INSERT INTO `4G_MAX` + SELECT `CCE_MAX`.* FROM `CCE_MAX` INNER JOIN `4G_MAX_IDX` + ON + `CCE_MAX`.`日期` = `4G_MAX_IDX`.`日期` AND + `CCE_MAX`.`CGI` = `4G_MAX_IDX`.`CGI` AND + `CCE_MAX`.`PDCCH资源利用率` = `4G_MAX_IDX`.`最大值` + WHERE NOT EXISTS ( + SELECT 1 FROM `4G_MAX` + WHERE `4G_MAX`.`日期` = `CCE_MAX`.`日期` + AND `4G_MAX`.`CGI` = `CCE_MAX`.`CGI` + ); + + +DROP TABLE IF EXISTS `CCE_MAX`; +DROP TABLE IF EXISTS `ULPRB_MAX`; +DROP TABLE IF EXISTS `DLPRB_MAX`; +DROP TABLE IF EXISTS `4G_MAX_IDX`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `日流量`(SELECT `日期`, `CGI`, SUM(`上下行总流量_GB`) AS `日流量` FROM `4G` GROUP BY `日期`, `CGI`); +ALTER TABLE `日流量` ADD INDEX (`CGI`); +DROP TABLE IF EXISTS `4G日均流量`; +CREATE TEMPORARY TABLE IF NOT EXISTS `4G日均流量` ( + SELECT `CGI`, AVG(`日流量`) AS `日均流量`, COUNT(`日期`) AS `流量有效天数` FROM `日流量` WHERE `日流量` > 0 GROUP BY `CGI` +); + + +CREATE TABLE IF NOT EXISTS `4G_结果表` ( + SELECT `CGI`, `基站名称`, `小区名称`, + ROUND(AVG(`上下行总流量_GB`),6) AS `自忙时流量(GB)`, + ROUND(AVG(`ERAB流量`),6) AS `ERAB流量`, + ROUND(AVG(`上行流量_GB`),6) AS `上行流量(GB)`, + ROUND(AVG(`下行流量_GB`),6) AS `下行流量(GB)`, + ROUND(AVG(`用户面平均激活UE数`),6) AS `用户面平均激活UE数`, + ROUND(AVG(`RRC连接建立最大用户数`),6) AS `YY-RRC连接建立最大用户数`, + ROUND(AVG(`上行PUSCH利用率`),6) AS `上行PUSCH利用率`, + ROUND(AVG(`下行PDSCH利用率`),6) AS `下行PDSCH利用率`, + ROUND(AVG(`PDCCH资源利用率`),6) AS `PDCCH资源利用率`, + ROUND(AVG(`用户面最大激活UE数`),6) AS `用户面最大激活UE数` + FROM `4G_MAX` GROUP BY `CGI`, `基站名称`, `小区名称` +); +DROP TABLE IF EXISTS `4G_MAX`; +ALTER TABLE `4G_结果表` ADD COLUMN `日均流量(GB)` DOUBLE AFTER `小区名称`, ADD INDEX (`CGI`); +ALTER TABLE `4G日均流量` ADD INDEX (`CGI`); +UPDATE `4G_结果表` INNER JOIN `4G日均流量` +ON `4G_结果表`.`CGI` = `4G日均流量`.`CGI` +SET `4G_结果表`.`日均流量(GB)` = ROUND(`4G日均流量`.`日均流量`,6); + +DROP TABLE IF EXISTS `日流量`; +DROP TABLE IF EXISTS `4G日均流量`; +DROP TABLE IF EXISTS `4G`; + + +# 5G脚本 + +CREATE TEMPORARY TABLE IF NOT EXISTS `ULPRB_MAX_IDX`( + SELECT + `日期`, + `NCGI`, + MAX(`上行PRB平均利用率`) AS `上行PRB平均利用率_忙时` + FROM `5G` + GROUP BY `日期`, `NCGI` +); + +ALTER TABLE `ULPRB_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `NCGI`, `上行PRB平均利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `NCGI`); + +UPDATE `ULPRB_MAX_IDX` INNER JOIN `5G` +ON + `ULPRB_MAX_IDX`.`日期` = `5G`.`日期` AND + `ULPRB_MAX_IDX`.`NCGI` = `5G`.`NCGI` AND + `ULPRB_MAX_IDX`.`上行PRB平均利用率_忙时` = `5G`.`上行PRB平均利用率` +SET + `ULPRB_MAX_IDX`.`日期时间` = `5G`.`日期时间`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `ULPRB_MAX` ( + SELECT `5G`.* + FROM `ULPRB_MAX_IDX` LEFT JOIN `5G` + ON `ULPRB_MAX_IDX`.`NCGI` = `5G`.`NCGI` AND + `ULPRB_MAX_IDX`.`日期时间` = `5G`.`日期时间` +); + +CREATE TEMPORARY TABLE IF NOT EXISTS `DLPRB_MAX_IDX`( + SELECT + `日期`, + `NCGI`, + MAX(`下行PRB平均利用率`) AS `下行PRB平均利用率_忙时` + FROM `5G` + GROUP BY `日期`, `NCGI` +); + +ALTER TABLE `DLPRB_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `NCGI`, `下行PRB平均利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `NCGI`); + +UPDATE `DLPRB_MAX_IDX` INNER JOIN `5G` +ON + `DLPRB_MAX_IDX`.`日期` = `5G`.`日期` AND + `DLPRB_MAX_IDX`.`NCGI` = `5G`.`NCGI` AND + `DLPRB_MAX_IDX`.`下行PRB平均利用率_忙时` = `5G`.`下行PRB平均利用率` +SET + `DLPRB_MAX_IDX`.`日期时间` = `5G`.`日期时间`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `DLPRB_MAX` ( + SELECT `5G`.* + FROM `DLPRB_MAX_IDX` LEFT JOIN `5G` + ON `DLPRB_MAX_IDX`.`NCGI` = `5G`.`NCGI` AND + `DLPRB_MAX_IDX`.`日期时间` = `5G`.`日期时间` +); + +DROP TABLE IF EXISTS `DLPRB_MAX_IDX`; +ALTER TABLE `DLPRB_MAX` ADD INDEX `DC`(`日期`, `NCGI`); +ALTER TABLE `ULPRB_MAX` ADD INDEX `DC`(`日期`, `NCGI`); + +DROP TABLE IF EXISTS `5G_Date`; +CREATE TEMPORARY TABLE IF NOT EXISTS `5G_Date` ( + SELECT `日期`, `NCGI` FROM `5G` GROUP BY `日期`, `NCGI` +); +ALTER TABLE `5G_Date` ADD INDEX `DC`(`日期`, `NCGI`); + +CREATE TEMPORARY TABLE IF NOT EXISTS `5G_MAX_IDX` ( + SELECT + `5G_Date`.`日期`, + `5G_Date`.`NCGI`, + GREATEST(`ULPRB_MAX`.`上行PRB平均利用率`, `DLPRB_MAX`.`下行PRB平均利用率`) AS `最大值` + FROM + `5G_Date` + INNER JOIN + `DLPRB_MAX` + ON + `5G_Date`.`日期` = `DLPRB_MAX`.`日期` AND + `5G_Date`.`NCGI` = `DLPRB_MAX`.`NCGI` + INNER JOIN + `ULPRB_MAX` + ON + `5G_Date`.`日期` = `ULPRB_MAX`.`日期` AND + `5G_Date`.`NCGI` = `ULPRB_MAX`.`NCGI` +); + +DROP TABLE IF EXISTS `5G_Date`; +ALTER TABLE `5G_MAX_IDX` ADD INDEX `DC`(`日期`, `NCGI`), ADD INDEX (`最大值`); + +CREATE TABLE IF NOT EXISTS `5G_MAX` ( + SELECT * FROM `5G` LIMIT 0 +); + +ALTER TABLE `5G_MAX` ADD INDEX (`日期`), ADD INDEX (`NCGI`), ADD INDEX `DC` (`日期`, `NCGI`), ADD INDEX `idx`(`NCGI`, `CU小区配置名称`); + +TRUNCATE `5G_MAX`; +INSERT INTO `5G_MAX` + SELECT `ULPRB_MAX`.* FROM `ULPRB_MAX` INNER JOIN `5G_MAX_IDX` + ON + `ULPRB_MAX`.`日期` = `5G_MAX_IDX`.`日期` AND + `ULPRB_MAX`.`NCGI` = `5G_MAX_IDX`.`NCGI` AND + `ULPRB_MAX`.`上行PRB平均利用率` = `5G_MAX_IDX`.`最大值`; + +DELETE FROM `DLPRB_MAX` WHERE `日期` IS NULL; +INSERT INTO `5G_MAX` + SELECT `DLPRB_MAX`.* FROM `DLPRB_MAX` INNER JOIN `5G_MAX_IDX` + ON + `DLPRB_MAX`.`日期` = `5G_MAX_IDX`.`日期` AND + `DLPRB_MAX`.`NCGI` = `5G_MAX_IDX`.`NCGI` AND + `DLPRB_MAX`.`下行PRB平均利用率` = `5G_MAX_IDX`.`最大值` + WHERE NOT EXISTS ( + SELECT 1 FROM `5G_MAX` + WHERE `5G_MAX`.`日期` = `DLPRB_MAX`.`日期` + AND `5G_MAX`.`NCGI` = `DLPRB_MAX`.`NCGI` + ); + +DROP TABLE IF EXISTS `ULPRB_MAX`; +DROP TABLE IF EXISTS `DLPRB_MAX`; +DROP TABLE IF EXISTS `5G_MAX_IDX`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `日流量`(SELECT `日期`, `NCGI`, SUM(`5G上下行总流量_GB`) AS `日流量` FROM `5G` GROUP BY `日期`, `NCGI`); +ALTER TABLE `日流量` ADD INDEX (`NCGI`); +CREATE TEMPORARY TABLE IF NOT EXISTS `5G日均流量` ( + SELECT `NCGI`, AVG(`日流量`) AS `日均流量`, COUNT(`日期`) AS `流量有效天数` FROM `日流量` WHERE `日流量` > 0 GROUP BY `NCGI` +); + +CREATE TABLE IF NOT EXISTS `5G_结果表` ( + SELECT `NCGI`, `CU小区配置名称`, + ROUND(AVG(`5G上下行总流量_GB`),6) AS `5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)`, + ROUND(AVG(`5G上行流量_GB`),6) AS `5G上行流量(上行PDCP PDU数据量)(GB)`, + ROUND(AVG(`5G下行流量_GB`),6) AS `5G下行流量(下行PDCP成功发送数据量)(GB)`, + ROUND(AVG(`小区平均激活UE数`),6) AS `小区平均激活UE数`, + ROUND(AVG(`RRC连接最大连接用户数`),6) AS `RRC连接最大连接用户数`, + ROUND(AVG(`RRC连接平均连接用户数`),6) AS `RRC连接平均连接用户数`, + ROUND(AVG(`小区最大激活UE数`),6) AS `小区最大激活UE数`, + ROUND(AVG(`上行PRB平均利用率`),6) AS `上行PRB平均利用率`, + ROUND(AVG(`下行PRB平均利用率`),6) AS `下行PRB平均利用率`, + ROUND(AVG(`PDCCH信道动态CCE占用率`),6) AS `PDCCH信道CCE占用率(动态)(%)`, + ROUND(AVG(`小区下行RLC_SDU字节数_MByte`),6) AS `小区下行RLC SDU字节数(MByte)`, + ROUND(AVG(`小区上行RLC_SDU字节数_MByte`),6) AS `小区上行RLC SDU字节数(MByte)`, + ROUND(AVG(`平均单Flow流量_MB`),6) AS `单Flow流量(MB)` + FROM `5G_MAX` GROUP BY `NCGI`, `CU小区配置名称` +); + +ALTER TABLE `5G_结果表` ADD COLUMN `日均流量(GB)` DOUBLE AFTER `CU小区配置名称`, ADD INDEX (`NCGI`); +ALTER TABLE `5G日均流量` ADD INDEX (`NCGI`); +UPDATE `5G_结果表` INNER JOIN `5G日均流量` +ON `5G_结果表`.`NCGI` = `5G日均流量`.`NCGI` +SET `5G_结果表`.`日均流量(GB)` = ROUND(`5G日均流量`.`日均流量`,6); + +DROP TABLE IF EXISTS `日流量`; +DROP TABLE IF EXISTS `5G日均流量`; +DROP TABLE IF EXISTS `4G`; +DROP TABLE IF EXISTS `5G`; +DROP TABLE IF EXISTS `5G_MAX`; + +UPDATE `4G_结果表` SET `日均流量(GB)` = IFNULL(`日均流量(GB)`, 0), +`自忙时流量(GB)` = IFNULL(`自忙时流量(GB)`, 0), +`ERAB流量` = IFNULL(`ERAB流量`, 0), +`上行流量(GB)` = IFNULL(`上行流量(GB)`, 0), +`下行流量(GB)` = IFNULL(`下行流量(GB)`, 0), +`用户面平均激活UE数` = IFNULL(`用户面平均激活UE数`, 0), +`YY-RRC连接建立最大用户数` = IFNULL(`YY-RRC连接建立最大用户数`, 0), +`上行PUSCH利用率` = IFNULL(`上行PUSCH利用率`, 0), +`下行PDSCH利用率` = IFNULL(`下行PDSCH利用率`, 0), +`PDCCH资源利用率` = IFNULL(`PDCCH资源利用率`, 0), +`用户面最大激活UE数` = IFNULL(`用户面最大激活UE数`, 0); + +UPDATE `5G_结果表` SET +`日均流量(GB)` = IFNULL(`日均流量(GB)`,0), +`5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)` = IFNULL(`5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)`,0), +`5G上行流量(上行PDCP PDU数据量)(GB)` = IFNULL(`5G上行流量(上行PDCP PDU数据量)(GB)`,0), +`5G下行流量(下行PDCP成功发送数据量)(GB)` = IFNULL(`5G下行流量(下行PDCP成功发送数据量)(GB)`,0), +`小区平均激活UE数` = IFNULL(`小区平均激活UE数`,0), +`RRC连接最大连接用户数` = IFNULL(`RRC连接最大连接用户数`,0), +`RRC连接平均连接用户数` = IFNULL(`RRC连接平均连接用户数`,0), +`小区最大激活UE数` = IFNULL(`小区最大激活UE数`,0), +`上行PRB平均利用率` = IFNULL(`上行PRB平均利用率`,0), +`下行PRB平均利用率` = IFNULL(`下行PRB平均利用率`,0), +`PDCCH信道CCE占用率(动态)(%)` = IFNULL(`PDCCH信道CCE占用率(动态)(%)`,0), +`小区下行RLC SDU字节数(MByte)` = IFNULL(`小区下行RLC SDU字节数(MByte)`,0), +`小区上行RLC SDU字节数(MByte)` = IFNULL(`小区上行RLC SDU字节数(MByte)`,0), +`单Flow流量(MB)` = IFNULL(`单Flow流量(MB)`,0); + + +# 处理日间(广电、移动)流量数据 + +ALTER TABLE `5G_结果表` +ADD COLUMN `_gNBId` varchar(50), +ADD COLUMN `_cellId` varchar(50), +ADD COLUMN `移动流量_GB` double NOT NULL DEFAULT 0 AFTER `CU小区配置名称`, +ADD COLUMN `广电流量_GB` double NOT NULL DEFAULT 0 AFTER `移动流量_GB`; + +UPDATE `5G_结果表` SET + `_gNBId` = SUBSTRING_INDEX(SUBSTRING_INDEX(`NCGI`, '-', 3), '-', -1), + `_cellId` = SUBSTRING_INDEX(`NCGI`, '-', -1); + +ALTER TABLE `5G_结果表` ADD INDEX `_rj`(`_gNBId`, `_cellId`); + + +CREATE TABLE `_rj_yd` ( `gNBId` varchar(50), `cellId` varchar(50), `上下行总流量_GB` double, INDEX (`gNBId`, `cellId`)) AS + SELECT `gNBId`, `cellId`, AVG(`上下行总流量_GB`) AS `上下行总流量_GB` FROM ( + SELECT `gNBId`, `cellId`, `上下行总流量_GB` FROM `2_6GRJYD` + UNION ALL + SELECT `gNBId`, `cellId`, `上下行总流量_GB` FROM `700MRJYD` + ) AS t + GROUP BY `gNBId`, `cellId`; + +CREATE TABLE `_rj_gd` ( + `gNBId` varchar(50), `cellId` varchar(50), `上下行总流量_GB` double, + INDEX (`gNBId`, `cellId`) +) AS +SELECT `gNBId`, `cellId`, AVG(`上下行总流量_GB`) AS `上下行总流量_GB` +FROM ( + SELECT `gNBId`, `cellId`, `上下行总流量_GB` FROM `2_6GRJGD` + UNION ALL + SELECT `gNBId`, `cellId`, `上下行总流量_GB` FROM `700MRJGD` +) AS t +GROUP BY `gNBId`, `cellId`; + +CREATE TABLE `rj` ( + `gNBId` varchar(50), `cellId` varchar(50), + `移动流量_GB` double NOT NULL DEFAULT 0, + `广电流量_GB` double NOT NULL DEFAULT 0, + INDEX (`gNBId`, `cellId`) +) AS +SELECT + `_rj_yd`.`gNBId`, `_rj_yd`.`cellId`, + IFNULL(`_rj_yd`.`上下行总流量_GB`, 0) AS `移动流量_GB`, + IFNULL(`_rj_gd`.`上下行总流量_GB`, 0) AS `广电流量_GB` +FROM `_rj_yd` +LEFT JOIN `_rj_gd` ON `_rj_yd`.`gNBId` = `_rj_gd`.`gNBId` AND `_rj_yd`.`cellId` = `_rj_gd`.`cellId` +UNION ALL +SELECT + `_rj_gd`.`gNBId`, `_rj_gd`.`cellId`, + 0, `_rj_gd`.`上下行总流量_GB` +FROM `_rj_gd` +LEFT JOIN `_rj_yd` ON `_rj_gd`.`gNBId` = `_rj_yd`.`gNBId` AND `_rj_gd`.`cellId` = `_rj_yd`.`cellId` +WHERE `_rj_yd`.`gNBId` IS NULL; + +UPDATE `5G_结果表` +LEFT JOIN `rj` + ON `5G_结果表`.`_gNBId` = `rj`.`gNBId` + AND `5G_结果表`.`_cellId` = `rj`.`cellId` +SET + `5G_结果表`.`移动流量_GB` = IFNULL(`rj`.`移动流量_GB`, 0), + `5G_结果表`.`广电流量_GB` = IFNULL(`rj`.`广电流量_GB`, 0); + +ALTER TABLE `5G_结果表` DROP INDEX `_rj`, DROP COLUMN `_gNBId`, DROP COLUMN `_cellId`; + +DROP TABLE `_rj_yd`; +DROP TABLE `_rj_gd`; +DROP TABLE `rj`; + + +# ============================================================ +# 从 sector / cellinfo 富集小区信息 +# ============================================================ + + +ALTER TABLE `4G_结果表` + ADD COLUMN `扇区` varchar(200) AFTER `小区名称`, + ADD COLUMN `物理站` varchar(200) AFTER `扇区`, + ADD COLUMN `站型` varchar(50) AFTER `物理站`, + ADD COLUMN `频段` varchar(50) AFTER `站型`, + ADD COLUMN `频点` varchar(50) AFTER `频段`, + ADD COLUMN `带宽` varchar(20) AFTER `频点`, + ADD COLUMN `制式` varchar(50) AFTER `带宽`, + ADD COLUMN `功率` varchar(100) AFTER `制式`; + +ALTER TABLE `5G_结果表` + ADD COLUMN `基站名称` varchar(200) AFTER `NCGI`, + ADD COLUMN `扇区` varchar(200) AFTER `基站名称`, + ADD COLUMN `物理站` varchar(200) AFTER `扇区`, + ADD COLUMN `站型` varchar(50) AFTER `物理站`, + ADD COLUMN `频段` varchar(50) AFTER `站型`, + ADD COLUMN `频点` varchar(50) AFTER `频段`, + ADD COLUMN `带宽` varchar(20) AFTER `频点`, + ADD COLUMN `制式` varchar(50) AFTER `带宽`, + ADD COLUMN `功率` varchar(100) AFTER `制式`; + + + +UPDATE `4G_结果表` `t` +LEFT JOIN `sector` `s` ON `t`.`CGI` = `s`.`CGI` +SET + `t`.`扇区` = `s`.`扇区`, + `t`.`物理站` = `s`.`物理站`, + `t`.`站型` = `s`.`站型`, + `t`.`频段` = `s`.`频段`; + + +UPDATE `4G_结果表` `t` +LEFT JOIN `cellinfo` `c` ON `t`.`CGI` = `c`.`CGI` +SET + `t`.`频点` = `c`.`频点`, + `t`.`带宽` = `c`.`带宽`, + `t`.`制式` = `c`.`制式`, + `t`.`功率` = `c`.`功率`; + + +UPDATE `5G_结果表` `t` +LEFT JOIN `sector` `s` ON `t`.`NCGI` = `s`.`CGI` +SET + `t`.`扇区` = `s`.`扇区`, + `t`.`物理站` = `s`.`物理站`, + `t`.`站型` = `s`.`站型`, + `t`.`频段` = `s`.`频段`; + + +UPDATE `5G_结果表` `t` +LEFT JOIN `cellinfo` `c` ON `t`.`NCGI` = `c`.`CGI` +SET + `t`.`基站名称` = `c`.`基站名称`, + `t`.`频点` = `c`.`频点`, + `t`.`带宽` = `c`.`带宽`, + `t`.`制式` = `c`.`制式`, + `t`.`功率` = `c`.`功率`; + + + + + + + +# ============================================================ +# 高负荷小区判定(互斥,利用率优先) +# 利用率达标 → 用户数达标 = 高负荷;否则 = 利用率预警 +# 利用率不达标 → 流量达标 = 高流量预警;否则 = 正常 +# 是否高负荷小区:仅「高负荷」= 是;两类预警与正常 = 否(预警 ≠ 高负荷)。 +# 门限区分 制式 / 带宽(直接用富集写入结果表的 `制式` `带宽` 列,无需再 JOIN);不分站型;3DMM 归 TDD(0.5)。 +# 阈值与命中量见 docs/高负荷小区判定_调研记录.md(§8)。 +# ============================================================ + +ALTER TABLE `4G_结果表` ADD COLUMN `是否高负荷小区` VARCHAR(100); +ALTER TABLE `4G_结果表` ADD COLUMN `高负荷问题` VARCHAR(50); + +ALTER TABLE `4G_结果表` ADD COLUMN `优化建议` TEXT; + +ALTER TABLE `5G_结果表` ADD COLUMN `是否高负荷小区` VARCHAR(100); +ALTER TABLE `5G_结果表` ADD COLUMN `高负荷问题` VARCHAR(50); +ALTER TABLE `5G_结果表` ADD COLUMN `优化建议` TEXT; + +# 4G:利用率 FDD≥0.7 / 其余(TDD、3DMM)≥0.5;用户数(YY-RRC)按带宽 20M=30/15M=25/10M=20/5M=12; +# 高流量(下行流量GB)按带宽 20M=10/15M=7.5/10M=5/5M=2.5。 +UPDATE `4G_结果表` SET `高负荷问题` = CASE + WHEN (`下行PDSCH利用率` >= CASE WHEN `制式` = 'FDD' THEN 0.7 ELSE 0.5 END + OR `上行PUSCH利用率` >= CASE WHEN `制式` = 'FDD' THEN 0.7 ELSE 0.5 END) + THEN CASE + WHEN `YY-RRC连接建立最大用户数` >= CASE `带宽` WHEN '15M' THEN 25 WHEN '10M' THEN 20 WHEN '5M' THEN 12 ELSE 30 END + THEN '高负荷' + ELSE '利用率预警' + END + WHEN `下行流量(GB)` >= CASE `带宽` WHEN '15M' THEN 7.5 WHEN '10M' THEN 5 WHEN '5M' THEN 2.5 ELSE 10 END + THEN '高流量预警' + ELSE NULL +END; + +UPDATE `4G_结果表` SET `是否高负荷小区` = IF(`高负荷问题` = '高负荷', '是', '否'); + +# 5G:利用率 上行PRB≥0.5 或 下行PRB≥0.7;用户数(RRC平均)按带宽 100M/80M=200/60M=120/30M=90; +# 高流量(上行GB 或 下行GB)按带宽 100M=5/70、60M=3/43、30M=8/30。 +UPDATE `5G_结果表` SET `高负荷问题` = CASE + WHEN (`上行PRB平均利用率` >= 0.5 OR `下行PRB平均利用率` >= 0.7) + THEN CASE + WHEN `RRC连接平均连接用户数` >= CASE `带宽` WHEN '60M' THEN 120 WHEN '30M' THEN 90 ELSE 200 END + THEN '高负荷' + ELSE '利用率预警' + END + WHEN (`5G上行流量(上行PDCP PDU数据量)(GB)` >= CASE `带宽` WHEN '60M' THEN 3 WHEN '30M' THEN 8 ELSE 5 END + OR `5G下行流量(下行PDCP成功发送数据量)(GB)` >= CASE `带宽` WHEN '60M' THEN 43 WHEN '30M' THEN 30 ELSE 70 END) + THEN '高流量预警' + ELSE NULL +END; + +UPDATE `5G_结果表` SET `是否高负荷小区` = IF(`高负荷问题` = '高负荷', '是', '否'); + + +# ============================================================ +# 优化建议(仅对 是否高负荷小区='是' 的高负荷小区生成) +# 分析范围:同扇区(`扇区`) + 同 PLMN(CGI/NCGI 前两段,如 460-00 移动 / 460-15 广电; +# 不同运营商不可均衡、不视为同扇区)。 +# 在该范围内取“最空闲”小区(`高负荷问题` IS NULL,按 下行利用率→日均流量 升序取一): +# 有空闲小区 → 模板1:给出本小区与空闲小区的指标,建议负载均衡; +# 无空闲小区(同扇区皆 高负荷/利用率预警/高流量预警)→ 模板2:建议人工分析周边小区。 +# `_idle_4g`/`_idle_5g` 为每个(扇区,PLMN)的最空闲小区缓存表,用后即删。 +# ============================================================ + +DROP TABLE IF EXISTS `_idle_4g`; +CREATE TABLE `_idle_4g` ( + `扇区` varchar(200), `plmn` varchar(20), + `cgi` varchar(120), `name` varchar(200), `ul` double, `dl` double, `flow` double, `pwr` varchar(120), + INDEX (`扇区`, `plmn`) +) AS +SELECT `扇区`, `plmn`, `CGI` AS `cgi`, `小区名称` AS `name`, `上行PUSCH利用率` AS `ul`, `下行PDSCH利用率` AS `dl`, `日均流量(GB)` AS `flow`, `功率` AS `pwr` +FROM ( + SELECT `扇区`, SUBSTRING_INDEX(`CGI`, '-', 2) AS `plmn`, `CGI`, `小区名称`, + `上行PUSCH利用率`, `下行PDSCH利用率`, `日均流量(GB)`, `功率`, + ROW_NUMBER() OVER (PARTITION BY `扇区`, SUBSTRING_INDEX(`CGI`, '-', 2) + ORDER BY `下行PDSCH利用率` ASC, `日均流量(GB)` ASC) AS `rn` + FROM `4G_结果表` + WHERE `高负荷问题` IS NULL AND `扇区` IS NOT NULL AND `扇区` <> '' +) `t` +WHERE `rn` = 1; + +UPDATE `4G_结果表` `t` +LEFT JOIN `_idle_4g` `i` ON `i`.`扇区` = `t`.`扇区` AND `i`.`plmn` = SUBSTRING_INDEX(`t`.`CGI`, '-', 2) +SET `t`.`优化建议` = CONCAT( + '高负荷小区', `t`.`CGI`, '(', IFNULL(`t`.`小区名称`, '-'), ')', + ':上行利用率:', ROUND(`t`.`上行PUSCH利用率` * 100, 1), '%', + ' 下行利用率:', ROUND(`t`.`下行PDSCH利用率` * 100, 1), '%', + ' 日均流量:', ROUND(`t`.`日均流量(GB)`, 2), 'GB', + ' 用户数:', ROUND(`t`.`YY-RRC连接建立最大用户数`, 0), + ' 当前小区功率:', IFNULL(`t`.`功率`, '-'), '。', + IF(`i`.`cgi` IS NOT NULL, + CONCAT('存在同覆盖空闲小区:', `i`.`cgi`, '(', IFNULL(`i`.`name`, '-'), ')', + ':下行利用率:', ROUND(`i`.`dl` * 100, 1), '%', + ' 上行利用率:', ROUND(`i`.`ul` * 100, 1), '%', + ' 日均流量:', ROUND(`i`.`flow`, 2), 'GB', + ' 小区功率:', IFNULL(`i`.`pwr`, '-'), + ',建议将本小区部分负荷均衡至该空闲小区(负载均衡/邻区参数优化)。'), + '经分析,本站同扇区(同PLMN)小区均为高负荷或预警状态、无空闲可均衡小区,建议人工分析周边小区是否可均衡。' + ) +) +WHERE `t`.`是否高负荷小区` = '是'; + +DROP TABLE IF EXISTS `_idle_4g`; + + +DROP TABLE IF EXISTS `_idle_5g`; +CREATE TABLE `_idle_5g` ( + `扇区` varchar(200), `plmn` varchar(20), + `cgi` varchar(120), `name` varchar(200), `ul` double, `dl` double, `flow` double, `pwr` varchar(120), + INDEX (`扇区`, `plmn`) +) AS +SELECT `扇区`, `plmn`, `NCGI` AS `cgi`, `CU小区配置名称` AS `name`, `上行PRB平均利用率` AS `ul`, `下行PRB平均利用率` AS `dl`, `日均流量(GB)` AS `flow`, `功率` AS `pwr` +FROM ( + SELECT `扇区`, SUBSTRING_INDEX(`NCGI`, '-', 2) AS `plmn`, `NCGI`, `CU小区配置名称`, + `上行PRB平均利用率`, `下行PRB平均利用率`, `日均流量(GB)`, `功率`, + ROW_NUMBER() OVER (PARTITION BY `扇区`, SUBSTRING_INDEX(`NCGI`, '-', 2) + ORDER BY `下行PRB平均利用率` ASC, `日均流量(GB)` ASC) AS `rn` + FROM `5G_结果表` + WHERE `高负荷问题` IS NULL AND `扇区` IS NOT NULL AND `扇区` <> '' +) `t` +WHERE `rn` = 1; + +UPDATE `5G_结果表` `t` +LEFT JOIN `_idle_5g` `i` ON `i`.`扇区` = `t`.`扇区` AND `i`.`plmn` = SUBSTRING_INDEX(`t`.`NCGI`, '-', 2) +SET `t`.`优化建议` = CONCAT( + '高负荷小区', `t`.`NCGI`, '(', IFNULL(`t`.`CU小区配置名称`, '-'), ')', + ':上行PRB利用率:', ROUND(`t`.`上行PRB平均利用率` * 100, 1), '%', + ' 下行PRB利用率:', ROUND(`t`.`下行PRB平均利用率` * 100, 1), '%', + ' 日均流量:', ROUND(`t`.`日均流量(GB)`, 2), 'GB', + ' 用户数:', ROUND(`t`.`RRC连接平均连接用户数`, 0), + ' 当前小区功率:', IFNULL(`t`.`功率`, '-'), '。', + IF(`i`.`cgi` IS NOT NULL, + CONCAT('存在同覆盖空闲小区:', `i`.`cgi`, '(', IFNULL(`i`.`name`, '-'), ')', + ':下行PRB利用率:', ROUND(`i`.`dl` * 100, 1), '%', + ' 上行PRB利用率:', ROUND(`i`.`ul` * 100, 1), '%', + ' 日均流量:', ROUND(`i`.`flow`, 2), 'GB', + ' 小区功率:', IFNULL(`i`.`pwr`, '-'), + ',建议将本小区部分负荷均衡至该空闲小区(负载均衡/邻区参数优化)。'), + '经分析,本站同扇区(同PLMN)小区均为高负荷或预警状态、无空闲可均衡小区,建议人工分析周边小区是否可均衡。' + ) +) +WHERE `t`.`是否高负荷小区` = '是'; + +DROP TABLE IF EXISTS `_idle_5g`; + diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..ee1f0d8 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,153 @@ +# CapacityReport 使用说明 + +本文面向使用者,介绍 Web 端各功能的操作方法与注意事项。系统的安装、运行与编译请见 [README](README.md)。 + +## 登录 + +- 启动服务后在浏览器访问 `http://localhost:9081`(桌面版直接打开应用窗口)。 +- 默认账号:用户名 `root`,密码 `Capacity`。 +- 登录后可在「系统设置 → 修改密码」修改密码。建议使用 Chrome / Edge 等现代浏览器。 + +## 整体使用流程 + +```mermaid +flowchart LR + A[登录] --> B[系统设置
配置数据库 + 远程数据源] + B --> C[数据处理
上传或远程下载数据] + C --> D[处理入库
执行报表 SQL] + D --> E[容量看板
查看高负荷分析] + D --> F[数据管理
查看/导出结果表] + C --> G[处理历史
查看日志/下载原始数据] +``` + +首次使用建议顺序:先在「系统设置」配好 MySQL 数据库,再在「数据处理」上传或下载数据,处理完成后到「容量看板」「数据管理」查看结果。 + +## 准备工作(系统设置) + +正式处理数据前,至少要配置好数据库;使用远程自动下载时再配置远程数据源。 + +| 配置项 | 位置 | 说明 | +| --- | --- | --- | +| 数据库(数据仓库) | 系统设置 → 数据库 | 填写 MySQL 8.0+ 的主机、端口、库名、账号密码,保存后点「测试连接」确认可用 | +| CellData 数据库 | 系统设置 → 数据库 | 可选。若使用 CellData 小区信息,配置其数据库(可与主库相同或独立) | +| 远程数据源 | 系统设置 → 远程数据源 | 可选。配置 FTP/SFTP 服务器与目录,用于「远程下载并处理」 | +| 自动调度 | 系统设置 → 自动调度 | 可选。开启后系统按目标自然周自动检查远程数据并处理 | +| 字段映射 / Sheet 过滤 / 目录映射 | 系统设置 → 规则映射 | 配置源数据字段到目标字段的映射、Sheet 过滤规则、目录到暂存表的映射 | +| 处理历史保留 | 系统设置 → 数据库 | 可选。设置自动清理已结束历史、保留最近次数 | + +> 提示:连接 FTP/SFTP 和 MySQL 的测试按钮会真实发起连接,若服务器较慢可能需要等待几秒。 + +## 功能页使用说明 + +### 数据处理 + +数据处理页提供「容量数据」和「CellData」两个区域,每个区域都支持本地上传与远程下载两种方式。每个卡片右上角有说明按钮,可查看所需文件格式。 + +| 操作 | 说明 | +| --- | --- | +| 本地上传(容量数据) | 拖拽或点击选择文件,支持 `.zip / .xlsx / .xls / .csv`,也可整文件夹上传 | +| 远程下载并处理(容量数据) | 从「系统设置」中配置的 FTP/SFTP 目录下载数据后自动处理 | +| CellData 上传 | 拖拽或选择**文件夹**(需包含 `700M`、`2.6G` 等频段子目录),不支持单个 ZIP 直接拖入 | +| CellData 远程下载并处理 | 按 CellData 数据源配置拉取并处理 | + +处理过程的阶段会在进度区实时显示: + +```mermaid +flowchart LR + S0[远程下载中] --> S1[更新 CellData] + S1 --> S2[授权校验] + S2 --> S3[解压数据] + S3 --> S4[转换 Excel] + S4 --> S5[导入数据] + S5 --> S6[运行脚本] + S6 --> S7[完成] +``` + +| 阶段 | 含义 | +| --- | --- | +| 远程下载中 | 从 FTP/SFTP 下载数据(仅远程方式) | +| 更新 CellData | 启用 CellData 数据源时,容量处理前先刷新小区信息 | +| 授权校验中 | 按数据日期校验授权期限 | +| 解压数据中 | 解压上传/下载的 ZIP | +| 转换 Excel 中 | 将 Excel 转换为 CSV | +| 导入数据中 | 数据写入 MySQL 暂存表 | +| 运行脚本中 | 执行 `ReportScript.sql` 生成结果表 | + +> 注意:同一时间只能运行一个处理任务。任务运行时上传区会隐藏,只显示进度与日志,请等当前任务结束后再发起新任务。 + +### 容量看板 + +展示主数据库中 4G/5G 结果表的高负荷分析大屏,需先完成数据处理生成结果表。 + +| 区域 | 说明 | +| --- | --- | +| 制式切换 / 刷新 | 顶部切换 4G / 5G,点击刷新重新加载 | +| 汇总卡片 | 小区总数、高负荷数、各类预警、平均利用率、总流量等 | +| 图表 | 负荷问题分布、上下行利用率分布、制式 / 站型 / 频段统计等 | +| 问题小区清单 | 按高负荷优先排序,支持筛选、关键字搜索、分页与导出 CSV | +| 小区详情 | 点击清单某行打开右侧抽屉,查看关键指标、优化建议、同扇区小区 | + +> 说明:未生成结果表时,看板显示「请先进行数据处理」。 + +### 处理历史 + +记录每次处理任务,便于回溯与排查。 + +| 操作 | 说明 | +| --- | --- | +| 查看详情 | 打开任务详情与分级处理日志 | +| 文件浏览 | 在详情中浏览该任务工作目录下的文件 | +| 下载 | 将历史原始数据打包为 ZIP 下载(任务完成后可用) | +| 删除 | 删除历史记录及其相关文件 | + +### 数据管理 + +直接查看与维护数据库中的表,可在主数据库与 CellData 数据库之间切换。 + +| 操作 | 说明 | +| --- | --- | +| 选择数据库 / 表 | 表标题旁图标按钮切换数据库;左侧列表选择表 | +| 浏览数据 | 分页查看表数据,列宽可拖拽,可展开字段结构 | +| 编辑 / 删除行 | 每行可编辑或删除;编辑时清空某字段表示写入 NULL | +| 导出 | 导出当前表为 CSV,或选择多张表导出为同一个 XLSX | +| 模板 / 导入 | 下载当前表字段的 CSV 模板;导入要求表头与表字段完全一致 | +| 清空 / 删除表 | 清空表数据或删除整张表 | + +> 注意:清空、删除表、删除行为不可恢复操作,请谨慎确认。 + +### 脚本编辑 + +在线编辑并执行业务 SQL,使用 Monaco 编辑器(带 SQL 高亮)。 + +| 操作 | 说明 | +| --- | --- | +| 切换脚本 | 顶部切换「容量报表脚本」(`ReportScript.sql`)和「CellData 脚本」(`CellData.sql`) | +| 保存 | 保存脚本,保存前会自动备份为 `.sql.bak` | +| 运行 | 在对应数据库上执行当前脚本,下方显示运行日志 | + +> 说明:脚本运行也占用同一个任务锁,运行期间不能同时发起数据处理。 + +### 系统设置 + +| 标签页 | 内容 | +| --- | --- | +| 数据库 | 主仓库 MySQL、CellData 数据库、处理历史保留 | +| 远程数据源 | 容量数据 FTP/SFTP、CellData 数据源(含扫描路径、规则设置) | +| 自动调度 | 开关、检查间隔、目标周期、预期目录、调度状态 | +| 规则映射 | Sheet 过滤规则、字段映射、数据目录映射 | +| 修改密码 | 修改登录密码 | + +页面顶部可下载 / 上传配置文件(`Configure.json`),方便备份与迁移。 + +## 注意事项 + +| 事项 | 说明 | +| --- | --- | +| 任务串行 | 数据处理、CellData 处理、脚本执行共用一个任务锁,同一时间只能运行一个,请勿并发触发 | +| 文件命名日期 | 容量数据 ZIP 文件名需含时间戳(`XXX_YYYYMMDDHHMM` 或 `XXX_YYYYMMDDHHMM_YYYYMMDDHHMM`),系统以**第一个**时间戳作为数据日期 | +| 每目录 7 天窗口 | 每个目录只保留并处理最近 7 个自然日的文件 | +| CellData 文件 | 来源为 `Result_300_*.zip`,时间取文件名末尾时间戳;本地上传必须按频段目录组织 | +| 授权期限 | 处理时按数据日期校验授权,超期会失败;可在弹出的激活框输入激活码延长 30 天 | +| 自动调度前提 | 开启自动调度会强制开启远程自动化与处理后删除源文件,请确认远程目录配置正确 | +| 数据安全 | 清空表、删除表、删除行、删除历史均不可恢复,操作前请确认 | +| 端口占用 | 服务默认使用 `9081` 端口,若被占用可在运行时指定其他端口 | diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..921a20e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""CapacityReport backend package.""" + diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..b05dfd6 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,2 @@ +"""API package.""" + diff --git a/app/api/routers/__init__.py b/app/api/routers/__init__.py new file mode 100644 index 0000000..f3c7a8e --- /dev/null +++ b/app/api/routers/__init__.py @@ -0,0 +1,2 @@ +"""API router package.""" + diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py new file mode 100644 index 0000000..77de9f5 --- /dev/null +++ b/app/api/routers/auth.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, Body +from fastapi.responses import JSONResponse + +from app.auth import create_jwt_token, get_auth_config, save_auth_password + + +router = APIRouter(tags=["auth"]) + + +@router.post("/api/login") +async def login( + username: str = Body(..., embed=True), + password: str = Body(..., embed=True), +): + auth = get_auth_config() + if username != auth["username"] or password != auth["password"]: + return JSONResponse(status_code=401, content={"detail": "账号或密码错误"}) + + token = create_jwt_token({"user": username}) + return {"success": True, "token": token} + + +@router.post("/api/change-password") +async def change_password( + current_password: str = Body(..., embed=True), + new_password: str = Body(..., embed=True), +): + auth = get_auth_config() + if current_password != auth["password"]: + return JSONResponse(status_code=400, content={"detail": "当前密码错误"}) + if len(new_password) < 4: + return JSONResponse(status_code=400, content={"detail": "新密码长度不能少于 4 位"}) + + save_auth_password(new_password) + return {"success": True, "message": "密码修改成功"} + diff --git a/app/api/routers/cache.py b/app/api/routers/cache.py new file mode 100644 index 0000000..c35c8bc --- /dev/null +++ b/app/api/routers/cache.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter + +from app.config import CACHE_DIR +from app.utils.files import format_size, get_dir_size + + +router = APIRouter(tags=["cache"]) + + +@router.get("/api/cache/size") +async def get_cache_size(): + if not CACHE_DIR.exists(): + return { + "success": True, + "size_bytes": 0, + "size_formatted": "0 B", + "file_count": 0, + "dir_count": 0, + } + + total_size = 0 + file_count = 0 + dir_count = 0 + + try: + for item in CACHE_DIR.iterdir(): + if item.name == "history.json": + continue + if item.is_dir(): + dir_count += 1 + elif item.is_file(): + file_count += 1 + total_size += get_dir_size(item) + except (PermissionError, OSError) as exc: + return {"success": False, "error": str(exc), "size_formatted": "计算失败"} + + return { + "success": True, + "size_bytes": total_size, + "size_formatted": format_size(total_size), + "file_count": file_count, + "dir_count": dir_count, + } + diff --git a/app/api/routers/cell_data.py b/app/api/routers/cell_data.py new file mode 100644 index 0000000..ffaddec --- /dev/null +++ b/app/api/routers/cell_data.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import time +from datetime import datetime +from pathlib import Path +from threading import Thread + +from fastapi import APIRouter, Body, File, HTTPException, UploadFile + +from app import state +from app.api.routers.task_runtime import set_task_stage +from app.config import AppConfig, CACHE_DIR, CELLDATA_SCRIPT +from app.processor import ProcessLogger +from app.services.cell_data import CellDataProcessor, execute_celldata_script, refresh_cell_data +from app.utils.files import safe_relative_path + +router = APIRouter(tags=["cell-data"]) + + +@router.post("/api/cell-data/process/start") +async def start_cell_data_processing(): + if state.global_task_lock["locked"]: + raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成") + + task_id = "cell_data_" + datetime.now().strftime("%Y%m%d_%H%M%S") + work_dir = CACHE_DIR / task_id + work_dir.mkdir(parents=True, exist_ok=True) + logs: list[str] = [] + current_stage = "locating" + + def log_callback(message: str) -> None: + logs.append(message) + set_task_stage(task_id, current_stage, logs) + + def stage_callback(stage: str) -> None: + nonlocal current_stage + current_stage = stage + set_task_stage(task_id, current_stage, logs) + + logger = ProcessLogger( + log_file=work_dir / "log.txt", + callback=log_callback, + stage_callback=stage_callback, + ) + app_config = state.current_config() + state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage} + state.global_task_lock.update( + { + "locked": True, + "task_id": task_id, + "stage": current_stage, + "started_at": datetime.now().isoformat(), + } + ) + + thread = Thread(target=_run_cell_data_processing, args=(task_id, work_dir, logger, app_config), daemon=True) + thread.start() + return {"success": True, "message": "CellData 处理已启动", "task_id": task_id, "stage": current_stage} + + +@router.post("/api/cell-data/process/upload") +async def upload_and_start_cell_data_processing(files: list[UploadFile] = File(...)): + if not files: + raise HTTPException(status_code=400, detail="没有上传文件") + if state.global_task_lock["locked"]: + raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成") + + task_id = "cell_data_" + datetime.now().strftime("%Y%m%d_%H%M%S") + work_dir = CACHE_DIR / task_id + upload_dir = work_dir / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + saved_count = 0 + for file in files: + if not file.filename: + continue + target = upload_dir / safe_relative_path(file.filename) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(await file.read()) + saved_count += 1 + if saved_count == 0: + raise HTTPException(status_code=400, detail="没有有效上传文件") + + logs: list[str] = [] + current_stage = "parsing" + + def log_callback(message: str) -> None: + logs.append(message) + set_task_stage(task_id, current_stage, logs) + + def stage_callback(stage: str) -> None: + nonlocal current_stage + current_stage = stage + set_task_stage(task_id, current_stage, logs) + + logger = ProcessLogger( + log_file=work_dir / "log.txt", + callback=log_callback, + stage_callback=stage_callback, + ) + app_config = state.current_config() + state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage} + state.global_task_lock.update( + { + "locked": True, + "task_id": task_id, + "stage": current_stage, + "started_at": datetime.now().isoformat(), + } + ) + + thread = Thread(target=_run_uploaded_cell_data_processing, args=(task_id, upload_dir, work_dir, logger, app_config), daemon=True) + thread.start() + return { + "success": True, + "message": "CellData 处理已启动", + "task_id": task_id, + "stage": current_stage, + "file_count": saved_count, + } + + +@router.post("/api/cell-data/process/status") +async def get_cell_data_processing_status(task_id: str = Body(..., embed=True)): + if task_id in state.processing_tasks: + task = state.processing_tasks[task_id] + return { + "task_id": task_id, + "status": task.get("status", "processing"), + "stage": task.get("stage", "processing"), + "logs": task.get("logs", []), + "error": task.get("error"), + "result": task.get("result"), + "elapsed_time": task.get("elapsed_time"), + } + raise HTTPException(status_code=404, detail="任务不存在") + + +def _run_cell_data_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None: + started = time.time() + try: + result = refresh_cell_data(app_config, work_dir, logger) + execute_celldata_script(CELLDATA_SCRIPT, app_config, logger) + elapsed = round(time.time() - started, 2) + state.processing_tasks[task_id] = { + "logs": logger.get_logs(), + "status": "completed", + "stage": "completed", + "elapsed_time": elapsed, + "result": { + "selected_files": result.selected_files, + "parsed_rows": result.parsed_rows, + "imported_rows": result.imported_rows, + "skipped_rows": result.skipped_rows, + }, + } + except Exception as exc: + logger.error(str(exc)) + state.processing_tasks[task_id] = { + "logs": logger.get_logs(), + "status": "failed", + "stage": "failed", + "error": str(exc), + "elapsed_time": round(time.time() - started, 2), + } + finally: + state.reset_task_lock() + + +def _run_uploaded_cell_data_processing(task_id: str, upload_dir: Path, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None: + started = time.time() + try: + result = CellDataProcessor(app_config, work_dir, logger).run_local(upload_dir) + execute_celldata_script(CELLDATA_SCRIPT, app_config, logger) + elapsed = round(time.time() - started, 2) + state.processing_tasks[task_id] = { + "logs": logger.get_logs(), + "status": "completed", + "stage": "completed", + "elapsed_time": elapsed, + "result": { + "selected_files": result.selected_files, + "parsed_rows": result.parsed_rows, + "imported_rows": result.imported_rows, + "skipped_rows": result.skipped_rows, + }, + } + except Exception as exc: + logger.error(str(exc)) + state.processing_tasks[task_id] = { + "logs": logger.get_logs(), + "status": "failed", + "stage": "failed", + "error": str(exc), + "elapsed_time": round(time.time() - started, 2), + } + finally: + state.reset_task_lock() diff --git a/app/api/routers/config.py b/app/api/routers/config.py new file mode 100644 index 0000000..5fca356 --- /dev/null +++ b/app/api/routers/config.py @@ -0,0 +1,365 @@ +import json +import re +from datetime import datetime +from typing import Any +from urllib.parse import quote + +import pymysql +from fastapi import APIRouter, Body, HTTPException, UploadFile, File +from fastapi.responses import Response + +from app import state +from app.config import ( + CellDataConfig, + DataMappingsConfig, + DEFAULT_CELL_DATA_MAPPING, + HistoryRetentionConfig, + MetrixConfig, + MySQLConfig, + RemoteDataConfig, + SOURCE_TYPES, + WAREHOUSE_TYPES, +) +from app.services.remote_download import RemoteDataDownloader + + +router = APIRouter(tags=["config"]) + + +@router.get("/api/config") +async def get_config(): + return state.current_config().to_dict() + + +@router.get("/api/config/full") +async def get_config_full(): + return state.current_config().to_dict_full() + + +@router.post("/api/config/mysql") +async def update_mysql_config( + host: str = Body(...), + port: int = Body(...), + user: str = Body(...), + passwd: str = Body(...), + dbname: str = Body(...), +): + state.reload_config() + state.config.mysql.host = host + state.config.mysql.port = port + state.config.mysql.user = user + state.config.mysql.passwd = passwd + state.config.mysql.dbname = dbname + state.config.save() + return {"success": True, "message": "数据库配置已更新", "update": state.config.update} + + +@router.post("/api/config/remote") +async def update_remote_config(config: dict[str, Any] = Body(...)): + state.reload_config() + state.config.remote_data = RemoteDataConfig.from_dict(config) + state.config.save() + return {"success": True, "message": "远程数据配置已更新", "update": state.config.update} + + +@router.post("/api/config/metrix-enabled") +async def update_metrix_enabled(enabled: bool = Body(..., embed=True)): + state.reload_config() + state.config.metrix_enabled = enabled + if not enabled: + state.config.source_type = "external" + state.config.warehouse_type = "mysql" + state.config.save() + return {"success": True, "message": "已更新", "update": state.config.update} + + +@router.post("/api/config/backend") +async def update_backend( + source_type: str = Body(...), + warehouse_type: str = Body(...), +): + if source_type not in SOURCE_TYPES: + raise HTTPException(status_code=400, detail="不支持的源类型") + if warehouse_type not in WAREHOUSE_TYPES: + raise HTTPException(status_code=400, detail="不支持的仓库类型") + state.reload_config() + state.config.source_type = source_type + state.config.warehouse_type = warehouse_type + state.config.save() + return {"success": True, "message": "后端类型已更新", "update": state.config.update} + + +@router.post("/api/config/metrix") +async def update_metrix_config(config: dict[str, Any] = Body(...)): + state.reload_config() + state.config.metrix = MetrixConfig.from_dict(config) + state.config.save() + return {"success": True, "message": "Metrix 连接配置已更新", "update": state.config.update} + + +@router.post("/api/config/data-mappings") +async def update_data_mappings_config(config: dict[str, Any] = Body(...)): + state.reload_config() + current = state.config.data_mappings.normalized() + payload = dict(config) + if "table_field_mappings" not in payload: + payload["table_field_mappings"] = current.table_field_mappings + state.config.data_mappings = DataMappingsConfig.from_dict(payload) + state.config.save() + return {"success": True, "message": "数据目录映射已更新", "update": state.config.update} + + +@router.post("/api/config/cell-data/remote") +async def update_cell_data_remote_config(config: dict[str, Any] = Body(...)): + state.reload_config() + current = state.config.cell_data.normalized() + state.config.cell_data = CellDataConfig( + remote_data=RemoteDataConfig.from_dict(config), + mysql=current.mysql, + scan_paths=current.scan_paths, + year_dir_regex=current.year_dir_regex, + month_dir_regex=current.month_dir_regex, + day_dir_regex=current.day_dir_regex, + file_name_regex=current.file_name_regex, + file_time_regex=current.file_time_regex, + mapping=current.mapping, + ).normalized() + state.config.save() + return {"success": True, "message": "CellData 远程数据源配置已更新", "update": state.config.update} + + +@router.post("/api/config/cell-data/mysql") +async def update_cell_data_mysql_config(config: dict[str, Any] = Body(...)): + state.reload_config() + current = state.config.cell_data.normalized() + state.config.cell_data = CellDataConfig( + remote_data=current.remote_data, + mysql=MySQLConfig.from_dict(config, default_dbname="celldata"), + scan_paths=current.scan_paths, + year_dir_regex=current.year_dir_regex, + month_dir_regex=current.month_dir_regex, + day_dir_regex=current.day_dir_regex, + file_name_regex=current.file_name_regex, + file_time_regex=current.file_time_regex, + mapping=current.mapping, + ).normalized() + state.config.save() + return {"success": True, "message": "CellData 数据库配置已更新", "update": state.config.update} + + +@router.post("/api/config/cell-data/remote/test") +def test_cell_data_remote_connection(config: dict[str, Any] | None = Body(None)): + try: + remote_config = RemoteDataConfig.from_dict(config) if config else state.current_config().cell_data.remote_data + RemoteDataDownloader(remote_config).test_connection() + return {"success": True, "message": "CellData 远程服务器连接成功"} + except Exception as exc: + return {"success": False, "message": f"连接失败: {exc}"} + + +@router.post("/api/config/cell-data/mysql/test") +def test_cell_data_mysql_connection(config: dict[str, Any] | None = Body(None)): + mysql_config = MySQLConfig.from_dict(config, default_dbname="celldata") if config else state.current_config().cell_data.mysql + return _test_mysql_config(mysql_config) + + +@router.post("/api/config/cell-data/settings") +async def update_cell_data_settings(config: dict[str, Any] = Body(...)): + validation = _validate_cell_data_settings(config) + if not validation["success"]: + raise HTTPException(status_code=400, detail=validation["message"]) + state.reload_config() + current = state.config.cell_data.normalized() + state.config.cell_data = CellDataConfig( + remote_data=current.remote_data, + mysql=current.mysql, + scan_paths=config.get("scan_paths", current.scan_paths), + year_dir_regex=str(config.get("year_dir_regex", current.year_dir_regex)), + month_dir_regex=str(config.get("month_dir_regex", current.month_dir_regex)), + day_dir_regex=str(config.get("day_dir_regex", current.day_dir_regex)), + file_name_regex=str(config.get("file_name_regex", current.file_name_regex)), + file_time_regex=str(config.get("file_time_regex", current.file_time_regex)), + mapping=config.get("mapping", current.mapping), + ).normalized() + state.config.save() + return {"success": True, "message": "CellData 规则已更新", "update": state.config.update} + + +@router.post("/api/config/cell-data/settings/validate") +async def validate_cell_data_settings(config: dict[str, Any] = Body(...)): + return _validate_cell_data_settings(config) + + +@router.get("/api/config/cell-data/mapping/default") +async def get_default_cell_data_mapping(): + return DEFAULT_CELL_DATA_MAPPING + + +@router.post("/api/config/history-retention") +async def update_history_retention(config: dict[str, Any] = Body(...)): + state.reload_config() + state.config.history_retention = HistoryRetentionConfig.from_dict(config) + state.config.save() + return {"success": True, "message": "处理历史保留配置已更新", "update": state.config.update} + + +@router.post("/api/config/sheet-filter") +async def update_sheet_filter(filters: list[str] = Body(...)): + state.reload_config() + state.config.sheet_filter = filters + state.config.save() + return {"success": True, "message": "Sheet 过滤规则已更新", "update": state.config.update} + + +@router.post("/api/config/extract-fields") +async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)): + state.reload_config() + state.config.extract_fields = fields + state.config.save() + return {"success": True, "message": "字段映射配置已更新", "update": state.config.update} + + +@router.get("/api/config/download") +async def download_config(): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"Configure_{timestamp}.json" + config_data = state.current_config().to_file_dict() + content = json.dumps(config_data, ensure_ascii=False, indent=2) + return Response( + content=content, + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"}, + ) + + +@router.post("/api/config/upload") +async def upload_config(file: UploadFile = File(...)): + if not file.filename or not file.filename.endswith(".json"): + raise HTTPException(status_code=400, detail="只支持 JSON 格式的配置文件") + + try: + data = json.loads((await file.read()).decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("配置文件格式错误:必须是 JSON 对象") + + state.reload_config() + _apply_config_data(data) + state.config.save() + return {"success": True, "message": "配置文件上传成功", "update": state.config.update} + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail="配置文件格式错误:不是有效的 JSON 文件") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"上传失败: {exc}") from exc + + +def _apply_config_data(data: dict[str, Any]) -> None: + if "MetrixEnabled" in data: + state.config.metrix_enabled = bool(data["MetrixEnabled"]) + + if data.get("SourceType") in SOURCE_TYPES: + state.config.source_type = data["SourceType"] + if data.get("WarehouseType") in WAREHOUSE_TYPES: + state.config.warehouse_type = data["WarehouseType"] + + # 未启用 Metrix 时强制回落为外部储存 + 直连 MySQL,避免导入后状态不一致 + if not state.config.metrix_enabled: + state.config.source_type = "external" + state.config.warehouse_type = "mysql" + + metrix_data = data.get("Metrix") + if isinstance(metrix_data, dict): + state.config.metrix = MetrixConfig.from_dict(metrix_data) + + data_mappings = data.get("DataMappings") + if isinstance(data_mappings, dict): + state.config.data_mappings = DataMappingsConfig.from_dict(data_mappings) + + mysql_data = data.get("MySQL_DBInfo") + if isinstance(mysql_data, dict): + state.config.mysql = MySQLConfig.from_dict(mysql_data) + + if "SheetFilter" in data: + state.config.sheet_filter = data["SheetFilter"] if isinstance(data["SheetFilter"], list) else [] + + if "ExtractField" in data: + state.config.extract_fields = data["ExtractField"] if isinstance(data["ExtractField"], list) else [] + + remote_data = data.get("RemoteData") + if isinstance(remote_data, dict): + state.config.remote_data = RemoteDataConfig.from_dict(remote_data) + + cell_data = data.get("CellData") + if isinstance(cell_data, dict): + state.config.cell_data = CellDataConfig.from_dict(cell_data) + + history_retention = data.get("HistoryRetention") + if isinstance(history_retention, dict): + state.config.history_retention = HistoryRetentionConfig.from_dict(history_retention) + + +def _test_mysql_config(config: MySQLConfig) -> dict[str, Any]: + normalized = config.normalized() + try: + conn = pymysql.connect( + host=normalized.host, + port=normalized.port, + user=normalized.user, + password=normalized.passwd, + database=normalized.dbname, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, + ) + try: + with conn.cursor() as cursor: + cursor.execute("SELECT 1") + finally: + conn.close() + return {"success": True, "message": "连接成功"} + except Exception as exc: + return {"success": False, "message": str(exc)} + + +def _validate_cell_data_settings(config: dict[str, Any]) -> dict[str, Any]: + scan_paths = config.get("scan_paths", []) + if not isinstance(scan_paths, list) or not any(str(path).strip() for path in scan_paths): + return {"success": False, "message": "请至少配置一个扫描路径"} + + for key in ("year_dir_regex", "month_dir_regex", "day_dir_regex", "file_name_regex", "file_time_regex"): + try: + re.compile(str(config.get(key, ""))) + except re.error as exc: + return {"success": False, "message": f"{key} 正则无效: {exc}"} + + mapping = config.get("mapping") + if not isinstance(mapping, dict): + return {"success": False, "message": "映射规则必须是 JSON 对象"} + if not str(mapping.get("target_table", "")).strip(): + return {"success": False, "message": "映射规则缺少 target_table"} + key_config = mapping.get("key") + if not isinstance(key_config, dict) or not key_config.get("field") or not key_config.get("expr"): + return {"success": False, "message": "映射规则缺少 key.field 或 key.expr"} + sources = mapping.get("sources") + if not isinstance(sources, list) or not sources: + return {"success": False, "message": "映射规则至少需要一个 sources 项"} + + for index, source in enumerate(sources, 1): + if not isinstance(source, dict): + return {"success": False, "message": f"sources 第 {index} 项必须是对象"} + if not source.get("band") or not source.get("file_prefix"): + return {"success": False, "message": f"sources 第 {index} 项缺少 band 或 file_prefix"} + fields = source.get("fields") + if not isinstance(fields, dict) or not fields: + return {"success": False, "message": f"sources 第 {index} 项缺少 fields"} + for target, rule in fields.items(): + if not str(target).strip(): + return {"success": False, "message": f"sources 第 {index} 项存在空目标字段"} + if isinstance(rule, str) and rule.strip(): + continue + if isinstance(rule, dict) and "value" in rule: + continue + return {"success": False, "message": f"{target} 的映射规则无效"} + return {"success": True, "message": "映射规则有效"} + diff --git a/app/api/routers/dashboard.py b/app/api/routers/dashboard.py new file mode 100644 index 0000000..610f66b --- /dev/null +++ b/app/api/routers/dashboard.py @@ -0,0 +1,339 @@ +"""容量看板分析接口。 + +基于 4G/5G 结果表(含富集 + 高负荷判定 + 优化建议列)做聚合分析,供前端容量看板展示。 +读经 `make_warehouse`(直连 MySQL 或 Metrix 平台),分析对象为“主仓库”里的结果表。 +""" +from __future__ import annotations + +import csv +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse +from starlette.background import BackgroundTask + +from app import state +from app.config import CACHE_DIR +from app.utils.files import remove_file_safely +from app.warehouse import make_warehouse + +router = APIRouter(tags=["dashboard"]) + +# 每个制式(结果表)的关键列映射 +RAT = { + "4g": { + "table": "4G_结果表", + "id": "CGI", + "name": "小区名称", + "ul": "上行PUSCH利用率", + "dl": "下行PDSCH利用率", + "ul_label": "上行PUSCH利用率", + "dl_label": "下行PDSCH利用率", + "users": "YY-RRC连接建立最大用户数", + }, + "5g": { + "table": "5G_结果表", + "id": "NCGI", + "name": "CU小区配置名称", + "ul": "上行PRB平均利用率", + "dl": "下行PRB平均利用率", + "ul_label": "上行PRB利用率", + "dl_label": "下行PRB利用率", + "users": "RRC连接平均连接用户数", + }, +} +FLOW = "日均流量(GB)" +PROBLEMS = ["高负荷", "利用率预警", "高流量预警"] + + +def _db(): + return make_warehouse(state.current_config()) + + +def _esc(value: str) -> str: + return str(value).replace("\\", "\\\\").replace("'", "''") + + +def _rows(db, sql: str) -> list[dict[str, Any]]: + ok, result = db.execute_sql(sql) + if not ok: + raise HTTPException(status_code=500, detail=str(result)) + return result if isinstance(result, list) else [] + + +def _num(value: Any, digits: int | None = None) -> float | int: + try: + num = float(value) + except (TypeError, ValueError): + return 0 + if digits is None: + return int(num) + return round(num, digits) + + +def _rat(rat: str) -> dict[str, str]: + cfg = RAT.get((rat or "").lower()) + if not cfg: + raise HTTPException(status_code=400, detail="rat 仅支持 4g / 5g") + return cfg + + +def _existing_tables(db) -> set[str]: + try: + return {str(t).lower() for t in db.get_tables()} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"读取数据表失败: {exc}") from exc + + +@router.get("/api/dashboard/status") +def dashboard_status(): + tables = _existing_tables(_db()) + has_4g = RAT["4g"]["table"].lower() in tables + has_5g = RAT["5g"]["table"].lower() in tables + return {"has_4g": has_4g, "has_5g": has_5g, "ready": has_4g and has_5g} + + +@router.get("/api/dashboard/overview") +def dashboard_overview(rat: str = Query("4g")): + cfg = _rat(rat) + db = _db() + if cfg["table"].lower() not in _existing_tables(db): + raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在,请先进行数据处理") + + t = f"`{cfg['table']}`" + ul, dl, flow, users = f"`{cfg['ul']}`", f"`{cfg['dl']}`", f"`{FLOW}`", f"`{cfg['users']}`" + + summary = _rows(db, ( + f"SELECT COUNT(*) total," + f" SUM(`高负荷问题`='高负荷') high_load," + f" SUM(`高负荷问题`='利用率预警') util_warn," + f" SUM(`高负荷问题`='高流量预警') flow_warn," + f" SUM(`高负荷问题` IS NULL) normal," + f" ROUND(AVG({ul})*100,1) avg_ul," + f" ROUND(AVG({dl})*100,1) avg_dl," + f" ROUND(MAX({dl})*100,1) max_dl," + f" ROUND(AVG({flow}),2) avg_flow," + f" ROUND(SUM({flow}),1) total_flow" + f" FROM {t}" + )) + s = summary[0] if summary else {} + summary_out = { + "total": _num(s.get("total")), + "high_load": _num(s.get("high_load")), + "util_warn": _num(s.get("util_warn")), + "flow_warn": _num(s.get("flow_warn")), + "normal": _num(s.get("normal")), + "avg_ul": _num(s.get("avg_ul"), 1), + "avg_dl": _num(s.get("avg_dl"), 1), + "max_dl": _num(s.get("max_dl"), 1), + "avg_flow": _num(s.get("avg_flow"), 2), + "total_flow": _num(s.get("total_flow"), 1), + } + + problem_pie = [ + {"name": "高负荷", "value": summary_out["high_load"]}, + {"name": "利用率预警", "value": summary_out["util_warn"]}, + {"name": "高流量预警", "value": summary_out["flow_warn"]}, + {"name": "正常", "value": summary_out["normal"]}, + ] + + def group_by(col: str, limit: int = 0, skip_unknown: bool = False) -> list[dict[str, Any]]: + limit_sql = f" LIMIT {int(limit)}" if limit else "" + where_sql = f" WHERE `{col}` IS NOT NULL AND `{col}` <> ''" if skip_unknown else "" + rows = _rows(db, ( + f"SELECT IFNULL(`{col}`,'未知') name, COUNT(*) total," + f" SUM(`是否高负荷小区`='是') high," + f" SUM(`高负荷问题` IS NOT NULL) flagged" + f" FROM {t}{where_sql} GROUP BY `{col}` ORDER BY flagged DESC, total DESC{limit_sql}" + )) + return [ + {"name": str(r.get("name")), "total": _num(r.get("total")), + "high": _num(r.get("high")), "flagged": _num(r.get("flagged"))} + for r in rows + ] + + def util_hist(col: str) -> list[dict[str, Any]]: + rows = _rows(db, ( + f"SELECT LEAST(FLOOR({col}*10),9) b, COUNT(*) c FROM {t}" + f" WHERE {col} IS NOT NULL GROUP BY b ORDER BY b" + )) + bucket_map = {int(_num(r.get("b"))): _num(r.get("c")) for r in rows} + return [{"bucket": f"{i*10}-{i*10+10}%", "value": bucket_map.get(i, 0)} for i in range(10)] + + top_rows = _rows(db, ( + f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') `system`," + f" IFNULL(`带宽`,'') band, IFNULL(`站型`,'') station," + f" ROUND({ul}*100,1) ul, ROUND({dl}*100,1) dl, ROUND({flow},2) flow, ROUND({users},0) users," + f" IFNULL(`高负荷问题`,'') problem" + f" FROM {t} WHERE `高负荷问题`='高负荷' ORDER BY {dl} DESC, {flow} DESC LIMIT 10" + )) + top_cells = [ + {"id": str(r.get("id")), "name": str(r.get("name")), "system": str(r.get("system")), + "band": str(r.get("band")), "station": str(r.get("station")), + "ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1), + "flow": _num(r.get("flow"), 2), "users": _num(r.get("users")), "problem": str(r.get("problem"))} + for r in top_rows + ] + + return { + "rat": rat.lower(), + "labels": {"ul": cfg["ul_label"], "dl": cfg["dl_label"]}, + "summary": summary_out, + "problem_pie": problem_pie, + "by_system": group_by("制式", skip_unknown=True), + "by_station": group_by("站型", skip_unknown=True), + "by_freq": group_by("频段", limit=12, skip_unknown=True), + "ul_hist": util_hist(ul), + "dl_hist": util_hist(dl), + "top_cells": top_cells, + } + + +@router.get("/api/dashboard/cells") +def dashboard_cells( + rat: str = Query("4g"), + problem: str = Query(""), + keyword: str = Query(""), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), +): + cfg = _rat(rat) + db = _db() + if cfg["table"].lower() not in _existing_tables(db): + raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在,请先进行数据处理") + + t = f"`{cfg['table']}`" + ul, dl, flow, users = f"`{cfg['ul']}`", f"`{cfg['dl']}`", f"`{FLOW}`", f"`{cfg['users']}`" + + wheres = ["`高负荷问题` IS NOT NULL"] + if problem in PROBLEMS: + wheres = [f"`高负荷问题`='{problem}'"] + if keyword.strip(): + kw = _esc(keyword.strip()) + wheres.append(f"(`{cfg['id']}` LIKE '%{kw}%' OR `{cfg['name']}` LIKE '%{kw}%')") + where_sql = " WHERE " + " AND ".join(wheres) + + total = _num(_rows(db, f"SELECT COUNT(*) c FROM {t}{where_sql}")[0].get("c")) + offset = (page - 1) * page_size + rows = _rows(db, ( + f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') `system`," + f" IFNULL(`带宽`,'') band, IFNULL(`站型`,'') station, IFNULL(`频段`,'') freq," + f" ROUND({ul}*100,1) ul, ROUND({dl}*100,1) dl, ROUND({flow},2) flow, ROUND({users},0) users," + f" IFNULL(`高负荷问题`,'') problem, IFNULL(`是否高负荷小区`,'否') is_high" + f" FROM {t}{where_sql}" + f" ORDER BY FIELD(`高负荷问题`,'高负荷','高流量预警','利用率预警'), {dl} DESC" + f" LIMIT {int(page_size)} OFFSET {int(offset)}" + )) + items = [ + {"id": str(r.get("id")), "name": str(r.get("name")), "system": str(r.get("system")), + "band": str(r.get("band")), "station": str(r.get("station")), "freq": str(r.get("freq")), + "ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1), "flow": _num(r.get("flow"), 2), + "users": _num(r.get("users")), "problem": str(r.get("problem")), "is_high": str(r.get("is_high"))} + for r in rows + ] + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +@router.get("/api/dashboard/export") +def dashboard_export(rat: str = Query("4g"), problem: str = Query(""), keyword: str = Query("")): + cfg = _rat(rat) + db = _db() + if cfg["table"].lower() not in _existing_tables(db): + raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在,请先进行数据处理") + + t = f"`{cfg['table']}`" + ul, dl, flow, users = f"`{cfg['ul']}`", f"`{cfg['dl']}`", f"`{FLOW}`", f"`{cfg['users']}`" + wheres = ["`高负荷问题` IS NOT NULL"] + if problem in PROBLEMS: + wheres = [f"`高负荷问题`='{problem}'"] + if keyword.strip(): + kw = _esc(keyword.strip()) + wheres.append(f"(`{cfg['id']}` LIKE '%{kw}%' OR `{cfg['name']}` LIKE '%{kw}%')") + where_sql = " WHERE " + " AND ".join(wheres) + + rows = _rows(db, ( + f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') sys," + f" IFNULL(`带宽`,'') band, IFNULL(`站型`,'') station, IFNULL(`频段`,'') freq," + f" ROUND({ul}*100,1) ul, ROUND({dl}*100,1) dl, ROUND({flow},2) flow, ROUND({users},0) users," + f" IFNULL(`高负荷问题`,'') problem, IFNULL(`优化建议`,'') suggestion" + f" FROM {t}{where_sql}" + f" ORDER BY FIELD(`高负荷问题`,'高负荷','高流量预警','利用率预警'), {dl} DESC" + )) + + CACHE_DIR.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"问题小区清单_{rat.lower()}_{timestamp}.csv" + filepath = CACHE_DIR / filename + header = [cfg["id"], "小区名称", "制式", "带宽", "站型", "频段", + "上行利用率(%)", "下行利用率(%)", "日均流量(GB)", "用户数", "高负荷问题", "优化建议"] + try: + with filepath.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(header) + for r in rows: + writer.writerow([ + r.get("id"), r.get("name"), r.get("sys"), r.get("band"), r.get("station"), + r.get("freq"), r.get("ul"), r.get("dl"), r.get("flow"), r.get("users"), + r.get("problem"), r.get("suggestion"), + ]) + except Exception: + remove_file_safely(filepath) + raise + return FileResponse( + path=str(filepath), filename=filename, media_type="text/csv", + background=BackgroundTask(remove_file_safely, filepath), + ) + + +@router.get("/api/dashboard/cell") +def dashboard_cell(rat: str = Query("4g"), id: str = Query(...)): + cfg = _rat(rat) + db = _db() + if cfg["table"].lower() not in _existing_tables(db): + raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在") + + t = f"`{cfg['table']}`" + cell_id = _esc(id) + rows = _rows(db, f"SELECT * FROM {t} WHERE `{cfg['id']}`='{cell_id}' LIMIT 1") + if not rows: + raise HTTPException(status_code=404, detail="未找到该小区") + row = rows[0] + + # 同扇区同 PLMN 的兄弟小区(用于详情页的均衡上下文) + sector = str(row.get("扇区") or "") + siblings: list[dict[str, Any]] = [] + if sector: + plmn = _esc("-".join(str(id).split("-")[:2])) + sector_e = _esc(sector) + dl = f"`{cfg['dl']}`" + sib_rows = _rows(db, ( + f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`带宽`,'') band," + f" IFNULL(`频段`,'') freq, ROUND(`{cfg['ul']}`*100,1) ul, ROUND({dl}*100,1) dl," + f" ROUND(`{FLOW}`,2) flow, IFNULL(`高负荷问题`,'正常') problem" + f" FROM {t} WHERE `扇区`='{sector_e}' AND SUBSTRING_INDEX(`{cfg['id']}`,'-',2)='{plmn}'" + f" AND `{cfg['id']}`<>'{cell_id}' ORDER BY {dl} DESC LIMIT 30" + )) + siblings = [ + {"id": str(r.get("id")), "name": str(r.get("name")), "band": str(r.get("band")), + "freq": str(r.get("freq")), "ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1), + "flow": _num(r.get("flow"), 2), "problem": str(r.get("problem"))} + for r in sib_rows + ] + + # 原始行转为字符串友好的 dict(数值保留,None→空) + detail = {str(k): (v if v is not None else "") for k, v in row.items()} + return { + "rat": rat.lower(), + "id": str(row.get(cfg["id"]) or id), + "name": str(row.get(cfg["name"]) or ""), + "labels": {"ul": cfg["ul_label"], "dl": cfg["dl_label"]}, + "id_field": cfg["id"], + "name_field": cfg["name"], + "ul_field": cfg["ul"], + "dl_field": cfg["dl"], + "users_field": cfg["users"], + "flow_field": FLOW, + "detail": detail, + "siblings": siblings, + } diff --git a/app/api/routers/database.py b/app/api/routers/database.py new file mode 100644 index 0000000..f8f0027 --- /dev/null +++ b/app/api/routers/database.py @@ -0,0 +1,430 @@ +import csv +import re +from datetime import datetime +from pathlib import Path +from typing import Optional + +import pandas as pd +from fastapi import APIRouter, Body, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse +from starlette.background import BackgroundTask + +from app import state +from app.config import CACHE_DIR +from app.database import DatabaseManager, detect_csv_encoding +from app.services.platform import make_client +from app.utils.files import remove_file_safely +from app.warehouse import make_cell_data_warehouse, make_warehouse + + +router = APIRouter(tags=["database"]) +INVALID_SHEET_NAME_CHARS = re.compile(r"[:\\/?*\[\]]") + + +def _resolve_requested_tables( + table_name: Optional[str], + table_names: Optional[list[str]], +) -> list[str]: + names = table_names if table_names is not None else ([table_name] if table_name else []) + return [name.strip() for name in names if isinstance(name, str) and name.strip()] + + +def _make_sheet_name(table_name: str, used_names: set[str]) -> str: + base = INVALID_SHEET_NAME_CHARS.sub("_", table_name).strip("'").strip() or "Sheet" + base = base[:31] + sheet_name = base + index = 2 + + while sheet_name in used_names: + suffix = f"_{index}" + sheet_name = f"{base[:31 - len(suffix)]}{suffix}" or f"Sheet_{index}" + index += 1 + + used_names.add(sheet_name) + return sheet_name + + +DatabaseSource = str + + +def _dataframe_from_table(db: DatabaseManager, table_name: str) -> pd.DataFrame: + result = db.query_table(table_name, page=1, page_size=1000000) + table_info = db.get_table_info(table_name) + columns = [str(column["Field"]) for column in table_info["columns"]] + return pd.DataFrame(result["data"], columns=columns) + + +def _db(database_source: DatabaseSource = "main"): + """Direct MySQL DatabaseManager, or a Metrix-backed warehouse with the same interface.""" + config = state.current_config() + if database_source == "cell_data": + return make_cell_data_warehouse(config) + if database_source != "main": + raise HTTPException(status_code=400, detail="不支持的数据库来源") + return make_warehouse(config) + + +@router.post("/api/database/test") +def test_database(database_source: DatabaseSource = Body("main", embed=True)): + db = _db(database_source) + success, message = db.test_connection() + return {"success": success, "message": message} + + +@router.get("/api/database/info") +def get_database_info(database_source: DatabaseSource = "main"): + db = _db(database_source) + try: + return {"success": True, **db.get_server_info()} + except Exception as exc: + return {"success": False, "error": str(exc)} + + +@router.get("/api/database/tables") +@router.post("/api/database/tables") +def get_tables(database_source: DatabaseSource = Body("main", embed=True)): + db = _db(database_source) + try: + return {"tables": db.get_tables()} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/api/database/table/info") +def get_table_info( + table_name: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), +): + db = _db(database_source) + try: + return db.get_table_info(table_name) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/api/database/table/data") +def query_table_data( + table_name: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), + page: int = Body(1), + page_size: int = Body(50), + order_by: Optional[str] = Body(None), + order_dir: str = Body("ASC"), +): + db = _db(database_source) + try: + return db.query_table(table_name, page, page_size, order_by=order_by, order_dir=order_dir) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/api/database/table/query") +def query_table_with_filter( + table_name: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), + page: int = Body(1), + page_size: int = Body(50), + filters: Optional[dict[str, str]] = Body(None), + order_by: Optional[str] = Body(None), + order_dir: str = Body("ASC"), +): + db = _db(database_source) + try: + return db.query_table( + table_name, + page, + page_size, + filters=filters or {}, + order_by=order_by, + order_dir=order_dir, + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/api/database/table/truncate") +def truncate_table( + table_name: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), +): + db = _db(database_source) + try: + db.truncate_table(table_name) + return {"success": True, "message": f"表 {table_name} 已清空"} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/api/database/table/drop") +def drop_table( + table_name: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), +): + db = _db(database_source) + try: + db.drop_table(table_name) + return {"success": True, "message": f"表 {table_name} 已删除"} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/api/database/table/drop-all") +def drop_all_tables(database_source: DatabaseSource = Body("main", embed=True)): + db = _db(database_source) + try: + result = db.drop_all_tables() + return { + "success": True, + "message": f"已删除 {result['dropped_count']} 个表", + "dropped_count": result["dropped_count"], + "tables": result["tables"], + } + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/api/database/table/row/update") +def update_table_row( + table_name: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), + identifier: dict = Body(...), + values: dict = Body(...), +): + if not identifier: + raise HTTPException(status_code=400, detail="缺少行定位信息") + db = _db(database_source) + try: + affected = db.update_row(table_name, identifier, values) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + if affected == 0: + raise HTTPException(status_code=404, detail="未找到匹配的数据行,可能已被修改或删除") + return {"success": True, "message": "已更新该行", "affected_rows": affected} + + +@router.post("/api/database/table/row/delete") +def delete_table_row( + table_name: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), + identifier: dict = Body(...), +): + if not identifier: + raise HTTPException(status_code=400, detail="缺少行定位信息") + db = _db(database_source) + try: + affected = db.delete_row(table_name, identifier) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + if affected == 0: + raise HTTPException(status_code=404, detail="未找到匹配的数据行,可能已被删除") + return {"success": True, "message": "已删除该行", "affected_rows": affected} + + +def _table_columns(db, table_name: str) -> list[str]: + info = db.get_table_info(table_name) + return [str(column["Field"]) for column in info.get("columns", [])] + + +@router.post("/api/database/table/template") +def download_table_template( + table_name: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), +): + db = _db(database_source) + try: + columns = _table_columns(db, table_name) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + if not columns: + raise HTTPException(status_code=400, detail="无法获取表字段,无法生成模板") + + CACHE_DIR.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filepath = CACHE_DIR / f"{table_name}_template_{timestamp}.csv" + try: + with filepath.open("w", encoding="utf-8-sig", newline="") as handle: + csv.writer(handle).writerow(columns) + except Exception: + remove_file_safely(filepath) + raise + return FileResponse( + path=str(filepath), + filename=f"{table_name}_模板.csv", + media_type="text/csv", + background=BackgroundTask(remove_file_safely, filepath), + ) + + +def _read_csv_header(path: Path, encoding: str) -> list[str]: + with path.open("r", encoding=encoding, newline="") as handle: + for row in csv.reader(handle): + return [str(cell).strip() for cell in row] + return [] + + +# Sync def so FastAPI runs it in a threadpool: file read + DB insert are blocking. +@router.post("/api/database/table/import") +def import_table_csv( + file: UploadFile = File(...), + table_name: str = Form(...), + database_source: str = Form("main"), +): + if not file.filename or not file.filename.lower().endswith(".csv"): + raise HTTPException(status_code=400, detail="仅支持 CSV 格式文件") + db = _db(database_source) + try: + columns = _table_columns(db, table_name) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + if not columns: + raise HTTPException(status_code=400, detail="无法获取表字段") + + CACHE_DIR.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + tmp_path = CACHE_DIR / f"import_{timestamp}.csv" + tmp_path.write_bytes(file.file.read()) + try: + encoding = detect_csv_encoding(str(tmp_path)) + header = _read_csv_header(tmp_path, encoding) + if not header: + raise HTTPException(status_code=400, detail="CSV 文件为空或缺少表头") + missing = [name for name in columns if name not in header] + extra = [name for name in header if name not in columns] + if missing or extra: + parts = [] + if missing: + parts.append("缺少字段: " + ", ".join(missing)) + if extra: + parts.append("多余字段: " + ", ".join(extra)) + raise HTTPException(status_code=400, detail="CSV 字段与模板不一致,导入失败。" + ";".join(parts)) + if database_source == "cell_data" and table_name.lower() == "sector": + stats = db.upsert_csv(str(tmp_path), table_name, "CGI", encoding=encoding) + return { + "success": True, + "message": ( + f"导入成功,共 {stats['imported_rows']} 行:" + f"新增 {stats['inserted_rows']} 行,更新 {stats['updated_rows']} 行," + f"清理历史重复 {stats['removed_duplicate_rows']} 行" + ), + **stats, + } + imported = db.import_csv(str(tmp_path), table_name, encoding=encoding) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + finally: + remove_file_safely(tmp_path) + return {"success": True, "message": f"导入成功,共 {imported} 行", "imported_rows": imported} + + +@router.post("/api/database/execute") +def execute_sql( + sql: str = Body(..., embed=True), + database_source: DatabaseSource = Body("main"), +): + db = _db(database_source) + try: + success, result = db.execute_sql(sql) + if success: + return {"success": True, "result": result} + raise HTTPException(status_code=400, detail=result) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +# Sync def so FastAPI runs it in a threadpool: exporting large tables (and the Metrix +# export-job polling) is blocking and would otherwise freeze the single-worker event loop. +@router.post("/api/download") +def download_table( + table_name: Optional[str] = Body(None, embed=True), + table_names: Optional[list[str]] = Body(None, embed=True), + database_source: DatabaseSource = Body("main"), + file_format: str = Body("csv", alias="format"), +): + if file_format not in {"csv", "xlsx"}: + raise HTTPException(status_code=400, detail="不支持的导出格式") + + requested_tables = _resolve_requested_tables(table_name, table_names) + if not requested_tables: + raise HTTPException(status_code=400, detail="请选择要导出的数据表") + if file_format == "csv" and len(requested_tables) != 1: + raise HTTPException(status_code=400, detail="CSV 每次只能导出一张表") + + config = state.current_config() + if database_source == "main" and config.warehouse_type == "metrix": + return _download_via_metrix(config, requested_tables, file_format) + + db = _db(database_source) + try: + available_tables = set(db.get_tables()) + missing_tables = [name for name in requested_tables if name not in available_tables] + if missing_tables: + raise HTTPException(status_code=400, detail=f"数据表不存在: {', '.join(missing_tables)}") + + table_frames = { + name: _dataframe_from_table(db, name) + for name in requested_tables + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename_prefix = requested_tables[0] if len(requested_tables) == 1 else "tables" + filename = f"{filename_prefix}_{timestamp}.{file_format}" + filepath = CACHE_DIR / filename + CACHE_DIR.mkdir(parents=True, exist_ok=True) + + try: + if file_format == "csv": + table_frames[requested_tables[0]].to_csv(filepath, index=False, encoding="utf-8-sig") + media_type = "text/csv" + else: + used_sheet_names: set[str] = set() + with pd.ExcelWriter(filepath) as writer: + for name, df in table_frames.items(): + df.to_excel(writer, sheet_name=_make_sheet_name(name, used_sheet_names), index=False) + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + except Exception: + remove_file_safely(filepath) + raise + + return FileResponse( + path=str(filepath), + filename=filename, + media_type=media_type, + background=BackgroundTask(remove_file_safely, filepath), + ) + + +def _download_via_metrix(config, requested_tables: list[str], file_format: str) -> FileResponse: + """Metrix 仓库模式:用平台导出任务生成文件后流式返回(避免分页上限丢行)。""" + metrix = config.metrix.normalized() + client = make_client(metrix) + try: + job_id = client.submit_export(metrix.database_conn_id, requested_tables, file_format, metrix.target_database) + job = client.wait_job(job_id) + if job.get("status") != "success": + raise HTTPException(status_code=500, detail=f"导出失败: {job.get('error_code') or job.get('status')}") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename_prefix = requested_tables[0] if len(requested_tables) == 1 else "tables" + filename = f"{filename_prefix}_{timestamp}.{file_format}" + filepath = CACHE_DIR / filename + CACHE_DIR.mkdir(parents=True, exist_ok=True) + client.download_job_file(job_id, filepath) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + media_type = "text/csv" if file_format == "csv" else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + return FileResponse( + path=str(filepath), + filename=filename, + media_type=media_type, + background=BackgroundTask(remove_file_safely, filepath), + ) diff --git a/app/api/routers/health.py b/app/api/routers/health.py new file mode 100644 index 0000000..446e181 --- /dev/null +++ b/app/api/routers/health.py @@ -0,0 +1,38 @@ +import os +from datetime import datetime + +from fastapi import APIRouter + +from app import state +from app.database import DatabaseManager + + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +async def health_check(): + checks = { + "app": {"status": "ok"}, + "database": {"status": "unknown"}, + } + + try: + db_manager = DatabaseManager(state.current_config()) + server_info = db_manager.get_server_info() + checks["database"] = { + "status": "ok", + "version": server_info.get("version", "unknown"), + "load_data_infile": server_info.get("load_data_infile", False), + } + except Exception as exc: + checks["database"] = {"status": "error", "message": str(exc)} + + is_healthy = all(check.get("status") == "ok" for check in checks.values()) + return { + "status": "healthy" if is_healthy else "unhealthy", + "timestamp": datetime.now().isoformat(), + "version": "3.0.0", + "uptime_pid": os.getpid(), + "checks": checks, + } diff --git a/app/api/routers/history.py b/app/api/routers/history.py new file mode 100644 index 0000000..479bed0 --- /dev/null +++ b/app/api/routers/history.py @@ -0,0 +1,243 @@ +import zipfile +from datetime import datetime +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Body, HTTPException +from fastapi.responses import FileResponse +from starlette.background import BackgroundTask + +from app import state +from app.config import CACHE_DIR +from app.utils.files import format_size, get_dir_size, remove_file_safely + + +router = APIRouter(tags=["history"]) + + +def _safe_filename_part(value: str) -> str: + safe = "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in value) + return safe.strip("_") or "history" + + +def _get_safe_work_dir(record_id: str, *, require_finished: bool = True) -> tuple[Path, str]: + record = state.history_manager.get(record_id) + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + if require_finished and record.status in {"pending", "processing"}: + raise HTTPException(status_code=409, detail="任务尚未完成,暂不能下载历史数据") + + work_dir = Path(record.work_dir).resolve() + cache_dir = CACHE_DIR.resolve() + try: + work_dir.relative_to(cache_dir) + except ValueError as exc: + raise HTTPException(status_code=400, detail="历史目录不合法") from exc + + if not work_dir.exists() or not work_dir.is_dir(): + raise HTTPException(status_code=404, detail="历史数据目录不存在") + + return work_dir, record.id + + +def _get_safe_history_path(record_id: str, relative_path: str | None, *, require_finished: bool = False) -> tuple[Path, Path, str]: + work_dir, safe_record_id = _get_safe_work_dir(record_id, require_finished=require_finished) + clean_path = _normalize_relative_path(relative_path) + target = work_dir.joinpath(*clean_path.split("/")).resolve() if clean_path else work_dir + + try: + target.relative_to(work_dir) + except ValueError as exc: + raise HTTPException(status_code=400, detail="历史文件路径不合法") from exc + + if not target.exists(): + raise HTTPException(status_code=404, detail="历史文件不存在") + + return work_dir, target, safe_record_id + + +def _normalize_relative_path(value: str | None) -> str: + normalized = (value or "").replace("\\", "/").strip("/") + if not normalized: + return "" + + parts = [part for part in normalized.split("/") if part and part != "."] + if any(part == ".." for part in parts): + raise HTTPException(status_code=400, detail="历史文件路径不合法") + return "/".join(parts) + + +def _zip_directory(source_dir: Path, archive_path: Path) -> None: + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + for item in source_dir.rglob("*"): + arcname = item.relative_to(source_dir).as_posix() + if item.is_dir(): + archive.writestr(f"{arcname}/", "") + elif item.is_file(): + archive.write(item, arcname) + + +def _zip_history_item(source_path: Path, archive_path: Path) -> None: + root_name = source_path.name or "history" + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + if source_path.is_file(): + archive.write(source_path, root_name) + return + + has_content = False + for item in source_path.rglob("*"): + has_content = True + arcname = Path(root_name, item.relative_to(source_path)).as_posix() + if item.is_dir(): + archive.writestr(f"{arcname}/", "") + elif item.is_file(): + archive.write(item, arcname) + + if not has_content: + archive.writestr(f"{root_name}/", "") + + +def _history_entry(item: Path, work_dir: Path) -> dict[str, Any]: + stat = item.stat() + is_dir = item.is_dir() + size = get_dir_size(item) if is_dir else stat.st_size + modified = datetime.fromtimestamp(stat.st_mtime) + return { + "name": item.name, + "path": item.relative_to(work_dir).as_posix(), + "type": "dir" if is_dir else "file", + "size": size, + "size_formatted": format_size(size), + "modified": modified.isoformat(), + "modified_formatted": modified.strftime("%Y-%m-%d %H:%M:%S"), + } + + +@router.post("/api/history") +async def get_history(limit: int = Body(50, embed=True)): + return {"records": state.history_manager.list(limit)} + + +@router.post("/api/history/delete") +async def delete_history(record_id: str = Body(..., embed=True)): + if state.history_manager.delete(record_id): + return {"success": True, "message": "删除成功"} + raise HTTPException(status_code=404, detail="记录不存在") + + +@router.post("/api/history/clear") +async def clear_history(): + count = state.history_manager.clear() + return {"success": True, "deleted": count} + + +@router.post("/api/history/detail") +async def get_history_detail(record_id: str = Body(..., embed=True)): + record = state.history_manager.get(record_id) + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + + result = record.to_dict() + result["logs"] = state.history_manager.get_logs(record_id) + return result + + +@router.post("/api/history/size") +async def get_history_size(record_id: str = Body(..., embed=True)): + record = state.history_manager.get(record_id) + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + + work_dir = Path(record.work_dir) + if not work_dir.exists(): + return {"success": True, "size": 0, "size_formatted": "0 B"} + + size = get_dir_size(work_dir) + return {"success": True, "size": size, "size_formatted": format_size(size)} + + +@router.post("/api/history/files") +async def list_history_files( + record_id: str = Body(..., embed=True), + path: str | None = Body("", embed=True), +): + work_dir, current_path, _safe_record_id = _get_safe_history_path(record_id, path) + if not current_path.is_dir(): + raise HTTPException(status_code=400, detail="请选择目录") + + current_relative = current_path.relative_to(work_dir).as_posix() + current_relative = "" if current_relative == "." else current_relative + parent_relative = None + if current_path != work_dir: + parent_relative = current_path.parent.relative_to(work_dir).as_posix() + parent_relative = "" if parent_relative == "." else parent_relative + + entries = [_history_entry(item, work_dir) for item in current_path.iterdir()] + entries.sort(key=lambda item: (item["type"] != "dir", item["name"].lower())) + return { + "success": True, + "record_id": record_id, + "path": current_relative, + "parent_path": parent_relative, + "entries": entries, + } + + +@router.post("/api/history/download") +async def download_history(record_id: str = Body(..., embed=True)): + work_dir, safe_record_id = _get_safe_work_dir(record_id) + export_dir = CACHE_DIR / ".downloads" + export_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{_safe_filename_part(safe_record_id)}_{timestamp}.zip" + archive_path = export_dir / filename + + try: + _zip_directory(work_dir, archive_path) + except Exception: + remove_file_safely(archive_path) + raise + + return FileResponse( + path=str(archive_path), + filename=filename, + media_type="application/zip", + background=BackgroundTask(remove_file_safely, archive_path), + ) + + +@router.post("/api/history/file/download") +async def download_history_file( + record_id: str = Body(..., embed=True), + path: str = Body(..., embed=True), +): + _work_dir, target_path, safe_record_id = _get_safe_history_path(record_id, path, require_finished=True) + if target_path.is_file(): + return FileResponse( + path=str(target_path), + filename=target_path.name, + media_type="application/octet-stream", + ) + + if not target_path.is_dir(): + raise HTTPException(status_code=400, detail="历史文件类型不支持下载") + + export_dir = CACHE_DIR / ".downloads" + export_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{_safe_filename_part(safe_record_id)}_{_safe_filename_part(target_path.name)}_{timestamp}.zip" + archive_path = export_dir / filename + + try: + _zip_history_item(target_path, archive_path) + except Exception: + remove_file_safely(archive_path) + raise + + return FileResponse( + path=str(archive_path), + filename=filename, + media_type="application/zip", + background=BackgroundTask(remove_file_safely, archive_path), + ) 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 new file mode 100644 index 0000000..028125e --- /dev/null +++ b/app/api/routers/remote.py @@ -0,0 +1,244 @@ +import time +from datetime import date, datetime +from pathlib import Path +from threading import Thread +from typing import Any, Callable, Iterable + +from fastapi import APIRouter, Body, HTTPException + +from app import state +from app.api.routers.task_runtime import ( + apply_history_retention_safely, + log_license_check, + set_task_stage, +) +from app.config import AppConfig, CACHE_DIR, RemoteDataConfig +from app.processor import DataProcessor, ProcessLogger +from app.config import CELLDATA_SCRIPT +from app.services.cell_data import copy_celldata_tables_to_capacity, execute_celldata_script, refresh_cell_data +from app.services.license import LicenseError, check_processing_allowed +from app.services.platform import PlatformStorageDownloader, make_source_downloader +from app.services.pipeline import RESULT_TABLES, run_import_and_report +from app.services.remote_download import RemoteDataDownloader + + +router = APIRouter(tags=["remote"]) + + +@router.post("/api/remote/test") +def test_remote_connection(config: dict[str, Any] | None = Body(None)): + app_config = state.current_config() + try: + if app_config.source_type == "metrix": + PlatformStorageDownloader(app_config).test_connection() + return {"success": True, "message": "平台储存连接成功"} + # FTP/SFTP: test the posted form config if provided, else the saved one. + remote_config = RemoteDataConfig.from_dict(config) if config else app_config.remote_data + RemoteDataDownloader(remote_config).test_connection() + return {"success": True, "message": "远程服务器连接成功"} + except Exception as exc: + return {"success": False, "message": f"连接失败: {exc}"} + + +@router.post("/api/remote/start") +async def start_remote_processing(): + return start_remote_processing_job(source="manual") + + +@router.get("/api/remote/scheduler/status") +def get_scheduler_status(): + if state.auto_scheduler is None: + return {"enabled": False, "running": False, "message": "自动调度器未启动"} + return state.auto_scheduler.get_status() + + +@router.post("/api/remote/scheduler/trigger") +def trigger_scheduler_check(): + if state.auto_scheduler is None: + raise HTTPException(status_code=503, detail="自动调度器未启动") + return state.auto_scheduler.check_and_run(manual=True) + + +def start_remote_processing_job( + *, + source: str = "manual", + on_finish: Callable[[str, str], None] | None = None, + target_dates: Iterable[date] | None = None, +) -> dict[str, Any]: + if state.global_task_lock["locked"]: + raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成") + + app_config = state.current_config() + remote_config = app_config.remote_data.normalized() + if not remote_config.enabled: + raise HTTPException(status_code=400, detail="请先启用远程数据配置") + + task_id = datetime.now().strftime("%Y%m%d_%H%M%S") + work_dir = CACHE_DIR / task_id + work_dir.mkdir(parents=True, exist_ok=True) + + logs: list[str] = [] + current_stage = "downloading" + + def log_callback(message: str) -> None: + logs.append(message) + set_task_stage(task_id, current_stage, logs) + + def stage_callback(stage: str) -> None: + nonlocal current_stage + current_stage = stage + set_task_stage(task_id, current_stage, logs) + + logger = ProcessLogger( + log_file=work_dir / "log.txt", + callback=log_callback, + stage_callback=stage_callback, + ) + state.history_manager.create(work_dir, 0, record_id=task_id) + state.history_manager.update(task_id, status="processing") + state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage} + state.global_task_lock.update( + { + "locked": True, + "task_id": task_id, + "stage": "downloading", + "started_at": datetime.now().isoformat(), + } + ) + + thread = Thread( + target=_run_remote_processing, + args=(task_id, work_dir, app_config, remote_config, logger, source, on_finish, target_dates), + daemon=True, + ) + thread.start() + + return { + "success": True, + "message": "自动调度远程下载处理任务已启动" if source == "scheduler" else "远程下载处理任务已启动", + "task_id": task_id, + "stage": "downloading", + } + + +def _run_remote_processing( + task_id: str, + work_dir: Path, + app_config: AppConfig, + remote_config: RemoteDataConfig, + logger: ProcessLogger, + source: str = "manual", + on_finish: Callable[[str, str], None] | None = None, + target_dates: Iterable[date] | None = None, +) -> None: + final_status = "failed" + try: + if app_config.source_type == "metrix": + logger.info(f"开始从平台储存下载,目录: {remote_config.remote_dir}") + else: + logger.info( + f"开始远程下载,协议: {remote_config.protocol.upper()}," + f"服务器: {remote_config.host}:{remote_config.port},目录: {remote_config.remote_dir}" + ) + downloader = make_source_downloader(app_config, logger.info) + download_result = downloader.download_to(work_dir, target_dates=target_dates) + logger.success( + f"下载完成,共 {download_result.file_count} 个文件," + f"{_format_bytes(download_result.total_bytes)}" + ) + + if download_result.file_count == 0: + raise RuntimeError("源目录中未下载到任何文件") + + state.history_manager.update(task_id, file_count=download_result.file_count) + + _try_refresh_cell_data(app_config, work_dir, logger) + + if app_config.warehouse_type == "metrix": + started = time.time() + run_import_and_report(work_dir, app_config, logger) + status, error, elapsed = "completed", None, round(time.time() - started, 2) + else: + 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") + elapsed = result.get("elapsed_time", 0) + if status == "completed" and remote_config.auto_delete_source: + try: + deleted_count = downloader.delete_source_files(download_result.remote_files) + logger.success(f"远程源文件清理完成,共删除 {deleted_count} 个文件,目录已保留") + final_status = status + except Exception as exc: + logger.warning(f"远程源文件清理失败,数据处理结果已保留: {exc}") + final_status = "source_cleanup_failed" + else: + final_status = status + + state.history_manager.update( + task_id, + status=status, + elapsed_time=elapsed, + error=error, + result_tables=RESULT_TABLES, + ) + 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 + if source == "scheduler": + logger.error(f"自动调度任务失败: {exc}") + else: + 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: + apply_history_retention_safely() + state.reset_task_lock() + if on_finish: + on_finish(task_id, final_status) + + +def _try_refresh_cell_data(app_config: AppConfig, work_dir: Path, logger: ProcessLogger) -> None: + # 1) 远程拉取 CellData(仅在配置了 FTP/SFTP 时);失败不阻断容量处理 + if app_config.cell_data.remote_data.enabled: + logger.set_stage("cell_data") + logger.info("── CellData 更新 ──") + try: + result = refresh_cell_data(app_config, work_dir, logger) + logger.success( + f"CellData 更新完成:{result.imported_rows} 行" + f"(解析 {result.parsed_rows},跳过 {result.skipped_rows})" + ) + except Exception as exc: + logger.warning(f"CellData 远程更新失败,继续容量处理: {exc}") + + # 2) 无论是否远程拉取,都执行 CellData 脚本并把表同步到容量库(覆盖手动上传场景); + # celldata 无数据 / 连不上则内部跳过,best-effort 不阻断容量处理 + logger.set_stage("cell_data") + try: + execute_celldata_script(CELLDATA_SCRIPT, app_config, logger) + copy_celldata_tables_to_capacity(app_config, logger) + except Exception as exc: + logger.warning(f"CellData 同步到容量库失败,继续容量处理: {exc}") + + +def _format_bytes(size: int) -> str: + value = float(size) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024: + return f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} TB" diff --git a/app/api/routers/script.py b/app/api/routers/script.py new file mode 100644 index 0000000..0a016f3 --- /dev/null +++ b/app/api/routers/script.py @@ -0,0 +1,141 @@ +import shutil +import uuid +from datetime import datetime +from pathlib import Path +from threading import Thread + +from fastapi import APIRouter, Body, HTTPException + +from app import state +from app.api.routers.task_runtime import set_task_stage +from app.config import AppConfig, CACHE_DIR, CELLDATA_SCRIPT, SQL_SCRIPT +from app.processor import DataProcessor, ProcessLogger + + +router = APIRouter(tags=["script"]) + + +def _resolve_script_path(script_type: str) -> tuple[Path, str]: + paths = {"report": SQL_SCRIPT, "celldata": CELLDATA_SCRIPT} + labels = {"report": "容量报表脚本", "celldata": "CellData 脚本"} + path = paths.get(script_type) + if path is None: + raise HTTPException(status_code=400, detail=f"不支持的脚本类型: {script_type}") + return path, labels.get(script_type, script_type) + + +@router.post("/api/script/content") +def get_script_content(script_type: str = Body("report", embed=True)): + script_path, label = _resolve_script_path(script_type) + try: + if not script_path.exists(): + return { + "success": True, + "content": f"# {label}文件不存在,请在此编写脚本\n", + "modified": None, + "path": str(script_path), + } + + modified = datetime.fromtimestamp(script_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") + return { + "success": True, + "content": script_path.read_text(encoding="utf-8"), + "modified": modified, + "path": str(script_path), + } + except Exception as exc: + return {"success": False, "error": str(exc)} + + +@router.post("/api/script/execute") +async def execute_script(script_type: str = Body("report", embed=True)): + script_path, _ = _resolve_script_path(script_type) + if state.global_task_lock["locked"]: + raise HTTPException(status_code=409, detail="已有任务在运行,请等待完成") + + task_id = f"script_{uuid.uuid4().hex[:8]}" + state.global_task_lock.update( + { + "locked": True, + "task_id": task_id, + "stage": "processing", + "started_at": datetime.now().isoformat(), + } + ) + + logs: list[str] = [] + + def log_callback(message: str) -> None: + logs.append(message) + set_task_stage(task_id, "processing", logs) + + logger = ProcessLogger(log_file=None, callback=log_callback) + set_task_stage(task_id, "processing", logs) + app_config = state.current_config() + + thread = Thread( + target=_run_script, + args=(task_id, logger, logs, app_config, script_path, script_type), + daemon=True, + ) + thread.start() + _, label = _resolve_script_path(script_type) + return {"success": True, "message": f"{label}执行任务已启动", "task_id": task_id} + + +@router.post("/api/script/save") +async def save_script_content( + content: str = Body(...), + script_type: str = Body("report"), +): + script_path, _ = _resolve_script_path(script_type) + try: + if script_path.exists(): + shutil.copy(script_path, script_path.with_suffix(".sql.bak")) + + script_path.write_text(content, encoding="utf-8") + modified = datetime.fromtimestamp(script_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") + return {"success": True, "message": "脚本保存成功", "modified": modified} + except Exception as exc: + return {"success": False, "error": str(exc)} + + +def _run_script( + task_id: str, + logger: ProcessLogger, + logs: list[str], + app_config: AppConfig, + script_path: Path, + script_type: str, +) -> None: + temp_work_dir: Path | None = None + labels = {"report": "容量报表脚本", "celldata": "CellData 脚本"} + label = labels.get(script_type, script_type) + try: + logger.info(f"开始执行{label}...") + if script_type == "celldata": + _run_celldata_script(script_path, app_config, logger) + elif app_config.warehouse_type == "metrix": + from app.services.pipeline import run_report_sql + + run_report_sql(app_config, logger) + else: + temp_work_dir = CACHE_DIR / task_id + temp_work_dir.mkdir(parents=True, exist_ok=True) + processor = DataProcessor(app_config, temp_work_dir, logger) + processor._execute_sql_script() + logger.success(f"{label}执行完成") + set_task_stage(task_id, "completed", logs, status="completed") + except Exception as exc: + logger.error(f"{label}执行失败: {exc}") + set_task_stage(task_id, "failed", logs, status="failed") + finally: + if temp_work_dir and temp_work_dir.exists(): + shutil.rmtree(temp_work_dir, ignore_errors=True) + state.reset_task_lock() + + +def _run_celldata_script(script_path: Path, app_config: AppConfig, logger: ProcessLogger) -> None: + from app.services.cell_data import execute_celldata_script + + execute_celldata_script(script_path, app_config, logger) diff --git a/app/api/routers/task_runtime.py b/app/api/routers/task_runtime.py new file mode 100644 index 0000000..2c2fcf8 --- /dev/null +++ b/app/api/routers/task_runtime.py @@ -0,0 +1,36 @@ +import logging +from typing import Any + +from app import state + + +logger = logging.getLogger(__name__) + + +def set_task_stage(task_id: str, stage: str, logs: list[str], status: str = "processing") -> None: + state.processing_tasks[task_id] = { + "logs": logs.copy(), + "status": status, + "stage": stage, + } + if state.global_task_lock["task_id"] == task_id: + state.global_task_lock["stage"] = stage + + +def log_license_check(logger: Any, info: Any) -> 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 文件,已跳过授权日期比对") + + +def apply_history_retention_safely() -> None: + try: + state.apply_history_retention() + except Exception as exc: + logger.warning("Failed to apply history retention: %s", exc) diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py new file mode 100644 index 0000000..9e30b5d --- /dev/null +++ b/app/api/routers/tasks.py @@ -0,0 +1,241 @@ +from datetime import datetime +from pathlib import Path +from threading import Thread +from typing import Any + +from fastapi import APIRouter, Body, HTTPException + +from app import state +from app.api.routers.task_runtime import ( + apply_history_retention_safely, + log_license_check, + set_task_stage, +) +from app.config import AppConfig +from app.processor import DataProcessor, ProcessLogger +from app.config import CELLDATA_SCRIPT +from app.services.cell_data import copy_celldata_tables_to_capacity, execute_celldata_script, refresh_cell_data +from app.services.license import LicenseError, check_processing_allowed + + +router = APIRouter(tags=["tasks"]) + + +@router.get("/api/task/status") +async def get_global_task_status(): + if state.global_task_lock["locked"]: + task_id = state.global_task_lock["task_id"] + if task_id and _task_finished(task_id): + state.reset_task_lock() + return {"has_active": False} + + return { + "has_active": True, + "task_id": task_id, + "stage": state.global_task_lock["stage"], + "started_at": state.global_task_lock["started_at"], + "logs": [], + } + + active_tasks = { + task_id: task + for task_id, task in state.processing_tasks.items() + if task.get("status") == "processing" + } + if active_tasks: + task_id = next(iter(active_tasks)) + return { + "has_active": True, + "task_id": task_id, + "stage": active_tasks[task_id].get("stage", "processing"), + "logs": active_tasks[task_id].get("logs", []), + } + + return {"has_active": False} + + +@router.post("/api/task/lock") +async def lock_task(task_id: str = Body(..., embed=True)): + if state.global_task_lock["locked"]: + raise HTTPException(status_code=409, detail="已有任务在运行") + + state.global_task_lock.update( + { + "locked": True, + "task_id": task_id, + "stage": "uploading", + "started_at": datetime.now().isoformat(), + } + ) + return {"success": True, "message": "任务已锁定"} + + +@router.post("/api/task/unlock") +async def unlock_task(task_id: str | None = Body(None, embed=True)): + if task_id and state.global_task_lock["task_id"] != task_id: + raise HTTPException(status_code=403, detail="无权解锁此任务") + + state.reset_task_lock() + return {"success": True, "message": "任务已解锁"} + + +@router.get("/api/process/active") +async def get_active_task(): + return await get_global_task_status() + + +@router.post("/api/process/start") +async def start_processing(task_id: str = Body(..., embed=True)): + record = state.history_manager.get(task_id) + if not record: + raise HTTPException(status_code=404, detail="任务不存在") + if record.status == "processing": + raise HTTPException(status_code=400, detail="任务正在处理中") + + work_dir = Path(record.work_dir) + if not work_dir.exists(): + raise HTTPException(status_code=400, detail="工作目录不存在") + + logs: list[str] = [] + current_stage = "processing" + + def log_callback(message: str) -> None: + logs.append(message) + set_task_stage(task_id, current_stage, logs) + + def stage_callback(stage: str) -> None: + nonlocal current_stage + current_stage = stage + set_task_stage(task_id, current_stage, logs) + + logger = ProcessLogger( + log_file=work_dir / "log.txt", + callback=log_callback, + stage_callback=stage_callback, + ) + app_config = state.current_config() + state.history_manager.update(task_id, status="processing") + state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage} + state.global_task_lock.update( + { + "locked": True, + "task_id": task_id, + "stage": "processing", + "started_at": datetime.now().isoformat(), + } + ) + + thread = Thread(target=_run_processing, args=(task_id, work_dir, logger, app_config), daemon=True) + thread.start() + + return {"success": True, "message": "处理任务已启动", "task_id": task_id} + + +@router.post("/api/process/status") +async def get_processing_status(task_id: str = Body(..., embed=True)): + if task_id in state.processing_tasks: + task_info = state.processing_tasks[task_id] + logs = task_info.get("logs") or state.history_manager.get_logs(task_id) + return { + "task_id": task_id, + "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) + if not record: + raise HTTPException(status_code=404, detail="任务不存在") + + return { + "task_id": task_id, + "status": record.status, + "stage": record.status, + "logs": state.history_manager.get_logs(task_id), + "elapsed_time": record.elapsed_time, + "error": record.error, + } + + +def _task_finished(task_id: str) -> bool: + record = state.history_manager.get(task_id) + if record and record.status in {"completed", "failed"}: + return True + + task_info: dict[str, Any] | None = state.processing_tasks.get(task_id) + return bool(task_info and task_info.get("status") in {"completed", "failed"}) + + +def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None: + try: + _try_refresh_cell_data(app_config, work_dir, logger) + if app_config.warehouse_type == "metrix": + import time + + from app.services.pipeline import RESULT_TABLES, run_import_and_report + + started = time.time() + run_import_and_report(work_dir, app_config, logger) + status, error, elapsed, result_tables = "completed", None, round(time.time() - started, 2), RESULT_TABLES + else: + 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") + elapsed = result.get("elapsed_time", 0) + result_tables = ["4G_结果表", "5G_结果表"] + state.history_manager.update( + task_id, + status=status, + elapsed_time=elapsed, + error=error, + result_tables=result_tables, + ) + 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: + apply_history_retention_safely() + state.reset_task_lock() + + +def _try_refresh_cell_data(app_config: AppConfig, work_dir: Path, logger: ProcessLogger) -> None: + # 1) 远程拉取 CellData(仅在配置了 FTP/SFTP 时);失败不阻断容量处理 + if app_config.cell_data.remote_data.enabled: + logger.set_stage("cell_data") + logger.info("── CellData 更新 ──") + try: + result = refresh_cell_data(app_config, work_dir, logger) + logger.success( + f"CellData 更新完成:{result.imported_rows} 行" + f"(解析 {result.parsed_rows},跳过 {result.skipped_rows})" + ) + except Exception as exc: + logger.warning(f"CellData 远程更新失败,继续容量处理: {exc}") + + # 2) 无论是否远程拉取,都执行 CellData 脚本并把表同步到容量库(覆盖手动上传场景); + # celldata 无数据 / 连不上则内部跳过,best-effort 不阻断容量处理 + logger.set_stage("cell_data") + try: + execute_celldata_script(CELLDATA_SCRIPT, app_config, logger) + copy_celldata_tables_to_capacity(app_config, logger) + except Exception as exc: + logger.warning(f"CellData 同步到容量库失败,继续容量处理: {exc}") diff --git a/app/api/routers/upload.py b/app/api/routers/upload.py new file mode 100644 index 0000000..fc062d7 --- /dev/null +++ b/app/api/routers/upload.py @@ -0,0 +1,112 @@ +from datetime import datetime +from typing import Any, Optional + +from fastapi import APIRouter, File, HTTPException, UploadFile + +from app import state +from app.config import CACHE_DIR +from app.utils.files import safe_relative_path + + +router = APIRouter(tags=["upload"]) + + +@router.post("/api/upload/create") +async def create_upload_session(): + session_id = datetime.now().strftime("%Y%m%d_%H%M%S") + work_dir = CACHE_DIR / session_id + work_dir.mkdir(parents=True, exist_ok=True) + + state.upload_sessions[session_id] = { + "work_dir": work_dir, + "files": [], + "created_at": datetime.now().isoformat(), + } + + return {"success": True, "session_id": session_id, "work_dir": str(work_dir)} + + +@router.post("/api/upload") +async def upload_files( + files: list[UploadFile] = File(...), + session_id: Optional[str] = None, +): + if not files: + raise HTTPException(status_code=400, detail="没有上传文件") + + is_new_session = False + if not session_id or session_id not in state.upload_sessions: + if state.global_task_lock["locked"]: + raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成") + + session_id = datetime.now().strftime("%Y%m%d_%H%M%S") + work_dir = CACHE_DIR / session_id + work_dir.mkdir(parents=True, exist_ok=True) + + state.global_task_lock.update( + { + "locked": True, + "task_id": session_id, + "stage": "uploading", + "started_at": datetime.now().isoformat(), + } + ) + is_new_session = True + + state.upload_sessions[session_id] = { + "work_dir": work_dir, + "files": [], + "created_at": datetime.now().isoformat(), + } + else: + if state.global_task_lock["locked"] and state.global_task_lock["task_id"] != session_id: + raise HTTPException(status_code=409, detail="已有其他任务在运行") + + session: dict[str, Any] = state.upload_sessions[session_id] + work_dir = session["work_dir"] + + try: + saved_files: list[str] = [] + for file in files: + if not file.filename: + continue + + relative_path = safe_relative_path(file.filename) + file_path = work_dir / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(await file.read()) + + saved_name = str(relative_path).replace("\\", "/") + saved_files.append(saved_name) + session["files"].append(saved_name) + + record = state.history_manager.get(session_id) + if record: + state.history_manager.update(session_id, file_count=len(session["files"])) + else: + state.history_manager.create(work_dir, len(session["files"]), record_id=session_id) + + return { + "success": True, + "task_id": session_id, + "session_id": session_id, + "work_dir": str(work_dir), + "file_count": len(saved_files), + "total_files": len(session["files"]), + "files": saved_files, + } + except Exception as exc: + if is_new_session: + state.reset_task_lock() + raise HTTPException(status_code=500, detail=f"上传失败: {exc}") from exc + + +@router.post("/api/upload/complete/{session_id}") +async def complete_upload_session(session_id: str): + if session_id not in state.upload_sessions: + raise HTTPException(status_code=404, detail="上传会话不存在") + + session = state.upload_sessions[session_id] + state.history_manager.update(session_id, file_count=len(session["files"])) + + return {"success": True, "session_id": session_id, "total_files": len(session["files"])} diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..5e271e3 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,140 @@ +import base64 +import configparser +import hashlib +import hmac +import json +import time +from dataclasses import dataclass +from typing import Optional + +from starlette.requests import Request + +from app.config import BASE_DIR + + +SECRET_KEY = "CapaReportSecretKey2026" +AUTH_INI_PATH = BASE_DIR / "auth.ini" +DEFAULT_USERNAME = "root" +DEFAULT_PASSWORD = "Capacity" + + +@dataclass(frozen=True) +class AuthContext: + kind: str + payload: dict + + +def _ensure_auth_ini() -> None: + if AUTH_INI_PATH.exists(): + return + + cfg = configparser.ConfigParser() + cfg["auth"] = {"username": DEFAULT_USERNAME, "password": DEFAULT_PASSWORD} + with AUTH_INI_PATH.open("w", encoding="utf-8") as file: + cfg.write(file) + + +def get_auth_config() -> dict[str, str]: + _ensure_auth_ini() + cfg = configparser.ConfigParser() + cfg.read(AUTH_INI_PATH, encoding="utf-8") + return { + "username": cfg.get("auth", "username", fallback=DEFAULT_USERNAME), + "password": cfg.get("auth", "password", fallback=DEFAULT_PASSWORD), + } + + +def save_auth_password(new_password: str) -> None: + _ensure_auth_ini() + cfg = configparser.ConfigParser() + cfg.read(AUTH_INI_PATH, encoding="utf-8") + cfg.set("auth", "password", new_password) + with AUTH_INI_PATH.open("w", encoding="utf-8") as file: + cfg.write(file) + + +def create_jwt_token(data: dict, expires_in: int = 86400 * 30) -> str: + header = _encode_json({"alg": "HS256", "typ": "JWT"}) + payload_data = data.copy() + payload_data["exp"] = int(time.time()) + expires_in + payload = _encode_json(payload_data) + signature = _sign(header, payload) + return f"{header}.{payload}.{signature}" + + +def verify_jwt_token(token: str) -> Optional[dict]: + try: + header, payload, signature = token.split(".") + if not hmac.compare_digest(signature, _sign(header, payload)): + return None + + payload_padded = payload + "=" * ((4 - len(payload) % 4) % 4) + data = json.loads(base64.urlsafe_b64decode(payload_padded).decode()) + if data.get("exp", 0) < int(time.time()): + return None + return data + except Exception: + return None + + +def extract_login_token(request: Request) -> str | None: + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + token = auth_header.split(" ", 1)[1].strip() + if token: + return token + + cookie_token = request.cookies.get("token", "").strip() + return cookie_token or None + + +def extract_access_token(request: Request) -> str | None: + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + token = auth_header.split(" ", 1)[1].strip() + if token: + return token + + api_token = request.headers.get("X-API-Token", "").strip() + if api_token: + return api_token + + cookie_token = request.cookies.get("token", "").strip() + return cookie_token or None + + +def resolve_login_context(request: Request) -> AuthContext | None: + token = extract_login_token(request) + if not token: + return None + + payload = verify_jwt_token(token) + if not payload: + return None + return AuthContext(kind="jwt", payload=payload) + + +def resolve_access_context(request: Request) -> AuthContext | None: + token = extract_access_token(request) + if not token: + return None + + payload = verify_jwt_token(token) + if payload: + return AuthContext(kind="jwt", payload=payload) + + return None + + +def _encode_json(data: dict) -> str: + raw = json.dumps(data, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _sign(header: str, payload: str) -> str: + raw = hmac.new( + SECRET_KEY.encode(), + f"{header}.{payload}".encode(), + hashlib.sha256, + ).digest() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..3234001 --- /dev/null +++ b/app/config.py @@ -0,0 +1,657 @@ +""" +配置管理模块 +""" +import json +import os +import sys +from copy import deepcopy +from datetime import datetime +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List + + +def _resolve_base_dir() -> Path: + env_base_dir = os.environ.get("CAPAREPORT_BASE_DIR") + if env_base_dir: + return Path(env_base_dir).expanduser().resolve() + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = _resolve_base_dir() +CACHE_DIR = BASE_DIR / "cache" +CONFIG_FILE = BASE_DIR / "Configure.json" +SQL_SCRIPT = BASE_DIR / "ReportScript.sql" +CELLDATA_SCRIPT = BASE_DIR / "CellData.sql" + + +@dataclass +class MySQLConfig: + host: str = "localhost" + port: int = 3306 + user: str = "root" + passwd: str = "" + dbname: str = "CapacityReport" + + def normalized(self) -> "MySQLConfig": + try: + port = int(self.port or 3306) + except (TypeError, ValueError): + port = 3306 + return MySQLConfig( + host=str(self.host or "").strip() or "localhost", + port=port, + user=str(self.user or "").strip(), + passwd=self.passwd or "", + dbname=str(self.dbname or "").strip(), + ) + + def to_dict(self, include_password: bool = False) -> Dict[str, Any]: + normalized = self.normalized() + data = { + "host": normalized.host, + "port": normalized.port, + "user": normalized.user, + "dbname": normalized.dbname, + } + if include_password: + data["passwd"] = normalized.passwd + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any] | None, default_dbname: str = "CapacityReport") -> "MySQLConfig": + data = data or {} + return cls( + host=str(data.get("host", "localhost")), + port=data.get("port", 3306), + user=str(data.get("user", "root")), + passwd=str(data.get("passwd", "")), + dbname=str(data.get("dbname", default_dbname)), + ).normalized() + + +# Source backends: external (FTP/SFTP), or Metrix storage platform. +SOURCE_TYPES = ("external", "metrix") +# Warehouse backends: direct MySQL, or Metrix database platform. +WAREHOUSE_TYPES = ("mysql", "metrix") + +DEFAULT_CELL_DATA_SCAN_PATHS = [ + "/网优日常优化数据文档/日常性能报表/{maxyear}年/300表", +] +DEFAULT_CELL_DATA_YEAR_DIR_REGEX = r"(?P\d{4})年" +DEFAULT_CELL_DATA_MONTH_DIR_REGEX = r"(?P\d{1,2})月" +DEFAULT_CELL_DATA_DAY_DIR_REGEX = r"(?P\d{1,2})日" +DEFAULT_CELL_DATA_FILE_NAME_REGEX = r"^Result_300_.*\.zip$" +DEFAULT_CELL_DATA_FILE_TIME_REGEX = r"(?P\d{14})(?=\.zip$)" +DEFAULT_CELL_DATA_MAPPING: Dict[str, Any] = { + "target_table": "cellinfo", + "key": {"field": "CGI", "expr": "{PLMN}-{eNodeBID}-{CellID}"}, + "sources": [ + { + "band": "2.6G", + "file_prefix": "LTE_ITBBU_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "CellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": {"value": "4G"}, + }, + }, + { + "band": "2.6G", + "file_prefix": "LTE_SDR_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "cellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": {"value": "4G"}, + }, + }, + { + "band": "2.6G", + "file_prefix": "NR_CellInfo", + "fields": { + "eNodeBID": "gNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "gNBName", + "小区名称": "CellName", + "频点": "ssbFrequency", + "带宽": "carrierBandwidth", + "制式": {"value": "2.6G"}, + "功率": "powerPerRERef", + "网络": {"value": "5G"}, + }, + }, + { + "band": "700M", + "file_prefix": "LTE_ITBBU_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "CellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": {"value": "4G"}, + }, + }, + { + "band": "700M", + "file_prefix": "NR_CellInfo", + "fields": { + "eNodeBID": "gNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "gNBName", + "小区名称": "CellName", + "频点": "ssbFrequency", + "带宽": "carrierBandwidth", + "制式": {"value": "700M"}, + "功率": "powerPerRERef", + "网络": {"value": "5G"}, + }, + }, + ], +} + + +@dataclass +class MetrixConfig: + """Connection to a Metrix platform, used when source_type/warehouse_type is 'metrix'. + + Metrix appears in the UI as two connection types ("存储平台"/"数据库平台") that share the + same base_url + token; storage_id is the file source, database_conn_id + target_database + are the warehouse. + """ + base_url: str = "http://host.docker.internal:8000" + token: str = "" + storage_id: str = "" + database_conn_id: str = "" + target_database: str = "" + recent_days: int = 7 + + def normalized(self) -> "MetrixConfig": + try: + recent_days = max(int(self.recent_days), 1) + except (TypeError, ValueError): + recent_days = 7 + return MetrixConfig( + base_url=str(self.base_url or "").strip(), + token=str(self.token or "").strip(), + storage_id=str(self.storage_id or "").strip(), + database_conn_id=str(self.database_conn_id or "").strip(), + target_database=str(self.target_database or "").strip(), + recent_days=recent_days, + ) + + def to_dict(self, include_token: bool = False) -> Dict[str, Any]: + n = self.normalized() + data = { + "base_url": n.base_url, + "storage_id": n.storage_id, + "database_conn_id": n.database_conn_id, + "target_database": n.target_database, + "recent_days": n.recent_days, + } + if include_token: + data["token"] = n.token + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any] | None) -> "MetrixConfig": + data = data or {} + return cls( + base_url=str(data.get("base_url", "http://host.docker.internal:8000")), + token=str(data.get("token", "")), + storage_id=str(data.get("storage_id", "")), + database_conn_id=str(data.get("database_conn_id", "")), + target_database=str(data.get("target_database", "")), + recent_days=data.get("recent_days", 7), + ).normalized() + + +@dataclass +class DataMappingsConfig: + """Source directories mapped to staging tables.""" + directories: List[Dict[str, str]] = field( + default_factory=lambda: [ + {"path": "4G", "table": "4G_UD", "ready_rule": "daily"}, + {"path": "5G", "table": "5G_UD", "ready_rule": "daily"}, + ] + ) + table_field_mappings: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) + + def normalized(self) -> "DataMappingsConfig": + directories = [] + seen = set() + for item in self.directories or []: + if not isinstance(item, dict): + continue + path = str(item.get("path", "")).replace("\\", "/").strip().strip("/") + table = str(item.get("table", "")).strip() + ready_rule = str(item.get("ready_rule", "daily")).strip().lower() + if ready_rule not in {"daily", "auto"}: + ready_rule = "daily" + if not path or not table: + continue + key = (path, table, ready_rule) + if key in seen: + continue + seen.add(key) + directories.append({"path": path, "table": table, "ready_rule": ready_rule}) + + if not directories: + directories = [ + {"path": "4G", "table": "4G_UD", "ready_rule": "daily"}, + {"path": "5G", "table": "5G_UD", "ready_rule": "daily"}, + ] + + mappings = {} + for table_name, fields in (self.table_field_mappings or {}).items(): + if isinstance(fields, list): + mappings[table_name] = [ + {k: v for k, v in f.items() if k in ("Source", "Target", "Type")} + for f in fields + if isinstance(f, dict) and "Source" in f and "Target" in f + ] + + return DataMappingsConfig(directories=directories, table_field_mappings=mappings) + + def to_dict(self) -> Dict[str, Any]: + normalized = self.normalized() + return { + "directories": normalized.directories, + "table_field_mappings": normalized.table_field_mappings, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any] | None) -> "DataMappingsConfig": + data = data or {} + directories = data.get("directories", []) + return cls( + directories=directories if isinstance(directories, list) else [], + table_field_mappings=data.get("table_field_mappings", {}) if isinstance(data.get("table_field_mappings"), dict) else {}, + ).normalized() + + +@dataclass +class AutoSchedulerConfig: + enabled: bool = False + check_interval_hours: int = 1 + expected_directories: List[str] = field(default_factory=list) + week_offset: int = 0 + + def normalized(self) -> "AutoSchedulerConfig": + try: + check_interval_hours = int(self.check_interval_hours) + except (TypeError, ValueError): + check_interval_hours = 1 + try: + week_offset = int(self.week_offset) + except (TypeError, ValueError): + week_offset = 0 + + directories = [] + seen = set() + for directory in self.expected_directories or []: + normalized = str(directory).replace("\\", "/").strip().strip("/") + if normalized and normalized not in seen: + directories.append(normalized) + seen.add(normalized) + + return AutoSchedulerConfig( + enabled=bool(self.enabled), + check_interval_hours=max(check_interval_hours, 1), + expected_directories=directories, + week_offset=week_offset, + ) + + def to_dict(self) -> Dict[str, Any]: + normalized = self.normalized() + return { + "enabled": normalized.enabled, + "check_interval_hours": normalized.check_interval_hours, + "expected_directories": normalized.expected_directories, + "week_offset": normalized.week_offset, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any] | None) -> "AutoSchedulerConfig": + data = data or {} + directories = data.get("expected_directories", []) + return cls( + enabled=bool(data.get("enabled", False)), + check_interval_hours=data.get("check_interval_hours", 1), + expected_directories=directories if isinstance(directories, list) else [], + week_offset=data.get("week_offset", 0), + ).normalized() + + +@dataclass +class RemoteDataConfig: + enabled: bool = False + protocol: str = "sftp" + host: str = "" + port: int = 22 + user: str = "" + passwd: str = "" + remote_dir: str = "/" + passive: bool = True + timeout: int = 30 + auto_delete_source: bool = False + auto_scheduler: AutoSchedulerConfig = field(default_factory=AutoSchedulerConfig) + + def normalized(self) -> "RemoteDataConfig": + protocol = self.protocol.lower().strip() + if protocol not in {"ftp", "sftp"}: + protocol = "sftp" + + port = self.port or (22 if protocol == "sftp" else 21) + scheduler = self.auto_scheduler.normalized() + return RemoteDataConfig( + enabled=bool(self.enabled) or scheduler.enabled, + protocol=protocol, + host=self.host.strip(), + port=port, + user=self.user.strip(), + passwd=self.passwd, + remote_dir=(self.remote_dir or "/").strip() or "/", + passive=bool(self.passive), + timeout=max(int(self.timeout or 30), 1), + auto_delete_source=bool(self.auto_delete_source) or scheduler.enabled, + auto_scheduler=scheduler, + ) + + def to_dict(self, include_password: bool = False) -> Dict[str, Any]: + data = { + "enabled": self.enabled, + "protocol": self.protocol, + "host": self.host, + "port": self.port, + "user": self.user, + "remote_dir": self.remote_dir, + "passive": self.passive, + "timeout": self.timeout, + "auto_delete_source": self.auto_delete_source, + "auto_scheduler": self.auto_scheduler.normalized().to_dict(), + } + if include_password: + data["passwd"] = self.passwd + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any] | None) -> "RemoteDataConfig": + data = data or {} + protocol = str(data.get("protocol", "sftp")).lower() + default_port = 22 if protocol == "sftp" else 21 + try: + port = int(data.get("port") or default_port) + except (TypeError, ValueError): + port = default_port + try: + timeout = int(data.get("timeout") or 30) + except (TypeError, ValueError): + timeout = 30 + return cls( + enabled=bool(data.get("enabled", False)), + protocol=protocol, + host=str(data.get("host", "")), + port=port, + user=str(data.get("user", "")), + passwd=str(data.get("passwd", "")), + remote_dir=str(data.get("remote_dir", "/")), + passive=bool(data.get("passive", True)), + timeout=timeout, + auto_delete_source=bool(data.get("auto_delete_source", False)), + auto_scheduler=AutoSchedulerConfig.from_dict(data.get("auto_scheduler")), + ).normalized() + + +@dataclass +class CellDataConfig: + """CellData side data source and database connection used by later processing steps.""" + remote_data: RemoteDataConfig = field( + default_factory=lambda: RemoteDataConfig( + enabled=False, + protocol="sftp", + remote_dir="/", + ) + ) + mysql: MySQLConfig = field(default_factory=lambda: MySQLConfig(dbname="celldata")) + scan_paths: List[str] = field(default_factory=lambda: list(DEFAULT_CELL_DATA_SCAN_PATHS)) + year_dir_regex: str = DEFAULT_CELL_DATA_YEAR_DIR_REGEX + month_dir_regex: str = DEFAULT_CELL_DATA_MONTH_DIR_REGEX + day_dir_regex: str = DEFAULT_CELL_DATA_DAY_DIR_REGEX + file_name_regex: str = DEFAULT_CELL_DATA_FILE_NAME_REGEX + file_time_regex: str = DEFAULT_CELL_DATA_FILE_TIME_REGEX + mapping: Dict[str, Any] = field(default_factory=lambda: deepcopy(DEFAULT_CELL_DATA_MAPPING)) + + def normalized(self) -> "CellDataConfig": + scan_paths = [] + seen = set() + for path in self.scan_paths or []: + normalized = str(path).replace("\\", "/").strip() + if normalized and normalized not in seen: + scan_paths.append(normalized) + seen.add(normalized) + if not scan_paths: + scan_paths = list(DEFAULT_CELL_DATA_SCAN_PATHS) + + mapping = self.mapping if isinstance(self.mapping, dict) and self.mapping else deepcopy(DEFAULT_CELL_DATA_MAPPING) + return CellDataConfig( + remote_data=self.remote_data.normalized(), + mysql=self.mysql.normalized(), + scan_paths=scan_paths, + year_dir_regex=str(self.year_dir_regex or DEFAULT_CELL_DATA_YEAR_DIR_REGEX).strip() or DEFAULT_CELL_DATA_YEAR_DIR_REGEX, + month_dir_regex=str(self.month_dir_regex or DEFAULT_CELL_DATA_MONTH_DIR_REGEX).strip() or DEFAULT_CELL_DATA_MONTH_DIR_REGEX, + day_dir_regex=str(self.day_dir_regex or DEFAULT_CELL_DATA_DAY_DIR_REGEX).strip() or DEFAULT_CELL_DATA_DAY_DIR_REGEX, + file_name_regex=str(self.file_name_regex or DEFAULT_CELL_DATA_FILE_NAME_REGEX).strip() or DEFAULT_CELL_DATA_FILE_NAME_REGEX, + file_time_regex=str(self.file_time_regex or DEFAULT_CELL_DATA_FILE_TIME_REGEX).strip() or DEFAULT_CELL_DATA_FILE_TIME_REGEX, + mapping=mapping, + ) + + def to_dict(self, include_password: bool = False) -> Dict[str, Any]: + normalized = self.normalized() + return { + "remote_data": normalized.remote_data.to_dict(include_password=include_password), + "mysql": normalized.mysql.to_dict(include_password=include_password), + "scan_paths": normalized.scan_paths, + "year_dir_regex": normalized.year_dir_regex, + "month_dir_regex": normalized.month_dir_regex, + "day_dir_regex": normalized.day_dir_regex, + "file_name_regex": normalized.file_name_regex, + "file_time_regex": normalized.file_time_regex, + "mapping": normalized.mapping, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any] | None) -> "CellDataConfig": + data = data or {} + default = cls() + remote_data = data.get("RemoteData") or data.get("remote_data") + mysql_data = data.get("MySQL_DBInfo") or data.get("mysql") + return cls( + remote_data=RemoteDataConfig.from_dict(remote_data) if isinstance(remote_data, dict) else default.remote_data, + mysql=MySQLConfig.from_dict(mysql_data, default_dbname="celldata") if isinstance(mysql_data, dict) else default.mysql, + scan_paths=data.get("scan_paths", default.scan_paths) if isinstance(data.get("scan_paths", default.scan_paths), list) else default.scan_paths, + year_dir_regex=str(data.get("year_dir_regex", default.year_dir_regex)), + month_dir_regex=str(data.get("month_dir_regex", default.month_dir_regex)), + day_dir_regex=str(data.get("day_dir_regex", default.day_dir_regex)), + file_name_regex=str(data.get("file_name_regex", default.file_name_regex)), + file_time_regex=str(data.get("file_time_regex", default.file_time_regex)), + mapping=data.get("mapping", default.mapping) if isinstance(data.get("mapping", default.mapping), dict) else default.mapping, + ).normalized() + + +@dataclass +class HistoryRetentionConfig: + enabled: bool = False + keep_count: int = 20 + + def normalized(self) -> "HistoryRetentionConfig": + try: + keep_count = int(self.keep_count) + except (TypeError, ValueError): + keep_count = 20 + + return HistoryRetentionConfig( + enabled=bool(self.enabled), + keep_count=max(keep_count, 0), + ) + + def to_dict(self) -> Dict[str, Any]: + normalized = self.normalized() + return { + "enabled": normalized.enabled, + "keep_count": normalized.keep_count, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any] | None) -> "HistoryRetentionConfig": + data = data or {} + return cls( + enabled=bool(data.get("enabled", False)), + keep_count=data.get("keep_count", 20), + ).normalized() + + +def _normalize_source_type(value: Any, protocol: str = "sftp") -> str: + text = str(value or "").strip().lower() + if text in SOURCE_TYPES: + return text + if text in ("ftp", "sftp"): + return "external" + return "external" + + +def _normalize_warehouse_type(value: Any) -> str: + text = str(value or "").strip().lower() + return text if text in WAREHOUSE_TYPES else "mysql" + + +@dataclass +class AppConfig: + update: str = "" + metrix_enabled: bool = False + source_type: str = "external" + warehouse_type: str = "mysql" + mysql: MySQLConfig = field(default_factory=MySQLConfig) + metrix: MetrixConfig = field(default_factory=MetrixConfig) + data_mappings: DataMappingsConfig = field(default_factory=DataMappingsConfig) + remote_data: RemoteDataConfig = field(default_factory=RemoteDataConfig) + cell_data: CellDataConfig = field(default_factory=CellDataConfig) + history_retention: HistoryRetentionConfig = field(default_factory=HistoryRetentionConfig) + sheet_filter: List[str] = field(default_factory=list) + extract_fields: List[Dict[str, Any]] = field(default_factory=list) + + @classmethod + def load(cls) -> "AppConfig": + """从 Configure.json 加载配置""" + if not CONFIG_FILE.exists(): + return cls() + + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + + mysql_config = MySQLConfig.from_dict(data.get("MySQL_DBInfo")) + remote_config = RemoteDataConfig.from_dict(data.get("RemoteData")) + metrix_config = MetrixConfig.from_dict(data.get("Metrix")) + data_mappings = DataMappingsConfig.from_dict(data.get("DataMappings")) + cell_data = CellDataConfig.from_dict(data.get("CellData")) + history_retention = HistoryRetentionConfig.from_dict(data.get("HistoryRetention")) + + return cls( + update=data.get("Update", ""), + metrix_enabled=bool(data.get("MetrixEnabled", False)), + source_type=_normalize_source_type(data.get("SourceType"), remote_config.protocol), + warehouse_type=_normalize_warehouse_type(data.get("WarehouseType")), + mysql=mysql_config, + metrix=metrix_config, + data_mappings=data_mappings, + remote_data=remote_config, + cell_data=cell_data, + history_retention=history_retention, + sheet_filter=data.get("SheetFilter", []), + extract_fields=data.get("ExtractField", []) + ) + + def save(self): + """保存配置到 Configure.json,并自动更新 Update 时间""" + self.update = datetime.now().strftime("%Y/%m/%d %H:%M:%S") + data = self.to_file_dict() + + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def to_file_dict(self) -> Dict[str, Any]: + """转换为配置文件结构(包含敏感字段,用于保存和下载)""" + return { + "Update": self.update, + "MetrixEnabled": self.metrix_enabled, + "SourceType": self.source_type, + "WarehouseType": self.warehouse_type, + "MySQL_DBInfo": self.mysql.normalized().to_dict(include_password=True), + "Metrix": self.metrix.normalized().to_dict(include_token=True), + "DataMappings": self.data_mappings.normalized().to_dict(), + "RemoteData": self.remote_data.normalized().to_dict(include_password=True), + "CellData": self.cell_data.normalized().to_dict(include_password=True), + "HistoryRetention": self.history_retention.normalized().to_dict(), + "SheetFilter": self.sheet_filter, + "ExtractField": self.extract_fields + } + + def to_dict(self) -> Dict[str, Any]: + """转换为字典(用于返回给前端,隐藏密码)""" + return { + "update": self.update, + "metrix_enabled": self.metrix_enabled, + "source_type": self.source_type, + "warehouse_type": self.warehouse_type, + "mysql": self.mysql.normalized().to_dict(), + "metrix": self.metrix.normalized().to_dict(), + "data_mappings": self.data_mappings.normalized().to_dict(), + "remote_data": self.remote_data.normalized().to_dict(), + "cell_data": self.cell_data.normalized().to_dict(), + "history_retention": self.history_retention.normalized().to_dict(), + "sheet_filter": self.sheet_filter, + "extract_fields": self.extract_fields + } + + def to_dict_full(self) -> Dict[str, Any]: + """转换为完整字典(包含密码,用于编辑时回显)""" + return { + "update": self.update, + "metrix_enabled": self.metrix_enabled, + "source_type": self.source_type, + "warehouse_type": self.warehouse_type, + "mysql": self.mysql.normalized().to_dict(include_password=True), + "metrix": self.metrix.normalized().to_dict(include_token=True), + "data_mappings": self.data_mappings.normalized().to_dict(), + "remote_data": self.remote_data.normalized().to_dict(include_password=True), + "cell_data": self.cell_data.normalized().to_dict(include_password=True), + "history_retention": self.history_retention.normalized().to_dict(), + "sheet_filter": self.sheet_filter, + "extract_fields": self.extract_fields + } + + +# 确保缓存目录存在 +CACHE_DIR.mkdir(exist_ok=True) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..511031e --- /dev/null +++ b/app/database.py @@ -0,0 +1,562 @@ +""" +数据库连接与操作模块 +""" +import csv +import pymysql +from contextlib import contextmanager +from typing import Any, Dict, List, Optional, Tuple + +from app.config import AppConfig, MySQLConfig + + +def detect_csv_encoding(file_path: str) -> str: + """识别数据管理导入 CSV 的 UTF-8 或 GBK 系列编码。""" + for encoding in ("utf-8-sig", "gb18030"): + try: + with open(file_path, "r", encoding=encoding, errors="strict") as handle: + while handle.read(1024 * 1024): + pass + return encoding + except UnicodeDecodeError: + continue + raise UnicodeError("CSV 编码无法识别,仅支持 UTF-8 或 GBK 编码") + + +def _read_csv_rows(file_path: str, encoding: str) -> tuple[List[str], List[Tuple]]: + with open(file_path, "r", encoding=encoding, newline="") as handle: + reader = csv.reader(handle) + try: + header = next(reader) + except StopIteration: + return [], [] + columns = [str(name).strip() for name in header] + width = len(columns) + data: List[Tuple] = [] + for row in reader: + if not any(str(cell).strip() for cell in row): + continue + cells = list(row[:width]) + [""] * (width - len(row)) + data.append(tuple(cells)) + return columns, data + + +def _deduplicate_rows_by_key( + columns: List[str], + data: List[Tuple], + key_column: str, +) -> tuple[List[Tuple], int]: + if key_column not in columns: + raise ValueError(f"CSV 缺少业务键字段: {key_column}") + + key_index = columns.index(key_column) + rows_by_key: dict[str, Tuple] = {} + for row_number, row in enumerate(data, start=2): + key = str(row[key_index] or "").strip() + if not key: + raise ValueError(f"CSV 第 {row_number} 行 {key_column} 为空") + normalized = list(row) + normalized[key_index] = key + rows_by_key[key] = tuple(normalized) + return list(rows_by_key.values()), len(data) - len(rows_by_key) + + +class DatabaseManager: + """数据库管理器""" + + def __init__(self, config: AppConfig, mysql_config: MySQLConfig | None = None): + self.config = config + self.mysql_config = (mysql_config or config.mysql).normalized() + + @contextmanager + def get_connection(self): + """ + 获取 PyMySQL 连接(上下文管理器) + + 注意:此方法创建的是独立连接(非连接池),适用于: + - 需要在整个操作过程中保持同一 session 的场景 + - 使用临时表(TEMPORARY TABLE)的场景(临时表是 session 级别的) + - 需要事务一致性的长时间操作 + + 如果需要高性能的短连接操作,请使用 engine 属性(连接池) + """ + mysql = self.mysql_config + conn = pymysql.connect( + host=mysql.host, + port=mysql.port, + user=mysql.user, + password=mysql.passwd, + database=mysql.dbname, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor, + local_infile=True, # 允许 LOAD DATA LOCAL + autocommit=False + ) + try: + yield conn + finally: + conn.close() + + @contextmanager + def get_fast_connection(self): + """获取高性能 PyMySQL 连接(用于批量插入)""" + mysql = self.mysql_config + conn = pymysql.connect( + host=mysql.host, + port=mysql.port, + user=mysql.user, + password=mysql.passwd, + database=mysql.dbname, + charset='utf8mb4', + cursorclass=pymysql.cursors.Cursor, # 使用普通游标更快 + local_infile=True, + autocommit=False, + read_timeout=300, + write_timeout=300 + ) + try: + yield conn + finally: + conn.close() + + def test_connection(self) -> Tuple[bool, str]: + """测试数据库连接""" + try: + with self.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute("SELECT 1") + return True, "连接成功" + except Exception as e: + return False, str(e) + + def check_load_data_support(self) -> Tuple[bool, str]: + """ + 检测数据库是否支持 LOAD DATA LOCAL INFILE + + Returns: + (是否支持, 详细信息) + """ + try: + with self.get_connection() as conn: + with conn.cursor() as cursor: + # 检查服务器端 local_infile 变量 + cursor.execute("SHOW VARIABLES LIKE 'local_infile'") + result = cursor.fetchone() + + if result: + value = result.get('Value', '').upper() + if value == 'ON': + return True, "服务器已启用 local_infile" + else: + return False, f"服务器 local_infile={value},需要设置为 ON" + else: + return False, "无法获取 local_infile 变量" + except Exception as e: + return False, f"检测失败: {str(e)}" + + def get_server_info(self) -> Dict[str, Any]: + """获取数据库服务器信息""" + try: + with self.get_connection() as conn: + with conn.cursor() as cursor: + # 获取版本 + cursor.execute("SELECT VERSION() as version") + version = cursor.fetchone().get('version', 'Unknown') + + # 检查 LOAD DATA 支持 + load_data_supported, load_data_msg = self.check_load_data_support() + + return { + "version": version, + "load_data_infile": load_data_supported, + "load_data_message": load_data_msg + } + except Exception as e: + return { + "version": "Unknown", + "load_data_infile": False, + "load_data_message": str(e) + } + + def get_tables(self) -> List[str]: + """获取所有表名""" + with self.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute("SHOW TABLES") + return [list(row.values())[0] for row in cursor.fetchall()] + + def get_table_info(self, table_name: str) -> Dict[str, Any]: + """获取表信息""" + with self.get_connection() as conn: + with conn.cursor() as cursor: + # 获取列信息 + cursor.execute(f"DESCRIBE `{table_name}`") + columns = cursor.fetchall() + + # 获取行数 + cursor.execute(f"SELECT COUNT(*) as count FROM `{table_name}`") + count = cursor.fetchone()['count'] + + return { + "name": table_name, + "columns": columns, + "row_count": count + } + + def query_table( + self, + table_name: str, + page: int = 1, + page_size: int = 50, + filters: Optional[Dict[str, str]] = None, + order_by: Optional[str] = None, + order_dir: str = "ASC" + ) -> Dict[str, Any]: + """分页查询表数据""" + offset = (page - 1) * page_size + + with self.get_connection() as conn: + with conn.cursor() as cursor: + # 构建 WHERE 条件 + where_clause = "" + params = [] + if filters: + conditions = [] + for col, val in filters.items(): + if val: + conditions.append(f"`{col}` LIKE %s") + params.append(f"%{val}%") + if conditions: + where_clause = "WHERE " + " AND ".join(conditions) + + # 获取总数 + count_sql = f"SELECT COUNT(*) as count FROM `{table_name}` {where_clause}" + cursor.execute(count_sql, params) + total = cursor.fetchone()['count'] + + # 构建排序 + order_clause = "" + if order_by: + order_clause = f"ORDER BY `{order_by}` {order_dir}" + + # 查询数据 + query_sql = f"SELECT * FROM `{table_name}` {where_clause} {order_clause} LIMIT %s OFFSET %s" + cursor.execute(query_sql, params + [page_size, offset]) + rows = cursor.fetchall() + + return { + "data": rows, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } + + def _row_conditions(self, identifier: Dict[str, Any]) -> Tuple[str, List[Any]]: + """按整行原值构造 WHERE:NULL 用 IS NULL;调用方再加 LIMIT 1 只影响单行。""" + parts: List[str] = [] + params: List[Any] = [] + for col, val in identifier.items(): + if val is None: + parts.append(f"`{col}` IS NULL") + else: + parts.append(f"`{col}` = %s") + params.append(val) + return (" AND ".join(parts) if parts else "1 = 0"), params + + def update_row(self, table_name: str, identifier: Dict[str, Any], values: Dict[str, Any]) -> int: + """更新单行:按 identifier(原始整行) 定位、LIMIT 1,避免影响重复行。返回影响行数。""" + if not values: + return 0 + set_clause = ", ".join(f"`{col}` = %s" for col in values) + set_params = list(values.values()) + where_clause, where_params = self._row_conditions(identifier) + sql = f"UPDATE `{table_name}` SET {set_clause} WHERE {where_clause} LIMIT 1" + with self.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(sql, set_params + where_params) + conn.commit() + return cursor.rowcount + + def delete_row(self, table_name: str, identifier: Dict[str, Any]) -> int: + """删除单行:按 identifier(原始整行) 定位、LIMIT 1。返回影响行数。""" + where_clause, where_params = self._row_conditions(identifier) + sql = f"DELETE FROM `{table_name}` WHERE {where_clause} LIMIT 1" + with self.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(sql, where_params) + conn.commit() + return cursor.rowcount + + def import_csv(self, file_path: str, table_name: str, encoding: str | None = None) -> int: + """按 CSV 表头列追加导入(列须与表字段一致,由调用方校验)。返回导入行数。""" + csv_encoding = encoding or detect_csv_encoding(file_path) + columns, data = _read_csv_rows(file_path, csv_encoding) + if not data: + return 0 + return self.bulk_insert(table_name, columns, data) + + def upsert_csv( + self, + file_path: str, + table_name: str, + key_column: str, + encoding: str | None = None, + batch_size: int = 1000, + ) -> Dict[str, int]: + """按业务键覆盖导入;上传行获胜,并清理该键已有的重复行。""" + csv_encoding = encoding or detect_csv_encoding(file_path) + columns, raw_data = _read_csv_rows(file_path, csv_encoding) + data, input_duplicate_rows = _deduplicate_rows_by_key(columns, raw_data, key_column) + if not data: + return { + "imported_rows": 0, + "inserted_rows": 0, + "updated_rows": 0, + "removed_duplicate_rows": 0, + "input_duplicate_rows": input_duplicate_rows, + } + + key_index = columns.index(key_column) + keys = [str(row[key_index]) for row in data] + placeholders = ", ".join(["%s"] * len(columns)) + column_names = ", ".join(f"`{column}`" for column in columns) + insert_sql = f"INSERT INTO `{table_name}` ({column_names}) VALUES ({placeholders})" + existing_keys: set[str] = set() + removed_rows = 0 + + with self.get_fast_connection() as connection: + try: + with connection.cursor() as cursor: + for start in range(0, len(keys), batch_size): + batch = keys[start:start + batch_size] + marks = ", ".join(["%s"] * len(batch)) + cursor.execute( + f"SELECT DISTINCT `{key_column}` FROM `{table_name}` " + f"WHERE `{key_column}` IN ({marks})", + batch, + ) + existing_keys.update(str(row[0]) for row in cursor.fetchall()) + + for start in range(0, len(keys), batch_size): + batch = keys[start:start + batch_size] + marks = ", ".join(["%s"] * len(batch)) + cursor.execute( + f"DELETE FROM `{table_name}` WHERE `{key_column}` IN ({marks})", + batch, + ) + removed_rows += max(cursor.rowcount, 0) + + for start in range(0, len(data), batch_size): + cursor.executemany(insert_sql, data[start:start + batch_size]) + connection.commit() + except Exception: + connection.rollback() + raise + + updated_rows = len(existing_keys) + return { + "imported_rows": len(data), + "inserted_rows": len(data) - updated_rows, + "updated_rows": updated_rows, + "removed_duplicate_rows": max(removed_rows - updated_rows, 0), + "input_duplicate_rows": input_duplicate_rows, + } + + def truncate_table(self, table_name: str) -> bool: + """清空表""" + with self.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"TRUNCATE TABLE `{table_name}`") + conn.commit() + return True + + def drop_table(self, table_name: str) -> bool: + """删除表""" + with self.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DROP TABLE IF EXISTS `{table_name}`") + conn.commit() + return True + + def drop_all_tables(self) -> Dict[str, Any]: + """删除所有表""" + with self.get_connection() as conn: + with conn.cursor() as cursor: + # 获取所有表名 + cursor.execute("SHOW TABLES") + tables = [list(row.values())[0] for row in cursor.fetchall()] + + if not tables: + return {"success": True, "dropped_count": 0, "tables": []} + + # 删除所有表 + dropped_tables = [] + for table in tables: + try: + cursor.execute(f"DROP TABLE IF EXISTS `{table}`") + dropped_tables.append(table) + except Exception: + # 记录错误但继续删除其他表 + pass + + conn.commit() + return { + "success": True, + "dropped_count": len(dropped_tables), + "tables": dropped_tables + } + + def execute_sql(self, sql: str) -> Tuple[bool, Any]: + """执行自定义 SQL""" + with self.get_connection() as conn: + with conn.cursor() as cursor: + try: + cursor.execute(sql) + if sql.strip().upper().startswith("SELECT"): + return True, cursor.fetchall() + else: + conn.commit() + return True, {"affected_rows": cursor.rowcount} + except Exception as e: + return False, str(e) + + def bulk_insert(self, table_name: str, columns: List[str], data: List[Tuple], + batch_size: int = 5000, conn=None) -> int: + """ + 高性能批量插入 + 使用 executemany + 批量提交,比 to_sql 快 5-10 倍 + + Args: + conn: 可选,复用已有连接 + """ + if not data: + return 0 + + total_inserted = 0 + placeholders = ', '.join(['%s'] * len(columns)) + column_names = ', '.join([f'`{col}`' for col in columns]) + sql = f"INSERT INTO `{table_name}` ({column_names}) VALUES ({placeholders})" + + def do_insert(connection): + nonlocal total_inserted + with connection.cursor() as cursor: + # 优化插入性能的设置 + cursor.execute("SET autocommit=0") + cursor.execute("SET unique_checks=0") + cursor.execute("SET foreign_key_checks=0") + + # 分批插入 + for i in range(0, len(data), batch_size): + batch = data[i:i + batch_size] + cursor.executemany(sql, batch) + total_inserted += len(batch) + + # 提交并恢复设置 + connection.commit() + cursor.execute("SET unique_checks=1") + cursor.execute("SET foreign_key_checks=1") + cursor.execute("SET autocommit=1") + + if conn: + do_insert(conn) + else: + with self.get_fast_connection() as connection: + do_insert(connection) + + return total_inserted + + def load_data_infile(self, table_name: str, columns: List[str], + temp_file: str, conn=None) -> int: + """ + 使用 LOAD DATA LOCAL INFILE 高速导入 CSV 文件 + 比 executemany 快 10-50 倍 + + Args: + table_name: 目标表名 + columns: 列名列表 + temp_file: 临时 CSV 文件路径 + conn: 可选,复用已有连接 + + Returns: + 导入的行数 + """ + column_names = ', '.join([f'`{col}`' for col in columns]) + + # 使用正斜杠路径(MySQL 兼容) + file_path = temp_file.replace('\\', '/') + + sql = f""" + LOAD DATA LOCAL INFILE '{file_path}' + INTO TABLE `{table_name}` + CHARACTER SET utf8mb4 + FIELDS TERMINATED BY ',' + OPTIONALLY ENCLOSED BY '"' + LINES TERMINATED BY '\\n' + IGNORE 1 LINES + ({column_names}) + """ + + def do_load(connection): + with connection.cursor() as cursor: + # 优化导入性能的设置 + cursor.execute("SET autocommit=0") + cursor.execute("SET unique_checks=0") + cursor.execute("SET foreign_key_checks=0") + + # 执行 LOAD DATA + cursor.execute(sql) + row_count = cursor.rowcount + + # 提交并恢复设置 + connection.commit() + cursor.execute("SET unique_checks=1") + cursor.execute("SET foreign_key_checks=1") + cursor.execute("SET autocommit=1") + + return row_count + + if conn: + return do_load(conn) + else: + with self.get_fast_connection() as connection: + return do_load(connection) + + # 字段类型到 MySQL 类型的映射 + TYPE_MAPPING = { + 'string': 'VARCHAR(255)', + 'datetime': 'DATETIME', + 'int': 'INT', + 'float': 'DOUBLE', + 'text': 'TEXT', + } + + def create_table_from_columns(self, table_name: str, columns: List[str], + column_types: Optional[Dict[str, str]] = None): + """ + 根据列名和类型创建表 + + Args: + table_name: 表名 + columns: 列名列表 + column_types: 列名到类型的映射 {列名: 类型},类型可选: string, datetime, int, float, text + """ + column_defs = [] + for col in columns: + # 获取类型,默认为 string + col_type = 'string' + if column_types and col in column_types: + col_type = column_types[col] + + # 转换为 MySQL 类型 + mysql_type = self.TYPE_MAPPING.get(col_type, 'VARCHAR(255)') + column_defs.append(f'`{col}` {mysql_type}') + + sql = ( + f"CREATE TABLE IF NOT EXISTS `{table_name}` ({', '.join(column_defs)}) " + "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ) + + with self.get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(sql) + conn.commit() diff --git a/app/db_init.py b/app/db_init.py new file mode 100644 index 0000000..6a214d4 --- /dev/null +++ b/app/db_init.py @@ -0,0 +1,155 @@ +"""数据库前置检查:先确保各「库」存在(不存在则自动创建),再确保各库「必须存在的结构表」已建好。 + +约定(见 db_init/README.md): +- 建库:对 celldata 与 capacityreport(仅直连 MySQL 时) 两个目标库执行 CREATE DATABASE IF NOT EXISTS, + 库名以用户配置为准;连接时若库不存在不会报错(先用不带 database 的连接建库)。 +- 建表:初始化 SQL 放在 BASE_DIR/db_init/,文件名形如 ``<库标识>.<表名>.sql``,仅当目标表「不存在」时执行, + 因此 sector_band_ref 这类带预设数据的表只在首次创建时写入,绝不覆盖用户自定义。 +- 库标识 -> 实际连接(库名以用户配置为准,不写死): + celldata -> AppConfig.cell_data.mysql(CellData 库,始终直连 MySQL) + capacityreport -> AppConfig.mysql(主仓库,仅当 warehouse_type == 'mysql' 直连时检查) +- 全过程 best-effort:单个库/单张表失败只记录日志并继续,不阻断启动或处理流程。 +""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import pymysql + +from app.config import BASE_DIR, AppConfig, MySQLConfig + +DB_INIT_DIR = BASE_DIR / "db_init" + +# 需要确保存在的目标库(建库不依赖是否有建表 SQL;主库表为动态生成,故无建表文件但仍需建库) +DB_KEYS = ("celldata", "capacityreport") + + +def _log(logger, message: str) -> None: + if logger is not None: + try: + logger.info(message) + return + except Exception: + pass + print(message) + + +def _target_mysql(app_config: AppConfig, db_key: str) -> Optional[MySQLConfig]: + """库标识 -> MySQL 连接配置;不适用(如主库走 Metrix)时返回 None。""" + if db_key == "celldata": + return app_config.cell_data.mysql.normalized() + if db_key == "capacityreport": + if app_config.warehouse_type != "mysql": + return None + return app_config.mysql.normalized() + return None + + +def _ensure_database(mysql: MySQLConfig) -> None: + """库不存在则创建(用不带 database 的连接执行 CREATE DATABASE IF NOT EXISTS)。""" + safe_name = str(mysql.dbname).replace("`", "") + conn = pymysql.connect( + host=mysql.host, + port=mysql.port, + user=mysql.user, + password=mysql.passwd, + charset="utf8mb4", + cursorclass=pymysql.cursors.Cursor, + autocommit=True, + ) + try: + with conn.cursor() as cursor: + cursor.execute( + f"CREATE DATABASE IF NOT EXISTS `{safe_name}` " + f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + finally: + conn.close() + + +def _ensure_databases(app_config: AppConfig, logger=None) -> None: + """确保所有目标库存在(best-effort)。""" + for db_key in DB_KEYS: + mysql = _target_mysql(app_config, db_key) + if mysql is None or not mysql.dbname: + continue + try: + _ensure_database(mysql) + except Exception as exc: # noqa: BLE001 + _log(logger, f"[前置检查] 确保库 {db_key}({mysql.dbname}@{mysql.host}:{mysql.port}) 失败:{exc}") + + +def _connect(mysql: MySQLConfig): + return pymysql.connect( + host=mysql.host, + port=mysql.port, + user=mysql.user, + password=mysql.passwd, + database=mysql.dbname, + charset="utf8mb4", + cursorclass=pymysql.cursors.Cursor, + autocommit=False, + ) + + +def _existing_tables(conn) -> set[str]: + with conn.cursor() as cursor: + cursor.execute("SHOW TABLES") + return {str(row[0]).lower() for row in cursor.fetchall()} + + +def _run_sql_file(conn, path: Path) -> None: + from app.processor import DataProcessor + + statements = DataProcessor.parse_sql_script(path.read_text(encoding="utf-8")) + with conn.cursor() as cursor: + for statement in statements: + cursor.execute(statement) + conn.commit() + + +def _discover() -> dict[str, list[tuple[str, Path]]]: + """收集 db_init 下的初始化 SQL,按库标识分组:{库标识: [(表名, 路径), ...]}。""" + groups: dict[str, list[tuple[str, Path]]] = {} + if not DB_INIT_DIR.exists(): + return groups + for path in sorted(DB_INIT_DIR.glob("*.sql")): + stem = path.stem # 例如 celldata.sector_band_ref + if "." not in stem: + continue + db_key, table = stem.split(".", 1) + groups.setdefault(db_key, []).append((table, path)) + return groups + + +def ensure_required_tables(app_config: AppConfig, logger=None) -> None: + """先确保各库存在(不存在则创建),再检查各库必须存在的表,缺则按初始化 SQL 建好。失败不抛出。""" + _ensure_databases(app_config, logger) + + groups = _discover() + for db_key, items in groups.items(): + mysql = _target_mysql(app_config, db_key) + if mysql is None or not mysql.dbname: + continue + try: + conn = _connect(mysql) + except Exception as exc: # noqa: BLE001 + _log(logger, f"[前置检查] 连接库 {db_key}({mysql.dbname}@{mysql.host}:{mysql.port}) 失败,跳过:{exc}") + continue + try: + existing = _existing_tables(conn) + for table, path in items: + if table.lower() in existing: + continue + try: + _run_sql_file(conn, path) + _log(logger, f"[前置检查] {db_key}.{table} 不存在,已按 {path.name} 初始化建表") + except Exception as exc: # noqa: BLE001 + try: + conn.rollback() + except Exception: + pass + _log(logger, f"[前置检查] 初始化 {db_key}.{table} 失败:{exc}") + finally: + conn.close() diff --git a/app/history.py b/app/history.py new file mode 100644 index 0000000..995030c --- /dev/null +++ b/app/history.py @@ -0,0 +1,251 @@ +""" +处理历史记录管理模块 +""" +import json +import logging +import shutil +from pathlib import Path +from datetime import datetime +from typing import Any, Dict, List, Optional +from dataclasses import dataclass, field, asdict + +from app.config import CACHE_DIR + + +HISTORY_FILE = CACHE_DIR / "history.json" +logger = logging.getLogger(__name__) + + +@dataclass +class HistoryRecord: + """历史记录""" + id: str + timestamp: str + status: str # pending, processing, completed, failed + work_dir: str + file_count: int = 0 + elapsed_time: float = 0.0 + error: Optional[str] = None + result_tables: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + def get_log_file(self) -> Path: + """获取日志文件路径""" + return Path(self.work_dir) / "log.txt" + + +class HistoryManager: + """历史记录管理器""" + + def __init__(self): + self._ensure_file() + + def _ensure_file(self): + """确保历史文件存在""" + if not HISTORY_FILE.exists(): + HISTORY_FILE.write_text("[]", encoding='utf-8') + + def _load(self) -> List[Dict[str, Any]]: + """加载历史记录""" + try: + return json.loads(HISTORY_FILE.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return [] + + def _save(self, records: List[Dict[str, Any]]): + """保存历史记录""" + HISTORY_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding='utf-8') + + def _delete_work_dir(self, work_dir: str | None) -> None: + if not work_dir: + return + + try: + work_path = Path(work_dir).resolve() + cache_path = CACHE_DIR.resolve() + + if work_path.exists() and work_path.is_dir(): + try: + work_path.relative_to(cache_path) + shutil.rmtree(work_path) + except ValueError: + logger.warning("Skip deleting path outside cache directory: %s", work_dir) + except Exception as e: + logger.warning("Failed to delete work directory %s: %s", work_dir, e) + + def create(self, work_dir: Path, file_count: int, record_id: Optional[str] = None) -> HistoryRecord: + """创建新的历史记录""" + # 如果提供了 record_id,使用它;否则生成新的 + if record_id is None: + record_id = datetime.now().strftime("%Y%m%d_%H%M%S") + + record = HistoryRecord( + id=record_id, + timestamp=datetime.now().isoformat(), + status="pending", + work_dir=str(work_dir), + file_count=file_count + ) + + records = self._load() + records.insert(0, record.to_dict()) + + # 只保留最近 100 条记录 + records = records[:100] + self._save(records) + + return record + + def _clean_record(self, rec: Dict[str, Any]) -> Dict[str, Any]: + """清理记录,移除不存在的字段(如旧的 logs 字段)""" + cleaned = { + 'id': rec.get('id'), + 'timestamp': rec.get('timestamp'), + 'status': rec.get('status'), + 'work_dir': rec.get('work_dir'), + 'file_count': rec.get('file_count', 0), + 'elapsed_time': rec.get('elapsed_time', 0.0), + 'error': rec.get('error'), + 'result_tables': rec.get('result_tables', []) + } + return cleaned + + def update(self, record_id: str, **kwargs) -> Optional[HistoryRecord]: + """更新历史记录""" + records = self._load() + + for i, rec in enumerate(records): + if rec['id'] == record_id: + rec.update(kwargs) + self._save(records) + cleaned = self._clean_record(rec) + return HistoryRecord(**cleaned) + + return None + + def get(self, record_id: str) -> Optional[HistoryRecord]: + """获取单条记录""" + records = self._load() + for rec in records: + if rec['id'] == record_id: + cleaned = self._clean_record(rec) + return HistoryRecord(**cleaned) + return None + + def list(self, limit: int = 50) -> List[Dict[str, Any]]: + """获取历史记录列表""" + records = self._load() + # 返回简化的列表(不包含日志) + result = [] + for rec in records[:limit]: + item = rec.copy() + item.pop('logs', None) # 列表不返回日志(兼容旧数据) + result.append(item) + return result + + def get_logs(self, record_id: str) -> List[str]: + """从 log.txt 文件读取日志""" + record = self.get(record_id) + if not record: + return [] + + log_file = record.get_log_file() + if not log_file.exists(): + return [] + + try: + content = log_file.read_text(encoding='utf-8') + # 按行分割,过滤空行 + logs = [line.strip() for line in content.split('\n') if line.strip()] + return logs + except Exception as e: + logger.warning("Failed to read history log file %s: %s", log_file, e) + return [] + + def delete(self, record_id: str) -> bool: + """删除历史记录,同时删除对应的文件目录""" + records = self._load() + + # 找到要删除的记录,获取其工作目录 + work_dir = None + for rec in records: + if rec['id'] == record_id: + work_dir = rec.get('work_dir') + break + + # 删除记录 + new_records = [r for r in records if r['id'] != record_id] + + if len(new_records) < len(records): + self._save(new_records) + + self._delete_work_dir(work_dir) + + return True + return False + + def prune_finished(self, keep_count: int) -> int: + """按保留数量清理已结束的处理历史,处理中和等待中的记录不会被删除""" + keep_count = max(int(keep_count), 0) + records = self._load() + kept_finished = 0 + pruned: List[Dict[str, Any]] = [] + remaining: List[Dict[str, Any]] = [] + + for rec in records: + status = rec.get("status") + if status not in {"completed", "failed"}: + remaining.append(rec) + continue + + if kept_finished < keep_count: + remaining.append(rec) + kept_finished += 1 + else: + pruned.append(rec) + + if not pruned: + return 0 + + self._save(remaining) + for rec in pruned: + self._delete_work_dir(rec.get("work_dir")) + + return len(pruned) + + def clear(self) -> int: + """清空所有历史记录,同时清空cache目录(保留history.json)""" + records = self._load() + count = len(records) + + # 清空历史记录 + self._save([]) + + # 清空cache目录(保留history.json文件) + try: + cache_path = CACHE_DIR.resolve() + for item in CACHE_DIR.iterdir(): + try: + item_path = item.resolve() + # 安全检查:确保路径在cache目录内 + item_path.relative_to(cache_path) + + if item.is_dir(): + # 删除所有子目录 + shutil.rmtree(item_path) + elif item.is_file() and item.name != "history.json": + # 删除所有文件,但保留history.json + item_path.unlink() + except ValueError: + # 路径不在cache目录内,跳过(安全保护) + logger.warning("Skip path outside cache directory: %s", item) + except Exception as e: + # 单个文件/目录删除失败,继续处理其他文件 + logger.warning("Failed to delete cache item %s: %s", item, e) + except Exception as e: + # 记录错误但不影响清空历史记录的操作 + logger.warning("Failed to clear cache directory: %s", e) + + return count diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8d419d1 --- /dev/null +++ b/app/main.py @@ -0,0 +1,192 @@ +import argparse +import os +from contextlib import asynccontextmanager +from pathlib import Path + +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse + +from app import state +from app.api.routers import ( + auth, + cache, + cell_data, + config, + dashboard, + database, + health, + history, + license, + remote, + script, + tasks, + upload, +) +from app.auth import resolve_access_context, resolve_login_context +from app.config import BASE_DIR +from app.services.auto_scheduler import AutoScheduler + + +APP_VERSION = "3.0.0" +APP_HOST = "0.0.0.0" +APP_PORT = 9081 +# Code/frontend live in the image (/app); runtime state lives on the data volume (BASE_DIR=/data). +FRONTEND_DIST_DIR = Path(os.environ.get("CAPAREPORT_FRONTEND_DIR") or (BASE_DIR / "frontend" / "dist")) +LOGIN_ONLY_API_PREFIXES = ( + "/api/config", + "/api/change-password", + "/api/license", +) + + +@asynccontextmanager +async def app_lifespan(app: FastAPI): + try: + from app.config import AppConfig + from app.db_init import ensure_required_tables + ensure_required_tables(AppConfig.load()) + except Exception as exc: # noqa: BLE001 + print(f"[前置检查] 启动检查异常:{exc}") + state.auto_scheduler = AutoScheduler() + state.auto_scheduler.start() + try: + yield + finally: + if state.auto_scheduler is not None: + state.auto_scheduler.stop() + + +def create_app() -> FastAPI: + app = FastAPI( + title="CapacityReport", + description="容量报表数据处理系统", + version=APP_VERSION, + docs_url=None, + redoc_url=None, + openapi_url=None, + lifespan=app_lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.middleware("http")(auth_middleware) + register_routes(app) + register_frontend(app) + return app + + +async def auth_middleware(request: Request, call_next): + if request.method == "OPTIONS": + return await call_next(request) + + path = request.url.path + if path == "/api/login": + return await call_next(request) + + if _is_login_only_api(path): + if resolve_login_context(request) is None: + return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"}) + return await call_next(request) + + if path.startswith("/api/"): + access_context = resolve_access_context(request) + if access_context is None: + return JSONResponse(status_code=401, content={"detail": "未授权,请提供有效的 Token"}) + + request.state.auth_context = access_context + + return await call_next(request) + + +def register_routes(app: FastAPI) -> None: + routers = [ + auth.router, + health.router, + upload.router, + remote.router, + tasks.router, + history.router, + license.router, + database.router, + config.router, + cache.router, + cell_data.router, + dashboard.router, + script.router, + ] + for router in routers: + app.include_router(router) + + +def register_frontend(app: FastAPI) -> None: + @app.get("/", include_in_schema=False) + @app.get("/{path:path}", include_in_schema=False) + async def serve_frontend(path: str = ""): + if path.startswith("api/"): + raise HTTPException(status_code=404, detail="接口不存在") + return _serve_frontend_path(path) + + +def _serve_frontend_path(path: str): + if not FRONTEND_DIST_DIR.exists(): + return JSONResponse( + status_code=503, + content={"detail": "前端构建产物不存在,请先执行 cd frontend && npm install && npm run build"}, + ) + + requested_file = _safe_file(FRONTEND_DIST_DIR, path) + if requested_file and requested_file.exists() and requested_file.is_file(): + return FileResponse(str(requested_file)) + + index_file = FRONTEND_DIST_DIR / "index.html" + if index_file.exists(): + return FileResponse(str(index_file)) + + return JSONResponse(status_code=503, content={"detail": "前端入口文件不存在"}) + + +def _safe_file(root: Path, path: str) -> Path | None: + if not path: + return None + + root = root.resolve() + requested = (root / path).resolve() + try: + requested.relative_to(root) + except ValueError: + return None + return requested + + +def _is_login_only_api(path: str) -> bool: + return path.startswith(LOGIN_ONLY_API_PREFIXES) + + +app = create_app() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="CapacityReport server") + parser.add_argument("--host", default=APP_HOST, help="Host to bind") + parser.add_argument("--port", default=APP_PORT, type=int, help="Port to bind") + return parser.parse_args() + + +def run_server(host: str = APP_HOST, port: int = APP_PORT) -> None: + print(f"CapacityReport v{APP_VERSION}") + print(f"Config update: {state.current_config().update}") + print(f"Frontend: http://localhost:{port}") + uvicorn.run(app, host=host, port=port, reload=False) + + +if __name__ == "__main__": + args = parse_args() + run_server(args.host, args.port) diff --git a/app/processor.py b/app/processor.py new file mode 100644 index 0000000..2efaf7c --- /dev/null +++ b/app/processor.py @@ -0,0 +1,1093 @@ +""" +数据处理核心模块 +""" +import chardet +import logging +import os +import re +import shutil +import tempfile +import time +import zipfile +import multiprocessing +import pandas as pd +from pathlib import Path +from threading import Lock +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed + +from app.config import AppConfig, SQL_SCRIPT +from app.database import DatabaseManager +from app.utils.file_dates import DirectoryDateSelection, select_recent_items_by_directory + + +logger = logging.getLogger(__name__) + + +class ProcessLogger: + """处理日志记录器""" + + def __init__( + self, + log_file: Optional[Path] = None, + callback: Optional[Callable[[str], None]] = None, + stage_callback: Optional[Callable[[str], None]] = None, + ): + self.logs: List[str] = [] + self.log_file = log_file + self.callback = callback + self.stage_callback = stage_callback + # 如果指定了日志文件,确保目录存在 + if self.log_file: + self.log_file.parent.mkdir(parents=True, exist_ok=True) + # 清空或创建日志文件 + self.log_file.write_text("", encoding='utf-8') + + def log(self, message: str, level: str = "INFO"): + """记录日志""" + timestamp = datetime.now().strftime("%H:%M:%S") + entry = f"[{timestamp}] [{level}] {message}" + self.logs.append(entry) + + # 实时写入文件 + if self.log_file: + try: + with self.log_file.open("a", encoding='utf-8') as f: + f.write(entry + "\n") + except Exception as e: + # 如果写入失败,至少记录到内存 + logger.warning("Failed to write process log file: %s", e) + + if self.callback: + self.callback(entry) + + def info(self, message: str): + self.log(message, "INFO") + + def error(self, message: str): + self.log(message, "ERROR") + + def warning(self, message: str): + self.log(message, "WARN") + + def success(self, message: str): + self.log(message, "SUCCESS") + + def set_stage(self, stage: str): + if self.stage_callback: + self.stage_callback(stage) + + def get_logs(self) -> List[str]: + return self.logs.copy() + + +class DataProcessor: + """数据处理器""" + + # 批量插入大小(根据实际测试,5000 是比较好的平衡点) + BATCH_SIZE = 5000 + # Excel 并行处理的最大线程数(根据 CPU 核心数自动调整) + # 使用 CPU 核心数,但至少为 1,最多不超过 8(避免过多线程导致上下文切换开销) + MAX_WORKERS = min(max(multiprocessing.cpu_count(), 1), 8) + SQL_ALTER_TABLE_RE = re.compile( + r"^\s*ALTER\s+TABLE\s+`?([^`\s]+)`?", + re.IGNORECASE | re.DOTALL, + ) + SQL_MODIFY_COLUMN_RE = re.compile( + r"MODIFY\s+(?:COLUMN\s+)?`([^`]+)`\s+([A-Za-z]+)", + re.IGNORECASE, + ) + SQL_NUMERIC_TYPE_HINTS = { + "int": "int", + "integer": "int", + "bigint": "int", + "smallint": "int", + "mediumint": "int", + "tinyint": "int", + "float": "float", + "double": "float", + "decimal": "float", + "numeric": "float", + } + MYSQL_NUMERIC_PATTERN = r"^-?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))([eE][+-]?[0-9]+)?$" + NUMERIC_ZERO_TEXTS = {"", "-", "--", "—", "–", "NA", "N/A", "NULL", "NONE", "NAN", "\\N"} + + def __init__(self, config: AppConfig, work_dir: Path, logger: ProcessLogger): + self.config = config + self.work_dir = work_dir + self.logger = logger + self.db = DatabaseManager(config) + self.results: Dict[str, Any] = {} + self._explicit_type_fields: set[str] = set() + self._generated_csv_files: set[Path] = set() + self._generated_csv_lock = Lock() + + # 预编译字段映射,避免重复查找 + self._field_map, self._type_map = self._build_field_map() + self._apply_sql_script_type_hints() + + # 预编译按表字段映射 + self._table_field_maps: Dict[str, Tuple[Dict[str, str], Dict[str, str]]] = {} + self._build_table_field_maps() + + # LOAD DATA INFILE 支持状态(在首次使用时检测) + self._load_data_supported: Optional[bool] = None + self._load_data_checked = False + + def _build_table_field_maps(self) -> None: + """预构建按表覆盖的字段映射""" + mappings = self.config.data_mappings.normalized().table_field_mappings + for table_name, fields in mappings.items(): + field_map = {} + type_map = {} + for field_def in fields: + source = field_def.get("Source") + target = field_def.get("Target") + field_type = field_def.get("Type", "string") + if source and target: + field_map[source] = target + type_map[target] = field_type + self._table_field_maps[table_name] = (field_map, type_map) + + def _build_field_map(self) -> Tuple[Dict[str, str], Dict[str, str]]: + """ + 预构建字段映射表和类型映射表,提高查找效率 + + Returns: + field_map: {源字段名: 目标字段名} + type_map: {目标字段名: 字段类型} + """ + field_map = {} + type_map = {} + for field_def in self.config.extract_fields: + db_field = field_def.get("Field") + field_type = field_def.get("Type", "string") # 默认类型为 string + if "Type" in field_def: + self._explicit_type_fields.add(db_field) + + # 记录目标字段的类型 + type_map[db_field] = field_type + + # 记录源字段到目标字段的映射 + for extract_name in field_def.get("Extract", []): + field_map[extract_name] = db_field + + return field_map, type_map + + def _get_field_map_for_table(self, table_name: str) -> Tuple[Dict[str, str], Dict[str, str]]: + """ + 获取指定表的字段映射和类型映射 + + Args: + table_name: 目标表名 + + Returns: + (field_map, type_map) + field_map: {源字段名: 目标字段名} + type_map: {目标字段名: 字段类型} + """ + if table_name in self._table_field_maps: + return self._table_field_maps[table_name] + + # 使用全局映射 + return self._field_map, self._type_map + + def _apply_sql_script_type_hints(self) -> None: + """从 ReportScript.sql 的 ALTER 语句补全缺失的数值字段类型。""" + if not SQL_SCRIPT.exists(): + return + + try: + sql_text = SQL_SCRIPT.read_text(encoding="utf-8") + except Exception as exc: + self.logger.warning(f"读取 SQL 脚本字段类型提示失败: {exc}") + return + + type_hints = self._extract_sql_script_type_hints(sql_text) + applied_count = 0 + for field_name, field_type in type_hints.items(): + if field_name not in self._type_map: + continue + if field_name in self._explicit_type_fields: + continue + if self._type_map.get(field_name) != field_type: + self._type_map[field_name] = field_type + applied_count += 1 + + if applied_count: + self.logger.info(f"已根据 SQL 脚本补全 {applied_count} 个数值字段类型") + + @classmethod + def _extract_sql_script_type_hints(cls, sql_text: str) -> Dict[str, str]: + """提取 SQL 脚本中 MODIFY COLUMN 的数值类型提示。""" + type_hints: Dict[str, str] = {} + for _, columns in cls._iter_numeric_alter_columns(sql_text): + type_hints.update(columns) + return type_hints + + @classmethod + def _iter_numeric_alter_columns(cls, sql_text: str) -> Generator[Tuple[str, Dict[str, str]], None, None]: + """迭代 ALTER TABLE 语句中需要转数值的字段。""" + for statement in cls.parse_sql_script(sql_text): + alter_match = cls.SQL_ALTER_TABLE_RE.search(statement) + if not alter_match: + continue + + table_name = alter_match.group(1) + columns: Dict[str, str] = {} + for column_name, mysql_type in cls.SQL_MODIFY_COLUMN_RE.findall(statement): + field_type = cls.SQL_NUMERIC_TYPE_HINTS.get(mysql_type.lower()) + if field_type: + columns[column_name] = field_type + + if columns: + yield table_name, columns + + def process(self) -> Dict[str, Any]: + """执行完整的数据处理流程""" + start_time = time.time() + self.logger.info(f"开始处理数据,工作目录: {self.work_dir}") + + try: + # 1. 解压 ZIP 文件 + self.logger.set_stage("extracting") + self._unzip_files() + + # 2. 处理 Excel 文件(并行) + self.logger.set_stage("converting") + self._process_excel_files_parallel() + + # 3. 处理 CSV 文件并上传到数据库(高性能批量插入) + self.logger.set_stage("importing") + self._process_csv_files() + + # 4. 执行 SQL 脚本 + self.logger.set_stage("scripting") + self._execute_sql_script() + + elapsed = round(time.time() - start_time, 2) + self.logger.set_stage("completed") + self.logger.success(f"处理完成!总耗时: {elapsed} 秒") + + self.results["success"] = True + self.results["elapsed_time"] = elapsed + + except Exception as e: + self.logger.set_stage("failed") + self.logger.error(f"处理失败: {str(e)}") + self.results["success"] = False + self.results["error"] = str(e) + + finally: + # 清理临时目录 + self._cleanup_temp_dir() + + return self.results + + def _unzip_files(self): + """解压所有 ZIP 文件(支持中文文件名)""" + self.logger.info("正在解压 ZIP 文件...") + zip_files = self._filter_recent_files(list(self.work_dir.rglob("*.zip")), "ZIP") + zip_count = 0 + + for zip_file in zip_files: + try: + rel_path = zip_file.relative_to(self.work_dir) + self.logger.info(f"解压: {rel_path}") + self._extract_zip_with_encoding(zip_file) + zip_count += 1 + except Exception as e: + rel_path = zip_file.relative_to(self.work_dir) + self.logger.error(f"解压失败 {rel_path}: {e}") + + self.logger.info(f"ZIP 解压完成,共 {zip_count} 个文件") + + def _extract_zip_with_encoding(self, zip_file: Path): + """ + 解压 ZIP 文件,自动处理中文文件名编码问题 + 支持 UTF-8、GBK、CP437 等多种编码 + """ + # 优先尝试 UTF-8(现代 ZIP 文件标准) + try: + with zipfile.ZipFile(zip_file, 'r', metadata_encoding='utf-8') as zf: + self._extract_zip_members(zf, zip_file.parent) + return + except (UnicodeDecodeError, zipfile.BadZipFile): + # UTF-8 失败,尝试 GBK(Windows 中文系统常用) + try: + with zipfile.ZipFile(zip_file, 'r', metadata_encoding='gbk') as zf: + self._extract_zip_members(zf, zip_file.parent) + return + except (UnicodeDecodeError, zipfile.BadZipFile): + # GBK 也失败,尝试 CP437(DOS 编码) + try: + with zipfile.ZipFile(zip_file, 'r', metadata_encoding='cp437') as zf: + self._extract_zip_members(zf, zip_file.parent) + return + except Exception as e: + # 所有编码都失败 + raise Exception(f"无法解压 ZIP 文件,编码检测失败: {e}") + + def _extract_zip_members(self, zf: zipfile.ZipFile, target_dir: Path) -> None: + root = target_dir.resolve() + for member in zf.infolist(): + member_name = member.filename.replace("\\", "/") + target_path = (root / member_name).resolve() + + try: + target_path.relative_to(root) + except ValueError: + self.logger.warning(f"跳过不安全的 ZIP 条目: {member.filename}") + continue + + if member.is_dir(): + target_path.mkdir(parents=True, exist_ok=True) + continue + + target_path.parent.mkdir(parents=True, exist_ok=True) + with zf.open(member) as source, target_path.open("wb") as target: + shutil.copyfileobj(source, target) + + if target_path.suffix.lower() == ".csv": + self._remember_generated_csv(target_path) + + def _remember_generated_csv(self, csv_file: Path) -> None: + with self._generated_csv_lock: + self._generated_csv_files.add(csv_file.resolve()) + + def _delete_generated_csv(self, csv_file: Path) -> None: + csv_path = csv_file.resolve() + with self._generated_csv_lock: + if csv_path not in self._generated_csv_files: + return + self._generated_csv_files.remove(csv_path) + + try: + csv_path.relative_to(self.work_dir.resolve()) + except ValueError: + return + + try: + if csv_path.exists() and csv_path.is_file(): + csv_path.unlink() + self.logger.info(f"已清理临时 CSV: {csv_path.relative_to(self.work_dir)}") + except OSError as exc: + self.logger.warning(f"清理临时 CSV 失败 {csv_path}: {exc}") + + def _scan_files(self, directory: Path, extensions: List[str]) -> Generator[Path, None, None]: + """扫描指定扩展名的文件""" + for ext in extensions: + for file in directory.rglob(f"*{ext}"): + yield file + + def _filter_recent_files(self, files: list[Path], label: str, root: Path | None = None) -> list[Path]: + if not files: + return files + + base = (root or self.work_dir).resolve() + + def parent_key(file_path: Path) -> str: + try: + parent = file_path.parent.resolve().relative_to(base) + except ValueError: + parent = file_path.parent + parent_text = str(parent).replace("\\", "/") + return "" if parent_text == "." else parent_text + + selected, summaries = select_recent_items_by_directory( + files, + parent_key=parent_key, + name_key=lambda file_path: file_path.name, + ) + self._log_recent_file_selection(label, summaries) + return sorted(selected) + + def _log_recent_file_selection(self, label: str, summaries: list[DirectoryDateSelection]) -> None: + skipped_total = sum(summary.skipped_count for summary in summaries) + if not skipped_total: + return + + for summary in summaries: + if not summary.skipped_count or not summary.start_date or not summary.max_date: + continue + self.logger.info( + f"{label}目录 {summary.directory or '.'}: 仅处理 " + f"{summary.start_date.isoformat()} 至 {summary.max_date.isoformat()} " + f"的 {summary.selected_count}/{summary.total_count} 个文件," + f"跳过 {summary.skipped_count} 个旧文件" + ) + + def _process_single_excel(self, excel_file: Path, sheet_filter: set) -> int: + """处理单个 Excel 文件(用于并行)""" + processed = 0 + try: + rel_path = excel_file.relative_to(self.work_dir) + + # 使用 openpyxl 的 read_only 模式会更快,但这里保持兼容性 + xl = pd.ExcelFile(excel_file, engine='openpyxl') + + for sheet_name in xl.sheet_names: + if sheet_name not in sheet_filter: + output_file = excel_file.parent / f"{excel_file.stem}_{sheet_name}.csv" + # 直接读取并写入,不做额外处理 + df = xl.parse(sheet_name) + df.to_csv(output_file, index=False, encoding='utf-8') + self._remember_generated_csv(output_file) + processed += 1 + + xl.close() + return processed + + except Exception as e: + rel_path = excel_file.relative_to(self.work_dir) + self.logger.error(f"Excel 处理失败 {rel_path}: {e}") + return 0 + + def _process_excel_files_parallel(self): + """并行处理 Excel 文件""" + self.logger.info("正在并行处理 Excel 文件...") + excel_files = self._filter_recent_files(list(self._scan_files(self.work_dir, ['.xlsx', '.xls'])), "Excel") + self.logger.info(f"找到 {len(excel_files)} 个 Excel 文件") + + if not excel_files: + return + + sheet_filter = set(self.config.sheet_filter) + total_processed = 0 + + # 使用线程池并行处理 + with ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as executor: + futures = { + executor.submit(self._process_single_excel, f, sheet_filter): f + for f in excel_files + } + + for future in as_completed(futures): + excel_file = futures[future] + try: + count = future.result() + total_processed += count + rel_path = excel_file.relative_to(self.work_dir) + if count > 0: + self.logger.info(f"处理完成: {rel_path} ({count} 个 sheet)") + except Exception as e: + rel_path = excel_file.relative_to(self.work_dir) + self.logger.error(f"Excel 处理异常 {rel_path}: {e}") + + self.logger.info(f"Excel 处理完成,共生成 {total_processed} 个 CSV 文件") + + def _detect_encoding(self, file_path: Path) -> str: + """快速检测文件编码(只读取前 8KB)""" + with open(file_path, 'rb') as f: + # 只读取前 8KB,足够检测编码,比 64KB 快很多 + result = chardet.detect(f.read(8192)) + encoding = result.get('encoding', 'utf-8') or 'utf-8' + encoding = encoding.lower() + if 'utf' in encoding: + return 'utf-8' + elif 'gb' in encoding: + return 'gbk' + return 'utf-8' + + def _process_csv_file_fast(self, csv_file: Path, table_name: str, + conn=None, table_created: bool = False) -> Tuple[int, bool]: + """ + 处理单个 CSV 文件 + 使用 LOAD DATA LOCAL INFILE,比 executemany 快 10-50 倍 + + Args: + csv_file: CSV 文件路径 + table_name: 目标表名 + conn: 数据库连接(复用) + table_created: 表是否已创建 + + Returns: + (导入行数, 表是否已创建) + """ + encoding = self._detect_encoding(csv_file) + rel_path = csv_file.relative_to(self.work_dir) + self.logger.info(f"处理 CSV: {rel_path} (编码: {encoding})") + + # 读取 CSV,使用优化参数 + df = pd.read_csv( + csv_file, + encoding=encoding, + thousands=',', + low_memory=True, # 低内存模式 + dtype=str, # 全部作为字符串读取,避免类型推断开销 + na_values=[''], # 只把空字符串当作 NA + keep_default_na=False # 不使用默认的 NA 值 + ) + + # 获取字段映射(按表配置优先,其他表使用全局映射) + field_map, type_map = self._get_field_map_for_table(table_name) + + # 快速字段匹配(使用预编译的映射表) + col_mapping = {} + mapped_targets = set() + for col in df.columns: + if col in field_map: + target_col = field_map[col] + if target_col in mapped_targets: + continue + col_mapping[col] = target_col + mapped_targets.add(target_col) + + if len(col_mapping) < 1: + if 'kpis' in str(csv_file).lower(): + self.logger.warning(f"跳过非数据文件: {rel_path}") + return 0, table_created + raise ValueError(f"字段匹配不足: {rel_path}") + + # 选择需要的列并重命名 + source_cols = list(col_mapping.keys()) + target_cols = list(col_mapping.values()) + + # 创建结果 DataFrame,使用目标列名 + df_result = df[source_cols].copy() + df_result.columns = target_cols + + # 替换 NA 为默认值 + df_result = df_result.fillna('') + + # 构建目标字段的类型映射 + column_types = {col: type_map.get(col, 'string') for col in target_cols} + + # 根据类型处理每列数据 + for col in target_cols: + col_type = column_types.get(col, 'string') + + if col_type == 'datetime': + # 日期时间类型处理 + df_result[col] = self._convert_datetime_column(df_result[col]) + + elif col_type == 'int': + # 整数类型处理 + df_result[col] = self._convert_int_column(df_result[col]) + + elif col_type == 'float': + # 浮点数类型处理 + df_result[col] = self._convert_float_column(df_result[col]) + + elif col_type == 'text': + # 长文本类型,截断到 65535 字符 + mask = df_result[col].str.len() > 65535 + if mask.any(): + df_result.loc[mask, col] = df_result.loc[mask, col].str[:65535] + + else: # string 或其他 + # 字符串类型:去除百分号、截断长度 + df_result[col] = df_result[col].str.replace('%', '', regex=False) + mask = df_result[col].str.len() > 255 + if mask.any(): + df_result.loc[mask, col] = df_result.loc[mask, col].str[:255] + + # 确保表存在(只在第一次创建) + if not table_created: + self.db.create_table_from_columns(table_name, target_cols, column_types) + table_created = True + + # 使用 LOAD DATA INFILE 导入 + inserted = self._load_data_infile(df_result, table_name, target_cols, conn) + + return inserted, table_created + + def _get_temp_dir(self) -> Path: + """获取临时目录(使用工作目录下的 .temp 子目录)""" + temp_dir = self.work_dir / '.temp' + temp_dir.mkdir(exist_ok=True) + return temp_dir + + def _cleanup_temp_dir(self): + """清理临时目录""" + temp_dir = self.work_dir / '.temp' + if temp_dir.exists(): + try: + shutil.rmtree(temp_dir) + except Exception: + pass + + def _check_load_data_support(self) -> bool: + """检测是否支持 LOAD DATA INFILE(只检测一次)""" + if self._load_data_checked: + return self._load_data_supported or False + + self._load_data_checked = True + supported, message = self.db.check_load_data_support() + self._load_data_supported = supported + + if supported: + self.logger.info(f"LOAD DATA INFILE: 已启用 ({message})") + else: + self.logger.warning(f"LOAD DATA INFILE: 不可用 ({message}),将使用批量插入模式") + + return supported + + def _load_data_infile(self, df: pd.DataFrame, table_name: str, + columns: List[str], conn=None) -> int: + """ + 使用 LOAD DATA LOCAL INFILE 导入数据 + 如果失败则自动回退到 bulk_insert 方式 + 临时文件放在工作目录的 .temp 子目录中 + """ + + # 检测是否支持 LOAD DATA INFILE + if not self._check_load_data_support(): + # 不支持,直接使用 bulk_insert + return self._bulk_insert_fallback(df, table_name, columns, conn) + + # 获取临时目录 + temp_dir = self._get_temp_dir() + temp_file = None + + try: + # 写入临时 CSV 文件 + with tempfile.NamedTemporaryFile( + mode='w', + suffix='.csv', + delete=False, + encoding='utf-8', + newline='', + dir=str(temp_dir) # 使用指定的临时目录 + ) as f: + temp_file = f.name + # 写入 CSV(带表头,用于 IGNORE 1 LINES) + df.to_csv(f, index=False, header=True, na_rep='\\N') + + # 使用 LOAD DATA LOCAL INFILE 导入 + inserted = self.db.load_data_infile(table_name, columns, temp_file, conn) + return inserted + + except Exception as e: + # LOAD DATA 失败,标记为不支持并回退 + self.logger.warning(f"LOAD DATA INFILE 执行失败: {e},回退到批量插入模式") + self._load_data_supported = False + return self._bulk_insert_fallback(df, table_name, columns, conn) + + finally: + # 清理临时文件 + if temp_file and os.path.exists(temp_file): + try: + os.remove(temp_file) + except Exception: + pass + + def _bulk_insert_fallback(self, df: pd.DataFrame, table_name: str, + columns: List[str], conn=None) -> int: + """批量插入回退方案""" + # 转换为元组列表 + data_tuples = [tuple(row) for row in df.values] + # 使用批量插入 + return self.db.bulk_insert(table_name, columns, data_tuples, self.BATCH_SIZE, conn) + + # 支持的日期时间格式列表 + DATETIME_FORMATS = [ + 'ISO8601', # 2026-01-06T00:00:00+08:00 + '%Y-%m-%d %H:%M:%S', # 2026-01-06 00:00:00 + '%Y-%m-%d %H:%M', # 2026-01-06 00:00 + '%Y/%m/%d %H:%M:%S', # 2026/01/06 00:00:00 + '%Y/%m/%d %H:%M', # 2026/01/06 00:00 + '%Y-%m-%d', # 2026-01-06 + '%Y/%m/%d', # 2026/01/06 + '%Y年%m月%d日 %H:%M:%S', # 2026年01月06日 00:00:00 + '%Y年%m月%d日', # 2026年01月06日 + '%Y%m%d%H%M%S', # 20260106000000 + '%Y%m%d', # 20260106 + ] + + def _detect_datetime_format(self, series: pd.Series, sample_size: int = 100) -> list: + """ + 采样检测时间格式,返回检测到的格式列表(按匹配数量排序) + """ + # 获取非空样本 + valid = series[series.notna() & (series != '') & (series.astype(str).str.strip() != '')] + if len(valid) == 0: + return self.DATETIME_FORMATS + + # 采样 + sample = valid.head(sample_size) if len(valid) > sample_size else valid + + # 检测每种格式的匹配率 + format_matches = {} + for fmt in self.DATETIME_FORMATS: + try: + if fmt == 'ISO8601': + parsed = pd.to_datetime(sample, errors='coerce', format='ISO8601') + else: + parsed = pd.to_datetime(sample, errors='coerce', format=fmt) + match_count = parsed.notna().sum() + if match_count > 0: + format_matches[fmt] = match_count + except Exception: + continue + + # 按匹配数量降序排序,只返回有匹配的格式 + if format_matches: + sorted_formats = sorted(format_matches.keys(), key=lambda x: format_matches[x], reverse=True) + return sorted_formats + + # 没有检测到格式,返回默认列表 + return self.DATETIME_FORMATS + + def _convert_datetime_column(self, series: pd.Series) -> pd.Series: + """ + 转换日期时间列,支持多种常见格式 + 使用采样检测优化性能:先检测主要格式,再批量处理 + """ + try: + valid_mask = series.notna() & (series != '') & (series.astype(str).str.strip() != '') + if not valid_mask.any(): + return pd.Series([None] * len(series), index=series.index) + + # 采样检测格式(只用前 100 条数据检测) + detected_formats = self._detect_datetime_format(series, sample_size=100) + + # 初始化结果 + parsed = pd.Series([pd.NaT] * len(series), index=series.index) + remaining = valid_mask.copy() + + # 按检测到的格式顺序处理 + for fmt in detected_formats: + if not remaining.any(): + break + + try: + if fmt == 'ISO8601': + raw = series[remaining].astype(str) + raw = raw.str.replace(r'(?:Z|[+-]\d{2}:\d{2})\s*$', '', regex=True) + raw = raw.str.replace('T', ' ', regex=False) + temp_parsed = pd.to_datetime(raw, errors='coerce') + else: + temp_parsed = pd.to_datetime(series[remaining], errors='coerce', format=fmt) + + success_mask = temp_parsed.notna() + if success_mask.any(): + success_indices = remaining[remaining].index[success_mask] + parsed.loc[success_indices] = temp_parsed[success_mask].values + remaining.loc[success_indices] = False + except Exception: + continue + + # 兜底:用 mixed 模式处理剩余的 + if remaining.any(): + try: + temp_parsed = pd.to_datetime(series[remaining], errors='coerce', format='mixed', dayfirst=False) + success_mask = temp_parsed.notna() + if success_mask.any(): + success_indices = remaining[remaining].index[success_mask] + parsed.loc[success_indices] = temp_parsed[success_mask].values + except Exception: + pass + + # 格式化输出 + return parsed.dt.strftime('%Y-%m-%d %H:%M:%S').fillna(None) + except Exception: + return series + + def _convert_int_column(self, series: pd.Series) -> pd.Series: + """转换整数列""" + try: + numeric = self._numeric_series(series) + rounded = numeric.round() + return pd.Series( + [int(value) for value in rounded], + index=series.index, + dtype=object, + ) + except Exception: + return series + + def _convert_float_column(self, series: pd.Series) -> pd.Series: + """转换浮点数列""" + try: + numeric = self._numeric_series(series) + return pd.Series( + [float(value) for value in numeric], + index=series.index, + dtype=object, + ) + except Exception: + return series + + def _numeric_series(self, series: pd.Series) -> pd.Series: + text = series.astype("string") + has_percent = text.str.contains(r"[%%]", regex=True, na=False) + cleaned = self._clean_numeric_text(text) + zero_mask = cleaned.isna() | cleaned.str.upper().isin(self.NUMERIC_ZERO_TEXTS) + numeric = pd.to_numeric(cleaned.mask(zero_mask, "0"), errors="coerce").fillna(0) + numeric[has_percent & numeric.notna()] = numeric[has_percent & numeric.notna()] / 100 + return numeric + + @staticmethod + def _clean_numeric_text(series: pd.Series) -> pd.Series: + """清理数值文本中的格式字符,不改变正常数字。""" + return ( + series.str.strip() + .str.replace(',', '', regex=False) + .str.replace(',', '', regex=False) + .str.replace('%', '', regex=False) + .str.replace('%', '', regex=False) + .str.replace('\t', '', regex=False) + .str.replace(' ', '', regex=False) + ) + + def _find_data_directories(self) -> Dict[str, List[Path]]: + """ + 查找包含数据文件的目录,返回 {表名: [目录路径]} + """ + data_dirs: Dict[str, List[Path]] = {} + self.logger.info(f"开始查找数据目录,工作目录: {self.work_dir}") + + for item in self.config.data_mappings.normalized().directories: + subdir = self.work_dir / item["path"] + if subdir.exists() and subdir.is_dir(): + self._add_data_directory(data_dirs, item["table"], subdir) + self.logger.info(f"发现数据目录: {subdir.relative_to(self.work_dir)} -> 表: {item['table']}") + + return data_dirs + + @staticmethod + def _add_data_directory(data_dirs: Dict[str, List[Path]], table_name: str, directory: Path) -> None: + existing = data_dirs.setdefault(table_name, []) + resolved = directory.resolve() + if all(path.resolve() != resolved for path in existing): + existing.append(directory) + + def _process_csv_files(self): + """处理所有 CSV 文件(使用 LOAD DATA INFILE + 连接复用)""" + self.logger.info("正在处理 CSV 文件并上传到数据库...") + + # 查找数据目录 + data_dirs = self._find_data_directories() + + if not data_dirs: + self.logger.warning("未找到任何数据目录") + return + + # 按目录分组处理,使用连接复用 + for table_name, subdirs in data_dirs.items(): + directory_names = ", ".join(str(subdir.relative_to(self.work_dir)) for subdir in subdirs) + self.logger.info(f"处理目录: {directory_names} -> 表: {table_name}") + + # 删除旧表 + self.db.drop_table(table_name) + + # 处理该目录下的所有 CSV + csv_files: list[Path] = [] + for subdir in subdirs: + csv_files.extend(self._filter_recent_files(list(self._scan_files(subdir, ['.csv'])), "CSV", root=subdir)) + self.logger.info(f"找到 {len(csv_files)} 个 CSV 文件") + + total_rows = 0 + start_time = time.time() + table_created = False + + # 使用连接复用:一个表的所有 CSV 文件共用一个连接 + with self.db.get_fast_connection() as conn: + for i, csv_file in enumerate(csv_files, 1): + try: + rows, table_created = self._process_csv_file_fast( + csv_file, table_name, conn, table_created + ) + total_rows += rows + self._delete_generated_csv(csv_file) + + # 每处理 10 个文件报告一次进度 + if i % 10 == 0: + elapsed = round(time.time() - start_time, 1) + self.logger.info(f"进度: {i}/{len(csv_files)} 文件, 已导入 {total_rows} 行, 耗时 {elapsed}s") + + except Exception as e: + rel_path = csv_file.relative_to(self.work_dir) + self.logger.error(f"CSV 处理失败 {rel_path}: {e}") + + elapsed = round(time.time() - start_time, 2) + speed = round(total_rows / elapsed) if elapsed > 0 else 0 + self.logger.success(f"表 {table_name} 导入完成: {total_rows} 行, 耗时 {elapsed}s, 速度 {speed} 行/秒") + + @staticmethod + def parse_sql_script(sql_text: str) -> List[str]: + """ + 解析 SQL 脚本,提取有效的 SQL 语句 + + 改进的SQL分割逻辑:直接按分号分割,更可靠 + 这样可以确保所有以分号结尾的语句都被正确识别 + + Args: + sql_text: SQL 脚本文本内容 + + Returns: + 有效的 SQL 语句列表 + """ + if not sql_text or not sql_text.strip(): + return [] + + # 改进的SQL分割逻辑:直接按分号分割,更可靠 + # 这样可以确保所有以分号结尾的语句都被正确识别 + parts = sql_text.split(';') + valid_sqls = [] + + for part in parts: + # 处理多行语句:移除以#开头的注释行,但保留SQL语句 + lines = [] + for line in part.split('\n'): + line = line.strip() + # 跳过空行和整行注释 + if line and not line.startswith('#'): + lines.append(line) + + if lines: + # 合并多行语句,保留换行符(MySQL支持多行SQL) + cleaned_sql = '\n'.join(lines) + cleaned_sql = cleaned_sql.strip() + # 跳过空语句(可能只剩下注释) + if cleaned_sql: + valid_sqls.append(cleaned_sql) + + return valid_sqls + + def _prepare_sql_script_session(self, cursor) -> None: + """记录业务 SQL 脚本执行所需的 MySQL session 信息。""" + cursor.execute("SELECT @@SESSION.sql_mode AS sql_mode") + row = cursor.fetchone() or {} + sql_mode = row.get("sql_mode") or "" + self.logger.info(f"MySQL SQL 模式: {sql_mode or '空'}") + + try: + cursor.execute("SELECT @@lower_case_table_names AS lower_case_table_names") + row = cursor.fetchone() or {} + lower_case_table_names = row.get("lower_case_table_names") + self.logger.info(f"MySQL 表名大小写模式: lower_case_table_names={lower_case_table_names}") + except Exception as exc: + self.logger.warning(f"读取 MySQL 表名大小写模式失败: {exc}") + + @staticmethod + def _quote_identifier(identifier: str) -> str: + return f"`{identifier.replace('`', '``')}`" + + def _build_mysql_clean_numeric_expr(self, quoted_column: str) -> str: + return ( + f"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(" + f"TRIM(CAST({quoted_column} AS CHAR)), ',', ''), " + f"',', ''), CHAR(37), ''), CHAR(9), ''), ' ', '')" + ) + + def _normalize_numeric_columns_before_alter(self, cursor, sql: str) -> None: + """在 ALTER 转数值前清理空字符串、千分位和异常数值文本。""" + alter_match = self.SQL_ALTER_TABLE_RE.search(sql) + if not alter_match: + return + + table_name = alter_match.group(1) + columns: Dict[str, str] = {} + for column_name, mysql_type in self.SQL_MODIFY_COLUMN_RE.findall(sql): + field_type = self.SQL_NUMERIC_TYPE_HINTS.get(mysql_type.lower()) + if field_type: + columns[column_name] = field_type + + if not columns: + return + + quoted_table = self._quote_identifier(table_name) + set_clauses = [] + set_params = [] + where_clauses = [] + where_params = [] + + for column_name, field_type in columns.items(): + quoted_column = self._quote_identifier(column_name) + clean_expr = self._build_mysql_clean_numeric_expr(quoted_column) + value_expr = clean_expr + if field_type == "int": + value_expr = f"CAST(ROUND(CAST({clean_expr} AS DOUBLE)) AS CHAR)" + set_clauses.append( + f"{quoted_column} = CASE " + f"WHEN {quoted_column} IS NULL OR {clean_expr} = '' OR {clean_expr} NOT REGEXP %s " + f"THEN '0' ELSE {value_expr} END" + ) + set_params.append(self.MYSQL_NUMERIC_PATTERN) + where_clauses.append( + f"{quoted_column} IS NULL " + f"OR TRIM(CAST({quoted_column} AS CHAR)) = '' " + f"OR {clean_expr} <> TRIM(CAST({quoted_column} AS CHAR)) " + f"OR {clean_expr} NOT REGEXP %s" + ) + where_params.append(self.MYSQL_NUMERIC_PATTERN) + + update_sql = ( + f"UPDATE {quoted_table} SET {', '.join(set_clauses)} " + f"WHERE {' OR '.join(f'({clause})' for clause in where_clauses)}" + ) + cursor.execute(update_sql, set_params + where_params) + affected_rows = cursor.rowcount if cursor.rowcount >= 0 else 0 + if affected_rows: + self.logger.info( + f"已清理 {table_name} 的数值字段格式,影响 {affected_rows} 行" + ) + + def _execute_sql_script(self): + """ + 执行 SQL 脚本 + + 重要说明: + - 使用 get_connection() 获取独立连接(非连接池),确保整个脚本在同一 session 中执行 + - 临时表(TEMPORARY TABLE)是 session 级别的,必须在同一连接中创建和使用 + - 如果使用连接池,不同 SQL 语句可能分配到不同连接,导致临时表不可见 + - 因此整个脚本必须在同一个连接中顺序执行,不能使用连接池 + """ + if not SQL_SCRIPT.exists(): + self.logger.warning("SQL 脚本文件不存在,跳过") + return + + self.logger.info("正在执行 SQL 脚本...") + + with open(SQL_SCRIPT, 'r', encoding='utf-8') as f: + sql_text = f.read() + + if not sql_text or not sql_text.strip(): + self.logger.warning("SQL 脚本文件为空,跳过执行") + return + + # 使用抽离的解析函数 + valid_sqls = self.parse_sql_script(sql_text) + + if not valid_sqls: + self.logger.warning("SQL 脚本中没有有效的 SQL 语句(可能全是注释或空行)") + return + + total = len(valid_sqls) + self.logger.info(f"共找到 {total} 条有效的 SQL 语句") + + executed_count = 0 + # 使用独立连接(非连接池),确保整个脚本在同一 session 中执行 + # 这对于临时表(TEMPORARY TABLE)至关重要,因为临时表是 session 级别的 + with self.db.get_connection() as conn: + with conn.cursor() as cursor: + self._prepare_sql_script_session(cursor) + + for i, sql in enumerate(valid_sqls, 1): + start_time = time.time() + preview = sql[:80].replace('\n', ' ') + self.logger.info(f"执行 SQL ({i}/{total}): {preview}...") + + try: + self._normalize_numeric_columns_before_alter(cursor, sql) + cursor.execute(sql) + executed_count += 1 + elapsed = round(time.time() - start_time, 2) + affected_rows = cursor.rowcount if cursor.rowcount >= 0 else 0 + if affected_rows > 0: + self.logger.info(f"完成,耗时 {elapsed} 秒,影响 {affected_rows} 行") + else: + self.logger.info(f"完成,耗时 {elapsed} 秒") + except Exception as e: + conn.rollback() + self.logger.error(f"SQL 执行失败: {e}") + raise RuntimeError( + f"SQL 脚本执行中断,第 {i}/{total} 条语句失败: {e}" + ) from e + + conn.commit() + + self.logger.success(f"SQL 脚本执行完成,共执行 {executed_count}/{total} 条语句") diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..8d73cb7 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,2 @@ +"""Service helpers.""" + diff --git a/app/services/auto_scheduler.py b/app/services/auto_scheduler.py new file mode 100644 index 0000000..a0be2db --- /dev/null +++ b/app/services/auto_scheduler.py @@ -0,0 +1,669 @@ +from __future__ import annotations + +import json +import threading +from collections import defaultdict +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Any + +from fastapi import HTTPException + +from app import state +from app.config import CACHE_DIR, AutoSchedulerConfig +from app.services.platform import make_source_downloader +from app.services.remote_download import RemoteDataDownloader, RemoteFileInfo +from app.utils.file_dates import parse_file_date_range, required_week_days + + +READY_DIR = CACHE_DIR / "auto_scheduler" +READY_FLAG = READY_DIR / "ready.flag" +DISABLED_CHECK_SECONDS = 60 +STARTUP_CHECK_SECONDS = 5 +FAILURE_RESULTS = {"scan_failed", "trigger_failed", "source_cleanup_failed", "failed", "invalid_flag"} + + +@dataclass(frozen=True) +class AutoDirectoryReadyStatus: + """自动粒度目录就绪状态,按最新文件自动识别日粒度或周粒度。""" + directory: str + ready: bool + granularity: str | None = None + found_days: list[date] | None = None + missing_days: list[date] | None = None + file_name: str | None = None + file_count: int = 0 + error: str | None = None + skipped: bool = False + skip_reason: str | None = None + + def to_dict(self) -> dict[str, Any]: + found_days = self.found_days or [] + missing_days = self.missing_days or [] + return { + "ready": self.ready, + "granularity": self.granularity, + "found_days": [item.isoformat() for item in found_days], + "missing_days": [item.isoformat() for item in missing_days], + "found_count": len(found_days), + "required_count": len(found_days) + len(missing_days), + "file_name": self.file_name, + "file_count": self.file_count, + "error": self.error, + "skipped": self.skipped, + "skip_reason": self.skip_reason, + } + + +@dataclass(frozen=True) +class DirectoryReadyStatus: + directory: str + ready: bool + found_days: list[date] + missing_days: list[date] + file_count: int + error: str | None = None + skipped: bool = False + skip_reason: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "ready": self.ready, + "found_days": [item.isoformat() for item in self.found_days], + "missing_days": [item.isoformat() for item in self.missing_days], + "found_count": len(self.found_days), + "required_count": len(self.found_days) + len(self.missing_days), + "file_count": self.file_count, + "error": self.error, + "skipped": self.skipped, + "skip_reason": self.skip_reason, + } + + +class AutoScheduler: + def __init__(self) -> None: + self._stop_event = threading.Event() + self._check_lock = threading.Lock() + self._status_lock = threading.Lock() + self._thread: threading.Thread | None = None + self._running = False + self._last_check_at: datetime | None = None + self._next_check_at: datetime | None = None + self._last_result = "not_started" + self._last_message = "自动调度器尚未检查" + self._directory_status: dict[str, dict[str, Any]] = {} + self._task_running = False + self._task_id: str | None = None + self._failure_count = 0 + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._running = True + self._stop_event.clear() + self._set_next_check(datetime.now() + timedelta(seconds=STARTUP_CHECK_SECONDS)) + self._thread = threading.Thread(target=self._run_loop, name="auto-scheduler", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._running = False + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + + def get_status(self) -> dict[str, Any]: + app_config = state.current_config() + config = app_config.remote_data.normalized() + scheduler = config.auto_scheduler.normalized() + data_mappings = app_config.data_mappings.normalized() + auto_directories = [ + item["path"] + for item in data_mappings.directories + if item.get("ready_rule") == "auto" + ] + target_days = required_week_days(scheduler.week_offset) + ready_flag = self._read_ready_flag() + + with self._status_lock: + return { + "enabled": scheduler.enabled, + "running": self._running, + "check_interval_hours": scheduler.check_interval_hours, + "expected_directories": scheduler.expected_directories, + "week_offset": scheduler.week_offset, + "auto_delete_source": config.auto_delete_source, + "auto_ready_directories": auto_directories, + "next_check_at": self._format_dt(self._next_check_at), + "last_check_at": self._format_dt(self._last_check_at), + "last_result": self._last_result, + "last_message": self._last_message, + "failure_count": self._failure_count, + "task_running": self._task_running, + "task_id": self._task_id, + "ready_flag": ready_flag, + "target_week": { + "start": target_days[0].isoformat(), + "end": target_days[-1].isoformat(), + "days": [item.isoformat() for item in target_days], + }, + "directory_status": self._directory_status, + } + + def check_and_run(self, manual: bool = False) -> dict[str, Any]: + if not self._check_lock.acquire(blocking=False): + return self._finish_check("busy", "自动调度器正在检查中", manual) + + try: + return self._check_and_run_locked(manual) + finally: + self._check_lock.release() + + def _run_loop(self) -> None: + while not self._stop_event.wait(self._seconds_until_next_check()): + self.check_and_run(manual=False) + + def _check_and_run_locked(self, manual: bool) -> dict[str, Any]: + now = datetime.now() + app_config = state.current_config() + remote_config = app_config.remote_data.normalized() + scheduler = remote_config.auto_scheduler.normalized() + self._last_check_at = now + + if not scheduler.enabled: + self._set_next_check(now + timedelta(seconds=DISABLED_CHECK_SECONDS)) + return self._finish_check("disabled", "自动调度未启用", manual) + + self._set_next_check(now + timedelta(hours=scheduler.check_interval_hours)) + + if not remote_config.enabled: + return self._finish_check("remote_disabled", "远程数据源未启用", manual) + + if state.global_task_lock["locked"]: + return self._finish_check("task_running", "已有任务在运行,本轮自动调度跳过", manual) + + ready_flag = self._read_ready_flag() + if ready_flag["exists"]: + if ready_flag.get("invalid"): + self._clear_ready_flag() + return self._finish_check("invalid_flag", "就绪标识格式错误,已清除,本轮不触发处理", manual) + target_dates = self._target_dates_from_flag(ready_flag, scheduler) + return self._trigger_processing(target_dates, ready_flag, manual) + + target_days = required_week_days(scheduler.week_offset) + downloader = make_source_downloader(app_config) + data_mappings = app_config.data_mappings.normalized() + auto_directories = { + item["path"] + for item in data_mappings.directories + if item.get("ready_rule") == "auto" + } + + # 自动粒度目录单独判断,避免被普通 7 天规则误拦。 + directory_status = self._check_remote_ready( + downloader, + scheduler, + target_days, + excluded_directories=auto_directories, + ) + + error_count = sum(1 for item in directory_status.values() if item.error) + if error_count: + self._set_combined_directory_status(directory_status, {}) + return self._finish_check( + "scan_failed", + f"远程目录扫描失败,{error_count}/{len(directory_status)} 个目录无法访问或扫描失败", + manual, + ) + + active_status = [item for item in directory_status.values() if not item.skipped] + skipped_count = len(directory_status) - len(active_status) + + # 检查自动粒度目录,按最新文件自动判断日粒度或周粒度。 + auto_status = self._check_auto_ready(downloader, target_days, sorted(auto_directories)) + auto_error_count = sum(1 for item in auto_status.values() if item.error) + self._set_combined_directory_status(directory_status, auto_status) + if auto_error_count: + return self._finish_check( + "scan_failed", + f"自动粒度远程目录扫描失败,{auto_error_count}/{len(auto_status)} 个目录无法访问或扫描失败", + manual, + ) + + active_auto_status = [item for item in auto_status.values() if not item.skipped] + daily_ready = all(item.ready for item in active_status) if active_status else not directory_status and bool(active_auto_status) + auto_ready = all(item.ready for item in active_auto_status) + auto_status_text = "" + + if active_auto_status: + auto_ready_count = sum(1 for item in active_auto_status if item.ready) + auto_total = len(active_auto_status) + if not auto_ready: + auto_status_text = f",自动粒度数据 {auto_ready_count}/{auto_total} 个有效目录就绪" + + # 两个条件都满足才触发 + if daily_ready and auto_ready: + self._mark_ready(target_days, directory_status, auto_status) + skipped_text = f",已跳过 {skipped_count} 个停推目录" if skipped_count else "" + auto_text = ",自动粒度数据已就绪" if active_auto_status else "" + return self._finish_check( + "marked_ready", + f"远程数据已满足目标周 7 天{skipped_text}{auto_text},已写入就绪标识,下次检查将自动处理", + manual, + ) + + if not active_status and directory_status: + return self._finish_check( + "waiting", + f"远程数据未就绪,{len(directory_status)} 个目录均为空,已视为停推但不会触发处理", + manual, + ) + + if not active_status: + return self._finish_check( + "waiting", + f"远程普通日数据未发现有效目录,无法触发处理{auto_status_text}", + manual, + ) + + ready_count = sum(1 for item in active_status if item.ready) + skipped_text = f",跳过 {skipped_count} 个停推目录" if skipped_count else "" + return self._finish_check( + "waiting", + f"远程数据未就绪,{ready_count}/{len(active_status)} 个有效目录满足目标周 7 天{skipped_text}{auto_status_text}", + manual, + ) + + def _check_remote_ready( + self, + downloader: RemoteDataDownloader, + scheduler: AutoSchedulerConfig, + target_days: list[date], + excluded_directories: set[str] | None = None, + ) -> dict[str, DirectoryReadyStatus]: + excluded = {self._normalize_directory_name(item) for item in (excluded_directories or set())} + expected_directories = scheduler.expected_directories + if expected_directories: + return { + directory: self._directory_ready_status( + directory, + self._safe_list_remote_zip_files(downloader, directory), + target_days, + ) + for directory in expected_directories + if not self._is_excluded_directory(directory, excluded) + } + + files = self._safe_list_remote_zip_files(downloader, None) + if files is None: + return { + ".": DirectoryReadyStatus( + directory=".", + ready=False, + found_days=[], + missing_days=target_days, + file_count=0, + error="远程目录扫描失败", + ) + } + + if not files: + return { + ".": DirectoryReadyStatus( + directory=".", + ready=False, + found_days=[], + missing_days=target_days, + file_count=0, + error="远程目录未找到 ZIP 文件", + ) + } + + grouped: dict[str, list[RemoteFileInfo]] = defaultdict(list) + for remote_file in files: + parent = remote_file.parent or "." + if self._is_excluded_directory(parent, excluded): + continue + grouped[parent].append(remote_file) + + if not grouped: + return {} + + return { + directory: self._directory_ready_status(directory, directory_files, target_days) + for directory, directory_files in sorted(grouped.items(), key=lambda item: item[0]) + } + + def _safe_list_remote_zip_files( + self, + downloader: RemoteDataDownloader, + directory: str | None, + ) -> list[RemoteFileInfo] | None: + try: + return downloader.list_remote_zip_files(directory) + except Exception: + return None + + def _directory_ready_status( + self, + directory: str, + files: list[RemoteFileInfo] | None, + target_days: list[date], + ) -> DirectoryReadyStatus: + if files is None: + return DirectoryReadyStatus( + directory=directory, + ready=False, + found_days=[], + missing_days=target_days, + file_count=0, + error="远程目录不存在或无法访问", + ) + + if not files: + return DirectoryReadyStatus( + directory=directory, + ready=True, + found_days=[], + missing_days=[], + file_count=0, + skipped=True, + skip_reason="目录为空,视为已停推并跳过", + ) + + required = set(target_days) + found = { + target_day + for remote_file in files + if (date_range := parse_file_date_range(remote_file.name)) + for target_day in date_range.covered_days() + if target_day in required + } + missing = [item for item in target_days if item not in found] + return DirectoryReadyStatus( + directory=directory, + ready=not missing, + found_days=sorted(found), + missing_days=missing, + file_count=len(files), + ) + + def _check_auto_ready( + self, + downloader: RemoteDataDownloader, + target_days: list[date], + directories: list[str], + ) -> dict[str, AutoDirectoryReadyStatus]: + """检查自动粒度目录是否就绪,自动识别目录最新文件是日粒度还是周粒度。""" + if not directories: + return {} + + result: dict[str, AutoDirectoryReadyStatus] = {} + + for directory in directories: + result[directory] = self._check_single_auto_directory(downloader, directory, target_days) + + return result + + def _check_single_auto_directory( + self, + downloader: RemoteDataDownloader, + directory: str, + target_days: list[date], + ) -> AutoDirectoryReadyStatus: + """检查单个自动粒度目录是否包含目标周数据。""" + files = self._safe_list_remote_zip_files(downloader, directory) + if files is None: + return AutoDirectoryReadyStatus( + directory=directory, + ready=False, + error="远程目录不存在或无法访问", + ) + + if not files: + return AutoDirectoryReadyStatus( + directory=directory, + ready=True, + file_count=0, + skipped=True, + skip_reason="目录为空,视为已停推并跳过", + ) + + parsed_files = [ + (remote_file, date_range) + for remote_file in files + if (date_range := parse_file_date_range(remote_file.name)) + ] + if not parsed_files: + return AutoDirectoryReadyStatus( + directory=directory, + ready=False, + found_days=[], + missing_days=target_days, + file_count=len(files), + error="目录中未找到可识别日期的 ZIP 文件", + ) + + latest_file, latest_range = max( + parsed_files, + key=lambda item: (item[1].start, item[1].end_exclusive, item[0].name), + ) + granularity = "daily" if latest_range.span_days <= 1 else "weekly" + required = set(target_days) + + found = { + target_day + for _, date_range in parsed_files + for target_day in date_range.covered_days() + if target_day in required + } + missing = [item for item in target_days if item not in found] + + if granularity == "daily": + return AutoDirectoryReadyStatus( + directory=directory, + ready=not missing, + granularity=granularity, + found_days=sorted(found), + missing_days=missing, + file_name=latest_file.name, + file_count=len(files), + ) + + for remote_file in files: + date_range = parse_file_date_range(remote_file.name) + if date_range and date_range.covers_all(required): + return AutoDirectoryReadyStatus( + directory=directory, + ready=True, + granularity=granularity, + found_days=target_days, + missing_days=[], + file_name=remote_file.name, + file_count=len(files), + ) + + return AutoDirectoryReadyStatus( + directory=directory, + ready=False, + granularity=granularity, + found_days=sorted(found), + missing_days=missing, + file_name=latest_file.name, + file_count=len(files), + ) + + def _trigger_processing( + self, + target_dates: list[date], + ready_flag: dict[str, Any], + manual: bool, + ) -> dict[str, Any]: + from app.api.routers.remote import start_remote_processing_job + + try: + result = start_remote_processing_job( + source="scheduler", + on_finish=self._on_processing_finish, + target_dates=target_dates, + ) + except HTTPException as exc: + return self._finish_check("trigger_failed", str(exc.detail), manual) + except Exception as exc: + return self._finish_check("trigger_failed", f"自动调度触发失败: {exc}", manual) + + task_id = result.get("task_id") + with self._status_lock: + self._task_running = True + self._task_id = str(task_id) if task_id else None + self._last_result = "triggered" + self._last_message = "已根据就绪标识触发远程下载并处理" + return { + "success": True, + "result": "triggered", + "message": "已根据就绪标识触发远程下载并处理", + "task_id": task_id, + "ready_flag": ready_flag, + "status": self.get_status(), + } + + def _mark_ready( + self, + target_days: list[date], + directory_status: dict[str, DirectoryReadyStatus], + auto_status: dict[str, AutoDirectoryReadyStatus] | None = None, + ) -> None: + READY_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "ready_at": datetime.now().isoformat(timespec="seconds"), + "week_start": target_days[0].isoformat(), + "week_end": target_days[-1].isoformat(), + "target_dates": [item.isoformat() for item in target_days], + "directories": { + name: item.to_dict() + for name, item in directory_status.items() + }, + } + if auto_status: + payload["auto_directories"] = { + name: item.to_dict() + for name, item in auto_status.items() + } + READY_FLAG.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def _clear_ready_flag(self) -> None: + try: + READY_FLAG.unlink(missing_ok=True) + except OSError: + pass + + def _read_ready_flag(self) -> dict[str, Any]: + if not READY_FLAG.exists(): + return {"exists": False} + try: + data = json.loads(READY_FLAG.read_text(encoding="utf-8")) + if isinstance(data, dict): + return {"exists": True, **data} + except Exception as exc: + return {"exists": True, "invalid": True, "error": str(exc)} + return {"exists": True, "invalid": True, "error": "ready.flag 格式错误"} + + def _target_dates_from_flag( + self, + ready_flag: dict[str, Any], + scheduler: AutoSchedulerConfig, + ) -> list[date]: + raw_dates = ready_flag.get("target_dates") + if isinstance(raw_dates, list): + dates: list[date] = [] + for raw_date in raw_dates: + try: + dates.append(date.fromisoformat(str(raw_date))) + except ValueError: + continue + if dates: + return sorted(dates) + return required_week_days(scheduler.week_offset) + + def _on_processing_finish(self, task_id: str, status: str) -> None: + if status == "completed": + self._clear_ready_flag() + message = "自动调度任务处理成功,已清除就绪标识" + result = "completed" + else: + message = "自动调度任务未完成,就绪标识已保留,下次检查会重试" + result = status or "failed" + + with self._status_lock: + self._task_running = False + self._task_id = task_id + self._last_result = result + self._last_message = message + self._failure_count = 0 if result == "completed" else self._failure_count + 1 + + def _finish_check(self, result: str, message: str, manual: bool) -> dict[str, Any]: + with self._status_lock: + self._last_result = result + self._last_message = message + if result in FAILURE_RESULTS: + self._failure_count += 1 + elif result not in {"busy", "task_running"}: + self._failure_count = 0 + if result not in {"triggered", "task_running"}: + self._task_running = False + + return { + "success": result not in FAILURE_RESULTS, + "manual": manual, + "result": result, + "message": message, + "status": self.get_status(), + } + + def _set_combined_directory_status( + self, + directory_status: dict[str, DirectoryReadyStatus], + auto_status: dict[str, AutoDirectoryReadyStatus], + ) -> None: + with self._status_lock: + combined = { + name: item.to_dict() + for name, item in directory_status.items() + } + combined.update( + { + name: item.to_dict() + for name, item in auto_status.items() + } + ) + self._directory_status = combined + + @staticmethod + def _normalize_directory_name(directory: str) -> str: + normalized = str(directory or "").replace("\\", "/").strip().strip("/") + return "." if normalized in {"", "."} else normalized + + @classmethod + def _is_excluded_directory(cls, directory: str, excluded: set[str]) -> bool: + if not excluded: + return False + normalized = cls._normalize_directory_name(directory) + return any( + normalized == item or normalized.startswith(f"{item}/") + for item in excluded + if item != "." + ) + + def _set_next_check(self, value: datetime) -> None: + with self._status_lock: + self._next_check_at = value + + def _seconds_until_next_check(self) -> float: + with self._status_lock: + next_check_at = self._next_check_at + if not next_check_at: + return STARTUP_CHECK_SECONDS + return max((next_check_at - datetime.now()).total_seconds(), 0) + + @staticmethod + def _format_dt(value: datetime | None) -> str | None: + return value.isoformat(timespec="seconds") if value else None diff --git a/app/services/cell_data.py b/app/services/cell_data.py new file mode 100644 index 0000000..484c964 --- /dev/null +++ b/app/services/cell_data.py @@ -0,0 +1,648 @@ +from __future__ import annotations + +import csv +import re +import stat +import zipfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Iterable + +import chardet +import pymysql + +from app.config import AppConfig, CellDataConfig +from app.services.remote_download import RemoteDataDownloader, RemoteFileInfo + +LogFn = Callable[[str], None] + +CELLINFO_COLUMNS = [ + "CGI", + "eNodeBID", + "CellID", + "PLMN", + "基站名称", + "小区名称", + "频点", + "带宽", + "制式", + "功率", + "网络", +] + + +@dataclass(frozen=True) +class SelectedZip: + scan_path: str + band: str + remote_file: RemoteFileInfo + timestamp: str + + +@dataclass +class CellDataResult: + selected_files: int = 0 + parsed_rows: int = 0 + imported_rows: int = 0 + skipped_rows: int = 0 + + +class CellDataProcessor: + def __init__(self, app_config: AppConfig, work_dir: Path, logger=None): + self.app_config = app_config + self.config = app_config.cell_data.normalized() + self.work_dir = work_dir + self.logger = logger + self.downloader = RemoteDataDownloader(self.config.remote_data, self._log) + self.year_dir_re = re.compile(self.config.year_dir_regex) + self.month_dir_re = re.compile(self.config.month_dir_regex) + self.day_dir_re = re.compile(self.config.day_dir_regex) + self.file_name_re = re.compile(self.config.file_name_regex) + self.file_time_re = re.compile(self.config.file_time_regex) + + def run(self) -> CellDataResult: + if not self.config.remote_data.enabled: + self._log("CellData 数据源未启用,跳过") + return CellDataResult() + + self._set_stage("locating") + selected = self._select_latest_zip_files() + if not selected: + raise RuntimeError("未找到可处理的 CellData ZIP 文件") + self._log(f"已选择 {len(selected)} 个 CellData ZIP 文件") + + self._set_stage("downloading") + local_files = self._download_selected(selected) + + self._set_stage("parsing") + result = CellDataResult(selected_files=len(selected)) + rows = self._parse_zip_files(local_files, result) + if not rows: + raise RuntimeError("CellData ZIP 中未解析到有效数据") + + self._set_stage("importing") + result.imported_rows = self._replace_cellinfo(rows) + self._log(f"CellData 导入完成,共 {result.imported_rows} 行") + return result + + def run_local(self, upload_root: Path) -> CellDataResult: + self._set_stage("locating") + local_files = self._select_local_zip_files(upload_root) + if not local_files: + raise RuntimeError("未找到可处理的 CellData ZIP 文件") + self._log(f"已选择 {len(local_files)} 个 CellData ZIP 文件") + + self._set_stage("parsing") + result = CellDataResult(selected_files=len(local_files)) + rows = self._parse_zip_files(local_files, result) + if not rows: + raise RuntimeError("CellData ZIP 中未解析到有效数据") + + self._set_stage("importing") + result.imported_rows = self._replace_cellinfo(rows) + self._log(f"CellData 导入完成,共 {result.imported_rows} 行") + return result + + def _select_latest_zip_files(self) -> list[SelectedZip]: + selected: list[SelectedZip] = [] + for template in self.config.scan_paths: + scan_path = self._resolve_scan_path(template) + band_dirs = [ + entry + for entry in self._list_dir(scan_path) + if entry["type"] == "directory" + ] + for band_dir in band_dirs: + band = str(band_dir["name"]) + files = [ + entry + for entry in self._list_dir(str(band_dir["path"])) + if entry["type"] == "file" and self.file_name_re.search(str(entry["name"])) + ] + latest = self._latest_file(files) + if latest: + selected.append( + SelectedZip( + scan_path=scan_path, + band=band, + remote_file=self._remote_file_info(str(latest["path"]), int(latest.get("size") or 0)), + timestamp=str(latest["timestamp"]), + ) + ) + self._log(f"{band}: {latest['name']}") + return selected + + def _resolve_scan_path(self, template: str) -> str: + path = self._replace_date_placeholders(str(template).replace("\\", "/").strip()) + if not path.startswith("/"): + path = self._join_remote_path(self.config.remote_data.remote_dir, path) + parts = [part for part in path.split("/") if part] + replacements = [ + ("{maxyear}", "year", self.year_dir_re, "年份"), + ("{maxmonth}", "month", self.month_dir_re, "月份"), + ("{maxday}", "day", self.day_dir_re, "日期"), + ] + for token, group_name, pattern, label in replacements: + token_index = next((index for index, part in enumerate(parts) if token in part), -1) + if token_index < 0: + continue + parent = "/" + "/".join(parts[:token_index]) if token_index else "/" + values: list[int] = [] + for entry in self._list_dir(parent): + if entry["type"] != "directory": + continue + match = pattern.search(str(entry["name"])) + if match: + values.append(int(match.group(group_name))) + if not values: + raise RuntimeError(f"未找到{label}目录: {parent}") + parts[token_index] = parts[token_index].replace(token, str(max(values))) + return "/" + "/".join(parts) + + def _replace_date_placeholders(self, path: str) -> str: + now = datetime.now() + return ( + path.replace("{yyyy}", now.strftime("%Y")) + .replace("{yyyymm}", now.strftime("%Y%m")) + .replace("{yyyymmdd}", now.strftime("%Y%m%d")) + ) + + def _latest_file(self, files: list[dict[str, Any]]) -> dict[str, Any] | None: + candidates: list[dict[str, Any]] = [] + for item in files: + match = self.file_time_re.search(str(item["name"])) + if not match: + continue + timestamp = match.group("timestamp") + candidates.append({**item, "timestamp": timestamp}) + if not candidates: + return None + return max(candidates, key=lambda item: str(item["timestamp"])) + + def _download_selected(self, selected: list[SelectedZip]) -> list[tuple[SelectedZip, Path]]: + target_dir = self.work_dir / "cell_data" + target_dir.mkdir(parents=True, exist_ok=True) + local_files: list[tuple[SelectedZip, Path]] = [] + if self.config.remote_data.protocol == "ftp": + with self.downloader._ftp_client() as ftp: # noqa: SLF001 - reuse existing connection helpers + for item in selected: + local_path = target_dir / item.band / item.remote_file.name + self.downloader._download_ftp_file(ftp, item.remote_file.path, local_path, self._download_result(), item.remote_file.size) # noqa: SLF001 + local_files.append((item, local_path)) + return local_files + + ssh = self.downloader._sftp_ssh_client() # noqa: SLF001 + try: + with ssh.open_sftp() as sftp: + for item in selected: + local_path = target_dir / item.band / item.remote_file.name + self.downloader._download_sftp_file(sftp, item.remote_file.path, local_path, self._download_result(), item.remote_file.size) # noqa: SLF001 + local_files.append((item, local_path)) + finally: + ssh.close() + return local_files + + def _select_local_zip_files(self, upload_root: Path) -> list[tuple[SelectedZip, Path]]: + grouped: dict[str, list[Path]] = {} + for path in upload_root.rglob("*.zip"): + if not self.file_name_re.search(path.name): + continue + try: + parent = str(path.parent.relative_to(upload_root)).replace("\\", "/") + except ValueError: + parent = "" + grouped.setdefault("" if parent == "." else parent, []).append(path) + + selected: list[tuple[SelectedZip, Path]] = [] + for parent, paths in sorted(grouped.items(), key=lambda item: item[0]): + candidates = [] + for path in paths: + match = self.file_time_re.search(path.name) + if match: + candidates.append((match.group("timestamp"), path)) + if not candidates: + continue + timestamp, path = max(candidates, key=lambda item: item[0]) + band = Path(parent).name if parent else "" + selected_zip = SelectedZip( + scan_path=str(upload_root), + band=band, + remote_file=RemoteFileInfo( + path=str(path), + relative_path=str(path.relative_to(upload_root)).replace("\\", "/"), + parent=parent, + name=path.name, + size=path.stat().st_size, + ), + timestamp=timestamp, + ) + if not band: + self._log(f"未从目录名识别频段: {path.name}") + else: + self._log(f"{band}: {path.name}") + selected.append((selected_zip, path)) + return selected + + @staticmethod + def _download_result(): + from app.services.remote_download import RemoteDownloadResult + + return RemoteDownloadResult() + + def _parse_zip_files(self, local_files: list[tuple[SelectedZip, Path]], result: CellDataResult) -> list[dict[str, str]]: + mapping = self.config.mapping + key_config = mapping["key"] + key_field = str(key_config["field"]) + rows_by_key: dict[str, dict[str, str]] = {} + self._log(f"开始解压并解析 {len(local_files)} 个 ZIP 文件...") + for selected, local_path in local_files: + band_label = selected.band or "未识别频段" + size_kb = local_path.stat().st_size / 1024 if local_path.exists() else 0 + self._log(f"[{band_label}] 解压 {local_path.name}({size_kb:.0f} KB)...") + zip_parsed_before = result.parsed_rows + zip_skipped_before = result.skipped_rows + with zipfile.ZipFile(local_path) as zf: + sources = list(mapping["sources"]) + csv_entries = [info for info in zf.infolist() if Path(info.filename).name.lower().endswith(".csv")] + self._log(f" 压缩包内含 {len(csv_entries)} 个 CSV 文件") + for info in csv_entries: + name = Path(info.filename).name + matching_sources = [ + source + for source in sources + if name.startswith(source["file_prefix"]) and (not selected.band or source["band"] == selected.band) + ] + if not matching_sources: + self._log(f" 跳过未匹配规则的文件: {name}") + continue + if not selected.band and len(matching_sources) > 1: + self._log(f" 跳过无法识别频段的文件: {name}") + continue + for source in matching_sources: + raw = zf.read(info.filename) + text = self._decode_csv(raw) + reader = csv.DictReader(text.splitlines()) + added = 0 + skipped = 0 + for csv_row in reader: + row = self._map_row(source["fields"], csv_row) + key = self._render_expr(str(key_config["expr"]), row) + if not key or "--" in key: + result.skipped_rows += 1 + skipped += 1 + continue + row[key_field] = key + rows_by_key[key] = {column: row.get(column, "") for column in CELLINFO_COLUMNS} + result.parsed_rows += 1 + added += 1 + self._log(f" 解析 {name}(频段 {source.get('band', '') or '通用'}):有效 {added} 行,跳过 {skipped} 行") + self._log( + f"[{band_label}] {local_path.name} 解析完成:" + f"本包有效 {result.parsed_rows - zip_parsed_before} 行,跳过 {result.skipped_rows - zip_skipped_before} 行" + ) + self._log( + f"全部解析完成:累计有效 {result.parsed_rows} 行,按 {key_field} 去重后 {len(rows_by_key)} 行," + f"累计跳过 {result.skipped_rows} 行" + ) + return list(rows_by_key.values()) + + def _map_row(self, fields: dict[str, Any], csv_row: dict[str, str]) -> dict[str, str]: + row: dict[str, str] = {} + for target, rule in fields.items(): + if isinstance(rule, str): + row[target] = str(csv_row.get(rule, "") or "").strip() + elif isinstance(rule, dict) and "value" in rule: + row[target] = str(rule.get("value", "") or "").strip() + else: + row[target] = "" + return row + + @staticmethod + def _render_expr(expr: str, row: dict[str, str]) -> str: + def replace(match): + return row.get(match.group(1), "") + + return re.sub(r"\{([^{}]+)\}", replace, expr).strip() + + @staticmethod + def _decode_csv(raw: bytes) -> str: + candidates = ["utf-8-sig", "utf-8", "gb18030", "gbk"] + detected = (chardet.detect(raw).get("encoding") or "").lower() + if detected and detected not in candidates: + candidates.append(detected) + for encoding in dict.fromkeys(candidates): + try: + return raw.decode(encoding).lstrip("\ufeff") + except (LookupError, UnicodeDecodeError): + pass + return raw.decode("utf-8", errors="replace").lstrip("\ufeff") + + def _replace_cellinfo(self, rows: list[dict[str, str]]) -> int: + mysql = self.config.mysql.normalized() + table = str(self.config.mapping.get("target_table") or "cellinfo") + self._log(f"准备写入表 `{table}`(库 {mysql.dbname}@{mysql.host}:{mysql.port}),共 {len(rows)} 行") + conn = pymysql.connect( + host=mysql.host, + port=mysql.port, + user=mysql.user, + password=mysql.passwd, + database=mysql.dbname, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + self._log("已连接 CellData 数据库") + try: + with conn.cursor() as cursor: + self._ensure_cellinfo_table(cursor, table) + self._log(f"已确认表结构 `{table}`") + cursor.execute(f"TRUNCATE TABLE `{table}`") + self._log(f"已清空表 `{table}`(TRUNCATE)") + placeholders = ", ".join(["%s"] * len(CELLINFO_COLUMNS)) + columns = ", ".join(f"`{column}`" for column in CELLINFO_COLUMNS) + values = [tuple(row.get(column, "") for column in CELLINFO_COLUMNS) for row in rows] + total = len(values) + for start in range(0, total, 1000): + cursor.executemany( + f"INSERT INTO `{table}` ({columns}) VALUES ({placeholders})", + values[start:start + 1000], + ) + self._log(f"写入中 {min(start + 1000, total)}/{total} 行...") + conn.commit() + self._log(f"已提交,成功写入 {len(rows)} 行到 `{table}`") + return len(rows) + except Exception as exc: + conn.rollback() + self._log(f"写入失败,已回滚: {exc}") + raise + finally: + conn.close() + + @staticmethod + def _ensure_cellinfo_table(cursor, table: str) -> None: + cursor.execute( + f""" + CREATE TABLE IF NOT EXISTS `{table}` ( + `CGI` varchar(120) DEFAULT NULL, + `eNodeBID` int DEFAULT NULL, + `CellID` int DEFAULT NULL, + `PLMN` varchar(100) DEFAULT NULL, + `基站名称` varchar(200) DEFAULT NULL, + `小区名称` varchar(200) DEFAULT NULL, + `频点` varchar(50) DEFAULT NULL, + `带宽` varchar(20) DEFAULT NULL, + `制式` varchar(50) DEFAULT NULL, + `功率` varchar(100) DEFAULT NULL, + `网络` varchar(20) DEFAULT NULL, + KEY `CGI` (`CGI`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + + def _list_dir(self, remote_path: str) -> list[dict[str, Any]]: + if self.config.remote_data.protocol == "ftp": + with self.downloader._ftp_client() as ftp: # noqa: SLF001 + return [ + { + "name": name, + "type": "directory" if entry_type == "dir" else "file", + "size": size, + "path": self._join_remote_path(remote_path, name), + } + for name, entry_type, size in self.downloader._list_ftp_entries(ftp, remote_path) # noqa: SLF001 + if name not in {".", ".."} + ] + + ssh = self.downloader._sftp_ssh_client() # noqa: SLF001 + try: + with ssh.open_sftp() as sftp: + return [ + { + "name": item.filename, + "type": "directory" if stat.S_ISDIR(item.st_mode) else "file", + "size": int(getattr(item, "st_size", 0) or 0), + "path": self._join_remote_path(remote_path, item.filename), + } + for item in sftp.listdir_attr(remote_path) + if item.filename not in {".", ".."} + ] + finally: + ssh.close() + + def _remote_file_info(self, remote_path: str, size: int) -> RemoteFileInfo: + return self.downloader._remote_file_info(remote_path, size) # noqa: SLF001 + + @staticmethod + def _join_remote_path(parent: str, child: str) -> str: + parent = parent.replace("\\", "/").rstrip("/") + child = child.replace("\\", "/").strip("/") + if not parent: + return child + if parent == "/": + return f"/{child}" + return f"{parent}/{child}" + + def _set_stage(self, stage: str) -> None: + if hasattr(self.logger, "set_stage"): + self.logger.set_stage(stage) + + def _log(self, message: str) -> None: + if self.logger: + if hasattr(self.logger, "info"): + self.logger.info(message) + else: + self.logger(message) + + +def refresh_cell_data(app_config: AppConfig, work_dir: Path, logger=None) -> CellDataResult: + return CellDataProcessor(app_config, work_dir, logger).run() + + +def execute_celldata_script(script_path: Path, app_config: AppConfig, logger=None) -> None: + try: + from app.db_init import ensure_required_tables + ensure_required_tables(app_config, logger) + except Exception as exc: # noqa: BLE001 + if logger: + logger.info(f"[前置检查] 执行异常:{exc}") + if not script_path.exists(): + if logger: + logger.info("CellData 脚本文件不存在,跳过") + return + sql_text = script_path.read_text(encoding="utf-8").strip() + if not sql_text: + if logger: + logger.info("CellData 脚本为空,跳过") + return + + from app.processor import DataProcessor + + statements = DataProcessor.parse_sql_script(sql_text) + if not statements: + if logger: + logger.info("CellData 脚本中没有有效语句,跳过") + return + + mysql = app_config.cell_data.mysql.normalized() + conn = pymysql.connect( + host=mysql.host, + port=mysql.port, + user=mysql.user, + password=mysql.passwd, + database=mysql.dbname, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + try: + with conn.cursor() as cursor: + for i, sql in enumerate(statements, 1): + preview = sql[:80].replace("\n", " ") + if logger: + logger.info(f"CellData SQL ({i}/{len(statements)}): {preview}...") + cursor.execute(sql) + affected = cursor.rowcount if cursor.rowcount >= 0 else 0 + if affected > 0 and logger: + logger.info(f"完成,影响 {affected} 行") + conn.commit() + if logger: + logger.success(f"CellData 脚本执行完成,共 {len(statements)} 条语句") + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def copy_celldata_tables_to_capacity(app_config: AppConfig, logger=None) -> int: + cd_mysql = app_config.cell_data.mysql.normalized() + cap_mysql = app_config.mysql.normalized() + + if ( + cd_mysql.host == cap_mysql.host + and cd_mysql.port == cap_mysql.port + and cd_mysql.dbname == cap_mysql.dbname + ): + if logger: + logger.info("CellData 与容量数据库相同,跳过表复制") + return 0 + + cd_conn = pymysql.connect( + host=cd_mysql.host, port=cd_mysql.port, + user=cd_mysql.user, password=cd_mysql.passwd, + database=cd_mysql.dbname, charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + ) + cap_conn = pymysql.connect( + host=cap_mysql.host, port=cap_mysql.port, + user=cap_mysql.user, password=cap_mysql.passwd, + database=cap_mysql.dbname, charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + local_infile=True, autocommit=False, + ) + try: + tables = _list_tables(cd_conn) + if not tables: + if logger: + logger.info("CellData 数据库中没有表,跳过复制") + return 0 + + target_charset, target_collation = _database_charset_and_collation(cap_conn) + copied = 0 + for table in tables: + rows = _copy_one_table( + cd_conn, + cap_conn, + table, + target_charset, + target_collation, + logger, + ) + if rows >= 0: + copied += 1 + cap_conn.commit() + if logger: + logger.success(f"已将 {copied} 张表从 CellData 复制到容量数据库") + return copied + except Exception: + cap_conn.rollback() + raise + finally: + cd_conn.close() + cap_conn.close() + + +def _list_tables(conn) -> list[str]: + with conn.cursor() as cur: + cur.execute("SHOW TABLES") + return [list(row.values())[0] for row in cur.fetchall()] + + +def _database_charset_and_collation(conn) -> tuple[str, str]: + with conn.cursor() as cur: + cur.execute( + "SELECT @@character_set_database AS `charset_name`, " + "@@collation_database AS `collation_name`" + ) + row = cur.fetchone() + charset = str(row["charset_name"]) + collation = str(row["collation_name"]) + if not re.fullmatch(r"[A-Za-z0-9_]+", charset) or not re.fullmatch(r"[A-Za-z0-9_]+", collation): + raise RuntimeError("目标数据库字符集或排序规则名称无效") + return charset, collation + + +def _copy_one_table( + src_conn, + dst_conn, + table: str, + target_charset: str, + target_collation: str, + logger=None, +) -> int: + with src_conn.cursor() as cur: + cur.execute(f"SHOW CREATE TABLE `{table}`") + row = cur.fetchone() + create_sql = row.get("Create Table") or list(row.values())[1] + + with src_conn.cursor() as cur: + cur.execute(f"SELECT COUNT(*) AS `cnt` FROM `{table}`") + count = cur.fetchone()["cnt"] + + with dst_conn.cursor() as cur: + cur.execute(f"DROP TABLE IF EXISTS `{table}`") + cur.execute(create_sql) + cur.execute( + f"ALTER TABLE `{table}` CONVERT TO CHARACTER SET {target_charset} " + f"COLLATE {target_collation}" + ) + + if count == 0: + if logger: + logger.info(f"复制表 {table}: 0 行(空表)") + return 0 + + with src_conn.cursor() as src_cur: + src_cur.execute(f"SELECT * FROM `{table}`") + columns = [desc[0] for desc in src_cur.description] + col_list = ", ".join(f"`{c}`" for c in columns) + placeholders = ", ".join(["%s"] * len(columns)) + insert_sql = f"INSERT INTO `{table}` ({col_list}) VALUES ({placeholders})" + + batch: list[tuple] = [] + inserted = 0 + with dst_conn.cursor() as dst_cur: + for row in src_cur: + batch.append(tuple(row.values())) + if len(batch) >= 5000: + dst_cur.executemany(insert_sql, batch) + inserted += len(batch) + batch.clear() + if batch: + dst_cur.executemany(insert_sql, batch) + inserted += len(batch) + + if logger: + logger.info(f"复制表 {table}: {inserted} 行") + return inserted diff --git a/app/services/csv_processor.py b/app/services/csv_processor.py new file mode 100644 index 0000000..ee80e42 --- /dev/null +++ b/app/services/csv_processor.py @@ -0,0 +1,412 @@ +"""纯数据处理流水线(容器版,无数据库代码)。 + +输入:包含已下载周 ZIP/CSV/Excel 的工作目录。 +输出:每张暂存表一个规范化 CSV({表名: csv 路径});由平台数据库导入 API 建表入库, + 再由平台 run-script 跑报表 SQL 生成结果表。这里不含任何数据库/LOAD DATA 代码。 +""" +from __future__ import annotations + +import shutil +import zipfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Callable + +import chardet +import pandas as pd + +from app.utils.file_dates import select_recent_items_by_directory + +ZERO_TEXTS = {"", "-", "--", "—", "–", "NA", "N/A", "NULL", "NONE", "NAN", "\\N"} +DATETIME_FORMATS = [ + "ISO8601", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y/%m/%d %H:%M:%S", + "%Y/%m/%d %H:%M", + "%Y-%m-%d", + "%Y/%m/%d", + "%Y年%m月%d日 %H:%M:%S", + "%Y年%m月%d日", + "%Y%m%d%H%M%S", + "%Y%m%d", +] +MAX_WORKERS = 8 + + +class CsvProcessor: + def __init__(self, work_dir: Path, config: dict, log: Callable[[str], None]): + self.work_dir = Path(work_dir) + self.config = config + self.log = log + self.recent_days = int(config.get("recent_days", 7)) + self.sheet_filter = set(config.get("sheet_filter", [])) + self.directories = _build_directory_mappings(config.get("directories") or []) + self.field_map, self.type_map = _build_global_map(config.get("extract_fields", [])) + self.table_maps = _build_table_maps(config.get("table_field_mappings") or {}) + self.out_dir = self.work_dir / ".out" + + def process(self) -> dict[str, Path]: + self._unzip_files() + self._excel_to_csv() + return self._build_table_csvs() + + # --- step 1: unzip --------------------------------------------------- + def _unzip_files(self) -> None: + zips = self._filter_recent(list(self.work_dir.rglob("*.zip")), "ZIP") + self.log(f"解压 ZIP: {len(zips)} 个") + for zip_file in zips: + try: + _extract_zip(zip_file, self.log) + except Exception as exc: # noqa: BLE001 - keep going on a bad archive + self.log(f"[WARN] 解压失败 {zip_file.name}: {exc}") + + # --- step 2: excel -> csv ------------------------------------------- + def _excel_to_csv(self) -> None: + excels = self._filter_recent(list(self._scan(self.work_dir, (".xlsx", ".xls"))), "Excel") + if not excels: + return + self.log(f"Excel 转 CSV: {len(excels)} 个文件") + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: + futures = {pool.submit(self._one_excel, f): f for f in excels} + for future in as_completed(futures): + excel = futures[future] + try: + future.result() + except Exception as exc: # noqa: BLE001 + self.log(f"[WARN] Excel 处理失败 {excel.name}: {exc}") + + def _one_excel(self, excel_file: Path) -> None: + xl = pd.ExcelFile(excel_file, engine="openpyxl") + try: + for sheet in xl.sheet_names: + if sheet in self.sheet_filter: + continue + out = excel_file.parent / f"{excel_file.stem}_{sheet}.csv" + xl.parse(sheet).to_csv(out, index=False, encoding="utf-8") + finally: + xl.close() + + # --- step 3: build one normalized CSV per staging table ------------- + def _build_table_csvs(self) -> dict[str, Path]: + data_dirs = self._find_data_dirs() + if not data_dirs: + self.log("[WARN] 未找到任何数据目录") + return {} + self.out_dir.mkdir(parents=True, exist_ok=True) + result: dict[str, Path] = {} + for table, directories in data_dirs.items(): + csv_files: list[Path] = [] + for directory in directories: + csv_files.extend(self._filter_recent(list(self._scan(directory, (".csv",))), "CSV", root=directory)) + if not csv_files: + continue + field_map, type_map = self._maps_for_table(table) + out_path = self.out_dir / f"{table}.csv" + rows = self._write_table_csv(table, csv_files, field_map, type_map, out_path) + if rows > 0: + result[table] = out_path + self.log(f"暂存表 {table}: {rows} 行 -> {out_path.name}") + return result + + def _write_table_csv(self, table, csv_files, field_map, type_map, out_path: Path) -> int: + # First pass: union of target columns across this table's CSV files. + union: list[str] = [] + seen: set[str] = set() + frames: list[tuple[Path, list[str]]] = [] + for csv_file in csv_files: + headers = _read_headers(csv_file) + targets = _ordered_targets(headers, field_map) + if not targets: + continue + frames.append((csv_file, headers)) + for target in targets: + if target not in seen: + seen.add(target) + union.append(target) + if not union: + return 0 + + total = 0 + header_written = False + for csv_file, _headers in frames: + df = self._normalize(csv_file, field_map, type_map, union) + if df is None or df.empty: + continue + df.to_csv(out_path, index=False, header=not header_written, mode="w" if not header_written else "a", encoding="utf-8") + header_written = True + total += len(df) + return total + + def _normalize(self, csv_file: Path, field_map, type_map, union: list[str]): + try: + df = pd.read_csv( + csv_file, + encoding=_detect_encoding(csv_file), + dtype=str, + na_values=[""], + keep_default_na=False, + low_memory=True, + ) + except Exception as exc: # noqa: BLE001 + self.log(f"[WARN] 读取 CSV 失败 {csv_file.name}: {exc}") + return None + + col_map: dict[str, str] = {} + mapped: set[str] = set() + for col in df.columns: + target = field_map.get(col) + if target and target not in mapped: + col_map[col] = target + mapped.add(target) + if not col_map: + return None + + out = df[list(col_map.keys())].copy() + out.columns = list(col_map.values()) + out = out.fillna("") + for col in out.columns: + col_type = type_map.get(col, "string") + if col_type == "datetime": + out[col] = _convert_datetime(out[col]) + elif col_type == "int": + out[col] = _convert_int(out[col]) + elif col_type == "float": + out[col] = _convert_float(out[col]) + else: + out[col] = out[col].astype("string").str.replace("%", "", regex=False).str.slice(0, 255) + # Reindex to the shared union columns; fill missing per type so numeric + # staging columns never carry '' (the report SQL re-types them later). + for col in union: + if col not in out.columns: + out[col] = "0" if type_map.get(col) in ("int", "float") else "" + return out[union] + + # --- directory detection -------------------------------------------- + def _find_data_dirs(self) -> dict[str, list[Path]]: + data_dirs: dict[str, list[Path]] = {} + for item in self.directories: + directory = self.work_dir / item["path"] + if directory.exists() and directory.is_dir(): + _add_data_dir(data_dirs, item["table"], directory) + return data_dirs + + def _maps_for_table(self, table: str): + if table in self.table_maps: + return self.table_maps[table] + return self.field_map, self.type_map + + # --- helpers --------------------------------------------------------- + def _scan(self, directory: Path, extensions: tuple[str, ...]): + for ext in extensions: + yield from directory.rglob(f"*{ext}") + + def _filter_recent(self, files: list[Path], label: str, root: Path | None = None) -> list[Path]: + if not files: + return files + base = (root or self.work_dir).resolve() + + def parent_key(file_path: Path) -> str: + try: + parent = file_path.parent.resolve().relative_to(base) + except ValueError: + parent = file_path.parent + text = str(parent).replace("\\", "/") + return "" if text == "." else text + + selected, summaries = select_recent_items_by_directory( + files, + parent_key=parent_key, + name_key=lambda f: f.name, + days=self.recent_days, + ) + for summary in summaries: + if summary.skipped_count and summary.start_date and summary.max_date: + self.log( + f"{label} {summary.directory or '.'}: 取 {summary.start_date}~{summary.max_date} " + f"{summary.selected_count}/{summary.total_count},跳过 {summary.skipped_count} 个旧文件" + ) + return sorted(selected) + + +def _build_global_map(extract_fields: list[dict]) -> tuple[dict[str, str], dict[str, str]]: + field_map: dict[str, str] = {} + type_map: dict[str, str] = {} + for field in extract_fields: + target = field.get("Field") + if not target: + continue + type_map[target] = field.get("Type", "string") + for source in field.get("Extract", []): + field_map[source] = target + return field_map, type_map + + +def _build_directory_mappings(items: list[dict]) -> list[dict[str, str]]: + mappings: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for item in items: + if not isinstance(item, dict): + continue + path = str(item.get("path", "")).replace("\\", "/").strip().strip("/") + table = str(item.get("table", "")).strip() + if not path or not table: + continue + key = (path, table) + if key in seen: + continue + seen.add(key) + mappings.append({"path": path, "table": table}) + return mappings + + +def _add_data_dir(data_dirs: dict[str, list[Path]], table: str, directory: Path) -> None: + existing = data_dirs.setdefault(table, []) + resolved = directory.resolve() + if all(path.resolve() != resolved for path in existing): + existing.append(directory) + + +def _build_table_maps(table_field_mappings: dict) -> dict[str, tuple[dict[str, str], dict[str, str]]]: + maps: dict[str, tuple[dict[str, str], dict[str, str]]] = {} + for table, fields in table_field_mappings.items(): + field_map: dict[str, str] = {} + type_map: dict[str, str] = {} + for field in fields: + source = field.get("Source") + target = field.get("Target") + if source and target: + field_map[source] = target + type_map[target] = field.get("Type", "string") + maps[table] = (field_map, type_map) + return maps + + +def _ordered_targets(headers: list[str], field_map: dict[str, str]) -> list[str]: + targets: list[str] = [] + seen: set[str] = set() + for col in headers: + target = field_map.get(col) + if target and target not in seen: + seen.add(target) + targets.append(target) + return targets + + +def _read_headers(csv_file: Path) -> list[str]: + try: + df = pd.read_csv(csv_file, encoding=_detect_encoding(csv_file), nrows=0, dtype=str) + return list(df.columns) + except Exception: # noqa: BLE001 + return [] + + +def _detect_encoding(file_path: Path) -> str: + with open(file_path, "rb") as handle: + result = chardet.detect(handle.read(8192)) + encoding = (result.get("encoding") or "utf-8").lower() + if "utf" in encoding: + return "utf-8" + if "gb" in encoding: + return "gbk" + return "utf-8" + + +def _extract_zip(zip_file: Path, log: Callable[[str], None]) -> None: + for enc in ("utf-8", "gbk", "cp437"): + try: + with zipfile.ZipFile(zip_file, "r", metadata_encoding=enc) as zf: + _extract_members(zf, zip_file.parent, log) + return + except (UnicodeDecodeError, zipfile.BadZipFile): + continue + raise RuntimeError("无法解压(编码检测失败)") + + +def _extract_members(zf: zipfile.ZipFile, target_dir: Path, log: Callable[[str], None]) -> None: + root = target_dir.resolve() + for member in zf.infolist(): + name = member.filename.replace("\\", "/") + target = (root / name).resolve() + try: + target.relative_to(root) + except ValueError: + log(f"[WARN] 跳过不安全的 ZIP 条目: {member.filename}") + continue + if member.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with zf.open(member) as source, target.open("wb") as out: + shutil.copyfileobj(source, out) + + +def _clean_numeric_text(series: pd.Series) -> pd.Series: + return ( + series.str.strip() + .str.replace(",", "", regex=False) + .str.replace(",", "", regex=False) + .str.replace("%", "", regex=False) + .str.replace("%", "", regex=False) + .str.replace("\t", "", regex=False) + .str.replace(" ", "", regex=False) + ) + + +def _numeric_series(series: pd.Series) -> pd.Series: + text = series.astype("string") + has_percent = text.str.contains(r"[%%]", regex=True, na=False) + cleaned = _clean_numeric_text(text) + zero_mask = cleaned.isna() | cleaned.str.upper().isin(ZERO_TEXTS) + numeric = pd.to_numeric(cleaned.mask(zero_mask, "0"), errors="coerce").fillna(0) + numeric[has_percent & numeric.notna()] = numeric[has_percent & numeric.notna()] / 100 + return numeric + + +def _convert_int(series: pd.Series) -> pd.Series: + try: + rounded = _numeric_series(series).round() + return pd.Series([int(v) for v in rounded], index=series.index, dtype=object) + except Exception: # noqa: BLE001 + return series + + +def _convert_float(series: pd.Series) -> pd.Series: + try: + numeric = _numeric_series(series) + return pd.Series([float(v) for v in numeric], index=series.index, dtype=object) + except Exception: # noqa: BLE001 + return series + + +def _convert_datetime(series: pd.Series) -> pd.Series: + try: + valid = series.notna() & (series != "") & (series.astype(str).str.strip() != "") + if not valid.any(): + return pd.Series([None] * len(series), index=series.index) + parsed = pd.Series([pd.NaT] * len(series), index=series.index) + remaining = valid.copy() + for fmt in DATETIME_FORMATS: + if not remaining.any(): + break + try: + temp = pd.to_datetime(series[remaining], errors="coerce", format=fmt) + except Exception: # noqa: BLE001 + continue + ok = temp.notna() + if ok.any(): + idx = remaining[remaining].index[ok] + parsed.loc[idx] = temp[ok].values + remaining.loc[idx] = False + if remaining.any(): + try: + temp = pd.to_datetime(series[remaining], errors="coerce", format="mixed", dayfirst=False) + ok = temp.notna() + if ok.any(): + idx = remaining[remaining].index[ok] + parsed.loc[idx] = temp[ok].values + except Exception: # noqa: BLE001 + pass + return parsed.dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: # noqa: BLE001 + return series diff --git a/app/services/license.py b/app/services/license.py new file mode 100644 index 0000000..6bf2b96 --- /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, 12, 30) +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/app/services/pipeline.py b/app/services/pipeline.py new file mode 100644 index 0000000..1aee6e4 --- /dev/null +++ b/app/services/pipeline.py @@ -0,0 +1,103 @@ +"""Metrix 仓库模式的处理流水线:CSV 处理 → 平台导入暂存表 → run-script(single_session) 跑报表 SQL。 + +仅当 warehouse_type == "metrix" 时使用;直连 MySQL 模式走原版 DataProcessor。 +""" +from __future__ import annotations + +from pathlib import Path + +from app.config import SQL_SCRIPT, AppConfig, MetrixConfig +from app.processor import ProcessLogger +from app.services.csv_processor import CsvProcessor +from app.services.platform import make_client + +RESULT_TABLES = ["4G_结果表", "5G_结果表"] + + +def validate_metrix(metrix: MetrixConfig) -> None: + missing = [] + if not metrix.base_url: + missing.append("平台地址") + if not metrix.token: + missing.append("API Token") + if not metrix.database_conn_id: + missing.append("数据库连接 ID") + if missing: + raise RuntimeError("Metrix 连接配置不完整: " + ", ".join(missing)) + + +def build_processor_config(app_config: AppConfig) -> dict: + metrix = app_config.metrix.normalized() + mappings = app_config.data_mappings.normalized() + return { + "recent_days": metrix.recent_days, + "sheet_filter": list(app_config.sheet_filter), + "directories": list(mappings.directories), + "extract_fields": app_config.extract_fields, + "table_field_mappings": mappings.table_field_mappings, + } + + +def read_report_sql() -> str: + if not SQL_SCRIPT.exists(): + return "" + return SQL_SCRIPT.read_text(encoding="utf-8").strip() + + +def run_report_sql(app_config: AppConfig, logger: ProcessLogger) -> list[dict]: + metrix = app_config.metrix.normalized() + validate_metrix(metrix) + report_sql = read_report_sql() + if not report_sql: + raise RuntimeError("报表 SQL(ReportScript.sql)为空或不存在") + client = make_client(metrix) + logger.info("执行报表 SQL(single_session)...") + result = client.run_script( + metrix.database_conn_id, + content=report_sql, + database=metrix.target_database, + single_session=True, + run_timeout=7200, + ) + statements = result.get("results", []) + failed = [item for item in statements if not item.get("ok")] + if result.get("stopped") or failed: + for item in failed[:5]: + logger.error(f"[SQL] 第 {item.get('index')} 条失败: {item.get('message')}") + raise RuntimeError("报表 SQL 执行失败") + logger.success(f"报表 SQL 执行完成,共 {len(statements)} 条语句") + return statements + + +def run_import_and_report(work_dir: Path, app_config: AppConfig, logger: ProcessLogger) -> dict: + """处理工作目录数据 → 平台导入暂存表 → 跑报表 SQL。失败抛 RuntimeError。""" + metrix = app_config.metrix.normalized() + validate_metrix(metrix) + + logger.set_stage("converting") + tables = CsvProcessor(work_dir, build_processor_config(app_config), logger.info).process() + if not tables: + raise RuntimeError("处理后没有产出任何暂存表数据") + + client = make_client(metrix) + conn_id = metrix.database_conn_id + target_db = metrix.target_database + + # 导入前 DROP 旧暂存表,让自动建表按当周实际列重建。 + logger.set_stage("importing") + drop_sql = "".join(f"DROP TABLE IF EXISTS `{table}`;\n" for table in tables) + drop_result = client.run_script(conn_id, content=drop_sql, database=target_db, run_timeout=600) + if drop_result.get("stopped"): + raise RuntimeError("清理旧暂存表失败") + + for table, csv_path in tables.items(): + logger.info(f"导入暂存表 {table} ...") + job_id = client.import_csv(conn_id, table, csv_path, mode="overwrite", database=target_db, create_table=True) + job = client.wait_job(job_id) + if job.get("status") != "success": + raise RuntimeError(f"暂存表 {table} 导入失败: {job.get('error_code') or job.get('status')}") + logger.success(f"暂存表 {table} 导入完成") + + logger.set_stage("scripting") + statements = run_report_sql(app_config, logger) + return {"tables": list(tables.keys()), "statements": len(statements)} diff --git a/app/services/platform.py b/app/services/platform.py new file mode 100644 index 0000000..48e7d60 --- /dev/null +++ b/app/services/platform.py @@ -0,0 +1,312 @@ +"""Metrix 平台集成:API 客户端 + 储存下载器。 + +当 source_type/warehouse_type 选 "metrix" 时,源数据走平台储存模块、数据仓库走平台数据库模块。 +连接信息(地址/token/storage_id/database_conn_id/target_database)来自 Configure.json 的 Metrix 段。 +储存下载器与 RemoteDataDownloader 接口一致,可被源工厂直接替换。 +""" +from __future__ import annotations + +import time +from datetime import date +from pathlib import Path +from typing import Iterable + +import requests + +from app.config import AppConfig, MetrixConfig +from app.services.remote_download import RemoteDownloadResult, RemoteFileInfo +from app.utils.file_dates import parse_file_date_range, select_recent_items_by_directory + + +class PlatformClient: + """平台储存 + 数据库模块的最小 API 封装(Bearer Token 鉴权)。""" + + def __init__(self, base_url: str, token: str, timeout: int = 60): + if not base_url: + raise ValueError("缺少平台地址,请在系统设置的 Metrix 连接中填写") + if not token: + raise ValueError("缺少平台 API Token,请在系统设置的 Metrix 连接中填写") + self.base = base_url.rstrip("/") + self.timeout = timeout + self.session = requests.Session() + self.session.headers["Authorization"] = f"Bearer {token}" + + # --- 储存模块 -------------------------------------------------------- + def list_storage_files(self, storage_id: str, path: str = "/", recursive: bool = True) -> list[dict]: + resp = self.session.get( + f"{self.base}/api/storages/{storage_id}/files", + params={"path": path, "recursive": "true" if recursive else "false"}, + timeout=self.timeout, + ) + resp.raise_for_status() + return resp.json().get("entries", []) + + def download_storage_file(self, storage_id: str, path: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + with self.session.get( + f"{self.base}/api/storages/{storage_id}/download", + params={"path": path}, + stream=True, + timeout=self.timeout, + ) as resp: + resp.raise_for_status() + with dest.open("wb") as handle: + for chunk in resp.iter_content(chunk_size=1024 * 64): + if chunk: + handle.write(chunk) + + def batch_delete_storage(self, storage_id: str, paths: list[str]) -> int: + deleted = 0 + for start in range(0, len(paths), 100): + chunk = [p for p in paths[start:start + 100] if p] + if not chunk: + continue + resp = self.session.post( + f"{self.base}/api/storages/{storage_id}/batch-delete", + json={"paths": chunk}, + timeout=self.timeout, + ) + resp.raise_for_status() + deleted += len(chunk) + return deleted + + # --- 数据库模块 ------------------------------------------------------ + def import_csv(self, conn_id: str, table: str, csv_path: Path, mode: str = "overwrite", + database: str = "", create_table: bool = True, upload_timeout: int = 1800) -> str: + with csv_path.open("rb") as handle: + resp = self.session.post( + f"{self.base}/api/databases/{conn_id}/import", + files={"file": (csv_path.name, handle, "text/csv")}, + data={ + "format": "csv", + "target_table": table, + "mode": mode, + "database": database, + "mapping": "{}", + "create_table": "true" if create_table else "false", + }, + timeout=upload_timeout, + ) + resp.raise_for_status() + return resp.json()["job_id"] + + def wait_job(self, job_id: str, interval: int = 2, max_wait: int = 7200) -> dict: + deadline = time.time() + max_wait + while time.time() < deadline: + resp = self.session.get( + f"{self.base}/api/database-transfer-jobs/{job_id}", timeout=self.timeout + ) + resp.raise_for_status() + job = resp.json() + if job.get("status") in ("success", "failed"): + return job + time.sleep(interval) + raise TimeoutError(f"导入任务 {job_id} 超过 {max_wait}s 仍未完成") + + def run_script(self, conn_id: str, content: str = "", + database: str = "", single_session: bool = False, run_timeout: int = 7200) -> dict: + # 始终按传入的 SQL 文本执行(来自本地 ReportScript.sql),不走 Metrix 库内脚本(script_id)那条路。 + body: dict = {"content": content, "database": database, "stop_on_error": True, "single_session": single_session} + resp = self.session.post( + f"{self.base}/api/databases/{conn_id}/run-script", + json=body, + timeout=run_timeout, + ) + resp.raise_for_status() + return resp.json() + + # --- 数据库读 / 导出(供仓库代理使用)------------------------------- + def list_tables(self, conn_id: str, database: str = "") -> list[str]: + resp = self.session.get( + f"{self.base}/api/databases/{conn_id}/tables", + params={"database": database}, + timeout=self.timeout, + ) + resp.raise_for_status() + return [str(item.get("name")) for item in resp.json() if item.get("name")] + + def table_columns(self, conn_id: str, table: str, database: str = "") -> list[dict]: + resp = self.session.get( + f"{self.base}/api/databases/{conn_id}/tables/{table}", + params={"database": database}, + timeout=self.timeout, + ) + resp.raise_for_status() + return resp.json().get("columns", []) + + def table_data(self, conn_id: str, table: str, database: str = "", page: int = 1, page_size: int = 50, + order_by: str = "", order_dir: str = "asc") -> dict: + params = {"database": database, "table": table, "page": page, "page_size": page_size} + if order_by: + params["order_by"] = order_by + params["order_dir"] = "desc" if str(order_dir).lower().startswith("desc") else "asc" + resp = self.session.get( + f"{self.base}/api/databases/{conn_id}/table-data", params=params, timeout=self.timeout + ) + resp.raise_for_status() + return resp.json() + + def submit_export(self, conn_id: str, tables: list[str], fmt: str, database: str = "") -> str: + resp = self.session.post( + f"{self.base}/api/databases/{conn_id}/export", + json={"format": fmt, "database": database, "tables": tables}, + timeout=self.timeout, + ) + resp.raise_for_status() + return resp.json()["job_id"] + + def download_job_file(self, job_id: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + with self.session.get( + f"{self.base}/api/database-transfer-jobs/{job_id}/download", stream=True, timeout=self.timeout + ) as resp: + resp.raise_for_status() + with dest.open("wb") as handle: + for chunk in resp.iter_content(chunk_size=1024 * 64): + if chunk: + handle.write(chunk) + + +def make_client(metrix: MetrixConfig) -> PlatformClient: + metrix = metrix.normalized() + return PlatformClient(metrix.base_url, metrix.token) + + +def make_source_downloader(app_config: AppConfig, logger=None): + """Return a file-source downloader matching app_config.source_type. FTP/SFTP use the + original RemoteDataDownloader; 'metrix' uses PlatformStorageDownloader. Both share the + interface test_connection / list_remote_zip_files / download_to / delete_source_files.""" + if app_config.source_type == "metrix": + return PlatformStorageDownloader(app_config, logger) + from app.services.remote_download import RemoteDataDownloader + + return RemoteDataDownloader(app_config.remote_data, logger) + + +class PlatformStorageDownloader: + """平台储存版下载器,接口与 RemoteDataDownloader 对齐,可被源工厂直接替换。""" + + def __init__(self, app_config: AppConfig, logger=None): + self.metrix = app_config.metrix.normalized() + self.remote_dir = (app_config.remote_data.remote_dir or "/").strip() or "/" + self.logger = logger + self.client = make_client(self.metrix) + + def _log(self, message: str) -> None: + if self.logger: + self.logger(message) + + def test_connection(self) -> None: + if not self.metrix.storage_id: + raise ValueError("缺少储存连接 ID,请在系统设置的 Metrix 连接中填写") + self.client.list_storage_files(self.metrix.storage_id, self.remote_dir, recursive=False) + + def list_remote_zip_files(self, directory: str | None = None) -> list[RemoteFileInfo]: + path = self._join(self.remote_dir, directory.strip("/")) if directory else self.remote_dir + entries = self.client.list_storage_files(self.metrix.storage_id, path, recursive=True) + files: list[RemoteFileInfo] = [] + for entry in entries: + if entry.get("is_dir"): + continue + name = str(entry.get("name", "")) + if not name.lower().endswith(".zip"): + continue + files.append(self._info(str(entry.get("path", "")), int(entry.get("size", 0) or 0))) + return files + + def download_to(self, destination: Path, target_dates: Iterable[date] | None = None) -> RemoteDownloadResult: + destination = Path(destination) + destination.mkdir(parents=True, exist_ok=True) + zip_files = self.list_remote_zip_files() + date_filter = set(target_dates or []) + + if date_filter: + selected = self._select_by_dates(zip_files, date_filter) + elif zip_files: + selected, summaries = select_recent_items_by_directory( + zip_files, + parent_key=lambda item: item.parent, + name_key=lambda item: item.name, + ) + for summary in summaries: + if summary.skipped_count and summary.start_date and summary.max_date: + self._log( + f"储存目录 {summary.directory or '.'}: 仅下载 " + f"{summary.start_date.isoformat()} 至 {summary.max_date.isoformat()} 的 " + f"{summary.selected_count}/{summary.total_count} 个 ZIP,跳过 {summary.skipped_count} 个旧文件" + ) + else: + selected = [] + + result = RemoteDownloadResult() + for remote_file in selected: + dest = destination / remote_file.relative_path + self._log(f"下载: {remote_file.relative_path}") + self.client.download_storage_file(self.metrix.storage_id, remote_file.path, dest) + result.file_count += 1 + result.total_bytes += remote_file.size or (dest.stat().st_size if dest.exists() else 0) + result.remote_files.append(remote_file.path) + return result + + def delete_source_files(self, remote_files: Iterable[str] | None = None) -> int: + files = [path for path in (remote_files or []) if path] + if not files: + return 0 + self._log(f"清理储存源文件,共 {len(files)} 个") + return self.client.batch_delete_storage(self.metrix.storage_id, files) + + # --- helpers --------------------------------------------------------- + def _select_by_dates(self, zip_files: list[RemoteFileInfo], target_dates: set[date]) -> list[RemoteFileInfo]: + grouped: dict[str, list[RemoteFileInfo]] = {} + for remote_file in zip_files: + grouped.setdefault(remote_file.parent, []).append(remote_file) + + selected: list[RemoteFileInfo] = [] + for parent, files in sorted(grouped.items(), key=lambda item: item[0]): + picked = [ + remote_file + for remote_file in files + if (date_range := parse_file_date_range(remote_file.name)) + and ( + date_range.covers_all(target_dates) + if date_range.span_days > 1 + else date_range.covers_any(target_dates) + ) + ] + selected.extend(picked) + skipped = len(files) - len(picked) + if skipped: + self._log( + f"储存目录 {parent or '.'}: 仅下载目标日期 " + f"{min(target_dates).isoformat()} 至 {max(target_dates).isoformat()} 的 " + f"{len(picked)}/{len(files)} 个 ZIP,跳过 {skipped} 个非目标文件" + ) + return selected + + @staticmethod + def _join(parent: str, child: str) -> str: + parent = (parent or "").replace("\\", "/").rstrip("/") + if not parent: + return child + if parent == "/": + return f"/{child}" + return f"{parent}/{child}" + + def _info(self, remote_path: str, size: int = 0) -> RemoteFileInfo: + normalized_root = self.remote_dir.replace("\\", "/").rstrip("/") + normalized_path = remote_path.replace("\\", "/") + if normalized_root and normalized_root != "/" and normalized_path.startswith(f"{normalized_root}/"): + relative_path = normalized_path[len(normalized_root) + 1:] + else: + relative_path = normalized_path.lstrip("/") + relative = Path(relative_path) + parent = str(relative.parent).replace("\\", "/") + if parent == ".": + parent = "" + return RemoteFileInfo( + path=remote_path, + relative_path=relative_path, + parent=parent, + name=relative.name, + size=size, + ) diff --git a/app/services/remote_download.py b/app/services/remote_download.py new file mode 100644 index 0000000..cf8fa03 --- /dev/null +++ b/app/services/remote_download.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import stat +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date +from ftplib import FTP +from pathlib import Path +from typing import Callable, Iterable + +from app.config import RemoteDataConfig +from app.utils.file_dates import parse_file_date_range, select_recent_items_by_directory + + +LogFn = Callable[[str], None] + + +@dataclass +class RemoteDownloadResult: + file_count: int = 0 + total_bytes: int = 0 + remote_files: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class RemoteFileInfo: + path: str + relative_path: str + parent: str + name: str + size: int = 0 + + +class RemoteDownloadError(RuntimeError): + pass + + +class RemoteDataDownloader: + def __init__(self, config: RemoteDataConfig, logger: LogFn | None = None): + self.config = config.normalized() + self.logger = logger + + def test_connection(self) -> None: + self._validate_config() + if self.config.protocol == "ftp": + with self._ftp_client() as ftp: + ftp.cwd(self.config.remote_dir) + return + + ssh = self._sftp_ssh_client() + try: + with ssh.open_sftp() as sftp: + sftp.stat(self.config.remote_dir) + finally: + ssh.close() + + def download_to(self, destination: Path, target_dates: Iterable[date] | None = None) -> RemoteDownloadResult: + self._validate_config() + destination.mkdir(parents=True, exist_ok=True) + zip_files = self.list_remote_zip_files() + date_filter = set(target_dates or []) + if date_filter: + selected_files = self._select_files_by_dates(zip_files, date_filter) + return self._download_selected_files(destination, selected_files) + + if zip_files: + selected_files, summaries = select_recent_items_by_directory( + zip_files, + parent_key=lambda item: item.parent, + name_key=lambda item: item.name, + ) + for summary in summaries: + if summary.max_date and summary.start_date and summary.skipped_count: + self._log( + f"远程目录 {summary.directory or '.'}: 仅下载 " + f"{summary.start_date.isoformat()} 至 {summary.max_date.isoformat()} " + f"的 {summary.selected_count}/{summary.total_count} 个 ZIP 文件," + f"跳过 {summary.skipped_count} 个旧文件" + ) + return self._download_selected_files(destination, selected_files) + + if self.config.protocol == "ftp": + return self._download_ftp(destination) + return self._download_sftp(destination) + + def _select_files_by_dates(self, zip_files: list[RemoteFileInfo], target_dates: set[date]) -> list[RemoteFileInfo]: + selected_files: list[RemoteFileInfo] = [] + grouped: dict[str, list[RemoteFileInfo]] = defaultdict(list) + for remote_file in zip_files: + grouped[remote_file.parent].append(remote_file) + + for parent, files in sorted(grouped.items(), key=lambda item: item[0]): + selected = [ + remote_file + for remote_file in files + if ( + date_range := parse_file_date_range(remote_file.name) + ) and ( + date_range.covers_all(target_dates) + if date_range.span_days > 1 + else date_range.covers_any(target_dates) + ) + ] + selected_files.extend(selected) + skipped_count = len(files) - len(selected) + if skipped_count: + first_day = min(target_dates).isoformat() + last_day = max(target_dates).isoformat() + self._log( + f"远程目录 {parent or '.'}: 仅下载调度目标日期 " + f"{first_day} 至 {last_day} 的 {len(selected)}/{len(files)} 个 ZIP 文件," + f"跳过 {skipped_count} 个非目标日期文件" + ) + + return selected_files + + def list_remote_zip_files(self, directory: str | None = None) -> list[RemoteFileInfo]: + self._validate_config() + remote_dir = ( + self._join_remote_path(self.config.remote_dir, directory.strip("/")) + if directory + else self.config.remote_dir + ) + if self.config.protocol == "ftp": + return self._list_ftp_zip_files(remote_dir) + return self._list_sftp_zip_files(remote_dir) + + def delete_source_files(self, remote_files: Iterable[str] | None = None) -> int: + self._validate_config() + source_files = list(remote_files or []) + if source_files: + if self.config.protocol == "ftp": + return self._delete_ftp_file_paths(source_files) + return self._delete_sftp_file_paths(source_files) + + if self.config.protocol == "ftp": + return self._delete_ftp_source_files() + return self._delete_sftp_source_files() + + def _validate_config(self) -> None: + if self.config.protocol not in {"ftp", "sftp"}: + raise RemoteDownloadError("远程协议只支持 FTP 或 SFTP") + if not self.config.host: + raise RemoteDownloadError("请填写远程服务器地址") + if not self.config.user: + raise RemoteDownloadError("请填写远程服务器用户名") + if not self.config.remote_dir: + raise RemoteDownloadError("请填写远程数据目录") + + def _log(self, message: str) -> None: + if self.logger: + self.logger(message) + + def _ftp_client(self) -> FTP: + ftp = FTP() + ftp.connect(self.config.host, self.config.port, timeout=self.config.timeout) + ftp.login(self.config.user, self.config.passwd) + ftp.set_pasv(self.config.passive) + return ftp + + def _download_ftp(self, destination: Path) -> RemoteDownloadResult: + result = RemoteDownloadResult() + with self._ftp_client() as ftp: + self._log(f"已连接 FTP: {self.config.host}:{self.config.port}") + self._download_ftp_dir(ftp, self.config.remote_dir, destination, result) + return result + + def _download_ftp_dir( + self, + ftp: FTP, + remote_dir: str, + local_dir: Path, + result: RemoteDownloadResult, + ) -> None: + local_dir.mkdir(parents=True, exist_ok=True) + entries = self._list_ftp_entries(ftp, remote_dir) + + for name, entry_type, size in entries: + if name in {".", ".."}: + continue + + remote_path = self._join_remote_path(remote_dir, name) + local_path = local_dir / name + + if entry_type == "dir": + self._download_ftp_dir(ftp, remote_path, local_path, result) + continue + + if entry_type == "unknown" and self._ftp_is_dir(ftp, remote_path): + self._download_ftp_dir(ftp, remote_path, local_path, result) + continue + + self._download_ftp_file(ftp, remote_path, local_path, result, size) + + def _list_ftp_entries(self, ftp: FTP, remote_dir: str) -> list[tuple[str, str, int]]: + try: + return [ + ( + name, + facts.get("type", "unknown"), + int(facts.get("size", "0") or 0), + ) + for name, facts in ftp.mlsd(remote_dir) + ] + except Exception: + names = ftp.nlst(remote_dir) + entries = [] + for name in names: + clean_name = Path(name.replace("\\", "/")).name + if clean_name: + entries.append((clean_name, "unknown", 0)) + return entries + + def _ftp_is_dir(self, ftp: FTP, remote_path: str) -> bool: + current = ftp.pwd() + try: + ftp.cwd(remote_path) + return True + except Exception: + return False + finally: + try: + ftp.cwd(current) + except Exception: + pass + + def _download_ftp_file( + self, + ftp: FTP, + remote_path: str, + local_path: Path, + result: RemoteDownloadResult, + expected_size: int = 0, + ) -> None: + local_path.parent.mkdir(parents=True, exist_ok=True) + self._log(f"下载: {remote_path}") + with local_path.open("wb") as file: + ftp.retrbinary(f"RETR {remote_path}", file.write) + result.file_count += 1 + result.total_bytes += expected_size or local_path.stat().st_size + result.remote_files.append(remote_path) + + def _download_selected_files(self, destination: Path, remote_files: list[RemoteFileInfo]) -> RemoteDownloadResult: + result = RemoteDownloadResult() + if not remote_files: + return result + + if self.config.protocol == "ftp": + with self._ftp_client() as ftp: + self._log(f"已连接 FTP: {self.config.host}:{self.config.port}") + for remote_file in remote_files: + self._download_ftp_file( + ftp, + remote_file.path, + destination / remote_file.relative_path, + result, + remote_file.size, + ) + return result + + ssh = self._sftp_ssh_client() + try: + with ssh.open_sftp() as sftp: + self._log(f"已连接 SFTP: {self.config.host}:{self.config.port}") + for remote_file in remote_files: + self._download_sftp_file( + sftp, + remote_file.path, + destination / remote_file.relative_path, + result, + remote_file.size, + ) + finally: + ssh.close() + return result + + def _list_ftp_zip_files(self, remote_dir: str) -> list[RemoteFileInfo]: + with self._ftp_client() as ftp: + return self._collect_ftp_zip_files(ftp, remote_dir) + + def _collect_ftp_zip_files(self, ftp: FTP, remote_dir: str) -> list[RemoteFileInfo]: + files: list[RemoteFileInfo] = [] + for name, entry_type, size in self._list_ftp_entries(ftp, remote_dir): + if name in {".", ".."}: + continue + + remote_path = self._join_remote_path(remote_dir, name) + if entry_type == "dir" or (entry_type == "unknown" and self._ftp_is_dir(ftp, remote_path)): + files.extend(self._collect_ftp_zip_files(ftp, remote_path)) + continue + + if name.lower().endswith(".zip"): + files.append(self._remote_file_info(remote_path, size)) + return files + + def _delete_ftp_source_files(self) -> int: + with self._ftp_client() as ftp: + self._log(f"开始清理 FTP 源文件: {self.config.remote_dir}") + return self._delete_ftp_files(ftp, self.config.remote_dir) + + def _delete_ftp_files(self, ftp: FTP, remote_dir: str) -> int: + deleted_count = 0 + for name, entry_type, _ in self._list_ftp_entries(ftp, remote_dir): + if name in {".", ".."}: + continue + + remote_path = self._join_remote_path(remote_dir, name) + if entry_type == "dir" or (entry_type == "unknown" and self._ftp_is_dir(ftp, remote_path)): + deleted_count += self._delete_ftp_files(ftp, remote_path) + continue + + self._log(f"删除远程文件: {remote_path}") + ftp.delete(remote_path) + deleted_count += 1 + return deleted_count + + def _delete_ftp_file_paths(self, remote_files: list[str]) -> int: + deleted_count = 0 + with self._ftp_client() as ftp: + self._log(f"开始清理 FTP 源文件,共 {len(remote_files)} 个") + for remote_path in remote_files: + self._log(f"删除远程文件: {remote_path}") + ftp.delete(remote_path) + deleted_count += 1 + return deleted_count + + def _sftp_ssh_client(self): + try: + import paramiko + except ImportError as exc: + raise RemoteDownloadError("SFTP 功能需要安装 paramiko,请执行 uv pip install -r requirements.txt") from exc + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=self.config.host, + port=self.config.port, + username=self.config.user, + password=self.config.passwd, + timeout=self.config.timeout, + banner_timeout=self.config.timeout, + auth_timeout=self.config.timeout, + ) + return ssh + + def _download_sftp(self, destination: Path) -> RemoteDownloadResult: + result = RemoteDownloadResult() + ssh = self._sftp_ssh_client() + try: + with ssh.open_sftp() as sftp: + self._log(f"已连接 SFTP: {self.config.host}:{self.config.port}") + self._download_sftp_path(sftp, self.config.remote_dir, destination, result) + finally: + ssh.close() + return result + + def _download_sftp_path( + self, + sftp, + remote_path: str, + local_path: Path, + result: RemoteDownloadResult, + ) -> None: + attrs = sftp.stat(remote_path) + if stat.S_ISDIR(attrs.st_mode): + local_path.mkdir(parents=True, exist_ok=True) + for item in sftp.listdir_attr(remote_path): + if item.filename in {".", ".."}: + continue + self._download_sftp_path( + sftp, + self._join_remote_path(remote_path, item.filename), + local_path / item.filename, + result, + ) + return + + self._download_sftp_file(sftp, remote_path, local_path, result, int(getattr(attrs, "st_size", 0) or 0)) + + def _download_sftp_file( + self, + sftp, + remote_path: str, + local_path: Path, + result: RemoteDownloadResult, + expected_size: int = 0, + ) -> None: + local_path.parent.mkdir(parents=True, exist_ok=True) + self._log(f"下载: {remote_path}") + sftp.get(remote_path, str(local_path)) + result.file_count += 1 + result.total_bytes += expected_size or local_path.stat().st_size + result.remote_files.append(remote_path) + + def _list_sftp_zip_files(self, remote_dir: str) -> list[RemoteFileInfo]: + ssh = self._sftp_ssh_client() + try: + with ssh.open_sftp() as sftp: + return self._collect_sftp_zip_files(sftp, remote_dir) + finally: + ssh.close() + + def _collect_sftp_zip_files(self, sftp, remote_path: str) -> list[RemoteFileInfo]: + attrs = sftp.stat(remote_path) + if not stat.S_ISDIR(attrs.st_mode): + name = Path(remote_path.replace("\\", "/")).name + if name.lower().endswith(".zip"): + return [self._remote_file_info(remote_path, int(getattr(attrs, "st_size", 0) or 0))] + return [] + + files: list[RemoteFileInfo] = [] + for item in sftp.listdir_attr(remote_path): + if item.filename in {".", ".."}: + continue + child_path = self._join_remote_path(remote_path, item.filename) + if stat.S_ISDIR(item.st_mode): + files.extend(self._collect_sftp_zip_files(sftp, child_path)) + elif item.filename.lower().endswith(".zip"): + files.append(self._remote_file_info(child_path, int(getattr(item, "st_size", 0) or 0))) + return files + + def _delete_sftp_source_files(self) -> int: + ssh = self._sftp_ssh_client() + try: + with ssh.open_sftp() as sftp: + self._log(f"开始清理 SFTP 源文件: {self.config.remote_dir}") + return self._delete_sftp_files(sftp, self.config.remote_dir) + finally: + ssh.close() + + def _delete_sftp_files(self, sftp, remote_path: str) -> int: + attrs = sftp.stat(remote_path) + if stat.S_ISDIR(attrs.st_mode): + deleted_count = 0 + for item in sftp.listdir_attr(remote_path): + if item.filename in {".", ".."}: + continue + deleted_count += self._delete_sftp_files( + sftp, + self._join_remote_path(remote_path, item.filename), + ) + return deleted_count + + self._log(f"删除远程文件: {remote_path}") + sftp.remove(remote_path) + return 1 + + def _delete_sftp_file_paths(self, remote_files: list[str]) -> int: + deleted_count = 0 + ssh = self._sftp_ssh_client() + try: + with ssh.open_sftp() as sftp: + self._log(f"开始清理 SFTP 源文件,共 {len(remote_files)} 个") + for remote_path in remote_files: + self._log(f"删除远程文件: {remote_path}") + sftp.remove(remote_path) + deleted_count += 1 + finally: + ssh.close() + return deleted_count + + @staticmethod + def _join_remote_path(parent: str, child: str) -> str: + parent = parent.replace("\\", "/").rstrip("/") + if not parent: + return child + if parent == "/": + return f"/{child}" + return f"{parent}/{child}" + + def _remote_file_info(self, remote_path: str, size: int = 0) -> RemoteFileInfo: + normalized_root = self.config.remote_dir.replace("\\", "/").rstrip("/") + normalized_path = remote_path.replace("\\", "/") + if normalized_root and normalized_root != "/" and normalized_path.startswith(f"{normalized_root}/"): + relative_path = normalized_path[len(normalized_root) + 1 :] + else: + relative_path = normalized_path.lstrip("/") + relative = Path(relative_path) + parent = str(relative.parent).replace("\\", "/") + if parent == ".": + parent = "" + return RemoteFileInfo( + path=remote_path, + relative_path=relative_path, + parent=parent, + name=relative.name, + size=size, + ) diff --git a/app/state.py b/app/state.py new file mode 100644 index 0000000..f02cf6b --- /dev/null +++ b/app/state.py @@ -0,0 +1,43 @@ +from typing import Any + +from app.config import AppConfig +from app.history import HistoryManager + + +config = AppConfig.load() +history_manager = HistoryManager() +processing_tasks: dict[str, dict[str, Any]] = {} +upload_sessions: dict[str, dict[str, Any]] = {} +auto_scheduler: Any | None = None + +global_task_lock: dict[str, Any] = { + "locked": False, + "task_id": None, + "stage": None, + "started_at": None, +} + + +def reset_task_lock() -> None: + global_task_lock["locked"] = False + global_task_lock["task_id"] = None + global_task_lock["stage"] = None + global_task_lock["started_at"] = None + + +def reload_config() -> AppConfig: + """从 Configure.json 重新加载配置,保证接口读取到最新文件内容。""" + global config + config = AppConfig.load() + return config + + +def current_config() -> AppConfig: + return reload_config() + + +def apply_history_retention() -> int: + retention = current_config().history_retention.normalized() + if not retention.enabled: + return 0 + return history_manager.prune_finished(retention.keep_count) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..33dafe6 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,2 @@ +"""Utility helpers.""" + diff --git a/app/utils/file_dates.py b/app/utils/file_dates.py new file mode 100644 index 0000000..7bfd132 --- /dev/null +++ b/app/utils/file_dates.py @@ -0,0 +1,174 @@ +"""Filename date parsing and per-directory date filtering.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Callable, Iterable, TypeVar + + +FILENAME_DATE_RE = re.compile(r"(? int: + return max((self.end_exclusive - self.start).days, 1) + + def covered_days(self) -> list[date]: + return [self.start + timedelta(days=offset) for offset in range(self.span_days)] + + def covers_any(self, target_days: set[date]) -> bool: + return any(day in target_days for day in self.covered_days()) + + def covers_all(self, target_days: set[date]) -> bool: + return target_days.issubset(set(self.covered_days())) + + +def extract_file_date(filename: str | Path) -> date | None: + """Extract the first timestamp date from a filename.""" + date_range = parse_file_date_range(filename) + return date_range.start if date_range else None + + +def parse_file_date_range(filename: str | Path) -> FileDateRange | None: + """Extract the covered natural date range from a filename. + + Files with one timestamp are treated as one-day data files. Files with two + timestamps use the second timestamp as an exclusive end when it is midnight, + matching names like xxx_202605110000_202605120000.zip. + """ + values = FILENAME_DATE_RE.findall(Path(str(filename)).name) + if not values: + return None + + start_dt = parse_file_timestamp_datetime(values[0]) + if not start_dt: + return None + + if len(values) == 1: + return FileDateRange( + start=start_dt.date(), + end_exclusive=start_dt.date() + timedelta(days=1), + timestamp_count=1, + ) + + end_dt = parse_file_timestamp_datetime(values[1]) + if not end_dt or end_dt <= start_dt: + return FileDateRange( + start=start_dt.date(), + end_exclusive=start_dt.date() + timedelta(days=1), + timestamp_count=len(values), + ) + + end_exclusive = end_dt.date() + if end_dt.time() != datetime.min.time(): + end_exclusive += timedelta(days=1) + if end_exclusive <= start_dt.date(): + end_exclusive = start_dt.date() + timedelta(days=1) + + return FileDateRange( + start=start_dt.date(), + end_exclusive=end_exclusive, + timestamp_count=len(values), + ) + + +def parse_file_timestamp(value: str) -> date | None: + parsed = parse_file_timestamp_datetime(value) + return parsed.date() if parsed else None + + +def parse_file_timestamp_datetime(value: str) -> datetime | None: + fmt = "%Y%m%d%H%M%S" if len(value) == 14 else "%Y%m%d%H%M" + try: + return datetime.strptime(value, fmt) + except ValueError: + return None + + +def required_week_days(week_offset: int = 0, today: date | None = None) -> list[date]: + """Return Monday-Sunday dates for the target natural week. + + week_offset=0 means last week, -1 means the week before last. + """ + base_day = today or date.today() + this_monday = base_day - timedelta(days=base_day.weekday()) + target_monday = this_monday - timedelta(days=7 * (1 - week_offset)) + return [target_monday + timedelta(days=offset) for offset in range(7)] + + +def select_recent_items_by_directory( + items: Iterable[T], + *, + parent_key: Callable[[T], str], + name_key: Callable[[T], str], + days: int = 7, +) -> tuple[list[T], list[DirectoryDateSelection]]: + """Select the latest N natural days of dated files in each directory. + + If a directory has no dated files at all, all files in that directory are + kept to preserve compatibility with legacy/manual data. + """ + safe_days = max(int(days), 1) + groups: dict[str, list[T]] = {} + for item in items: + groups.setdefault(parent_key(item), []).append(item) + + selected: list[T] = [] + summaries: list[DirectoryDateSelection] = [] + for directory, group_items in sorted(groups.items(), key=lambda pair: pair[0]): + dated_items = [(item, extract_file_date(name_key(item))) for item in group_items] + valid_dates = [file_date for _, file_date in dated_items if file_date] + if not valid_dates: + selected.extend(group_items) + summaries.append( + DirectoryDateSelection( + directory=directory, + total_count=len(group_items), + selected_count=len(group_items), + skipped_count=0, + undated_count=len(group_items), + ) + ) + continue + + max_date = max(valid_dates) + start_date = max_date - timedelta(days=safe_days - 1) + group_selected = [ + item + for item, file_date in dated_items + if file_date is not None and start_date <= file_date <= max_date + ] + selected.extend(group_selected) + summaries.append( + DirectoryDateSelection( + directory=directory, + total_count=len(group_items), + selected_count=len(group_selected), + skipped_count=len(group_items) - len(group_selected), + undated_count=sum(1 for _, file_date in dated_items if file_date is None), + max_date=max_date, + start_date=start_date, + ) + ) + + return selected, summaries diff --git a/app/utils/files.py b/app/utils/files.py new file mode 100644 index 0000000..01cf1e2 --- /dev/null +++ b/app/utils/files.py @@ -0,0 +1,46 @@ +from pathlib import Path + + +def safe_relative_path(value: str, fallback: str = "upload.bin") -> Path: + parts = [ + part + for part in str(value or "").replace("\\", "/").split("/") + if part and part not in {".", ".."} + ] + if not parts: + parts = [fallback] + return Path(*parts) + + +def remove_file_safely(path: Path) -> None: + try: + path.unlink() + except OSError: + pass + + +def get_dir_size(path: Path) -> int: + total_size = 0 + try: + if path.is_file(): + return path.stat().st_size + + if path.is_dir(): + for item in path.iterdir(): + total_size += get_dir_size(item) + except (PermissionError, OSError): + return total_size + + return total_size + + +def format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + + value = float(size_bytes) + for unit in ["B", "KB", "MB", "GB", "TB"]: + if value < 1024.0: + return f"{value:.2f} {unit}" + value /= 1024.0 + return f"{value:.2f} PB" diff --git a/app/warehouse.py b/app/warehouse.py new file mode 100644 index 0000000..fa54ae8 --- /dev/null +++ b/app/warehouse.py @@ -0,0 +1,229 @@ +"""数据仓库抽象:直连 MySQL 或 Metrix 数据库平台。 + +`make_warehouse(config)` 按 warehouse_type 返回: +- 直连 MySQL: 原版 `DatabaseManager`(已具备下列方法)。 +- Metrix: `MetrixWarehouse`,用平台数据库 API 实现相同方法,供查看/导出路由透明替换。 + +两者都提供: test_connection / get_server_info / get_tables / get_table_info / +query_table / truncate_table / drop_table / drop_all_tables / execute_sql。 +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from app.config import AppConfig +from app.database import DatabaseManager +from app.services.platform import make_client + + +def make_warehouse(config: AppConfig): + if config.warehouse_type == "metrix": + return MetrixWarehouse(config) + return DatabaseManager(config) + + +def make_cell_data_warehouse(config: AppConfig): + return DatabaseManager(config, config.cell_data.normalized().mysql) + + +def _quote_ident(name: str) -> str: + return "`" + str(name).replace("`", "``") + "`" + + +def _quote_value(value: str) -> str: + return "'" + str(value).replace("\\", "\\\\").replace("'", "''") + "'" + + +def _sql_literal(value: Any) -> str: + return "NULL" if value is None else _quote_value(str(value)) + + +class MetrixWarehouse: + """用 Metrix 数据库 API 实现 DatabaseManager 的只读/管理子集。""" + + def __init__(self, config: AppConfig): + self.metrix = config.metrix.normalized() + self.conn_id = self.metrix.database_conn_id + self.database = self.metrix.target_database + self.client = make_client(self.metrix) + + # --- 连接 / 诊断 ----------------------------------------------------- + def test_connection(self) -> Tuple[bool, str]: + try: + self.client.list_tables(self.conn_id, self.database) + return True, "连接成功" + except Exception as exc: # noqa: BLE001 + return False, str(exc) + + def get_server_info(self) -> Dict[str, Any]: + version = "Metrix" + try: + res = self.client.run_script(self.conn_id, content="SELECT VERSION() AS v", database=self.database, run_timeout=30) + rows = (res.get("results") or [{}])[0].get("rows") or [] + if rows: + version = str(list(rows[0].values())[0]) + except Exception: # noqa: BLE001 + pass + return {"version": version, "load_data_infile": True, "load_data_message": "Metrix 平台导入"} + + # --- 表 / 数据 ------------------------------------------------------- + def get_tables(self) -> List[str]: + return self.client.list_tables(self.conn_id, self.database) + + def get_table_info(self, table_name: str) -> Dict[str, Any]: + columns = self.client.table_columns(self.conn_id, table_name, self.database) + # Map Metrix column shape -> original DESCRIBE-like shape used by the frontend. + mapped = [ + { + "Field": col.get("name"), + "Type": col.get("type", ""), + "Null": "YES" if col.get("nullable", True) else "NO", + "Key": "PRI" if col.get("primary_key") else "", + "Default": col.get("default"), + "Extra": "auto_increment" if col.get("autoincrement") else "", + } + for col in columns + ] + data = self.client.table_data(self.conn_id, table_name, self.database, page=1, page_size=1) + return {"name": table_name, "columns": mapped, "row_count": int(data.get("total") or 0)} + + def query_table( + self, + table_name: str, + page: int = 1, + page_size: int = 50, + filters: Optional[Dict[str, str]] = None, + order_by: Optional[str] = None, + order_dir: str = "ASC", + ) -> Dict[str, Any]: + active_filters = {k: v for k, v in (filters or {}).items() if v} + if active_filters: + return self._query_with_filters(table_name, page, page_size, active_filters, order_by, order_dir) + data = self.client.table_data( + self.conn_id, table_name, self.database, page=page, page_size=page_size, + order_by=order_by or "", order_dir=order_dir, + ) + total = int(data.get("total") or 0) + return { + "data": data.get("rows", []), + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size if page_size else 0, + } + + def _query_with_filters(self, table_name, page, page_size, filters, order_by, order_dir) -> Dict[str, Any]: + where = " AND ".join(f"{_quote_ident(col)} LIKE {_quote_value('%' + str(val) + '%')}" for col, val in filters.items()) + where_sql = f" WHERE {where}" if where else "" + table_sql = _quote_ident(table_name) + total_res = self.client.run_script( + self.conn_id, content=f"SELECT COUNT(*) AS n FROM {table_sql}{where_sql}", + database=self.database, run_timeout=120, + ) + total = int(((total_res.get("results") or [{}])[0].get("rows") or [{}])[0].get("n") or 0) + order_sql = "" + if order_by: + direction = "DESC" if str(order_dir).upper() == "DESC" else "ASC" + order_sql = f" ORDER BY {_quote_ident(order_by)} {direction}" + offset = max(page - 1, 0) * page_size + data_res = self.client.run_script( + self.conn_id, + content=f"SELECT * FROM {table_sql}{where_sql}{order_sql} LIMIT {int(page_size)} OFFSET {int(offset)}", + database=self.database, run_timeout=300, + ) + rows = (data_res.get("results") or [{}])[0].get("rows") or [] + return { + "data": rows, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size if page_size else 0, + } + + # --- 管理操作 -------------------------------------------------------- + def truncate_table(self, table_name: str) -> bool: + self._run(f"TRUNCATE TABLE {_quote_ident(table_name)}") + return True + + def drop_table(self, table_name: str) -> bool: + self._run(f"DROP TABLE IF EXISTS {_quote_ident(table_name)}") + return True + + def drop_all_tables(self) -> Dict[str, Any]: + tables = self.get_tables() + if not tables: + return {"success": True, "dropped_count": 0, "tables": []} + drop_sql = "".join(f"DROP TABLE IF EXISTS {_quote_ident(t)};\n" for t in tables) + self._run(drop_sql) + return {"success": True, "dropped_count": len(tables), "tables": tables} + + def _row_where(self, identifier: Dict[str, Any]) -> str: + parts = [ + f"{_quote_ident(col)} IS NULL" if val is None else f"{_quote_ident(col)} = {_sql_literal(val)}" + for col, val in identifier.items() + ] + return " AND ".join(parts) if parts else "1 = 0" + + def update_row(self, table_name: str, identifier: Dict[str, Any], values: Dict[str, Any]) -> int: + if not values: + return 0 + set_clause = ", ".join(f"{_quote_ident(col)} = {_sql_literal(val)}" for col, val in values.items()) + sql = f"UPDATE {_quote_ident(table_name)} SET {set_clause} WHERE {self._row_where(identifier)} LIMIT 1" + ok, result = self.execute_sql(sql) + if not ok: + raise RuntimeError(str(result)) + return int(result.get("affected_rows", 0)) if isinstance(result, dict) else 0 + + def delete_row(self, table_name: str, identifier: Dict[str, Any]) -> int: + sql = f"DELETE FROM {_quote_ident(table_name)} WHERE {self._row_where(identifier)} LIMIT 1" + ok, result = self.execute_sql(sql) + if not ok: + raise RuntimeError(str(result)) + return int(result.get("affected_rows", 0)) if isinstance(result, dict) else 0 + + def import_csv(self, file_path: str, table_name: str, encoding: str | None = None) -> int: + """走 Metrix 平台导入 API 追加导入到已存在的表(字段映射由平台按列名处理)。""" + source_path = Path(file_path) + upload_path = source_path + converted_path: Path | None = None + if encoding and encoding.lower() not in {"utf-8", "utf-8-sig"}: + converted_path = source_path.with_name(f"{source_path.stem}_utf8{source_path.suffix}") + with source_path.open("r", encoding=encoding, newline="") as source: + with converted_path.open("w", encoding="utf-8-sig", newline="") as target: + while chunk := source.read(1024 * 1024): + target.write(chunk) + upload_path = converted_path + + try: + job_id = self.client.import_csv( + self.conn_id, table_name, upload_path, mode="append", + database=self.database, create_table=False, + ) + job = self.client.wait_job(job_id) + if job.get("status") != "success": + raise RuntimeError(job.get("error_code") or job.get("status") or "导入失败") + return int(job.get("row_count") or 0) + finally: + if converted_path is not None: + converted_path.unlink(missing_ok=True) + + def execute_sql(self, sql: str) -> Tuple[bool, Any]: + try: + res = self.client.run_script(self.conn_id, content=sql, database=self.database, run_timeout=600) + except Exception as exc: # noqa: BLE001 + return False, str(exc) + if res.get("stopped"): + failed = [r for r in res.get("results", []) if not r.get("ok")] + return False, (failed[0].get("message") if failed else "SQL 执行失败") + results = res.get("results", []) + last = results[-1] if results else {} + if last.get("rows"): + return True, last["rows"] + return True, {"affected_rows": sum(int(r.get("affected_rows") or 0) for r in results)} + + def _run(self, content: str) -> None: + res = self.client.run_script(self.conn_id, content=content, database=self.database, run_timeout=600) + if res.get("stopped"): + failed = [r for r in res.get("results", []) if not r.get("ok")] + raise RuntimeError(failed[0].get("message") if failed else "SQL 执行失败") diff --git a/db_init/README.md b/db_init/README.md new file mode 100644 index 0000000..c8663a2 --- /dev/null +++ b/db_init/README.md @@ -0,0 +1,53 @@ +# db_init —— 数据库前置检查 / 结构表初始化 + +本目录存放各数据库「**必须存在的结构表**」的初始化 SQL。程序启动时、以及每次执行 +CellData 脚本前,会由 `app/db_init.py` 的 `ensure_required_tables()` 自动检查: +**① 库(schema)不存在时自动创建**(celldata 与 capacityreport,库名以配置为准); +**② 某张必需表不存在时,执行对应的初始化 SQL 把它建好**,从而避免运行过程中因缺库/缺表报错。 + +## 文件命名规则 + +``` +<库标识>.<表名>.sql +``` + +- `<库标识>` 决定连到哪个库(**实际库名以用户在「系统设置」里配置的为准,不写死**): + | 库标识 | 对应配置 | 说明 | + |---|---|---| + | `celldata` | `CellData.MySQL_DBInfo` | CellData 库,始终直连 MySQL | + | `capacityreport` | `MySQL_DBInfo`(主仓库) | 仅当仓库为「直连 MySQL」时检查;Metrix 模式跳过 | +- `<表名>` 为要检查/创建的表名(区分库标识后剩余部分,表名本身不含 `.`)。 + +例:`celldata.sector.sql` = 在 CellData 库里确保 `sector` 表存在。 + +## 执行时机与规则 + +- 触发点:应用启动(`app/main.py` lifespan)、执行 CellData 脚本前(`execute_celldata_script`)。 +- 判定:用 `SHOW TABLES` 取现有表,**仅当目标表不存在**时才执行该 `.sql`;已存在则整文件跳过。 +- 因此带预设数据的表(如 `sector_band_ref`)**只在首次创建时写入预设**,之后你在库里 + 对它的任何增改都会被保留,不会被覆盖或重置。 +- 全过程 best-effort:单库连不上 / 单表初始化失败只记日志,不会中断启动或数据处理。 +- **建库**:连接前先用不带 database 的连接执行 `CREATE DATABASE IF NOT EXISTS`,故库不存在也能自愈; + 前提是 MySQL 账号有建库权限(root 默认有)。Metrix 模式下主库走平台 API,不在此建库。 + +## 当前清单 + +### celldata(CellData 库) +| 文件 | 表 | 用途 | +|---|---|---| +| `celldata.cellinfo.sql` | `cellinfo` | 小区基础信息。正常由导入自动建,此处兜底。 | +| `celldata.sector.sql` | `sector` | 扇区表,CellData.sql 逆推写入目标。**不会被任何流程自动创建**,必须前置建好。 | +| `celldata.sector_band_ref.sql` | `sector_band_ref` | 频段特征库(频点区间+PLMN→制式/频段),含预设 10 条规则,供逆推用,可自行增改。 | + +### capacityreport(主仓库) +**会自动建库**(直连 MySQL 模式下),但**无需前置建表**:主仓库的原始表(`4G_UD`/`5G_UD`)、结果表(`4G_结果表`/`5G_结果表`) +以及从 CellData 复制过来的 `sector`/`cellinfo`,都由导入流程 / `ReportScript.sql` / +跨库复制以 `DROP TABLE IF EXISTS` + `CREATE` 动态生成,结构随源数据字段而定, +不宜在此预先固定结构(会与动态建表冲突)。如确有需要,按上面的命名规则新增 +`capacityreport.<表名>.sql` 即可被自动纳入检查。 + +## 新增一张必需表 + +1. 在本目录新建 `<库标识>.<表名>.sql`,内容用 `CREATE TABLE IF NOT EXISTS ...`; + 如需预设数据,在其后追加 `INSERT ...`(只会在表首次创建时执行)。 +2. 无需改 Python,`ensure_required_tables()` 会自动发现并按需执行。 diff --git a/db_init/celldata.cellinfo.sql b/db_init/celldata.cellinfo.sql new file mode 100644 index 0000000..0f03ba8 --- /dev/null +++ b/db_init/celldata.cellinfo.sql @@ -0,0 +1,17 @@ +-- celldata.cellinfo:小区基础信息表 +-- 正常由 CellData 导入流程自动创建(CREATE TABLE IF NOT EXISTS);此处为前置兜底, +-- 避免在尚未导入时执行 CellData.sql 的 `UPDATE cellinfo ...` 因缺表报错。 +CREATE TABLE IF NOT EXISTS `cellinfo` ( + `CGI` varchar(120) DEFAULT NULL, + `eNodeBID` int DEFAULT NULL, + `CellID` int DEFAULT NULL, + `PLMN` varchar(100) DEFAULT NULL, + `基站名称` varchar(200) DEFAULT NULL, + `小区名称` varchar(200) DEFAULT NULL, + `频点` varchar(50) DEFAULT NULL, + `带宽` varchar(20) DEFAULT NULL, + `制式` varchar(50) DEFAULT NULL, + `功率` varchar(100) DEFAULT NULL, + `网络` varchar(20) DEFAULT NULL, + KEY `CGI` (`CGI`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/db_init/celldata.sector.sql b/db_init/celldata.sector.sql new file mode 100644 index 0000000..08cec1e --- /dev/null +++ b/db_init/celldata.sector.sql @@ -0,0 +1,14 @@ +-- celldata.sector:扇区表(CellData.sql 逆推写入的目标表) +-- 不会被任何导入流程自动创建,缺表会导致 CellData.sql 的 `INSERT INTO sector ...` 报错, +-- 因此必须前置建好结构。已有数据(含人工修正)由 CellData.sql「仅补缺不覆盖」保护。 +CREATE TABLE IF NOT EXISTS `sector` ( + `CGI` varchar(120) DEFAULT NULL, + `扇区` varchar(200) DEFAULT NULL, + `物理站` varchar(200) DEFAULT NULL, + `制式` varchar(50) DEFAULT NULL, + `频段` varchar(50) DEFAULT NULL, + `带宽` varchar(20) DEFAULT NULL, + `站型` varchar(50) DEFAULT NULL, + `网络` varchar(20) DEFAULT NULL, + UNIQUE KEY `uq_sector_cgi` (`CGI`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/db_init/celldata.sector_band_ref.sql b/db_init/celldata.sector_band_ref.sql new file mode 100644 index 0000000..3573976 --- /dev/null +++ b/db_init/celldata.sector_band_ref.sql @@ -0,0 +1,24 @@ +-- celldata.sector_band_ref:频段特征库(频点区间 + PLMN -> 制式/频段),供 CellData.sql 逆推使用 +-- 频点区间为左闭右开 [频点下限, 频点上限);PLMN 为空表示该频段不分运营商。 +-- 仅在该表不存在时由前置检查执行本文件(建表 + 写入下列预设); +-- 表已存在则完全跳过本文件,保留用户对特征库的自定义,不会被覆盖或重置。 +CREATE TABLE IF NOT EXISTS `sector_band_ref` ( + `网络` varchar(8) DEFAULT NULL, + `频点下限` decimal(10,2) DEFAULT NULL, + `频点上限` decimal(10,2) DEFAULT NULL, + `PLMN` varchar(16) DEFAULT NULL, + `制式` varchar(20) DEFAULT NULL, + `频段` varchar(20) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `sector_band_ref` (`网络`,`频点下限`,`频点上限`,`PLMN`,`制式`,`频段`) VALUES +('5G',4000,99999,NULL,'4.9G','4.9G'), +('5G',700,800,'460-00','700M','700M'), +('5G',700,800,'460-15','广电','广电'), +('5G',2000,3000,NULL,'2.6G','2.6G'), +('4G',900,1000,NULL,'FDD','FDD900'), +('4G',1000,1880,NULL,'FDD','FDD1800'), +('4G',1880,1920,NULL,'TDD','F频'), +('4G',1920,2110,NULL,'TDD','A频'), +('4G',2300,2400,NULL,'TDD','E频'), +('4G',2400,2700,NULL,'TDD','D频'); diff --git a/docker/apply_env_config.py b/docker/apply_env_config.py new file mode 100644 index 0000000..7bdedde --- /dev/null +++ b/docker/apply_env_config.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""首次播种 Configure.json 后,按容器环境变量改写「数据库 / SFTP 连接」。 + +仅在 entrypoint 首次创建 /data/Configure.json 时调用一次;用户之后在界面里的修改不会被覆盖。 +保留数据格式相关配置(DataMappings / ExtractField / CellData.mapping 等)不变。 +""" +import json +import os + +BASE_DIR = os.environ.get("CAPAREPORT_BASE_DIR", "/data") +CONFIG_PATH = os.path.join(BASE_DIR, "Configure.json") + + +def set_str(target: dict, key: str, env: str) -> None: + val = os.environ.get(env) + if val not in (None, ""): + target[key] = val + + +def set_int(target: dict, key: str, env: str) -> None: + val = os.environ.get(env) + if val not in (None, ""): + try: + target[key] = int(val) + except ValueError: + pass + + +def main() -> None: + if not os.path.exists(CONFIG_PATH): + return + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + cfg = json.load(f) + + # 主仓库 MySQL + main_db = cfg.setdefault("MySQL_DBInfo", {}) + set_str(main_db, "host", "DB_HOST") + set_int(main_db, "port", "DB_PORT") + set_str(main_db, "user", "DB_USER") + set_str(main_db, "passwd", "DB_PASSWD") + set_str(main_db, "dbname", "MAIN_DB_NAME") + + cell = cfg.setdefault("CellData", {}) + # CellData MySQL + cell_db = cell.setdefault("mysql", {}) + set_str(cell_db, "host", "DB_HOST") + set_int(cell_db, "port", "DB_PORT") + set_str(cell_db, "user", "DB_USER") + set_str(cell_db, "passwd", "DB_PASSWD") + set_str(cell_db, "dbname", "CELLDATA_DB_NAME") + + # 容量数据源 SFTP + remote = cfg.setdefault("RemoteData", {}) + set_str(remote, "host", "SFTP_HOST") + set_int(remote, "port", "SFTP_PORT") + set_str(remote, "user", "SFTP_USER") + set_str(remote, "passwd", "SFTP_PASSWD") + + # CellData 数据源 SFTP + cell_remote = cell.setdefault("remote_data", {}) + set_str(cell_remote, "host", "SFTP_HOST") + set_int(cell_remote, "port", "SFTP_PORT") + set_str(cell_remote, "user", "SFTP_USER") + set_str(cell_remote, "passwd", "SFTP_PASSWD") + + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + print("[entrypoint] 已按容器环境变量初始化 Configure.json 的数据库/SFTP 连接") + + +if __name__ == "__main__": + main() diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..9026187 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -e + +# 运行态都落在数据卷 /data:配置、脚本、特征库、历史、缓存、登录态。 +mkdir -p /data/cache + +seeded=0 +if [ ! -f /data/Configure.json ]; then + cp /app/defaults/Configure.json /data/Configure.json + seeded=1 +fi +[ -f /data/ReportScript.sql ] || cp /app/defaults/ReportScript.sql /data/ReportScript.sql +[ -f /data/CellData.sql ] || cp /app/defaults/CellData.sql /data/CellData.sql +[ -d /data/db_init ] || cp -r /app/defaults/db_init /data/db_init + +# 首次播种时按容器环境变量改写 数据库/SFTP 连接(用户后续在界面的修改不会被覆盖) +if [ "$seeded" = "1" ] && [ -f /app/docker/apply_env_config.py ]; then + python /app/docker/apply_env_config.py || true +fi + +exec python -m uvicorn app.main:app --host 0.0.0.0 --port 9081 diff --git a/docs/auto_scheduler_plan.md b/docs/auto_scheduler_plan.md new file mode 100644 index 0000000..1426c89 --- /dev/null +++ b/docs/auto_scheduler_plan.md @@ -0,0 +1,366 @@ +# 远程数据自动调度功能设计方案 + +## 一、需求概述 + +### 1.1 背景 + +当前系统需要每周一手动点击"远程下载并处理"来跑上周数据(周一到周日,7天数据)。由于服务器时间不准确,无法使用定时任务在固定时间运行。需要一个基于**数据就绪状态**的自动调度机制。 + +### 1.2 核心需求 + +1. **文件时间过滤**:处理时只处理最近7天的文件 +2. **自动就绪检测**:每小时检查远程目录,判断上周7天数据是否齐全 +3. **自动触发处理**:数据就绪后自动下载并处理 +4. **状态标识管理**:使用标识文件避免重复触发 + +--- + +## 二、文件时间解析规则 + +### 2.1 文件名格式 + +| 格式 | 示例 | 数据日期 | +|------|------|----------| +| `XXX_YYYYMMDDHHMM_YYYYMMDDHHMM` | `CapacityReportData2.6_202605110000_202605120000.zip` | 覆盖起止时间范围,结束零点按右开区间处理 → 2026-05-11 | +| `XXX_YYYYMMDDHHMM` | `CapacityReportData2.6_202605110000.zip` | 视为单日文件 → 2026-05-11 | + +### 2.2 解析逻辑 + +复用项目已有的正则 `_ZIP_DATE_RE = re.compile(r"(? FileDateRange | None: + """从文件名提取数据覆盖范围。""" + values = _ZIP_DATE_RE.findall(filename) + if not values: + return None + start = parse_timestamp(values[0]) + end = parse_timestamp(values[1]) if len(values) > 1 else start + timedelta(days=1) + return FileDateRange(start=start.date(), end_exclusive=normalize_end(end)) +``` + +--- + +## 三、自动调度机制 + +### 3.1 工作流程 + +``` +每小时定时器触发 + │ + ▼ +┌─────────────────────┐ +│ 检查就绪标识文件 │ +│ (ready.flag) │ +└─────────────────────┘ + │ + ├── 标识存在 ──► 跳过检测,直接触发处理 + │ + ▼ +┌─────────────────────┐ +│ 连接 FTP/SFTP │ +│ 遍历远程目录 │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ 对每个 expected_directory: │ +│ 1. 列出所有 ZIP 文件名 │ +│ 2. 从文件名提取数据日期 │ +│ 3. 检查是否覆盖上周7天(周一~周日)│ +└─────────────────────────────────────┘ + │ + ├── 未就绪 ──► 记录日志,等待下次检查 + │ + ▼ +┌─────────────────────┐ +│ 写入就绪标识文件 │ +│ (ready.flag) │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ 触发远程下载处理 │ +│ (复用现有流程) │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ 处理成功后 │ +│ 1. 删除远程源文件 │ +│ 2. 删除就绪标识 │ +└─────────────────────┘ + │ + ▼ + 继续每小时检查 +``` + +### 3.2 目标日期范围(严格按自然周) + +**永远是上周一到上周日**(自然周,7天)。无论今天是周几,程序都会自动计算正确的上周一和上周日。 + +```python +def get_target_week_range(week_offset: int = 0) -> tuple[date, date]: + """获取目标周的周一到周日。 + week_offset=0 表示上周,-1 表示上上周 + """ + today = date.today() + this_monday = today - timedelta(days=today.weekday()) + target_monday = this_monday - timedelta(days=7 * (1 - week_offset)) + target_sunday = target_monday + timedelta(days=6) + return target_monday, target_sunday +``` + +示例(假设 week_offset=0): + +| 今天 | 本周一 | 上周一 | 上周日 | 检查范围 | +|------|--------|--------|--------|----------| +| 周一 5/19 | 5/19 | 5/12 | 5/18 | 5/12~5/18 | +| 周三 5/21 | 5/19 | 5/12 | 5/18 | 5/12~5/18 | +| 周日 5/25 | 5/19 | 5/12 | 5/18 | 5/12~5/18 | + +**不会**把非自然周的7天范围(如上周五到本周四)当作目标。`week_offset` 参数仅用于补跑场景:如果上周处理失败,可设为 `-1` 检查上上周的周一到周日。 + +### 3.3 就绪判断 + +一个目录的"就绪"条件:该目录下所有 ZIP 文件提取出的数据日期集合,**完全覆盖**目标周(上周一~上周日)的全部 7 天。 + +**关键**: +1. 不依赖系统日期,而是从文件名中提取日期 +2. 文件名带两个时间戳时按覆盖范围判断;只有一个时间戳时视为单日文件 +3. 例如文件 `CapacityReportData2.6_202605120000_202605130000.zip` → 覆盖 2026-05-12 +4. 如果某个日期没有对应的 ZIP 文件,该目录判定为未就绪 + +RJ 目录会额外按最新文件自动判断粒度:最新文件为单日时要求 7 个目标日都齐全;最新文件为多日/周文件时要求存在一个 ZIP 覆盖完整目标自然周。 + +### 3.4 标识文件 + +- **位置**:`cache/auto_scheduler/ready.flag` +- **格式**:JSON + +```json +{ + "ready_at": "2026-05-19T10:00:00", + "week_start": "2026-05-12", + "week_end": "2026-05-18", + "directories": { + "4G/FDD": {"found_days": 7, "required_days": 7}, + "4G/900": {"found_days": 7, "required_days": 7}, + "5G/2.6": {"found_days": 7, "required_days": 7}, + "5G/700": {"found_days": 7, "required_days": 7} + } +} +``` + +--- + +## 四、配置项设计 + +### 4.1 新增配置(`Configure.json` → `RemoteData` 节点下) + +```json +{ + "RemoteData": { + "enabled": true, + "protocol": "sftp", + "host": "127.0.0.1", + "port": 2022, + "user": "nixevol", + "remote_dir": "/CapacityReportData", + "passive": true, + "timeout": 30, + "auto_delete_source": true, + "passwd": "", + + "auto_scheduler": { + "enabled": false, + "check_interval_hours": 1, + "expected_directories": [ + "4G/FDD", + "4G/900", + "5G/2.6", + "5G/700" + ], + "week_offset": 0 + } + } +} +``` + +### 4.2 字段说明 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `enabled` | bool | `false` | 是否启用自动调度 | +| `check_interval_hours` | int | `1` | 检查间隔(小时),最小1 | +| `expected_directories` | list[str] | `[]` | 需要检测就绪的子目录路径列表(相对于 `remote_dir`)。为空时按实际 ZIP 所在目录逐个检测 | +| `week_offset` | int | `0` | 周偏移,`0` = 检查上周,`-1` = 检查上上周 | + +### 4.3 约束规则 + +1. **自动调度开启时,`auto_delete_source` 强制为 `true`**(前端灰显、后端校验) +2. `expected_directories` 为空时按远程 ZIP 实际所在目录逐个检测 +3. `check_interval_hours` 最小值为 1 +4. 配置了 `expected_directories` 时,某个预期目录可访问但完全没有 ZIP 文件,视为该目录已停推并跳过;所有目录都为空时不触发自动处理 + +--- + +## 五、代码改动计划 + +### 5.1 后端 + +#### `app/config.py` +- `RemoteDataConfig` 新增 4 个字段 + `AutoSchedulerConfig` 子配置类 +- 更新 `from_dict()`、`to_dict()`、`to_file_dict()` 序列化逻辑 +- 新增约束校验:`auto_scheduler.enabled = true` 时强制 `auto_delete_source = true` + +#### `app/services/remote_download.py` +- 新增 `list_remote_zip_files(directory: str | None) -> list[RemoteFileInfo]`:递归列出指定远程目录下所有 ZIP 文件路径、相对路径、所在目录和大小(只扫描,不下载) +- 远程下载会先按每个实际目录筛选最近 7 天 ZIP;自动调度触发时按 ready flag 中的目标日期精确下载,避免数据就绪后远程目录又出现新文件时误下载非目标周数据。 + +#### `app/services/auto_scheduler.py`(**新建**) +- `AutoScheduler` 类 + - `start()` / `stop()`:启动/停止后台定时线程 + - `check_and_run()`:检查 → 就绪 → 触发处理 + - `_is_ready()`:检查所有目录就绪状态 + - `_mark_ready()` / `_clear_ready()`:标识文件管理 + - `_trigger_processing()`:复用 `remote.py` 中的 `_run_remote_processing` 逻辑 + - `get_status()` → dict:返回调度器当前状态 + +#### `app/main.py` +- 应用启动时初始化 `AutoScheduler` 并 start +- 应用关闭时 stop + +#### `app/api/routers/remote.py` +- 新增 `GET /api/remote/scheduler/status`:查询调度状态 +- 新增 `POST /api/remote/scheduler/trigger`:手动触发一次检测 +- 修改 `POST /api/remote/start`:自动调度期间禁止手动触发(避免冲突) + +### 5.2 前端 + +#### `frontend/src/components/SettingsPanel.vue` +在"远程数据源"配置区域新增"自动调度"折叠卡片: +- 启用开关 +- 检查间隔(小时)输入框 +- 预期目录列表(支持增删) +- 周偏移选择(默认上周) +- 状态面板:下次检查时间、上次检查结果、就绪状态、各目录检测结果 + +--- + +## 六、日志与监控 + +### 6.1 日志示例 + +``` +[10:00:00] 自动调度:开始检查远程目录就绪状态 +[10:00:01] 自动调度:连接 SFTP 127.0.0.1:2022 +[10:00:02] 自动调度:检查 4G/FDD → 找到 5/7 天文件(缺少 2026-05-13, 2026-05-14) +[10:00:03] 自动调度:检查 4G/900 → 找到 7/7 天文件 ✓ +[10:00:04] 自动调度:检查 5G/2.6 → 找到 7/7 天文件 ✓ +[10:00:05] 自动调度:检查 5G/700 → 找到 6/7 天文件(缺少 2026-05-18) +[10:00:06] 自动调度:数据未就绪(2/4 目录满足),等待下次检查 +... +[11:00:00] 自动调度:开始检查远程目录就绪状态 +[11:00:05] 自动调度:所有目录数据就绪 (4/4),写入标识文件 +[11:00:06] 自动调度:触发远程下载处理任务 +[11:05:00] 自动调度:任务处理成功 +[11:05:01] 自动调度:远程源文件已清理 +[11:05:02] 自动调度:已清除就绪标识,继续监控 +``` + +### 6.2 状态接口响应 + +```json +GET /api/remote/scheduler/status + +{ + "enabled": true, + "running": true, + "next_check_at": "2026-05-19T12:00:00", + "last_check_at": "2026-05-19T11:00:00", + "last_result": "triggered", + "failure_count": 0, + "task_running": false, + "ready_flag": { + "exists": false + }, + "target_week": { + "start": "2026-05-12", + "end": "2026-05-18" + }, + "directory_status": { + "4G/FDD": {"found_days": ["2026-05-12"], "found_count": 7, "required_count": 7, "ready": true}, + "4G/900": {"found_days": ["2026-05-12"], "found_count": 7, "required_count": 7, "ready": true}, + "5G/2.6": {"found_days": ["2026-05-12"], "found_count": 7, "required_count": 7, "ready": true}, + "5G/700": {"found_days": ["2026-05-12"], "found_count": 7, "required_count": 7, "ready": true} + } +} +``` + +--- + +## 七、异常处理 + +| 场景 | 处理方式 | +|------|----------| +| SFTP/FTP 连接失败 | 记录错误日志,等下次周期重试 | +| 连续失败 ≥3 次 | 在前端调度状态面板显示红色失败状态 | +| 处理失败 | **保留就绪标识**,下次检查时直接触发处理(不重新检测) | +| 远程目录不存在 | 跳过该目录,记录警告,其他目录正常检测 | +| 预期目录内无 ZIP 文件 | 视为该目录已停推并跳过,不阻塞其他目录;如果所有目录都为空,则继续等待,不触发处理 | +| 手动触发远程处理 | 自动调度运行中禁止手动触发,反之亦然(互斥锁复用 `global_task_lock`) | + +--- + +## 八、测试要点 + +1. **文件名解析** + - `XXX_202605110000_202605120000.zip` → 2026-05-11 ✓ + - `XXX_202605110000.zip` → 2026-05-11 ✓ + - `no_date_here.zip` → None(跳过) + - `XXX_20260511000012345.zip` → 14位匹配 → 2026-05-11 ✓ + +2. **就绪检测** + - 所有目录都覆盖7天 → 就绪 + - 某目录缺少1天 → 未就绪 + - `expected_directories` 为空 → 只检测根目录 + - 子目录不存在 → 跳过并记录警告 + +3. **调度流程** + - 检测 → 写标识 → 触发处理 → 成功 → 删标识 → 循环 + - 处理失败 → 保留标识 → 下次直接触发 + +4. **配置联动** + - 开启自动调度 → `auto_delete_source` 自动变为 true + - 修改配置 → 调度器热重载 + +5. **互斥** + - 自动调度运行中 → 手动触发被拒绝 + - 手动任务运行中 → 自动调度跳过本轮 + +--- + +## 九、实施步骤与工时估算 + +| 步骤 | 内容 | 预估工时 | +|------|------|----------| +| 1 | `app/config.py`:扩展 `RemoteDataConfig`,新增 `AutoSchedulerConfig` | 20min | +| 2 | `app/services/remote_download.py`:新增 `list_remote_zip_names()` | 30min | +| 3 | `app/services/auto_scheduler.py`:新建调度器核心逻辑 | 2h | +| 4 | `app/api/routers/remote.py`:新增状态查询和手动触发接口 | 30min | +| 5 | `app/main.py`:集成调度器启动/停止 | 10min | +| 6 | `frontend/src/components/SettingsPanel.vue`:自动调度配置 UI | 1.5h | +| 7 | `frontend/src/api/client.ts`:新增 API 调用方法 | 10min | +| 8 | 联调测试 | 1h | +| **合计** | | **约6h** | + +--- + +## 十、注意事项 + +1. **不改动现有处理流程**:自动调度只是在现有"远程下载并处理"之上加了一层检测和触发机制 +2. **标识文件是运行时数据**:`cache/auto_scheduler/ready.flag` 不应提交到版本库 +3. **`week_offset` 用途**:如果某周处理失败需要下周补跑,可将 `week_offset` 设为 `-1` 来检查上上周的数据 +4. **配置热重载**:修改自动调度配置后,调度器应在下一次检查周期使用新配置(不需要重启服务) +5. **前端 `auto_delete_source` 联动**:当自动调度开启时,前端应将"处理成功后删除源文件"开关灰显为强制开启状态 diff --git a/docs/platform_architecture_plan.md b/docs/platform_architecture_plan.md new file mode 100644 index 0000000..6b0727a --- /dev/null +++ b/docs/platform_architecture_plan.md @@ -0,0 +1,333 @@ +# 数据处理平台架构设计 + +## 目标 + +建设一个通用的数据处理平台,用于接入 FTP/SFTP 等远程数据源,处理压缩包、CSV、XLSX 等报表文件,完成入库、清洗、合并、脚本执行、定时任务和结果查询。平台后续不绑定单一报表类型,容量报表只是一个业务应用或任务模板。 + +当前 CapaReport 项目只作为参考和代码借鉴来源。新平台前期放在当前项目根目录的 `platform/` 目录下开发,该目录已加入 CapaReport 的 `.gitignore`,避免影响当前项目提交。 + +## 隔离边界 + +`platform/` 是完全独立的新平台工作区,只是临时放在当前项目根目录下,方便开发时查看和参考 CapaReport 的现有实现。该目录下的所有内容都不属于 CapaReport 项目。 + +必须遵守以下边界: + +- 不使用 CapaReport 的 Python 虚拟环境,例如 `.venv/`、`venv/`。 +- 不使用 CapaReport 的 Python 依赖文件,例如 `requirements.txt`。 +- 不使用 CapaReport 的启动脚本、构建脚本或运行配置。 +- 不使用 CapaReport 的前端依赖目录,例如 `frontend/node_modules/`。 +- 不使用 CapaReport 的前端构建配置或包管理文件,例如 `frontend/package.json`、`frontend/package-lock.json`、`frontend/vite.config.ts`。 +- 不从 CapaReport 的 `app/`、`frontend/`、`src-tauri/` 等目录直接导入代码。 +- 不读取或依赖 CapaReport 的 `Configure.json`、`ReportScript.sql`、缓存、日志、授权文件或本地运行数据。 +- 不把 `platform/` 的代码、依赖、配置或生成物提交到 CapaReport 仓库。 +- 如需复用思路,只能按模块职责重新实现或后续抽取为独立仓库,不能形成对当前项目目录的运行时依赖。 + +## 设计原则 + +- 只做 Web 平台,不再引入桌面端打包形态。 +- 后端使用 Python,前端使用 Web 技术栈。 +- 模块之间通过明确接口协作,避免一个模块同时承担下载、解析、入库、SQL 执行等多种职责。 +- 每个核心模块独立 Git 仓库维护,平台主仓库通过 submodule 组合。 +- 先保证文件接入、数据入库、任务执行、结果查询这条主链路稳定,再扩展更多报表和自动化能力。 +- 当前阶段避免过度抽象,模块边界清晰即可,插件化能力按真实需求逐步增强。 + +## 仓库与目录组织 + +平台根目录建议为独立 Git 仓库,放在当前项目的 `platform/` 下。本目录不进入 CapaReport 仓库。 + +建议平台主仓库负责: + +- 应用启动与服务编排。 +- 全局配置、认证、权限、审计、任务调度。 +- 前后端集成。 +- submodule 版本锁定。 +- 平台级文档和部署脚本。 + +建议 submodule 放在平台主仓库的 `modules/` 下: + +| 模块仓库 | 职责 | +| --- | --- | +| `platform-core` | 应用核心模型、配置、任务状态、事件、日志、通用接口约定 | +| `source-remote` | FTP/SFTP 连接、目录扫描、文件下载、远端删除、连接测试 | +| `file-processing` | 解压缩、文件识别、CSV/XLSX 读取、字段映射、数据标准化、临时文件管理 | +| `database-service` | 数据库连接、库表管理、数据入库策略、SQL 执行、结果查询、导出 | +| `job-runner` | 手动任务、定时任务、脚本任务、任务依赖、失败重试、运行历史 | +| `web-ui` | Web 界面、配置管理、任务监控、数据管理、脚本管理、API 文档 | +| `api-client-contracts` | 前后端共享的 API 契约、类型约定、错误码和响应结构 | + +早期可以减少仓库数量,例如先保留 `platform-core`、`source-remote`、`file-processing`、`database-service`、`web-ui` 五个模块。等脚本和调度复杂度上来后,再把 `job-runner` 独立出去。 + +## 模块职责 + +### 平台核心模块 + +平台核心模块只负责平台级能力,不处理具体业务数据。 + +核心职责: + +- 任务、运行记录、数据源、数据集、文件、脚本、调度计划等基础模型。 +- 统一日志、错误码、状态流转和事件通知。 +- 配置读取、配置校验、敏感信息存储策略。 +- 模块注册和服务发现。 +- 认证、Token、权限、审计的基础接口。 + +不应承担: + +- FTP/SFTP 协议细节。 +- CSV/XLSX 解析细节。 +- 具体数据库 SQL 方言。 +- 具体业务报表规则。 + +### FTP/SFTP 模块 + +远程数据源模块只负责“远程文件发现和传输”。 + +核心职责: + +- FTP/SFTP 连接配置、测试、目录递归扫描。 +- 远程文件列表、文件大小、修改时间、文件名时间范围识别辅助信息。 +- 按规则下载文件和目录。 +- 下载完成后的远端源文件删除,只删除文件不删除目录。 +- 网络异常、断连、重试、超时控制。 + +不应承担: + +- 解压缩。 +- CSV/XLSX 字段解析。 +- 数据入库。 +- SQL 脚本执行。 + +### 文件处理模块 + +文件处理模块负责把原始文件转成“可入库的数据批次”。 + +核心职责: + +- ZIP 等压缩包解压,保留原始数据和清理临时展开文件。 +- CSV/XLSX 文件识别、编码识别、Sheet 过滤、表头识别。 +- 字段映射、类型转换、空值和异常值标准化。 +- 文件名日期解析、按目录选择最近 N 天或目标周期数据。 +- 生成数据批次元信息,例如来源文件、字段列表、行数、日期范围、目标逻辑表。 +- 为数据库模块提供流式或分块数据输入。 + +不应承担: + +- 真实数据库连接管理。 +- 建表、删表、查表。 +- SQL 脚本执行。 +- 结果表查询。 + +### 数据库模块 + +数据库模块负责“数据库侧”的所有能力。 + +核心职责: + +- 数据库连接配置、连接池、连接测试。 +- 数据库、表、字段、索引管理。 +- 数据入库策略:批量插入、数据库原生加载、临时表、落地中间文件。 +- SQL 脚本执行、SQL 查询、结果分页、结果导出。 +- 表结构查看、表数据管理、权限控制下的数据修改。 +- 多数据库适配的接口预留,早期以 MySQL 为主。 + +数据入库边界建议: + +- 文件处理模块负责解析、清洗、标准化和分批输出。 +- 数据库模块负责选择入库方式并执行入库。 +- 业务任务模块只编排“先处理文件,再入库,再执行脚本”,不直接操作底层文件或数据库细节。 + +性能取舍: + +- 大 CSV 优先走数据库原生批量加载能力。 +- 原生加载不可用时使用分块批量插入。 +- XLSX 不应整文件无脑载入内存,优先采用流式读取或按 Sheet 分块处理。 +- 大任务必须有进度、行数、速度、当前阶段和失败定位。 + +### 任务与脚本模块 + +任务模块负责“什么时候做、做什么、做到哪一步”。 + +核心职责: + +- 手动上传处理、远程下载处理、定时扫描处理。 +- SQL 脚本、Python 脚本和后续其他脚本类型的执行入口。 +- 任务状态、阶段、日志、运行历史、失败重试。 +- 调度策略:按小时扫描、按文件日期判断就绪、就绪后延迟触发。 +- 任务互斥、取消、超时、异常恢复。 + +任务模块不直接解析文件,也不直接写数据库底层逻辑,只调用文件处理模块和数据库模块。 + +### Web 界面模块 + +Web 界面模块负责平台交互,不包含业务处理逻辑。 + +核心页面建议: + +- 数据源配置:FTP/SFTP、连接测试、远端目录、删除源文件策略。 +- 文件处理配置:Sheet 过滤、字段映射、文件日期规则、目标表规则。 +- 数据库配置:连接、库表管理、数据查询、导出、SQL 执行。 +- 任务中心:手动运行、远程运行、定时任务、运行历史、日志。 +- 脚本中心:SQL/Python 脚本编辑、版本、执行记录。 +- API Token 与 API 文档。 +- 系统设置:用户、权限、历史保留、平台运行参数。 + +界面模块只调用 API,不直接耦合后端模块内部实现。 + +## 核心数据流 + +平台主链路建议分为以下阶段: + +1. 数据源扫描:远程模块返回文件清单和基础元信息。 +2. 数据选择:任务模块根据调度规则和文件日期规则选择待处理文件。 +3. 数据获取:远程模块下载文件,形成原始数据目录。 +4. 文件展开:文件处理模块解压压缩包并识别 CSV/XLSX。 +5. 数据标准化:文件处理模块完成编码、字段、类型、空值、异常值处理。 +6. 数据入库:数据库模块按入库策略写入目标库表。 +7. 脚本处理:任务模块调用数据库模块执行 SQL 或调用脚本执行器。 +8. 结果输出:数据库模块提供结果表查询、导出和 API 查询。 +9. 历史归档:任务模块记录原始数据、日志、结果、运行耗时和错误信息。 +10. 清理策略:按配置清理临时文件、历史记录和远端源文件。 + +## 关键模型 + +建议平台统一维护以下概念: + +| 模型 | 含义 | +| --- | --- | +| 数据源 | FTP/SFTP、本地目录或未来其他来源 | +| 文件清单 | 一次扫描得到的远程或本地文件列表 | +| 数据集 | 一批准备处理的数据,可以来自上传或远程下载 | +| 处理配置 | 字段映射、Sheet 过滤、文件日期规则、目标表规则 | +| 任务 | 一次手动或自动触发的处理动作 | +| 运行记录 | 任务的一次实际运行,包含状态、阶段、日志、耗时 | +| 数据批次 | 文件处理模块输出给数据库模块的标准化数据 | +| 脚本 | SQL、Python 或其他脚本类型 | +| 结果资源 | 结果表、导出文件、日志、原始数据归档 | + +## 配置设计 + +配置应分层: + +- 平台配置:端口、认证、历史保留、日志、存储目录。 +- 数据源配置:协议、地址、端口、账号、目录、超时、删除源文件策略。 +- 文件处理配置:文件类型、压缩包规则、字段映射、日期解析、异常值策略。 +- 数据库配置:连接、目标库、入库策略、表名前缀、大小写兼容。 +- 任务配置:调度周期、目标日期规则、失败重试、并发限制。 +- 脚本配置:脚本类型、执行环境、参数、权限。 + +敏感配置可以先支持明文内网部署,后续再增加加密存储或密钥管理。 + +## API 设计方向 + +API 应按资源分组,而不是按页面分组。 + +建议资源: + +- 数据源 API。 +- 文件与数据集 API。 +- 数据库 API。 +- 任务 API。 +- 脚本 API。 +- 历史 API。 +- Token 与用户 API。 +- 系统配置 API。 + +外部系统接入时优先通过 Token 调用任务和查询 API。API 文档应自动生成,并为常用接口提供中文说明、参数示例和错误说明。 + +## 调度设计 + +调度不应依赖服务器系统时间判断数据是否已经推送完成,应优先依据文件名中的数据日期或文件清单中的业务日期。 + +建议支持: + +- 每隔固定时间扫描远程目录。 +- 按目录判断目标日期是否齐全。 +- 空目录可配置为停推目录并跳过。 +- 日粒度和周粒度文件按文件名自动识别。 +- 就绪后先写入就绪标识,下一轮扫描再触发处理,避免刚推送完成时立即下载半成品。 +- 处理成功后按配置删除远端源文件,并清除就绪标识。 + +日粒度与周粒度规则必须配置化,避免不同目录、不同厂家、不同报表命名习惯互相影响。 + +## 存储与历史 + +建议保留以下目录概念: + +- 原始数据目录:保存下载或上传的原始文件。 +- 临时处理目录:保存解压后的 CSV/XLSX、中间转换文件。 +- 归档目录:保存可追溯的任务原始数据和日志。 +- 导出目录:保存临时导出文件,下载完成后自动清理。 +- 脚本目录:保存 SQL、Python 等脚本。 + +处理历史应支持: + +- 查看任务详情、阶段、日志。 +- 下载完整原始数据包。 +- 单文件或单目录下载。 +- 按保留次数或保留天数清理。 +- 清理临时文件但不误删原始归档。 + +## 当前项目可复用经验 + +当前 CapaReport 可作为参考的能力: + +- FTP/SFTP 下载、远程删除、连接测试。 +- 文件名日期解析和最近 N 天筛选。 +- ZIP 解压、CSV/XLSX 处理、字段映射。 +- MySQL 表管理、数据查询、CSV/XLSX 导出。 +- SQL 脚本执行和日志展示。 +- 处理历史、原始数据下载、临时文件清理。 +- API Token、OpenAPI 文档、Web 管理界面。 +- 自动调度的就绪标识和二次触发机制。 + +迁移时应避免直接复制成一个巨型模块,应先按职责拆分,再逐步搬运可复用逻辑。 + +## 开发路线 + +### 阶段一:平台骨架 + +- 建立 `platform/` 独立仓库。 +- 明确模块 submodule 目录。 +- 建立后端 Web 服务、前端壳、基础认证、配置读写。 +- 接入一个最小远程数据源配置和连接测试。 + +### 阶段二:文件到数据库主链路 + +- 完成远程下载、本地上传、解压、CSV/XLSX 识别。 +- 完成字段映射和基础类型转换。 +- 完成 MySQL 入库、表管理、数据查询。 +- 建立任务状态、日志和历史记录。 + +### 阶段三:脚本与调度 + +- 支持 SQL 脚本执行。 +- 支持定时扫描远程目录。 +- 支持按目录和日期判断数据就绪。 +- 支持处理成功后删除远端源文件。 + +### 阶段四:业务模板化 + +- 将容量报表作为一个业务模板接入平台。 +- 增加更多报表模板和处理脚本。 +- 支持任务参数、脚本参数、结果表配置。 + +### 阶段五:平台增强 + +- 增加权限细分、审计、任务重试、告警。 +- 增加更多数据库适配。 +- 增加模块版本管理和升级策略。 + +## 风险与决策点 + +| 风险 | 建议 | +| --- | --- | +| 模块拆得太细导致开发慢 | 早期只拆核心边界,复杂后再独立仓库 | +| 文件处理和数据库入库职责混乱 | 坚持文件模块输出标准数据批次,数据库模块负责落库 | +| 大文件处理性能不足 | 优先流式、分块、数据库原生批量加载 | +| 不同报表命名规则不一致 | 日期解析和周期规则配置化 | +| 脚本权限风险 | 脚本执行入口必须有权限控制和审计记录 | +| submodule 管理复杂 | 主仓库锁版本,模块仓库保持独立发布说明 | + +## 近期建议 + +先在 `platform/` 下建立独立平台主仓库,再从 CapaReport 中抽取最小可用能力:远程连接测试、文件日期解析、CSV 入库、任务日志。第一版不要急着覆盖所有报表,先跑通“远程目录到数据库结果表”的稳定链路。 diff --git a/docs/project_context.md b/docs/project_context.md new file mode 100644 index 0000000..4c64db7 --- /dev/null +++ b/docs/project_context.md @@ -0,0 +1,1122 @@ +# 项目上下文记录 + +## 2026-06-26:新增工具发布平台投稿文档(docs/upload) + +- 按工具平台投稿模板新增 `docs/upload/` 三份文档:`工具说明书.docx`、`测试报告.docx`、`工具详细介绍.md`。 +- Word 文档用 python-docx 生成(一次性脚本写在 tmp/docs 下,生成后已删除,依赖装在 .venv,未写入 requirements.txt)。设置了中文字体(微软雅黑)、标题/章节层级、蓝色表头底纹的表格样式。 + - 工具说明书:介绍(名称/版本 3.0.0/作者 NIXEVOL)、安装与配置、使用指南(6 模块)、常见问题(含桌面版闪退=9081 占用、默认账号 root/Capacity 等)。 + - 测试报告:介绍/测试目的/测试环境/测试用例/测试结果/改进规划/性能评估/总结;测试人员与联系信息留「(请填写)」占位,测试日期 2026-06-26。 +- 内容均以实际项目为准;本机无 LibreOffice,无法自动渲染 PNG,改用 python-docx 回读校验结构(章节数、表格行列、表头正确)。 +- 提示:`.docx`/`.md` 不在 .gitignore 忽略范围,会随仓库提交。 + +## 2026-06-26:新增使用说明 USAGE.md + 文档去 Metrix + +- 新增根目录 `USAGE.md`(Web 端使用说明):登录(`root`/`Capacity`)、整体流程与数据处理管线 Mermaid 图、准备工作/各功能页/系统设置标签页/注意事项均用表格。内容以实际代码为准:处理阶段取 `FileWorkflow.vue` 的 `stageLabels`(远程下载/更新 CellData/授权校验/解压/转换 Excel/导入/运行脚本);系统设置 6 个标签(数据库、远程数据源、自动调度、规则映射、修改密码,及仅 Metrix 启用时的「数据源/仓库」);数据处理含容量数据 + CellData 两区、本地上传 vs 远程下载;任务锁串行、文件名时间戳、每目录 7 天、授权按数据日期等注意事项。`README.md` 增加指向 USAGE.md 的链接。 +- 开发文档/README 去掉 Metrix 描述(暂未使用):移除 requests/csv_processor/pipeline/platform 等仅服务 Metrix 的条目与字样。 +- 文档命名:开发文档由 `docs/dev_guide.md` 移到根目录 `DEVELOPMENT.md`;README/USAGE/DEVELOPMENT 三篇均在根目录,`docs/` 仅保留 `project_context.md`。 + +## 2026-06-26:重写 README 与新增开发文档(以实际代码为准) + +- `README.md` 精简重写:系统简介、技术栈、6 个导航模块作用表、初次运行(默认账号 `root`/`Capacity`,需配套 FTP/SFTP 数据源 + MySQL 仓库)、运行与编译命令(复用 `scripts/` 下 py 脚本,可复制即用)、以及跳转开发文档的锚点链接。去掉了原 README 中偏底层的「常用接口」「维护注意事项」等内容。 +- 新增 `docs/dev_guide.md` 开发文档:①技术栈与主要库(仅列实际用到的:后端 fastapi/uvicorn/pandas/openpyxl/chardet/pymysql/cryptography/paramiko/requests;前端 vue/vue-router/naive-ui/@vicons/echarts/monaco-editor/@tauri-apps/api + vite 构建链;Tauri tauri/shell/dialog/reqwest/sysinfo/open/serde);②目录结构及各目录/文件职责;③导航栏 6 功能(数据处理/容量看板/处理历史/数据管理/脚本编辑/系统设置)逐一用表格列出前端组件、后端路由、相关服务文件与职责,方便定位修改点。 +- 文档事实核对要点(以代码为准):默认账号在 `app/auth.py`(`root`/`Capacity`,存 `auth.ini`);运行时 SQL 为 `ReportScript.sql` 与 `CellData.sql`(`app/config.py`);历史记录存 `cache/history.json`(`app/history.py`);6 个路由页对应 `frontend/src/router.ts`;端口 9081。 + +## 2026-06-26:统一运行/编译脚本为 scripts/ 下纯 Python(傻瓜式,自动 venv) + +把分散的运行/编译入口(根 `run.bat`/`debug.bat`/`start.sh`/`dev.py`、`scripts/build.{sh,ps1,bat}`、`supervisord.conf`、`packaging/Dockerfile`)清理掉,统一改为 `scripts/` 下只依赖标准库的 Python 脚本,可直接 `python scripts/xxx.py` 运行: + +- `scripts/_env.py`:共享工具。自动创建 `.venv`(标准库 `venv`,**不使用 uv**),按 `requirements.txt` 的 sha256(存 `.venv/.requirements.hash`)变化自动重装依赖;自动装前端依赖;检测 Node.js / Rust(cargo,rustc) / Docker,缺失时打印安装引导并退出;提供 `build_frontend`(可传 `VITE_API_BASE`)、PyInstaller 打包(`build_server_binary` onedir/onefile)、`build_tauri_sidecar`(按 rust host triple 命名复制到 `src-tauri/binaries`)、`remove_path`(限工作区内)等助手。额外包用 `pip show` 探测是否已装。 +- `scripts/dev.py`:开发测试(移植旧 `dev.py`)——后端 uvicorn `--reload`(9081) + 前端 Vite HMR(5174),多进程流式日志、Ctrl+C 统一退出。 +- `scripts/run.py`:本地运行(替代 `run.bat`)——构建前端(缺失时) + `python -m app.main`,支持 `--host/--port/--rebuild`。 +- `scripts/tauri_dev.py`:桌面端测试运行——构建前端(desktop api base) + 构建 sidecar(缺失时) + `cargo tauri dev`,`--rebuild` 强制重建。 +- `scripts/build_tauri.py`:桌面版编译——`--platform current|windows|linux|macos`,与宿主不一致时报错提示需本机构建(Tauri 不可靠交叉编译);Windows 复用旧 ps1 的 NSIS 模板定制(默认装 `D:\Program Files\CapacityReport`、内置 WebView2 离线安装器走 tauri.conf.json),临时改 `tauri.conf.json` 的 `nsis.template` 后构建并还原;产物收集到 `dist/desktop/`。 +- `scripts/build_server.py`:Server 便携版——PyInstaller onedir + 前端 dist + `Configure.json`/`ReportScript.sql`/`CellData.sql` + `cache/`/`logs/` + 启动脚本(win `run.bat`/unix `start.sh`),zip 打包,`--no-archive` 跳过。 +- `scripts/build_docker.py`:Docker 编译/更新——以**根 `Dockerfile` + `docker/entrypoint.sh`**(前端预构建、`/data` 卷、uvicorn)为准;`build`(默认)生成 `dist/docker/` 离线包(tar+compose+配置+mysql),`update` 重新构建并 `docker compose ... up -d --force-recreate`,`--no-save` 跳过 tar。 +- `scripts/clean.py`:清理 `__pycache__`/`*.pyc`、编译产物(`dist`、`frontend/dist`、`.vite`、`src-tauri/target`、`src-tauri/binaries`、各 `.cache`)、运行时临时数据(`cache`/`logs`/`uploads`);`--deep` 再清 `.venv`/`node_modules`。遍历跳过 `.git/.venv/node_modules`,仅删工作区内路径。 +- `scripts/gen_license_code.py` 保留。 + +清理与统一: +- 删除 `scripts/build.sh`、`scripts/build.ps1`、`scripts/build.bat`、根 `run.bat`/`debug.bat`/`start.sh`/`dev.py`、`supervisord.conf`、`packaging/Dockerfile`(supervisord 多阶段那套已废弃,确认 `app/` 无 supervisor 引用、根 Dockerfile 用 `docker/entrypoint.sh`)。 +- 重写 `packaging/docker-compose.yml` 匹配根 Dockerfile:app 服务挂 `capacity-data:/data` 命名卷(不再 `/app` 挂载 + supervisord 环境变量),保留 MySQL 服务与 `mysql/` 初始化/配置。 +- 运行时 SQL 文件确认为 `ReportScript.sql` 与 `CellData.sql`(`app/config.py`:`CELLDATA_SCRIPT = BASE_DIR / "CellData.sql"`,非 `CellDataScript.sql`)。便携版/Docker 离线包按此打包。 +- 环境检测:Tauri 桌面端用 Rust(非 Go),脚本检测 Node.js + Rust(+Docker 镜像);用户最初提到的「go」实为 Rust。 +- `README.md` 改写「运行与编译脚本」表格与各分项说明,目录结构条目改为 `scripts/`(py 脚本) 与根 `Dockerfile`。 +- 验证:9 个脚本 `python -m py_compile` 全通过;`clean.py`/`build_docker.py`/`build_tauri.py` `--help` 正常。 + +## 2026-06-01:修复前端 npm audit moderate 漏洞 + +- `frontend/package.json` 增加 `overrides.dompurify=3.4.7`,将 `monaco-editor@0.55.1` 间接依赖的 vulnerable `dompurify@3.2.7` 覆盖到安全版本。 +- 未执行 `npm audit fix --force`,因为 npm 建议的自动修复会把 Monaco 降级到 `0.53.0`,属于破坏性方向;override 保持 Monaco 版本不变,风险更小。 +- 已验证 `npm audit --json` 返回 0 漏洞,`npm ls monaco-editor dompurify` 显示 `monaco-editor@0.55.1 -> dompurify@3.4.7 overridden`,`npm run build` 通过。 + +## 2026-06-01:Naive UI 自动按需导入 + +- `frontend/vite.config.ts` 增加 `unplugin-vue-components`、`unplugin-auto-import` 和 `NaiveUiResolver`,模板中的 `n-*` 组件改为构建期自动按需导入;`dts` 关闭以避免生成额外类型文件。 +- `frontend/src/main.ts` 移除 `app.use(naive)` 全量注册,保留脚本中对 `useMessage`、`useDialog`、`darkTheme` 等 Naive UI API 的显式导入。 +- 已验证 `npm run build` 通过:主入口 JS 从约 1497KB 降到约 572KB,gzip 从约 415KB 降到约 171KB;剩余大 chunk 主要是已懒加载的 Monaco `ScriptPanel` 和 Swagger `ApiDocs`。 +- 注意:`npm install` 报告 2 个 moderate audit 项,未执行 `npm audit fix --force`,避免自动升级引入额外风险。 + +## 2026-06-01:前端非首页路由懒加载 + +- `frontend/src/router.ts` 保留首屏 `FileWorkflow` 同步加载,将 `HistoryPanel`、`DatabasePanel`、`SettingsPanel` 改为动态导入,和已有的 `ScriptPanel`、`ApiDocs` 保持一致。 +- 已验证 `npm run build` 通过:主入口 JS 从约 1556KB 降到约 1497KB,非首页页面拆成独立小 chunk;`ScriptPanel` 和 `ApiDocs` 仍是独立懒加载重 chunk。 +- 当前剩余大 chunk 主要来自 Monaco Editor、Swagger UI 和主包中的 Naive UI/Vue 生态;Monaco/Swagger 已经不影响首屏,若继续压主入口体积,下一步应评估 Naive UI 按需引入。 + +## 2026-06-01:完善 RJ 自动调度日/周粒度识别 + +- `app/utils/file_dates.py` 新增文件日期范围解析:`XXX_YYYYMMDDHHMM` 视为单日文件,`XXX_YYYYMMDDHHMM_YYYYMMDDHHMM` 按起止时间展开自然日,结束时间为零点时按右开区间处理。 +- `app/services/auto_scheduler.py` 的 RJ 检查改为按目录最新 ZIP 自动识别 `daily` 或 `weekly`:日粒度目录要求目标自然周 7 天都存在,周粒度目录要求有一个 ZIP 覆盖目标自然周;空 RJ 目录继续视为停推并跳过。 +- 自动调度普通 4G/5G 目录扫描会排除已配置的 RJ 目录,避免 `expected_directories=[]` 时 RJ 周目录被普通 7 天规则误判阻塞。 +- `app/services/remote_download.py` 的调度下载筛选改为使用日期覆盖范围:单日文件只要覆盖目标日即下载,多日/周文件必须覆盖完整目标周才下载,避免 ready 后漏下或误下 RJ 周文件。 +- `Configure.json` 将 `RJ/700M/700RJGD`、`RJ/700M/700RJYD` 加入 RJ 数据目录,并补充 `700MRJGD`、`700MRJYD` 字段映射;`processor.py` 避免多个源字段别名映射到同一目标字段时生成重复列。 +- 配置上传接口现在会导入/保存 `RJData`,前端类型补充 `rj_data` 和调度状态中的 `granularity` 字段。 +- 已验证:后端 AST 语法检查、`Configure.json` JSON 解析、`npm run build`、真实 SFTP 清单识别、MySQL 临时导入 700RJYD 样本并清理测试表均通过;前端构建仅保留既有大 chunk 警告。 + +## 2026-05-29:新增 RJ 周数据处理功能 + +- 新增 `RJData` 配置块到 `app/config.py`,支持 `enabled`、`weekly_directories` 和 `table_field_mappings` 配置项,用于管理 RJ 周数据目录和字段映射。 +- `app/services/auto_scheduler.py` 新增 `RJWeeklyDirectoryStatus` 数据类和 `_check_rj_weekly_ready()` 方法,支持检查 RJ 周数据目录是否包含目标周的文件。 +- 自动调度逻辑改为:现有 7 天目录检查 **且** RJ 周数据检查都满足时才触发处理,两个条件是 AND 关系。 +- `app/processor.py` 新增 `_get_field_map_for_table()` 方法和 `_find_rj_data_directories()` 方法,支持 RJ 表使用专用字段映射。 +- RJ 数据目录结构:`/CapacityReportData/RJ/2.6G/2.6RJGD/` 和 `RJ/2.6G/2.6RJYD/`,每个目录每周一个 ZIP 文件。 +- 目标表名映射:`2.6RJGD` -> `2_6GRJGD`,`2.6RJYD` -> `2_6GRJYD`。 +- 字段映射配置:`开始时间`、`结束时间`、`gNBId`、`cellId`、`gNBplmn`、`上下行总流量_GB`。 +- `Configure.json` 新增 `RJData` 配置块,包含启用状态、周数据目录列表和表字段映射。 +- 已验证:`.venv\Scripts\python.exe -m compileall app` 通过;GD 和 YD 数据字段映射测试成功,6 个字段全部匹配。 + +## 2026-05-25:新增远程自动调度和每目录 7 天处理窗口 + +- 新增 `app/utils/file_dates.py`,统一解析文件名中的第一个 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳,并提供按目录筛选最近 7 个自然日文件的工具;本地手动上传和远程下载后的 ZIP、Excel、CSV 处理都会按所在目录只保留最近 7 天文件,未携带日期且同目录没有任何可识别日期时保留兼容。 +- `app/services/remote_download.py` 增加远程 ZIP 清单扫描和筛选下载:普通远程处理按每个远程目录下载最近 7 天 ZIP;自动调度触发时按 ready flag 中记录的目标日期精确下载,若没有匹配 ZIP 不会回退全量下载,避免误处理新旧混杂数据。 +- `app/config.py` 增加 `RemoteData.auto_scheduler` 配置,包含 `enabled`、`check_interval_hours`、`expected_directories` 和 `week_offset`。自动调度开启时后端会强制 `RemoteData.enabled=True` 和 `auto_delete_source=True`,前端也同步灰显并强制打开相关开关。 +- 新增 `app/services/auto_scheduler.py` 后台线程:应用启动后按配置间隔检查 FTP/SFTP 目录,使用本机当前日期计算目标自然周(`week_offset=0` 为上周,`-1` 为上上周),但文件覆盖情况完全以 ZIP 文件名日期为准;全部目录覆盖 7 天后写入 `cache/auto_scheduler/ready.flag`,下一轮检测再触发远程下载并处理。 +- 调度成功且远程源文件清理成功后会删除 ready flag;处理失败、触发失败或源文件清理失败会保留 ready flag 供下轮重试。扫描失败会记录 `scan_failed`,连续失败次数通过状态接口返回,前端会显示红色失败状态。 +- `app/api/routers/remote.py` 新增 `/api/remote/scheduler/status` 和 `/api/remote/scheduler/trigger`,并将远程处理启动逻辑抽成 `start_remote_processing_job()` 供手动按钮和调度器复用。 +- `frontend/src/components/SettingsPanel.vue` 在远程数据源配置中加入自动调度区域:启用开关、检查间隔、目标周期、预期目录维护、调度状态、刷新状态和立即检查。配置下载/上传会随 `RemoteData` 一起携带自动调度配置。 +- 已验证:文件名日期解析、目标周计算、每目录 7 天筛选、调度开启强制删除源文件配置、调度日期精确筛选逻辑均通过临时 Python 片段;`.venv\Scripts\python.exe -m compileall app` 与 `npm run build` 通过,前端构建仅保留既有 Vite 大 chunk 警告。 +- 后续调整:预期目录可访问但完全没有 ZIP 文件时,自动调度会把该目录标记为 `skipped/已停推`,不再阻塞其他目录就绪;但所有目录都为空时不会写入 ready flag,也不会触发处理。 + +## 2026-05-25:修复 API Token 指定日期输入不可见 + +- `frontend/src/components/ApiTokenManager.vue` 中创建/编辑 Token 的到期日期控件从 `n-date-picker` 改为原生 `input[type=date]`,避免日期选择组件在弹窗内出现占位但输入框不可见的问题。 +- 原生日期输入继续使用 `YYYY-MM-DD` 字符串绑定到 `tokenForm.expires_at`,后端 `/api/tokens/create` 和 `/api/tokens/update` 请求体不变;仅补充本地样式以匹配当前主题和 Naive UI 表单尺寸。 +- 已验证:`npm run build` 和 `.venv\Scripts\python.exe -m compileall app` 通过;浏览器实测 `系统设置 > API Token > 生成 Token > 指定日期` 下日期输入框可见,并可输入 `2026-12-31`。 + +## 2026-05-25:拆分 API Token 管理和 API 文档 + +- 前端删除旧 `frontend/src/components/ApiCenter.vue`,拆为 `ApiTokenManager.vue` 和 `ApiDocs.vue`:API Token 管理迁入 `系统设置 > API Token` 独立分页,左侧菜单“API 中心”改为“API 文档”,只展示 Swagger 文档。 +- API 文档正式路由为 `/api-docs`,旧 `/api-center` 保留为前端兼容别名;后端 `/api/docs-ui` 改为跳转 `/api-docs`。Swagger UI 仍从登录后可见的 `/api/openapi.json` 加载,Token 传递示例指向系统设置中的 API Token 分页。 +- `app/main.py` 的 OpenAPI 后处理增加中文 tag、接口 summary/description、常用请求示例和稳定 operationId;业务 API 声明登录 JWT 或 API Token 鉴权,配置/授权/Token 管理/文档接口仍只声明登录 JWT。Swagger 前端隐藏底部 Schemas 区域,减少噪音。 +- 修复登录页按钮点击不触发登录的问题:登录按钮改为显式调用 `submit()`,保留密码框回车提交,并在 loading 时防止重复提交。 +- 已验证:`.venv\Scripts\python.exe -m compileall app`、`npm run build` 通过;真实 HTTP 检查未登录 `/api/openapi.json` 返回 401、登录后返回 `CapacityReport API`;浏览器实测点击登录按钮可登录,左侧显示 `API 文档`,系统设置显示 `API Token` 分页,API 文档页可见中文 Swagger 分组。 + +## 2026-05-23:新增 API Token 和离线 API 文档 +- API Token 验收补强:非永久 Token 必须明确传入到期日期,非法或缺失到期日期返回 400;到期、停用、重生成旧 Token 均会拒绝业务 API。已实测 API Token 可执行 `/api/database/execute` 和 `/api/database/table/query`,但不能访问 `/api/tokens` 管理接口。 +- `frontend/src/components/ApiCenter.vue` 的 Swagger UI 改为通过 `apiUrl('/api/openapi.json')` 加载文档,并在请求拦截器中只在未手动填写 Authorization 时补登录 JWT;桌面端或配置 `VITE_API_BASE` 时,Try it out 请求会自动补全后端基址,避免相对 `/api/*` 请求打到错误 origin。 +- 新增 `app/services/api_tokens.py` 和 `app/api/routers/api_tokens.py`:API Token 存储在运行时 `api_tokens.json`,只保存 HMAC-SHA256 哈希、前后缀、启用状态、到期时间和最近使用信息;完整 Token 仅在创建或重生成时返回一次。`api_tokens.json` 已加入 `.gitignore`。 +- `app/auth.py` 增加登录态和访问态解析:登录态只接受 JWT/cookie,业务访问态接受登录 JWT、`Authorization: Bearer ` 和 `X-API-Token`。Token 管理、系统配置、授权和 API 文档仍要求登录后访问。 +- `app/main.py` 的全局鉴权中间件改为 JWT + API Token 双鉴权;业务 API 可由 API Token 调用,`/api/openapi.json` 和 `/api/docs-info` 仅登录后可见。OpenAPI schema 同时声明 `BearerAuth` 和 `ApiTokenHeader`,便于外部系统对接。 +- 前端新增 `frontend/src/components/ApiCenter.vue`,侧边栏新增 `API 中心`;页面支持 Token 列表、创建、编辑启停/有效期、重生成、删除、复制一次性完整 Token,并内嵌本地 `swagger-ui-dist` 文档。API 中心懒加载,避免 Swagger UI 影响普通页面首屏。 +- 新增前端依赖 `swagger-ui-dist`,并在 `frontend/package.json` 中关闭 Scarf 安装期匿名统计配置,确保运行期和离线部署不依赖外网 CDN。 +- 已验证:`.venv\Scripts\python.exe -m compileall app`、`npm run build` 通过;本地服务实测未登录访问 `/api/openapi.json` 返回 401,登录创建临时 Token 成功,API Token 可访问 `/api/database/tables` 且不能访问 `/api/tokens`,登录访问 `/api/openapi.json` 成功,非法到期日期返回 400,临时 Token 已删除。 +## 2026-05-22:统一日志框与桌面端下载路径反馈 + +- 数据处理页菜单从“数据上传”改为“数据处理”,路由标题同步改为“数据处理”。 +- 上传/处理日志、历史详情日志和脚本执行日志统一使用分级日志渲染:默认/INFO 跟随主题文字色,SUCCESS 绿色,WARNING 黄色,ERROR 红色;日志框背景和滚动条颜色跟随主题,支持横纵向滚动。 +- 数据处理页日志高度改为响应式上限,避免长任务日志把外层页面撑出纵向滚动条。 +- 脚本编辑页的手动运行日志移到编辑器下方,编辑器与日志框共享页面高度;脚本运行状态保存在前端全局状态中,切换页面回来可继续轮询或查看上次运行结果,运行结束不自动隐藏日志。 +- 桌面端下载完成弹窗会显示保存路径,并提供“打开所在文件夹”;新增 Tauri 命令 `open_path_in_file_manager` 通过系统文件管理器打开下载目录。 + +## 2026-05-21:桌面端 sidecar 进程改为跨平台管理 + +- Tauri 桌面端启动/关闭后端 sidecar 不再调用 Windows `netstat`、`tasklist`、`taskkill` 等控制台命令,避免启动和退出时闪过 DOS 窗口。 +- 桌面端启动 sidecar 后会在应用数据目录写入 `server.pid`;下次启动前读取该 pid,并通过 `sysinfo` 跨平台确认进程名为 `capareport-server` 后再终止残留进程,pid 被复用为其他程序时不会误杀。 +- 关闭桌面端时只使用 Tauri shell 的 `CommandChild.kill()` 停止当前 sidecar,并删除 `server.pid`;若 `9081` 被非本程序占用,则启动前直接报端口占用错误。 +- 新增 Rust 依赖 `sysinfo`,仅启用 `system` feature;已验证 `npm run build` 和带临时 sidecar 占位文件的 `cargo check --manifest-path src-tauri\Cargo.toml` 通过,验证产物已清理。 + +## 2026-05-21:收紧系统设置页头部空间 + +- 系统设置页移除了内容卡片内重复的“系统设置/更新时间”标题栏,保留外层统一页面标题,减少首屏垂直空间占用。 +- 配置更新时间改为页面头部动作区的文本项,显示在“下载配置”按钮左侧;窄屏下沿用全局头部规则隐藏辅助文本。 + +## 2026-05-21:增加下载完成提示弹窗 + +- 数据表 CSV/XLSX 导出、历史数据压缩包下载和配置文件下载在下载流程完成后会弹出 Naive UI 成功对话框,使用绿色成功图标提示用户文件已下载完成。 +- 提示逻辑统一放在 `frontend/src/composables/downloadFeedback.ts`;桌面端会在 Tauri 原生保存流程确认写入后提示,用户取消保存时不弹完成提示,Web 端会在浏览器下载触发后提示。 + +## 2026-05-21:排查离线机器桌面端白屏 + +- 运行时外链扫描确认:`frontend/src`、`app`、`src-tauri/src` 中没有 CDN、在线字体或外网业务接口;桌面端前端只访问本机 `http://127.0.0.1:9081` sidecar,`src-tauri/tauri.conf.json` 的 `https://schema.tauri.app/config/2` 只是编辑器/构建 schema,不参与用户机器运行。 +- 离线白屏的主要风险点是 Windows WebView2 Runtime:Tauri 默认 `webviewInstallMode` 为 `downloadBootstrapper`,目标机器没有外网且未预装 WebView2 时,安装或启动阶段可能无法正常创建 WebView。`src-tauri/tauri.conf.json` 已改为 `bundle.windows.webviewInstallMode = { type: "offlineInstaller", silent: true }`,新的 Windows 安装包会内置 WebView2 离线安装器。 +- 清理了 `src-tauri/tauri.conf.json` 中误残留的本机构建临时 NSIS `template` 绝对路径;该路径不应进入源码或发布配置。 +- 已验证 `npm run build` 和带临时 sidecar 占位文件的 `cargo check --manifest-path src-tauri\Cargo.toml` 通过;构建产物和临时 sidecar 占位文件需在提交前清理。 + +## 2026-05-21:修复桌面端下载不弹保存路径 + +- 桌面端不再依赖 WebView 的 `` 行为保存文件;`frontend/src/api/client.ts` 在 Tauri 环境下会通过 `@tauri-apps/api/core` 调用原生命令,普通浏览器和 Server Portable 仍保留原有 Blob 下载逻辑。 +- `src-tauri/src/main.rs` 新增 `download_to_file` 命令:先弹出系统保存对话框,再使用 Rust `reqwest` 按前端传入的 HTTP 方法、URL、Header 和请求体流式请求后端接口,并写入用户选择的路径;用户取消保存时不报错,HTTP 错误会带回前端并继续触发 401 退出登录逻辑。 +- 新增依赖 `@tauri-apps/api`、`tauri-plugin-dialog`、`reqwest` 和 `serde`;Tauri schema 文件会因 dialog 插件更新,`src-tauri/gen/schemas/` 仍需保留在版本库中用于 VS Code JSON 校验。 +- 已验证 `npm run build`、`.venv\Scripts\python.exe -m compileall app`、`cargo fmt --manifest-path src-tauri\Cargo.toml --check` 和带临时 sidecar 占位文件的 `cargo check --manifest-path src-tauri\Cargo.toml` 均通过;验证后已清理 `frontend/dist`、`src-tauri/target`、`src-tauri/binaries` 和 Python 缓存。 + +## 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:修复桌面版跨源预检导致配置网络错误 + +- 桌面版前端访问 `127.0.0.1:19082` 属于 WebView 跨源请求,带 `Authorization` 或上传配置文件时浏览器会先发 `OPTIONS` 预检;`app/main.py` 的 JWT 中间件现在直接放行 `OPTIONS`,让 FastAPI CORS 中间件返回允许头,避免配置读取和配置上传显示“网络错误”。 +- 桌面版版本提升为 `2.0.3`,同步更新 `app/main.py`、健康检查、服务状态、Tauri 配置、Cargo 包版本和侧边栏版本号,避免同版本安装包覆盖时难以确认是否装到新包。 +- `src-tauri/Cargo.toml` 启用 Tauri `devtools` feature,`tauri.conf.json` 主窗口设置 `devtools: true`;Windows release 桌面包可按 `F12` 或右键打开开发者工具排查真实请求。 +- 已用真实 uvicorn 服务验证 `Origin: http://tauri.localhost` 下 `/api/config/full` 和 `/api/config/upload` 的 `OPTIONS` 预检均返回 200,并且登录后 `/api/config/full` 可正常返回;随后执行 `scripts\build.bat desktop` 生成 `dist\desktop\CapacityReport_2.0.3_x64-setup.exe`,静默安装启动后验证 `/health` 返回 `2.0.3`、配置读取正常、脚本读取正常、配置上传返回 200。 + +## 2026-05-20:修复桌面版残留 sidecar 和卸载用户数据选择 + +- `src-tauri/src/main.rs` 启动 sidecar 前会先清理占用 `19082` 的旧 `capareport-server` 监听进程,避免卸载/重装或异常退出后连到旧服务;启动后不只检查端口可连接,还会请求 `/health` 返回 HTTP 200 才继续。 +- `frontend/src/api/client.ts` 在 Tauri 运行环境下即使构建时未注入 `VITE_API_BASE`,也会兜底使用 `http://127.0.0.1:19082`,并保留短暂 fetch 重试,避免桌面版出现配置页默认空值和脚本页 `Failed to fetch`。 +- Windows 桌面包收敛为 NSIS `setup.exe`,不再同时产出 MSI;新增 `src-tauri/windows/nsis-hooks.nsh`,卸载前会尝试关闭桌面进程和 sidecar,卸载后会询问是否删除 `%APPDATA%\com.nixevol.capacityreport` 中的配置、脚本、授权、缓存和日志。 +- 已执行 `scripts\build.bat desktop`,产物为 `dist\desktop\CapacityReport_2.0.2_x64-setup.exe`;脚本已自动清理 `dist/.tmp`、`frontend/dist`、`src-tauri/target` 和 `src-tauri/binaries`。 +- 已用新 NSIS 包静默覆盖安装并启动桌面版验证:`/health` 正常,`/api/config/full` 读取到 32 个字段映射,`/api/script/content` 成功读取 AppData 下的 `ReportScript.sql`;验证结束后已停止测试启动的桌面和 sidecar 进程。 + +## 2026-05-20:修复桌面版启动期配置和脚本加载竞态 + +- 桌面版运行配置和脚本仍从安装包资源 `Configure.json`、`ReportScript.sql` 首次复制到系统 AppData 后读取;安装目录中的 `_up_` 是 Tauri 对 `../` 资源的打包目录,不是后端实际运行目录。 +- `src-tauri/src/main.rs` 在启动 Python sidecar 后会等待 `127.0.0.1:19082` 可连接,最多等待 20 秒;如果端口没有起来,会主动杀掉刚启动的 sidecar 并让启动失败,避免前端先加载导致配置页停在默认空表单、脚本页停在“正在加载”。 +- `frontend/src/api/client.ts` 对普通 `fetch` 请求增加短暂重试,处理桌面 sidecar 启动或服务重启瞬间的 `Failed to fetch`;上传 XHR 不做自动重试,避免重复上传。 +- 已实测当前安装目录 `D:\Program Files\CapacityReport\_up_` 和运行目录 `%APPDATA%\com.nixevol.capacityreport` 均存在配置与脚本,`http://127.0.0.1:19082/health`、`/api/config/full`、`/api/script/content` 均能读取;本次修复的是前端初始请求早于 sidecar 就绪的竞态。 +- 已执行 `.venv\Scripts\python.exe -m compileall app`、`npm run build` 和带临时 sidecar 占位文件的 `cargo check --manifest-path src-tauri\Cargo.toml`,均通过;生成产物随后清理。 + +## 2026-05-20:修复登录失败误提示会话过期 + +- `frontend/src/api/client.ts` 不再把 `/api/login` 的 401 响应当作全局会话过期处理,登录失败会按后端真实错误显示“账号或密码错误”。 +- 已登录业务接口遇到 401 时仍会清理本地 token 并切回登录页,但 `AppShell` 不再额外弹出全局“登录已过期”提示,避免组件自身错误提示和全局提示同时出现。 +- 默认登录密码仍由 `app/auth.py` 定义为 `Capacity`,大小写敏感;如本地 `auth.ini` 未修改,输入小写 `capacity` 会按正常登录失败处理。 +- 已执行 `npm run build`,构建通过;前端构建仅保留 Vite 大 chunk 提示,生成产物随后清理。 + +## 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` 后,被某个单项保存接口用旧内存配置覆盖。 +- 本地/远程处理任务启动时会读取一次最新配置并作为任务快照传入 `DataProcessor`;任务运行过程中不再反复重载,避免处理中途改配置导致同一任务前后规则不一致。 +- 已执行 `.venv\Scripts\python.exe -m compileall app`、`uvx --offline ruff check .` 和 `npm run build`,均通过;前端构建仅保留 Vite 大 chunk 提示。 + +## 2026-05-19:补全字段映射配置 +- 当前本地 `Configure.json` 的 `ExtractField` 已按旧版可用映射补全:`基站名称` 增加 `ENBFunction名称`,`ERAB流量` 增加 `ERAB流量(新高负荷)_1538186901014-7-0`,`上行流量_GB/下行流量_GB` 增加 `上行流量(GB)/下行流量(GB)`。 +- 4G/5G 数值字段显式补回 `Type: float/int`,避免依赖 SQL 脚本推断类型;`AppConfig.load()` 已验证能读取 32 个字段映射和正确的 `SheetFilter`。 +- 修改配置时需要注意 Windows PowerShell 管道的中文编码问题;如果要脚本化写入 `Configure.json`,优先从已有 UTF-8 JSON 读取并用 Unicode escape 合并,避免把中文字段写成 `????`。 + +## 2026-05-19:清理生成 CSV 并支持历史原始数据下载 +- `DataProcessor` 现在会追踪 ZIP 解压出的 CSV 和 Excel 转换生成的 CSV,只有这些处理过程中生成的临时 CSV 会在对应 CSV 成功导入后自动删除;原始 ZIP、Excel 和用户本来上传/远程下载得到的原始 CSV 不会被误删。 +- ZIP 解压从 `extractall()` 改为逐条安全解压,会跳过越界路径条目,并在解压 CSV 时登记为后续可清理的临时文件。 +- `POST /api/history/download` 会校验历史任务目录必须位于 `cache/` 下,任务完成后才能下载;接口将整个历史工作目录压缩为 ZIP 返回,并通过 `BackgroundTask` 在响应结束后删除临时压缩包。 +- `frontend/src/components/HistoryPanel.vue` 在历史列表的“详情”左侧增加“下载”按钮,下载时显示 loading,未完成任务禁用下载,避免重复点击和下载不完整的历史数据。 +- 已执行 `.venv\Scripts\python.exe -m compileall app`、`uvx --offline ruff check .` 和 `npm run build`,均通过;本次未启动浏览器或 headless Chrome。 + +## 2026-05-19:调整数值异常值归零和完成后日志高度 + +- `DataProcessor` 数值字段清洗策略从异常值写入 `NULL` 改为写入 `0`:空串、`-`、`--`、长短横线、`NA/N/A/NULL/NONE/NAN/\N` 以及其它无法转数值的文本都会归零,正常 `0` 不受影响。 +- 数值格式继续清理千分位逗号、全角逗号、半角/全角百分号和空白;例如 `12,345.123` 会导入为 `12345.123`,`95%`/`95%` 会导入为 `0.95`。 +- `frontend/src/components/FileWorkflow.vue` 在任务完成或失败后给处理进度区增加 `finished` 状态,`frontend/src/styles.css` 让完成后的日志框使用自适应最大高度,避免上传区恢复显示后日志仍按运行中高度撑开页面。 +- 已执行数值转换样例验证、`.venv\Scripts\python.exe -m compileall app`、`uvx --offline ruff check .` 和 `npm run build`,均通过。 + +## 2026-05-19:修复 CSV 导入阶段数值截断错误 + +- `DataProcessor` 仍会根据 `ReportScript.sql` 的 `MODIFY COLUMN` 提前把业务数值字段建成 `INT/FLOAT`,但数值清洗改为返回真正的 Python `None/int/float`,避免 pandas `` 或异常文本被 PyMySQL 当作字符串写入数值列。 +- 数值字段导入前会把空串、`-`、`--`、长短横线、`NA/N/A/NULL/NONE/NAN/\N` 等源 CSV 占位符转为数据库 `NULL`,正常 `0` 保留为 `0`;逗号/全角逗号、半角/全角百分号和空白仍按数值格式清理。 +- 该问题本质是新版提前按 SQL 类型建表后,MySQL 严格模式会在 CSV 导入阶段拒绝脏数值;旧版多为字符串先落库,所以不会在导入阶段出现 `Data truncated for column`。 +- 已执行数值转换样例验证、`.venv\Scripts\python.exe -m compileall app` 和 `uvx --offline ruff check .`,均通过。 + +## 2026-05-19:优化处理进度阶段显示和日志跟随 + +- `ProcessLogger` 新增轻量阶段回调,`DataProcessor.process()` 会在远程下载后依次上报 `extracting`、`converting`、`importing`、`scripting`、`completed/failed` 阶段。 +- `/api/process/status` 和 `/api/task/status` 返回当前 `stage`,本地上传处理和远程下载处理都通过 `state.processing_tasks` 与全局任务锁同步阶段,前端轮询即可实时显示“远程下载中 / 解压数据中 / 上传数据中 / 运行脚本中”等状态。 +- `frontend/src/components/FileWorkflow.vue` 的处理进度卡片新增“保持最新 Log”勾选框,勾选后新日志到达会自动滚动到日志底部;当前任务提示不再显示原始阶段码,改为中文阶段文本。 +- 已执行 `.venv\Scripts\python.exe -m compileall app`、`uvx --offline ruff check .` 和 `npm run build`,均通过。 + +## 2026-05-19:清理后端冗余代码和未用依赖 + +- `app/database.py` 移除未使用的 SQLAlchemy 连接池、`engine` 属性、`dispose()` 空释放路径和未引用的 `delete_rows()`;数据库访问统一保留现有 PyMySQL 上下文连接。 +- `app/api/routers/database.py`、`app/api/routers/health.py` 和 `app/processor.py` 同步去除无效 `dispose()` 调用,避免保留没有实际资源释放意义的样板代码。 +- `requirements.txt`、`run.bat` 和 `README.md` 移除 SQLAlchemy 依赖和说明;`build/build.py` 清理无用端口常量、内联导入和宽泛异常捕获。 +- 已清理本地 `.ruff_cache/` 与重复的 `ReportScript.sql.bak`;已执行 `.venv\Scripts\python.exe -m compileall app build`、`uvx ruff check .`、`uvx vulture app build --min-confidence 80` 和 `npm run build`,均通过。 + +## 2026-05-19:移除旧版 HTML 前端和双端口托管 + +- 删除 `frontend_old/` 旧版 HTML/CSS/JS 前端及其本地 Monaco 资源,项目只保留 Vue 3 新前端。 +- `app/main.py` 移除旧版前端托管、`/old` 路由、`9082` 端口和双 socket 分流逻辑,运行时只监听 `9081` 并托管 `frontend/dist`。 +- `run.bat`、`build/Dockerfile`、`build/docker-compose.yml`、`build/build.py`、`build/README.md` 和 `README.md` 同步移除旧版端口说明及 `19082 -> 9082` 映射。 +- 已执行 `.venv\Scripts\python.exe -m compileall app build`,旧版引用扫描未发现剩余可执行入口。 + +## 2026-05-19:修复数据管理导出下拉选择不触发弹窗 + +- `frontend/src/AppShell.vue` 将页头下拉动作从模板事件表达式 `@select` 改为 `:on-select` 回调属性,确保 Naive UI 下拉菜单选择 CSV/XLSX 后会真正执行页面动作。 +- 修复数据管理页点击“导出”下拉项后 CSV/XLSX 表选择弹窗不显示的问题。 +- 已执行 `npm run build`,构建通过;构建只保留 Vite 原有大 chunk 提示。 + +## 2026-05-19:优化数据管理导出入口和弹窗宽度 + +- `frontend/src/composables/pageHeader.ts` 和 `frontend/src/AppShell.vue` 为页面顶部动作支持 Naive UI 下拉菜单,按钮内显示下拉箭头。 +- `frontend/src/components/DatabasePanel.vue` 将顶部“导出 CSV / 导出 XLSX”两个按钮合并为一个“导出”下拉按钮,点击后选择 CSV 或 XLSX 再进入对应表选择弹窗。 +- CSV 和 XLSX 导出弹窗改为固定 420px 内的响应式宽度,避免在宽屏下铺满整页;表选择列表增加边框和背景,视觉上更集中。 +- 已执行 `npm run build`,构建通过;构建只保留 Vite 原有大 chunk 提示。 + +## 2026-05-19:调整数据管理导出交互并支持多表 XLSX + +- `frontend/src/components/DatabasePanel.vue` 将导出入口移到页面顶部,并放在“删除全部表”按钮左侧;内容区工具栏只保留刷新、清空、删除当前表。 +- 点击“导出 CSV”会弹出全部表单选弹窗,用户选择一张表后下载 CSV;点击“导出 XLSX”会弹出全部表多选弹窗,用户可选择多张表并下载同一个 XLSX。 +- `app/api/routers/database.py` 的 `/api/download` 支持 `table_names`,XLSX 会按表名分 sheet 写入同一工作簿,sheet 名会兼容 Excel 的非法字符和 31 字符限制;CSV 仍限制单表导出。 +- `frontend/src/api/client.ts` 的 POST 下载会优先使用后端 `Content-Disposition` 文件名,便于多表导出使用服务端生成的文件名。 +- 已执行 `.venv\Scripts\python.exe -m compileall app` 和 `npm run build`,均通过;构建只保留 Vite 原有大 chunk 提示。 + +## 2026-05-19:优化数据表导出临时文件清理 + +- `app/api/routers/database.py` 的 `/api/download` 导出接口增加格式校验,只允许 `csv` 和 `xlsx`。 +- 导出文件仍临时写入 `cache/`,但 `FileResponse` 发送完成后会通过 `BackgroundTask` 自动删除;写入失败时也会清理半成品文件,避免导出残留占用服务器磁盘。 +- 已清理 `cache/` 中旧的导出缓存文件 2 个,仅保留处理历史目录和 `history.json`。 +- 已执行 `.venv\Scripts\python.exe -m compileall app`,编译检查通过。 + +## 2026-05-19:调整上传框操作按钮为换行显示 + +- `frontend/src/components/FileWorkflow.vue` 移除上传框操作区里无效的 `
`,避免在 flex 布局中形成异常间距。 +- `frontend/src/styles.css` 将 `.upload-zone-actions` 改为纵向 flex 布局,使“或者点击选择文件”和“远程下载并处理”按钮固定分两行显示。 +- 已执行 `npm run build`,构建通过;本次按用户要求未启动浏览器或 headless Chrome。 + +## 2026-05-19:调整连接配置页卡片布局 + +- `frontend/src/components/SettingsPanel.vue` 将连接配置页改成左列堆叠“数据库配置”和“处理历史保留”,右列显示“远程数据源”,避免右侧远程数据源卡片高度把处理历史保留卡片挤到很下面。 +- `frontend/src/styles.css` 新增 `.settings-connection-stack`,左列卡片之间使用固定 18px 间距。 +- 已执行 `npm run build`;已通过本机 Chrome DevTools 验证设置页中数据库配置与处理历史保留同列显示,间距为 18px,远程数据源位于右列。 + +## 2026-05-19:新增处理历史保留配置并完善配置导入导出 + +- `app/config.py` 新增 `HistoryRetention` 配置块,包含 `enabled` 和 `keep_count`;`keep_count=0` 表示不保留已结束处理历史,关闭开关时不自动删除历史。 +- `app/history.py` 新增按保留数量清理已结束历史的能力,只清理 `completed/failed` 记录及其 `cache/` 工作目录,不删除 `pending/processing` 记录。 +- `app/api/routers/tasks.py` 和 `app/api/routers/remote.py` 在本地上传处理、远程下载并处理任务结束后自动应用历史保留规则。 +- `app/api/routers/config.py` 新增 `/api/config/history-retention` 保存接口;配置下载改为从当前内存配置生成完整 JSON,确保导出的配置始终包含 `RemoteData` 和 `HistoryRetention`;配置上传也会恢复这两个配置块。 +- `frontend/src/components/SettingsPanel.vue` 在连接配置页新增“处理历史保留”卡片,可设置自动清理开关和保留最近次数。 +- 已执行 `.venv\Scripts\python.exe -m compileall app`、临时目录历史清理验证、配置导入导出验证和 `npm run build`;已用本机 Chrome DevTools 验证设置页新增卡片可见。 + +## 2026-05-19:调整上传页远程下载入口 + +- `frontend/src/components/FileWorkflow.vue` 将“远程下载并处理”按钮移动到拖拽上传框内部,删除独立的“远程自动化”卡片,上传页首屏只保留一个主要操作区域。 +- 远程入口说明“从已配置的 FTP/SFTP 目录递归下载数据,然后自动开始处理。”改为按钮 hover tooltip 展示;按钮点击使用事件阻止冒泡,避免触发拖拽框的本地文件选择逻辑。 +- `frontend/src/styles.css` 清理远程自动化卡片样式,新增拖拽框内操作区样式。 +- 已执行 `npm run build`;已通过本机 Chrome DevTools 验证 `/upload`:远程按钮位于拖拽框内,外部远程卡片 DOM 数量为 0,tooltip 文案正常显示。 + +## 2026-05-19:修复规则映射窄宽度滚动 + +- `frontend/src/styles.css` 将系统设置的规则映射区域改为 tab 内部滚动容器,避免宽度或高度不足时被 `overflow: hidden` 裁切导致 `Sheet 过滤规则` 卡片不可达。 +- 浏览器宽度不足触发单列布局时,规则映射区按 `Sheet 过滤规则` 在上、`字段映射配置` 在下排列,字段映射卡片限制高度并继续使用内部字段列表滚动。 +- 已执行 `npm run build`;已通过本机 Chrome DevTools 以约 1074px 视口验证 `/settings` 规则映射页:Sheet 卡片可见、容器可纵向滚动、文档没有横向溢出。 + +## 2026-05-19:移除数据管理表格重复横向滚动条 + +- `frontend/src/components/DatabasePanel.vue` 移除了上一版额外添加的 `.database-horizontal-scrollbar` 外置滚动条和同步滚动逻辑,避免与 Naive UI DataTable 自带横向滚动条同时显示。 +- 数据表仍保留 `scroll-x` 和列最小宽度计算,由 Naive UI 原生表格滚动条负责横向浏览,字段结构折叠头继续保留“字段结构 / 收起字段结构”状态文案。 +- 已执行 `npm run build`;已通过本机 Chrome DevTools 验证 `/database` 中外置滚动条 DOM 数量为 0,表格自身仍存在横向溢出滚动。 + +## 2026-05-19:修复数据管理表格滚动和字段结构收起 + +- `frontend/src/components/DatabasePanel.vue` 为数据表增加明确的 `scroll-x` 宽度和底部外置横向滚动条,滚动条会与 Naive UI 表格内部横向滚动位置双向同步,避免字段结构区域或分页区域遮挡表格底部横向滚动入口。 +- 字段结构区域从默认 `n-collapse` 改为受控折叠头,展开后标题显示“收起字段结构”,再次点击恢复“字段结构”,并使用本地图标箭头表示展开状态。 +- 字段结构明细使用内部滚动容器限制高度,数据表主体保持 flex 占位,避免展开字段结构后挤掉分页或整页出现不必要滚动。 +- 已执行 `npm run build`;已通过本机 Chrome DevTools 验证 `/database` 中表格横向滚动条可见且与表格滚动同步,字段结构展开/收起文案正常切换。 + +## 2026-05-19:压缩系统设置页布局高度 + +- `frontend/src/styles.css` 调整系统设置页为固定高度布局,外层 `settings-workspace` 减小 padding 并隐藏溢出,避免主内容区出现整页滚动条。 +- 连接配置页 `.settings-section-grid` 作为 tab 内部滚动容器,浏览器高度变小时只滚动连接配置内容,不裁切远程数据源表单。 +- 字段映射配置卡片在规则映射页中占满可用高度,卡片内容区使用 flex 固定 header/footer,字段列表通过内部纵向滚动条浏览,避免主页面滚动或内容溢出。 +- Naive UI 当前卡片内容区 DOM class 为 `.n-card-content`,样式同时兼容 `.n-card__content`;字段映射项必须 `flex: 0 0 auto`,否则列表项会被 flex 压缩而无法形成真实滚动高度。 + +## 2026-05-19:拆分系统设置页并支持远程源文件自动清理 + +- `frontend/src/components/SettingsPanel.vue` 的系统设置改为 Naive UI Tabs:连接配置页放数据库配置和远程数据源,规则映射页放 Sheet 过滤规则和字段映射配置,修改密码独立一页,避免设置内容堆在一个长页面。 +- `RemoteData` 配置新增 `auto_delete_source` 开关;前端在远程数据源配置中显示“处理成功后删除源文件”,默认关闭。 +- `app/services/remote_download.py` 新增远程源文件清理能力,下载阶段会记录本次实际下载的远程文件路径;自动清理只删除这些文件,不删除目录,也不会删除处理期间新进入远程目录的文件。 +- `app/api/routers/remote.py` 在远程下载并处理成功后才会执行源文件清理;清理失败只写入警告日志,不改变已完成的数据处理结果。 +- 已执行 `.venv\Scripts\python.exe -m compileall app` 和 `npm run build`,均通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:新增 FTP/SFTP 远程自动化处理 + +- `Configure.json` 新增 `RemoteData` 配置,包含启用状态、协议、主机、端口、用户名、密码、远程目录、FTP 被动模式、超时时间和源文件自动清理开关;`app/config.py` 会兼容旧配置并在保存时写回该配置块。 +- 新增 `app/services/remote_download.py`,FTP 使用标准库 `ftplib`,SFTP 使用 `paramiko`,会递归下载远程目录下的全部文件和文件夹到本地任务缓存目录。 +- 新增 `app/api/routers/remote.py`,`POST /api/remote/test` 用于测试远程连接,`POST /api/remote/start` 会创建历史任务、下载远程数据并复用 `DataProcessor` 完成现有处理流程。 +- `frontend/src/components/SettingsPanel.vue` 新增“远程数据源”配置卡片,支持 FTP/SFTP 切换、保存和测试连接;`frontend/src/components/FileWorkflow.vue` 新增“远程下载并处理”入口。 +- 远程任务启动后会把全局任务阶段设置为 `downloading`,前端处理进度页会显示“远程下载中...”,下载完成后再切换为既有数据处理流程。 +- 新增依赖 `paramiko`,当前 `.venv` 已执行 `uv pip install -r requirements.txt`;已执行 `.venv\Scripts\python.exe -m compileall app` 和 `npm run build`,均通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:完善服务重启交互和运行时兼容 + +- `frontend/src/AppShell.vue` 的重启按钮点击后会先弹出确认框,确认后显示全屏“正在重启服务”遮罩和旋转加载动画,避免用户重复操作。 +- 重启请求发出后前端会轮询 `/api/service/status`,服务恢复时自动刷新页面;重启过程中请求中断会被视为正常情况继续等待。 +- `app/api/routers/service.py` 和 `app/services/runtime.py` 统一重启逻辑:优先使用 supervisor 重启,失败时退回进程退出;Windows 本地依赖 `run.bat` 循环拉起,容器环境会识别 Docker/containerd/k8s 并交给 supervisor 或容器重启策略拉起。 +- `/api/service/status` 返回值新增 `container` 字段,用于前端或排障区分容器运行时。 +- 已执行 `.venv\Scripts\python.exe -m compileall app` 和 `npm run build`,均通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:修正字段映射标题换行 + +- `frontend/src/styles.css` 调整设置页字段映射卡片头部布局,“字段映射配置”和字段数量标签不再被搜索框挤压换行。 +- 字段搜索框改为弹性宽度,优先占用剩余空间,并保留最小宽度和最大宽度约束。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:优化数据管理表列表和导出入口 + +- `frontend/src/components/DatabasePanel.vue` 的 MySQL 表列表顶部新增“表”标题和刷新图标按钮,刷新按钮复用 `loadTables()` 并在加载中显示 loading,避免重复刷新。 +- 数据表导出从独立 CSV/XLSX 按钮改为 Naive UI 下拉按钮,用户先选择 CSV 或 XLSX 格式再下载。 +- 导出请求期间按钮显示 loading 和当前格式文案,并禁用下拉入口,避免大数据表导出等待期间被重复点击。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:修复数值字段转换失败 + +- `app/processor.py` 会从 `ReportScript.sql` 的 `ALTER TABLE ... MODIFY COLUMN ... float/int` 自动补全缺失的字段类型提示,配置中未标 `Type` 的数值字段在导入阶段也会按数值清洗和落库。 +- 数值清洗只处理格式问题:空值保留为 NULL 后由脚本 `IFNULL` 归零,千分位逗号、中文逗号、百分号、空格和制表符会被移除,正常数值保持不变,正常 0 不再被误判为空值。 +- 执行 SQL 脚本时仍保留 MySQL 严格模式;在 `ALTER` 转数值前会对目标表的相关数值列做一次保险清洗,兼容已经导入过的旧字符串表。 +- SQL 语句执行失败后不再继续执行后续语句,避免前置 ALTER 失败后继续产生大量 `Unknown column` 和临时表不存在的级联错误,并让任务正确进入失败状态。 + +## 2026-05-18:修正历史详情日志滚动条颜色 + +- `frontend/src/styles.css` 为历史详情 `colored-log-panel` 单独设置滚动条颜色,避免继承全局 hover 颜色后在深色日志背景里不可见。 +- 日志框滚动条轨道使用深色,滑块和 hover 状态使用更亮的灰蓝色,同时补充横向/纵向滚动条和 corner 样式。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:优化历史详情日志查看 + +- `frontend/src/components/HistoryPanel.vue` 的历史详情“处理日志”标题右侧新增复制按钮,点击后复制当前详情日志文本,优先使用 Clipboard API,失败时回退到 textarea 复制。 +- 历史详情日志不再使用 `n-log`,改为自定义 `colored-log-panel`,日志框固定高度并同时支持横向和纵向滚动,不自动换行。 +- 日志行按内容识别级别并着色:`INFO` 为蓝色,`SUCCESS/COMPLETED` 为绿色,`WARN/WARNING` 为黄色,`ERROR/FAILED` 为红色。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:切换为圆形侧边栏收缩触发器 + +- `frontend/src/AppShell.vue` 的 `n-layout-sider` 收缩触发器从 `show-trigger="bar"` 改为 `show-trigger="arrow-circle"`,恢复为 Naive UI 文档中侧栏右侧居中的圆形箭头按钮样式。 +- 侧边栏收缩状态仍通过 `handleSidebarCollapsed()` 写入 `localStorage.sidebarCollapsed`。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:恢复侧边栏原生收缩触发器 + +- `frontend/src/AppShell.vue` 移除顶部标题栏里的自定义汉堡收缩按钮,改用 Naive UI `n-layout-sider` 的 `show-trigger="bar"` 原生触发器。 +- 侧边栏收缩状态仍写入 `localStorage.sidebarCollapsed`,刷新页面后保持用户上次的展开/收缩状态。 +- `frontend/src/styles.css` 清理自定义 `.sidebar-toggle` 样式,避免顶部标题栏出现额外按钮。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:修正折叠侧边栏菜单图标居中 + +- `frontend/src/styles.css` 的折叠侧边栏菜单项强制改为 flex 居中布局,避免 Naive Menu 折叠时透明文本列继续占据 grid 空间导致图标偏左。 +- 折叠态下隐藏菜单文本列和箭头列,并让图标容器自身水平垂直居中,保持选中背景和图标中心对齐。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:优化历史详情弹窗信息布局 + +- `frontend/src/components/HistoryPanel.vue` 打开历史详情后会自动调用 `/api/history/size` 计算占用,不再需要用户点击“计算占用”按钮。 +- 历史详情基础信息从 Naive UI `n-descriptions` 表格改为自定义键值列表,统一为左侧标题、右侧值,长路径和值会自动换行。 +- 占用计算期间显示“计算中...”,失败时显示“计算失败”并保留错误 toast。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:数据管理页切换时刷新表列表 + +- `frontend/src/components/DatabasePanel.vue` 在数据管理页激活时会重新执行 `loadTables()`,确保从其他页面切回时左侧表列表拉取最新状态。 +- 离开数据管理页时会清空表列表、当前选中表和表数据,避免已删除的表在下次进入前短暂残留。 +- 表列表请求增加 `tableLoadToken`,忽略离开页面后返回的旧请求,防止过期响应把已清空的列表重新写回。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:合并数据管理页快速导入检测入口 + +- `frontend/src/components/DatabasePanel.vue` 移除数据库状态卡片里的独立“重新检测”文字按钮,把刷新动作合并到“快速导入”的状态徽标上。 +- 点击“可用 / 未启用 / 检测中”徽标会自动重新检测并刷新状态;成功不弹 toast,失败仍显示错误提示。 +- 数据库状态卡片去掉为底部按钮预留的额外内边距,布局更紧凑。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:处理任务运行时隐藏上传区域 + +- `frontend/src/components/FileWorkflow.vue` 新增 `taskInProgress` 计算状态;当存在活动任务或任务状态未完成/失败时,上传区和已选文件列表不再渲染。 +- 上传文件阶段仍保留已选文件和上传进度;后端处理任务开始后页面只显示处理进度卡片和日志,避免已完成上传列表继续占据首屏。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:修复主题按钮图标和折叠侧栏版本显示 + +- `frontend/src/AppShell.vue` 的主题切换按钮现在根据 `themeName` 动态显示 `MoonOutline` 或 `SunnyOutline`,切换主题后图标和标题同步变化。 +- 侧边栏底部版本信息拆分为版本标签、版本号和 Power by 文案;折叠侧边栏时只保留纯版本号 `v2.0.2`,隐藏“版本:”和 Power by。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-18:修复登录页回车提交 + +- `frontend/src/components/LoginView.vue` 的登录按钮改为原生 submit 类型,继续复用 `n-form` 的 `@submit.prevent` 登录流程。 +- 密码输入框增加 `@keydown.enter.prevent="submit"` 兜底,用户输完密码按回车即可触发登录,无需手动点击按钮。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-15:取消数据管理页初始化成功提示 + +- `frontend/src/components/DatabasePanel.vue` 进入页面时仍会自动执行数据库连接检测和数据库信息刷新,但连接成功不再弹出 toast,避免切换到数据管理页时产生无意义提示。 +- 用户手动点击“重新检测”时仍保留成功提示;连接失败或接口异常仍会显示错误 toast。 +- 已执行 `npm run build`,构建通过;未启动浏览器或 headless Chrome。 + +## 2026-05-15:优化新版前端图标和侧边栏选中态 + +- `frontend/src/composables/pageHeader.ts` 的页头动作图标从 emoji 字符串改为 Vue 组件,页面按钮统一传入 `@vicons/ionicons5` 图标组件,构建后随前端资源本地打包,适合内网运行。 +- `AppShell.vue` 的折叠侧边栏按钮、主题切换按钮和各页面页头动作已移除 emoji 图标;当前前端源码中仅保留主品牌图标 `📊`。 +- `styles.css` 优化侧边栏菜单选中态:展开态使用浅色背景、品牌色描边和左侧短标记,折叠态收敛为居中的 40px 图标块,避免选中背景过宽。 +- 本次只执行 `npm run build` 做构建验证,未按用户要求启动浏览器或 headless Chrome。 + +## 2026-05-15:修复新版前端夜间模式组件颜色 + +- 新增 `frontend/src/composables/theme.ts` 作为前端共享主题状态,统一读写 `localStorage.theme` 并同步 `document.documentElement[data-theme]`。 +- `App.vue` 的 `n-config-provider` 现在会在夜间模式下使用 Naive UI `darkTheme`,修复 `n-card`、`n-input`、`n-form`、`n-menu`、`n-button` 等组件仍按亮色主题渲染的问题。 +- `AppShell.vue` 的主题切换改为调用共享主题逻辑,避免 AppShell 和 Naive Provider 各自维护主题状态。 +- `styles.css` 补充工作卡片、卡片标题、表单标签、菜单图标和菜单文字颜色兜底,防止局部组件样式覆盖暗色文本。 +- 已执行 `npm run build`,构建通过,仅有 Monaco/Vite 大 chunk 体积警告;已用 headless Chrome 打开新版 `/settings` 并预置 `theme=dark` 验证:页面、卡片标题、表单标签、输入框文字、字段映射标题和顶部按钮均为暗色主题可读颜色。 + +## 2026-05-15:对齐新版前端主体布局并修复全局拖拽默认行为 + +- 新版 Vue 前端主体内容继续按旧版 `frontend_old/` 的工作区布局对齐:上传页保留旧版上传区尺寸和文件列表结构,数据库页恢复左侧表列表 + 右侧数据区,设置页恢复左右列,脚本页恢复编辑器容器和底部状态栏。 +- `frontend/src/composables/pageHeader.ts` 新增页面顶部副标题和动作注册机制;各页面把原本散落在页面内部的主要动作注册到 `AppShell` 顶栏,避免每个页面重复实现标题栏。 +- `AppShell.vue` 在捕获阶段统一拦截带 `Files` 的 `dragover/drop` 默认行为,修复文件拖到新版页面空白处时浏览器打开文件或弹出下载的问题;真正的文件处理仍由上传区自己的 `drop` 事件完成。 +- 上传区 `FileWorkflow.vue` 明确使用 `.prevent.stop` 处理拖拽事件,并继续支持文件和目录拖拽;目录读取使用 `webkitGetAsEntry()` 递归遍历,文件路径按相对路径去重。 +- 已执行 `npm run build`,构建通过,仅有 Monaco/Vite 大 chunk 体积警告;已用 headless Chrome 验证新版 `/upload` 上传区位于 `x=252,y=88,width=1156,height=220`,拖到页面空白处不会跳转,拖到上传区会加入 `drag-test.csv`。 + +## 2026-05-15:恢复新版数据库页左右工作区布局 + +- `frontend/src/components/DatabasePanel.vue` 已从纵向卡片堆叠改回旧版主体布局:左侧固定 240px 数据表列表,右侧为表数据面板,左下角显示数据库版本和快速导入状态。 +- 数据库连接状态不再占用页面顶部;“重新检测”保留在左下状态块中,表列表刷新和删除全部放在左侧列表顶部。 +- 右侧表数据区在未选择表时显示“请选择左侧的数据表”,选择表后显示表名、总行数、刷新、CSV、XLSX、清空、删除、数据表格、字段结构和分页。 +- 已执行 `npm run build`;并用 headless Chrome 对照旧版 `http://127.0.0.1:9082/` 与新版 `http://127.0.0.1:9081/database`,确认两边数据库主体均为横向 flex,左栏宽 240px,右侧内容区占用剩余宽度。 + +## 2026-05-15:修复旧版前端未登录闪烁 + +- `frontend_old/js/app.js` 现在会在首页初始化前检查登录 token;未登录时直接跳到旧版登录页,避免首页继续初始化并反复请求 API 造成未授权提示闪烁。 +- 旧版前端鉴权统一兼容 `capacity_report_token` 和旧 key `token`:请求优先读取新版 key,登录页会同时写入两个 key,并继续写入 `token` cookie。 +- 旧版 API 401、XHR 上传 401 和退出登录都会清理两个本地 token key 及 cookie,并跳转到 `/login.html`;通过 `/old/...` 路径访问旧版时会跳转到 `/old/login.html`。 +- 已用 headless Chrome 验证:清空本地存储后访问 `http://127.0.0.1:9082/` 会进入 `http://127.0.0.1:9082/login.html`;按本机 `auth.ini` 登录后回到旧版首页,`/api/cache/size` 携带 token 调用返回 200。 + +## 2026-05-15:新版上传页对齐旧版布局并恢复拖拽上传 + +- `frontend/src/components/FileWorkflow.vue` 的上传页内容区已按旧版 `frontend_old/index.html` 的上传结构重排,保留新版侧边导航和顶部标题栏,只对齐页面主体中的上传区、文件列表、上传进度和处理日志布局。 +- 上传区支持点击选择文件,也支持把文件或文件夹直接拖拽到页面;目录拖拽使用 `DataTransferItem.webkitGetAsEntry()` 递归读取,`readEntries()` 会循环读取完整批次,兼容 Chrome 目录拖拽一次只返回部分条目的情况。 +- 上传文件统一过滤 `.zip`、`.xlsx`、`.xls`、`.csv`,路径会归一化为 `/`,并按相对路径去重;文件状态显示为等待上传、上传中、已完成或失败。 +- 旧版对照端口仍为 `9082`,新版端口仍为 `9081`;已用 headless Chrome 对比 `9082/` 与 `9081/upload`,新版上传框尺寸、虚线边框、圆角和文案与旧版基本一致,并通过模拟拖拽 `drag-test.csv` 验证文件列表能正常出现。 +- 构建验证命令为 `cd frontend && npm run build`;当前只存在 Vite 大 chunk 体积警告,构建本身通过,`frontend/dist/` 仍按 `.gitignore` 作为本地构建产物处理。 + +## 2026-05-15:拆分新旧前端访问端口 + +- `python -m app.main` 现在由同一个 FastAPI 进程同时监听 `9081` 和 `9082`,共享后端运行状态、任务锁和 API。 +- `9081` 固定服务新版 Vue 3 前端,`9082` 固定服务 `frontend_old/` 旧版 HTML/CSS/JS 前端;旧版页面仍通过 `/old/...` 加载本地 Monaco 等静态资源。 +- `app.main:app` 保留为新版单端口 ASGI 实例,`app.main:old_app` 保留为旧版单端口 ASGI 实例,`app.main:split_app` 用于按请求端口切换前端。 +- `run.bat`、`supervisord.conf`、Dockerfile、Docker Compose 和离线构建脚本已同步新旧端口:本地为 `9081/9082`,容器宿主机映射为 `19081/19082`。 + +## 2026-05-15:增加旧版前端对照入口和新版路由 + +- 从旧提交 `54773f547f6fcb853d73785f05ff5ac39ab2e5f5` 恢复原生 HTML/CSS/JS 前端到 `frontend_old/`,包含旧版 `index.html`、`login.html`、样式、脚本和本地 Monaco 资源。 +- 后端在 `app/main.py` 中通过 `/old` 和 `/old/...` 托管 `frontend_old/`,旧版静态资源统一改为 `/old/...` 前缀;旧版页面仍复用当前 `/api/...` 接口,便于和新版直接对比。 +- 新版 Vue 前端新增 `vue-router`,页面路径为 `/upload`、`/history`、`/database`、`/script`、`/settings`;菜单切换会更新浏览器地址,刷新时由 FastAPI SPA fallback 返回新版入口,不再固定回到主页。 +- `frontend/dist/` 仍是本地构建产物,只用于运行验证,不进入版本库;源码运行时需要先在 `frontend/` 执行 `npm install` 和 `npm run build`。 + +## 2026-05-15:恢复前端工作台交互质量 + +- 前端工作台布局重新对齐旧版 `54773f547f6fcb853d73785f05ff5ac39ab2e5f5` 的信息架构:左侧导航、顶部标题栏、紧凑后台式内容区,导航项使用“数据上传 / 处理历史 / 数据管理 / 脚本编辑 / 系统设置”。 +- `ScriptPanel.vue` 不再使用普通 textarea,改为 `monaco-editor` SQL 编辑器,保留脚本读取、保存、执行和状态轮询接口;支持 SQL 高亮、行号、缩略图、光标行列状态和未保存状态。 +- Monaco 通过 Vue 异步组件按需加载,避免脚本编辑器依赖进入首屏主包;Vite worker 类型由 `frontend/src/vite-env.d.ts` 提供。 +- `SettingsPanel.vue` 的字段提取配置恢复为结构化树状配置:字段名、字段类型、提取来源列表、搜索、增删和去重保存,不再要求用户直接编辑 JSON。 +- 新增前端运行依赖 `monaco-editor`,构建时仍会生成 `frontend/dist/`,该目录继续作为构建产物忽略,不进入版本库。 + +## 2026-05-15:修复 Windows 启动脚本编码问题 + +- `run.bat` 改为纯 ASCII 输出,并规范为 CRLF 行尾,避免 Windows `cmd` 在 PowerShell 中执行 UTF-8 中文批处理时把提示文本解析成碎片命令。 +- 启动脚本仍使用 `.venv\Scripts\python.exe`、检查 Python 依赖和 `frontend/dist/index.html`,实际启动方式保持 `python -m app.main` 不变。 +- 后续如需中文启动提示,优先放到 PowerShell 脚本或应用日志中,不建议直接写入 `.bat`。 + +## 2026-05-15:后端拆分与前端迁移 + +### 当前架构 + +- 后端入口收敛到 `app/main.py`,只负责创建 FastAPI 应用、注册中间件、注册路由和托管前端构建产物。 +- API 按业务拆分到 `app/api/routers/`: + - `auth.py`:登录和修改密码。 + - `upload.py`:上传会话和文件上传。 + - `tasks.py`:任务锁、处理启动、处理状态。 + - `history.py`:历史记录、日志和记录删除。 + - `database.py`:数据库测试、表查询、表维护和导出。 + - `config.py`:配置读取、保存、上传和下载。 + - `cache.py`:缓存大小统计。 + - `script.py`:SQL 脚本读取、保存和执行。 + - `health.py`:健康检查。 +- 运行时共享状态放在 `app/state.py`,包括配置实例、历史管理器、处理任务、上传会话和全局任务锁。 +- 登录、密码文件和 Token 逻辑放在 `app/auth.py`,继续使用本地 `auth.ini`。 +- 文件大小等工具函数放在 `app/utils/files.py`。 + +### 前端 + +- 旧 `static/` 原生 HTML/CSS/JS 已替换为 `frontend/`。 +- 前端技术栈为 Vue 3 + TypeScript + Vite + Naive UI。 +- 构建产物位于 `frontend/dist`,由 FastAPI 根路由托管;`/assets` 映射到 `frontend/dist/assets`。 +- 未构建前端时,后端会返回 503,并提示执行 `cd frontend && npm install && npm run build`。 +- Vite 开发服务器将 `/api` 和 `/health` 代理到后端 `http://localhost:9081`。 + +### 部署与运行 + +- Windows 本地运行使用 `run.bat`,优先使用 uv 创建的 `.venv\Scripts\python.exe`。 +- `run.bat` 会检查 Python 依赖和 `frontend/dist/index.html`,缺少前端产物时要求先构建前端。 +- Docker 构建改为多阶段: + - `node:22-slim` 阶段安装前端依赖并执行 `npm run build`。 + - `python:3.13.11-slim` 阶段安装后端依赖,复制应用代码,再复制前端构建产物。 +- `.dockerignore` 只排除 `frontend/node_modules`、`frontend/dist` 等本地产物,不再排除完整前端源码。 + +### 依赖与清理 + +- Python 依赖保留当前代码实际使用项:`fastapi`、`uvicorn[standard]`、`python-multipart`、`pymysql`、`cryptography`、`pandas`、`openpyxl`、`chardet`、`supervisor`。 +- 已移除未使用的 `sqlparse`、`aiofiles`、`python-dateutil`。 +- 旧静态目录、根目录打包产物、日志和 Python 编译缓存属于可清理产物,不应提交。 + +### 注意事项 + +- 当前项目按每周整包替换使用,不维护旧版 API 兼容层;但核心处理流程、配置文件和 `ReportScript.sql` 仍沿用现有语义。 +- `ReportScript.sql` 是业务处理链路的一部分,重构接口或前端时不要改写 SQL 语义。 +- `auth.ini`、`cache/`、`dist/`、`frontend/dist/`、`frontend/node_modules/` 均为本地运行或构建产物,不进入版本库。 + +## 2026-05-20: Cross-platform packaging + +- Added one-command build entry points: `scripts/build.bat`, `scripts/build.ps1`, and `scripts/build.sh`. Targets are `server`, `desktop`, `docker`, and `all`; script output stays ASCII to avoid console encoding issues on Windows. +- Server Portable uses PyInstaller one-dir mode and packages the backend executable with `frontend/dist`, `Configure.json`, `ReportScript.sql`, `cache/`, `logs/`, and launch scripts. The default server port remains `9081`. +- Desktop packaging uses Tauri 2 + Vue + Python sidecar. The build sets `VITE_API_BASE=http://127.0.0.1:19082`, builds a PyInstaller one-file `capareport-server` sidecar, and the desktop app starts it on `127.0.0.1:19082`. +- `app/config.py` supports `CAPAREPORT_BASE_DIR`; frozen PyInstaller server builds default `BASE_DIR` to the executable directory. The Tauri sidecar sets `CAPAREPORT_BASE_DIR` to the app data directory so runtime files are not written into the install directory. +- Tauri startup creates app-data `cache/` and `logs/`, then copies bundled `Configure.json` and `ReportScript.sql` on first run. Resource lookup supports both normal Tauri resources and the `_up_` directory generated for bundled `../` resources. +- Windows desktop shutdown uses `taskkill /F /T /PID` before killing the shell child, which avoids PyInstaller one-file sidecar process leftovers. +- Docker build now copies only required app files and frontend build output. `.dockerignore` excludes local dependencies, caches, and generated output; deployment compose files are emitted under `dist/docker/`. +- `app/main.py` accepts `--host` and `--port` and exposes `run_server()` so portable launchers, Docker, and the Tauri sidecar share the same backend entry point. +- Windows verification completed: `scripts\build.bat server -NoArchive`, `scripts\build.bat server`, `scripts\build.bat docker`, and `scripts\build.bat desktop`. Server portable `/health`, Docker container `/health`, and desktop sidecar `/health` all returned HTTP 200; desktop first run also copied config and SQL into app data. +- Cleanup verification completed: `.venv\Scripts\python.exe -m compileall app`, `npm run build`, PowerShell AST parse for `scripts/build.ps1`, Docker-hosted `sh -n scripts/build.sh`, and `cargo check --manifest-path src-tauri\Cargo.toml` with a temporary sidecar placeholder all passed. Linux/macOS native server and desktop packages still need native OS verification. +- `src-tauri/gen/schemas/` is intentionally tracked because `src-tauri/capabilities/default.json` references `../gen/schemas/desktop-schema.json`; do not ignore or delete these schema files during cleanup, otherwise VS Code JSON validation reports a missing schema. +- `README.md` has been rewritten to document local startup, Server Portable, Tauri desktop, Docker, Linux/macOS build commands, configuration blocks, common APIs, and cleanup rules. Keep future build instructions in sync with `scripts/build.*`. + +## 2026-05-20: Tauri desktop console and installer language + +- `src-tauri/src/main.rs` uses `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` so Windows release builds use the GUI subsystem and do not open an extra console window when launched from Explorer or the installer shortcut. +- `packaging/capareport-server.spec` keeps Server Portable in console mode, but desktop one-file sidecar builds (`CAPAREPORT_ONEFILE=1`) use PyInstaller `console=False`; PyInstaller should select `runw.exe` for the sidecar so it also stays hidden behind the Tauri window. +- `src-tauri/tauri.conf.json` sets Windows installer localization through `bundle.windows.wix.language = "zh-CN"` and `bundle.windows.nsis.languages = ["SimpChinese"]`. NSIS language keys must use NSIS names such as `SimpChinese`, while WiX/MSI uses locale names such as `zh-CN`. +- Verification on Windows: `cargo check --manifest-path src-tauri\Cargo.toml` passed, `scripts\build.bat desktop` generated the MSI and NSIS bundles, PE subsystem checks reported `Windows GUI` for both `capacity-report-desktop.exe` and `capareport-server.exe`, and the generated NSIS script included `MUI_LANGUAGE "SimpChinese"`. + +## 2026-05-20: Unified release output under dist + +- The former `build/` packaging source directory was renamed to `packaging/` to avoid confusing source-side packaging recipes with generated output. `packaging/` now holds `Dockerfile`, `docker-compose.yml`, the PyInstaller spec, and MySQL container config. +- `scripts/build.ps1` and `scripts/build.sh` now use `dist/.tmp/` for PyInstaller work output and copy final deliverables to `dist/server/`, `dist/desktop/`, and `dist/docker/`. Successful builds remove `dist/.tmp`, `frontend/dist`, `src-tauri/target`, and `src-tauri/binaries`. +- Docker builds now include `Configure.json` in the image and also create a deployable `dist/docker/` bundle containing `capacity-report-app-latest.tar`, `docker-compose.yml`, `Configure.json`, `ReportScript.sql`, `mysql/`, `cache/`, and `logs/`. +- Server Portable still includes `Configure.json` and `ReportScript.sql` inside `dist/server/CapacityReport-Server--x64/`. Tauri desktop still bundles both files through `src-tauri/tauri.conf.json` resources and copies them to app data on first run. + +## 2026-05-25: API Token management improvements + +- API Token records now persist the complete token value in `api_tokens.json` in addition to the HMAC hash, prefix, and suffix. Existing hash-only records remain readable but cannot expose the full token; the UI asks users to regenerate those tokens before copying. +- `app/services/api_tokens.py` now supports exporting/importing token records for configuration migration and batch deletion by token ID. Config download adds an `ApiTokens` block, and config upload restores it when present. +- Token update is a partial update path: callers may change only `name`, `enabled`, or expiration fields without accidentally changing unspecified fields. +- `frontend/src/components/ApiTokenManager.vue` now shows selectable token rows, a batch delete button, a compact per-row action dropdown, copy-token action, and enable/disable action. New token creation defaults to a specified expiration date one month after the current browser date, while permanent tokens remain available via the radio option. +- OpenAPI and README text were updated to describe repeatable token copying, token migration through config upload/download, and batch delete. +- Verification performed: `api_tokens` service create/list/verify/enable/disable/export/import/batch-delete test passed, HTTP endpoints for create/list/update/config download/batch-delete passed on local port `9081`, `.venv\Scripts\python.exe -m compileall app` passed, and `npm run build` passed with only the existing Vite large chunk warning. + +## 2026-05-25: API Token row visibility controls + +- `frontend/src/components/ApiTokenManager.vue` renders Token values as a compact row with right-side icon buttons: an eye button toggles masked/full Token display, and a copy button copies the complete Token directly from the row. +- The per-row operation dropdown now keeps edit, enable/disable, regenerate, and delete actions; copying is surfaced beside the Token value for faster repeated use. +- Build verification: `npm run build` passed with only the existing Vite large chunk warning. +- Follow-up UI adjustment: the eye/copy buttons now sit immediately after the Token text instead of being pushed to the far right, and Token rows show `created_at` in the metadata area. + +## 2026-06-01: API documentation toolbar cleanup + +- `frontend/src/components/ApiDocs.vue` no longer registers a page-header `copy sample` action, avoiding duplicate `复制传参示例` buttons on the API documentation page. +- The API documentation card still keeps its local toolbar actions: `复制传参示例` and `OpenAPI JSON`. +- Verification performed: `npm run build` passed, and generated frontend build output was removed after verification. + +## 2026-06-01: Frontend entry chunk reduction + +- `frontend/src/router.ts` now lazy-loads `FileWorkflow.vue`, matching the other main pages and keeping the data processing page out of the base entry chunk until the route is opened. +- `frontend/src/AppShell.vue` now lazy-loads `LoginView.vue` and the hidden license activation dialog; the dialog UI and activation API flow moved to `frontend/src/components/LicenseActivationModal.vue`. +- Build verification: `npm run build` passed. The base `index` chunk dropped from about `572 kB` to about `461 kB`; the remaining large chunks are still `ApiDocs` (`swagger-ui-dist`) and `ScriptPanel` (`monaco-editor`). + +## 2026-06-01: History detail file browser + +- `app/api/routers/history.py` adds `/api/history/files` for browsing a history work directory by safe relative path, and `/api/history/file/download` for downloading one file directly or one directory as a temporary ZIP. Both paths are constrained under the record's `cache/` work directory; download still rejects pending/processing records. +- `frontend/src/components/HistoryPanel.vue` keeps the processing log at the bottom of the task detail modal, and adds a `详情 / 文件` tab area above it. The file tab shows breadcrumb navigation, parent/refresh controls, directory/file type, size, modified time, and per-item download actions. +- `frontend/src/types.ts` defines `HistoryFileEntry` and `HistoryFilesResponse`; `app/main.py` adds Chinese OpenAPI descriptions and examples for the new history file APIs. +- Verification performed: `.venv\Scripts\python.exe -m compileall app`, `npm run build`, direct history file listing for `20260519_172434`, path traversal rejection, and single-file download of `log.txt` on a temporary local server all passed. Build output and Python caches were removed after verification. + +## 2026-06-01: RJ scheduler display cleanup + +- `app/services/auto_scheduler.py` now keeps RJ directory status keys clean instead of prefixing them with `rj:` in the combined scheduler response. +- `frontend/src/components/SettingsPanel.vue` strips any legacy `rj:` prefix before rendering the scheduler directory name, so the UI shows `RJ/2.6G/...` instead of `rj:RJ/2.6G/...`. +- This is display-only cleanup; the underlying RJ directory detection and readiness logic were not changed. + +## 2026-06-01: Settings page split + +- `frontend/src/components/SettingsPanel.vue` splits the former combined connection page into a dedicated `数据库配置` tab and a separate `远程数据源` tab. +- Database-related settings now stay together with `数据库配置` and `处理历史保留`; remote automation settings now own the FTP/SFTP fields, auto-delete toggle, scheduler configuration, and scheduler status panel. +- `frontend/src/styles.css` now uses dedicated layout classes for the database and remote settings panes, with independent scrolling and responsive single-column stacking on narrower screens. +- Verification performed: `cd frontend && npm run build` passed. Browser inspection on `http://127.0.0.1:9081/settings` confirmed the two new tabs render correctly, the remote panel shows its own configuration and scheduler cards, and horizontal overflow remained at `0`. + +## 2026-06-01: Settings remote scheduler refinement + +- `frontend/src/components/SettingsPanel.vue` keeps `远程数据源` focused on FTP/SFTP connection fields. Protocol, host, port, timeout, and FTP passive mode render on one row when width allows; remote automation and delete-source toggles now sit below `远程目录`. +- `自动调度` is now a separate settings tab. Scheduler controls are disabled unless `启用远程自动化` is on, and turning off remote automation automatically turns off scheduler enablement in the current form state and saved payload. +- `debug.bat` starts the Python backend on `127.0.0.1:9081` and the Vite frontend on `127.0.0.1:5173` for source-level debugging without requiring `frontend/dist`. +- Verification performed: `cmd /c debug.bat` started the debug backend/frontend, `cd frontend && npm run build` passed, and browser inspection confirmed the remote connection fields align in one row with no horizontal overflow. Debug processes and `frontend/dist/` were removed after verification. + +## 2026-06-01: Task runtime cleanup + +- `app/api/routers/task_runtime.py` centralizes shared task-stage updates, processing license log output, and safe history-retention cleanup for manual processing and remote processing routes. +- `app/api/routers/tasks.py` and `app/api/routers/remote.py` now reuse the shared helpers instead of carrying duplicate `_set_task_stage` and `_log_license_check` implementations. +- Verification performed: `.venv\Scripts\python.exe -m compileall app`, `npm run build`, and `cargo check --manifest-path src-tauri\Cargo.toml` with a temporary sidecar placeholder all passed. Generated build output, Python caches, and temporary Tauri sidecar files were removed after verification. + +## 2026-06-01: Download cleanup helper + +- `app/utils/files.py` now provides `remove_file_safely()` for best-effort temporary file cleanup. +- Database table export and history archive download routes now reuse this helper instead of carrying duplicate private `_remove_file()` functions. +- Verification performed: `.venv\Scripts\python.exe -m compileall app`, `npm run build`, and `cargo check --manifest-path src-tauri\Cargo.toml` with a temporary sidecar placeholder all passed. Generated build output, Python caches, and temporary Tauri sidecar files were removed after verification. + +## 2026-06-01: Clipboard helper cleanup + +- `frontend/src/composables/clipboard.ts` centralizes browser clipboard writes with the existing hidden-textarea fallback. +- API documentation, API Token management, and history detail log copying now reuse `writeClipboardText()` instead of each component carrying its own clipboard fallback. +- Verification performed: `npm run build`, `.venv\Scripts\python.exe -m compileall app`, and `cargo check --manifest-path src-tauri\Cargo.toml` with a temporary sidecar placeholder all passed. Generated build output, Python caches, and temporary Tauri sidecar files were removed after verification. + +## 2026-06-01: Final cleanup pass + +- A follow-up static import scan removed the leftover unused `Path` import from `app/api/routers/database.py`. +- Final verification included `.venv\Scripts\python.exe -m compileall app`, `npm run build`, a lightweight Python AST unused-import scan, `.venv\Scripts\python.exe -m pip check`, and `npm audit --omit dev`. +- Remaining scan hits are intentional runtime/cleanup console messages or behaviorally different format helpers; no further low-risk cleanup item was found in the final pass. + +## 2026-06-01: Script task status cleanup + +- `app/api/routers/script.py` now reuses the shared `set_task_stage()` helper for manual SQL script task status updates. +- Script execution status entries now include the same `stage` field shape used by processing and remote tasks while preserving the existing status values. +- Verification performed: `.venv\Scripts\python.exe -m compileall app`, `npm run build`, and `cargo check --manifest-path src-tauri\Cargo.toml` with a temporary sidecar placeholder all passed. Generated build output, Python caches, and temporary Tauri sidecar files were removed after verification. + +## 2026-06-02: Platform architecture planning + +- `docs/platform_architecture_plan.md` records the planned standalone Web data-processing platform architecture, module boundaries, submodule strategy, data flow, storage/history model, and staged roadmap. +- The future platform workspace is reserved as `platform/` under the current repository root and is ignored by CapaReport through `.gitignore` so exploratory platform development does not affect this project. +- The design intentionally treats CapaReport as a reference implementation only; reusable ideas should be extracted by responsibility rather than copied into one large module. +- `platform/` is explicitly isolated from CapaReport: it must not use this repository's virtual environment, dependency files such as `requirements.txt`, frontend packages such as `frontend/node_modules`, build scripts, configs, runtime data, or source modules. + +## 2026-06-23:双模式后端(自带 FTP/MySQL + Metrix 平台可选,两侧独立) + +把应用做成「自包含 + Metrix 可选」:源与仓库各自可在直连与 Metrix 间独立选择,互不依赖。基于原版(pre-M2 全功能:FTP/MySQL/查看导出/license)叠加 Metrix 后端。 + +- 配置 `app/config.py`:新增 `source_type`(ftp/sftp/metrix)、`warehouse_type`(mysql/metrix)、`MetrixConfig`(base_url/token/storage_id/database_conn_id/target_database/recent_days/data_dir_to_table),保留 `MySQLConfig`/`RemoteDataConfig`;Configure.json 新增 `SourceType`/`WarehouseType`/`Metrix`(token 隐藏于 to_dict,含于 to_file_dict);缺省向后兼容(source_type 缺省取 RemoteData.protocol,warehouse 缺省 mysql)。 +- 源工厂 `app/services/platform.py::make_source_downloader`:按 source_type 返回 `RemoteDataDownloader`(FTP/SFTP) 或 `PlatformStorageDownloader`(Metrix 储存),接口一致。`platform.py` 改用 MetrixConfig(token 从配置读),并扩展 `PlatformClient` 增 list_tables/table_columns/table_data/submit_export/download_job_file 供仓库代理。 +- 仓库分派:`remote.py`/`tasks.py`/`script.py` 按 warehouse_type 分流——mysql 走原版 `DataProcessor`(直连、LOAD DATA、单会话报表 SQL);metrix 走 `app/services/pipeline.py`(CsvProcessor → 平台 import → run-script single_session)。`auto_scheduler.py` 扫描也改用 make_source_downloader。 +- 仓库视图代理 `app/warehouse.py`:`make_warehouse(config)` → 直连返回原版 `DatabaseManager`,Metrix 返回 `MetrixWarehouse`(用平台 API 实现 get_tables/get_table_info/query_table/truncate/drop/drop_all/execute_sql 同接口);`routers/database.py` 的 `_db()` 透明切换,`/api/download` 在 metrix 模式代理到平台导出任务(避免分页上限丢行)。 +- 路由 `routers/config.py`:新增 `POST /api/config/backend`(类型)、`/api/config/metrix`(连接),配置上传也识别 SourceType/WarehouseType/Metrix。 +- 前端 `SettingsPanel.vue`:新增「数据源/仓库」标签——源/仓库单选 + Metrix 连接卡片(地址/Token/storage_id/database_conn_id/目标库/recent_days)+ 保存/测试储存;保留原 MySQL/远程数据源标签与 DatabasePanel 查看导出。`types.ts` 加 `source_type/warehouse_type/metrix`+`MetrixConfig`。 +- 重要修复:`routers/database.py` 全部处理函数由 `async def` 改为 `def`——这些是阻塞式(直连 pymysql / Metrix HTTP / 大表导出轮询),放在事件循环里会冻结单 worker(实测大表导出把 /health 也卡死);改 def 后 FastAPI 用线程池执行。 +- 容器:`main.py` 重新支持 `CAPAREPORT_FRONTEND_DIR`(代码/前端在 /app、运行态 /data 分离,robocopy 覆盖后补回);`.dockerignore` 放开 `frontend/dist`;`requirements.txt` 含 requests + pymysql/cryptography/paramiko(双模式都要)。Token 改存配置,entrypoint 不再需要环境变量。 +- 验证:前端 `npm run build`(vue-tsc)通过;镜像构建成功;容器冒烟(Metrix 模式)端到端通过——登录/`config/full`(新字段)/tables/table info/table-data/execute/导出代理全部 200,行数与列数正确。直连 MySQL 路径为原版未改代码。 + +## 2026-06-24:精简(去 API 文档 / API Token)+ 设置页卡片自适应 + 授权默认期改 2026-12-30 + +随双模式集成一起进入 `metrix-integration` 分支。去掉与数据处理无关的对外 API 能力,业务接口仅保留登录态访问: + +- 删除 API Token 与离线 API 文档:删 `app/api/routers/api_tokens.py`、`app/services/api_tokens.py`、`frontend/src/components/ApiDocs.vue`、`ApiTokenManager.vue`;前端去掉 `router.ts`/`AppShell.vue` 的 `api-center` 路由与菜单、`package.json` 的 `swagger-ui-dist` 依赖、`types.ts` 的 `ApiToken*` 类型、`vite-env.d.ts` 的 swagger 声明、`SettingsPanel.vue` 的「API Token」分页。 +- 后端解耦:`auth.py::resolve_access_context` 只保留 JWT(去掉 api_token 分支);`main.py` 去掉 api_tokens 路由注册、`touch_token_usage`、`/api/openapi.json` `/api/docs-ui` 文档端点,并删除随之不可达的整套 OpenAPI 定制(`custom_openapi`/`TAG_LABELS`/`OPENAPI_TAGS`/`OPENAPI_OPERATION_DOCS` 及 `_make_operation_id` 等辅助、`get_openapi` 导入、`LOGIN_ONLY_API_PATHS`),`LOGIN_ONLY_API_PREFIXES` 去掉 `/api/tokens`;`config.py` 去掉配置下载/上传里的 `ApiTokens` 字段。 +- 授权默认到期日:`app/services/license.py::DEFAULT_EXPIRES_ON` 由 `2026-06-20` 改为 `2026-12-30`,前端兜底文案(`FileWorkflow.vue`、`LicenseActivationModal.vue`)同步;授权功能本身保留(连点品牌图标 8 次打开延期窗口)。 +- 设置页排版:`styles.css` 的 `.settings-database-stack` 由纵向 `column` 改为 `row wrap`,子卡 `flex:1 1 360px;min-width:320px`,宽屏并排、窄屏自动换行;「处理历史保留」卡加 `work-card-narrow`(`flex-grow:0` + `max-width`)显著收窄;规则同时作用于「数据源/仓库」与「数据库」两个标签页;清理已失效的 `.settings-token-panel` 规则。 +- 验证:`python -m compileall app` 通过;前端 `npm run build`(vue-tsc)通过,产物中不再出现 swagger/ApiDocs chunk。 + +## 2026-06-24:Metrix 模式报表 SQL 固定按本地 ReportScript.sql 执行(移除库内脚本 script_id 死路径) + +- 背景:Metrix 平台 `POST /run-script` 同时支持 `content`(直接执行 SQL 文本)与 `script_id`(执行平台数据库里保存的脚本,且 `script_id` 会覆盖 `content`)。CapacityReport 期望「无论哪种触发,Metrix 模式都执行本应用本地的 `ReportScript.sql`」,不使用平台库内保存的脚本。 +- 现状确认:`pipeline.py`/`warehouse.py` 所有 `run_script` 调用本就只传 `content`(报表 SQL 来自 `read_report_sql()` 读取本地 `ReportScript.sql`),从不传 `script_id`,行为已正确。 +- 改动:`app/services/platform.py::run_script` 删除一直未被调用的 `script_id` 参数与对应 body 分支,方法只构造 `content`,从代码层面杜绝走平台库内脚本那条路;签名由 `(conn_id, script_id=None, content="", ...)` 改为 `(conn_id, content="", ...)`,现有调用全用 `content=` 关键字、`conn_id` 位置参,未受影响。 +- 验证:`python -m compileall app` 通过;全仓 `app` 内除该行注释外无 `script_id` 引用。 + +## 2026-06-24:数据目录映射统一为 DataMappings + +- 背景:UD 与 RJ 在处理阶段本质都是「源目录 -> 暂存表」。此前 UD 使用 `UDData.directories`,RJ 使用 `RJData.weekly_directories` + 代码内置目录名到表名映射,概念重复且前端容易继续堆卡片。 +- 配置:删除 `UDData` / `RJData` 两套配置,改为顶层 `DataMappings`。`DataMappings.directories` 每行结构为 `{path, table, ready_rule}`,`ready_rule=daily` 表示目标周每日 7 天检查,`ready_rule=auto` 表示按目录最新 ZIP 自动识别日粒度或周粒度;RJ 原字段映射并入 `DataMappings.table_field_mappings`,按目标表名覆盖全局字段映射。`MetrixConfig` 仍只保留平台连接信息。 +- 处理链路:直连 MySQL 的 `DataProcessor` 与 Metrix 仓库模式的 `CsvProcessor` 都只读取 `DataMappings.directories`。一个表可对应多个目录:每个目录先按最近日期筛选 CSV,再合并导入同一张暂存表(Metrix 模式写 `.out/{table}.csv`,MySQL 模式逐文件导入同表)。表级字段映射存在时优先使用 `DataMappings.table_field_mappings[table]`,否则使用全局 `ExtractField`。 +- 自动调度:原 RJ 专用检查改为通用自动粒度检查。`ready_rule=auto` 的目录会从普通每日扫描中排除,并单独按最新 ZIP 判断日/周粒度;如果配置里只有自动粒度目录,只要这些目录就绪也可触发调度。 +- 前端:设置页「规则映射」左侧独立滚动配置栏中只保留一个「数据目录映射」卡片,每行可编辑目录、暂存表与就绪规则,并保存到 `/api/config/data-mappings`。后续新增目录类映射继续加同一张表,不再新增配置卡片。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过(仅既有大 chunk 提示);构建产物与 Python 缓存已清理。 + +## 2026-06-24:新增 CellData 远程源与数据库配置入口 + +- 背景:后续需要引入 CellData 自动化处理,处理完成报表 SQL 后还会从 CellData 数据库表匹配数据并写入结果表。本轮先只落配置与界面,不接入实际提取/处理/入库流水线。 +- 配置:新增顶层 `CellData` 配置块,包含 `RemoteData`(FTP/SFTP 连接,默认远程目录 `/CellData`,不参与现有自动调度)与 `MySQL_DBInfo`(默认库名 `celldata`)。CellData 数据库可与主仓库 MySQL 相同,也可指向独立数据库。 +- 后端接口:新增 `/api/config/cell-data/remote`、`/api/config/cell-data/mysql` 保存接口,以及 `/api/config/cell-data/remote/test`、`/api/config/cell-data/mysql/test` 测试接口;测试逻辑分别复用 `RemoteDataDownloader` 和独立 PyMySQL `SELECT 1`。 +- 前端:系统设置「数据库」页新增「CellData 数据库配置」卡片;「远程数据源」页新增「CellData 数据源」卡片;「数据源 / 仓库」页说明 CellData 为独立辅助数据源,不影响主数据源/仓库选择。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过(仅既有大 chunk 提示);构建产物与 Python 缓存已清理。 + +## 2026-06-24:精简系统设置与历史页文案 + +- 设置页说明文案去掉开发实现细节,只保留用户填写配置所需的短提示:主数据源/仓库、Metrix 连接、CellData 数据库/远程源、远程数据源、自动调度、目录映射、Sheet 过滤和字段映射等位置均已压缩。 +- 历史删除确认中的“缓存文件”改为“相关文件”,避免把内部存储实现暴露给用户。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:数据管理支持切换主数据库与 CellData 数据库 + +- 数据管理页左侧表列表新增数据库选择,可在主数据库与 CellData 数据库之间切换;选项名称跟随系统设置里的主仓库库名和 `CellData.MySQL_DBInfo.dbname`。 +- 后端数据库接口新增 `database_source` 参数,`main` 保持原有直连 MySQL / Metrix 仓库逻辑,`cell_data` 使用 `CellData.MySQL_DBInfo` 创建独立 MySQL 仓库。表列表、表结构、分页数据、清空、删除、删除全部、执行 SQL 和导出均按该参数选择数据库。 +- CellData 数据库当前只支持直连 MySQL 配置;Metrix 仓库模式只影响主数据库。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过(仅既有大 chunk 提示);构建产物与 Python 缓存已清理。 + +## 2026-06-25:数据管理数据库选择改为弹窗列表 + +- 数据管理页不再用下拉框切换数据库,改为表标题旁的图标按钮打开「选择数据库」弹窗;弹窗内为固定高度列表,超出高度滚动。 +- 左侧标题只显示分类名(主数据库 / CellData),不显示具体库名,也不再显示“表”字;弹窗列表显示真实库名(如 `主数据库:CapacityReport`)。选择项后续新增更多数据库时继续扩展同一列表,不占用侧栏宽度。 +- 验证:`frontend` `npm run build` 通过(仅既有大 chunk 提示);构建产物已清理。 + +## 2026-06-25:CellData cellinfo 来源与映射规则(待实现) + +- 数据来源:SFTP `127.0.0.1:2022` 开发环境中,CellData 原始文件位于 `/网优日常优化数据文档/日常性能报表/2026年/300表/{700M,2.6G}/`。目录下文件为 `Result_300_*.zip`;文件时间取 ZIP 文件名末尾时间戳(如 `20260620132109`),**不要用 SFTP 修改时间**。 +- ZIP 结构:压缩包内部包含若干 CSV,例如 `LTE_ITBBU_CellInfo_*.csv`、`LTE_SDR_CellInfo_*.csv`、`NR_CellInfo_*.csv`、`NetworkInfoStat_*.csv`、`SpecificColumn/...`、`others/...`。核心入库来源先按文件名前缀识别 `LTE_ITBBU_CellInfo`、`LTE_SDR_CellInfo`、`NR_CellInfo`。 +- 编码注意:样本中 `NR_CellInfo` 用 GBK/GB18030 解码中文正常,按 UTF-8 会乱码;后续读取 CSV 时需要做编码探测或优先兼容 GBK。 +- 目标表:`celldata.cellinfo`,字段为 `CGI/eNodeBID/CellID/PLMN/基站名称/小区名称/频点/带宽/制式/功率/网络`。其中 `CGI` 由 `PLMN-eNodeBID-CellID` 拼接生成。 +- 2.6G 映射: + - `LTE_ITBBU_CellInfo`:`eNodeBID<-eNBId`,`CellID<-cellLocalId`,`PLMN<-plmn`,`基站名称<-eNBName`,`小区名称<-CellName`,`频点<-frequency`,`带宽<-bandWidth`,`制式<-radioMode`,`功率<-cpSpeRefSigPwr`,`网络="4G"`。 + - `LTE_SDR_CellInfo`:同 `LTE_ITBBU_CellInfo`。 + - `NR_CellInfo`:`eNodeBID<-gNBId`,`CellID<-cellLocalId`,`PLMN<-plmn`,`基站名称<-gNBName`,`小区名称<-CellName`,`频点<-ssbFrequency`,`带宽<-carrierBandwidth`,`制式="2.6G"`,`功率<-powerPerRERef`,`网络="5G"`。 +- 700M 映射: + - `LTE_ITBBU_CellInfo`:`eNodeBID<-eNBId`,`CellID<-cellLocalId`,`PLMN<-plmn`,`基站名称<-eNBName`,`小区名称<-CellName`,`频点<-frequency`,`带宽<-bandWidth`,`制式<-radioMode`,`功率<-cpSpeRefSigPwr`,`网络="4G"`。 + - `NR_CellInfo`:`eNodeBID<-gNBId`,`CellID<-cellLocalId`,`PLMN<-plmn`,`基站名称<-gNBName`,`小区名称<-CellName`,`频点<-ssbFrequency`,`带宽<-carrierBandwidth`,`制式="700M"`,`功率<-powerPerRERef`,`网络="5G"`。 + +## 2026-06-25:实现 CellData 预处理与单独刷新入口 + +- 配置:`CellData` 新增 `scan_paths`、`year_dir_regex`、`file_name_regex`、`file_time_regex` 与 `mapping`。默认扫描路径为 `/网优日常优化数据文档/日常性能报表/{maxyear}年/300表`;默认 ZIP 过滤为 `Result_300_*.zip`;默认映射写入 `cellinfo` 并生成 `CGI={PLMN}-{eNodeBID}-{CellID}`。 +- 设置页:CellData 数据源卡片新增扫描路径列表、路径说明弹窗、三个高级正则输入、映射 JSON 编辑框,以及“校验 JSON / 恢复默认映射 / 保存规则”操作。说明弹窗包含路径模板、占位符、正则和多目录示例,避免在表单页堆长文案。 +- 后端:新增 `app/services/cell_data.py`,负责路径模板解析(含 `{maxyear}`/`{yyyy}`/`{yyyymm}`/`{yyyymmdd}`)、按扫描目录下一级子目录选择最新 ZIP、解析目标 CSV、执行 JSON 映射、清空并批量写入 `celldata.cellinfo`。CSV 解码优先 UTF-8/UTF-8-SIG,回退 GB18030/GBK。 +- 接口:新增 `app/api/routers/cell_data.py`,提供 `POST /api/cell-data/process/start` 与 `/status`,数据处理页新增独立 CellData 卡片,可只刷新 CellData,不跑容量处理。 +- 接入:本地上传、远程手动和自动调度入口都会在容量处理前调用 CellData 预处理;CellData 单独处理和容量处理共用现有全局任务锁,避免并发写库。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过;远程定位可从最新年份 `2026年/300表` 选出 `2.6G` 与 `700M` 各自最新 `Result_300` ZIP;用 SFTP MCP 读取的小样本 ZIP 验证解析和入库,`celldata.cellinfo` 写入 1 行且中文字段正常(样本 `CGI=460-00-12683845-1`)。 + +## 2026-06-25:CellData 卡片支持本地上传处理 + +- 数据处理页 CellData 卡片改为与主上传区一致的拖拽/点击上传样式,支持拖入或选择多个 `Result_300_*.zip`,也支持选择文件夹上传。 +- 新增 `/api/cell-data/process/upload`,上传后复用 CellData 解析入库逻辑;若 ZIP 不在 `700M/2.6G` 等目录下且无法识别频段,会跳过无法唯一匹配的 CSV。 +- CellData 卡片仍保留「远程刷新」操作,用于按系统设置中的 CellData SFTP/FTP 配置拉取处理。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:数据处理卡片补说明按钮并规范 CellData 上传 + +- 数据处理页的容量数据卡片与 CellData 卡片左上角均显示数据类型,右上角均提供说明图标按钮,点击后以小弹窗展示所需文件格式和目录结构。 +- CellData 卡片改为点击/拖拽文件夹上传,不再提供单文件选择;直接拖入单个 ZIP 会提示选择包含 `Result_300` ZIP 的文件夹。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:数据处理卡片并排展示 + +- 数据处理页容量数据与 CellData 两个卡片在宽屏下左右并排展示,窄屏下自动回落单列,减少对日志区域的挤压。 +- 容量数据说明补充:启用 CellData 数据源时,处理容量数据前会先刷新 CellData;CellData 卡片按钮文案统一为「远程下载并处理」。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:数据处理卡片说明与上传方式调整 + +- 容量数据和 CellData 卡片左上角均标注数据类型,右上角均有说明按钮;容量数据说明弹窗明确启用 CellData 时会在处理容量数据前先更新 CellData。 +- CellData 卡片点击后选择文件夹,拖拽也只接受文件夹;不支持单个 ZIP 文件直接拖入,避免缺少频段目录导致无法映射。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:远程数据源页收纳 CellData 规则设置 + +- 远程数据源页只保留容量数据源与 CellData 数据源两张连接配置卡片,宽屏下并排展示;CellData 的扫描路径、正则和映射 JSON 收纳到「规则设置」弹窗。 +- CellData 规则弹窗包含扫描路径列表、路径说明入口、高级正则和 Monaco JSON 编辑器,支持格式化、校验、恢复默认映射和保存规则。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:精简 CellData 数据源路径配置 + +- CellData 数据源卡片去掉“远程目录”输入,连接根目录固定走 `/`;实际数据位置统一由「规则设置」中的扫描路径模板决定,避免两个路径概念混淆。 +- 扫描路径占位符扩展支持 `{maxmonth}`、`{maxday}`,并新增 `month_dir_regex`、`day_dir_regex` 高级正则配置;说明弹窗同步补充相关说明。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过;构建产物与 Python 缓存已清理。 + +## 2026-06-25:CellData 扫描路径回到数据源卡片 + +- 扫描路径属于文件来源配置,已移回 CellData 数据源卡片中展示和维护;「规则设置」弹窗只保留高级正则与映射 JSON,避免弹窗承担过多基础配置。 +- CellData 数据源不再展示“远程目录”,连接根目录固定为 `/`;实际文件位置完全由扫描路径控制。 +- 扫描路径占位符支持 `{maxyear}`、`{maxmonth}`、`{maxday}`、`{yyyy}`、`{yyyymm}`、`{yyyymmdd}`,并提供对应年份/月/日目录正则配置。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过;构建产物与 Python 缓存已清理。 + +## 2026-06-25:优化 CellData 扫描路径排版 + +- CellData 数据源卡片中的扫描路径区域改为全宽列表布局,说明文字、说明按钮、路径输入、删除按钮和添加输入保持对齐,减少左侧拥挤。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:CellData 映射支持图形化编辑 + +- CellData「规则设置」弹窗中的映射规则增加“图形化 / JSON”切换。图形化模式可维护目标表、主键字段、主键表达式、来源目录、CSV 前缀和字段映射;字段映射支持“CSV 字段”和“固定值”两种模式。 +- JSON 模式仍使用 Monaco 编辑器,支持格式化、校验、恢复默认;两种模式共用同一份 `CellData.mapping` JSON,切换时自动互转。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:修正 LTE_SDR 小区名称映射 + +- `LTE_SDR_CellInfo` 的小区名称字段实际为 `cellName`(小写 c),不是 `CellName`;默认 CellData 映射和 `Configure.json` 已同步修正。 +- 使用 2.6G 样本重新导入后,`celldata.cellinfo` 中 4G/5G 的 `小区名称` 空值数均为 0。 +- 验证:`python -m compileall -q app` 通过。 + +## 2026-06-25:固定图形化映射基础区与添加来源按钮 + +- CellData「规则设置」弹窗不再整体滚动;高级匹配、目标表、主键字段和主键表达式固定显示,图形化模式下仅来源列表滚动。 +- “添加来源”按钮固定在来源列表下方,始终可见;弹窗底部保存按钮也保持可见。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:图形化来源支持折叠 + +- CellData 图形化映射中每个来源卡片支持展开/收起,来源头展示目录与 CSV 前缀摘要;“添加来源”按钮移动到映射规则标题区右侧。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:调整图形化映射添加来源位置 + +- “添加来源”按钮移动到主键表达式输入区下方右侧;点击后来源列表自动滚动到新增来源。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:清理后端直接 print 诊断输出 + +- `app/history.py`、`app/processor.py`、`app/api/routers/task_runtime.py` 中的异常诊断从直接 `print()` 改为模块级 `logging`,避免后台任务和历史清理在 stdout 中产生零散噪音,同时保留必要警告信息。 +- `app/main.py` 中的启动横幅 `print()` 保留,仍用于命令行直接启动时提示版本、配置更新时间和访问地址。 +- 验证:`CapacityReport\.venv\Scripts\python.exe -m compileall -q app` 通过;`frontend` `npx vue-tsc --noEmit --noUnusedLocals --noUnusedParameters` 通过。 + +## 2026-06-25:收敛上传文件相对路径 + +- 新增 `app.utils.files.safe_relative_path()`,容量数据上传和 CellData 本地上传保存文件前统一过滤空片段、`.` 与 `..`,避免客户端文件名或 `webkitRelativePath` 中的路径穿越片段写出任务工作目录,同时保留合法的文件夹层级。 +- 验证:`safe_relative_path` 典型路径断言通过;`python -m compileall -q app` 通过;`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:连接测试接口改为线程池执行 + +- `/api/remote/test`、`/api/config/cell-data/remote/test`、`/api/config/cell-data/mysql/test` 改为同步路由函数,保持响应结构不变,但让 FastAPI 在线程池中执行 FTP/SFTP、Metrix 平台 HTTP 与 MySQL 连接测试,避免慢连接或超时阻塞事件循环。 +- 验证:`.venv\Scripts\python.exe -m compileall -q app` 通过;`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:CellData 容量处理集成改进与前端处理进度优化 + +- 容量处理集成:`tasks.py` 和 `remote.py` 中的 `refresh_cell_data()` 调用改为 `_try_refresh_cell_data()` 包装函数,CellData 数据源未启用时直接跳过,CellData 处理失败时记录警告但继续执行容量处理,不再因 CellData SFTP 连接失败或无数据等原因导致整个容量处理任务失败。 +- CellData 阶段标识:容量处理流程中 CellData 更新阶段会设置独立的 `cell_data` stage,前端可显示"更新 CellData..."状态文案;CellData 完成后日志输出导入行数、解析行数和跳过行数摘要。 +- 前端阶段标签:`stageLabels` 新增 `cell_data: '更新 CellData...'`;`importing` 标签从"上传数据中..."改为"导入数据中..."以避免与文件上传混淆。 +- 前端结果摘要:CellData 独立处理或容量处理完成后,处理进度区域显示成功摘要(文件数、导入行数、跳过行数、耗时);`TaskStatus` 类型新增 `result?: CellDataResult` 字段。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过;构建产物已清理。 + +## 2026-06-25:ReportScript.sql 性能优化与标识符规范化 + +- Buffer Pool:脚本头部新增 `SET GLOBAL innodb_buffer_pool_size = 1073741824`(1 GB),原值 128 MB 不足以容纳 4G/5G 各 500 万行的工作集,导致 UPDATE...JOIN 等操作变成磁盘随机 I/O。 +- 消除标记-删除模式:4G 部分的 DLPRB_MAX 和 CCE_MAX、5G 部分的 DLPRB_MAX 原先各用 `ADD COLUMN insert → UPDATE...JOIN → ADD INDEX → DELETE → DROP COLUMN` 五步标记并删除重复行,现改为 `INSERT...WHERE NOT EXISTS` 一步完成,共减少约 15 条冗余 DDL/DML。这正是导致 CCE_MAX JOIN 4G_MAX UPDATE 跑 >1 小时的直接原因。 +- 标识符规范化:全文所有表名和列名统一加反引号,避免以数字开头的表名(如 `4G`、`5G`)和中文列名在不同 MySQL 版本或 SQL 模式下引起解析歧义。 +- 业务逻辑未变:忙时取法(ULPRB > DLPRB > CCE 优先级)、结果表聚合和 IFNULL 归零逻辑均保持原样。 + +## 2026-06-25:修复 ISO8601 日期时间导入时区偏移 + +- 根因:源 CSV 中日期时间格式为 `2026-06-15T00:00:00+08:00`(ISO 8601 带时区),`pd.to_datetime(format='ISO8601')` 会自动转为 UTC(`2026-06-14 16:00:00`),导致入库后比实际壁钟时间偏移 -8 小时。 +- 修复:`processor.py::_convert_datetime_column` 在 ISO8601 解析前先用正则剥离时区后缀(`+08:00`、`-05:30`、`Z`)和 `T` 分隔符,再按朴素日期时间解析,保留原始壁钟时间。 +- SQL 清理:`ReportScript.sql` 删除 4G/5G 的 `DATE_ADD(INTERVAL 8 HOUR)` 补偿语句,因为数据导入阶段已正确保留本地时间,不再需要 SQL 层面修正。 +- 验证:`python -m compileall -q app` 通过。 + +## 2026-06-25:CellData 脚本编辑与跨库表复制 + +- 新增 `CellDataScript.sql` 脚本路径(`config.py::CELLDATA_SCRIPT = BASE_DIR / "CellDataScript.sql"`),与 `ReportScript.sql` 并列,用于 CellData 数据导入后执行的 SQL。 +- 脚本 API(`script.py`)三个接口(读取/保存/执行)新增 `script_type` 参数(`report` / `celldata`),默认 `report` 保持向后兼容;CellData 脚本执行在 CellData 数据库上下文中运行。 +- 新增 `cell_data.py::execute_celldata_script()`:在 CellData MySQL 库上执行 CellDataScript.sql,复用 `DataProcessor.parse_sql_script` 解析。 +- 新增 `cell_data.py::copy_celldata_tables_to_capacity()`:列出 CellData 库所有表,逐表 `SHOW CREATE TABLE` → 目标库建表 → 分批 `SELECT * / INSERT INTO` 复制数据;两库相同时跳过。 +- CellData 独立处理(远程 / 本地上传)完成后自动执行 CellData 脚本(不复制表)。 +- 容量处理集成(`tasks.py` / `remote.py` 的 `_try_refresh_cell_data`):CellData 数据更新 → 执行 CellData 脚本 → 复制表到容量库 → 后续 ReportScript.sql 可直接引用 CellData 表。 +- 前端脚本编辑页(`ScriptPanel.vue`)页头下拉按钮切换「容量报表脚本」和「CellData 脚本」;运行按钮文案统一为「运行」,跟随当前脚本类型执行对应库的 SQL。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过;构建产物已清理。 + +## 2026-06-25:Metrix 平台模式按需启用 + 数据源合并为外部储存 + +- Metrix 平台开关:新增 `AppConfig.metrix_enabled`(默认 `false`),存储为 `Configure.json` 的 `MetrixEnabled`;关闭时 `source_type` 和 `warehouse_type` 自动回落为 `external` / `mysql`。 +- 开关入口:连点品牌图标 8 次打开的「授权延期」弹窗底部新增「启用 Metrix 平台」开关(`LicenseActivationModal.vue`),通过 `POST /api/config/metrix-enabled` 保存。 +- 条件显示:设置页「数据源 / 仓库」标签页仅在 `metrix_enabled=true` 时显示;未启用时该页不可见,系统默认走外部储存 + MySQL 直连。 +- 数据源合并:`source_type` 从 `ftp | sftp | metrix` 改为 `external | metrix`;FTP/SFTP 协议选择保留在远程数据源配置的 `RemoteData.protocol` 中,`external` 统一代表外部储存。旧值 `ftp`/`sftp` 自动规范化为 `external`。 +- 全局状态:`metrixEnabled` 通过 `composables/metrixEnabled.ts` 共享响应式状态,`AppShell` 登录后和激活弹窗切换时同步更新。 +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过;构建产物已清理。 + +## 2026-06-26:数据管理增强(列宽可调 + 行编辑/删除 + 模板/导入)与 CellData 处理日志细化 + +数据管理(`frontend/src/components/DatabasePanel.vue` + `app/api/routers/database.py` + `app/database.py` + `app/warehouse.py`): +- 数据表列宽可拖拽:`n-data-table` 每列加 `resizable + width`,`scroll-x` 计入新增操作列宽度。 +- 新增固定右侧「操作」列:每行「编辑 / 删除」。编辑弹窗按列字段逐项填写后保存(清空某字段=写入 NULL,前端空串→`null`,后端 `None`→SQL `NULL`);删除二次确认;操作后自动刷新当前页。 +- 行定位策略:以「整行原值」作为 WHERE 条件 + `LIMIT 1`(NULL 用 `IS NULL`),无主键表也能精确改/删单行、避免误伤重复行。后端 `DatabaseManager.update_row/delete_row`(参数化)与 `MetrixWarehouse.update_row/delete_row`(经 `execute_sql` 字面量 SQL)。 +- 工具栏新增「模板」「导入」(在 刷新/清空/删除 同组):模板下载当前表字段的 CSV 表头(`POST /api/database/table/template`,FileResponse);导入上传 CSV(`POST /api/database/table/import`,multipart,sync def 走线程池)。**导入按模板校验**:CSV 表头必须与表字段完全一致,缺字段/多字段一律 400 失败;通过后追加导入(`DatabaseManager.import_csv` 走 `bulk_insert`;Metrix 走平台 `/import` mode=append、create_table=false)。 +- 新接口均支持 `database_source`(main/cell_data) 双库;前端用 `client.upload`(multipart) 导入、`download`(POST) 下模板。 + +CellData 处理日志细化(`app/services/cell_data.py`): +- `_parse_zip_files`(解压/解析):逐 ZIP 打「解压 {频段}/{文件名}(KB)」+「含 N 个 CSV」,逐 CSV 打「解析 {文件名}(频段):有效 X / 跳过 Y 行」,每包小计,末尾打「累计有效 / 去重后 / 累计跳过」。 +- `_replace_cellinfo`(上传/导入):打「准备写入表/库」「已连接」「确认表结构」「清空表(TRUNCATE)」「写入中 written/total 行」分批进度「已提交,成功写入 N 行」,失败回滚也记录。 + +附带修复:`frontend/src/vite-env.d.ts` 增加 `declare module 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js'`,修复 ScriptPanel SQL 补全的深层导入缺类型声明导致 `vue-tsc`(`npm run build`)失败(TS7016)。 + +- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过(DatabasePanel chunk≈124KB)。 + +## 2026-06-26:容量看板(4G/5G 高负荷分析大屏) + +- 后端 `app/api/routers/dashboard.py`(`main.py` 注册):基于「主仓库」(`make_warehouse`,直连 MySQL 或 Metrix) 的 4G/5G 结果表聚合分析,4 个接口: + - `GET /api/dashboard/status` → `{has_4g,has_5g,ready}`(两表都在才 ready); + - `GET /api/dashboard/overview?rat=4g|5g` → 汇总(总数/高负荷/利用率预警/高流量预警/正常/平均下行利用率/总流量) + 问题分布饼 + 制式/带宽/站型/频段 分组统计 + 下行利用率 10 档直方 + Top10 高负荷小区; + - `GET /api/dashboard/cells?rat&problem&keyword&page&page_size` → 问题小区清单(按 高负荷>高流量预警>利用率预警 + 下行利用率 排序,可筛选); + - `GET /api/dashboard/cell?rat&id` → 单小区全字段 + 优化建议 + 同扇区同 PLMN 兄弟小区。 + - 4G 指标用 PUSCH/PDSCH 利用率与 YY-RRC,5G 用 PRB 利用率与 RRC 平均;execute_sql 原始 SQL,用户输入(id/keyword) 转义。 +- 前端:新增依赖 `echarts`;`components/EChart.vue`(echarts 封装,ResizeObserver 自适应 + dispose);`components/CapacityDashboard.vue`(科技感大屏:4G/5G 分段切换、6 张汇总卡片含数字滚动、6 个图表(环形/直方/分组柱/饼/横向柱)、问题小区清单(筛选+分页+点击行打开)、右侧详情抽屉(关键指标 + 优化建议 + 同扇区兄弟小区))。无结果表时横纵居中「请先进行数据处理」。 +- 路由 `/dashboard`(name=`dashboard`,懒加载);侧边菜单「容量看板」置于「数据处理」下方(`AppShell` menuKeys/menuOptions + `StatsChartOutline`)。看板自带深色科技风(径向辉光/玻璃拟态/霓虹色),不随应用明暗主题;图表 echarts 自定义深色配色。 +- 验证:`npm run build`(vue-tsc)通过(CapacityDashboard 懒加载 chunk≈1.16MB 含 echarts);后端 `compileall` 通过;各接口 SQL 经 MCP 对真实数据验证(4G 高负荷 792 / 5G 46,同扇区邻区 6–8 个)。 + +## 2026-06-26:容量看板配色统一 + 整页不滚动 + 清单导出 + +- 组件配色统一:`CapacityDashboard.vue` 整体用 `` 包裹,强制看板内所有 Naive 组件(数据表/搜索框/分页/抽屉/Empty/按钮)走深色 + 青色(`#22d3ee`)强调色,解决浅色应用主题下表格/搜索/分页与深色大屏不匹配的问题。`naiveDark` 覆盖 DataTable(透明 td、半透明 th、淡边框)、Input(深色半透明底)、Pagination(深色项+青色激活)、Drawer(`#0c1422`)、Empty。抽屉内自定义 `.cap-*` 样式由 `var(--td-*)` 改为固定深色值,不再跟随应用主题。 +- 整页不滚动:`.cap-dashboard` 由 `overflow:auto` 改为 `height:100%; overflow:hidden; display:flex; flex-direction:column`(父链 `.content` 高 `calc(100vh-64px)` 为确定值)。布局分三段:顶栏(auto) → `.cap-spin`(flex:0 0 auto,包裹卡片+图表) → `.cap-list`(flex:1,占满剩余)。**清单已移出 `n-spin`** 成为 `.cap-body` 直接 flex 子项以获得确定高度;表格 `n-data-table` 加 `flex-height + height:100%`,由 `.cap-table-wrap`(flex:1 min-height:0) 提供高度,表体内部纵向滚动而非整页滚动。图表改为 2 行 3 列等宽网格,高度 `clamp(136px,16vh,184px)` 随视口自适应;卡片/字号整体缩小。窄屏(<1100px) 回退为 `overflow:auto` 多列换行。 +- 清单导出:后端新增 `GET /api/dashboard/export?rat&problem&keyword`(`dashboard.py`,sync def 走线程池),WHERE 与 `cells` 完全一致(同 problem/keyword 筛选、同排序、无分页),导出全部命中行为 CSV(`utf-8-sig`,FileResponse + BackgroundTask 清理 CACHE_DIR 临时文件);列含 CGI/NCGI、小区名称、制式/带宽/站型/频段、上下行利用率%、日均流量、用户数、高负荷问题、优化建议。前端清单工具栏「查询」旁加「导出」按钮(`downloadGet`,`exporting` 态,清单为空时禁用)。 +- 验证:`frontend` `vue-tsc --noEmit` 通过;后端 `py_compile dashboard.py` 通过。 + +## 2026-06-26:容量看板布局重构(定高不滚动)+ KPI/图表调整 + +- **修复整页布局塌陷**:上一版 `.cap-dashboard { height:100% }` 在 Naive `n-layout-content`(`.content`=`height:calc(100vh-64px); overflow:auto`) 结构下未解析为确定高度,导致 `flex:1` 的清单塌成 0、下方露白、加载态只剩顶部一条。改为 `.cap-dashboard { height: calc(100vh - 64px); box-sizing:border-box; overflow:hidden; flex column }`(视口定高,不依赖父级百分比,与 `.content` 等高故无页面滚动条)。窄屏(<1180px) 回退 `height:auto; overflow:auto`。 +- **清单占满剩余 + 表头固定**:`.cap-body` flex 列三段=顶栏(0) / `.cap-overview`(0,n-spin 包裹卡片+图表) / `.cap-list`(flex:1)。清单作为 `.cap-body` 直接 flex 子项;`n-data-table` 用 `flex-height + height:100%`,由 `.cap-table-wrap`(flex:1 min-height:0) 给高,**表体内部纵向滚动、表头与 scroll-x 横向条不跟随滚动**。 +- **KPI 卡片 7 个**(一行,宽度缩小):新增「平均上行利用率」(`summary.avg_ul`,后端 SQL 加 `AVG(ul)*100`);「总日均流量」改为 `formatFlow()` 自动换算单位 B/KB/MB/GB/TB/PB(源值 GB,≥1024 逐级进位,<1 逐级降级),4G≈780.1 TB / 5G≈2.34 PB。 +- **图表两行三列**:行一=负荷问题分布(环形) / 上行利用率分布(直方) / 下行利用率分布(直方);行二=制式分布(分组柱) / 站型分布(饼) / 频段标记小区(横向柱)。**删除带宽分布**;后端 overview 删 `by_band`、`util_hist` 拆为 `ul_hist`+`dl_hist`(复用 `util_hist(col)` 函数)。 +- **制式分布排除「未知」**:`group_by` 增 `skip_unknown` 参数(`WHERE 制式 IS NOT NULL AND <> ''`),制式分布只剩 TDD/FDD。 +- 行间距:统一用 flex `gap:12px`,卡片/图表 panel 内边距收紧,避免叠压。图表高度 `clamp(108px,13vh,160px)` 适配矮窗口。 +- 验证:`frontend` `npm run build`(vue-tsc+vite) 通过;后端 `py_compile` 通过;4G/5G 的 avg_ul/total_flow、制式排除空值、上下行直方均经 MCP 对真实数据验证。 + +## 2026-06-26:容量看板适配日/夜双主题 + +- 跟随应用主题:复用全局 `composables/theme.ts` 的 `themeName`(`light|dark`,写 `documentElement[data-theme]`)。看板 `isLight = computed(themeName==='light')`。 +- Naive 组件:`n-config-provider :theme` 绑定 `lightTheme|darkTheme`;`:theme-overrides` 绑定 `naiveLight|naiveDark`(两套 DataTable/Input/Pagination/Drawer/Empty 覆盖,浅色强调色用 `#0891b2`、深色用 `#22d3ee`)。 +- ECharts:新增 `chartPalette` computed(轴文字/轴线/分割线/tooltip 底色与文字/图例/饼描边/柱底色 两套值),所有 option 通过 `axisLine/axisLabel/splitLine/tooltipBase/legendStyle` 辅助函数读取,主题切换时 computed 重算、`EChart.vue` 深度 watch `setOption(opt,true)` 自动重绘。 +- CSS:硬编码颜色抽成 `--cap-*` 变量(bg/panel/card/border/shadow/text 系列/soft-bg/tag 等),**定义在 `:global([data-theme='dark'])` 与 `:global([data-theme='light'])`(documentElement)** 上——这样 teleport 到 body 的详情抽屉也能继承同一套变量。浅色:白底玻璃卡片 + 柔和阴影 + 深色文字;深色:原科技风径向辉光。强调色(青/紫/玫红/琥珀)与 `.cap-spark`/分段激活态在两主题保持一致。卡片 icon/底色条改用更深的实色(#06b6d4/#f43f5e 等)以保证浅色下对比度。 +- 验证:`frontend` `npm run build`(vue-tsc+vite) 通过。 + +## 2026-06-26:容量看板默认 5G + 数值轴刻度防重叠 + +- 默认制式:看板进入默认 `rat='5g'`(原 4g)。 +- 修复矮图表数值轴刻度重叠:图表高度仅 `clamp(108px,13vh,160px)`,上行/下行利用率分布 0-10% 桶计数极大(约 1.9w)且带千分位,ECharts 自动放 5-6 条刻度导致 Y 轴标签纵向叠压。新增 `abbrNum()`(≥1万→「x万」、≥1千→「xk」、≥1亿→「x亿」)与 `valueAxis()`(`splitNumber:3` + 缩写 formatter),套用到上/下行直方与制式分布的数值轴;频段标记小区横向柱的数值 x 轴同样加 `splitNumber:3`+缩写。 +- 验证:`frontend` `vue-tsc --noEmit` 通过。 + +## 2026-06-26:容量看板标题/操作并入框架页头 + +- 参照「处理历史」做法,看板不再自绘标题栏:删除 `.cap-topbar`(标题/副标题/制式切换/刷新)。改用 `composables/pageHeader.ts` 的 `setPageHeader`/`resetPageHeader` 把内容注入 AppShell 顶部 `page-header`(与其他页一致,标题取路由 meta「容量看板」)。 +- 注入内容:subtitle「高负荷小区分析 · 实时数据」;actions = 4G / 5G 两个按钮(激活=`type:primary, variant:solid`,未激活=`default+outline`,点击 `switchRat`)+「刷新」(icon `RefreshOutline`,`loading/disabled` 绑 `loadingOverview`)。因 `PageHeaderAction.type/variant` 非响应式,用 `watch([rat, ready], applyPageHeader)` 在制式切换/就绪时重建 actions;`onBeforeUnmount` 调 `resetPageHeader` 清理;未就绪(无结果表)时不注入。 +- 清理:移除 `ratOptions` 及 `.cap-topbar/.cap-title/.cap-spark/.cap-sub/.cap-controls/.cap-refresh` 样式;`.cap-seg*` 保留(问题清单的筛选分段仍在用)。 +- 验证:`frontend` `npm run build`(vue-tsc+vite) 通过。 + +## 2026-06-26:看板页头去副标题 + 站型/频段排除未知 + 矮屏可滚动 + +- 去掉页头副标题「高负荷小区分析 · 实时数据」:`applyPageHeader` 只传 `actions`,不传 `subtitle`。 +- 站型分布、频段标记小区也排除「未知」:`overview` 的 `by_station`、`by_freq` 调用加 `skip_unknown=True`(同制式分布,`WHERE 列 IS NOT NULL AND <> ''`)。 +- 矮屏可滚动:`.cap-dashboard` `overflow:hidden` → `overflow-y:auto`(仍固定 `height:calc(100vh-64px)`);`.cap-list` `min-height:0` → `min-height:300px`。视口够高时 flex 填满不滚动;过矮时卡片+图表+清单(≥300px) 总高超出 → 整页出纵向滚动条,可滚到问题小区清单。移除原 `@media(<=1180px)` 里多余的 `height:auto/overflow` 覆盖(统一由主规则处理)。 +- 验证:后端 `py_compile` 通过;`frontend` `vue-tsc --noEmit` 通过。 + +## 2026-06-26:看板小区详情抽屉优化 + +- 抽屉标题 CGI 与小区名称上下两行:`.cap-detail-head` 改 `flex-direction:column`。 +- 标签区去掉重复的「频段」tag(5G 制式值与频段常同为如 700M 显得重复),保留 制式/带宽/站型。 +- 指标卡片重排为:物理站 → 扇区 → 上行利用率 → 下行利用率 → 日均流量 → 用户数 → 小区功率 → 是否高负荷(`detailMetrics` 顺序调整)。 +- 优化建议右上角加复制按钮:`copySuggestion()` 复用 `composables/clipboard.writeClipboardText` 复制 `detailSuggestion`,成功/失败 `message` 提示;`.cap-suggest-head` 改 space-between 布局。 +- 同扇区同运营商小区每项加详情图标按钮(`OpenOutline`),点击 `openDetail(sib.id)` 在抽屉内切换到该小区详情;`.cap-sib-item` 改 flex 行(`.cap-sib-body` 占主 + 右侧 `.cap-icon-btn`)。新增通用 `.cap-icon-btn` 样式(复制/详情共用)。 +- 验证:`frontend` `vue-tsc --noEmit` 通过。 + +## 2026-06-26:CellData.sql 新增 cellinfo→sector 逆推(特征库 + 补缺不覆盖) + +- 背景:`celldata.cellinfo`(自动处理、有数据源)→ `celldata.sector`(原人工整理)。新脚本自动逆推 sector 的 扇区/物理站/制式/频段/带宽/站型/网络。原 `区域` 列经全项目检索无任何引用(其余「区域」均为 UI 文案),已 `DROP COLUMN` 删除,CellData.sql INSERT 同步去除。 +- 特征库 `sector_band_ref`(频点区间+PLMN→制式/频段,10 条):700M/广电同频 763.25 用 PLMN 区分(460-00/460-15);4.9G 用频点≥4000;4G FDD900/1800、TDD F/A/E/D 频按频点分档。**3DMM 在 cellinfo 无任何字段标注**(cellinfo.制式 仅 700M/TDD/FDD/2.6G,那 1400 个 sector-3DMM 在 cellinfo 全是 TDD/4G);按"3DMM 用 TDD 制式规则",脚本依频点判 TDD/D频 即正确,故制式实际正确率≈100%。 +- 逆推 staging `_sector_infer`:预处理小区名(全角括号→半角、【】→()、去空格/制表符)→ 剥设备码正则 `[A-Z0-9]+-Z[A-Z0-9]{2}-[0-9]+$` 得 base;站型(码尾 W=室分 / 名称含"微小"=微站 / 否则宏站);制式频段经特征表 JOIN(未命中回落 cellinfo 制式);物理站(非700M 删全部成对括号+清悬空+回贴(微小X);700M 取「外层去CBN-的前2字地市前缀」匹配同前缀括号内容、否则取外层——通用不写死江门,3字地市取前2字仍可);扇区=物理站+扇区号(700M 末位/其余 %100)。 +- 落库:`INSERT` 仅补 sector 中缺失 CGI(**不清空、不覆盖**已有行,保人工修正如 3DMM/15M/纠正物理站),末尾 DROP staging;`sector_band_ref` 作为持久特征库保留。 +- 准确率(MCP 实测,重叠 66001):网络 100%、制式 97.88%(排除 3DMM 即 100%)、频段 97.6%、带宽 99.75%、站型 99.94%、**物理站 99.59%、扇区 99.20%**;残差为不可还原的人工差异(室分多载波编号、700M 个别人工编号、源数据制表符等)。详见 `docs/sector_inference_research.md`。 +- 关键坑:MySQL 字符串字面量会吞掉未知转义的反斜杠,正则字面量需写 `\\(`(.sql 文件)/ JSON 调用需 `\\\\(`;ICU 正则字符类内的 `[ ]` 易误判,统一把名称中的 `[]` 转 `()` 后只处理圆括号。 + +## 2026-06-26:前置检查补全「库不存在自动创建」 + +- 问题:原 db_init 只建表不建库,库(schema)不存在时连接 `database=dbname` 直接失败,库始终建不出来。 +- 修复:`app/db_init.py` 新增 `_ensure_database`(用**不带 database** 的连接执行 `CREATE DATABASE IF NOT EXISTS \`库\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`)与 `_ensure_databases`(遍历 `DB_KEYS=(celldata, capacityreport)`,主库仅直连 MySQL 模式);`ensure_required_tables` 开头先建库再建表。建库走独立库清单**不依赖建表 SQL**(主库无建表文件但仍需建库)。库名以配置为准、反引号去冲突;best-effort 不阻断。 +- 调用链不变:main 启动 lifespan + execute_celldata_script 前均会触发,故启动/手动上传/容量处理前库都会自愈。需 MySQL 账号有建库权限(root 默认有);Metrix 模式主库走平台不在此建。 +- 校验:py_compile 通过;MCP 验证 `CREATE DATABASE IF NOT EXISTS` 语法/权限有效(建临时库→确认→删除)。db_init/README.md 同步更正(原写"只建表不建库")。 + +## 2026-06-26:修复看板问题清单固定列重叠(透明背景透出下层列) + +- 现象:问题小区清单横向滚动时,右固定列「问题」与滚动区「日均流量」列视觉重叠。 +- 根因:naive DataTable override 把 `tdColor` 设为 `transparent`(为融入玻璃面板),导致固定列(CGI 左固定 / 问题 右固定)背景透明,滚动时透出下层普通列内容。 +- 修复:新增主题变量 `--cap-table-fixed-bg`/`--cap-table-fixed-bg-hover`(深色 #111c2e/#18283f,浅色 #fff/#eaf6fb),用 `:deep` 给 `.n-data-table-th/td--fixed-left/right` 设不透明背景(含 tr:hover)。非固定列仍保持透明融入面板。 +- 校验:vue-tsc 通过。 + +## 2026-06-26:配置导入补全 MetrixEnabled(导出导入字段全对齐) + +- 问题:`config.py::to_file_dict`(导出)含 12 字段,但 `api/routers/config.py::_apply_config_data`(导入)漏处理 `MetrixEnabled` → 导入后「启用 Metrix 平台」开关不恢复,且可能出现 `SourceType=metrix` 但 `metrix_enabled=false` 的不一致。 +- 修复:导入开头读取 `MetrixEnabled`;并在未启用 Metrix 时强制 `source_type=external`/`warehouse_type=mysql`(与 `/api/config/metrix-enabled` 开关行为一致)。前端导出/导入直接传整份 Configure.json 走 `/api/config/download`、`/api/config/upload`,不在前端拆字段,故后端补全即全覆盖。 +- 校验:导出字段集合 vs 导入处理集合比对,「导出但未导入(除时间戳 Update)」为空;py_compile 通过。 + +## 2026-06-26:Docker 一键编排补齐(应用 + MySQL + sftpgo) + +- `packaging/docker-compose.yml`:在原 capacity-app + capacity-mysql 基础上**新增 sftpgo 服务**(`drakkan/sftpgo:v2.7.1`,SFTP 2022、Web 18080:8080,卷 `sftpgo-config`/`sftpgo-data`,`SFTPGO_LOADDATA_FROM` 导入 SFTP 用户)。capacity-app 加 `build` 上下文与一组连接环境变量(DB_HOST/PORT/USER/PASSWD、MAIN_DB_NAME、CELLDATA_DB_NAME、SFTP_HOST/PORT/USER/PASSWD),depends_on mysql(healthy)+sftpgo。 +- `packaging/sftpgo/sftpgo-init.json`:自动建 SFTP 用户 `capacity/capacity123`(home `/srv/sftpgo/data/capacity`);Web 管理员 `admin/gmcc123` 由 env 创建。 +- `packaging/mysql/init/01-init-db.sql`:补建 `celldata` 库(原只建 CapacityReport);表仍由前置检查按需建。 +- **修复镜像缺文件**:根 `Dockerfile` 原只拷 `Configure.json/ReportScript.sql`,漏了 `CellData.sql` 与 `db_init/`(容器内跑 CellData/前置检查会缺);现增拷 `CellData.sql`、`db_init/`、整个 `docker/`。`docker/entrypoint.sh` 增加播种 `CellData.sql`、`db_init/` 到 /data,并在**首次播种**时调用新脚本 `docker/apply_env_config.py` 按 env 改写 Configure.json 的数据库/SFTP 连接(用户后续界面改动不被覆盖)。 +- `packaging/README.md`:一键编排使用说明(构建前端→compose build/up、默认账号、放数据路径、数据卷)。 +- 校验:`docker compose config -q` 通过;JSON/py_compile 通过。 +- 文档:`packaging/README.md` 重写为「一键构建整套系统」完整指南(方式A 联网 build+up、方式B 内网离线 load+up、默认账号、上传数据路径、数据卷、运维命令);主 `README.md` 增「Docker 编排」段落并链接 packaging/README.md。 +- 修复 `scripts/build_docker.py` 离线包缺口:`make_bundle` 原只拷 `mysql/`,现补拷 `sftpgo/` 与 `packaging/README.md`(compose 引用 `./sftpgo/sftpgo-init.json`,否则离线包启动缺文件)。 +- 离线镜像:`F:\CR\env\images` 已 `docker save` 三个基础镜像(mysql_8.0.44.tar/sftpgo_v2.7.1.tar/python_3.13.11-slim.tar)+ `load_images.py`(扫描同目录 *.tar 逐个 `docker load -i ./xxx.tar`,已实测导入成功)。注:F: 盘离线资产不在仓库内。 + +## 2026-06-26:测试数据抽取工具(本地 test/,已 gitignore) + +- `.gitignore` 新增忽略 `test/` 与 `temp/`(生成的测试数据)。`test/` 目录及其脚本不入库,仅本地用。 +- `test/make_test_data.py`:交互式从完整 CapacityReportData 源目录抽小样本,输出到 `<仓库>/temp/CapacityReport`(结构同源 4G/5G/RJ,每次生成前先清空)。启动询问「源目录」「每频段数量(默认500)」。 +- 抽取规则:按数据文件夹为单位,4G 主文件按 `制式` 列拆 TD-LTE(TDD)/LTE FDD(FDD) 各取数量;每频段 = `test/dump.csv` 清单中属于该频段的小区(优先全保留) + 指定数量其他小区;保留全部列与时序仅删行;RJ 日均流量整体原样复制。小区主键 4G=eNodeBId-cellId、5G/RJ=网元ID-cellId(注意 4G CSV 同时含网元ID,需以 eNodeBId 为准并保留制式拆分)。 +- `test/dump.csv`:首行 `CGI` 表头,其后每行一个 CGI/NCGI(自动去 PLMN 前缀匹配,无注释);用于固定保留高负荷等特定小区。抽取数量由运行时输入决定(默认 500)。 +- 实测:源 673MB→样本(500/频段) 约 34MB;优先小区与制式拆分均验证正确。 + +## 2026-06-26:容量处理无条件同步 CellData→容量库(覆盖手动上传场景) + +- 问题:原 `_try_refresh_cell_data`(tasks.py / remote.py)首行 `if not cell_data.remote_data.enabled: return`,把「远程拉取 + 执行 CellData.sql + `copy_celldata_tables_to_capacity`」整体锁在 FTP/SFTP 开关后。手动上传 CellData 入库(无 FTP)时,容量处理不会把 celldata 的 cellinfo/sector 复制到 capacityreport,导致 ReportScript 富集用的是旧/缺失数据(celldata 与容量为不同库时)。 +- 改法:解耦——FTP 远程拉取 `refresh_cell_data` 仍仅在 `remote_data.enabled` 时执行;`execute_celldata_script` + `copy_celldata_tables_to_capacity` 改为**容量处理时无条件执行**,独立 try/except,best-effort(celldata 无数据/连不上、或 celldata 与容量同库则内部跳过,不阻断容量处理)。tasks.py 与 remote.py 两处同改。 +- 效果:无论 FTP 自动拉取还是手动上传 CellData,容量处理都会先把最新 cellinfo/sector 同步到 capacityreport 再跑报表。 + +## 2026-06-26:数据库前置检查 / 缺表自动初始化(db_init) + +- 目的:运行前自动检查两库「必须存在的结构表」,缺表则按预设 SQL 自动建好,避免运行中因缺表报错。 +- 目录 `db_init/`(一个目录,文件名 `<库标识>.<表名>.sql`):`<库标识>` 决定连哪个库且**库名以用户配置为准**——`celldata`→`cell_data.mysql`(始终直连)、`capacityreport`→`mysql`(仅 `warehouse_type=='mysql'` 直连时检查,Metrix 模式跳过)。当前文件:`celldata.cellinfo.sql`、`celldata.sector.sql`、`celldata.sector_band_ref.sql`(含 10 条预设频段规则)。`README.md` 说明命名/时机/规则。 +- 必须存在表分析:celldata 的 `sector` **无任何流程会自动建**(CellData.sql 直接 INSERT,缺表必报错),`sector_band_ref` 需持久+预设,`cellinfo` 兜底(正常由导入 `CREATE TABLE IF NOT EXISTS` 自建)。capacityreport 的 `4G_UD/5G_UD`、`4G_结果表/5G_结果表`、复制来的 `sector/cellinfo` 全由导入/ReportScript/跨库复制以 `DROP+CREATE` 动态生成(结构随源字段变),**不前置强建**以免冲突。 +- 实现 `app/db_init.py::ensure_required_tables(app_config, logger=None)`:扫描 `db_init/*.sql` 按库分组;`SHOW TABLES` 取现有表,**仅当目标表不存在**时用 `DataProcessor.parse_sql_script` 拆分并执行该文件(故 `sector_band_ref` 预设只在首建时写入,绝不覆盖用户自定义);逐库/逐表 try/except,best-effort 不阻断。 +- 接入:`app/main.py` lifespan 启动时调用;`execute_celldata_script` 执行 CellData.sql 前调用(确保 sector/sector_band_ref 就位)。 +- CellData.sql 同步:**移除原 `DROP TABLE sector_band_ref; CREATE; INSERT` 重建块**,改为依赖前置检查持久维护(用户可在库中自行增改频段规则不被冲掉);其余逆推逻辑不变,小节重排为 1/2.x/3。 +- 前提:数据库本身需已存在(本机制只建表不建库)。本地无 pymysql(运行态在 Docker,requirements 已含),端到端以 py_compile + 三表 CREATE 语法(MCP)验证。 + +## 2026-06-26:离线可用性全面审计(运行期零外网) + +结论:**运行期已完全离线就绪,无需改运行时代码**;外网仅「构建期」需要。审计证据: + +运行期(无外网)已满足: +- 前端 `dist` 完全自包含:所有 JS/CSS、图标(`@vicons/ionicons5` 按需 tree-shake 进 JS)、Monaco 图标字体 `codicon-*.ttf` 均为本地资源。`rg --no-ignore` 扫描整个 `dist`,出现的 http(s) 字符串全是**无害标识符/文档链接**:`127.0.0.1`/`host.docker.internal`(本地后端地址)、`json-schema.org`/`w3.org`(JSON Schema 方言 ID 与 XML/SVG 命名空间,仅作字符串永不下载)、`github.com`/`vuejs.org`/`naiveui.com`/`microsoft.com`(库源码注释与报错文档链接,用户点击才跳)。无 CDN、无在线字体(`@font-face`)、无外部 ` + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e7e9778 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2393 @@ +{ + "name": "capacity-report-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "capacity-report-frontend", + "version": "1.0.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0", + "@vicons/ionicons5": "^0.13.0", + "echarts": "^6.1.0", + "monaco-editor": "^0.55.1", + "naive-ui": "^2.42.0", + "vue": "^3.5.0", + "vue-router": "^5.0.7" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.0", + "typescript": "^5.9.0", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^32.1.0", + "vite": "^7.0.0", + "vue-tsc": "^3.0.0" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.5.tgz", + "integrity": "sha512-nFZPWz3FHIS7y6rMIVoa/WBwjdutfIaRJIBQjzn+t3RnecZoRNlGmGcyR2wb0T/IgSd50Kz/6dG8/LvMCRunjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-rc.5", + "@babel/types": "^8.0.0-rc.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.5.tgz", + "integrity": "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.5.tgz", + "integrity": "sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.5.tgz", + "integrity": "sha512-/Mfg83rK3+jsRbl4Vbd0jqxc6M1A1/WNFtgrowRM1unEsD3XcNnrBdMM0JWakd0/RN9lseQKwPduW1TiEwKOlQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.5.tgz", + "integrity": "sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.5", + "@babel/helper-validator-identifier": "^8.0.0-rc.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vicons/ionicons5": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@vicons/ionicons5/-/ionicons5-0.13.0.tgz", + "integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.2.tgz", + "integrity": "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.2" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.2.tgz", + "integrity": "sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.2", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.2.tgz", + "integrity": "sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.9.tgz", + "integrity": "sha512-ie0ojt/0fU/GfIogh+zgHbaYRPlt9S+cLOxcWwF7nTSFh897BVgnFKL2byT4kpp1mlqYWZ2psGwSniyE2xsxYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/naive-ui": { + "version": "2.44.1", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz", + "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.65" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/unimport": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-5.7.0.tgz", + "integrity": "sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "pkg-types": "^2.3.0", + "scule": "^1.3.0", + "strip-literal": "^3.1.0", + "tinyglobby": "^0.2.15", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz", + "integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "picomatch": "^4.0.3", + "unimport": "^5.6.0", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^4.0.0", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-auto-import/node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-vue-components": { + "version": "32.1.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz", + "integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.2.0", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.4", + "tinyglobby": "^0.2.16", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.7.tgz", + "integrity": "sha512-dqfk8kvRbCutmCOCj/XLDqDEYxc1wBdAOGLuVy5M93ifYMsBd5fIjfaPN4tQAbxr5IprdBDIox1gr4wYyOx/SA==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^8.0.0-rc.4", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.1.1", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34", + "pinia": "^3.0.4", + "vue": "^3.5.34" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.9.tgz", + "integrity": "sha512-qm8/nbo+9eZc1SCndm9wT+gq23pM+wRIdHY0wjm83B3lIginHTwcdrLUyTrKjDWXbMVNjKegNrnymhpdqnCL3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.9" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.65", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ec86b4f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "capacity-report-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "scarfSettings": { + "enabled": false + }, + "overrides": { + "dompurify": "3.4.7" + }, + "dependencies": { + "@tauri-apps/api": "^2.11.0", + "@vicons/ionicons5": "^0.13.0", + "echarts": "^6.1.0", + "monaco-editor": "^0.55.1", + "naive-ui": "^2.42.0", + "vue": "^3.5.0", + "vue-router": "^5.0.7" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.0", + "typescript": "^5.9.0", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^32.1.0", + "vite": "^7.0.0", + "vue-tsc": "^3.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..552dd99 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/AppShell.vue b/frontend/src/AppShell.vue new file mode 100644 index 0000000..aec5fb7 --- /dev/null +++ b/frontend/src/AppShell.vue @@ -0,0 +1,318 @@ + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..123a95e --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,394 @@ +import type { ApiError, ApiErrorDetail } from '../types'; + +const TOKEN_KEY = 'capacity_report_token'; +const DESKTOP_API_BASE = 'http://127.0.0.1:9081'; +const API_BASE = resolveApiBase(); +const API_FETCH_RETRIES = 8; +const API_FETCH_RETRY_DELAY_MS = 500; +const TAURI_HTTP_ERROR_PREFIX = 'CAPAREPORT_HTTP_ERROR:'; +let onUnauthorized: (() => void) | null = null; +let cachedTauriInvoke: TauriInvoke | null = null; + +type TauriInvoke = (command: string, args?: Record) => Promise; + +export interface DownloadResult { + saved: boolean; + path?: string; +} + +interface TauriDownloadHeader { + name: string; + value: string; +} + +interface TauriDownloadRequest { + url: string; + method: 'GET' | 'POST'; + filename: string; + headers: TauriDownloadHeader[]; + body?: string; +} + +interface TauriDownloadResult { + saved: boolean; + path?: string; +} + +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) || ''; +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); + document.cookie = `token=${token}; path=/; max-age=${60 * 60 * 24 * 30}`; +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY); + document.cookie = 'token=; path=/; max-age=0'; +} + +export function setUnauthorizedHandler(handler: () => void): void { + onUnauthorized = handler; +} + +export async function apiGet(url: string): Promise { + return request(url, { method: 'GET' }); +} + +export async function apiPost(url: string, body?: unknown): Promise { + return request(url, { + method: 'POST', + body: body === undefined ? undefined : JSON.stringify(body), + headers: { 'Content-Type': 'application/json' } + }); +} + +export async function request(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = isLoginRequest(url) ? '' : getToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + const response = await fetchWithRetry(apiUrl(url), { ...init, headers }); + if (response.status === 401) { + if (isLoginRequest(url)) { + const error = await readError(response); + throw new ApiRequestError(error.message, response.status, error.detail); + } + + clearToken(); + onUnauthorized?.(); + throw new ApiRequestError('登录已过期,请重新登录', response.status, { + code: 'UNAUTHORIZED', + message: '登录已过期,请重新登录' + }); + } + + if (!response.ok) { + const error = await readError(response); + throw new ApiRequestError(error.message, response.status, error.detail); + } + + return response.json() as Promise; +} + +export function upload( + url: string, + formData: FormData, + onProgress?: (percent: number) => void +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl(url)); + + const token = getToken(); + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + } + + xhr.upload.addEventListener('progress', event => { + if (event.lengthComputable) { + onProgress?.(Math.round((event.loaded / event.total) * 100)); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status === 401) { + clearToken(); + onUnauthorized?.(); + reject( + new ApiRequestError('登录已过期,请重新登录', xhr.status, { + code: 'UNAUTHORIZED', + message: '登录已过期,请重新登录' + }) + ); + return; + } + + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(parseXhrError(xhr.responseText))); + return; + } + + resolve(JSON.parse(xhr.responseText) as T); + }); + + xhr.addEventListener('error', () => reject(new Error('网络错误'))); + xhr.send(formData); + }); +} + +export async function download(url: string, body: unknown, filename: string): Promise { + const headers = new Headers({ 'Content-Type': 'application/json' }); + const token = getToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + if (isTauriRuntime()) { + return await downloadWithTauri({ + url: apiUrl(url), + method: 'POST', + filename, + headers: headersToPairs(headers), + body: JSON.stringify(body) + }); + } + + const response = await fetchWithRetry(apiUrl(url), { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const error = await readError(response); + throw new ApiRequestError(error.message, response.status, error.detail); + } + + return await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename); +} + +export async function downloadGet(url: string, fallbackFilename: string): Promise { + const headers = new Headers(); + const token = getToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + if (isTauriRuntime()) { + return await downloadWithTauri({ + url: apiUrl(url), + method: 'GET', + filename: fallbackFilename, + headers: headersToPairs(headers) + }); + } + + const response = await fetchWithRetry(apiUrl(url), { headers }); + if (response.status === 401) { + clearToken(); + onUnauthorized?.(); + throw new ApiRequestError('登录已过期,请重新登录', response.status, { + code: 'UNAUTHORIZED', + message: '登录已过期,请重新登录' + }); + } + + if (!response.ok) { + const error = await readError(response); + throw new ApiRequestError(error.message, response.status, error.detail); + } + + const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename; + return await saveBlobResponse(response, filename); +} + +async function readError(response: Response): Promise<{ message: string; detail?: ApiErrorDetail }> { + try { + const data = (await response.json()) as ApiError; + return parseApiError(data, response.statusText); + } catch { + return { message: response.statusText || '请求失败' }; + } +} + +function parseXhrError(text: string): string { + try { + const data = JSON.parse(text) as ApiError; + 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); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + link.click(); + URL.revokeObjectURL(objectUrl); + return { saved: true }; +} + +async function downloadWithTauri(request: TauriDownloadRequest): Promise { + try { + const invoke = await getTauriInvoke(); + const result = await invoke('download_to_file', { request }); + return { saved: result.saved, path: result.path }; + } catch (error) { + throw parseTauriDownloadError(error); + } +} + +export async function openPathInFileManager(path: string): Promise { + if (!isTauriRuntime()) return; + const invoke = await getTauriInvoke(); + await invoke('open_path_in_file_manager', { path }); +} + +async function getTauriInvoke(): Promise { + if (!cachedTauriInvoke) { + const api = await import('@tauri-apps/api/core'); + cachedTauriInvoke = api.invoke; + } + return cachedTauriInvoke; +} + +function headersToPairs(headers: Headers): TauriDownloadHeader[] { + const pairs: TauriDownloadHeader[] = []; + headers.forEach((value, name) => { + pairs.push({ name, value }); + }); + return pairs; +} + +function parseTauriDownloadError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error || '下载失败'); + if (!message.startsWith(TAURI_HTTP_ERROR_PREFIX)) { + return error instanceof Error ? error : new Error(message); + } + + const payload = message.slice(TAURI_HTTP_ERROR_PREFIX.length); + const separator = payload.indexOf(':'); + const status = Number(payload.slice(0, separator)); + const body = separator >= 0 ? payload.slice(separator + 1) : ''; + const parsed = parseApiErrorText(body, `下载失败 (${status || 'HTTP'})`); + + if (status === 401) { + clearToken(); + onUnauthorized?.(); + } + + return new ApiRequestError(parsed.message, status || 500, parsed.detail); +} + +function parseApiErrorText(text: string, fallback: string): { message: string; detail?: ApiErrorDetail } { + try { + const data = JSON.parse(text) as ApiError; + return parseApiError(data, fallback); + } catch { + return { message: text || fallback }; + } +} + +async function fetchWithRetry(url: string, init: RequestInit): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= API_FETCH_RETRIES; attempt += 1) { + try { + return await fetch(url, init); + } catch (error) { + lastError = error; + if (attempt === API_FETCH_RETRIES) { + break; + } + await sleep(API_FETCH_RETRY_DELAY_MS); + } + } + + throw lastError instanceof Error ? lastError : new Error('网络请求失败'); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => window.setTimeout(resolve, ms)); +} + +function parseFilename(disposition: string | null): string { + if (!disposition) return ''; + + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]); + } + + const plainMatch = disposition.match(/filename="?([^";]+)"?/i); + return plainMatch?.[1] || ''; +} + +export function apiUrl(url: string): string { + if (!API_BASE || /^https?:\/\//i.test(url)) { + return url; + } + return `${API_BASE}${url.startsWith('/') ? url : `/${url}`}`; +} + +export function getApiBaseUrl(): string { + return API_BASE || window.location.origin; +} + +function resolveApiBase(): string { + const configured = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); + if (configured) { + return configured; + } + return isTauriRuntime() ? DESKTOP_API_BASE : ''; +} + +function isTauriRuntime(): boolean { + const win = window as Window & { + __TAURI__?: unknown; + __TAURI_INTERNALS__?: unknown; + }; + return Boolean(win.__TAURI__ || win.__TAURI_INTERNALS__ || navigator.userAgent.includes('Tauri')); +} + +function isLoginRequest(url: string): boolean { + try { + const parsed = new URL(url, window.location.origin); + return parsed.pathname === '/api/login'; + } catch { + return url.replace(/^https?:\/\/[^/]+/i, '').split('?')[0] === '/api/login'; + } +} diff --git a/frontend/src/components/CapacityDashboard.vue b/frontend/src/components/CapacityDashboard.vue new file mode 100644 index 0000000..6ac7062 --- /dev/null +++ b/frontend/src/components/CapacityDashboard.vue @@ -0,0 +1,980 @@ + + + + + diff --git a/frontend/src/components/DatabasePanel.vue b/frontend/src/components/DatabasePanel.vue new file mode 100644 index 0000000..ea8a60d --- /dev/null +++ b/frontend/src/components/DatabasePanel.vue @@ -0,0 +1,1564 @@ + + + + + diff --git a/frontend/src/components/EChart.vue b/frontend/src/components/EChart.vue new file mode 100644 index 0000000..09e0d1f --- /dev/null +++ b/frontend/src/components/EChart.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/frontend/src/components/FileWorkflow.vue b/frontend/src/components/FileWorkflow.vue new file mode 100644 index 0000000..c39e1c2 --- /dev/null +++ b/frontend/src/components/FileWorkflow.vue @@ -0,0 +1,1006 @@ + + + diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue new file mode 100644 index 0000000..31c0d80 --- /dev/null +++ b/frontend/src/components/HistoryPanel.vue @@ -0,0 +1,470 @@ + + + diff --git a/frontend/src/components/LicenseActivationModal.vue b/frontend/src/components/LicenseActivationModal.vue new file mode 100644 index 0000000..aac6180 --- /dev/null +++ b/frontend/src/components/LicenseActivationModal.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/components/LoginView.vue b/frontend/src/components/LoginView.vue new file mode 100644 index 0000000..f890d48 --- /dev/null +++ b/frontend/src/components/LoginView.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/src/components/ScriptPanel.vue b/frontend/src/components/ScriptPanel.vue new file mode 100644 index 0000000..0864325 --- /dev/null +++ b/frontend/src/components/ScriptPanel.vue @@ -0,0 +1,403 @@ + + + diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue new file mode 100644 index 0000000..179d454 --- /dev/null +++ b/frontend/src/components/SettingsPanel.vue @@ -0,0 +1,2021 @@ + + + diff --git a/frontend/src/composables/clipboard.ts b/frontend/src/composables/clipboard.ts new file mode 100644 index 0000000..fe50643 --- /dev/null +++ b/frontend/src/composables/clipboard.ts @@ -0,0 +1,23 @@ +export async function writeClipboardText(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.top = '-1000px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + if (!document.execCommand('copy')) { + throw new Error('copy failed'); + } + } finally { + document.body.removeChild(textarea); + } +} diff --git a/frontend/src/composables/downloadFeedback.ts b/frontend/src/composables/downloadFeedback.ts new file mode 100644 index 0000000..e683b12 --- /dev/null +++ b/frontend/src/composables/downloadFeedback.ts @@ -0,0 +1,32 @@ +import { h, type VNodeChild } from 'vue'; +import type { useDialog } from 'naive-ui'; + +import { openPathInFileManager } from '../api/client'; + +type DownloadDialog = ReturnType; + +export function showDownloadCompleteDialog(dialog: DownloadDialog, filename?: string, path?: string): void { + const safeName = filename?.trim(); + const safePath = path?.trim(); + const content = safePath + ? (): VNodeChild => h('div', { class: 'download-complete-content' }, [ + h('p', safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。'), + h('button', { + class: 'download-path-link', + type: 'button', + title: safePath, + onClick: () => void openPathInFileManager(safePath) + }, safePath) + ]) + : safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。'; + + dialog.success({ + title: '下载完成', + content, + positiveText: safePath ? '打开所在文件夹' : '知道了', + negativeText: safePath ? '关闭' : undefined, + closable: true, + maskClosable: true, + onPositiveClick: safePath ? () => openPathInFileManager(safePath) : undefined + }); +} diff --git a/frontend/src/composables/logLines.ts b/frontend/src/composables/logLines.ts new file mode 100644 index 0000000..f46b8c4 --- /dev/null +++ b/frontend/src/composables/logLines.ts @@ -0,0 +1,28 @@ +export type LogLevel = 'default' | 'info' | 'success' | 'warning' | 'error'; + +export interface ColoredLogLine { + text: string; + level: LogLevel; +} + +export function toColoredLogLines(text: string, fallback = '暂无日志'): ColoredLogLine[] { + const lines = text ? text.split(/\r?\n/) : [fallback]; + return lines.map(line => ({ + text: line, + level: resolveLogLevel(line) + })); +} + +export function resolveLogLevel(line: string): LogLevel { + const marker = line.match(/\[(ERROR|WARN|WARNING|SUCCESS|INFO)\]/i)?.[1]?.toUpperCase(); + if (marker === 'ERROR') return 'error'; + if (marker === 'WARN' || marker === 'WARNING') return 'warning'; + if (marker === 'SUCCESS') return 'success'; + if (marker === 'INFO') return 'info'; + if (/\b(ERROR|FAILED|FAILURE)\b|错误|失败/i.test(line)) return 'error'; + if (/\b(WARN|WARNING)\b|警告/i.test(line)) return 'warning'; + if (/\b(SUCCESS|SUCCEEDED|COMPLETED)\b|成功|已完成|处理完成|下载完成|执行完成/i.test(line)) { + return 'success'; + } + return 'default'; +} diff --git a/frontend/src/composables/metrixEnabled.ts b/frontend/src/composables/metrixEnabled.ts new file mode 100644 index 0000000..9934892 --- /dev/null +++ b/frontend/src/composables/metrixEnabled.ts @@ -0,0 +1,3 @@ +import { ref } from 'vue'; + +export const metrixEnabled = ref(false); diff --git a/frontend/src/composables/pageHeader.ts b/frontend/src/composables/pageHeader.ts new file mode 100644 index 0000000..4006bda --- /dev/null +++ b/frontend/src/composables/pageHeader.ts @@ -0,0 +1,46 @@ +import { shallowReactive, unref, type Component, type Ref } from 'vue'; +import type { DropdownOption } from 'naive-ui'; + +type HeaderValue = T | Ref | (() => T); + +export interface PageHeaderAction { + key: string; + label: HeaderValue; + icon?: HeaderValue; + title?: HeaderValue; + kind?: 'button' | 'text'; + type?: 'default' | 'primary' | 'success' | 'warning' | 'error'; + variant?: 'solid' | 'outline' | 'text'; + loading?: HeaderValue; + disabled?: HeaderValue; + dropdownOptions?: HeaderValue; + onClick?: () => void | Promise; + onSelect?: (key: string | number, option: DropdownOption) => void | Promise; +} + +export const pageHeader = shallowReactive<{ + subtitle: HeaderValue | ''; + actions: PageHeaderAction[]; +}>({ + subtitle: '', + actions: [] +}); + +export function setPageHeader(config: { subtitle?: HeaderValue; actions?: PageHeaderAction[] }) { + pageHeader.subtitle = config.subtitle || ''; + pageHeader.actions = config.actions || []; +} + +export function resetPageHeader() { + pageHeader.subtitle = ''; + pageHeader.actions = []; +} + +export function resolveHeaderValue(value: HeaderValue | undefined): T | undefined; +export function resolveHeaderValue(value: HeaderValue | undefined, fallback: T): T; +export function resolveHeaderValue(value: HeaderValue | undefined, fallback?: T): T | undefined { + if (typeof value === 'function') { + return (value as () => T)(); + } + return value === undefined ? fallback : unref(value); +} diff --git a/frontend/src/composables/theme.ts b/frontend/src/composables/theme.ts new file mode 100644 index 0000000..6b8dfab --- /dev/null +++ b/frontend/src/composables/theme.ts @@ -0,0 +1,40 @@ +import { ref } from 'vue'; + +export type AppTheme = 'light' | 'dark'; + +const THEME_KEY = 'theme'; + +export const themeName = ref(readStoredTheme()); + +applyTheme(themeName.value); + +export function setAppTheme(nextTheme: AppTheme) { + themeName.value = nextTheme; + applyTheme(nextTheme); +} + +export function toggleAppTheme() { + setAppTheme(themeName.value === 'dark' ? 'light' : 'dark'); +} + +function readStoredTheme(): AppTheme { + try { + const stored = typeof localStorage === 'undefined' ? null : localStorage.getItem(THEME_KEY); + return stored === 'dark' ? 'dark' : 'light'; + } catch { + return 'light'; + } +} + +function applyTheme(nextTheme: AppTheme) { + if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-theme', nextTheme); + } + try { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(THEME_KEY, nextTheme); + } + } catch { + // Ignore storage failures so theme switching never blocks the UI. + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..486d283 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,24 @@ +import { createApp } from 'vue'; + +import App from './App.vue'; +import { router } from './router'; +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).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')); +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..0ba6146 --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,24 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; + +const FileWorkflow = () => import('./components/FileWorkflow.vue'); +const CapacityDashboard = () => import('./components/CapacityDashboard.vue'); +const HistoryPanel = () => import('./components/HistoryPanel.vue'); +const DatabasePanel = () => import('./components/DatabasePanel.vue'); +const SettingsPanel = () => import('./components/SettingsPanel.vue'); +const ScriptPanel = () => import('./components/ScriptPanel.vue'); + +export const routes: RouteRecordRaw[] = [ + { path: '/', redirect: { name: 'workflow' } }, + { path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据处理' } }, + { path: '/dashboard', name: 'dashboard', component: CapacityDashboard, meta: { title: '容量看板' } }, + { path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } }, + { path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } }, + { path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } }, + { path: '/settings', name: 'settings', component: SettingsPanel, meta: { title: '系统设置' } }, + { path: '/:pathMatch(.*)*', redirect: { name: 'workflow' } } +]; + +export const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes +}); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..d163dc9 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,2376 @@ +/* ==================== TDesign style variables from the legacy UI ==================== */ +:root { + --td-brand-color: #0052d9; + --td-brand-color-hover: #0034b5; + --td-brand-color-active: #002a9c; + --td-brand-color-light: #ecf2fe; + --td-brand-color-focus: rgba(0, 82, 217, 0.2); + --td-success-color: #00a870; + --td-success-color-light: #e8f8f2; + --td-warning-color: #ed7b2f; + --td-warning-color-light: #fef3e6; + --td-error-color: #e34d59; + --td-error-color-light: #fdecee; + --td-text-color-primary: rgba(0, 0, 0, 0.9); + --td-text-color-secondary: rgba(0, 0, 0, 0.6); + --td-text-color-placeholder: rgba(0, 0, 0, 0.4); + --td-text-color-disabled: rgba(0, 0, 0, 0.26); + --td-text-color-anti: #ffffff; + --td-bg-color-page: #f3f3f3; + --td-bg-color-container: #ffffff; + --td-bg-color-container-hover: #f3f3f3; + --td-bg-color-container-active: #e7e7e7; + --td-bg-color-secondarycontainer: #f3f3f3; + --td-bg-color-component: #f3f3f3; + --td-bg-color-component-hover: #e7e7e7; + --td-bg-color-component-disabled: #eeeeee; + --td-border-color: #dcdcdc; + --td-border-color-light: #e7e7e7; + --td-scrollbar-color: rgba(0, 0, 0, 0.2); + --td-mask-color: rgba(0, 0, 0, 0.6); + --td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.05), 0 4px 5px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.12); + --td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.05), 0 8px 10px 1px rgba(0, 0, 0, 0.06), 0 5px 5px -3px rgba(0, 0, 0, 0.1); + --td-shadow-3: 0 6px 30px 5px rgba(0, 0, 0, 0.05), 0 16px 24px 2px rgba(0, 0, 0, 0.04), 0 8px 10px -5px rgba(0, 0, 0, 0.08); + --td-radius-small: 3px; + --td-radius-default: 6px; + --td-radius-large: 12px; + --td-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif; + --td-font-family-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; + --td-transition: 0.2s cubic-bezier(0.38, 0, 0.24, 1); +} + +[data-theme="dark"] { + --td-brand-color: #4787f0; + --td-brand-color-hover: #618dff; + --td-brand-color-active: #366ef4; + --td-brand-color-light: #1b2f51; + --td-brand-color-focus: rgba(71, 135, 240, 0.35); + --td-success-color-light: #0d3926; + --td-warning-color-light: #4f2a1d; + --td-error-color-light: #4b191d; + --td-text-color-primary: rgba(255, 255, 255, 0.9); + --td-text-color-secondary: rgba(255, 255, 255, 0.55); + --td-text-color-placeholder: rgba(255, 255, 255, 0.35); + --td-text-color-disabled: rgba(255, 255, 255, 0.22); + --td-bg-color-page: #181818; + --td-bg-color-container: #242424; + --td-bg-color-container-hover: #383838; + --td-bg-color-container-active: #4c4c4c; + --td-bg-color-secondarycontainer: #2c2c2c; + --td-bg-color-component: #383838; + --td-bg-color-component-hover: #4c4c4c; + --td-bg-color-component-disabled: #383838; + --td-border-color: #5e5e5e; + --td-border-color-light: #4c4c4c; + --td-scrollbar-color: rgba(255, 255, 255, 0.28); + --td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.15), 0 4px 5px rgba(0, 0, 0, 0.22), 0 2px 4px -1px rgba(0, 0, 0, 0.32); + --td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.15), 0 8px 10px 1px rgba(0, 0, 0, 0.18), 0 5px 5px -3px rgba(0, 0, 0, 0.28); + --td-shadow-3: 0 6px 30px 5px rgba(0, 0, 0, 0.15), 0 16px 24px 2px rgba(0, 0, 0, 0.12), 0 8px 10px -5px rgba(0, 0, 0, 0.24); +} + +/* ==================== Base ==================== */ +* { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + height: 100%; + min-width: 0; + margin: 0; +} + +body { + overflow: hidden; + color: var(--td-text-color-primary); + background: var(--td-bg-color-page); + font-family: var(--td-font-family); + font-size: 14px; + line-height: 22px; + transition: background-color var(--td-transition), color var(--td-transition); +} + +button, +input, +textarea, +select { + font: inherit; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--td-border-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--td-text-color-placeholder); +} + +.hidden-input { + display: none; +} + +.full-width { + width: 100%; +} + +.page-subtitle { + overflow: hidden; + color: var(--td-text-color-placeholder); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.section-label { + margin-bottom: 8px; + color: var(--td-text-color-primary); + font-weight: 600; +} + +/* ==================== App shell ==================== */ +.app-layout { + height: 100vh; + background: var(--td-bg-color-page); +} + +.app-sider { + background: var(--td-bg-color-container); + border-right: 1px solid var(--td-border-color-light); + transition: background-color var(--td-transition), border-color var(--td-transition); +} + +.app-sider .n-layout-sider-scroll-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.brand { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 12px; + height: 64px; + padding: 0 24px; + border-bottom: 1px solid var(--td-border-color-light); +} + +.n-layout-sider--collapsed .brand { + justify-content: center; + padding: 0 16px; +} + +.brand-mark { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + color: #ffffff; + font-size: 18px; + background: linear-gradient(135deg, var(--td-brand-color), #618dff); + border-radius: var(--td-radius-default); + cursor: default; + user-select: none; +} + +.brand-text { + overflow: hidden; + color: var(--td-text-color-primary); + font-size: 16px; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +.n-layout-sider--collapsed .brand-text { + display: none; +} + +.app-menu { + flex: 1; + padding: 16px 8px; + overflow-x: hidden; + overflow-y: auto; +} + +.app-menu .n-menu-item-content { + position: relative; + height: 40px; + margin-bottom: 4px; + padding: 0 16px !important; + color: var(--td-text-color-secondary); + border-radius: var(--td-radius-default); + transition: all var(--td-transition); +} + +.app-menu .n-menu-item-content-header, +.app-menu .n-menu-item-content__icon { + color: inherit; +} + +.app-menu .n-menu-item-content:hover { + color: var(--td-brand-color); + background: var(--td-bg-color-container-hover); +} + +.app-menu .n-menu-item-content--selected { + color: var(--td-brand-color); + font-weight: 600; + background: var(--td-brand-color-light); + box-shadow: inset 0 0 0 1px var(--td-brand-color-focus); +} + +.app-menu .n-menu-item-content--selected::before { + content: ""; + position: absolute; + top: 8px; + bottom: 8px; + left: 6px; + width: 3px; + background: var(--td-brand-color); + border-radius: 3px; +} + +.app-menu .n-menu-item-content--selected .n-menu-item-content__icon { + color: var(--td-brand-color); +} + +.n-layout-sider--collapsed .app-menu { + padding: 16px 8px; +} + +.n-layout-sider--collapsed .app-menu .n-menu-item-content { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin: 0 auto 4px; + padding: 0 !important; +} + +.n-layout-sider--collapsed .app-menu .n-menu-item-content-header, +.n-layout-sider--collapsed .app-menu .n-menu-item-content__arrow { + display: none; +} + +.n-layout-sider--collapsed .app-menu .n-menu-item-content--selected::before { + display: none; +} + +.n-layout-sider--collapsed .app-menu .n-menu-item-content__icon { + display: flex; + align-items: center; + justify-content: center; + margin-right: 0 !important; +} + +.sider-footer { + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 4px; + align-items: center; + padding: 12px 16px; + color: var(--td-text-color-secondary); + font-family: var(--td-font-family-mono); + font-size: 12px; + text-align: center; + border-top: 1px solid var(--td-border-color-light); +} + +.n-layout-sider--collapsed .sider-footer { + padding: 10px 6px; +} + +.n-layout-sider--collapsed .sider-version-label, +.n-layout-sider--collapsed .sider-powered { + display: none; +} + +.topbar, +.page-header { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 16px; + height: 64px; + padding: 0 32px; + background: var(--td-bg-color-container); + border-bottom: 1px solid var(--td-border-color-light); +} + +.page-header-content { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + gap: 16px; +} + +.page-header h1 { + margin: 0; + color: var(--td-text-color-primary); + font-size: 20px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +.page-header-actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} + +.header-info-text { + color: var(--td-text-color-secondary); + font-family: var(--td-font-family-mono); + font-size: 12px; + white-space: nowrap; +} + +.header-dropdown-label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.header-dropdown-caret { + font-size: 13px; +} + +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + padding: 0; + color: var(--td-text-color-primary); + font-size: 14px; + white-space: nowrap; + background: transparent; + border: 1px solid var(--td-border-color); + border-radius: var(--td-radius-default); + cursor: pointer; + transition: all var(--td-transition); +} + +.theme-toggle:hover { + background: var(--td-bg-color-container-hover); + border-color: var(--td-text-color-secondary); +} + +.theme-toggle svg { + display: block; + width: 16px; + height: 16px; +} + +.logout-button { + flex-shrink: 0; +} + +.content { + height: calc(100vh - 64px); + min-width: 0; + overflow: auto; + background: var(--td-bg-color-page); +} + +/* ==================== Naive UI surface normalization ==================== */ +.n-card.work-card, +.work-card { + background: var(--td-bg-color-container); + border-color: var(--td-border-color-light); + border-radius: var(--td-radius-large); + transition: all var(--td-transition); +} + +.work-card > .n-card-header { + min-height: 54px; + padding: 14px 20px; + border-bottom: 1px solid var(--td-border-color-light); +} + +.work-card > .n-card-header .n-card-header__main, +.work-card > .n-card-header .n-card-header__extra { + color: var(--td-text-color-primary); +} + +.work-card > .n-card__content, +.work-card > .n-card-content { + padding: 16px 20px; + color: var(--td-text-color-primary); +} + +.work-card > .n-card__footer { + padding: 12px 20px; + background: var(--td-bg-color-container); + border-top: 1px solid var(--td-border-color-light); +} + +.work-card .n-form-item-label, +.work-card .n-form-item-label__text { + color: var(--td-text-color-secondary); +} + +.login-page { + display: grid; + min-height: 100vh; + place-items: center; + padding: 24px; + background: var(--td-bg-color-page); +} + +.login-card { + width: min(420px, 100%); + border-radius: var(--td-radius-large); + box-shadow: var(--td-shadow-2); +} + +.login-brand { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 24px; +} + +.login-brand h1 { + margin: 0; + font-size: 22px; +} + +.login-brand p { + margin: 3px 0 0; + color: var(--td-text-color-secondary); +} + +.brand-mark.large { + width: 44px; + height: 44px; +} + +/* ==================== Upload ==================== */ +.upload-page-body, +.history-page-body { + min-height: 100%; + padding: 24px 32px; +} + +.upload-page-body { + display: flex; + min-height: 0; + flex-direction: column; +} + +.upload-zone { + display: flex; + position: relative; + align-items: center; + justify-content: center; + min-height: 180px; + padding: 32px 24px; + flex-direction: column; + text-align: center; + background: var(--td-bg-color-container); + border: 1px dashed var(--td-border-color); + border-radius: var(--td-radius-large); + cursor: pointer; + transition: all var(--td-transition); +} + +.upload-card-label { + position: absolute; + top: 12px; + left: 16px; + color: var(--td-text-color-primary); + font-size: 13px; + font-weight: 600; +} + +.upload-help-button { + position: absolute; + top: 10px; + right: 10px; +} + +.upload-zone:hover, +.upload-zone.dragover { + background: var(--td-brand-color-light); + border-color: var(--td-brand-color); +} + +.upload-zone.dragover { + border-style: solid; +} + +.upload-zone.disabled { + cursor: not-allowed; + opacity: 0.72; +} + +.upload-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + margin: 0 auto 12px; + color: var(--td-brand-color); + font-size: 24px; + background: var(--td-brand-color-light); + border-radius: 50%; +} + +.upload-zone h3 { + margin: 0 0 8px; + color: var(--td-text-color-primary); + font-size: 16px; + font-weight: 500; +} + +.upload-zone p { + margin: 0 0 4px; + color: var(--td-text-color-secondary); + font-size: 14px; +} + +.upload-hint { + display: inline-flex; + align-items: center; + margin-top: 0 !important; + color: var(--td-brand-color) !important; + font-size: 13px !important; +} + +.upload-zone-actions { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + margin-top: 12px; +} + +.upload-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.remote-run-trigger { + display: inline-flex; +} + +.cell-data-upload-zone { + display: flex; + position: relative; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + padding: 24px 18px; + text-align: center; + background: var(--td-bg-color-container); + border: 1px dashed var(--td-border-color); + border-radius: var(--td-radius-large); + cursor: pointer; + transition: all var(--td-transition); +} + +.cell-data-upload-zone:hover, +.cell-data-upload-zone.dragover { + background: var(--td-brand-color-light); + border-color: var(--td-brand-color); +} + +.cell-data-upload-zone.disabled { + cursor: not-allowed; + opacity: 0.72; +} + +.cell-data-upload-text h3, +.cell-data-upload-text p { + margin: 0; +} + +.cell-data-upload-text h3 { + color: var(--td-text-color-primary); + font-size: 15px; + font-weight: 600; +} + +.cell-data-upload-text p { + margin-top: 4px; + color: var(--td-text-color-secondary); + font-size: 13px; +} + +.cell-data-help-scroll { + max-height: 360px; +} + +.cell-data-help-section { + display: grid; + gap: 6px; + padding-bottom: 14px; +} + +.cell-data-help-section + .cell-data-help-section { + padding-top: 12px; + border-top: 1px solid var(--td-border-color-light); +} + +.cell-data-help-section h4, +.cell-data-help-section p { + margin: 0; +} + +.cell-data-help-section code { + display: block; + overflow-x: auto; + padding: 6px 8px; + color: var(--td-text-color-primary); + background: var(--td-bg-color-secondarycontainer); + border-radius: var(--td-radius-small); +} + +.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; +} + +.file-list-header, +.file-list-footer, +.upload-stats, +.upload-stats-item, +.file-item, +.file-item-status, +.card-header, +.card-header-left, +.card-header-right, +.total-progress-header { + display: flex; + align-items: center; +} + +.file-list-header, +.file-list-footer, +.card-header, +.total-progress-header { + justify-content: space-between; +} + +.file-list-header { + margin-bottom: 12px; +} + +.file-list-header h4 { + margin: 0; + color: var(--td-text-color-primary); + font-size: 14px; + font-weight: 500; +} + +.file-list-footer { + margin-top: 16px; +} + +#fileCount { + color: var(--td-text-color-secondary); + font-size: 13px; +} + +.file-items { + max-height: 240px; + margin-top: 20px; + overflow-y: auto; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); +} + +.file-item { + position: relative; + min-height: 44px; + padding: 10px 16px; + flex-wrap: wrap; + color: var(--td-text-color-primary); + font-size: 13px; + border-bottom: 1px solid var(--td-border-color-light); + transition: background var(--td-transition); +} + +.file-item:last-child { + border-bottom: none; +} + +.file-item:hover { + background: var(--td-bg-color-container-hover); +} + +.file-item.uploading { + background: var(--td-brand-color-light); +} + +.file-item.uploaded { + background: var(--td-success-color-light); +} + +.file-item.error { + background: var(--td-error-color-light); +} + +.file-item-icon { + flex: 0 0 auto; + margin-right: 10px; + color: var(--td-brand-color); + font-size: 16px; +} + +.file-item-name { + min-width: 0; + flex: 1; + overflow: hidden; + font-family: var(--td-font-family-mono); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.file-item-size { + flex: 0 0 auto; + margin-left: 8px; + color: var(--td-text-color-placeholder); + font-family: var(--td-font-family-mono); + font-size: 12px; +} + +.file-item-status { + flex: 0 0 auto; + gap: 6px; + margin-left: 12px; + font-size: 12px; +} + +.file-item-status.pending { + color: var(--td-text-color-placeholder); +} + +.file-item-status.uploading { + color: var(--td-brand-color); + font-weight: 500; +} + +.file-item-status.uploaded { + color: var(--td-success-color); +} + +.file-item-status.error { + color: var(--td-error-color); +} + +.file-progress { + width: 100%; + height: 3px; + margin-top: 8px; + overflow: hidden; + background: var(--td-bg-color-component); + border-radius: 2px; +} + +.file-progress-bar { + height: 100%; + background: var(--td-brand-color); + border-radius: 2px; + transition: width 0.2s ease; +} + +.file-item.uploaded .file-progress-bar { + background: var(--td-success-color); +} + +.file-item.error .file-progress-bar { + background: var(--td-error-color); +} + +.total-progress { + margin: 0 0 16px; +} + +.total-progress-header { + margin-bottom: 8px; + font-size: 13px; +} + +.total-progress-header .label { + color: var(--td-text-color-secondary); +} + +.total-progress-header .percent { + color: var(--td-brand-color); + font-weight: 600; +} + +.total-progress-bar { + height: 6px; + overflow: hidden; + background: var(--td-bg-color-component); + border-radius: 3px; +} + +.total-progress-bar-inner { + height: 100%; + background: linear-gradient(90deg, var(--td-brand-color), #618dff); + border-radius: 3px; + transition: width 0.3s ease; +} + +.upload-stats { + gap: 16px; + margin-bottom: 12px; + padding: 12px 16px; + font-size: 13px; + background: var(--td-bg-color-secondarycontainer); + border-radius: var(--td-radius-default); +} + +.upload-stats-item { + gap: 6px; +} + +.upload-stats-item .count { + color: var(--td-text-color-primary); + font-weight: 600; +} + +.upload-stats-item.success .count { + color: var(--td-success-color); +} + +.upload-stats-item.error .count { + color: var(--td-error-color); +} + +.process-section { + min-height: 0; + margin-top: 12px; +} + +.upload-page-body > .process-section { + flex: 1 1 auto; +} + +.upload-process-card { + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-large); + transition: all var(--td-transition); +} + +.card-header { + min-height: 58px; + padding: 16px 20px; + color: var(--td-text-color-primary); + font-size: 14px; + font-weight: 600; + border-bottom: 1px solid var(--td-border-color-light); +} + +.card-header-left, +.card-header-right { + gap: 12px; +} + +.card-title { + color: var(--td-text-color-primary); + font-size: 14px; + font-weight: 600; +} + +.card-body { + padding: 20px; + min-height: 0; +} + +.process-status { + padding: 4px 12px; + color: #ffffff; + font-size: 12px; + font-weight: 500; + background: var(--td-brand-color); + border-radius: 10px; +} + +.process-status.processing { + animation: status-pulse 1.5s infinite; +} + +.process-status.completed { + background: var(--td-success-color); +} + +.process-status.failed { + background: var(--td-error-color); +} + +.task-alert { + margin-bottom: 12px; +} + +.log-container { + height: clamp(200px, calc(100vh - 400px), 520px); + min-height: 180px; + margin-top: 16px; + padding: 16px; + overflow: auto; + background: var(--td-bg-color-page); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); + scrollbar-color: var(--td-scrollbar-color) transparent; + scrollbar-width: thin; +} + +.process-section.finished .log-container { + height: clamp(180px, calc(100vh - 460px), 380px); + min-height: 160px; +} + +.upload-page-body > .process-section .upload-process-card { + display: flex; + height: 100%; + max-height: 100%; + min-height: 0; + flex-direction: column; +} + +.upload-page-body > .process-section .card-body { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.upload-page-body > .process-section .log-container { + flex: 1 1 auto; +} + +.log-content { + margin: 0; + color: var(--td-text-color-secondary); + font-family: var(--td-font-family-mono); + font-size: 12px; + line-height: 1.8; + white-space: pre-wrap; +} + +@keyframes status-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.6; + } +} + +/* ==================== History ==================== */ +.page-loading { + display: grid; + min-height: 220px; + place-items: center; +} + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 220px; + flex-direction: column; + gap: 10px; + color: var(--td-text-color-placeholder); +} + +.empty-icon { + display: flex; + align-items: center; + justify-content: center; + font-size: 42px; + line-height: 1; +} + +.empty-state p { + margin: 0; +} + +.history-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.history-item { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); + transition: all var(--td-transition); +} + +.history-item:hover { + border-color: var(--td-brand-color); + box-shadow: var(--td-shadow-1); +} + +.history-status { + width: 8px; + height: 8px; + flex: 0 0 auto; + border-radius: 50%; +} + +.history-status.completed { + background: var(--td-success-color); +} + +.history-status.failed { + background: var(--td-error-color); +} + +.history-status.processing { + background: var(--td-brand-color); + animation: status-pulse 1s infinite; +} + +.history-status.pending { + background: var(--td-text-color-placeholder); +} + +.history-info { + min-width: 0; + flex: 1; +} + +.history-id { + overflow: hidden; + margin-bottom: 4px; + color: var(--td-text-color-primary); + font-family: var(--td-font-family-mono); + font-size: 14px; + font-weight: 500; + white-space: nowrap; + text-overflow: ellipsis; +} + +.history-meta { + color: var(--td-text-color-placeholder); + font-size: 12px; +} + +.record-size { + color: var(--td-text-color-secondary); + font-family: var(--td-font-family-mono); + white-space: nowrap; +} + +.history-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +.history-detail-modal { + width: min(900px, calc(100vw - 48px)); +} + +.detail-info-list { + overflow: hidden; + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); +} + +.detail-info-row { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + min-height: 44px; + border-bottom: 1px solid var(--td-border-color-light); +} + +.detail-info-row:last-child { + border-bottom: 0; +} + +.detail-info-label, +.detail-info-value { + display: flex; + align-items: center; + min-width: 0; + padding: 10px 14px; +} + +.detail-info-label { + color: var(--td-text-color-secondary); + font-weight: 500; + background: var(--td-bg-color-secondarycontainer); + border-right: 1px solid var(--td-border-color-light); +} + +.detail-info-value { + color: var(--td-text-color-primary); + word-break: break-word; +} + +.detail-info-value.monospace, +.detail-info-value.path-value { + font-family: var(--td-font-family-mono); +} + +.detail-info-value.muted { + color: var(--td-text-color-placeholder); +} + +.detail-info-row.error-row .detail-info-value { + color: var(--td-error-color); +} + +.history-file-browser { + min-height: 280px; + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); + overflow: hidden; +} + +.history-file-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 46px; + padding: 8px 12px; + background: var(--td-bg-color-secondarycontainer); + border-bottom: 1px solid var(--td-border-color-light); +} + +.history-file-breadcrumb { + display: flex; + align-items: center; + min-width: 0; + gap: 4px; + overflow: hidden; +} + +.breadcrumb-link { + max-width: 180px; + padding: 2px 4px; + overflow: hidden; + color: var(--td-brand-color); + font: inherit; + text-overflow: ellipsis; + white-space: nowrap; + background: transparent; + border: 0; + cursor: pointer; +} + +.breadcrumb-link:hover { + color: var(--td-brand-color-hover); +} + +.breadcrumb-separator { + flex: 0 0 auto; + color: var(--td-text-color-placeholder); + font-size: 13px; +} + +.history-file-toolbar-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +.history-file-list { + max-height: 360px; + overflow: auto; +} + +.history-file-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 58px; + padding: 9px 12px; + border-bottom: 1px solid var(--td-border-color-light); +} + +.history-file-row:last-child { + border-bottom: 0; +} + +.history-file-main { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; +} + +.history-file-icon { + flex: 0 0 auto; + font-size: 20px; + color: var(--td-text-color-secondary); +} + +.history-file-icon.dir { + color: var(--td-warning-color); +} + +.history-file-text { + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; +} + +.history-file-name { + max-width: 100%; + padding: 0; + overflow: hidden; + color: var(--td-text-color-primary); + font: inherit; + font-weight: 500; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + background: transparent; + border: 0; +} + +.history-file-name:not(:disabled) { + cursor: pointer; +} + +.history-file-name:not(:disabled):hover { + color: var(--td-brand-color); +} + +.history-file-name:disabled { + cursor: default; +} + +.history-file-meta { + overflow: hidden; + color: var(--td-text-color-placeholder); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-file-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +.log-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.colored-log-panel { + height: 300px; + overflow: auto; + padding: 12px; + color: var(--td-text-color-primary); + background: var(--td-bg-color-page); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); + scrollbar-color: var(--td-scrollbar-color) transparent; + scrollbar-width: thin; +} + +.colored-log-panel::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.colored-log-panel::-webkit-scrollbar-track { + background: transparent; + border-radius: 5px; +} + +.colored-log-panel::-webkit-scrollbar-thumb { + background: var(--td-scrollbar-color); + border: 2px solid var(--td-bg-color-page); + border-radius: 5px; +} + +.colored-log-panel::-webkit-scrollbar-thumb:hover { + background: var(--td-text-color-placeholder); +} + +.colored-log-panel::-webkit-scrollbar-corner { + background: var(--td-bg-color-page); +} + +.colored-log-content { + display: block; + min-width: max-content; + margin: 0; + font-family: var(--td-font-family-mono); + font-size: 13px; + line-height: 1.7; + white-space: pre; +} + +.colored-log-line { + display: block; + color: var(--td-text-color-primary); + white-space: pre; +} + +.colored-log-line.log-level-info { + color: var(--td-text-color-primary); +} + +.colored-log-line.log-level-success { + color: var(--td-success-color); +} + +.colored-log-line.log-level-warning { + color: var(--td-warning-color); +} + +.colored-log-line.log-level-error { + color: var(--td-error-color); +} + +.script-log { + height: 100%; +} + +.download-complete-content { + display: flex; + min-width: 0; + flex-direction: column; + gap: 8px; +} + +.download-complete-content p { + margin: 0; +} + +.download-path-link { + overflow: hidden; + padding: 0; + color: var(--td-brand-color); + font: inherit; + font-family: var(--td-font-family-mono); + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + background: transparent; + border: 0; +} + +.download-path-link:hover { + text-decoration: underline; +} + +/* ==================== Settings ==================== */ +.settings-workspace { + height: 100%; + box-sizing: border-box; + padding: 10px 18px 12px; + overflow: hidden; +} + +.settings-tabs-card { + display: flex; + height: 100%; + min-width: 0; + flex-direction: column; +} + +.settings-tabs-card > .n-card__content, +.settings-tabs-card > .n-card-content { + flex: 1; + min-height: 0; + padding: 6px 18px 12px; + overflow: hidden; +} + +.settings-tabs-card > .n-card__content > .n-tabs, +.settings-tabs-card > .n-card-content > .n-tabs { + height: 100%; +} + +.settings-tabs { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; +} + +.settings-tabs .n-tab-pane { + height: 100%; + padding-top: 10px; + overflow: hidden; +} + +.settings-tabs .n-tabs-pane-wrapper { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.settings-database-panel, +.settings-remote-panel, +.settings-scheduler-panel, +.settings-rules-grid { + display: grid; + align-items: start; + gap: 18px; +} + +.settings-database-panel, +.settings-remote-panel, +.settings-scheduler-panel { + grid-template-columns: minmax(420px, 680px); + height: 100%; + min-height: 0; + padding-right: 6px; + overflow-x: hidden; + overflow-y: auto; + scrollbar-color: var(--td-scrollbar-color) transparent; + scrollbar-width: thin; +} + +.settings-database-panel { + grid-template-columns: 1fr; +} + +.settings-database-stack { + display: flex; + min-width: 0; + flex-flow: row wrap; + align-items: flex-start; + gap: 18px; +} + +.settings-database-stack > .work-card { + flex: 1 1 360px; + min-width: 320px; +} + +.settings-database-stack > .work-card-narrow { + flex: 0 1 360px; + min-width: 300px; + max-width: 460px; +} + +.settings-database-panel::-webkit-scrollbar, +.settings-remote-panel::-webkit-scrollbar, +.settings-scheduler-panel::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.settings-database-panel::-webkit-scrollbar-thumb, +.settings-remote-panel::-webkit-scrollbar-thumb, +.settings-scheduler-panel::-webkit-scrollbar-thumb { + background: var(--td-scrollbar-color); + border-radius: 8px; +} + +.settings-database-panel::-webkit-scrollbar-track, +.settings-remote-panel::-webkit-scrollbar-track, +.settings-scheduler-panel::-webkit-scrollbar-track { + background: transparent; +} + +.settings-remote-card, +.settings-scheduler-card { + min-width: 0; +} + +.settings-remote-panel { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); +} + +.settings-scheduler-panel { + grid-template-columns: minmax(420px, 760px); +} + +.remote-connection-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(120px, 100%), 1fr)); + gap: 12px; + align-items: start; +} + +.remote-passive-field .n-form-item-blank { + align-items: center; + min-height: 34px; +} + +.remote-toggle-row { + display: flex; + flex-wrap: wrap; + gap: 12px 56px; +} + +.remote-toggle-row .n-form-item { + margin-bottom: 0; +} + +.cell-data-path-editor, +.cell-data-path-list { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + gap: 10px; +} + +.cell-data-path-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--td-text-color-placeholder); + font-size: 12px; + line-height: 20px; +} + +.cell-data-path-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.cell-data-path-add { + width: 100%; +} + +.cell-data-path-row .n-input, +.cell-data-path-add .n-input { + min-width: 0; +} + +.cell-data-help-scroll { + max-height: 360px; +} + +.cell-data-help-section { + display: grid; + gap: 6px; + padding-bottom: 14px; +} + +.cell-data-help-section + .cell-data-help-section { + padding-top: 12px; + border-top: 1px solid var(--td-border-color-light); +} + +.cell-data-help-section h4, +.cell-data-help-section p { + margin: 0; +} + +.cell-data-help-section code { + display: block; + overflow-x: auto; + padding: 6px 8px; + color: var(--td-text-color-primary); + background: var(--td-bg-color-secondarycontainer); + border-radius: var(--td-radius-small); +} + +.cell-data-settings-body { + display: flex; + height: min(68vh, 680px); + min-height: 0; + flex-direction: column; + gap: 14px; + padding-right: 4px; + overflow: hidden; +} + +.cell-data-settings-section { + display: grid; + flex: 0 0 auto; + gap: 8px; + min-height: 0; +} + +.cell-data-settings-section:last-child { + flex: 1 1 auto; +} + +.cell-data-section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.cell-data-json-editor { + height: 360px; + min-height: 260px; + overflow: hidden; + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); +} + +.cell-data-mapping-form, +.cell-data-source-card { + display: grid; + gap: 10px; +} + +.cell-data-mapping-form { + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr); +} + +.cell-data-mapping-key { + display: grid; + grid-template-columns: minmax(140px, 0.8fr) minmax(120px, 0.6fr) minmax(240px, 1.4fr); + gap: 12px; +} + +.cell-data-source-card { + padding: 12px; + background: var(--td-bg-color-secondarycontainer); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); +} + +.cell-data-source-list { + display: grid; + gap: 10px; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + +.cell-data-mapping-actions { + display: flex; + justify-content: flex-end; +} + +.cell-data-source-header, +.cell-data-source-meta, +.cell-data-field-row { + display: grid; + align-items: center; + gap: 8px; +} + +.cell-data-source-header { + grid-template-columns: minmax(0, 1fr) auto; + cursor: pointer; +} + +.cell-data-source-title { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.cell-data-source-title span:last-child { + min-width: 0; + overflow: hidden; + color: var(--td-text-color-secondary); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.cell-data-source-toggle { + flex: 0 0 auto; + color: var(--td-text-color-placeholder); + font-size: 11px; +} + +.cell-data-source-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.cell-data-field-list { + display: grid; + gap: 6px; +} + +.cell-data-field-row { + grid-template-columns: minmax(120px, 0.9fr) 96px minmax(160px, 1fr) auto; +} + +.settings-rules-grid { + display: grid; + grid-template-columns: minmax(280px, 0.72fr) minmax(0, 1.4fr); + grid-template-rows: minmax(0, 1fr); + align-items: stretch; + height: 100%; + min-height: 0; + overflow: hidden; + scrollbar-color: var(--td-scrollbar-color) transparent; + scrollbar-width: thin; +} + +.settings-rules-grid::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.settings-rules-grid::-webkit-scrollbar-thumb { + background: var(--td-scrollbar-color); + border-radius: 8px; +} + +.settings-rules-grid::-webkit-scrollbar-track { + background: transparent; +} + +.settings-rules-grid > .work-card, +.settings-rules-side > .work-card { + min-width: 0; +} + +.settings-rules-side { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 18px; + padding-right: 6px; + overflow-x: hidden; + overflow-y: auto; +} + +.settings-security-panel { + max-width: 560px; +} + +.card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 4px; +} + +.filter-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 28px; +} + +.directory-mapping-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.directory-mapping-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 104px auto; + align-items: center; + gap: 8px; +} + +.directory-ready-select { + width: 112px; +} + +.form-hint { + margin: 0; + color: var(--td-text-color-placeholder); + font-size: 12px; + line-height: 20px; +} + +.scheduler-dir-editor { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + gap: 8px; +} + +.scheduler-dir-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; +} + +.scheduler-status-panel { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; + padding: 10px 12px; + background: var(--td-bg-color-secondarycontainer); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); +} + +.scheduler-status-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--td-text-color-primary); + font-size: 13px; + font-weight: 600; +} + +.scheduler-status-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.scheduler-status-grid > div { + min-width: 0; +} + +.scheduler-status-grid span { + display: block; + margin-bottom: 2px; + color: var(--td-text-color-placeholder); + font-size: 11px; +} + +.scheduler-status-grid strong { + display: block; + overflow: hidden; + color: var(--td-text-color-primary); + font-family: var(--td-font-family-mono); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + text-overflow: ellipsis; +} + +.scheduler-directory-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + max-height: 96px; + overflow-y: auto; +} + +.scheduler-directory-row { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + gap: 8px; + padding: 5px 8px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-small); +} + +.scheduler-directory-name { + min-width: 0; + overflow: hidden; + color: var(--td-text-color-primary); + font-family: var(--td-font-family-mono); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.field-mapping-card { + display: flex !important; + height: 100%; + min-height: 0; + min-width: 0; + flex-direction: column; + overflow: hidden; +} + +.field-mapping-card > .n-card-header { + display: flex; + flex: 0 0 auto; + align-items: flex-start; + padding: 14px 20px; +} + +.field-mapping-card > .n-card-header .n-card-header__main { + min-width: 0; + width: 100%; +} + +.field-mapping-card > .n-card__content, +.field-mapping-card > .n-card-content { + display: flex; + flex: 1 1 0; + flex-direction: column; + height: 0; + min-height: 0; + max-height: none; + padding: 12px 16px; + overflow: hidden; +} + +.field-mapping-card > .n-card__footer { + flex: 0 0 auto; + padding: 12px 20px; + background: var(--td-bg-color-container); + border-top: 1px solid var(--td-border-color-light); +} + +.field-card-header { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + gap: 10px; +} + +.field-card-title-row { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + gap: 16px; +} + +.field-card-title { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--td-text-color-primary); + font-weight: 600; + white-space: nowrap; +} + +.field-card-title > span, +.field-card-title .n-tag { + flex: 0 0 auto; + white-space: nowrap; +} + +.field-search { + flex: 1 1 240px; + width: auto; + min-width: 180px; + max-width: 360px; +} + +.field-mapping-scroll { + display: flex; + flex: 1 1 auto; + height: auto; + max-height: none; + min-height: 0; + flex-direction: column; + gap: 12px; + padding: 0 4px 4px 0; + overflow-x: hidden; + overflow-y: scroll; + scrollbar-color: var(--td-scrollbar-color) transparent; + scrollbar-width: thin; +} + +.field-mapping-scroll::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.field-mapping-scroll::-webkit-scrollbar-thumb { + background: var(--td-scrollbar-color); + border-radius: 8px; +} + +.field-mapping-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.field-mapping-item { + position: relative; + flex: 0 0 auto; + padding: 32px 14px 12px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color); + border-radius: var(--td-radius-default); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + transition: box-shadow var(--td-transition); +} + +.field-mapping-item:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); +} + +.field-mapping-number { + display: flex; + position: absolute; + top: 4px; + left: 4px; + z-index: 1; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: #ffffff; + font-size: 11px; + font-weight: 600; + background: var(--td-brand-color); + border-radius: var(--td-radius-small); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.field-remove-button { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; +} + +.field-mapping-header { + display: flex; + align-items: flex-end; + gap: 12px; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--td-border-color-light); +} + +.field-name-control { + flex: 1; + min-width: 0; + margin-bottom: 0; + padding-left: 28px; +} + +.field-type-control { + flex: 0 0 160px; + margin-bottom: 0; +} + +.field-name-control .n-form-item-label, +.field-type-control .n-form-item-label { + min-height: 20px; + color: var(--td-text-color-secondary); + font-size: 12px; + font-weight: 500; +} + +.extract-list { + display: flex; + flex-direction: column; +} + +.extract-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.extract-list-header > span { + flex: 0 0 auto; + color: var(--td-text-color-primary); + font-size: 12px; + font-weight: 500; +} + +.extract-add-group { + flex: 1; + justify-content: flex-end; + min-width: 250px; +} + +.extract-tree { + max-height: 120px; + margin-bottom: 8px; + overflow-y: auto; + background: var(--td-bg-color-secondarycontainer); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-small); +} + +.extract-tree-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 32px; + padding: 6px 10px; + color: var(--td-text-color-primary); + font-size: 13px; + border-bottom: 1px solid var(--td-border-color-light); + transition: background var(--td-transition); +} + +.extract-tree-item:last-child { + border-bottom: none; +} + +.extract-tree-item:hover { + background: var(--td-bg-color-container-hover); +} + +.tree-text { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.tree-text::before { + content: "└"; + flex: 0 0 auto; + color: var(--td-text-color-placeholder); + font-size: 12px; +} + +.extract-empty { + margin-bottom: 8px; + padding: 12px; + color: var(--td-text-color-placeholder); + font-size: 12px; + text-align: center; + border: 1px dashed var(--td-border-color); + border-radius: var(--td-radius-small); +} + +/* ==================== Script editor ==================== */ +.script-page-body { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 !important; + overflow: hidden; +} + +.script-editor-container { + flex: 1 1 auto; + min-height: 0; + margin: 16px 16px 0; + overflow: hidden; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color); + border-radius: var(--td-radius-default); +} + +.script-editor { + width: 100%; + height: 100%; +} + +.script-status-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 16px; + margin: 8px 16px 16px; + padding: 6px 16px; + color: var(--td-text-color-secondary); + font-size: 12px; + background: var(--td-bg-color-secondarycontainer); + border-radius: var(--td-radius-default); +} + +.script-status-bar span:first-child, +.script-status-bar span:last-child { + font-family: var(--td-font-family-mono); +} + +.script-status-bar span:nth-child(2) { + font-weight: 500; +} + +.script-process-section { + flex: 0 0 auto; + margin: 8px 16px 0; +} + +.script-process-section .script-log { + max-height: clamp(140px, 22vh, 240px); + overflow: auto; +} + +/* ==================== Responsive ==================== */ +@media (max-width: 1200px) { + .settings-database-panel, + .settings-remote-panel, + .settings-scheduler-panel, + .settings-rules-grid { + grid-template-columns: 1fr; + } + + .settings-rules-grid { + grid-template-rows: auto minmax(520px, 1fr); + align-items: start; + overflow-x: hidden; + overflow-y: auto; + } + + .settings-tabs .n-tab-pane { + overflow: hidden; + } + + .field-mapping-card { + grid-row: auto; + grid-column: auto; + height: min(760px, calc(100vh - 260px)); + min-height: 520px; + } +} + +@media (max-width: 900px) { + .topbar, + .page-header { + padding: 0 16px; + } + + .page-header-content { + gap: 8px; + } + + .page-header h1 { + font-size: 18px; + } + + .page-subtitle, + .header-info-text { + display: none; + } + + .upload-page-body, + .history-page-body, + .settings-workspace { + padding: 16px; + } + + .upload-card-grid { + grid-template-columns: 1fr; + } + + .history-item { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + + .history-actions { + width: 100%; + justify-content: flex-end; + } + + .history-file-toolbar, + .history-file-row { + align-items: stretch; + flex-direction: column; + } + + .history-file-toolbar-actions, + .history-file-actions { + justify-content: flex-end; + } + + .card-actions, + .field-card-title-row, + .extract-list-header { + align-items: stretch; + flex-direction: column; + } + + .field-mapping-header { + align-items: stretch; + flex-direction: column; + } + + .field-name-control { + padding-left: 0; + } + + .field-type-control, + .field-search, + .extract-add-group { + width: 100%; + flex: none; + } + + .scheduler-status-grid, + .scheduler-directory-list { + grid-template-columns: 1fr; + } +} + +@media (max-width: 700px) { + .remote-connection-row { + grid-template-columns: 1fr; + } + + .remote-toggle-row { + gap: 12px 24px; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..0879af7 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,271 @@ +export interface LoginResponse { + success: boolean; + token: string; +} + +export interface ApiError { + 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 CellDataResult { + selected_files: number; + parsed_rows: number; + imported_rows: number; + skipped_rows: number; +} + +export interface TaskStatus { + task_id: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | string; + stage?: string | null; + logs: string[]; + elapsed_time?: number; + error?: string; + error_detail?: ApiErrorDetail; + result?: CellDataResult; +} + +export interface ActiveTask { + has_active: boolean; + task_id?: string; + stage?: string; + started_at?: string; + logs?: string[]; +} + +export interface HistoryRecord { + id: string; + timestamp: string; + status: string; + work_dir: string; + file_count: number; + elapsed_time: number; + error?: string | null; + result_tables: string[]; +} + +export interface HistoryDetail extends HistoryRecord { + logs: string[]; +} + +export interface HistoryFileEntry { + name: string; + path: string; + type: 'dir' | 'file'; + size: number; + size_formatted: string; + modified: string; + modified_formatted: string; +} + +export interface HistoryFilesResponse { + success: boolean; + record_id: string; + path: string; + parent_path: string | null; + entries: HistoryFileEntry[]; +} + +export interface CacheSize { + success: boolean; + size_bytes: number; + size_formatted: string; + file_count: number; + dir_count: number; +} + +export interface AppConfig { + update: string; + metrix_enabled: boolean; + source_type: 'external' | 'metrix'; + warehouse_type: 'mysql' | 'metrix'; + mysql: { + host: string; + port: number; + user: string; + passwd?: string; + dbname: string; + }; + metrix: MetrixConfig; + data_mappings: DataMappingsConfig; + remote_data: RemoteDataConfig; + cell_data: CellDataConfig; + history_retention: HistoryRetentionConfig; + sheet_filter: string[]; + extract_fields: Array>; +} + +export interface MetrixConfig { + base_url: string; + token?: string; + storage_id: string; + database_conn_id: string; + target_database: string; + recent_days: number; +} + +export interface DataDirectoryMapping { + path: string; + table: string; + ready_rule: 'daily' | 'auto'; +} + +export interface DataMappingsConfig { + directories: DataDirectoryMapping[]; + table_field_mappings: Record>>; +} + +export interface RemoteDataConfig { + enabled: boolean; + protocol: 'ftp' | 'sftp'; + host: string; + port: number; + user: string; + passwd?: string; + remote_dir: string; + passive: boolean; + timeout: number; + auto_delete_source: boolean; + auto_scheduler: RemoteAutoSchedulerConfig; +} + +export interface CellDataConfig { + remote_data: RemoteDataConfig; + mysql: { + host: string; + port: number; + user: string; + passwd?: string; + dbname: string; + }; + scan_paths: string[]; + year_dir_regex: string; + month_dir_regex: string; + day_dir_regex: string; + file_name_regex: string; + file_time_regex: string; + mapping: Record; +} + +export interface RemoteAutoSchedulerConfig { + enabled: boolean; + check_interval_hours: number; + expected_directories: string[]; + week_offset: number; +} + +export interface RemoteSchedulerStatus { + enabled: boolean; + running: boolean; + check_interval_hours?: number; + expected_directories?: string[]; + week_offset?: number; + auto_delete_source?: boolean; + next_check_at?: string | null; + last_check_at?: string | null; + last_result?: string; + last_message?: string; + failure_count?: number; + task_running?: boolean; + task_id?: string | null; + ready_flag?: { + exists: boolean; + ready_at?: string; + week_start?: string; + week_end?: string; + [key: string]: unknown; + }; + target_week?: { + start: string; + end: string; + days?: string[]; + }; + directory_status?: Record< + string, + { + ready: boolean; + granularity?: 'daily' | 'weekly' | string | null; + found_days: string[]; + missing_days: string[]; + found_count: number; + required_count: number; + file_count: number; + error?: string | null; + skipped?: boolean; + skip_reason?: string | null; + } + >; + message?: string; +} + +export interface HistoryRetentionConfig { + enabled: boolean; + keep_count: number; +} + +export interface TableInfo { + name: string; + columns: Array<{ + Field?: string; + Type?: string; + Null?: string; + Key?: string; + Default?: unknown; + Extra?: string; + [key: string]: unknown; + }>; + row_count: number; +} + +export interface TableData { + data: Array>; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface ScriptContent { + success: boolean; + content: string; + modified: string | null; + path: string; + error?: string; +} + +export interface DatabaseInfo { + success: boolean; + version?: string; + load_data_infile?: boolean; + load_data_message?: string; + error?: string; +} + +export interface ApiMessage { + success: boolean; + message?: string; + error?: string; + update?: string; + task_id?: string; +} + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..55343b7 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare module 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js'; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..723f3ac --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} + diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..a2ae16d --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8dd5344 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import AutoImport from 'unplugin-auto-import/vite'; +import Components from 'unplugin-vue-components/vite'; +import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'; + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + imports: ['vue', 'vue-router'], + resolvers: [NaiveUiResolver()], + dts: false + }), + Components({ + resolvers: [NaiveUiResolver()], + dts: false + }) + ], + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:9081', + '/health': 'http://localhost:9081' + } + } +}); diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..b577a9b --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,124 @@ +# Docker 编排:一键构建并运行整套系统(应用 + MySQL + sftpgo) + +`docker-compose.yml` 把整套系统编排为三个互相连通的服务,开箱即用。 + +| 服务 | 容器名 | 端口(宿主:容器) | 说明 | +| --- | --- | --- | --- | +| capacity-app | capacity-report-app | 9081:9081 | 应用(前端 + 后端),数据落在 `/data` 卷 | +| capacity-mysql | capacity-mysql | 13306:3306 | MySQL 8,自动建 `CapacityReport` 与 `celldata` 两库 | +| sftpgo | capacity-sftpgo | 2022:2022(SFTP)、18080:8080(Web) | 文件服务,供应用拉取数据 | + +> 应用镜像 `capacity-report-app:latest` 由本仓库根的 `Dockerfile` 构建;MySQL、sftpgo 为公共镜像。 + +--- + +## 前置要求 + +- 已安装 Docker(含 Docker Compose v2,命令为 `docker compose`)。 +- 构建应用镜像需要先产出前端 `frontend/dist`(下方步骤会做)。 +- 联网构建需要能拉取 `python:3.13.11-slim`、`mysql:8.0.44`、`drakkan/sftpgo:v2.7.1`; + 内网离线见「方式 B」。 + +--- + +## 方式 A:联网机器,一键构建并启动 + +```bash +# 1) 构建前端(产出 frontend/dist,镜像会拷贝它) +cd ../frontend +npm ci && npm run build +cd ../packaging + +# 2) 构建应用镜像并启动整套系统 +docker compose build +docker compose up -d + +# 3) 查看状态 / 日志 +docker compose ps +docker compose logs -f capacity-app +``` + +启动后访问 `http://localhost:9081`(默认登录 `root` / `Capacity`)。 + +> 也可以用脚本一步构建并导出离线包:在仓库根执行 `python scripts/build_docker.py`, +> 产物在 `dist/docker/`(含 `capacity-report-app-latest.tar` + `docker-compose.yml` + `mysql/` + `sftpgo/` + 默认脚本)。 + +--- + +## 方式 B:内网离线部署(无外网) + +在有网机器准备好以下文件,拷贝到内网机器: + +1. **基础/公共镜像**(python、mysql、sftpgo)——可用离线镜像目录方案: + - 有网机器:`docker pull` 三个镜像后 `docker save -o xxx.tar`,连同导入脚本放一个目录。 + - 内网机器:在该目录执行 `python load_images.py` 逐个 `docker load -i ./xxx.tar`。 + - (示例:本项目已在 `F:\CR\env\images` 准备了 `mysql_8.0.44.tar`、`sftpgo_v2.7.1.tar`、`python_3.13.11-slim.tar` 与 `load_images.py`。) +2. **应用镜像**:在有网机器 `docker compose build` 后 + `docker save capacity-report-app:latest -o capacity-report-app.tar`,拷到内网 `docker load -i ./capacity-report-app.tar`。 + - 或直接用 `python scripts/build_docker.py` 生成 `dist/docker/` 整包带走。 +3. **编排文件**:本目录的 `docker-compose.yml` + `mysql/` + `sftpgo/`。 + +内网机器导入镜像后,在编排文件目录执行: + +```bash +docker compose up -d # 镜像已在本地,不会联网拉取 +``` + +> 若内网机器**不便构建**应用镜像,务必走「先在有网机器 build 出 `capacity-report-app:latest` 再 save」, +> 这样内网仅需 `docker load` + `docker compose up -d`,无需 python 基础镜像与构建。 + +--- + +## 默认账号 / 连接 + +| 项 | 值 | +| --- | --- | +| 应用登录 | `root` / `Capacity` | +| MySQL | `root` / `gmcc123`,库 `CapacityReport`、`celldata`(容器内主机名 `capacity-mysql`) | +| SFTP 用户 | `capacity` / `capacity123`(home `/srv/sftpgo/data/capacity`) | +| sftpgo Web | `admin` / `gmcc123`(http://localhost:18080) | + +应用**首次启动**时,`entrypoint` 会按 `docker-compose.yml` 里的环境变量把 `Configure.json` 的 +「数据库 / SFTP 连接」自动改写为容器服务(主机名用 `capacity-mysql` / `sftpgo`); +之后你在界面里的修改不会被覆盖。要改默认密码等,编辑 compose 的环境变量与 `sftpgo/sftpgo-init.json`。 + +--- + +## 上传数据 + +通过 SFTP(`localhost:2022`,`capacity`/`capacity123`)或 sftpgo Web(`:18080`)上传到 `capacity` 用户目录: + +- 容量数据 → `/CapacityReportData`(对应 `RemoteData.remote_dir`,结构 `4G/ 5G/ RJ/`)。 +- CellData → `/网优日常优化数据文档/...`(对应 `CellData.scan_paths`)。 + +> 用仓库 `test/make_test_data.py` 生成的小样本可直接传上来做快速测试。 +> 上传后在「数据处理」页执行处理;首次会自动建表(`db_init/` 前置检查)。 + +--- + +## 数据卷(持久化) + +| 卷 | 内容 | +| --- | --- | +| `capacity-data` | 应用 `/data`:Configure.json、ReportScript.sql、CellData.sql、db_init、历史、缓存 | +| `capacity-mysql-data` | MySQL 数据 | +| `sftpgo-config` / `sftpgo-data` | sftpgo 状态与上传的文件 | + +--- + +## 常用运维 + +```bash +docker compose ps # 状态 +docker compose logs -f capacity-app # 应用日志 +docker compose restart capacity-app # 重启应用 +docker compose down # 停止(保留数据卷) +docker compose down -v # 停止并删除数据卷(彻底重置,慎用) + +# 改了代码 / 前端后更新应用: +cd ../frontend && npm run build && cd ../packaging +docker compose build capacity-app && docker compose up -d capacity-app +``` + +> 重置应用侧连接:`Configure.json` 仅在首次播种时按环境变量初始化;如需重新按 env 生成, +> 删除 `capacity-data` 卷(`docker volume rm packaging_capacity-data`)后重新 `up -d`。 diff --git a/packaging/capareport-server.spec b/packaging/capareport-server.spec new file mode 100644 index 0000000..791c736 --- /dev/null +++ b/packaging/capareport-server.spec @@ -0,0 +1,99 @@ +# -*- mode: python ; coding: utf-8 -*- + +import os +from pathlib import Path + + +project_root = Path.cwd() +onefile = os.environ.get("CAPAREPORT_ONEFILE") == "1" + +hiddenimports = [ + "uvicorn.logging", + "uvicorn.loops.auto", + "uvicorn.loops.asyncio", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.http.h11_impl", + "uvicorn.protocols.websockets.auto", + "uvicorn.protocols.websockets.websockets_impl", + "uvicorn.lifespan.on", + "pymysql", + "paramiko", + "openpyxl", + "chardet", +] + +excludes = [ + "pytest", + "pandas.tests", + "numpy.tests", + "openpyxl.tests", + "matplotlib", + "IPython", + "notebook", + "tkinter", +] + +a = Analysis( + [str(project_root / "app" / "main.py")], + pathex=[str(project_root)], + binaries=[], + datas=[], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=excludes, + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +if onefile: + exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + exclude_binaries=False, + name="capareport-server", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + ) +else: + exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="capareport-server", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + ) + + coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="capareport-server", + ) diff --git a/packaging/docker-compose.capacityrepost.yml b/packaging/docker-compose.capacityrepost.yml new file mode 100644 index 0000000..5d2b457 --- /dev/null +++ b/packaging/docker-compose.capacityrepost.yml @@ -0,0 +1,67 @@ +services: + web: + image: capacityrepost-web:baaea9a + container_name: capacityrepost-web + restart: unless-stopped + ports: + - "19082:9081" + volumes: + - capacityrepost-web-data:/data + environment: + TZ: Asia/Shanghai + PYTHONUNBUFFERED: "1" + DB_HOST: capacityrepost-mysql + DB_PORT: "3306" + DB_USER: root + DB_PASSWD: gmcc123 + MAIN_DB_NAME: CapacityReport + CELLDATA_DB_NAME: celldata + depends_on: + mysql: + condition: service_healthy + networks: + - capacityrepost-network + + mysql: + image: capacity-mysql:8.0.44 + container_name: capacityrepost-mysql + restart: unless-stopped + expose: + - "3306" + volumes: + - capacityrepost-mysql-data:/var/lib/mysql + - ./mysql/conf.d:/etc/mysql/conf.d:ro + - ./mysql/init:/docker-entrypoint-initdb.d:ro + environment: + TZ: Asia/Shanghai + MYSQL_ROOT_PASSWORD: gmcc123 + MYSQL_DATABASE: CapacityReport + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --local-infile=ON + - --secure-file-priv= + - --max_allowed_packet=256M + - --innodb_buffer_pool_size=512M + - --innodb_log_file_size=128M + - --innodb_flush_log_at_trx_commit=2 + - --sync_binlog=0 + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pgmcc123"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - capacityrepost-network + +networks: + capacityrepost-network: + name: capacityrepost-network + driver: bridge + +volumes: + capacityrepost-web-data: + name: capacityrepost-web-data + capacityrepost-mysql-data: + name: capacityrepost-mysql-data diff --git a/packaging/docker-compose.yml b/packaging/docker-compose.yml new file mode 100644 index 0000000..6f57d74 --- /dev/null +++ b/packaging/docker-compose.yml @@ -0,0 +1,105 @@ +# 一键编排:CapacityReport 应用 + MySQL + sftpgo +# 构建前先在仓库根构建前端:cd frontend && npm ci && npm run build(产出 frontend/dist) +# 然后在本目录:docker compose build && docker compose up -d +services: + capacity-app: + build: + context: .. + dockerfile: Dockerfile + image: capacity-report-app:latest + container_name: capacity-report-app + restart: unless-stopped + ports: + - "9081:9081" + volumes: + - capacity-data:/data + environment: + - TZ=Asia/Shanghai + - PYTHONUNBUFFERED=1 + # 首次启动按以下变量初始化 Configure.json 的连接(用户后续在界面改动不被覆盖) + - DB_HOST=capacity-mysql + - DB_PORT=3306 + - DB_USER=root + - DB_PASSWD=gmcc123 + - MAIN_DB_NAME=CapacityReport + - CELLDATA_DB_NAME=celldata + - SFTP_HOST=sftpgo + - SFTP_PORT=2022 + - SFTP_USER=capacity + - SFTP_PASSWD=capacity123 + depends_on: + capacity-mysql: + condition: service_healthy + sftpgo: + condition: service_started + networks: + - capacity-network + + capacity-mysql: + image: mysql:8.0.44 + container_name: capacity-mysql + restart: unless-stopped + ports: + - "13306:3306" + volumes: + - capacity-mysql-data:/var/lib/mysql + - ./mysql/conf.d:/etc/mysql/conf.d:ro + - ./mysql/init:/docker-entrypoint-initdb.d:ro + environment: + - TZ=Asia/Shanghai + - MYSQL_ROOT_PASSWORD=gmcc123 + - MYSQL_DATABASE=CapacityReport + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --local-infile=ON + - --secure-file-priv= + - --max_allowed_packet=256M + - --innodb_buffer_pool_size=512M + - --innodb_log_file_size=128M + - --innodb_flush_log_at_trx_commit=2 + - --sync_binlog=0 + networks: + - capacity-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pgmcc123"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + sftpgo: + image: drakkan/sftpgo:v2.7.1 + container_name: capacity-sftpgo + restart: unless-stopped + ports: + - "2022:2022" # SFTP(上传容量/CellData 数据) + - "18080:8080" # Web 管理后台 + environment: + - TZ=Asia/Shanghai + # Web 后台管理员 + - SFTPGO_DEFAULT_ADMIN_USERNAME=admin + - SFTPGO_DEFAULT_ADMIN_PASSWORD=gmcc123 + # 启动时导入 SFTP 用户(capacity/capacity123);MODE=1 保留文件、每次启动确保用户存在 + - SFTPGO_LOADDATA_FROM=/sftpgo-init.json + - SFTPGO_LOADDATA_MODE=1 + volumes: + - sftpgo-config:/var/lib/sftpgo + - sftpgo-data:/srv/sftpgo/data + - ./sftpgo/sftpgo-init.json:/sftpgo-init.json:ro + networks: + - capacity-network + +networks: + capacity-network: + driver: bridge + +volumes: + capacity-mysql-data: + driver: local + capacity-data: + driver: local + sftpgo-config: + driver: local + sftpgo-data: + driver: local diff --git a/packaging/mysql/conf.d/custom.cnf b/packaging/mysql/conf.d/custom.cnf new file mode 100644 index 0000000..0113937 --- /dev/null +++ b/packaging/mysql/conf.d/custom.cnf @@ -0,0 +1,29 @@ +# MySQL settings for CapacityReport + +[mysqld] +# Charset +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# LOAD DATA LOCAL INFILE +local_infile = ON +secure_file_priv = "" + +# Performance +max_allowed_packet = 256M +innodb_buffer_pool_size = 512M +innodb_log_file_size = 128M +innodb_flush_log_at_trx_commit = 2 +sync_binlog = 0 + +# Connections +max_connections = 200 +wait_timeout = 28800 +interactive_timeout = 28800 + +[mysql] +local_infile = ON +default-character-set = utf8mb4 + +[client] +default-character-set = utf8mb4 diff --git a/packaging/mysql/init/01-init-db.sql b/packaging/mysql/init/01-init-db.sql new file mode 100644 index 0000000..9851900 --- /dev/null +++ b/packaging/mysql/init/01-init-db.sql @@ -0,0 +1,14 @@ +-- MySQL initialization for CapacityReport. +-- This file runs only when the MySQL data directory is empty. +-- 建两个库:CapacityReport(主仓库/容量结果)与 celldata(CellData 小区/扇区)。 +-- 仅建库;具体表由应用前置检查(app/db_init.py + db_init/)按需创建。 + +CREATE DATABASE IF NOT EXISTS CapacityReport + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +CREATE DATABASE IF NOT EXISTS celldata + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +SELECT 'CapacityReport & celldata databases initialized with utf8mb4 charset' AS status; diff --git a/packaging/sftpgo/sftpgo-init.json b/packaging/sftpgo/sftpgo-init.json new file mode 100644 index 0000000..6cf37ee --- /dev/null +++ b/packaging/sftpgo/sftpgo-init.json @@ -0,0 +1,13 @@ +{ + "users": [ + { + "username": "capacity", + "password": "capacity123", + "status": 1, + "home_dir": "/srv/sftpgo/data/capacity", + "permissions": { + "/": ["*"] + } + } + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce718f0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# Web 框架 +fastapi +uvicorn[standard] +python-multipart + +# Metrix 平台 API 客户端(Metrix 源/仓库模式) +requests + +# 直连数据库 / 授权(直连 MySQL 模式 + 本地授权) +pymysql +cryptography + +# 数据处理 + FTP/SFTP +pandas +openpyxl +chardet +paramiko diff --git a/scripts/_env.py b/scripts/_env.py new file mode 100644 index 0000000..b93b761 --- /dev/null +++ b/scripts/_env.py @@ -0,0 +1,342 @@ +"""CapacityReport 脚本共享工具。 + +所有 scripts/ 下的运行/编译脚本都只依赖 Python 标准库,可直接用任意系统 Python 运行 +(python scripts/xxx.py)。本模块负责: + +- 自动创建并维护项目 .venv(使用标准库 venv,不使用 uv),按 requirements.txt 变化自动重装依赖。 +- 检测 Node.js / Rust(Cargo) / Docker 环境,缺失时给出安装引导。 +- 统一的日志、命令执行、平台判断等助手。 +""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +IS_WINDOWS = os.name == "nt" + +VENV_DIR = ROOT / ".venv" +VENV_BIN = VENV_DIR / ("Scripts" if IS_WINDOWS else "bin") +VENV_PYTHON = VENV_BIN / ("python.exe" if IS_WINDOWS else "python") +REQUIREMENTS = ROOT / "requirements.txt" +REQ_HASH_FILE = VENV_DIR / ".requirements.hash" + +FRONTEND_DIR = ROOT / "frontend" +SRC_TAURI_DIR = ROOT / "src-tauri" +PACKAGING_DIR = ROOT / "packaging" +DIST_DIR = ROOT / "dist" + +DESKTOP_API_BASE = "http://127.0.0.1:9081" +SERVER_NAME = "capareport-server" + + +# --------------------------------------------------------------------------- 日志 + + +def _supports_color() -> bool: + return sys.stdout.isatty() and not IS_WINDOWS + + +_C = { + "reset": "\033[0m", + "green": "\033[32m", + "cyan": "\033[36m", + "yellow": "\033[33m", + "red": "\033[31m", +} + + +def setup_console() -> None: + """让 Windows 控制台也能正常输出 UTF-8,避免中文乱码报错。""" + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + +def step(message: str) -> None: + color = _C["green"] if _supports_color() else "" + reset = _C["reset"] if _supports_color() else "" + print(f"\n{color}==> {message}{reset}", flush=True) + + +def info(message: str) -> None: + print(f" {message}", flush=True) + + +def warn(message: str) -> None: + color = _C["yellow"] if _supports_color() else "" + reset = _C["reset"] if _supports_color() else "" + print(f"{color}[warn] {message}{reset}", flush=True) + + +def die(message: str, hint: str | None = None) -> "NoReturn": # type: ignore[name-defined] + color = _C["red"] if _supports_color() else "" + reset = _C["reset"] if _supports_color() else "" + print(f"{color}[error] {message}{reset}", file=sys.stderr, flush=True) + if hint: + print(f" {hint}", file=sys.stderr, flush=True) + raise SystemExit(1) + + +# --------------------------------------------------------------------------- 命令执行 + + +def run(args: list[str], cwd: Path | None = None, env: dict[str, str] | None = None) -> None: + """执行命令并实时输出;失败抛 SystemExit。""" + printable = " ".join(str(a) for a in args) + info(printable) + completed = subprocess.run( + [str(a) for a in args], + cwd=str(cwd) if cwd else None, + env=env, + shell=False, + ) + if completed.returncode != 0: + die(f"命令执行失败(退出码 {completed.returncode}):{printable}") + + +def which(name: str) -> str | None: + return shutil.which(name) + + +def host_platform() -> str: + if IS_WINDOWS: + return "windows" + if sys.platform == "darwin": + return "macos" + return "linux" + + +def server_exe_name(platform: str | None = None) -> str: + platform = platform or host_platform() + return f"{SERVER_NAME}.exe" if platform == "windows" else SERVER_NAME + + +# --------------------------------------------------------------------------- Python venv + + +def _base_python() -> str: + """返回用于创建 venv 的基础解释器,优先当前运行脚本的解释器。""" + if sys.executable: + return sys.executable + for name in ("python3", "python"): + found = shutil.which(name) + if found: + return found + die( + "未找到可用的 Python 解释器。", + "请安装 Python 3.10+ 并加入 PATH:https://www.python.org/downloads/", + ) + + +def _requirements_hash() -> str: + try: + return hashlib.sha256(REQUIREMENTS.read_bytes()).hexdigest() + except OSError: + return "" + + +def ensure_venv(extra_packages: list[str] | None = None) -> Path: + """确保 .venv 存在且依赖最新,返回 venv 内的 python 路径。""" + extra_packages = extra_packages or [] + created = False + + if not VENV_PYTHON.exists(): + base = _base_python() + step(f"创建虚拟环境 .venv({base})") + run([base, "-m", "venv", str(VENV_DIR)]) + created = True + + current_hash = _requirements_hash() + stored_hash = REQ_HASH_FILE.read_text(encoding="utf-8").strip() if REQ_HASH_FILE.exists() else "" + + if created or current_hash != stored_hash: + step("安装/更新 Python 依赖(requirements.txt)") + run([str(VENV_PYTHON), "-m", "pip", "install", "--upgrade", "pip"]) + run([str(VENV_PYTHON), "-m", "pip", "install", "-r", str(REQUIREMENTS)], cwd=ROOT) + try: + REQ_HASH_FILE.write_text(current_hash, encoding="utf-8") + except OSError: + pass + + for package in extra_packages: + _ensure_python_package(package) + + return VENV_PYTHON + + +def _ensure_python_package(package: str) -> None: + """确保某个额外包(如 pyinstaller)已安装在 venv 中。""" + name = package.split("==")[0].split(">=")[0].strip() + probe = subprocess.run( + [str(VENV_PYTHON), "-m", "pip", "show", name], + capture_output=True, + ) + if probe.returncode == 0: + return + step(f"安装构建依赖 {package}") + run([str(VENV_PYTHON), "-m", "pip", "install", package], cwd=ROOT) + + +# --------------------------------------------------------------------------- Node.js + + +def ensure_node() -> str: + """确保 Node.js / npm 可用,返回 npm 可执行路径。""" + npm = shutil.which("npm") + node = shutil.which("node") + if not npm or not node: + die( + "未检测到 Node.js / npm(构建前端需要)。", + "请安装 Node.js 18+:https://nodejs.org/ ,安装后重新打开终端再运行。", + ) + return npm + + +def ensure_frontend_deps() -> None: + npm = ensure_node() + if (FRONTEND_DIR / "node_modules").exists(): + return + step("安装前端依赖(npm install)") + run([npm, "install"], cwd=FRONTEND_DIR) + + +def build_frontend(api_base: str | None = None) -> None: + npm = ensure_node() + ensure_frontend_deps() + step("构建前端(npm run build)") + env = os.environ.copy() + if api_base: + env["VITE_API_BASE"] = api_base + else: + env.pop("VITE_API_BASE", None) + completed = subprocess.run([npm, "run", "build"], cwd=str(FRONTEND_DIR), env=env) + if completed.returncode != 0: + die("前端构建失败(npm run build)") + + +# --------------------------------------------------------------------------- Rust / Tauri + + +def ensure_rust() -> None: + """确保 Rust 工具链(cargo + rustc)可用(编译 Tauri 桌面端需要)。""" + if shutil.which("cargo") and shutil.which("rustc"): + return + die( + "未检测到 Rust 工具链(cargo / rustc),编译 Tauri 桌面端需要。", + "请安装 Rust:https://www.rust-lang.org/tools/install ,安装后重新打开终端再运行。", + ) + + +def rust_host_triple() -> str: + output = subprocess.run(["rustc", "-vV"], capture_output=True, text=True) + for line in output.stdout.splitlines(): + if line.startswith("host:"): + return line.split(":", 1)[1].strip() + die("无法识别 Rust host triple(rustc -vV)") + + +def ensure_tauri_cli() -> None: + probe = subprocess.run(["cargo", "tauri", "--version"], capture_output=True) + if probe.returncode == 0: + return + step("安装 Tauri CLI(cargo install tauri-cli)") + run(["cargo", "install", "tauri-cli", "--locked"]) + + +# --------------------------------------------------------------------------- PyInstaller + +PYINSTALLER_SPEC = PACKAGING_DIR / "capareport-server.spec" + + +def build_server_binary(python: Path, onefile: bool, work_dir: Path, dist_dir: Path) -> Path: + """用 PyInstaller 打包后端为可执行文件。 + + onefile=True 返回单文件可执行文件路径;onefile=False 返回 onedir 目录路径。 + """ + remove_path(work_dir) + remove_path(dist_dir) + work_dir.mkdir(parents=True, exist_ok=True) + dist_dir.mkdir(parents=True, exist_ok=True) + + env = os.environ.copy() + if onefile: + env["CAPAREPORT_ONEFILE"] = "1" + else: + env.pop("CAPAREPORT_ONEFILE", None) + + step(f"PyInstaller 打包后端({'onefile' if onefile else 'onedir'})") + run( + [ + str(python), "-m", "PyInstaller", "--clean", "--noconfirm", + "--workpath", str(work_dir), "--distpath", str(dist_dir), str(PYINSTALLER_SPEC), + ], + cwd=ROOT, + env=env, + ) + + if onefile: + binary = dist_dir / server_exe_name() + if not binary.exists(): + die(f"未找到 PyInstaller 单文件产物:{binary}") + return binary + + out_dir = dist_dir / SERVER_NAME + if not out_dir.exists(): + die(f"未找到 PyInstaller onedir 产物:{out_dir}") + return out_dir + + +def build_tauri_sidecar(python: Path, work_dir: Path, dist_dir: Path) -> Path: + """构建桌面端 sidecar(onefile)并按 Rust host triple 命名复制到 src-tauri/binaries。""" + binary = build_server_binary(python, onefile=True, work_dir=work_dir, dist_dir=dist_dir) + triple = rust_host_triple() + sidecar_dir = SRC_TAURI_DIR / "binaries" + sidecar_dir.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if host_platform() == "windows" else "" + target = sidecar_dir / f"{SERVER_NAME}-{triple}{suffix}" + shutil.copy2(binary, target) + info(f"sidecar -> {target}") + return target + + +# --------------------------------------------------------------------------- Docker + + +def ensure_docker() -> None: + if not shutil.which("docker"): + die( + "未检测到 Docker,构建镜像需要。", + "请安装 Docker Desktop / Docker Engine:https://docs.docker.com/get-docker/", + ) + + +# --------------------------------------------------------------------------- 清理助手 + + +def remove_path(path: Path) -> bool: + """安全删除文件或目录,仅允许删除工作区内路径。""" + try: + resolved = path.resolve() + except OSError: + return False + if ROOT not in resolved.parents and resolved != ROOT: + warn(f"跳过工作区外路径:{resolved}") + return False + if not resolved.exists(): + return False + if resolved.is_dir(): + shutil.rmtree(resolved, ignore_errors=True) + else: + try: + resolved.unlink() + except OSError: + return False + return True diff --git a/scripts/build_docker.py b/scripts/build_docker.py new file mode 100644 index 0000000..aa99d4a --- /dev/null +++ b/scripts/build_docker.py @@ -0,0 +1,92 @@ +"""Docker 镜像编译 / 更新脚本(基于根目录 Dockerfile + docker/entrypoint.sh)。 + +用法: + python scripts/build_docker.py # 编译镜像并生成可部署离线包(dist/docker/) + python scripts/build_docker.py update # 重新编译镜像并就地更新本机运行的容器 + python scripts/build_docker.py --no-save # 编译但不导出 tar(本机调试更快) + +镜像 tag:capacity-report-app:latest。镜像内运行态数据落在 /data 数据卷, +首次启动由 docker/entrypoint.sh 播种默认 Configure.json / ReportScript.sql。 +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +IMAGE_TAG = "capacity-report-app:latest" +DOCKERFILE = _env.ROOT / "Dockerfile" +COMPOSE_FILE = _env.PACKAGING_DIR / "docker-compose.yml" +BUNDLE_FILES = ["Configure.json", "ReportScript.sql", "CellData.sql"] + + +def build_image() -> None: + _env.ensure_docker() + _env.build_frontend() + _env.step("构建 Docker 镜像") + _env.run( + ["docker", "build", "--progress=plain", "-f", str(DOCKERFILE), "-t", IMAGE_TAG, "."], + cwd=_env.ROOT, + ) + + +def make_bundle(save_tar: bool) -> Path: + out_dir = _env.DIST_DIR / "docker" + _env.remove_path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "cache").mkdir(exist_ok=True) + (out_dir / "logs").mkdir(exist_ok=True) + + shutil.copy2(COMPOSE_FILE, out_dir / "docker-compose.yml") + shutil.copytree(_env.PACKAGING_DIR / "mysql", out_dir / "mysql", dirs_exist_ok=True) + shutil.copytree(_env.PACKAGING_DIR / "sftpgo", out_dir / "sftpgo", dirs_exist_ok=True) + readme = _env.PACKAGING_DIR / "README.md" + if readme.exists(): + shutil.copy2(readme, out_dir / "README.md") + for name in BUNDLE_FILES: + src = _env.ROOT / name + if src.exists(): + shutil.copy2(src, out_dir / name) + + if save_tar: + _env.step("导出镜像 tar") + _env.run(["docker", "save", "-o", str(out_dir / "capacity-report-app-latest.tar"), IMAGE_TAG]) + + _env.step(f"Docker 离线包:{out_dir}") + return out_dir + + +def update_running() -> None: + _env.step("更新本机运行的容器(docker compose up -d)") + _env.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "up", "-d", "--force-recreate", "capacity-app"], + cwd=_env.PACKAGING_DIR, + ) + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="编译 / 更新 Docker 镜像") + parser.add_argument("action", nargs="?", choices=["build", "update"], default="build", help="build=编译并打包;update=编译并就地更新容器") + parser.add_argument("--no-save", action="store_true", help="编译镜像但不导出 tar") + args = parser.parse_args() + + build_image() + + if args.action == "update": + update_running() + else: + make_bundle(save_tar=not args.no_save) + + _env.step("清理前端中间产物") + _env.remove_path(_env.FRONTEND_DIR / "dist") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_server.py b/scripts/build_server.py new file mode 100644 index 0000000..dc5daf5 --- /dev/null +++ b/scripts/build_server.py @@ -0,0 +1,105 @@ +"""Server 便携版编译脚本:打包后端可执行文件 + 前端产物 + 配置 + 启动脚本。 + +用法: + python scripts/build_server.py # 编译当前系统的便携版 + python scripts/build_server.py --no-archive # 不生成 zip 压缩包 + +产物:dist/server/CapacityReport-Server-<平台>-x64/ 及其 .zip。 +注意:便携版需在目标系统原生构建(Windows 包在 Windows、Linux 包在 Linux、macOS 包在 macOS)。 +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +RUNTIME_FILES = ["Configure.json", "ReportScript.sql", "CellData.sql"] + +RUN_BAT = """@echo off +setlocal +cd /d "%~dp0" +if not exist cache mkdir cache +if not exist logs mkdir logs +echo Starting CapacityReport server... +echo URL: http://localhost:9081 +"%~dp0{exe}" --host 0.0.0.0 --port 9081 +pause +""" + +START_SH = """#!/usr/bin/env sh +set -eu +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +cd "$SCRIPT_DIR" +mkdir -p cache logs +chmod +x "./{exe}" 2>/dev/null || true +echo "Starting CapacityReport server..." +echo "URL: http://localhost:9081" +exec "./{exe}" --host 0.0.0.0 --port 9081 +""" + + +def write_launchers(out_dir: Path, platform: str) -> None: + exe = _env.server_exe_name(platform) + if platform == "windows": + (out_dir / "run.bat").write_text(RUN_BAT.format(exe=exe), encoding="utf-8") + else: + start = out_dir / "start.sh" + start.write_text(START_SH.format(exe=exe).replace("\r\n", "\n"), encoding="utf-8", newline="\n") + start.chmod(0o755) + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="编译 Server 便携版") + parser.add_argument("--no-archive", action="store_true", help="不生成 zip 压缩包") + args = parser.parse_args() + + platform = _env.host_platform() + python = _env.ensure_venv(extra_packages=["pyinstaller"]) + _env.build_frontend() + + tmp = _env.DIST_DIR / ".tmp" / "pyinstaller" + binary_dir = _env.build_server_binary(python, onefile=False, work_dir=tmp / "work", dist_dir=tmp / "dist") + + out_name = f"CapacityReport-Server-{platform}-x64" + out_dir = _env.DIST_DIR / "server" / out_name + _env.remove_path(out_dir) + _env.remove_path(_env.DIST_DIR / "server" / f"{out_name}.zip") + out_dir.mkdir(parents=True, exist_ok=True) + + _env.step("组装便携版目录") + shutil.copytree(binary_dir, out_dir, dirs_exist_ok=True) + for name in RUNTIME_FILES: + src = _env.ROOT / name + if src.exists(): + shutil.copy2(src, out_dir / name) + (out_dir / "cache").mkdir(exist_ok=True) + (out_dir / "logs").mkdir(exist_ok=True) + + frontend_dist = _env.FRONTEND_DIR / "dist" + if not frontend_dist.exists(): + _env.die(f"前端产物不存在:{frontend_dist}") + shutil.copytree(frontend_dist, out_dir / "frontend" / "dist", dirs_exist_ok=True) + + write_launchers(out_dir, platform) + + if not args.no_archive: + _env.step("生成压缩包") + archive = shutil.make_archive(str(out_dir), "zip", root_dir=out_dir) + _env.info(f"压缩包:{archive}") + + _env.step("清理中间产物") + _env.remove_path(_env.DIST_DIR / ".tmp") + _env.remove_path(frontend_dist) + + _env.step(f"便携版完成:{out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_tauri.py b/scripts/build_tauri.py new file mode 100644 index 0000000..bd40291 --- /dev/null +++ b/scripts/build_tauri.py @@ -0,0 +1,255 @@ +"""Tauri 桌面版编译脚本。 + +用法: + python scripts/build_tauri.py # 编译当前系统的桌面版 + python scripts/build_tauri.py --platform windows # 指定平台(windows/linux/macos) + +特性: +- Windows 安装包内置 WebView2 离线安装器(tauri.conf.json 已配置 offlineInstaller), + 适合无外网、未预装 WebView2 Runtime 的机器。 +- Windows NSIS 安装器默认安装到 D:\\Program Files\\CapacityReport(无 D 盘时回落系统盘)。 +- 跨平台限制:Tauri 无法可靠跨系统交叉编译,--platform 必须与当前系统一致,否则会提示需在目标系统本机构建。 + +产物:dist/desktop/ 下的安装包(Windows: .msi/.exe;Linux: .deb/.AppImage;macOS: .dmg)。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import sys +import tarfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +TAURI_CONF = _env.SRC_TAURI_DIR / "tauri.conf.json" +NSIS_HOOK = _env.SRC_TAURI_DIR / "windows" / "nsis-hooks.nsh" + +INSTALLER_EXTS = { + "windows": (".msi", ".exe"), + "linux": (".deb", ".AppImage", ".rpm"), + "macos": (".dmg", ".app.tar.gz"), +} + + +# --------------------------------------------------------------------------- NSIS 定制(Windows 默认装 D 盘) + +_RESTORE_LINE = " Call RestorePreviousInstallLocation" + +_INSTALL_DIR_OVERRIDE = """ 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 +""" + +_ON_INIT_TAIL = """ !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif +FunctionEnd""" + +_ON_INIT_OVERRIDE = """ !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""" + +_RESTORE_FUNCTION = """Function RestorePreviousInstallLocation + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 +FunctionEnd""" + +_RESTORE_OVERRIDE = """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""" + + +def _cargo_home() -> Path: + return Path(os.environ.get("CARGO_HOME") or (Path.home() / ".cargo")) + + +def _read_template_from_src() -> str | None: + """优先读取 registry/src 下已解压的 tauri-bundler 模板。""" + registry = _cargo_home() / "registry" / "src" + if not registry.exists(): + return None + candidates = [ + path + for path in registry.rglob("installer.nsi") + if "tauri-bundler-" in str(path) and path.parts[-4:] == ("bundle", "windows", "nsis", "installer.nsi") + ] + if not candidates: + return None + return sorted(candidates, reverse=True)[0].read_text(encoding="utf-8") + + +def _read_template_from_crate() -> str | None: + """registry/src 被 cargo gc 清理时,从缓存的 tauri-bundler-*.crate 包内直接解出模板。""" + cache = _cargo_home() / "registry" / "cache" + if not cache.exists(): + return None + crates = sorted(cache.rglob("tauri-bundler-*.crate"), reverse=True) + member_suffix = "src/bundle/windows/nsis/installer.nsi" + for crate in crates: + try: + with tarfile.open(crate, "r:gz") as archive: + for member in archive.getmembers(): + if member.name.endswith(member_suffix): + extracted = archive.extractfile(member) + if extracted is not None: + return extracted.read().decode("utf-8") + except (tarfile.TarError, OSError): + continue + return None + + +def read_nsis_template_text() -> str: + content = _read_template_from_src() or _read_template_from_crate() + if content is None: + _env.die( + "未找到 Tauri NSIS 模板(installer.nsi)", + "先运行一次 cargo tauri build 拉取 tauri-bundler。", + ) + return content + + +def make_nsis_template(tmp_dir: Path) -> Path: + content = read_nsis_template_text() + + hook_path = str(NSIS_HOOK.resolve()) + include_line = f'!include "{hook_path}"' + content, n = re.subn( + r'\{\{#if installer_hooks\}\}\s*!include "\{\{installer_hooks\}\}"\s*\{\{/if\}\}', + lambda _: include_line, + content, + ) + if n == 0: + _env.die("Tauri NSIS 模板已变化:installer_hooks 标记未找到") + + for marker, replacement, label in ( + (_RESTORE_LINE, _INSTALL_DIR_OVERRIDE, "RestorePreviousInstallLocation 调用点"), + (_ON_INIT_TAIL, _ON_INIT_OVERRIDE, ".onInit 结尾"), + (_RESTORE_FUNCTION, _RESTORE_OVERRIDE, "RestorePreviousInstallLocation 函数"), + ): + if marker not in content: + _env.die(f"Tauri NSIS 模板已变化:{label}标记未找到") + content = content.replace(marker, replacement) + + target = tmp_dir / "tauri-nsis-installer.nsi" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content.replace("\r\n", "\n"), encoding="utf-8", newline="\n") + return target + + +def build_with_windows_nsis(tmp_dir: Path) -> None: + template = make_nsis_template(tmp_dir) + # 按字节备份/还原,避免 Windows 文本写入把原文件 LF 改成 CRLF。 + original = TAURI_CONF.read_bytes() + try: + config = json.loads(original.decode("utf-8")) + config["bundle"]["windows"]["nsis"]["template"] = str(template) + TAURI_CONF.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") + _env.remove_path(_env.SRC_TAURI_DIR / "target" / "release" / "bundle") + _env.step("编译 Tauri 桌面版(cargo tauri build)") + _env.run(["cargo", "tauri", "build"], cwd=_env.SRC_TAURI_DIR) + finally: + TAURI_CONF.write_bytes(original) + + +def collect_installers(platform: str) -> None: + bundle_dir = _env.SRC_TAURI_DIR / "target" / "release" / "bundle" + out_dir = _env.DIST_DIR / "desktop" + _env.remove_path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + exts = INSTALLER_EXTS[platform] + found = 0 + for path in bundle_dir.rglob("*"): + if path.is_file() and path.name.endswith(exts): + shutil.copy2(path, out_dir / path.name) + found += 1 + _env.info(f"安装包:{path.name}") + if found == 0: + _env.warn(f"未在 {bundle_dir} 找到安装包产物") + _env.step(f"桌面版完成:{out_dir}") + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="编译 Tauri 桌面版") + parser.add_argument( + "--platform", + choices=["current", "windows", "linux", "macos"], + default="current", + help="目标平台(默认当前系统)", + ) + args = parser.parse_args() + + host = _env.host_platform() + platform = host if args.platform == "current" else args.platform + if platform != host: + _env.die( + f"无法在 {host} 上交叉编译 {platform} 桌面版。", + f"Tauri 需要在目标系统本机构建,请在 {platform} 机器上运行本脚本。", + ) + + _env.ensure_rust() + python = _env.ensure_venv(extra_packages=["pyinstaller"]) + _env.build_frontend(api_base=_env.DESKTOP_API_BASE) + + tmp = _env.DIST_DIR / ".tmp" + _env.build_tauri_sidecar(python, tmp / "pyinstaller" / "work", tmp / "pyinstaller" / "dist") + _env.ensure_tauri_cli() + + if platform == "windows": + build_with_windows_nsis(tmp) + else: + _env.remove_path(_env.SRC_TAURI_DIR / "target" / "release" / "bundle") + _env.step("编译 Tauri 桌面版(cargo tauri build)") + _env.run(["cargo", "tauri", "build"], cwd=_env.SRC_TAURI_DIR) + + collect_installers(platform) + + _env.step("清理中间产物") + _env.remove_path(tmp) + _env.remove_path(_env.FRONTEND_DIR / "dist") + _env.remove_path(_env.SRC_TAURI_DIR / "binaries") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/clean.py b/scripts/clean.py new file mode 100644 index 0000000..e08efc0 --- /dev/null +++ b/scripts/clean.py @@ -0,0 +1,91 @@ +"""清理脚本:清除缓存、__pycache__、编译产物和运行时临时数据,保持项目整洁。 + +用法: + python scripts/clean.py # 清理缓存 / 编译产物 / 运行时临时数据(保留 .venv 与前端依赖) + python scripts/clean.py --deep # 额外清理 .venv 与 frontend/node_modules(下次运行会重装) + +默认不会删除源码、配置(Configure.json/*.sql)、授权文件或 .git。 +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +# 遍历时跳过这些目录(既慢又无意义) +PRUNE_DIRS = {".git", ".venv", "node_modules"} + +# 编译产物 / 缓存目录(相对项目根) +ARTIFACT_DIRS = [ + "dist", + "frontend/dist", + "frontend/.vite", + "src-tauri/target", + "src-tauri/binaries", + ".ruff_cache", + ".pytest_cache", + ".mypy_cache", +] + +# 运行时临时数据目录 +RUNTIME_DIRS = ["cache", "logs", "uploads"] + +DEEP_DIRS = [".venv", "frontend/node_modules"] + + +def clean_pycache() -> int: + removed = 0 + for current, dirs, files in os.walk(_env.ROOT): + dirs[:] = [d for d in dirs if d not in PRUNE_DIRS] + for d in list(dirs): + if d == "__pycache__": + if _env.remove_path(Path(current) / d): + removed += 1 + dirs.remove(d) + for f in files: + if f.endswith((".pyc", ".pyo")): + if _env.remove_path(Path(current) / f): + removed += 1 + return removed + + +def clean_dirs(rel_dirs: list[str]) -> int: + removed = 0 + for rel in rel_dirs: + if _env.remove_path(_env.ROOT / rel): + _env.info(f"已删除 {rel}") + removed += 1 + return removed + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="清理缓存与编译产物") + parser.add_argument("--deep", action="store_true", help="额外清理 .venv 与 frontend/node_modules") + args = parser.parse_args() + + _env.step("清理 __pycache__ / *.pyc") + n = clean_pycache() + _env.info(f"清理 {n} 项") + + _env.step("清理编译产物") + clean_dirs(ARTIFACT_DIRS) + + _env.step("清理运行时临时数据(cache / logs / uploads)") + clean_dirs(RUNTIME_DIRS) + + if args.deep: + _env.step("深度清理依赖(.venv / node_modules)") + clean_dirs(DEEP_DIRS) + + _env.step("清理完成") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev.py b/scripts/dev.py new file mode 100644 index 0000000..9baef73 --- /dev/null +++ b/scripts/dev.py @@ -0,0 +1,150 @@ +"""开发测试脚本:同时启动后端(自动重载)和前端(Vite 热更新)。 + +用法: + python scripts/dev.py + +启动两个进程: + [api] CapacityReport API http://127.0.0.1:9081 (自动重载) + [web] CapacityReport web http://127.0.0.1:5174 (Vite 热更新,/api 代理到 9081) + +按 Ctrl+C 停止全部。首次运行会自动创建 .venv 并安装依赖、自动安装前端依赖。 +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +API_PORT = 9081 +WEB_PORT = 5174 + +_processes: list[tuple[str, subprocess.Popen]] = [] +_stopping = threading.Event() + + +def log(name: str, message: str) -> None: + print(f"[{name}] {message}".rstrip(), flush=True) + + +def _stream(name: str, process: subprocess.Popen) -> None: + assert process.stdout is not None + for raw in iter(process.stdout.readline, b""): + log(name, raw.decode("utf-8", "replace").rstrip("\r\n")) + + +def _spawn(name: str, args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.Popen: + kwargs: dict[str, object] = { + "cwd": str(cwd), + "stdout": subprocess.PIPE, + "stderr": subprocess.STDOUT, + "env": env, + } + if _env.IS_WINDOWS: + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "CREATE_NO_WINDOW", 0) + else: + kwargs["start_new_session"] = True + process = subprocess.Popen(args, **kwargs) # type: ignore[arg-type] + _processes.append((name, process)) + threading.Thread(target=_stream, args=(name, process), daemon=True).start() + return process + + +def _stop_all() -> None: + if _stopping.is_set(): + return + _stopping.set() + for name, process in _processes: + if process.poll() is not None: + continue + log(name, "stopping...") + try: + if _env.IS_WINDOWS: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(process.pid)], capture_output=True, check=False) + else: + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + except Exception: + pass + + +def _wait_for_http(proc_name: str, url: str, timeout: float = 90.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _stopping.is_set(): + return False + if any(name == proc_name and proc.poll() is not None for name, proc in _processes): + return False + try: + with urllib.request.urlopen(url, timeout=2): + return True + except urllib.error.HTTPError: + return True + except (urllib.error.URLError, OSError): + time.sleep(0.4) + return False + + +def main() -> int: + _env.setup_console() + + python = _env.ensure_venv() + _env.ensure_frontend_deps() + + node = _env.which("node") + vite_bin = _env.FRONTEND_DIR / "node_modules" / "vite" / "bin" / "vite.js" + if not node or not vite_bin.exists(): + _env.die("未找到 node 或 vite(请确认前端依赖已安装)") + + _env.step("启动开发服务") + _spawn( + "api", + [str(python), "-m", "uvicorn", "app.main:app", "--reload", "--host", "127.0.0.1", "--port", str(API_PORT)], + _env.ROOT, + ) + log("api", f"starting -> http://127.0.0.1:{API_PORT} (auto-reload)") + + log("web", "waiting for backend ...") + if _wait_for_http("api", f"http://127.0.0.1:{API_PORT}/health"): + log("web", "backend ready") + elif not _stopping.is_set(): + log("web", "backend not ready in time; starting frontend anyway") + + api_proc = _processes[0][1] + if _stopping.is_set() or api_proc.poll() is not None: + return api_proc.poll() or 1 + + _spawn("web", [node, str(vite_bin), "--host", "127.0.0.1", "--port", str(WEB_PORT)], _env.FRONTEND_DIR) + log("web", f"starting -> http://127.0.0.1:{WEB_PORT} (hot reload)") + log("dev", "press Ctrl+C to stop") + + reported: set[str] = set() + try: + while not _stopping.is_set(): + for name, process in _processes: + code = process.poll() + if code is None or name in reported: + continue + reported.add(name) + if name == "api": + log(name, f"exited with code {code}; shutting down") + return code or 0 + log(name, f"exited with code {code}") + time.sleep(0.5) + except KeyboardInterrupt: + log("dev", "received Ctrl+C") + finally: + _stop_all() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev_tauri.py b/scripts/dev_tauri.py new file mode 100644 index 0000000..2ad7acf --- /dev/null +++ b/scripts/dev_tauri.py @@ -0,0 +1,54 @@ +"""Tauri 桌面端测试运行脚本:构建前端 + 后端 sidecar,然后 cargo tauri dev。 + +用法: + python scripts/dev_tauri.py # 复用已有 sidecar/前端产物(缺失才构建) + python scripts/dev_tauri.py --rebuild # 强制重建前端和 sidecar + +桌面端会启动本机 sidecar(http://127.0.0.1:9081)。首次运行会自动准备 .venv、 +前端依赖、PyInstaller,并在缺少 Rust / Tauri CLI 时给出安装引导。 +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="Tauri 桌面端测试运行") + parser.add_argument("--rebuild", action="store_true", help="强制重建前端与 sidecar") + args = parser.parse_args() + + _env.ensure_rust() + python = _env.ensure_venv(extra_packages=["pyinstaller"]) + + dist_index = _env.FRONTEND_DIR / "dist" / "index.html" + if args.rebuild or not dist_index.exists(): + _env.build_frontend(api_base=_env.DESKTOP_API_BASE) + + triple = _env.rust_host_triple() + suffix = ".exe" if _env.host_platform() == "windows" else "" + sidecar = _env.SRC_TAURI_DIR / "binaries" / f"{_env.SERVER_NAME}-{triple}{suffix}" + if args.rebuild or not sidecar.exists(): + tmp = _env.DIST_DIR / ".tmp" / "pyinstaller" + _env.build_tauri_sidecar(python, tmp / "work", tmp / "dist") + + _env.ensure_tauri_cli() + + _env.step("启动 Tauri 开发模式(cargo tauri dev)") + _env.info("按 Ctrl+C 停止") + try: + completed = subprocess.run(["cargo", "tauri", "dev"], cwd=str(_env.SRC_TAURI_DIR)) + except KeyboardInterrupt: + return 0 + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gen_license_code.py b/scripts/gen_license_code.py new file mode 100644 index 0000000..37615c9 --- /dev/null +++ b/scripts/gen_license_code.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""根据授权 key 生成激活码(与 app/services/license.py 算法一致)。""" + +from __future__ import annotations + +import argparse +import sys +from datetime import date, datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.services.license import activation_hash, format_key_label + + +def parse_key(raw: str) -> date: + text = raw.strip() + for fmt in ("%Y/%m/%d", "%Y-%m-%d"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + raise ValueError("无法解析 key,请使用 YYYY/MM/DD 格式,例如 2026/06/20") + + +def generate_code(key: str) -> tuple[str, str]: + expires_on = parse_key(key) + key_label = format_key_label(expires_on) + return key_label, activation_hash(expires_on) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="输入授权 key(到期日期),输出对应激活码。", + ) + parser.add_argument( + "key", + nargs="?", + help="授权 key,格式 YYYY/MM/DD 或 YYYY-MM-DD,例如 2026/06/20", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + key_input = args.key + if not key_input: + try: + key_input = input("请输入 key (YYYY/MM/DD): ").strip() + except EOFError: + print("未输入 key。", file=sys.stderr) + return 1 + + if not key_input: + print("key 不能为空。", file=sys.stderr) + return 1 + + try: + key_label, code = generate_code(key_input) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + + print(f"key: {key_label}") + print(f"激活码: {code}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run.py b/scripts/run.py new file mode 100644 index 0000000..b809174 --- /dev/null +++ b/scripts/run.py @@ -0,0 +1,49 @@ +"""本地运行脚本:构建前端(如缺失)并启动后端服务。 + +用法: + python scripts/run.py # 默认 0.0.0.0:9081 + python scripts/run.py --port 8080 # 指定端口 + python scripts/run.py --rebuild # 强制重新构建前端 + +首次运行会自动创建 .venv、安装依赖;前端构建产物缺失时自动构建。按 Ctrl+C 停止。 +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="本地运行 CapacityReport 服务") + parser.add_argument("--host", default="0.0.0.0", help="监听地址(默认 0.0.0.0)") + parser.add_argument("--port", default="9081", help="监听端口(默认 9081)") + parser.add_argument("--rebuild", action="store_true", help="强制重新构建前端") + args = parser.parse_args() + + python = _env.ensure_venv() + + dist_index = _env.FRONTEND_DIR / "dist" / "index.html" + if args.rebuild or not dist_index.exists(): + _env.build_frontend() + + _env.step(f"启动服务 -> http://localhost:{args.port}") + _env.info("按 Ctrl+C 停止") + try: + completed = subprocess.run( + [str(python), "-m", "app.main", "--host", args.host, "--port", str(args.port)], + cwd=str(_env.ROOT), + ) + except KeyboardInterrupt: + return 0 + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..ac19d67 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,4943 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "capacity-report-desktop" +version = "3.0.0" +dependencies = [ + "open", + "reqwest", + "serde", + "sysinfo", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-shell", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..4cc3e01 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "capacity-report-desktop" +version = "3.0.0" +description = "CapacityReport desktop shell" +authors = ["NIXEVOL"] +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +open = "5.3.5" +reqwest = { version = "0.13", default-features = false, features = ["stream"] } +serde = { version = "1", features = ["derive"] } +sysinfo = { version = "0.38.4", default-features = false, features = ["system"] } +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-shell = "2" + +[features] +default = [] diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..0fcb823 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default desktop permissions", + "windows": ["main"], + "permissions": ["core:default", "core:webview:deny-internal-toggle-devtools"] +} diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json new file mode 100644 index 0000000..9a4e12e --- /dev/null +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -0,0 +1 @@ +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json new file mode 100644 index 0000000..595d9d1 --- /dev/null +++ b/src-tauri/gen/schemas/capabilities.json @@ -0,0 +1 @@ +{"default":{"identifier":"default","description":"Default desktop permissions","local":true,"windows":["main"],"permissions":["core:default","core:webview:deny-internal-toggle-devtools"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json new file mode 100644 index 0000000..6eba1dc --- /dev/null +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -0,0 +1,2678 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "deny": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "ShellScopeEntryAllowedArg": { + "description": "A command argument allowed to be executed by the webview API.", + "anyOf": [ + { + "description": "A non-configurable argument that is passed to the command in the order it was specified.", + "type": "string" + }, + { + "description": "A variable that is set while calling the command from the webview API.", + "type": "object", + "required": [ + "validator" + ], + "properties": { + "raw": { + "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", + "default": false, + "type": "boolean" + }, + "validator": { + "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "ShellScopeEntryAllowedArgs": { + "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", + "anyOf": [ + { + "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", + "type": "boolean" + }, + { + "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/ShellScopeEntryAllowedArg" + } + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/gen/schemas/windows-schema.json b/src-tauri/gen/schemas/windows-schema.json new file mode 100644 index 0000000..6eba1dc --- /dev/null +++ b/src-tauri/gen/schemas/windows-schema.json @@ -0,0 +1,2678 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "deny": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "ShellScopeEntryAllowedArg": { + "description": "A command argument allowed to be executed by the webview API.", + "anyOf": [ + { + "description": "A non-configurable argument that is passed to the command in the order it was specified.", + "type": "string" + }, + { + "description": "A variable that is set while calling the command from the webview API.", + "type": "object", + "required": [ + "validator" + ], + "properties": { + "raw": { + "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", + "default": false, + "type": "boolean" + }, + "validator": { + "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "ShellScopeEntryAllowedArgs": { + "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", + "anyOf": [ + { + "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", + "type": "boolean" + }, + { + "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/ShellScopeEntryAllowedArg" + } + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..bda2a1028e967586e2fd9acc8845ef309607fd89 GIT binary patch literal 6124 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..3da16ba71f1b127cf7509c9d8b97518b9e02ff7f GIT binary patch literal 1377 zcmV-n1)lneP)~42^VWp{3+k&N{sh}mO4H^`KFC^*%hDc28lZgRMOpHFLFB)Ho@q#|! zgNa5lAw_*K#7l!U8Z8!$Rxz3)RG}Ntr9!*3JA0Yq`OneEPDdbzzsBnF3Uy0g&^&L0M=RV&0sk=X5gK3AwXnTc#0ZjDQo` za%qx=nFZT}uuw)QN%IC{5l;be##$P$tAaKDc5`@Z|7r|v*?^vvtvG*a0Z$K)puHuF zN4MP~6(phGpDy6Q_ykH780MO%y(Crr%%GiDPM(llX8|+#gM}MCd zY}SL-?QK|*%iw`q*W<@CXHl&?xPMbW279|O^wuy^sZ@gkJi?#~4@AHN*YbsG6@6Fb zrSM-0MQnfZ0REaQVP#7b%C#Eq8Ms!y`qc0z7&$h9Gp{{|JJ($!INy|c&l)g*1RjXS zo~B8GS`~wRtK{>(k#U@xE#c}bT2SM4*mhO8SynFWym`Ik_^x~#UJ*QBs#cKC5v40U zU^E&V34xM2aFI^nqN+tdRRKWLgsdIRLBR1+flW7{y*Y=`$y0dtqfcdM+GZ`}kquR`W(uY1a_!-FKEM-)4&$zMy?AJF0O#jR zE(qfanSwVXXeQ8D^+q7&iBH3q@Q7C>vfL z{R)$R7qNfW4h(d4;*to1Avq!3y;6|F`jvU1b@t2z}k*HzWcL) zshNLpZf*{r{dz)p<)ZY+?t>rV$m9uZ?Cz8}iD$?#BP^eKc}y1gn@o>XUOh5~eWPQN z4J(dIL`C81`}V&rJ=Gz+@Izr5+uwK-&6yOMo6T}qs&=c+Yn7jUGo}Osw7wEL$ph=%5!W8ly6({R>u$% z`>*{$!uwbv59lHJzrvH-bt`PO;%mY32v_?7Bzu5ZO%BuZl!tz%F_=1{>nwsNNXwaQ z%b7P46rhXxb}GBny-E}>cFHjpJD%zNM5(eMM%l{_j|cpt7*Q-88mQr*);W4E@R7@c zl8VMEm)OR82NNOuww_*OQ|Z8S7FkYbH99U)HKRj=q))AEMviJw%WBNgOxw?uqnRUx ze=~j3s}rJJcRsnd#tdebYZb|EECF=B<$foVd}?+Q!5Fw*2+vZpBKBH->7|lOXSJoF j9gD;~dEGV82`Ktsq*3|@u+qYn00000NkvXXu0mjfaXgYq literal 0 HcmV?d00001 diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..ef9e19b1349cf8b2958ee21946d2d767d94af728 GIT binary patch literal 2859 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 JPDHLkV1m*Mj^+RW literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..462646620ab22e6b8e279bec82859b8926ea28bd GIT binary patch literal 5452 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..37cc6e5a5e9f0fdf9eab773330df9920fc953839 GIT binary patch literal 7087 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f9a409d2038548f35588fbfce33af3198cf983b0 GIT binary patch literal 1972 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 TD;vQt00000NkvXXu0mjfn(S}l literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bee19c673fc736f1547da32a52b0edfb0b884a7c GIT binary patch literal 4319 zcmV<55Fqb~P)9wl)4Wc${gh@mm`ywMbG0OWZ{4LIi~k9l-DPK87nm>86o{=N@A8hPYRQ_{pB3kk*$UBqeSVTcw81Y}V@slqbNxk^Fu^=dg;L=R!bnJCZk85YW;uK~v}fIthjuj5AgIy_c} z(TQ^PQX;`<=`hq9Mg?F=Vx;uQ%$-JB(uzqiS~{cr`!Riwu@mP4f&sKNm7u+)DWbYG z77|@lC71y2Sw0WWIcbhKPra~A+Hx&}rpf7ELX!qnP;K|>DgpEyY`sU9uc|@VewXO2 zz1fG}oqOOfo3}$7KMydi$@uC+i6TgX3E*#Cb~22Y18DX=XdL=uq7pz;$%6-8cm>vO zd{szu+^NQG-%m+lEYJ~M5Wsy0N1%UTM5#x#KUvlsQd&fTJo5529%%lJS@Lx{QYwY` zHhWq-%;}m2gJa_owWeFApXh-aE+GjffUms$F__?hr#r$Tih0&}KnEqh5Ql0F4HHFJ zo~L^sCaHXJ-^lYKDSqf1SHXgr(^GvqDvE@!1%ag{n*biKRJegAS4Nfi!*iW##xiM9 zO`FjYFvnWdS0=hzs#Yu1R6&}2`MZ=kCa6>fYSQ}ICWe^?M-of`drYS;fyQc5u1DvX zoZ2iUrl};kGFi5F4;4$rGDA&XrgQlj7Asc*nC|nuwC$EbF42=Sbip#bhO(unt9czC zntTxeslU$t2S6joqOQqd8^{lzhFcsf-$_M7uYwt^h{GK(S~lv09w&ab?$Dj#6H?0GoO*)Cguo7g=Wy>XpY7 z`PVtXOhXpT?4R}%l?`3E#?UF$8NhbBP+X#+%L^58SM*g~Q>beRMnD%oOIEuuSXNp> zuWx{jt=R&$C&uKMtz%QTrU7ski;QPd=d@21mKgws+6Fy8tNh*W5)6Cr5r+9i$(HHGyrxVOkki4KbeNejj;(0fb%Fq7DmtHkWLM2Lqme|T*B#? zhjcj&bfH=@4S@4Kc4jQRjLK%T`dHOx+UVr;u2chE_-Td0Y>1$BaGIk|!{<@I4VD7X)#O!s36!9!ptsPwivEHV;LM89pONGQvk3M-eSBQ4 zY~Wy`h-=PW2G{j07m`+rCh-vX@9ZCd)z55#m)_a~{ev6?-v7<(?&Q)p2d-JMT!g2h zre}|X`}zikpy$W;^3HHVgs;w<-U&av{Cqfj(E|g#}>L>K6vH zI+{FBm9%=Z+T=5>(a<0ukBp4N)t@?%_wX$|8Zr2u}&z!Ij7SB0KfFDwb2M@peDy-eKMNk&C zl1Tv!Hst_yX0;`_1x-_U-JCrX$4in!rmdQGFV_gLu(OA0X{@WY1#Z1)g(BTSqN@Go z`G3RBk31$iR-96b$PWbavw_RZ+2il#O=HI48lnlNbAT6&u2c_d9Yh(_QNFQnKYaDR zwb1+S9+<%;IGlHfwFAsF(|y%|7lkf7<3vQ3?_qLrSBg2NES$C$H}5#vqN8UdPNK;I zOti53&;T459ubC3DH<;O_))XqhI7vn6oHwzh)Do!^;o9UdPc4VdsA@r2UEHxt{IRD zHatENdB?)UXD*sAR5aqzls8Sui^YbX#+REIBS!|I=clXTlskR~zkKFJkzWvXc%MJ@ zWVrLP&%(giXmy^)Q~=C$*09B_wi9}q%!@eOF*2EV9UOpd@9j_VC3R5OpLaU!<7O~f zbR^ZWi;_M>r?GB45d_xhc2p*6EmV;=u6-2#x^=tYV^SYUySVxjr-;u1?u{cAZ%P1G zBdu8OXf@{Q$ynov27yN}|EG=HlBOMZNZ-EjECKYM!vn(gIT7i`WVz8jb*lOuhYDk(1Fk5*m;$8u-UXn8{GhsJ`l2+Vdw`u#m8ih!KS z;5kD^749tWI&}#g9*vs2MW3e@(#Cb;9sT`q`{Pe0WhZH#!SnpV#TQhKkx2oJW-pxb z{=R+L0ktz?HI2#93lL@OJ2DJ6J@f>uxo(wsNdm-UVbi_hv?bz0j`k7W?8$>RgFD${ z{w^nat;NL{yE(aGGm`^)&$BPWmrh>_OXtl^ynSC-z7!tWycO1Mc~zvL(UO`0M#Gc_ zR62fRJORoOO@7dmUb4HRML2wJT=RJ3ne+II2Z9F36-m-Py{%QuRi4S|f$kiN{Ki5H zk@AgQpE3BMdlZwn2aa z31|ZEo@aVt<=qd$);)a!=-BsLQpSD*jBsvFzf%E#b6OPU(gm|vFq0g%rR~Gg>ccyAA_ayj}h927j;Nu+~2X6^Y@nb`rwJzcEEFQzAH@UiJ&a}5`W+Jrf6YD zcxTz-`>=m_NK_(|_?4bTzKc87KN+cs(LD5ss^)fe!oiV|#Od!2Fk1Cvw9QD3eGQnm zfXF1{BFemJ6JXTD*S+#OJiO^O;n$!Ki=9;DT^{x-JLrB zLScZleV28LnQX}!96B`zx48QvaH<1Orc4iZjQ?Y%DQ4QRCAAm*fW)S~uK>GGnDngx z*E(ALp$AxR*qh4cp=mmAj;UDB0OxuvnL1jn1BIbsv>F-!gGNmQ+me*=Dgn-OtFZyF z^AxjcUY^^!XxIQ)1(@Yghi>@ExF#kPSevt+C?coDGwZr=n7;~O-BW~Nbi}ZpoM&D? zSVI@A0kC~a1Lo0%%DXNDstthkuHXz^uzc-;YLtpQlqQ>u$K*DJuR z7{KP>7S}b1>KAPEgnluuW$(99&FeE*uJ}iv?{r6v^pD4z^ay>ZrTGY zwjo!sCC~E~n{phsuhr171h!|_1u^IAXCsEP(!iu8##tu9Q(Y~6lIzIJtGXIzDY+~$ z4CZ_jpj;~1y#78z-Ac6D6%KW~aitlCrsUEh`JNoGo!gYrFb@RnQR~q;xz*m1lWcvLukND}o(5v89SU)!;6IuG-Z;RpV@@TEaB6CaI)7bo zi&9GzG(t0kXfGq$YDeq@V_N;%dCw*niNbu3pVJOHh84T>#^efJOTRY({LfuGL_lLT zfJWM-ZO%=1jqm`xR=M|O(L@>wo62mpliQoCDwMJvre@O?be%TfGD0Jj0}JiEQ*7rR zOr3}E*X@3DRhn(Pn8jQ}^X|lEQH8eqaoGAt^I}+FJ0mBZ81(vTF2Ii48R@pnLv2Wz zx(1O_fB>sJo!<7+VY^Jwtdoz~9vp4GdEU04#ZXAMooXU zX%m0crY|uu*3#fuTNEFSbQ)iSGMWg%X(=F#!+_4*8D{Q1yL;{P+Rro7(sYK&<(#wM zYp>V0*52XrA3QzI7%y{xgNgqcK(F)^<@5$wo|NhDd0Vw!wh&Kdl*2hz+}1aiv%jNl zNJM}ICB`B#aeyGu8^@C{&m``Fxwqau7N2q3v{AuZF0WOW-`N3n4`ykVLFn+KI1Sz8GzuiiN>mK!0cYceW@v z#10D4@Q9_>##IK%hcIAx=by)M=+rPOL=2TtV-hFg1QGGm9h)$8c0!afQKd|5{$uzb z{o^wpk6K1igvFin@Zj@%(SPg&YSlVgoCrbAI^qNoaorF1;B508${~wc=al*XP+1iN z!7aImLl^WiAv9^$0Y&rFGzMTw+`u(XBI^-&n=BwHUn#xwYB>}E<7$O zmzd2t@d!B#%R%3qL;cGcnAB^!W7g|!?LMBi%-s*hT-?&pxNn&4BICB=X=SEpOk*aM zUFS~UGkrfzyhsW-H!Ne`F35Jqx4^kTL@F1|ux`q@xw5;=tHP1lBtl<{0d*241i6K9 zHNu=ALZ2VDXtB(X02*ghxx^eGLNf^5B0^d%Q-st-bH)tnXNd?)B1}|}KopG&Cf=C~ zM5Isd0HT?)Yv|O}3#Qxn^CAN0M}$LnGq9S1MpKbEZ}z{B4iN5MC>g>Q|yDi70exHqwV$GEnjo;ZSg{ zS}~RcRyyucoMEaX1w<@t)bP~p*JIP_6_!ukaLH2qYJa~()Hi?gN_5sLR+|TUzJ(|L z@>e|i((AH5;vOTxVnAzq_L!{D>H6XFqPp#-2}=Tr?%J^xS6_0GtbY5G6L{gkA)Fnb z!1|RJiU6oXpw&Q%-0)~qF7%a;j^HQz`tX%yi*fIkjaVXrqzAsY8PC3Z5QC>r%Xovk z^fFx$<3@y;_z|v}F^5Am9S}`Y5+UXASQ9&YuE~garf&du{`y5s3DKd@fE4(G&U&28 zpzeuJI(FuB{ORDw*mK}Rba&M7P|s#L)$;kBI5aebdLR-3Gb(68Od^4Ju_h|L|J zn#yuuYnLq&=Cdr51ECPFl2GuN$b9T}k}P=R@F4Df_62-CaSk=1*xNU(6Gk8xM=x5i zT!F63fg1{>9v+S)9G8n685_rY|M*lEUwlC~?%DEnocdxEr-g`(G@BS29g_-ArdOtM zl0@3z@EQE9cOM?y{gV8?Vci-VwqyVqjlr7NZKlk=QkugNxbAx>nXs{+dW=*5>Mft6ATzqn}|x@s{Ij5(ylieQzb zInzL+D;3JU=$B-ktjzEF-p9e?r*PY*t0Wg_p`@Ec!Vv=DuR zN2Q_Y5dLD|pVyqV3l-h{COi5Hmu*>7X=y6zMxB&8-48+T10k$jq`A*Qr2N>CgP3t{JJRY0~qE z>i-`PS z)wS6gN4O)m#k~hL|8FAPnSWhkLiME7Iwy2~&jJyylqoYpU2hhJ8JUaOB%1&+9vrv zz0QUxy1gb?K^`|QN>7TCS{2N3`^YRmKIJ;_;|@HRJXz);{6}|8qJ_3V$ARKi67F#= z9!a4_d<0CVr*!bhy(h8$<7Dm%)C!%eWnUYdPJw$>=DP>95BIrgzB!e4f1zhlD$)Q{Xuu@AX z7)Q;JxUPKI@9iY7_U6A1qoZ8TKxdc^MtLf_+dVVISLY1W+-ni6Dmw}X3cI`~Z~pWziK*P@Dnsd?m(--1v#Bh;=}P!^CY)`BYo zIo}m=Qmv9^t;_=&u3sMDnC7xTZUwEe6tn(sg$>Lke)s*Y00000NkvXXu0mjf0D_%M literal 0 HcmV?d00001 diff --git a/src-tauri/icons/app-icon.png b/src-tauri/icons/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e65f1589bb9589f6104b6a27dedc47af4eedf273 GIT binary patch literal 103835 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 Lo$|q?8B6~MB#rhx literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..8425dccfccf7753442cfb868b396cf100fc24996 GIT binary patch literal 110073 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

R|IinPkZw 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%0DPzUZt-F}?7H5P?&S`hIQK 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

BoGj)W4#9rRD*1HT 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%0DM$0ZRY+9X%`c=n4X8dZ4Y&8OVy3R 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 Y?*H)rJwX2#pZ|-`{}zG&zw_h&1MZMD!~g&Q literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e0502024b42b9f8519c858210f21d3d5a4a67ef4 GIT binary patch literal 23493 zcmbq*2UrtZxAp`=4@D3OAQFn8f)a`p1p_ufP*hMU-T6;JUgn-x~3fwVRUETF_w~Y`qAr2qI z|6!m@3J`Q2+#7NHm_9GJ7 zV$JND*Hm;}<5PDdn=I#!x4-{gj!I2DlAWreDm_K@EOhkE4BPAUJ-eH-gu@!_I+0U# zhF!D)qsuO8D}V`(vI8F|l{#?5R)=8)7dO@<%jzB9DkA*h3sxmR6u#RwPLCa-lBmZ# z3dPo)Au*o-A7?UOR@S__XdE3B8ye5=KVLiZONk(%KOVsed89zI#_Y3KRGRGt&`>ax z!l7dS#hgL8TsY55o|kW=9XXZ!aQB7P57`4QX@r;_T3E@C?ok62+E(BUI|7ZYBf*oh z!&8Gx2P-Oln-p*hau){T`TJiI#`#lTp4^#%1yjLKQa<3wcLq9inz&eO<8uEtQL15Xztra8$D;|Z$;=zH(4Mgz6cs2Q?dW`7?3@Zf0hW;Mq z?<1gOl;9>zR-9g@bTA4Cs#mi_H}f<(de zLWd@HnfuR-m#JAT?aFTOb)?suP7m(h;-gx4zrF8AO_Z1a4UqwtsiLsltuC}Tv3 zvqyzomQM0>^bQc|=9ER;n#B}lrMZ8%qDl&rDJv;t&vZyVdj!(4R-U4M3vCv3C|iYr}k)G_~G&1*Yq)$P^fVUq$I z0e}L_K?p>mo}A&zF7JMw5y(5%Jiu^Mz|bcnzgHawJ;F7ukT-dHZTr4qc|v>};r#*c zWpn@SpCl!YN9IJ?fyYxCkab9v5Cm{)FrMMtVF{w|_yhWA+A3@I;xmp}uQZwz{e4)e z5OM9NWQ_uDLay~S!W9_Kd~IoZelTx@lIZ0te&JWmxYlM4X(T`Zz_O?<3&0>64i|CC z^{;sL&4m$COsMr%BcdXnKBFlE^bgKnZsRv1J4?vT**bmOJAK9k1I#JClB<^q^Ytcu zPh8%m*7L5zlrt_DpoxVQMl;%0(NtD(zJN@v_3v_7Qx-)1{PA;C%j;T&g@42Ho88lz zc?G@M)9(^A7_HN`9<8}r(}sdb@MIK1f$2a@-vPK*3~qe>nr*vH6iW*)(&4SfCD~}( zR^d%~N5vuxL2!dadBA7-ITFw_9N~bWF{(cSjDXQpwe;BaF1Bz1=YWF^KmZB0Ab@Bv z3W7bL0{&q73iAk{7j{)F?CJ5qUO+Q2X#Np1U~h+oM5%0Af6%n@59?t=Vj@CjCCI4g*K^#=LL`6;2d<+_-A| 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fb666bed32faa61319e1075e7592e8b1eda4a38e GIT binary patch literal 21548 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 kv>N9q^l1EzYtg%OaK4? literal 0 HcmV?d00001 diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..b6898ed --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,366 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use std::error::Error; +use std::fs; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::thread; +use std::time::{Duration, Instant}; + +use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; +use tauri::{Manager, Runtime}; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_shell::process::CommandChild; +use tauri_plugin_shell::ShellExt; + +const SERVER_PORT: u16 = 9081; +const SERVER_ADDR: &str = "127.0.0.1:9081"; +const HTTP_ERROR_PREFIX: &str = "CAPAREPORT_HTTP_ERROR:"; +const SERVER_PROCESS_NAME: &str = "capareport-server"; +const SERVER_PID_FILE: &str = "server.pid"; + +struct ServerProcess { + child: CommandChild, + pid_file: PathBuf, +} + +struct ServerState(Mutex>); + +#[derive(serde::Deserialize)] +struct DownloadHeader { + name: String, + value: String, +} + +#[derive(serde::Deserialize)] +struct DownloadFileRequest { + url: String, + method: String, + filename: String, + headers: Vec, + body: Option, +} + +#[derive(serde::Serialize)] +struct DownloadFileResult { + saved: bool, + path: Option, +} + +fn copy_resource( + app: &tauri::App, + name: &str, + data_dir: &Path, +) -> Result<(), Box> { + let target = data_dir.join(name); + if target.exists() { + return Ok(()); + } + + for source in resource_candidates(app, name)? { + if source.exists() { + fs::copy(source, target)?; + return Ok(()); + } + } + + Err(format!("Bundled resource not found: {name}").into()) +} + +fn resource_candidates( + app: &tauri::App, + name: &str, +) -> Result, Box> { + let resource_path = app + .path() + .resolve(name, tauri::path::BaseDirectory::Resource)?; + let escaped_parent_path = app + .path() + .resolve(format!("_up_/{name}"), tauri::path::BaseDirectory::Resource)?; + let exe_dir = std::env::current_exe()? + .parent() + .map(Path::to_path_buf) + .ok_or("Unable to resolve executable directory")?; + + Ok(vec![ + resource_path, + escaped_parent_path, + exe_dir.join(name), + exe_dir.join("_up_").join(name), + ]) +} + +fn start_server(app: &tauri::App) -> Result<(), Box> { + let data_dir = app.path().app_data_dir()?; + fs::create_dir_all(&data_dir)?; + fs::create_dir_all(data_dir.join("cache"))?; + fs::create_dir_all(data_dir.join("logs"))?; + copy_resource(app, "Configure.json", &data_dir)?; + copy_resource(app, "ReportScript.sql", &data_dir)?; + let pid_file = data_dir.join(SERVER_PID_FILE); + stop_recorded_server(&pid_file); + ensure_server_port_available(SERVER_ADDR)?; + + let port_arg = SERVER_PORT.to_string(); + let (mut rx, child) = app + .shell() + .sidecar("capareport-server")? + .env( + "CAPAREPORT_BASE_DIR", + data_dir.to_string_lossy().to_string(), + ) + .args(["--host", "127.0.0.1", "--port", &port_arg]) + .spawn()?; + let pid = child.pid(); + fs::write(&pid_file, pid.to_string())?; + + if let Err(error) = wait_for_server(SERVER_ADDR, Duration::from_secs(60)) { + let _ = child.kill(); + let _ = fs::remove_file(&pid_file); + return Err(error); + } + + tauri::async_runtime::spawn(async move { while rx.recv().await.is_some() {} }); + + let state = app.state::(); + *state.0.lock().expect("server state lock poisoned") = Some(ServerProcess { child, pid_file }); + Ok(()) +} + +fn ensure_server_port_available(addr: &str) -> Result<(), Box> { + let socket_addr: SocketAddr = addr.parse()?; + if TcpStream::connect_timeout(&socket_addr, Duration::from_millis(300)).is_ok() { + return Err(format!("Port {SERVER_PORT} is already in use").into()); + } + Ok(()) +} + +fn wait_for_server(addr: &str, timeout: Duration) -> Result<(), Box> { + let socket_addr: SocketAddr = addr.parse()?; + let started_at = Instant::now(); + while started_at.elapsed() < timeout { + if health_check(&socket_addr).unwrap_or(false) { + return Ok(()); + } + thread::sleep(Duration::from_millis(250)); + } + Err(format!("Server did not start within {} seconds", timeout.as_secs()).into()) +} + +fn health_check(socket_addr: &SocketAddr) -> Result> { + let mut stream = TcpStream::connect_timeout(socket_addr, Duration::from_millis(500))?; + stream.set_read_timeout(Some(Duration::from_millis(500)))?; + stream.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")) +} + +fn stop_recorded_server(pid_file: &Path) { + let Ok(content) = fs::read_to_string(pid_file) else { + return; + }; + + if let Ok(pid) = content.trim().parse::() { + if !terminate_server_process(pid) { + let _ = fs::remove_file(pid_file); + return; + } + wait_for_process_exit(pid, Duration::from_secs(5)); + } + let _ = fs::remove_file(pid_file); +} + +fn terminate_server_process(pid: u32) -> bool { + let mut system = System::new(); + let pid = Pid::from_u32(pid); + system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + + let Some(process) = system.process(pid) else { + return false; + }; + + if !is_server_process_name(process.name()) { + return false; + } + + if process.kill_with(Signal::Kill) != Some(true) { + let _ = process.kill(); + } + true +} + +fn wait_for_process_exit(pid: u32, timeout: Duration) { + let started_at = Instant::now(); + while started_at.elapsed() < timeout { + if !is_process_running(pid) { + return; + } + thread::sleep(Duration::from_millis(100)); + } +} + +fn is_process_running(pid: u32) -> bool { + let mut system = System::new(); + let pid = Pid::from_u32(pid); + system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + system.process(pid).is_some() +} + +fn is_server_process_name(name: &std::ffi::OsStr) -> bool { + let normalized = name.to_string_lossy().to_ascii_lowercase(); + normalized == SERVER_PROCESS_NAME || normalized == format!("{SERVER_PROCESS_NAME}.exe") +} + +fn stop_server(app: &tauri::AppHandle) { + let state = app.state::(); + let child = state.0.lock().expect("server state lock poisoned").take(); + if let Some(process) = child { + let _ = process.child.kill(); + let _ = fs::remove_file(process.pid_file); + } +} + +#[tauri::command] +async fn download_to_file( + app: tauri::AppHandle, + window: tauri::Window, + request: DownloadFileRequest, +) -> Result { + let filename = safe_download_filename(&request.filename); + let mut dialog = app + .dialog() + .file() + .set_file_name(&filename) + .set_parent(&window); + + if let Some(extension) = Path::new(&filename) + .extension() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + { + dialog = dialog.add_filter(extension.to_ascii_uppercase(), &[extension]); + } + + let Some(file_path) = dialog + .blocking_save_file() + .map(|file_path| file_path.into_path().map_err(|error| error.to_string())) + .transpose()? + else { + return Ok(DownloadFileResult { + saved: false, + path: None, + }); + }; + + if let Err(error) = write_download(request, &file_path).await { + let _ = fs::remove_file(&file_path); + return Err(error); + } + + Ok(DownloadFileResult { + saved: true, + path: Some(file_path.to_string_lossy().to_string()), + }) +} + +#[tauri::command] +fn open_path_in_file_manager(path: String) -> Result<(), String> { + let target = PathBuf::from(path); + let directory = if target.is_dir() { + target + } else { + target + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "Unable to resolve download directory".to_string())? + }; + + open::that(directory).map_err(|error| format!("Unable to open download directory: {error}")) +} + +async fn write_download(request: DownloadFileRequest, file_path: &Path) -> Result<(), String> { + let method = reqwest::Method::from_bytes(request.method.as_bytes()) + .map_err(|error| format!("Invalid download method: {error}"))?; + let client = reqwest::Client::new(); + let mut builder = client.request(method, request.url); + + for header in request.headers { + let name = reqwest::header::HeaderName::from_bytes(header.name.as_bytes()) + .map_err(|error| format!("Invalid download header: {error}"))?; + let value = reqwest::header::HeaderValue::from_str(&header.value) + .map_err(|error| format!("Invalid download header value: {error}"))?; + builder = builder.header(name, value); + } + + if let Some(body) = request.body { + builder = builder.body(body); + } + + let mut response = builder + .send() + .await + .map_err(|error| format!("Download request failed: {error}"))?; + let status = response.status(); + if !status.is_success() { + let body = response + .bytes() + .await + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .unwrap_or_default(); + return Err(format!("{HTTP_ERROR_PREFIX}{}:{body}", status.as_u16())); + } + + let mut file = fs::File::create(file_path) + .map_err(|error| format!("Unable to create download file: {error}"))?; + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("Unable to read download stream: {error}"))? + { + file.write_all(&chunk) + .map_err(|error| format!("Unable to write download file: {error}"))?; + } + file.flush() + .map_err(|error| format!("Unable to flush download file: {error}"))?; + Ok(()) +} + +fn safe_download_filename(filename: &str) -> String { + let trimmed = filename.trim(); + if trimmed.is_empty() { + return "download.bin".to_string(); + } + + Path::new(trimmed) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("download.bin") + .to_string() +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .invoke_handler(tauri::generate_handler![ + download_to_file, + open_path_in_file_manager + ]) + .manage(ServerState(Mutex::new(None))) + .setup(|app| { + start_server(app)?; + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + stop_server(window.app_handle()); + } + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..d273927 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "CapacityReport", + "version": "3.0.0", + "identifier": "com.nixevol.capacityreport", + "build": { + "frontendDist": "../frontend/dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "CapacityReport", + "width": 1280, + "height": 820, + "minWidth": 1100, + "minHeight": 720 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": [ + "nsis" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "externalBin": [ + "binaries/capareport-server" + ], + "resources": [ + "../Configure.json", + "../ReportScript.sql" + ], + "windows": { + "webviewInstallMode": { + "type": "offlineInstaller", + "silent": true + }, + "wix": { + "language": "zh-CN" + }, + "nsis": { + "installerHooks": "./windows/nsis-hooks.nsh", + "installMode": "perMachine", + "languages": [ + "SimpChinese" + ] + } + } + } +} diff --git a/src-tauri/windows/nsis-hooks.nsh b/src-tauri/windows/nsis-hooks.nsh new file mode 100644 index 0000000..2c15c9a --- /dev/null +++ b/src-tauri/windows/nsis-hooks.nsh @@ -0,0 +1,34 @@ +!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 + nsExec::ExecToLog 'taskkill /F /T /IM capacity-report-desktop.exe' + nsExec::ExecToLog 'taskkill /F /T /IM capareport-server.exe' +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + MessageBox MB_YESNO|MB_ICONQUESTION "是否同时删除本机用户数据?" IDYES DeleteUserData IDNO Done + DeleteUserData: + RMDir /r "$APPDATA\com.nixevol.capacityreport" + Done: +!macroend diff --git a/tests/test_database_import.py b/tests/test_database_import.py new file mode 100644 index 0000000..0e61dda --- /dev/null +++ b/tests/test_database_import.py @@ -0,0 +1,33 @@ +import unittest + +from app.database import _deduplicate_rows_by_key + + +class SectorImportTests(unittest.TestCase): + def test_duplicate_cgi_uses_last_uploaded_row(self): + columns = ["CGI", "扇区", "物理站"] + rows = [ + ("460-00-1-1", "旧扇区", "旧物理站"), + ("460-00-2-1", "新增扇区", "新增物理站"), + ("460-00-1-1", "新扇区", "新物理站"), + ] + + result, duplicate_count = _deduplicate_rows_by_key(columns, rows, "CGI") + + self.assertEqual(duplicate_count, 1) + self.assertEqual(result, [ + ("460-00-1-1", "新扇区", "新物理站"), + ("460-00-2-1", "新增扇区", "新增物理站"), + ]) + + def test_blank_cgi_is_rejected(self): + with self.assertRaisesRegex(ValueError, "第 2 行 CGI 为空"): + _deduplicate_rows_by_key(["CGI", "扇区"], [(" ", "扇区1")], "CGI") + + def test_missing_cgi_column_is_rejected(self): + with self.assertRaisesRegex(ValueError, "缺少业务键字段: CGI"): + _deduplicate_rows_by_key(["扇区"], [("扇区1",)], "CGI") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_database_import_mysql.py b/tests/test_database_import_mysql.py new file mode 100644 index 0000000..ad52ea3 --- /dev/null +++ b/tests/test_database_import_mysql.py @@ -0,0 +1,232 @@ +import csv +import os +import tempfile +import unittest +from pathlib import Path + +import pymysql +from pymysql.constants import CLIENT + +from app.config import AppConfig, MySQLConfig +from app.database import DatabaseManager + + +MYSQL_HOST = os.environ.get("CAPACITYREPORT_TEST_MYSQL_HOST") +MYSQL_PORT = int(os.environ.get("CAPACITYREPORT_TEST_MYSQL_PORT", "3306")) +MYSQL_USER = os.environ.get("CAPACITYREPORT_TEST_MYSQL_USER", "root") +MYSQL_PASSWORD = os.environ.get("CAPACITYREPORT_TEST_MYSQL_PASSWORD", "") +TEST_DATABASE = "capacityreport_sector_import_test" + + +@unittest.skipUnless(MYSQL_HOST, "未配置 CapacityReport 测试 MySQL") +class SectorImportMySQLTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.admin_connection = pymysql.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + charset="utf8mb4", + autocommit=True, + ) + with cls.admin_connection.cursor() as cursor: + cursor.execute(f"DROP DATABASE IF EXISTS `{TEST_DATABASE}`") + cursor.execute( + f"CREATE DATABASE `{TEST_DATABASE}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + config = MySQLConfig( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + passwd=MYSQL_PASSWORD, + dbname=TEST_DATABASE, + ) + cls.manager = DatabaseManager(AppConfig(mysql=config), config) + + @classmethod + def tearDownClass(cls): + with cls.admin_connection.cursor() as cursor: + cursor.execute(f"DROP DATABASE IF EXISTS `{TEST_DATABASE}`") + cls.admin_connection.close() + + def setUp(self): + self.connection = pymysql.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + database=TEST_DATABASE, + charset="utf8mb4", + autocommit=True, + ) + + def tearDown(self): + self.connection.close() + + def _write_csv(self, columns, rows): + handle = tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8-sig", + newline="", + suffix=".csv", + delete=False, + ) + try: + writer = csv.writer(handle) + writer.writerow(columns) + writer.writerows(rows) + return Path(handle.name) + finally: + handle.close() + + def test_upsert_updates_inserts_and_removes_historical_duplicates(self): + with self.connection.cursor() as cursor: + cursor.execute("DROP TABLE IF EXISTS `sector_upsert_test`") + cursor.execute( + "CREATE TABLE `sector_upsert_test` (" + "`CGI` varchar(120), `扇区` varchar(200), `物理站` varchar(200)) " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + cursor.executemany( + "INSERT INTO `sector_upsert_test` VALUES (%s, %s, %s)", + [ + ("460-00-1-1", "旧扇区1", "旧站1"), + ("460-00-1-1", "旧扇区2", "旧站2"), + ("460-00-9-9", "保留扇区", "保留站"), + ], + ) + + csv_path = self._write_csv( + ["CGI", "扇区", "物理站"], + [ + ("460-00-1-1", "更新扇区", "更新站"), + ("460-00-2-2", "新增扇区", "新增站"), + ], + ) + try: + stats = self.manager.upsert_csv( + str(csv_path), "sector_upsert_test", "CGI" + ) + finally: + csv_path.unlink(missing_ok=True) + + self.assertEqual( + stats, + { + "imported_rows": 2, + "inserted_rows": 1, + "updated_rows": 1, + "removed_duplicate_rows": 1, + "input_duplicate_rows": 0, + }, + ) + with self.connection.cursor() as cursor: + cursor.execute( + "SELECT `CGI`, `扇区`, `物理站` FROM `sector_upsert_test` ORDER BY `CGI`" + ) + self.assertEqual( + cursor.fetchall(), + ( + ("460-00-1-1", "更新扇区", "更新站"), + ("460-00-2-2", "新增扇区", "新增站"), + ("460-00-9-9", "保留扇区", "保留站"), + ), + ) + + def test_upsert_rolls_back_deletion_when_insert_fails(self): + with self.connection.cursor() as cursor: + cursor.execute("DROP TABLE IF EXISTS `sector_rollback_test`") + cursor.execute( + "CREATE TABLE `sector_rollback_test` (" + "`CGI` varchar(120), `扇区编号` int NOT NULL)" + ) + cursor.execute( + "INSERT INTO `sector_rollback_test` VALUES (%s, %s)", + ("460-00-1-1", 7), + ) + + csv_path = self._write_csv( + ["CGI", "扇区编号"], [("460-00-1-1", "not-an-integer")] + ) + try: + with self.assertRaises((pymysql.DataError, pymysql.IntegrityError)): + self.manager.upsert_csv( + str(csv_path), "sector_rollback_test", "CGI" + ) + finally: + csv_path.unlink(missing_ok=True) + + with self.connection.cursor() as cursor: + cursor.execute("SELECT `CGI`, `扇区编号` FROM `sector_rollback_test`") + self.assertEqual(cursor.fetchall(), (("460-00-1-1", 7),)) + + def test_celldata_script_handles_mixed_collations(self): + with self.connection.cursor() as cursor: + cursor.execute("DROP TABLE IF EXISTS `cellinfo`") + cursor.execute("DROP TABLE IF EXISTS `sector`") + cursor.execute("DROP TABLE IF EXISTS `sector_band_ref`") + cursor.execute( + "CREATE TABLE `cellinfo` (" + "`CGI` varchar(120), `PLMN` varchar(100), `小区名称` varchar(200), " + "`频点` varchar(50), `带宽` varchar(20), `制式` varchar(50), " + "`网络` varchar(20)) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + cursor.execute( + "CREATE TABLE `sector` (" + "`CGI` varchar(120), `扇区` varchar(200), `物理站` varchar(200), " + "`制式` varchar(50), `频段` varchar(50), `带宽` varchar(20), " + "`站型` varchar(50), `网络` varchar(20), UNIQUE KEY (`CGI`)) " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + cursor.execute( + "CREATE TABLE `sector_band_ref` (" + "`网络` varchar(8), `频点下限` decimal(10,2), `频点上限` decimal(10,2), " + "`PLMN` varchar(16), `制式` varchar(20), `频段` varchar(20)) " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci" + ) + cursor.execute( + "INSERT INTO `cellinfo` VALUES " + "('460-00-122116-32','460-00','江门恩平敬老院F-ZLH-102'," + "'1909.4','20','TDD','4G')" + ) + cursor.execute( + "INSERT INTO `sector_band_ref` VALUES " + "('4G',1880,1920,NULL,'TDD','F频')" + ) + + script = (Path(__file__).parents[1] / "CellData.sql").read_text( + encoding="utf-8-sig" + ) + script_connection = pymysql.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + database=TEST_DATABASE, + charset="utf8mb4", + autocommit=True, + client_flag=CLIENT.MULTI_STATEMENTS, + ) + try: + with script_connection.cursor() as cursor: + cursor.execute(script) + while cursor.nextset(): + pass + finally: + script_connection.close() + + with self.connection.cursor() as cursor: + cursor.execute( + "SELECT `扇区`, `物理站`, `制式`, `频段` FROM `sector` " + "WHERE `CGI` = '460-00-122116-32'" + ) + self.assertEqual( + cursor.fetchone(), + ("江门恩平敬老院2", "江门恩平敬老院", "TDD", "F频"), + ) + + +if __name__ == "__main__": + unittest.main()