Commit 4ece8b72 authored by wangteng's avatar wangteng

首页 分析 功能上线

parent b0550425
2026/09/29-09:38:47.176 8f7c Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Extension Rules since it was missing.
2026/09/29-09:38:47.209 8f7c Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Extension Rules/MANIFEST-000001
2026/09/29-09:38:47.212 8f7c Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Extension Scripts since it was missing.
2026/09/29-09:38:47.242 8f7c Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Extension Scripts/MANIFEST-000001
2026/09/29-09:38:47.366 387c Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Extension State since it was missing.
2026/09/29-09:38:47.386 387c Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Extension State/MANIFEST-000001
2026/09/29-09:38:50.741 4c80 Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\GCM Store\Encryption since it was missing.
2026/09/29-09:38:50.758 4c80 Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\GCM Store\Encryption/MANIFEST-000001
2026/09/29-09:38:50.763 4c80 Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\GCM Store since it was missing.
2026/09/29-09:38:50.776 4c80 Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\GCM Store/MANIFEST-000001
2026/09/29-09:38:47.223 216c Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Local Storage\leveldb since it was missing.
2026/09/29-09:38:47.305 216c Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Local Storage\leveldb/MANIFEST-000001
2026/09/29-09:38:47.351 3978 Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Service Worker\Database since it was missing.
2026/09/29-09:38:47.379 3978 Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Service Worker\Database/MANIFEST-000001
2026/09/29-09:38:47.281 2f20 Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Session Storage since it was missing.
2026/09/29-09:38:47.340 2f20 Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Session Storage/MANIFEST-000001
2026/09/29-09:38:47.151 24d4 Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Site Characteristics Database since it was missing.
2026/09/29-09:38:47.187 24d4 Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Site Characteristics Database/MANIFEST-000001
2026/09/29-09:38:47.128 8f7c Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Sync Data\LevelDB since it was missing.
2026/09/29-09:38:47.156 8f7c Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\Sync Data\LevelDB/MANIFEST-000001
2026/09/29-09:38:47.321 7c18 Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\shared_proto_db since it was missing.
2026/09/29-09:38:47.345 7c18 Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\shared_proto_db/MANIFEST-000001
2026/09/29-09:38:47.279 37d4 Creating DB C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\shared_proto_db\metadata since it was missing.
2026/09/29-09:38:47.317 37d4 Reusing MANIFEST C:\Users\cdkj\WEB\db-tools\.chrome-cdp-20260929\Default\shared_proto_db\metadata/MANIFEST-000001
{"autofill":{"ablation_seed":"x5Jwb1OzUwQ="},"breadcrumbs":{"enabled":false,"enabled_time":"13435119527101803"},"browser":{"shortcut_migration_version":"153.0.8010.53","whats_new":{"enabled_order":["VerticalTabsLaunch"]}},"enterprise_reporting":{"saas_usage":{"last_trigger_time":"13435119530383263"}},"hardware_acceleration_mode_previous":true,"legacy":{"profile":{"name":{"migrated":true}}},"local":{"password_hash_data_list":[]},"management":{"platform":{"azure_active_directory":0,"azure_active_directory_device":0,"enterprise_mdm_win":0}},"network_time":{"network_time_mapping":{"local":1.790654927479557e+12,"network":1.790654927e+12,"ticks":498678158828.0,"uncertainty":1461828.0}},"optimization_guide":{"model_store_metadata":{},"on_device":{"last_version":"153.0.8010.53","model_crash_count":0,"performance_class":6,"performance_class_gpu_id":"10de:28e0:D3D12 driver version 32.0.15.6626","performance_class_version":"153.0.8010.53","vram_mb":"7957"},"predictionmodelfetcher":{"last_fetch_attempt":"13435128504785849"}},"os_crypt":{"audit_enabled":true,"encrypted_key":"RFBBUEkBAAAA0Iyd3wEV0RGMegDAT8KX6wEAAADq3VJ/Ht2EQLRdMoHevhfNEAAAABwAAABHAG8AbwBnAGwAZQAgAEMAaAByAG8AbQBlAAAAEGYAAAABAAAgAAAAN20eb+rUguV4sjmiIKCy0ZerpUX3+fJAWyvRbn0kK8cAAAAADoAAAAACAAAgAAAAPFCNhqy7EzF6ZxB2zV3bBK/Nu6gql/1BCYbCMXqFsI0wAAAAC//fKHc9jJCtiLLmgA9pAWbs7Z5eU1HXEEJEeQtW+wjoqFUqmo5lIVyGmKCuIavaQAAAAM91KSB0+pVFpEmQdIfOHAtRrkiJPFtjxmyVX0u9VbFwZzP/q+RI77OSDR/zTNslPFGqi8st/k2w4fXgWVOLz30="},"os_update_handler_enabled":true,"password_manager":{"is_biometric_avaliable":false,"os_password_blank":false,"os_password_last_changed":"13376741566974143"},"performance_intervention":{"last_daily_sample":"13435119527240483"},"platform_experience_helper":{"last_sync_time":"13435119527240723"},"policy":{"last_statistics_update":"13435119527101260"},"profile":{"info_cache":{"Default":{"active_time":1790645927.23958,"avatar_icon":"chrome://theme/IDR_PROFILE_AVATAR_26","background_apps":false,"default_avatar_fill_color":-2890755,"default_avatar_stroke_color":-16166200,"force_signin_profile_locked":false,"gaia_id":"","is_consented_primary_account":false,"is_ephemeral":false,"is_glic_eligible":false,"is_using_default_avatar":true,"is_using_default_name":true,"managed_user_id":"","metrics_bucket_index":1,"name":"您的 Chrome","profile_color_seed":-16033840,"profile_highlight_color":-2890755,"signin.with_credential_provider":false,"user_name":""}},"last_active_profiles":["Default"],"metrics":{"next_bucket_index":2},"profile_counts_reported":"13435119527103727","profiles_order":["Default"]},"session_id_generator_last_value":"124213042","signin":{"active_accounts_last_emitted":"13435119526946427"},"subresource_filter":{"ruleset_version":{"checksum":0,"content":"","format":0}},"tab_stats":{"discards_external":0,"discards_frozen":0,"discards_proactive":0,"discards_suggested":0,"discards_urgent":0,"last_daily_sample":"13435119527089019","max_tabs_per_window":2,"reloads_external":0,"reloads_frozen":0,"reloads_proactive":0,"reloads_suggested":0,"reloads_urgent":0,"total_tab_count_max":2,"window_count_max":1},"toast":{"non_milestone_update_toast_version":"153.0.8010.53"},"ukm":{"persisted_logs":[]},"uninstall_metrics":{"installation_date2":"1732251160"},"updateclientdata":{"apps":{"bjbcblmdcnggnibecjikpoljcgkbgphl":{"cohort":"1:2t4f:","cohortname":"Stable","dlrc":7210,"installdate":7210,"pf":"110884e2-21f7-43ca-95f9-d47d86ee0eaa"},"ceofaddefefcbblgcgnibnonglccbfja":{"cohort":"1:3mcl:","cohortname":"Auto","dlrc":7210,"fp":"","installdate":7210,"max_pv":"0.0.0.0","pf":"7ab66c08-71a6-4e4d-bc36-10288195f2bc","pv":"1.20260927.2"},"efniojlnjndmcbiieegkicadnoecjjef":{"cohort":"1:18ql:","cohortname":"Auto Stage3","dlrc":7210,"installdate":7210,"pf":"abdfdc1f-2469-4c8f-b2a1-00bc3395b93a"},"gcmjkmgdlgnkkcocmoeiminaijmmjnii":{"cohort":"1:bm1:","cohortname":"Stable","dlrc":7210,"installdate":7210,"pf":"d018392a-268d-41de-bc7e-9892a40cd65d"},"ggkkehgbnfjpeggfpleeakpidbkibbmn":{"cohort":"1:ut9/1a0f:3uz9@0.025","cohortname":"M108 and Above","dlrc":7210,"installdate":7210,"pf":"317af3df-9af0-49f4-9cbf-b1b5f20f34ef"},"giekcmmlnklenlaomppkphknjmnnpneh":{"cohort":"1:j5l:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"63808bc1-25cb-4ed1-b36a-d10ea5086ab8"},"gonpemdgkjcecdgbnaabipppbmgfggbe":{"cohort":"1:z1x:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"7e87dd04-f8a7-4128-9753-5759086e7a87"},"hajigopbbjhghbfimgkfmpenfkclmohk":{"cohort":"1:2tdl:","cohortname":"Stable","dlrc":7210,"installdate":7210,"pf":"c11838c8-b510-4da3-902d-163cb203c2c1"},"hfnkpimlhhgieaddgfemjhofmfblmnib":{"cohort":"1:287f:","cohortname":"Auto full","dlrc":7210,"installdate":7210,"pf":"6bd4b5b3-8fa4-46a7-b641-9e45479a2045"},"ihnlcenocehgdaegdmhbidjhnhdchfmm":{"cohort":"1::","cohortname":"","dlrc":7210,"installdate":7210,"pf":"500b8fd0-cd2f-454b-abf3-64ba84738f79"},"jamhcnnkihinmdlkakkaopbjbbcngflc":{"cohort":"1:wvr:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"2d65ef3c-5130-48e0-8e61-b277bad47048"},"jflookgnkcckhobaglndicnbbgbonegd":{"cohort":"1:s7x:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"7b79dc56-d30d-4496-a230-343d55312914"},"khaoiebndkojlmppeemjhbpbandiljpe":{"cohort":"1:cux:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"9173ffe5-731c-45a2-b39a-49fda945f652"},"kiabhabjdbkjdpjbpigfodbdjmbglcoo":{"cohort":"1:v3l:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"13642e8e-baa5-43e1-96ea-d592d2aeb14d"},"laoigpblnllgcgjnjnllmfolckpjlhki":{"cohort":"1:10zr:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"bf50bf09-01a7-401f-8798-1a4cd6cccd30"},"llkgjffcdpffmhiakmfcdcblohccpfmo":{"cohort":"1::","cohortname":"","dlrc":7210,"installdate":7210,"pf":"9b70ba98-6fff-4a00-826a-ae5579fe0e34"},"lmelglejhemejginpboagddgdfbepgmp":{"cohort":"1:lwl:3m9f@0.0,3ux3@0.5","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"8bac27bd-dd8f-4d8b-bfde-6aa0cd9a41e2"},"niikhdgajlphfehepabhhblakbdgeefj":{"cohort":"1:1uh3:","cohortname":"Auto Main Cohort.","dlrc":7210,"installdate":7210,"pf":"e29dc4f5-05ca-4987-9b0a-29d8b13d6ac7"},"ninodabcejpeglfjbkhdplaoglpcbffj":{"cohort":"1:3bsf:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"06affed3-8769-464c-b1ef-67098e2ba062"},"obedbbhbpmojnkanicioggnmelmoomoc":{"cohort":"1:s6f:3cr3@0.025","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"d4613e4e-f46f-4b9b-8f7e-50d6ba43bd34"},"oimompecagnajdejgnnjijobebaeigek":{"cohort":"1:3cjr:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"d7c3f9d9-07a3-4000-9d8d-32f3b7f91def"},"ojhpjlocmbogdgmfpkhlaaeamibhnphh":{"cohort":"1:w0x:","cohortname":"All users","dlrc":7210,"installdate":7210,"pf":"fc4c32d4-2e57-4389-8495-c100902fe400"},"pficcddpglkpaaihklmahepgjmefdnom":{"cohort":"1:3exl:","cohortname":"Auto","dlrc":7210,"installdate":7210,"pf":"f8d32bc8-3f5f-4990-b767-0a2f16949ad5"}}},"user_experience_metrics":{"app_drive_has_seek_penalty":false,"client_id2":"8d8c6464-dd91-402f-aad7-a7eac276bc15","client_id_timestamp":"1732251160","initial_logs2":[],"last_seen":{"CrashpadMetrics":"0"},"limited_entropy_randomization_source":"FEE2D27F954AAF99927FD523FDDADC93","log_finalized_record_id":3,"log_record_id":4,"low_entropy_source3":2573,"machine_id":3974586,"ongoing_logs2":[],"pseudo_low_entropy_source":891,"session_id":0,"stability":{"browser_last_live_timestamp":"13435128527144576","saved_system_profile":"CMjHrdUGEhAxNTMuMC44MDEwLjUzLTY0GMCDgLoGIgV6aC1DTioYCgpXaW5kb3dzIE5UEgoxMC4wLjI2MjAwMoQDCgZ4ODZfNjQQ+nkYgICs4u3/HyIjQVNVUyBUVUYgR2FtaW5nIEExNSBGQTUwN05WX0ZBNTA3TlYoATCgBjjYBEKhAQj//wMQ//8DGg8xMC4wLjI2MTAwLjk1NDkyF0dvb2dsZSBJbmMuIChNaWNyb3NvZnQpOm1BTkdMRSAoTWljcm9zb2Z0LCBNaWNyb3NvZnQgQmFzaWMgUmVuZGVyIERyaXZlciAoMHgwMDAwMDA4QykgRGlyZWN0M0QxMSB2c181XzAgcHNfNV8wLCBEM0QxMS0xMC4wLjI2MTAwLjk1NDkpTXfEDUNV7mYNQ2UAAIA/ahkKDEF1dGhlbnRpY0FNRBDBnpAFGBAgACgAggECCACKAQIIAKoBBng4Nl82NLABAcoBXQoVQVNVU1RlSyBDT01QVVRFUiBJTkMuEgdGQTUwN05WGidBbWVyaWNhbiBNZWdhdHJlbmRzIEludGVybmF0aW9uYWwsIExMQy4iEF9BU1VTXyAtIDEwNzIwMDkoAkoKDW0jOl4VZMQ7YEoKDUGQ8rYVkLr1mkoKDZK3V7MVMK7y3EoKDQUO8PQVgI19ykoKDfV2WaMV6h098koKDZjOgZQV9PRHPUoKDQNsQioV9PRHPUoKDRiFZ3AVqG/m3koKDTSHM74VqG/m3koKDakHmV8VqG/m3koKDZrL7I4VqG/m3koKDYNWRisVqG/m3koKDSZ5/FIVaWE97koKDR02m7wVqG/m3koKDYhxGqQVqG/m3koKDdy/cf8VqG/m3koKDQzdWSEVqG/m3koKDdV5zOcVqG/m3koKDUVVk0sV9PRHPUoKDeO6OJoVp8hGYEoKDeEErUEVQF8G5EoKDaNDHi0V9PRHPUoKDRTOCjcV3xdKP0oKDWfCbTgV9PRHPUoKDX2WndYVbu9mSEoKDaF1jzwVLMHzbUoKDTVrQKQV1uJXFkoKDUahjUAV1uJXFkoKDWoQcq8VogJIvkoKDZj2g6kVQHrAPVAEWgIIAWoICAAQAjgGQAaAAcCDgLoGkgEG4QHkAdcDmAEAwgEVCDASBzAuMC4wLjAdAAAAACWGs9h8wgEVCAsSBzAuMC4wLjAdAAAAACWotGVvwgEVCB8SBzAuMC4wLjAdAAAAACX8LwCOwgEVCAgSBzAuMC4wLjAdAAAAACU7ykkcwgEVCCgSBzAuMC4wLjAdAAAAACUJY3G/wgEVCEISBzAuMC4wLjAdAAAAACW2n7DgwgEVCAoSBzAuMC4wLjAdAAAAACWkB8diwgEVCDISBzAuMC4wLjAdAAAAACXb7aAAwgEVCCkSBzAuMC4wLjAdAAAAACV8PydZwgEVCDMSBzAuMC4wLjAdAAAAACVvNcf0wgEVCAISBzAuMC4wLjAdAAAAACVV9L/6wgEVCD4SBzAuMC4wLjAdAAAAACV2OI3mwgEeCC8SEDEuMC43LjE2NTI5MDY4MjMdAAAAACWCu7X1wgEVCAMSBzAuMC4wLjAdAAAAACXb7aAAwgEVCBISBzAuMC4wLjAdAAAAACURqxD7wgEVCBkSBzAuMC4wLjAdAAAAACW+LgUlwgEZCA0SCzQuMTAuMzExMi4wHQAAAAAl1BbOAsIBFQgbEgcwLjAuMC4wHQAAAAAl48o0tsoBIAgBEAUYAyAAKAAwADgAQABQAFgAYABoA3gAgAEAiAEAygEgCAEQBRgCIAAoADAAOABAAFAAWABgAGgAeACAAQCIAQDKASAIARAFGAIgACgAMAA4AEAAUABYAGAAaAJ4AIABAIgBAPgBjRSAAv///////////wGIAgGSAiQ4ZDhjNjQ2NC1kZDkxLTQwMmYtYWFkNy1hN2VhYzI3NmJjMTWoAvsGsgIgEAA8kzdEROo5C92iJ0SDSV8OC8DoElc2TPMNXXu8DQPxAnje3qKDpgxo+gIvDQBETUESCDYuMjQuMC42IhBBTUQgICAgICAgICAgICAgMgwyLjAsIDAsIDEuNTk=","saved_system_profile_hash":"887CAABCAC033B52FD68E9EF5B250F98387F9C0A","stats_buildtime":"1789617096","stats_version":"153.0.8010.53-64","system_crash_count":0},"unsent_log_metadata":{"initial_logs":{"sent_samples_count":0,"unsent_persisted_size_in_kb":0,"unsent_samples_count":0},"ongoing_logs":{"sent_samples_count":418007,"unsent_persisted_size_in_kb":0,"unsent_samples_count":0}},"user_data_drive_has_seek_penalty":false},"variations_country":"cn","variations_crash_streak":0,"variations_failed_to_fetch_seed_streak":0,"variations_geo_level1":"","variations_google_groups":{"Default":[]},"variations_last_fetch_time":"13435128527654522","variations_permanent_consistency_country":["153.0.8010.53","cn"],"variations_seed_date":"13435128527000000","variations_seed_serial_number":"SMChYyMDI2MDkyNy0wMzAwNDUuNDY5MDAwEgsIABADGJkBIAAoAQ==#Mg6I3bEiJmM=","was":{"restarted":false}}
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
㵀 F :
"001(0kB"
fnSQQ(<OZ>L%l
*mb" 32_13435126995893286 "4_13435126995892998 
\ No newline at end of file
㵀 G :
"001(0l~4B"
$P2B=3x||Ka41-Q"4_13435126995900091 
\ No newline at end of file
㵀 G :
"001(0!iCB"
|i)=wh:S`*gg:LGp"4_13435126995911308 
\ No newline at end of file
㵀 G :
 "001(0+B"
~i ENI}Clqe"4_13435126995887324 
\ No newline at end of file
㵀 G :
"001(0B"
z%x"1WԥL 7`ag"4_13435126995903466 
\ No newline at end of file
"""web 后端 · FastAPI 入口""" """web 后端 · FastAPI 入口"""
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
import sys import sys
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parent BACKEND_DIR = Path(__file__).resolve().parent
...@@ -37,10 +39,20 @@ init_db() ...@@ -37,10 +39,20 @@ init_db()
logger.info("DB: %s", engine.url.render_as_string(hide_password=True)) logger.info("DB: %s", engine.url.render_as_string(hide_password=True))
# 2026-09-29:新版 FastAPI 已移除 app.add_event_handler,改用 lifespan 挂调度器启停;
# start_scheduler/stop_scheduler 由下方路由 import 绑定到模块全局,lifespan 执行时已就绪
@asynccontextmanager
async def _lifespan(_: FastAPI):
await asyncio.to_thread(start_scheduler)
yield
stop_scheduler()
app = FastAPI( app = FastAPI(
title="数据治理 Web 工具 · web 后端", title="数据治理 Web 工具 · web 后端",
description="数据质量检测(任务 / 字段 / 规则 / 不合规)。前端在 web/,由 Vite 5175 提供。", description="数据质量检测(任务 / 字段 / 规则 / 不合规)。前端在 web/,由 Vite 5175 提供。",
version="0.1.0", version="0.1.0",
lifespan=_lifespan,
) )
app.add_middleware( app.add_middleware(
...@@ -67,6 +79,7 @@ from web.backend.routers.rules import router as rules_router # noqa: E402 ...@@ -67,6 +79,7 @@ from web.backend.routers.rules import router as rules_router # noqa: E402
from web.backend.routers.table_design import router as table_design_router # noqa: E402 from web.backend.routers.table_design import router as table_design_router # noqa: E402
from web.backend.routers.llm_config import router as llm_config_router # noqa: E402 from web.backend.routers.llm_config import router as llm_config_router # noqa: E402
from web.backend.routers.table_prefs import router as table_prefs_router # noqa: E402 from web.backend.routers.table_prefs import router as table_prefs_router # noqa: E402
from web.backend.routers.analysis import router as analysis_router, start_scheduler, stop_scheduler # noqa: E402
app.include_router(task_groups_router, prefix="/api") app.include_router(task_groups_router, prefix="/api")
app.include_router(tasks_router, prefix="/api") app.include_router(tasks_router, prefix="/api")
app.include_router(db_router, prefix="/api") app.include_router(db_router, prefix="/api")
...@@ -79,6 +92,7 @@ app.include_router(rules_router, prefix="/api") ...@@ -79,6 +92,7 @@ app.include_router(rules_router, prefix="/api")
app.include_router(table_design_router, prefix="/api") app.include_router(table_design_router, prefix="/api")
app.include_router(llm_config_router, prefix="/api") app.include_router(llm_config_router, prefix="/api")
app.include_router(table_prefs_router, prefix="/api") app.include_router(table_prefs_router, prefix="/api")
app.include_router(analysis_router, prefix="/api")
logger.info("已注册路由:/api/task-groups, /api/tasks, /api/data-sources, /api/connect/*, /api/connection-presets, /api/ai/*, /api/queries/*") logger.info("已注册路由:/api/task-groups, /api/tasks, /api/data-sources, /api/connect/*, /api/connection-presets, /api/ai/*, /api/queries/*")
......
...@@ -50,6 +50,31 @@ SCHEMA_COMMENTS: Dict[str, dict] = { ...@@ -50,6 +50,31 @@ SCHEMA_COMMENTS: Dict[str, dict] = {
"table": "表设计校验配置", "table": "表设计校验配置",
"columns": {"id": "主键", "name": "配置名称", "source_id": "数据源ID", "schema_name": "Schema名称", "scope": "校验范围", "tables_json": "指定数据表JSON", "check_table_comment": "是否校验表注释", "check_column_comment": "是否校验字段注释", "checked_at": "最近校验时间", "last_result_json": "最近校验结果JSON", "created_at": "创建时间", "updated_at": "更新时间"}, "columns": {"id": "主键", "name": "配置名称", "source_id": "数据源ID", "schema_name": "Schema名称", "scope": "校验范围", "tables_json": "指定数据表JSON", "check_table_comment": "是否校验表注释", "check_column_comment": "是否校验字段注释", "checked_at": "最近校验时间", "last_result_json": "最近校验结果JSON", "created_at": "创建时间", "updated_at": "更新时间"},
}, },
"analysis_task": {
"table": "数据分析任务",
"columns": {
"id": "主键", "name": "分析任务名称", "source_id": "数据源ID",
"schema_name": "数据库/Schema名称", "cron_expression": "Cron执行表达式",
"enabled": "是否启用", "created_at": "创建时间",
},
},
"analysis_run": {
"table": "数据分析执行记录",
"columns": {
"id": "主键", "task_id": "分析任务ID", "source_name": "数据源名称",
"schema_name": "数据库/Schema名称", "trigger_type": "触发方式",
"status": "执行状态", "error": "错误信息", "started_at": "开始时间",
"finished_at": "完成时间",
},
},
"analysis_table_snapshot": {
"table": "数据分析表快照",
"columns": {
"id": "主键", "run_id": "分析执行记录ID", "table_name": "数据表名称",
"table_comment": "数据表注释", "row_count": "数据行数",
"columns_json": "字段结构快照JSON",
},
},
"llm_config": { "llm_config": {
"table": "AI模型服务配置", "table": "AI模型服务配置",
"columns": {"id": "固定单例主键", "base_url": "模型服务地址", "model": "模型名称", "api_key": "模型服务密钥", "max_tokens": "最大输出Token数", "timeout": "请求超时秒数", "updated_at": "更新时间"}, "columns": {"id": "固定单例主键", "base_url": "模型服务地址", "model": "模型名称", "api_key": "模型服务密钥", "max_tokens": "最大输出Token数", "timeout": "请求超时秒数", "updated_at": "更新时间"},
......
...@@ -17,5 +17,6 @@ from .operation_record import OperationRecord ...@@ -17,5 +17,6 @@ from .operation_record import OperationRecord
from .table_design_config import TableDesignConfig from .table_design_config import TableDesignConfig
from .llm_config import LLMConfigRecord from .llm_config import LLMConfigRecord
from .system_config import SystemConfig from .system_config import SystemConfig
from .analysis import AnalysisTask, AnalysisRun, AnalysisTableSnapshot
__all__ = ["ConnectionPreset", "Field", "Rule", "RuleLibrary", "FieldRule", "Task", "TaskGroup", "DataSource", "OperationRecord", "TableDesignConfig", "LLMConfigRecord", "SystemConfig", "TablePref"] __all__ = ["ConnectionPreset", "Field", "Rule", "RuleLibrary", "FieldRule", "Task", "TaskGroup", "DataSource", "OperationRecord", "TableDesignConfig", "LLMConfigRecord", "SystemConfig", "TablePref", "AnalysisTask", "AnalysisRun", "AnalysisTableSnapshot"]
"""Scheduled database inventory and immutable scan snapshots."""
from sqlalchemy import Column, DateTime, ForeignKey, Integer, BigInteger, String, Text, Index
from sqlalchemy.sql import func
from web.backend.db.database import Base
class AnalysisTask(Base):
__tablename__ = "analysis_task"
id = Column(Integer, primary_key=True)
name = Column(String(120), nullable=False)
source_id = Column(Integer, ForeignKey("data_source.id"), nullable=False)
schema_name = Column(String(255), nullable=False)
cron_expression = Column(String(100), nullable=False)
enabled = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
class AnalysisRun(Base):
__tablename__ = "analysis_run"
id = Column(Integer, primary_key=True)
task_id = Column(Integer, ForeignKey("analysis_task.id"), nullable=False)
source_name = Column(String(120), nullable=False)
schema_name = Column(String(255), nullable=False)
trigger_type = Column(String(20), nullable=False)
status = Column(String(20), nullable=False, default="running")
error = Column(Text)
started_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
finished_at = Column(DateTime)
class AnalysisTableSnapshot(Base):
__tablename__ = "analysis_table_snapshot"
__table_args__ = (Index("ix_analysis_snapshot_run_table", "run_id", "table_name", unique=True),)
id = Column(Integer, primary_key=True)
run_id = Column(Integer, ForeignKey("analysis_run.id", ondelete="CASCADE"), nullable=False)
table_name = Column(String(255), nullable=False)
table_comment = Column(Text)
row_count = Column(BigInteger, nullable=False)
columns_json = Column(Text, nullable=False)
...@@ -12,3 +12,4 @@ dmPython>=2.5 ...@@ -12,3 +12,4 @@ dmPython>=2.5
anthropic>=0.30 anthropic>=0.30
pyyaml>=6.0 pyyaml>=6.0
cryptography>=42.0 cryptography>=42.0
APScheduler>=3.10,<4
"""Scheduled schema and row-count scans for MySQL and Oracle."""
from __future__ import annotations
import json
import logging
import re
import threading
from datetime import date, datetime
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from web.backend.core.db_adapter import DBConnection, quote_ident
from web.backend.core.llm import LLMUnavailable, get_llm_client
from web.backend.db.database import SessionLocal, get_session
from web.backend.models.analysis import AnalysisRun, AnalysisTableSnapshot, AnalysisTask
from web.backend.models.data_source import DataSource
from web.backend.routers.data_sources import _cfg
router = APIRouter(prefix="/analysis", tags=["analysis"])
logger = logging.getLogger(__name__)
_scheduler = BackgroundScheduler()
_active: set[int] = set()
_active_lock = threading.Lock()
_COLUMNS = ("column_name", "ordinal_position", "column_type", "data_type",
"char_max_length", "numeric_precision", "numeric_scale",
"is_nullable", "column_default", "column_comment", "extra")
class TaskInput(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
source_id: int
schema: str = Field(..., min_length=1, max_length=255)
cron_expression: str = Field(..., min_length=1, max_length=100)
enabled: bool = True
class CronPrompt(BaseModel):
prompt: str = Field(..., min_length=1, max_length=500)
def _cron(expression: str) -> CronTrigger:
parts = expression.strip().split()
if len(parts) != 5:
raise HTTPException(422, "请输入 5 段 cron 表达式:分 时 日 月 周")
try:
return CronTrigger.from_crontab(" ".join(parts))
except ValueError as exc:
raise HTTPException(422, f"cron 表达式无效:{exc}") from exc
def _source(db: Session, source_id: int) -> DataSource:
source = db.get(DataSource, source_id)
if source is None:
raise HTTPException(404, "数据源不存在")
if _cfg(source).db_type not in ("mysql", "oracle"):
raise HTTPException(422, "分析仅支持 MySQL 和 Oracle 数据源")
return source
def _task_dict(row: AnalysisTask) -> dict:
return {"id": row.id, "name": row.name, "source_id": row.source_id,
"schema": row.schema_name, "cron_expression": row.cron_expression,
"enabled": bool(row.enabled), "created_at": row.created_at,
"next_run_at": str(_scheduler.get_job(f"analysis-{row.id}").next_run_time)
if _scheduler.get_job(f"analysis-{row.id}") else None}
def _run_dict(row: AnalysisRun) -> dict:
return {"id": row.id, "task_id": row.task_id, "source_name": row.source_name,
"schema": row.schema_name, "trigger_type": row.trigger_type,
"status": row.status, "error": row.error,
"started_at": row.started_at, "finished_at": row.finished_at}
def _schedule(row: AnalysisTask) -> None:
job_id = f"analysis-{row.id}"
if _scheduler.get_job(job_id):
_scheduler.remove_job(job_id)
if row.enabled:
_scheduler.add_job(_start_scan, _cron(row.cron_expression), args=[row.id, "scheduled"],
id=job_id, max_instances=1, coalesce=True,
misfire_grace_time=60, replace_existing=True)
def start_scheduler() -> None:
if _scheduler.running:
return
_scheduler.start()
with SessionLocal() as db:
for task in db.query(AnalysisTask).filter(AnalysisTask.enabled == 1).all():
try:
_schedule(task)
except Exception:
logger.exception("Failed to schedule analysis task %s", task.id)
def stop_scheduler() -> None:
if _scheduler.running:
_scheduler.shutdown(wait=False)
def _quote_schema(schema: str, db_type: str) -> str:
"""Quote schema/catalog names safely; MySQL database names may contain hyphens."""
if db_type == "mysql":
if not schema or "\x00" in schema:
raise ValueError("数据库名称无效")
return "`" + schema.replace("`", "``") + "`"
return quote_ident(schema, db_type)
def _json_value(value):
if isinstance(value, (datetime, date)):
return value.isoformat()
return str(value)
def _scan(task_id: int, run_id: int) -> None:
with SessionLocal() as db:
run = db.get(AnalysisRun, run_id)
task = db.get(AnalysisTask, task_id)
try:
if task is None:
raise ValueError("扫描任务已删除")
source = _source(db, task.source_id)
cfg = _cfg(source)
schema = task.schema_name
# MySQL can inspect another schema through information_schema, but
# COUNT must explicitly qualify that schema rather than use cfg.database.
with DBConnection(cfg) as conn:
tables = [t for t in conn.list_tables(schema)
if str(t.get("table_type") or "BASE TABLE").upper() == "BASE TABLE"]
snapshots = []
for table in tables:
name = str(table["table_name"])
qualified = f"{_quote_schema(schema, cfg.db_type)}.{_quote_schema(name, cfg.db_type)}"
count = int(conn.fetch_scalar(f"SELECT COUNT(*) FROM {qualified}"))
columns = [{key: col.get(key) for key in _COLUMNS}
for col in conn.list_columns(schema, name)]
snapshots.append(AnalysisTableSnapshot(
run_id=run_id, table_name=name,
table_comment=str(table.get("table_comment") or ""),
row_count=count,
columns_json=json.dumps(columns, ensure_ascii=False, default=_json_value),
))
db.add_all(snapshots)
run.status = "success"
except Exception as exc:
db.rollback()
run = db.get(AnalysisRun, run_id)
run.status = "failed"
run.error = f"{type(exc).__name__}: {exc}"[:2000]
logger.exception("Analysis run %s failed", run_id)
finally:
run.finished_at = datetime.now()
db.commit()
with _active_lock:
_active.discard(task_id)
def _start_scan(task_id: int, trigger_type: str) -> int | None:
with _active_lock:
if task_id in _active:
return None
_active.add(task_id)
try:
with SessionLocal() as db:
task = db.get(AnalysisTask, task_id)
if task is None:
raise HTTPException(404, "扫描任务不存在")
source = _source(db, task.source_id)
run = AnalysisRun(task_id=task_id, source_name=source.name,
schema_name=task.schema_name, trigger_type=trigger_type,
status="running", started_at=datetime.now())
db.add(run)
db.commit()
run_id = run.id
threading.Thread(target=_scan, args=(task_id, run_id), daemon=True).start()
return run_id
except Exception:
with _active_lock:
_active.discard(task_id)
raise
@router.get("/tasks")
def list_tasks(db: Session = Depends(get_session)):
return [_task_dict(x) for x in db.query(AnalysisTask).order_by(AnalysisTask.id.desc()).all()]
@router.post("/tasks", status_code=201)
def create_task(payload: TaskInput, db: Session = Depends(get_session)):
_source(db, payload.source_id)
_cron(payload.cron_expression)
row = AnalysisTask(name=payload.name.strip(), source_id=payload.source_id,
schema_name=payload.schema.strip(), cron_expression=payload.cron_expression.strip(),
enabled=int(payload.enabled))
db.add(row)
db.commit()
db.refresh(row)
_schedule(row)
return _task_dict(row)
@router.put("/tasks/{task_id}")
def update_task(task_id: int, payload: TaskInput, db: Session = Depends(get_session)):
row = db.get(AnalysisTask, task_id)
if row is None:
raise HTTPException(404, "扫描任务不存在")
_source(db, payload.source_id)
_cron(payload.cron_expression)
if task_id in _active:
raise HTTPException(409, "扫描正在执行,完成后再修改任务")
row.name = payload.name.strip()
row.source_id = payload.source_id
row.schema_name = payload.schema.strip()
row.cron_expression = payload.cron_expression.strip()
row.enabled = int(payload.enabled)
db.commit()
_schedule(row)
return _task_dict(row)
@router.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_session)):
row = db.get(AnalysisTask, task_id)
if row is None:
raise HTTPException(404, "扫描任务不存在")
if task_id in _active:
raise HTTPException(409, "扫描正在执行")
if db.query(AnalysisRun.id).filter(AnalysisRun.task_id == task_id).first():
raise HTTPException(409, "任务已有扫描报告,请停用以保留历史报告")
db.delete(row)
db.commit()
job = _scheduler.get_job(f"analysis-{task_id}")
if job:
job.remove()
@router.post("/tasks/{task_id}/run", status_code=202)
def run_task(task_id: int):
run_id = _start_scan(task_id, "manual")
if run_id is None:
raise HTTPException(409, "该任务正在扫描")
return {"run_id": run_id}
@router.post("/cron/generate")
def generate_cron(payload: CronPrompt):
try:
result = get_llm_client().complete(
f"将以下中文执行频率转换为标准 5 段 cron 表达式(分 时 日 月 周):{payload.prompt}\n"
"只返回表达式,不要代码块或解释。注意周日为 0,不能使用秒或年份。",
max_tokens=100,
).strip().strip("`")
except LLMUnavailable as exc:
raise HTTPException(503, str(exc)) from exc
expression = result.splitlines()[0].strip() if result else ""
# Reject prose and unsupported Quartz syntax before the generated value is saved.
if not re.fullmatch(r"[\d*,/\- ]+", expression):
raise HTTPException(502, "AI 未返回有效的 cron 表达式,请调整描述后重试")
_cron(expression)
return {"cron_expression": expression}
def _snapshots(db: Session, run_id: int) -> dict[str, AnalysisTableSnapshot]:
return {x.table_name: x for x in db.query(AnalysisTableSnapshot)
.filter(AnalysisTableSnapshot.run_id == run_id).all()}
def _report(db: Session, run: AnalysisRun) -> dict:
previous = (db.query(AnalysisRun).filter(AnalysisRun.task_id == run.task_id,
AnalysisRun.status == "success", AnalysisRun.id < run.id)
.order_by(AnalysisRun.id.desc()).first())
current = _snapshots(db, run.id) if run.status == "success" else {}
old = _snapshots(db, previous.id) if previous and run.status == "success" else {}
tables = []
for name in sorted(set(current) | set(old)):
now, before = current.get(name), old.get(name)
current_cols = {c["column_name"]: c for c in json.loads(now.columns_json)} if now else {}
old_cols = {c["column_name"]: c for c in json.loads(before.columns_json)} if before else {}
changes = []
for col_name in (sorted(set(current_cols) | set(old_cols)) if previous else []):
left, right = old_cols.get(col_name), current_cols.get(col_name)
if left is None:
changes.append({"column": col_name, "kind": "added", "before": None, "after": right})
elif right is None:
changes.append({"column": col_name, "kind": "removed", "before": left, "after": None})
elif left != right:
changes.append({"column": col_name, "kind": "changed", "before": left, "after": right})
tables.append({"table_name": name, "change": "baseline" if previous is None else "added" if before is None else "removed" if now is None else
"changed" if changes or now.table_comment != before.table_comment else "unchanged",
"table_comment_before": before.table_comment if before else None,
"table_comment_after": now.table_comment if now else None,
"row_count": now.row_count if now else (0 if previous else None),
"previous_row_count": before.row_count if before else (0 if previous else None),
"row_delta": ((now.row_count if now else 0) - (before.row_count if before else 0)) if previous else None,
"columns": current_cols if now else old_cols,
"column_changes": changes,
"comment_changed": bool(now and before and now.table_comment != before.table_comment)})
result = _run_dict(run)
result.update({"previous_run_id": previous.id if previous else None,
"table_count": len(current), "total_rows": sum(x.row_count for x in current.values()),
"previous_total_rows": sum(x.row_count for x in old.values()) if previous and run.status == "success" else None,
"total_rows_delta": sum(x.row_count for x in current.values()) - sum(x.row_count for x in old.values()) if previous and run.status == "success" else None,
"tables_added": sum(x["change"] == "added" for x in tables) if previous else 0,
"tables_removed": sum(x["change"] == "removed" for x in tables),
"tables_changed": sum(x["change"] == "changed" for x in tables),
"tables": tables})
return result
@router.get("/runs")
def list_runs(task_id: int | None = None, db: Session = Depends(get_session)):
query = db.query(AnalysisRun)
if task_id is not None:
query = query.filter(AnalysisRun.task_id == task_id)
return [_run_dict(x) for x in query.order_by(AnalysisRun.id.desc()).limit(100).all()]
@router.get("/runs/{run_id}")
def get_report(run_id: int, db: Session = Depends(get_session)):
run = db.get(AnalysisRun, run_id)
if run is None:
raise HTTPException(404, "扫描报告不存在")
return _report(db, run)
# ── 表详情:SQL 导出 + AI 分析(2026-09-29)─────────────────
# 数据全部来自快照(_report 已含当前结构 columns / column_changes / 行数对比),
# 不回源库查询;SQL 按数据源方言生成,仅作展示/复制,不直接执行。
def _table_report(db: Session, run: AnalysisRun, table_name: str) -> dict:
if run.status != "success":
raise HTTPException(409, "本次扫描未成功,没有可查看的详情")
report = _report(db, run)
row = next((t for t in report["tables"] if t["table_name"] == table_name), None)
if row is None:
raise HTTPException(404, "报告中没有该数据表")
return row
def _run_dialect(db: Session, run: AnalysisRun) -> str:
task = db.get(AnalysisTask, run.task_id)
if task is None:
return "mysql"
source = db.get(DataSource, task.source_id)
return _cfg(source).db_type if source else "mysql"
def _sql_literal(value) -> str:
return "'" + str(value).replace("'", "''") + "'"
def _column_def(col: dict, dialect: str) -> str:
"""列定义片段:类型 + NOT NULL + 默认值(注释由调用方按方言拼)。"""
ctype = str(col.get("column_type") or col.get("data_type") or "VARCHAR(255)")
definition = f"{quote_ident(str(col.get('column_name') or ''), dialect)} {ctype}"
if str(col.get("is_nullable") or "YES").upper() == "NO":
definition += " NOT NULL"
default = col.get("column_default")
if default is not None and str(default).strip() != "":
text = str(default).strip().strip("'")
upper = text.upper()
# 数值/函数默认值不加引号(SYSDATE/CURRENT_* 等),字符串默认值加引号
if upper.startswith(("SYSDATE", "SYS_GUID", "CURRENT_", "NEXTVAL", "CURRVAL")) or \
(text[:1].isdigit() or (text[:1] in "+-" and text[1:2].isdigit())):
definition += f" DEFAULT {text}"
else:
definition += f" DEFAULT {_sql_literal(text)}"
return definition
def _ordered_columns(row: dict) -> list[dict]:
columns = row.get("columns") or {}
return [columns[k] for k in sorted(columns,
key=lambda n: int(columns[n].get("ordinal_position") or 0))]
def _table_ref(table: str, dialect: str) -> str:
return quote_ident(table, dialect) if dialect == "oracle" else f"`{table}`"
def _generate_sql(row: dict, dialect: str) -> dict:
"""当前结构 CREATE TABLE + 相对上次扫描的差异 ALTER。"""
table = str(row["table_name"])
columns = _ordered_columns(row)
comment_after = row.get("table_comment_after") or ""
ref = _table_ref(table, dialect)
if dialect == "oracle":
defs = ",\n ".join(_column_def(c, dialect) for c in columns)
current = f"CREATE TABLE {ref} (\n {defs}\n);"
if comment_after:
current += f"\nCOMMENT ON TABLE {ref} IS {_sql_literal(comment_after)};"
for c in columns:
if str(c.get("column_comment") or "").strip():
current += (f"\nCOMMENT ON COLUMN {ref}."
f"{quote_ident(str(c.get('column_name') or ''), dialect)} "
f"IS {_sql_literal(c.get('column_comment'))};")
else:
parts = []
for c in columns:
d = _column_def(c, dialect)
if str(c.get("column_comment") or "").strip():
d += f" COMMENT {_sql_literal(c.get('column_comment'))}"
parts.append(d)
current = f"CREATE TABLE {ref} (\n " + ",\n ".join(parts) + "\n)"
if comment_after:
current += f" COMMENT={_sql_literal(comment_after)}"
diff_lines = []
for change in row.get("column_changes") or []:
col, kind = change["column"], change["kind"]
if kind == "added" and change.get("after"):
diff_lines.append(f"ALTER TABLE {ref} ADD ({_column_def(change['after'], dialect)});")
elif kind == "removed":
diff_lines.append(f"ALTER TABLE {ref} DROP COLUMN {quote_ident(col, dialect)};")
elif kind == "changed" and change.get("after"):
diff_lines.append(f"ALTER TABLE {ref} MODIFY ({_column_def(change['after'], dialect)});")
if row.get("comment_changed") and comment_after:
diff_lines.append(
f"COMMENT ON TABLE {ref} IS {_sql_literal(comment_after)};" if dialect == "oracle"
else f"ALTER TABLE {ref} COMMENT={_sql_literal(comment_after)};")
return {"dialect": dialect, "current_sql": current,
"diff_sql": "\n".join(diff_lines) or "-- 与上次扫描相比没有结构差异"}
@router.get("/runs/{run_id}/tables/{table_name}/sql")
def export_table_sql(run_id: int, table_name: str, db: Session = Depends(get_session)):
"""按数据源方言导出当前结构 SQL 与差异 SQL(仅展示,不执行)。"""
run = db.get(AnalysisRun, run_id)
if run is None:
raise HTTPException(404, "扫描报告不存在")
row = _table_report(db, run, table_name)
return _generate_sql(row, _run_dialect(db, run))
@router.post("/runs/{run_id}/tables/{table_name}/analyze")
def analyze_table(run_id: int, table_name: str, db: Session = Depends(get_session)):
"""AI 分析单表:结构、字段/注释变化、数据量变化的解读。"""
run = db.get(AnalysisRun, run_id)
if run is None:
raise HTTPException(404, "扫描报告不存在")
row = _table_report(db, run, table_name)
columns = _ordered_columns(row)
col_lines = []
for c in columns[:80]:
line = f"- {c.get('column_name')} {c.get('column_type')}"
line += " 非空" if str(c.get("is_nullable") or "YES").upper() == "NO" else " 可空"
if c.get("column_default") is not None:
line += f" 默认 {c.get('column_default')}"
if str(c.get("column_comment") or "").strip():
line += f",注释:{c.get('column_comment')}"
col_lines.append(line)
change_lines = []
for change in (row.get("column_changes") or [])[:60]:
if change["kind"] == "changed" and change.get("before") and change.get("after"):
detail = f"({change['before'].get('column_type')} → {change['after'].get('column_type')})"
else:
detail = ""
label = {"added": "新增", "removed": "删除", "changed": "修改"}.get(change["kind"], change["kind"])
change_lines.append(f"- {change['column']} {label}{detail}")
prompt = (
"你是数据库治理助手。请用中文分析下面这张表的现状与变化,输出三部分:\n"
"1) 表用途推断(结合表名、注释、字段);\n"
"2) 变化解读(字段增删改、注释变化、数据量增减的可能业务含义与风险);\n"
"3) 建议(是否需要关注,如敏感字段、容量增长、结构规范化)。\n"
"用简洁的条目列出,不要输出 SQL。\n\n"
f"表名:{table_name}\n"
f"表注释:{row.get('table_comment_after') or '(空)'}"
f"{'(上次:' + row['table_comment_before'] + ')' if row.get('comment_changed') else ''}\n"
f"数据量:本次 {row.get('row_count')} 行,上次 {row.get('previous_row_count')} 行"
f"(差值 {row.get('row_delta')})\n\n"
f"当前字段(共 {len(columns)} 列,最多列 80):\n" + "\n".join(col_lines) +
("\n\n与上次扫描的字段变化:\n" + "\n".join(change_lines) if change_lines
else "\n\n与上次扫描相比无字段变化") +
(f"\n(另有 {len(columns) - 80} 列未列出)" if len(columns) > 80 else ""))
try:
client = get_llm_client()
text = client.complete(
prompt, system="你是资深数据库管理员,输出简明、专业、面向业务人员的中文分析。")
except LLMUnavailable as exc:
raise HTTPException(503, f"AI 服务不可用:{exc}") from exc
except Exception as exc:
raise HTTPException(502, f"AI 分析失败:{type(exc).__name__}: {exc}") from exc
return {"table_name": table_name, "analysis": text}
...@@ -5,7 +5,7 @@ import base64 ...@@ -5,7 +5,7 @@ import base64
import json import json
import os import os
import re import re
from datetime import date, datetime from datetime import date, datetime, timedelta, time
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
...@@ -29,6 +29,9 @@ from web.backend.models.data_source import DataSource ...@@ -29,6 +29,9 @@ from web.backend.models.data_source import DataSource
from web.backend.routers.data_sources import _cfg, _resolve_schema, _apply_database_override from web.backend.routers.data_sources import _cfg, _resolve_schema, _apply_database_override
from web.backend.core.operation_history import record_operation from web.backend.core.operation_history import record_operation
from web.backend.models.operation_record import OperationRecord from web.backend.models.operation_record import OperationRecord
from web.backend.models.task import Task
from web.backend.models.table_design_config import TableDesignConfig
from web.backend.models.analysis import AnalysisRun, AnalysisTableSnapshot, AnalysisTask
router = APIRouter(prefix="/backup", tags=["backup"]) router = APIRouter(prefix="/backup", tags=["backup"])
...@@ -417,6 +420,108 @@ def backup_versions(): ...@@ -417,6 +420,108 @@ def backup_versions():
return files return files
@router.get("/dashboard-summary")
def dashboard_summary(db: Session = Depends(get_session)):
"""首页统计汇总:数据库完成聚合,避免前端逐类拉取完整历史记录。"""
from sqlalchemy import func
operations = ("backup", "restore", "clear", "encrypt", "decrypt")
counts = {operation: 0 for operation in operations}
for operation, count in db.query(OperationRecord.operation, func.count(OperationRecord.id)).group_by(OperationRecord.operation).all():
if operation in counts:
counts[operation] = int(count)
today = datetime.now().date()
start_at = datetime.combine(today - timedelta(days=6), time.min)
trend = []
trend_rows = (
db.query(OperationRecord.operation, func.date(OperationRecord.created_at), func.count(OperationRecord.id))
.filter(OperationRecord.created_at >= start_at)
.group_by(OperationRecord.operation, func.date(OperationRecord.created_at))
.all()
)
for operation, day, count in trend_rows:
if operation in counts:
trend.append({"operation": operation, "date": str(day), "count": int(count)})
recent = (
db.query(OperationRecord)
.order_by(OperationRecord.created_at.desc(), OperationRecord.id.desc())
.limit(5)
.all()
)
backup_dir = get_backup_dir()
versions = sum(
1 for path in backup_dir.iterdir()
if path.is_file() and (path.name.endswith(".json") or path.name.endswith(".dbx"))
)
analysis_task_count = int(db.query(func.count(AnalysisTask.id)).scalar() or 0)
analysis_enabled_count = int(db.query(func.count(AnalysisTask.id)).filter(AnalysisTask.enabled == 1).scalar() or 0)
analysis_report_counts = {status: 0 for status in ("success", "failed", "running")}
for status, count in db.query(AnalysisRun.status, func.count(AnalysisRun.id)).group_by(AnalysisRun.status).all():
if status in analysis_report_counts:
analysis_report_counts[status] = int(count)
def analysis_report_summary(run, task_name):
current_rows = db.query(AnalysisTableSnapshot).filter(AnalysisTableSnapshot.run_id == run.id).all()
previous = (db.query(AnalysisRun).filter(
AnalysisRun.task_id == run.task_id,
AnalysisRun.status == "success",
AnalysisRun.id < run.id,
).order_by(AnalysisRun.id.desc()).first())
previous_rows = db.query(AnalysisTableSnapshot).filter(AnalysisTableSnapshot.run_id == previous.id).all() if previous else []
current = {row.table_name: row for row in current_rows}
before = {row.table_name: row for row in previous_rows}
added = set(current) - set(before) if previous else set()
removed = set(before) - set(current) if previous else set()
changed = 0
for table_name in set(current) & set(before):
current_columns = json.loads(current[table_name].columns_json or "[]")
previous_columns = json.loads(before[table_name].columns_json or "[]")
if current_columns != previous_columns or current[table_name].table_comment != before[table_name].table_comment:
changed += 1
return {
"id": run.id,
"task_id": run.task_id,
"task_name": task_name,
"source_name": run.source_name,
"schema": run.schema_name,
"status": run.status,
"trigger_type": run.trigger_type,
"error": run.error,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"table_count": len(current),
"total_rows": sum(row.row_count for row in current_rows),
"tables_added": len(added),
"tables_removed": len(removed),
"tables_changed": changed,
}
analysis_reports = []
recent_analysis_runs = (db.query(AnalysisRun, AnalysisTask.name)
.join(AnalysisTask, AnalysisTask.id == AnalysisRun.task_id)
.order_by(AnalysisRun.started_at.desc(), AnalysisRun.id.desc())
.limit(5).all())
for run, task_name in recent_analysis_runs:
analysis_reports.append(analysis_report_summary(run, task_name))
return {
"counts": counts,
"sources": int(db.query(func.count(DataSource.id)).scalar() or 0),
"tasks": int(db.query(func.count(Task.id)).scalar() or 0),
"tables": int(db.query(func.count(TableDesignConfig.id)).scalar() or 0),
"versions": versions,
"trend": trend,
"recent": [row.to_dict() for row in recent],
"analysis": {
"tasks": {"total": analysis_task_count, "enabled": analysis_enabled_count,
"disabled": analysis_task_count - analysis_enabled_count},
"reports": {"total": sum(analysis_report_counts.values()), **analysis_report_counts},
"recent": analysis_reports,
},
}
@router.get("/records") @router.get("/records")
def list_operation_records( def list_operation_records(
operation: Optional[Literal["backup", "restore", "clear", "encrypt", "decrypt"]] = None, operation: Optional[Literal["backup", "restore", "clear", "encrypt", "decrypt"]] = None,
......
import { http } from './client'
export const listAnalysisTasks = () => http.get('/analysis/tasks')
export const createAnalysisTask = (payload) => http.post('/analysis/tasks', payload)
export const updateAnalysisTask = (id, payload) => http.put(`/analysis/tasks/${id}`, payload)
export const deleteAnalysisTask = (id) => http.delete(`/analysis/tasks/${id}`)
export const runAnalysisTask = (id) => http.post(`/analysis/tasks/${id}/run`)
export const generateCron = (prompt) => http.post('/analysis/cron/generate', { prompt })
export const listAnalysisRuns = (taskId) => http.get('/analysis/runs', { params: taskId ? { task_id: taskId } : {} })
export const getAnalysisReport = (runId) => http.get(`/analysis/runs/${runId}`)
export const exportTableSql = (runId, tableName) => http.get(`/analysis/runs/${runId}/tables/${encodeURIComponent(tableName)}/sql`)
export const analyzeTable = (runId, tableName) => http.post(`/analysis/runs/${runId}/tables/${encodeURIComponent(tableName)}/analyze`)
import { http } from './client'
export const getDashboardSummary = () => http.get('/backup/dashboard-summary')
...@@ -2,7 +2,10 @@ ...@@ -2,7 +2,10 @@
<el-container class="layout-root"> <el-container class="layout-root">
<!-- 顶栏 --> <!-- 顶栏 -->
<el-header class="top-header"> <el-header class="top-header">
<div class="top-brand">数据库工具</div> <div class="top-brand">
<img class="top-brand-icon" :src="brandLogo" alt="" aria-hidden="true" />
<span>数据库工具</span>
</div>
<div class="top-actions"> <div class="top-actions">
<!-- 字号大小切换:小/中/大 --> <!-- 字号大小切换:小/中/大 -->
<el-dropdown trigger="click" @command="setFontScale"> <el-dropdown trigger="click" @command="setFontScale">
...@@ -51,6 +54,9 @@ ...@@ -51,6 +54,9 @@
:default-openeds="openGroups" :default-openeds="openGroups"
@select="onMenuSelect" @select="onMenuSelect"
> >
<el-menu-item index="/home">
<el-icon><HomeFilled /></el-icon><template #title>首页</template>
</el-menu-item>
<el-sub-menu index="backup"> <el-sub-menu index="backup">
<template #title><el-icon><Download /></el-icon><span>备份</span></template> <template #title><el-icon><Download /></el-icon><span>备份</span></template>
<el-menu-item index="/backup"><template #title>备份</template></el-menu-item> <el-menu-item index="/backup"><template #title>备份</template></el-menu-item>
...@@ -67,6 +73,11 @@ ...@@ -67,6 +73,11 @@
<el-menu-item index="/validation/table-design"><template #title>表设计</template></el-menu-item> <el-menu-item index="/validation/table-design"><template #title>表设计</template></el-menu-item>
<el-menu-item index="/validation"><template #title>数据</template></el-menu-item> <el-menu-item index="/validation"><template #title>数据</template></el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="analysis">
<template #title><el-icon><TrendCharts /></el-icon><span>分析</span></template>
<el-menu-item index="/analysis/tasks"><template #title>任务</template></el-menu-item>
<el-menu-item index="/analysis/reports"><template #title>报告</template></el-menu-item>
</el-sub-menu>
<el-sub-menu index="config"> <el-sub-menu index="config">
<template #title><el-icon><Setting /></el-icon><span>配置</span></template> <template #title><el-icon><Setting /></el-icon><span>配置</span></template>
<el-menu-item index="/config/data-sources"><template #title>数据源</template></el-menu-item> <el-menu-item index="/config/data-sources"><template #title>数据源</template></el-menu-item>
...@@ -105,14 +116,17 @@ import { computed, onMounted, provide, ref } from 'vue' ...@@ -105,14 +116,17 @@ import { computed, onMounted, provide, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { listTaskGroups } from '@/api/taskGroups' import { listTaskGroups } from '@/api/taskGroups'
import { fontScaleOptions, useUiPrefs } from '@/composables/useUiPrefs' import { fontScaleOptions, useUiPrefs } from '@/composables/useUiPrefs'
import brandLogoDark from '@/assets/brand-logo-dark.png'
import brandLogoLight from '@/assets/brand-logo-light.png'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const openGroups = ref(['backup', 'crypto', 'config', 'validation']) const openGroups = ref(['backup', 'crypto', 'config', 'validation', 'analysis'])
// ── UI 偏好(主题 / 字号,见 composables/useUiPrefs.js)──── // ── UI 偏好(主题 / 字号,见 composables/useUiPrefs.js)────
const { fontScale, currentScaleLabel, themeTip, themeIcon, setFontScale, toggleTheme } = useUiPrefs() const { theme, fontScale, currentScaleLabel, themeTip, themeIcon, setFontScale, toggleTheme } = useUiPrefs()
const brandLogo = computed(() => theme.value === 'dark' ? brandLogoDark : brandLogoLight)
// ── 分组码表(来自后端 /api/task-groups) ─────────────────── // ── 分组码表(来自后端 /api/task-groups) ───────────────────
const taskGroups = ref([]) // 原始 [{id,name,ord}, ...] const taskGroups = ref([]) // 原始 [{id,name,ord}, ...]
...@@ -155,11 +169,22 @@ onMounted(async () => { ...@@ -155,11 +169,22 @@ onMounted(async () => {
color: var(--color-text-regular); color: var(--color-text-regular);
} }
.top-brand { .top-brand {
display: inline-flex;
align-items: center;
gap: 9px;
flex-shrink: 0;
font-family: var(--font-family-base); font-family: var(--font-family-base);
font-size: var(--font-size-2xl); font-size: var(--font-size-2xl);
letter-spacing: 2px; letter-spacing: 2px;
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.top-brand-icon {
display: block;
width: 36px;
height: 26px;
object-fit: contain;
flex-shrink: 0;
}
/* ---- 顶栏右侧设置入口(字号 / 主题,对齐 housekeeping AppHeader)---- */ /* ---- 顶栏右侧设置入口(字号 / 主题,对齐 housekeeping AppHeader)---- */
.top-actions { .top-actions {
...@@ -265,6 +290,14 @@ onMounted(async () => { ...@@ -265,6 +290,14 @@ onMounted(async () => {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
min-width: 0; min-width: 0;
/* 菜单仍可滚动,但不显示浏览器滚动条 */
scrollbar-width: none;
-ms-overflow-style: none;
}
.layout-menu::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
} }
.layout-menu :deep(.el-sub-menu__title) { .layout-menu :deep(.el-sub-menu__title) {
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
......
...@@ -11,13 +11,18 @@ import ModelConfigView from '@/views/ModelConfigView.vue' ...@@ -11,13 +11,18 @@ import ModelConfigView from '@/views/ModelConfigView.vue'
import RuleLibraryView from '@/views/RuleLibraryView.vue' import RuleLibraryView from '@/views/RuleLibraryView.vue'
import TableDesignView from '@/views/TableDesignView.vue' import TableDesignView from '@/views/TableDesignView.vue'
import TableDesignResultsView from '@/views/TableDesignResultsView.vue' import TableDesignResultsView from '@/views/TableDesignResultsView.vue'
import HomeView from '@/views/HomeView.vue'
import AnalysisTasksView from '@/views/AnalysisTasksView.vue'
import AnalysisReportsView from '@/views/AnalysisReportsView.vue'
import AnalysisReportDetailView from '@/views/AnalysisReportDetailView.vue'
const routes = [ const routes = [
{ {
path: '/', path: '/',
component: MainLayout, component: MainLayout,
redirect: '/validation', redirect: '/home',
children: [ children: [
{ path: 'home', name: 'Home', component: HomeView, meta: { title: '首页' } },
{ {
path: 'validation', path: 'validation',
name: 'ValidationTasks', name: 'ValidationTasks',
...@@ -30,6 +35,9 @@ const routes = [ ...@@ -30,6 +35,9 @@ const routes = [
name: 'DataQuality', name: 'DataQuality',
component: DataQualityView, component: DataQualityView,
}, },
{ path: 'analysis/tasks', name: 'AnalysisTasks', component: AnalysisTasksView, meta: { title: '任务' } },
{ path: 'analysis/reports', name: 'AnalysisReports', component: AnalysisReportsView, meta: { title: '报告' } },
{ path: 'analysis/reports/detail', name: 'AnalysisReportDetail', component: AnalysisReportDetailView, meta: { title: '报告详情' } },
{ {
path: 'config/rule-config', path: 'config/rule-config',
name: 'TaskConfig', name: 'TaskConfig',
......
...@@ -437,7 +437,7 @@ ...@@ -437,7 +437,7 @@
font-size: var(--font-size-base); font-size: var(--font-size-base);
} }
.el-dialog__footer { .el-dialog__footer {
padding: 10px 24px 0; padding: 10px 24px 14px;
font-size: var(--font-size-base); font-size: var(--font-size-base);
} }
......
/**
* 轻量 Markdown 渲染(AI 分析弹框用)
*
* 只覆盖 LLM 输出的常见形态:# 标题、**加粗**、`行内代码`、- 列表、空行分段。
* 不引第三方依赖(本机 npm 需镜像,为一个弹框不值得);所有文本先做 HTML
* 转义再替换标记,防注入。
*/
export function renderMarkdownLite(text) {
const escape = (s) => String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const inline = (s) => escape(s)
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
const out = []
let inList = false
for (const raw of String(text || '').split(/\r?\n/)) {
const line = raw.trimEnd()
const heading = line.match(/^(#{1,4})\s+(.*)$/)
const item = line.match(/^\s*[-*•]\s+(.*)$/)
if (item) {
if (!inList) { out.push('<ul>'); inList = true }
out.push(`<li>${inline(item[1])}</li>`)
continue
}
if (inList) { out.push('</ul>'); inList = false }
if (heading) {
const level = Math.min(heading[1].length + 2, 5) // ## → h4,层级往下压避免盖过弹框标题
out.push(`<h${level}>${inline(heading[2])}</h${level}>`)
} else if (!line.trim()) {
out.push('')
} else {
out.push(`<p>${inline(line)}</p>`)
}
}
if (inList) out.push('</ul>')
return out.join('\n')
}
<template>
<div class="page-container list-shell-page">
<section class="filter-card detail-toolbar">
<el-button @click="back">返回报告</el-button>
<el-button :disabled="loadingSql" @click="loadSql('current')">{{ sqlMode === 'current' ? '当前结构 SQL' : '导出当前结构 SQL' }}</el-button>
<el-button :disabled="loadingSql" @click="loadSql('diff')">{{ sqlMode === 'diff' ? '差异 SQL' : '导出差异 SQL(较上次)' }}</el-button>
<el-button type="primary" :loading="analyzing" @click="runAnalyze">AI 分析</el-button>
</section>
<div v-if="row" class="task-info-bar">
<el-icon><InfoFilled /></el-icon>
<span>数据表:<b>{{ row.table_name }}</b></span>
<span class="meta">表注释:<b>{{ row.table_comment_after || '—' }}</b></span>
<span class="meta">数据量:<b>{{ number(row.row_count) }}</b> 行(上次 {{ number(row.previous_row_count) }} ·
<span :class="row.row_delta > 0 ? 'positive' : row.row_delta < 0 ? 'negative' : ''">{{ delta(row.row_delta) }}</span>)</span>
<span class="meta">字段数:<b>{{ columnList.length }}</b></span>
</div>
<article v-if="row" class="table-card detail-card">
<div v-if="error" class="query-error">{{ error }}</div>
<!-- 变化摘要 -->
<div v-if="hasChanges" class="change-block">
<b>与上次扫描的变化</b>
<div v-if="row.comment_changed" class="change-line">表注释:{{ row.table_comment_before || '(空)' }} → {{ row.table_comment_after || '(空)' }}</div>
<div v-for="change in row.column_changes" :key="change.column" class="change-line">
<el-tag :type="changeTag(change.kind)" size="small">{{ changeLabel(change.kind) }}</el-tag>
<b>{{ change.column }}</b>
<span v-if="change.kind === 'changed'">{{ change.before?.column_type }} → {{ change.after?.column_type }}
<template v-if="commentDiff(change)">,注释:{{ change.before?.column_comment || '(空)' }} → {{ change.after?.column_comment || '(空)' }}</template></span>
<span v-else-if="change.kind === 'added'">{{ change.after?.column_type }}{{ change.after?.column_comment ? `,注释:${change.after.column_comment}` : '' }}</span>
</div>
</div>
<div v-else class="change-block muted-text">与上次扫描相比无字段 / 注释变化</div>
<!-- 当前结构 -->
<div class="section-title">当前表结构({{ columnList.length }} 列)</div>
<div class="table-scroll">
<table class="config-list">
<thead><tr><th>字段名</th><th>类型</th><th style="width:70px;">可空</th><th>默认值</th><th>字段注释</th></tr></thead>
<tbody>
<tr v-for="col in columnList" :key="col.column_name">
<td>{{ col.column_name }}</td><td>{{ col.column_type }}</td>
<td>{{ col.is_nullable === 'NO' ? '否' : '是' }}</td>
<td>{{ col.column_default ?? '—' }}</td>
<td>{{ col.column_comment || '—' }}</td>
</tr>
<tr v-if="!columnList.length"><td colspan="5" class="empty-cell">该表在本次扫描中已被删除,仅保留上次快照</td></tr>
</tbody>
</table>
</div>
</article>
<article v-else class="table-card"><div class="empty-cell">{{ loading ? '加载中…' : '未找到报告数据' }}</div></article>
<!-- SQL / AI 结果弹层 -->
<CommonFormDialog v-model="resultOpen" :title="resultTitle" width="860px" class="ai-result-dialog">
<div v-if="analyzing" class="empty-cell">AI 分析中,请稍候…</div>
<template v-else-if="sqlText !== null">
<div class="sql-actions"><el-button size="small" @click="copySql">复制</el-button></div>
<pre class="sql-view">{{ sqlText }}</pre>
</template>
<template v-else-if="aiText">
<div class="ai-header">以下分析由 AI 基于扫描快照生成,仅供参考</div>
<!-- eslint-disable-next-line vue/no-v-html —— 内容经 markdownLite 全量转义后仅追加受控标签 -->
<div class="ai-view markdown-body" v-html="renderedAi"></div>
</template>
<template #footer><el-button @click="resultOpen = false">关闭</el-button></template>
</CommonFormDialog>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import CommonFormDialog from '@/components/CommonFormDialog.vue'
import { analyzeTable, exportTableSql, getAnalysisReport } from '@/api/analysis'
import { renderMarkdownLite } from '@/utils/markdownLite'
const route = useRoute(); const router = useRouter()
const runId = Number(route.query.run)
const tableName = String(route.query.table || '')
const row = ref(null); const loading = ref(true); const error = ref('')
const resultOpen = ref(false); const sqlMode = ref(''); const sqlText = ref(null)
const aiText = ref(''); const analyzing = ref(false); const loadingSql = ref(false)
const columnList = computed(() => {
const cols = row.value?.columns || {}
return Object.keys(cols).sort((a, b) => (cols[a].ordinal_position || 0) - (cols[b].ordinal_position || 0))
.map((k) => cols[k])
})
const renderedAi = computed(() => renderMarkdownLite(aiText.value))
const hasChanges = computed(() => row.value?.comment_changed || row.value?.column_changes?.length)
const resultTitle = computed(() => (sqlText.value !== null ? (sqlMode.value === 'diff' ? '差异 SQL' : '当前结构 SQL') : 'AI 分析'))
const number = (v) => v === null || v === undefined ? '—' : Number(v).toLocaleString()
const delta = (v) => v === null || v === undefined ? '基线' : `${v > 0 ? '+' : ''}${Number(v).toLocaleString()}`
const changeLabel = (k) => ({ added: '新增', removed: '删除', changed: '修改' }[k] || k)
const changeTag = (k) => ({ added: 'success', removed: 'danger', changed: 'warning' }[k] || 'info')
const commentDiff = (c) => (c.before?.column_comment || '') !== (c.after?.column_comment || '')
async function loadDetail() {
if (!Number.isFinite(runId) || !tableName) { error.value = '缺少报告参数'; loading.value = false; return }
try {
const report = await getAnalysisReport(runId)
row.value = (report.tables || []).find((t) => t.table_name === tableName) || null
if (!row.value) error.value = '报告中没有该数据表'
} catch (e) { error.value = e.message } finally { loading.value = false }
}
async function loadSql(mode) {
loadingSql.value = true
try {
const r = await exportTableSql(runId, tableName)
sqlMode.value = mode; sqlText.value = mode === 'diff' ? r.diff_sql : r.current_sql
aiText.value = ''; resultOpen.value = true
} catch (e) { ElMessage.error(`导出 SQL 失败:${e.message}`) } finally { loadingSql.value = false }
}
async function runAnalyze() {
analyzing.value = true; sqlText.value = null; aiText.value = ''; resultOpen.value = true
try { const r = await analyzeTable(runId, tableName); aiText.value = r.analysis }
catch (e) { aiText.value = ''; resultOpen.value = false; ElMessage.error(e.message || 'AI 分析失败') }
finally { analyzing.value = false }
}
async function copySql() {
try { await navigator.clipboard.writeText(sqlText.value || ''); ElMessage.success('已复制到剪贴板') }
catch { ElMessage.warning('复制失败,请手动选择复制') }
}
function back() { router.push({ name: 'AnalysisReports' }) }
onMounted(loadDetail)
</script>
<style scoped>
/* 间距与其他结果页(数据/表设计)保持一致:页面级用全局骨架默认
(filter-card 10px / 信息条 12px),卡片内部与其他 table-card 观感对齐 */
.detail-toolbar { display: flex; gap: 10px; align-items: center; }
/* EP 默认给相邻按钮加 12px 左边距,与 gap 叠加后比其他页面(10px)宽一倍 —— 清掉 */
.detail-toolbar .el-button + .el-button { margin-left: 0; }
.detail-toolbar .el-button { min-height: 36px; height: 36px; }
.detail-card { display: flex; flex-direction: column; gap: 8px; padding: 0; overflow: hidden; }
/* 滚动收敛到 .table-scroll(表头 sticky 相对它生效);对齐报告页 report-table 的穿头修复 */
.detail-card > *:not(.table-scroll) { flex-shrink: 0; }
.detail-card .table-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
.detail-card table.config-list { border-collapse: separate; border-spacing: 0; }
/* 数据全部居左(含被全局「操作列居中」误命中的最后一列「字段注释」) */
.detail-card table.config-list tbody td { text-align: left; }
.detail-card table.config-list thead th { z-index: 3; box-shadow: inset 0 -1px var(--table-border-color); }
.section-title { margin: 0 0 2px; font-weight: 600; color: var(--color-text-primary); }
/* 摘要块与信息条/表格同宽对齐(不再左右缩进 12px),内容缩进靠 padding */
.change-block { margin: 0; padding: 12px; background: var(--theme-surface-mid); border: 1px solid var(--color-border-light); border-radius: var(--radius-sm); }
.muted-text { margin: 0; color: var(--app-text-muted); }
.query-error { margin: 0 12px; }
.change-block b { display: block; margin-bottom: 6px; }
.change-line { display: flex; align-items: center; gap: 8px; padding: 2px 0; color: var(--color-text-regular); }
.positive { color: var(--color-success); font-weight: 600; }
.negative { color: var(--color-danger); font-weight: 600; }
.sql-actions { display: flex; justify-content: flex-end; margin-bottom: 8px; }
.sql-view, .ai-view { margin: 0; padding: 12px; max-height: 60vh; overflow: auto; overflow-wrap: anywhere;
font-family: Consolas, 'Courier New', monospace; font-size: var(--font-size-sm); line-height: 1.7;
background: var(--theme-surface-mid); border: 1px solid var(--color-border-light); border-radius: var(--radius-sm); }
/* pre-wrap 只给纯文本的 SQL:AI 的 markdown 由块级标签排版,
pre-wrap 会把标签间的换行符渲染成空行,导致内容隔得很开 */
.sql-view { white-space: pre-wrap; }
.ai-view { font-family: var(--font-family-base); }
/* AI 结果按 Markdown 排版(标题/列表/加粗/行内码),不再是纯文本符号 */
.ai-header { margin-bottom: 8px; padding: 6px 10px; font-size: var(--font-size-sm); color: var(--app-text-muted);
background: var(--theme-surface-mid); border-left: 3px solid var(--color-primary); border-radius: var(--radius-sm); }
.markdown-body { font-size: var(--font-size-base); line-height: 1.6; color: var(--color-text-regular); }
.markdown-body :deep(h3), .markdown-body :deep(h4), .markdown-body :deep(h5) {
margin: 10px 0 4px; color: var(--color-text-primary); font-weight: 600; }
.markdown-body :deep(h3):first-child, .markdown-body :deep(h4):first-child, .markdown-body :deep(h5):first-child { margin-top: 0; }
.markdown-body :deep(h3) { font-size: calc(var(--font-size-base) + 2px); }
.markdown-body :deep(ul) { margin: 2px 0; padding-left: 18px; }
.markdown-body :deep(li) { margin: 1px 0; }
.markdown-body :deep(li)::marker { color: var(--color-primary); }
.markdown-body :deep(b) { color: var(--color-text-primary); }
.markdown-body :deep(code) { padding: 1px 6px; font-family: Consolas, 'Courier New', monospace; font-size: var(--font-size-sm);
background: var(--app-bg-hover); border-radius: var(--radius-sm); }
.markdown-body :deep(p) { margin: 2px 0; }
</style>
<style>
/* AI/SQL 结果弹框(Teleport 到 body,须全局类):单个「关闭」按钮水平居中 */
.ai-result-dialog .el-dialog__footer { text-align: center; }
</style>
<template>
<div class="page-container list-shell-page">
<section class="filter-card report-toolbar"><el-select v-model="taskId" clearable filterable placeholder="全部任务" @change="loadRuns"><el-option v-for="t in tasks" :key="t.id" :label="t.name" :value="t.id" /></el-select><el-button @click="loadRuns">刷新</el-button></section>
<article class="table-card report-card">
<div class="report-layout">
<aside class="runs"><button v-for="run in runs" :key="run.id" :class="['run-item', { active: run.id === selectedRunId }]" @click="openRun(run.id)"><b>{{ formatDate(run.started_at) }}</b><span>{{ run.trigger_type === 'manual' ? '手动' : '定时' }} · <em :class="run.status">{{ statusText(run.status) }}</em></span></button><div v-if="!runs.length" class="empty-cell">暂无报告</div></aside>
<section class="report-main" v-if="report"><div v-if="report.status === 'failed'" class="report-error"><b>扫描失败</b><div>{{ report.error || '后端未返回错误原因' }}</div></div><div v-else-if="report.status === 'running'" class="report-running">扫描执行中,请稍后刷新报告</div><div v-if="report.status === 'success'" class="summary"><div><small>当前表数量</small><strong>{{ report.table_count }}</strong></div><div><small>当前总数据量</small><strong>{{ report.total_rows.toLocaleString() }}</strong><small :class="['summary-delta', deltaClass(report.total_rows_delta)]">{{ delta(report.total_rows_delta) }} 较上次</small></div><div><small>新增 / 删除表</small><strong><span :class="{ positive: report.tables_added > 0 }">{{ report.tables_added }}</span><span> / </span><span :class="{ negative: report.tables_removed > 0 }">{{ report.tables_removed }}</span></strong></div><div><small>结构变化表</small><strong>{{ report.tables_changed }}</strong></div></div><div v-if="report.status === 'success'" class="report-note">{{ report.previous_run_id ? `本次与上次成功扫描(#${report.previous_run_id})比较` : '首次扫描,仅建立基线' }} · {{ formatDate(report.finished_at) }}</div><div v-if="report.status === 'success'" class="table-scroll"><table class="config-list report-table"><thead><tr><th>数据表</th><th>本次数据量</th><th>上次数据量</th><th>数据量增减</th><th>表变化</th><th>字段变化</th><th style="width:80px;">操作</th></tr></thead><tbody><tr v-for="row in report.tables" :key="row.table_name"><td><span>{{ row.table_name }}</span><span v-if="tableComment(row)" class="table-comment" :title="tableComment(row)">({{ tableComment(row) }})</span></td><td>{{ number(row.row_count) }}</td><td>{{ number(row.previous_row_count) }}</td><td :class="deltaClass(row.row_delta)">{{ delta(row.row_delta) }}</td><td><el-tag :type="changeType(row.change)">{{ changeLabel(row) }}</el-tag></td><td><el-popover v-if="row.column_changes?.length || row.comment_changed" trigger="click" width="460"><template #reference><el-button link type="primary">{{ columnSummary(row.column_changes, row) }} · 查看明细</el-button></template><div class="change-detail"><div v-if="row.comment_changed"><b>表注释</b><div>上次:{{ row.table_comment_before || '(空)' }}</div><div>本次:{{ row.table_comment_after || '(空)' }}</div></div><div v-for="change in row.column_changes" :key="change.column" class="column-change"><b>{{ change.column }} · {{ change.kind === 'added' ? '新增' : change.kind === 'removed' ? '删除' : '修改' }}</b><div v-if="change.before">上次:{{ change.before.column_type }},注释:{{ change.before.column_comment || '(空)' }}</div><div v-if="change.after">本次:{{ change.after.column_type }},注释:{{ change.after.column_comment || '(空)' }}</div></div></div></el-popover><span v-else>—</span></td><td><button class="btn-action" @click="viewDetail(row)">详情</button></td></tr><tr v-if="!report.tables.length"><td colspan="7" class="empty-cell">本次没有可访问的数据表</td></tr></tbody></table></div></section><div v-else class="empty-cell report-empty">选择左侧记录查看报告</div>
</div>
</article>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { listAnalysisTasks, listAnalysisRuns, getAnalysisReport } from '@/api/analysis'
const route = useRoute(); const router = useRouter()
const tasks = ref([]); const taskId = ref(null); const runs = ref([]); const selectedRunId = ref(null); const report = ref(null)
const TASK_FILTER_KEY = 'analysis-reports-task-id'
const formatDate = (v) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—'; const number = (v) => v === null || v === undefined ? '—' : Number(v).toLocaleString(); const delta = (v) => v === null || v === undefined ? '基线' : `${v > 0 ? '+' : ''}${Number(v).toLocaleString()}`; const deltaClass = (v) => v > 0 ? 'positive' : v < 0 ? 'negative' : ''
const statusText = (v) => ({ success: '成功', failed: '失败', running: '执行中' }[v] || v); const changeText = (v) => ({ baseline: '基线', added: '新增', removed: '删除', changed: '结构变化', unchanged: '无变化' }[v] || v); const changeType = (v) => ({ baseline: 'info', added: 'success', removed: 'danger', changed: 'warning', unchanged: 'info' }[v] || 'info'); const tableComment = (row) => row?.table_comment_after || row?.table_comment_before || ''; const changeLabel = (row) => { if (row?.change !== 'changed') return changeText(row?.change); if (row.comment_changed && row.column_changes?.length) return '结构/注释变化'; if (row.comment_changed) return '注释变化'; return changeText(row.change) }; const columnSummary = (a, row) => { const text = !a?.length ? '' : `新增 ${a.filter(x => x.kind === 'added').length} · 删除 ${a.filter(x => x.kind === 'removed').length} · 修改 ${a.filter(x => x.kind === 'changed').length}`; return [text, row?.comment_changed ? '表注释变化' : ''].filter(Boolean).join(' · ') || '—' }
async function loadRuns() { try { if (taskId.value === null || taskId.value === undefined) localStorage.removeItem(TASK_FILTER_KEY); else localStorage.setItem(TASK_FILTER_KEY, String(taskId.value)); runs.value = await listAnalysisRuns(taskId.value); if (runs.value.length) await openRun(runs.value[0].id); else { selectedRunId.value = null; report.value = null } } catch (e) { ElMessage.error(`加载报告失败:${e.message}`) } }
async function openRun(id) { selectedRunId.value = id; try { report.value = await getAnalysisReport(id) } catch (e) { ElMessage.error(`读取报告失败:${e.message}`) } }
function viewDetail(row) { router.push({ name: 'AnalysisReportDetail', query: { run: selectedRunId.value, table: row.table_name } }) }
onMounted(async () => { try { tasks.value = await listAnalysisTasks(); const fromQuery = Number(route.query.task); const saved = Number(localStorage.getItem(TASK_FILTER_KEY)); const pick = Number.isFinite(fromQuery) && tasks.value.some((item) => item.id === fromQuery) ? fromQuery : (Number.isFinite(saved) && tasks.value.some((item) => item.id === saved) ? saved : null); if (pick) taskId.value = pick; await loadRuns() } catch (e) { ElMessage.error(`加载失败:${e.message}`) } })
</script>
<style scoped>
.report-toolbar { display: flex; gap: 10px; align-items: center; }
.report-toolbar .el-select { width: 280px; }
.report-toolbar :deep(.el-select__wrapper), .report-toolbar :deep(.el-button) { min-height: 36px; height: 36px; box-sizing: border-box; }
.report-table th, .report-table td { text-align: center; }
.report-table tbody td:first-child { text-align: left; }
.report-table { border-collapse: separate; border-spacing: 0; }
.report-table thead th { z-index: 3; background: var(--table-header-bg); box-shadow: inset 0 -1px var(--table-border-color); }
.report-card { overflow: hidden; }
.report-layout { display: flex; height: 100%; min-height: 0; overflow: hidden; }
.runs { width: 220px; flex: 0 0 220px; border-right: 1px solid var(--color-border-light); overflow: auto; }
.run-item { display: flex; flex-direction: column; gap: 5px; width: 100%; padding: 12px 14px; text-align: left; color: var(--app-text-secondary); background: transparent; border: 0; border-bottom: 1px solid var(--color-border-light); cursor: pointer; }
.run-item:hover, .run-item.active { background: var(--app-bg-hover); color: var(--app-text-primary); }
.run-item span { font-size: var(--font-size-sm); }.run-item em { font-style: normal; }.success { color: var(--color-success); }.failed { color: var(--color-danger); }.running { color: var(--color-warning); }
.report-main { flex: 1; min-width: 0; min-height: 0; padding: 0 14px 14px; display: flex; flex-direction: column; overflow: hidden; }.summary { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; }.summary div { padding: 12px; background: var(--theme-surface-mid); border: 1px solid var(--color-border-light); }.summary small { display: block; color: var(--app-text-muted); }.summary small.summary-delta { margin-top: 6px; font-weight: 600; }.summary small.summary-delta.positive { color: var(--color-success); }.summary small.summary-delta.negative { color: var(--color-danger); }.summary strong { display: block; margin-top: 5px; font-size: var(--font-size-xl); }.report-running { padding: 12px; color: var(--app-text-muted); }.report-error { margin-bottom: 12px; padding: 12px; color: var(--color-danger); background: var(--chip-danger-bg); border: 1px solid var(--chip-danger-border); }.report-note { padding: 12px 0; color: var(--app-text-muted); }.report-main .table-scroll { flex: 1 1 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }.table-comment { display: inline-block; max-width: 220px; margin-left: 6px; color: var(--app-text-muted); font-size: var(--font-size-sm); vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.change-detail { max-height: 360px; overflow: auto; }.column-change { padding: 8px 0; border-top: 1px solid var(--color-border-light); }.change-detail div { overflow-wrap: anywhere; }.positive { color: var(--color-success); font-weight: 600; }.negative { color: var(--color-danger); font-weight: 600; }.report-empty { flex: 1; display: grid; place-items: center; }
@media (max-width: 800px) { .runs { width: 170px; flex-basis: 170px; }.summary { grid-template-columns: repeat(2, 1fr); } }
</style>
<template>
<div class="page-container list-shell-page">
<section class="filter-card">
<el-form inline class="filter-form" @submit.prevent>
<el-form-item class="filter-main-actions">
<el-button @click="onReset">重置</el-button>
<el-button type="primary" @click="openAdd">+ 新建</el-button>
</el-form-item>
<el-form-item label="任务名称">
<el-input v-model="keyword" placeholder="请输入关键词" clearable style="width: 200px" @keyup.enter="onQuery" @clear="onQuery">
<template #append><el-button :icon="Search" aria-label="查询" @click="onQuery" /></template>
</el-input>
</el-form-item>
</el-form>
</section>
<article class="table-card">
<div class="table-scroll"><table class="config-list" data-page-key="analysis-tasks"><thead><tr><th>任务名称</th><th>数据源</th><th>数据库/Schema</th><th>执行频率</th><th>状态</th><th>下次执行</th><th>操作</th></tr></thead>
<tbody><tr v-if="loading"><td colspan="7" class="empty-cell">加载中...</td></tr><tr v-else-if="!pagedTasks.length"><td colspan="7" class="empty-cell">{{ filteredTasks.length ? '当前页暂无数据' : '暂无任务' }}</td></tr>
<tr v-for="item in pagedTasks" :key="item.id"><td>{{ item.name }}</td><td>{{ sourceName(item.source_id) }}</td><td>{{ item.schema }}</td><td><code>{{ item.cron_expression }}</code></td><td><el-tag :type="item.enabled ? 'success' : 'info'">{{ item.enabled ? '启用' : '停用' }}</el-tag></td><td>{{ formatDate(item.next_run_at) }}</td><td><button class="btn-action" @click="viewReports(item)">详情</button><button class="btn-action" @click="run(item)">立即执行</button><button class="btn-action" @click="edit(item)">编辑</button><button class="btn-action danger" @click="remove(item)">删除</button></td></tr>
</tbody></table></div>
<div v-if="filteredTasks.length" class="pagination-wrap"><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 15, 20, 25, 30]" :total="filteredTasks.length" layout="total, sizes, prev, pager, next" /></div>
</article>
<CommonFormDialog v-model="dialogOpen" :title="editingId ? '编辑任务' : '新建任务'" width="680px">
<el-form label-width="140px" class="analysis-form">
<el-form-item label="任务名称"><el-input v-model="form.name" placeholder="如:生产库每日表分析" /></el-form-item>
<el-form-item label="数据源"><el-select v-model="form.source_id" filterable placeholder="请选择数据源" @change="loadSchemas"><el-option v-for="s in sources" :key="s.id" :label="`${s.name}(${s.db_type})`" :value="s.id" /></el-select></el-form-item>
<el-form-item label="数据库/Schema"><el-select v-model="form.schema" filterable placeholder="请选择数据库或 Schema" :disabled="!form.source_id"><el-option v-for="s in schemas" :key="s" :label="s" :value="s" /></el-select></el-form-item>
<el-form-item label="Cron 表达式"><el-input v-model="form.cron_expression" placeholder="分 时 日 月 周,例如 0 2 * * *" /><div class="form-tip">标准 5 段格式,例:每天 02:00 为 <code>0 2 * * *</code></div></el-form-item>
<el-form-item label="AI 生成"><div class="cron-ai"><el-input v-model="cronPrompt" placeholder="例如:每天凌晨两点扫描" /><el-button :loading="generating" @click="aiCron">生成表达式</el-button></div></el-form-item>
<el-form-item label="任务状态"><el-switch v-model="form.enabled" active-text="启用定时执行" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</CommonFormDialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import CommonFormDialog from '@/components/CommonFormDialog.vue'
import { listDataSources } from '@/api/dataSources'
import { createAnalysisTask, deleteAnalysisTask, generateCron, listAnalysisTasks, runAnalysisTask, updateAnalysisTask } from '@/api/analysis'
import { http } from '@/api/client'
const tasks = ref([]); const sources = ref([]); const schemas = ref([]); const loading = ref(false); const saving = ref(false); const generating = ref(false); const dialogOpen = ref(false); const editingId = ref(null); const cronPrompt = ref('')
const router = useRouter()
const keyword = ref(''); const appliedKeyword = ref(''); const currentPage = ref(1); const pageSize = ref(10)
const filteredTasks = computed(() => { const name = appliedKeyword.value.trim().toLowerCase(); return name ? tasks.value.filter((item) => item.name.toLowerCase().includes(name)) : tasks.value })
const pageCount = computed(() => Math.max(1, Math.ceil(filteredTasks.value.length / pageSize.value)))
const pagedTasks = computed(() => filteredTasks.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
watch([filteredTasks, pageCount], () => { if (currentPage.value > pageCount.value) currentPage.value = pageCount.value })
function onQuery() { appliedKeyword.value = keyword.value; currentPage.value = 1 }
function onReset() { keyword.value = ''; appliedKeyword.value = ''; currentPage.value = 1 }
const form = reactive({ name: '', source_id: null, schema: '', cron_expression: '0 2 * * *', enabled: true })
const sourceName = (id) => sources.value.find((x) => x.id === id)?.name || '—'
const formatDate = (v) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—'
async function load() { loading.value = true; try { tasks.value = await listAnalysisTasks(); sources.value = await listDataSources(); currentPage.value = 1 } catch (e) { ElMessage.error(`加载失败:${e.message}`) } finally { loading.value = false } }
async function loadSchemas() { schemas.value = []; form.schema = ''; if (!form.source_id) return; try { const r = await http.get(`/table-design/${form.source_id}/objects`); schemas.value = r.schemas || []; form.schema = r.schema || schemas.value[0] || '' } catch (e) { ElMessage.error(`读取数据库失败:${e.message}`) } }
function reset() { Object.assign(form, { name: '', source_id: null, schema: '', cron_expression: '0 2 * * *', enabled: true }); schemas.value = []; cronPrompt.value = '' }
function openAdd() { editingId.value = null; reset(); dialogOpen.value = true }
function edit(item) { editingId.value = item.id; Object.assign(form, { name: item.name, source_id: item.source_id, schema: item.schema, cron_expression: item.cron_expression, enabled: item.enabled }); dialogOpen.value = true; loadSchemas().then(() => { form.schema = item.schema }) }
async function save() { if (!form.name.trim() || !form.source_id || !form.schema || !form.cron_expression.trim()) { ElMessage.warning('请填写任务名称、数据源、数据库和执行频率'); return }; saving.value = true; try { const row = editingId.value ? await updateAnalysisTask(editingId.value, form) : await createAnalysisTask(form); const i = tasks.value.findIndex((x) => x.id === row.id); if (i >= 0) tasks.value[i] = row; else tasks.value.unshift(row); dialogOpen.value = false; ElMessage.success('任务已保存') } catch (e) { ElMessage.error(`保存失败:${e.message}`) } finally { saving.value = false } }
async function aiCron() { if (!cronPrompt.value.trim()) return ElMessage.warning('请先描述执行频率'); generating.value = true; try { const r = await generateCron(cronPrompt.value); form.cron_expression = r.cron_expression; ElMessage.success('已生成 cron 表达式') } catch (e) { ElMessage.error(`AI 生成失败:${e.message}`) } finally { generating.value = false } }
async function run(item) { try { await runAnalysisTask(item.id); ElMessage.success('扫描已开始,可在报告中查看结果') } catch (e) { ElMessage.error(`启动失败:${e.message}`) } }
function viewReports(item) { router.push({ name: 'AnalysisReports', query: { task: item.id } }) }
async function remove(item) { try { await ElMessageBox.confirm(`确定删除任务「${item.name}」?`, '提示', { type: 'warning' }); await deleteAnalysisTask(item.id); tasks.value = tasks.value.filter((x) => x.id !== item.id) } catch (_) {} }
onMounted(load)
</script>
<style scoped>
.analysis-form :deep(.el-select), .analysis-form :deep(.el-input) { width: 100%; }
.analysis-form :deep(.el-form-item__label) { white-space: nowrap; }
.form-tip { color: var(--app-text-muted); font-size: var(--font-size-sm); margin-top: 5px; }
.cron-ai { display: flex; gap: 8px; width: 100%; }
.cron-ai .el-input { flex: 1; }
code { color: var(--color-primary); }
.config-list th, .config-list td { text-align: center; }
</style>
<template>
<div class="home-page">
<section class="metrics-grid" aria-label="核心指标">
<article v-for="metric in metrics" :key="metric.label" class="metric-card" :class="`metric-${metric.tone}`">
<div class="metric-icon"><el-icon><component :is="metric.icon" /></el-icon></div>
<div class="metric-copy"><span>{{ metric.label }}</span><strong>{{ metric.value }}</strong><small>{{ metric.note }}</small></div>
<div class="metric-accent" aria-hidden="true"></div>
</article>
</section>
<section class="module-overview" aria-label="模块统计">
<div v-for="item in moduleOverview" :key="item.label" class="module-stat"><span class="module-stat-dot" :class="item.tone"></span><span>{{ item.label }}</span><strong>{{ item.value }}</strong><small>{{ item.unit }}</small></div>
</section>
<section class="dashboard-grid">
<article class="dashboard-panel trend-panel">
<div class="panel-heading"><div><h2>操作趋势</h2></div><span class="panel-badge">总计 {{ totalOperations }}</span></div>
<div class="trend-chart" aria-label="近七天操作趋势图">
<div class="axis-labels"><span>40</span><span>30</span><span>20</span><span>10</span><span>0</span></div>
<div class="chart-area">
<div class="grid-lines"><i v-for="line in 5" :key="line"></i></div>
<div class="bar-group" v-for="day in trend" :key="day.label" @mouseenter="hoveredDay = day" @mouseleave="hoveredDay = null">
<div class="bars"><span v-for="series in seriesList" :key="series.key" class="bar" :class="series.key" :style="{ height: `${barHeight(day[series.key])}%` }"></span></div>
<div v-if="hoveredDay && hoveredDay.date === day.date" class="bar-tooltip"><strong>{{ day.label }}</strong><span v-for="series in seriesList" :key="series.key"><i :class="series.key"></i>{{ series.label }} {{ day[series.key] }} 次</span></div>
<span class="bar-label">{{ day.label }}</span>
</div>
</div>
</div>
<div class="chart-legend"><span v-for="series in seriesList" :key="series.key"><i :class="series.key"></i>{{ series.label }}</span></div>
</article>
<article class="dashboard-panel distribution-panel">
<div class="panel-heading"><div><h2>操作分布</h2></div><span class="panel-badge">{{ totalOperations }} 次</span></div>
<div class="donut-wrap" @mouseleave="hoveredDistribution = null">
<div class="donut" :style="donutStyle" @mousemove="showDistributionTooltip"><div class="donut-center"><strong>{{ totalOperations }}</strong><span>累计操作</span></div></div>
<div v-if="hoveredDistribution" class="donut-tooltip" :style="{ left: `${hoveredDistribution.x}px`, top: `${hoveredDistribution.y}px` }"><i :class="hoveredDistribution.key"></i>{{ hoveredDistribution.label }}:{{ hoveredDistribution.value }} 次({{ hoveredDistribution.percent }}%)</div>
<div class="distribution-list"><div v-for="item in distribution" :key="item.key" class="distribution-item"><span class="distribution-name"><i :class="item.key"></i>{{ item.label }}</span><strong>{{ item.value }}</strong><small>{{ item.percent }}%</small></div></div>
</div>
</article>
</section>
<section class="lower-grid">
<article class="dashboard-panel shortcuts-panel">
<div class="panel-heading"><div><h2>常用功能</h2></div></div>
<div class="shortcut-grid"><button v-for="item in shortcuts" :key="item.label" class="shortcut" type="button" @click="go(item.path)"><span class="shortcut-icon" :class="item.tone"><el-icon><component :is="item.icon" /></el-icon></span><span>{{ item.label }}</span><el-icon class="shortcut-arrow"><ArrowRight /></el-icon></button></div>
</article>
<article class="dashboard-panel activity-panel">
<div class="panel-heading"><div><h2>最近操作</h2></div><el-button text class="view-all" @click="go('/backup')">查看全部<el-icon><ArrowRight /></el-icon></el-button></div>
<div v-if="recentActivities.length" class="activity-list"><button v-for="item in recentActivities" :key="item.id" type="button" class="activity-row" @click="go(item.path)"><span class="activity-mark" :class="item.tone"><el-icon><component :is="item.icon" /></el-icon></span><span class="activity-main"><strong>{{ item.title }}</strong><small>{{ item.description }}</small></span><time>{{ item.time }}</time></button></div>
<div v-else class="activity-empty"><el-icon><Document /></el-icon><span>暂无操作记录</span></div>
</article>
<article class="dashboard-panel analysis-panel">
<div class="analysis-heading"><h2>分析概览</h2><span class="analysis-heading-count">{{ analysisOverview.recent.length }} 条最近报告</span></div>
<div class="analysis-summary">
<button class="analysis-stat analysis-stat-task" type="button" @click="go('/analysis/tasks')"><span class="analysis-stat-label"><i></i>分析任务</span><strong>{{ analysisOverview.tasks.total }}</strong><small><b>{{ analysisOverview.tasks.enabled }}</b> 启用 <em>·</em> {{ analysisOverview.tasks.disabled }} 停用</small></button>
<button class="analysis-stat analysis-stat-report" type="button" @click="go('/analysis/reports')"><span class="analysis-stat-label"><i></i>分析报告</span><strong>{{ analysisOverview.reports.total }}</strong><small><b>{{ analysisOverview.reports.success }}</b> 成功 <em>·</em> {{ analysisOverview.reports.running }} 执行中 <em>·</em> {{ analysisOverview.reports.failed }} 失败</small></button>
</div>
<div v-if="analysisOverview.recent.length" class="analysis-reports"><div class="analysis-report-list"><button v-for="item in analysisOverview.recent" :key="item.id" type="button" class="analysis-report-row" @click="go('/analysis/reports')"><span class="analysis-status" :class="item.status"></span><span class="analysis-report-main"><strong>{{ item.task_name }}</strong><small>{{ formatTime(item.finished_at || item.started_at) }}</small></span><span class="analysis-report-result" :class="item.status">{{ analysisStatusText(item.status) }}<template v-if="item.status === 'success'"> · {{ item.table_count }} 张表 · {{ number(item.total_rows) }} 行 · 变化 {{ item.tables_changed }}</template></span><el-icon><ArrowRight /></el-icon></button></div></div>
<div v-else class="activity-empty"><el-icon><Document /></el-icon><span>暂无分析报告</span></div>
</article>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowRight, Coin, DataAnalysis, Delete, Document, Download, Files, Lock, Refresh, Setting, Unlock } from '@element-plus/icons-vue'
import { getDashboardSummary } from '@/api/dashboard'
const router = useRouter()
const counts = ref({ backup: 0, restore: 0, clear: 0, encrypt: 0, decrypt: 0, sources: 0, tables: 0, tasks: 0, versions: 0 })
const recentActivities = ref([])
const analysisOverview = ref({ tasks: { total: 0, enabled: 0, disabled: 0 }, reports: { total: 0, success: 0, running: 0, failed: 0 }, recent: [] })
const operationLabels = { backup: '备份', restore: '还原', clear: '清表', encrypt: '加密', decrypt: '解密' }
const seriesList = [
{ key: 'backup', label: '备份' }, { key: 'restore', label: '还原' }, { key: 'clear', label: '清表' }, { key: 'encrypt', label: '加密' }, { key: 'decrypt', label: '解密' },
]
const shortcuts = [
{ label: '新建备份', path: '/backup', icon: Download, tone: 'blue' }, { label: '还原备份', path: '/restore', icon: Refresh, tone: 'green' },
{ label: '数据加密', path: '/encrypt', icon: Lock, tone: 'purple' }, { label: '数据解密', path: '/decrypt', icon: Unlock, tone: 'blue' },
]
const metrics = computed(() => [
{ label: '累计操作', value: totalOperations.value, note: '备份 / 还原 / 清表 / 加密', icon: Coin, tone: 'blue' },
{ label: '备份版本', value: counts.value.versions, note: '可用备份文件', icon: Download, tone: 'green' },
{ label: '数据源', value: counts.value.sources, note: '已配置连接', icon: Setting, tone: 'orange' },
{ label: '校验任务', value: counts.value.tasks, note: `${counts.value.tables} 个表设计配置`, icon: DataAnalysis, tone: 'purple' },
{ label: '分析', value: analysisOverview.value.tasks.total, note: `${analysisOverview.value.reports.total} 份分析报告`, icon: DataAnalysis, tone: 'analysis' },
])
const moduleOverview = computed(() => [
{ label: '备份', value: counts.value.backup, unit: '次', tone: 'backup' }, { label: '还原', value: counts.value.restore, unit: '次', tone: 'restore' },
{ label: '清表', value: counts.value.clear, unit: '次', tone: 'clear' }, { label: '加密', value: counts.value.encrypt, unit: '次', tone: 'encrypt' },
{ label: '解密', value: counts.value.decrypt, unit: '次', tone: 'decrypt' }, { label: '表设计', value: counts.value.tables, unit: '项', tone: 'design' },
{ label: '数据', value: counts.value.tasks, unit: '任务', tone: 'data' },
{ label: '分析', value: analysisOverview.value.tasks.total, unit: '任务', tone: 'analysis' },
{ label: '报告', value: analysisOverview.value.reports.total, unit: '份', tone: 'report' },
])
const totalOperations = computed(() => Object.values(counts.value).slice(0, 5).reduce((sum, value) => sum + value, 0))
const distribution = computed(() => {
const items = ['backup', 'restore', 'clear', 'encrypt', 'decrypt'].map(key => ({ key, label: operationLabels[key], value: counts.value[key] }))
return items.map(item => ({ ...item, percent: totalOperations.value ? Math.round(item.value / totalOperations.value * 100) : 0 }))
})
const donutStyle = computed(() => {
const colors = ['#2990ff', '#24ca49', '#f7b940', '#a67cff', '#fd6258']
let offset = 0
const parts = distribution.value.map((item, index) => { const start = offset; offset += totalOperations.value ? item.value / totalOperations.value * 100 : 0; return `${colors[index]} ${start}% ${offset}%` })
return { background: parts.length && totalOperations.value ? `conic-gradient(${parts.join(', ')})` : 'conic-gradient(var(--color-border-light) 0 100%)' }
})
const trend = ref(buildTrend())
const hoveredDay = ref(null)
const hoveredDistribution = ref(null)
function buildTrend() {
const now = new Date()
return Array.from({ length: 7 }, (_, index) => { const d = new Date(now); d.setDate(now.getDate() - (6 - index)); return { date: d.toISOString().slice(0, 10), label: `${d.getMonth() + 1}/${d.getDate()}`, backup: 0, restore: 0, clear: 0, encrypt: 0, decrypt: 0 } })
}
function barHeight(value) { return Math.min(100, Math.max(4, value / Math.max(10, ...trend.value.flatMap(day => seriesList.map(series => day[series.key]))) * 100)) }
function showDistributionTooltip(event) {
if (!totalOperations.value) return
const bounds = event.currentTarget.getBoundingClientRect()
const relativeAngle = (Math.atan2(event.clientY - bounds.top - bounds.height / 2, event.clientX - bounds.left - bounds.width / 2) * 180 / Math.PI + 450) % 360
let cumulative = 0
const item = distribution.value.find(entry => {
cumulative += entry.value / totalOperations.value * 360
return relativeAngle < cumulative
})
if (!item) return
const wrap = event.currentTarget.parentElement.getBoundingClientRect()
hoveredDistribution.value = { ...item, x: Math.min(event.clientX - wrap.left + 12, wrap.width - 190), y: Math.max(0, event.clientY - wrap.top - 16) }
}
function formatTime(value) { if (!value) return ''; const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : date.toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }
function number(value) { return Number(value || 0).toLocaleString('zh-CN') }
function analysisStatusText(value) { return ({ success: '成功', failed: '失败', running: '执行中' })[value] || value }
function go(path) { router.push(path) }
async function loadDashboard() {
try {
const summary = await getDashboardSummary()
counts.value = { ...counts.value, ...summary.counts, sources: summary.sources, tasks: summary.tasks, tables: summary.tables, versions: summary.versions }
const analysis = summary.analysis || analysisOverview.value
// 首页分析概览只展示最近 4 条报告,避免面板过高
analysisOverview.value = { ...analysis, recent: (analysis.recent || []).slice(0, 4) }
recentActivities.value = (summary.recent || []).map((row, index) => ({ id: `${row.operation}-${row.id || index}`, title: operationLabels[row.operation], description: row.execute_setting || `${row.database || '数据库'} · ${(row.tables || []).join('、') || '整库'}`, time: formatTime(row.created_at), path: row.operation === 'clear' ? '/clear-tables' : `/${row.operation}`, icon: row.operation === 'backup' ? Download : row.operation === 'restore' ? Refresh : row.operation === 'encrypt' ? Lock : row.operation === 'decrypt' ? Unlock : Document, tone: row.operation }))
trend.value = buildTrend()
;(summary.trend || []).forEach(row => { const day = trend.value.find(item => item.date === row.date); if (day && day[row.operation] !== undefined) day[row.operation] += row.count })
} catch (_) {
// 首页统计接口不可用时保留空态,不阻塞首页其它模块渲染。
recentActivities.value = []
trend.value = buildTrend()
}
}
onMounted(loadDashboard)
</script>
<style scoped>
.home-page { height: 100%; color: var(--color-text-regular); padding: 0; box-sizing: border-box; display: grid; grid-template-rows: auto auto minmax(200px, 1fr) minmax(0, 1fr); align-content: start; gap: var(--spacing-base); overflow: visible; }
h2, p { margin: 0; }
.metrics-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--spacing-base); min-height: 0; }
.metric-card, .dashboard-panel { background: var(--app-bg-card); border: 1px solid var(--color-border-light); box-shadow: var(--shadow-card); border-radius: var(--radius-base); }
.metric-card { min-height: 96px; padding: 12px 16px; box-sizing: border-box; display: flex; align-items: center; gap: 13px; position: relative; overflow: hidden; }
.metric-icon { width: 42px; height: 42px; display: inline-flex; align-items: center; justify-content: center; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 14%, transparent); border: 1px solid color-mix(in srgb, var(--color-primary) 35%, transparent); border-radius: 10px; font-size: 21px; flex: 0 0 auto; }
.metric-copy { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .metric-copy span { color: var(--app-text-muted); font-size: var(--font-size-sm); } .metric-copy strong { color: var(--color-text-primary); font-size: calc(28px * var(--font-scale-ratio)); line-height: 1.15; font-weight: 500; } .metric-copy small { color: var(--app-text-dim); font-size: var(--font-size-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.metric-accent { position: absolute; right: -18px; bottom: -30px; width: 80px; height: 80px; border-radius: 50%; border: 14px solid color-mix(in srgb, var(--color-primary) 12%, transparent); } .metric-green .metric-icon { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 14%, transparent); border-color: color-mix(in srgb, var(--color-success) 35%, transparent); } .metric-orange .metric-icon { color: var(--color-warning); background: color-mix(in srgb, var(--color-warning) 14%, transparent); border-color: color-mix(in srgb, var(--color-warning) 35%, transparent); } .metric-purple .metric-icon { color: #a67cff; background: color-mix(in srgb, #a67cff 14%, transparent); border-color: color-mix(in srgb, #a67cff 35%, transparent); } .metric-analysis .metric-icon { color: #36c9b0; background: color-mix(in srgb, #36c9b0 14%, transparent); border-color: color-mix(in srgb, #36c9b0 35%, transparent); } .metric-analysis .metric-accent { border-color: color-mix(in srgb, #36c9b0 12%, transparent); }
.module-overview { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 1px; background: var(--color-border-light); border: 1px solid var(--color-border-light); border-radius: var(--radius-base); overflow: hidden; min-height: 0; } .module-stat { background: var(--app-bg-card); min-height: 42px; padding: 6px 10px; box-sizing: border-box; display: flex; align-items: center; gap: 7px; font-size: var(--font-size-xs); color: var(--app-text-muted); } .module-stat strong { color: var(--color-text-primary); font-size: var(--font-size-base); font-weight: 500; margin-left: auto; } .module-stat small { color: var(--app-text-dim); } .module-stat-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--color-primary); flex: 0 0 auto; } .module-stat-dot.backup { background: #2990ff; } .module-stat-dot.restore { background: #24ca49; } .module-stat-dot.clear { background: #f7b940; } .module-stat-dot.encrypt { background: #a67cff; } .module-stat-dot.decrypt { background: #fd6258; } .module-stat-dot.design { background: #26b8c9; } .module-stat-dot.data { background: #ec6da6; } .module-stat-dot.analysis { background: #36c9b0; } .module-stat-dot.report { background: #d07cff; }
.dashboard-grid, .lower-grid { display: grid; gap: var(--spacing-base); min-height: 0; } .dashboard-grid { grid-template-columns: minmax(0, 1.55fr) minmax(360px, 1fr); } .lower-grid { grid-template-columns: minmax(220px, 430px) minmax(300px, 1fr) minmax(360px, 1fr); grid-template-rows: minmax(0, 1fr); } .shortcuts-panel, .activity-panel, .analysis-panel { min-height: 0; }
.dashboard-panel { padding: 14px; min-width: 0; min-height: 0; box-sizing: border-box; overflow: hidden; } .dashboard-grid > .dashboard-panel, .lower-grid > .dashboard-panel { display: flex; flex-direction: column; } .panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; flex: 0 0 auto; } .panel-heading h2 { color: var(--color-text-primary); font-size: var(--font-size-lg); font-weight: 500; } .panel-heading p { color: var(--app-text-muted); font-size: var(--font-size-xs); margin-top: 3px; } .panel-badge { color: var(--color-primary); font-size: var(--font-size-xs); border: 1px solid color-mix(in srgb, var(--color-primary) 35%, transparent); background: color-mix(in srgb, var(--color-primary) 10%, transparent); padding: 3px 8px; border-radius: var(--radius-sm); }
.trend-chart { flex: 1 1 auto; min-height: 0; display: flex; gap: 8px; } .axis-labels { display: flex; flex-direction: column; justify-content: space-between; color: var(--app-text-dim); font-size: var(--font-size-xs); padding-bottom: 21px; } .chart-area { position: relative; flex: 1; display: flex; justify-content: space-around; gap: 6px; padding: 0 2px; min-width: 0; min-height: 0; } .grid-lines { position: absolute; inset: 0 0 20px; display: flex; flex-direction: column; justify-content: space-between; } .grid-lines i { border-top: 1px dashed var(--color-border-light); } .bar-group { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 6px; z-index: 1; flex: 1; min-width: 20px; } .bars { height: 100%; min-height: 40px; display: flex; align-items: flex-end; gap: 2px; } .bar { display: block; width: clamp(5px, 1.1vw, 12px); min-height: 4px; transition: height .3s ease; } .bar-label { color: var(--app-text-muted); font-size: var(--font-size-xs); white-space: nowrap; } .bar.backup, .distribution-item .backup, .chart-legend .backup { background: #2990ff; color: #2990ff; } .bar.restore, .distribution-item .restore, .chart-legend .restore { background: #24ca49; color: #24ca49; } .bar.clear, .distribution-item .clear, .chart-legend .clear { background: #f7b940; color: #f7b940; } .bar.encrypt, .distribution-item .encrypt, .chart-legend .encrypt { background: #a67cff; color: #a67cff; } .bar.decrypt, .distribution-item .decrypt, .chart-legend .decrypt { background: #fd6258; color: #fd6258; } .chart-legend { display: flex; flex-wrap: wrap; gap: 12px; padding: 6px 0 0 30px; color: var(--app-text-muted); font-size: var(--font-size-xs); flex: 0 0 auto; } .chart-legend span { display: inline-flex; align-items: center; gap: 6px; } .chart-legend i, .distribution-name i { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: currentColor; }
.donut-wrap { flex: 1 1 auto; min-height: 0; display: flex; align-items: center; gap: clamp(16px, 3vw, 32px); position: relative; } .donut { width: clamp(108px, 30%, 150px); aspect-ratio: 1; height: auto; border-radius: 50%; display: grid; place-items: center; flex: 0 0 auto; position: relative; } .donut::before { content: ''; width: 66%; height: 66%; border-radius: 50%; background: var(--app-bg-card); position: absolute; } .donut-center { z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; } .donut-center strong { color: var(--color-text-primary); font-size: calc(24px * var(--font-scale-ratio)); font-weight: 500; } .donut-center span { color: var(--app-text-muted); font-size: var(--font-size-xs); } .distribution-list { flex: 1; min-width: 140px; } .distribution-item { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 12px; padding: 5px 0; border-bottom: 1px solid var(--color-border-light); font-size: var(--font-size-sm); } .distribution-item:last-child { border-bottom: none; } .distribution-name { display: inline-flex; align-items: center; gap: 8px; color: var(--app-text-secondary); } .distribution-item strong { color: var(--color-text-primary); font-weight: 500; } .distribution-item small { color: var(--app-text-muted); min-width: 34px; text-align: right; }
.shortcut-grid { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 1fr; grid-auto-rows: minmax(0, 1fr); gap: var(--spacing-sm); } .shortcut { border: 1px solid var(--color-border-light); background: var(--theme-surface-soft); color: var(--color-text-regular); min-height: 0; padding: 6px 9px; border-radius: var(--radius-sm); display: flex; align-items: center; gap: 8px; font: inherit; cursor: pointer; text-align: left; transition: border-color var(--transition-fast), background var(--transition-fast); } .shortcut:hover { border-color: var(--color-primary); background: var(--app-bg-hover); } .shortcut-icon { width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; font-size: 16px; flex: 0 0 auto; border-radius: var(--radius-sm); } .shortcut-icon.blue { color: #2990ff; } .shortcut-icon.green { color: #24ca49; } .shortcut-icon.orange { color: #f7b940; } .shortcut-icon.purple { color: #a67cff; } .shortcut-arrow { margin-left: auto; color: var(--app-text-dim); font-size: 14px; } .shortcut:hover .shortcut-arrow { color: var(--color-primary); }
.view-all { color: var(--color-primary); font-size: var(--font-size-xs); padding: 0; } .panel-actions { display: flex; align-items: center; gap: 10px; } .activity-list { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: auto; } .activity-row { flex: 1 1 0; min-height: 40px; border: 0; border-bottom: 1px solid var(--color-border-light); background: transparent; color: inherit; padding: 6px 0; display: flex; align-items: center; gap: 9px; text-align: left; cursor: pointer; font: inherit; } .activity-row:last-child { border-bottom: 0; } .activity-row:hover .activity-main strong { color: var(--color-primary); } .activity-mark { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent); flex: 0 0 auto; } .activity-mark.restore { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 12%, transparent); } .activity-mark.clear { color: var(--color-warning); background: color-mix(in srgb, var(--color-warning) 12%, transparent); } .activity-mark.encrypt, .activity-mark.decrypt { color: #a67cff; background: color-mix(in srgb, #a67cff 12%, transparent); } .activity-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } .activity-main strong { color: var(--color-text-regular); font-size: var(--font-size-sm); font-weight: 400; } .activity-main small { color: var(--app-text-muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--font-size-xs); } .activity-row time { color: var(--app-text-dim); font-size: var(--font-size-xs); white-space: nowrap; } .activity-empty { flex: 1; min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--app-text-muted); font-size: var(--font-size-sm); }
.activity-list { overflow: visible; }
.activity-row { min-height: 0; padding: 2px 0; }
.activity-mark { width: 24px; height: 24px; border-radius: var(--radius-sm); }
.activity-main { gap: 1px; }
.shortcut-grid { flex: 1 1 auto; grid-auto-rows: minmax(52px, 1fr); align-content: stretch; }
.analysis-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.analysis-heading h2 { color: var(--color-text-primary); font-size: var(--font-size-lg); font-weight: 500; }
.analysis-heading-count { color: var(--app-text-dim); font-size: var(--font-size-xs); }
.analysis-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 8px; }
.analysis-stat { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; align-items: center; gap: 4px 12px; padding: 8px 12px; border: 1px solid var(--color-border-light); border-left: 3px solid var(--color-primary); border-radius: var(--radius-sm); background: var(--theme-surface-mid); color: inherit; cursor: pointer; font: inherit; text-align: left; transition: border-color var(--transition-fast), background var(--transition-fast); }
.analysis-stat-task { border-left-color: #36c9b0; }
.analysis-stat-report { border-left-color: #a67cff; }
.analysis-stat:hover { border-color: var(--color-primary); background: var(--app-bg-hover); }
.analysis-stat-label { color: var(--app-text-muted); font-size: var(--font-size-xs); display: inline-flex; align-items: center; gap: 7px; }
.analysis-stat-label i { width: 7px; height: 7px; border-radius: 50%; background: var(--color-primary); }
.analysis-stat-task .analysis-stat-label i { background: #36c9b0; }
.analysis-stat-report .analysis-stat-label i { background: #a67cff; }
.analysis-stat small { grid-column: 1 / -1; color: var(--app-text-muted); font-size: var(--font-size-xs); }
.analysis-stat small b { color: var(--color-text-regular); font-weight: 500; }
.analysis-stat small em { color: var(--app-text-dim); font-style: normal; margin: 0 3px; }
.analysis-stat strong { color: var(--color-text-primary); font-size: var(--font-size-xl); line-height: 1; font-weight: 500; }
.analysis-reports { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.analysis-report-list { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-top: 1px solid var(--color-border-light); }
.analysis-report-row { flex: 1 1 0; display: grid; grid-template-columns: 7px minmax(130px, 180px) 1fr 14px; justify-content: start; align-items: center; gap: 9px; min-height: 30px; padding: 4px 0; border: 0; border-bottom: 1px solid var(--color-border-light); background: transparent; color: inherit; cursor: pointer; font: inherit; text-align: left; }
.analysis-report-row:last-child { border-bottom: 0; }
.analysis-report-row:hover .analysis-report-main strong { color: var(--color-primary); }
.analysis-status { width: 7px; height: 7px; border-radius: 50%; background: var(--color-info); flex: 0 0 auto; }
.analysis-status.success { background: var(--color-success); } .analysis-status.failed { background: var(--color-danger); } .analysis-status.running { background: var(--color-warning); }
.analysis-report-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.analysis-report-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-regular); font-size: var(--font-size-sm); font-weight: 400; }
.analysis-report-main small { color: var(--app-text-muted); font-size: var(--font-size-xs); }
.analysis-report-result { max-width: min(520px, 48vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--app-text-muted); font-size: var(--font-size-xs); }
.analysis-report-result.success { color: var(--color-success); }
.analysis-report-result.failed { color: var(--color-danger); }
.analysis-report-result.running { color: var(--color-warning); }
.trend-panel { position: relative; }
.trend-panel .chart-legend { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); order: initial; justify-content: center; padding: 0; z-index: 2; }
.trend-panel .trend-chart { order: initial; }
.bar-group { position: relative; }
.bar-tooltip, .donut-tooltip { z-index: 3; pointer-events: none; padding: 7px 10px; border: 1px solid color-mix(in srgb, var(--color-primary) 45%, transparent); border-radius: var(--radius-sm); background: var(--app-bg-card); color: var(--color-text-primary); font-size: var(--font-size-xs); box-shadow: var(--shadow-card); }
.bar-tooltip { position: absolute; top: 0; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 4px; white-space: nowrap; }
.bar-tooltip strong { font-weight: 500; margin-bottom: 2px; }
.bar-tooltip span { display: flex; align-items: center; gap: 6px; color: var(--app-text-secondary); }
.bar-tooltip i, .donut-tooltip i { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: currentColor; }
.bar-tooltip .backup, .donut-tooltip .backup { color: #2990ff; }
.bar-tooltip .restore, .donut-tooltip .restore { color: #24ca49; }
.bar-tooltip .clear, .donut-tooltip .clear { color: #f7b940; }
.bar-tooltip .encrypt, .donut-tooltip .encrypt { color: #a67cff; }
.bar-tooltip .decrypt, .donut-tooltip .decrypt { color: #fd6258; }
.donut-tooltip { position: absolute; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
@media (max-width: 1100px) { .home-page { height: auto; min-height: 100%; grid-template-rows: auto auto minmax(200px, 26vh) auto; align-content: start; } .metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .module-overview { grid-template-columns: repeat(4, minmax(0, 1fr)); } .dashboard-grid, .lower-grid { grid-template-columns: 1fr; grid-template-rows: auto; } .dashboard-grid > .dashboard-panel { min-height: 280px; } .lower-grid > .dashboard-panel { min-height: 190px; } .analysis-panel { grid-column: auto; } .analysis-report-row { grid-template-columns: 7px minmax(120px, 160px) 1fr 14px; } .analysis-report-result { max-width: 42vw; } }
@media (max-width: 640px) { .home-page { padding: 0; } .metrics-grid, .module-overview { grid-template-columns: 1fr; } .donut-wrap { flex-direction: row; gap: 14px; } .donut { width: 112px; } .shortcut-grid { grid-template-columns: 1fr; } .dashboard-panel { padding: 12px; } .trend-panel .chart-legend { position: static; transform: none; padding: 0 0 8px; } .trend-panel .trend-chart { order: 2; } }
</style>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment