From e3843180467cf7ceafb2e820b7360270c435037d Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 28 May 2026 16:24:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=20ECharts=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=9B=BE=E8=A1=A8=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=91=98=E5=B7=A5=E7=94=BB=E5=83=8F=E6=A0=87=E7=AD=BE=E5=88=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端优化员工行为画像服务和辅助函数,完善系统设置模型和 配置持久化,前端引入 ECharts 替换所有图表组件实现统一 渲染,新增员工画像标签分页器和数字员工工作记录组件,优 化工作台响应式布局和登录页过渡动画,完善预算中心和数字 员工页面样式细节。 --- .../app/api/v1/endpoints/employee_profiles.py | 78 ++ server/src/app/models/system_setting.py | 1 + server/src/app/schemas/settings.py | 13 + .../employee_behavior_profile_helpers.py | 28 + .../employee_behavior_profile_service.py | 29 - server/src/app/services/settings.py | 237 +++-- .../test_employee_behavior_profile_service.py | 34 + web/package-lock.json | 32 + web/package.json | 1 + web/src/assets/styles/app.css | 163 ++- .../digital-employee-work-records.css | 548 ++++++++++ .../personal-workbench-responsive.css | 19 +- .../styles/components/personal-workbench.css | 10 +- web/src/assets/styles/global.css | 19 + .../styles/views/budget-center-dialog.css | 26 +- .../styles/views/budget-center-view.css | 227 ++-- .../styles/views/digital-employees-view.css | 8 + .../styles/views/employee-management-view.css | 4 +- web/src/assets/styles/views/logs-view.css | 987 +++++++----------- .../travel-request-detail-view-part2.css | 48 +- .../views/travel-request-detail-view.css | 79 +- .../audit/DigitalEmployeeWorkRecords.vue | 332 ++++++ .../business/ExpenseProfileDetailModal.vue | 369 ++++--- .../business/ExpenseProfileTagPager.vue | 322 ++++++ .../components/business/PersonalWorkbench.vue | 107 +- web/src/components/charts/BarChart.vue | 193 ++-- .../components/charts/BudgetTrendChart.vue | 11 +- web/src/components/charts/DonutChart.vue | 139 +-- web/src/components/charts/GaugeChart.vue | 109 +- web/src/components/charts/RadarChart.vue | 346 ++++-- web/src/components/charts/TrendChart.vue | 285 ++--- web/src/components/layout/SidebarRail.vue | 2 +- web/src/components/layout/TopBar.vue | 28 +- .../travel/BudgetAssistantReport.vue | 20 +- web/src/composables/useEcharts.js | 79 ++ web/src/composables/useNavigation.js | 8 +- web/src/composables/useSettings.js | 15 +- web/src/composables/useSystemState.js | 179 ++-- web/src/data/personalWorkbench.js | 2 +- web/src/services/reimbursements.js | 12 + web/src/utils/employeeProfileViewModel.js | 445 ++++++++ web/src/utils/loginEntryTransition.js | 35 + web/src/utils/settingsModelHelper.js | 5 + web/src/views/AppShellRouteView.vue | 54 +- web/src/views/BudgetCenterView.vue | 171 ++- web/src/views/DigitalEmployeesView.vue | 26 +- web/src/views/LoginRouteView.vue | 12 + web/src/views/LogsView.vue | 419 ++++---- web/src/views/TravelRequestDetailView.vue | 34 +- web/src/views/scripts/BudgetCenterView.js | 8 +- web/src/views/scripts/LogsView.js | 695 +++++------- .../scripts/budgetAssistantReportModel.js | 6 +- .../digitalEmployeeWorkRecordsModel.js | 107 ++ 53 files changed, 4698 insertions(+), 2468 deletions(-) create mode 100644 web/src/assets/styles/components/digital-employee-work-records.css create mode 100644 web/src/components/audit/DigitalEmployeeWorkRecords.vue create mode 100644 web/src/components/business/ExpenseProfileTagPager.vue create mode 100644 web/src/composables/useEcharts.js create mode 100644 web/src/utils/employeeProfileViewModel.js create mode 100644 web/src/utils/loginEntryTransition.js create mode 100644 web/src/views/scripts/digitalEmployeeWorkRecordsModel.js diff --git a/server/src/app/api/v1/endpoints/employee_profiles.py b/server/src/app/api/v1/endpoints/employee_profiles.py index 61256ea..3d8f0d0 100644 --- a/server/src/app/api/v1/endpoints/employee_profiles.py +++ b/server/src/app/api/v1/endpoints/employee_profiles.py @@ -3,9 +3,11 @@ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext, get_current_user, get_db +from app.models.employee import Employee from app.schemas.employee_profile import EmployeeProfileLatestRead from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService @@ -14,6 +16,53 @@ DbSession = Annotated[Session, Depends(get_db)] CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] +@router.get( + "/me/latest", + response_model=EmployeeProfileLatestRead, + summary="读取当前登录人的最新用户画像", + description="按当前登录用户的邮箱或姓名匹配员工目录,返回个人工作台使用的综合用户画像。", +) +def get_current_employee_latest_profile( + db: DbSession, + current_user: CurrentUser, + scene: Annotated[str, Query(max_length=50)] = "operations", + window_days: Annotated[int, Query(ge=1, le=365)] = 90, + expense_type_scope: Annotated[str, Query(max_length=50)] = "overall", +) -> EmployeeProfileLatestRead: + employee = _resolve_current_employee(db, current_user) + if employee is None: + return EmployeeProfileLatestRead( + employee_id=current_user.username, + employee_name=current_user.name, + scene=scene, + window_days=window_days, + expense_type_scope=expense_type_scope, + empty_reason="当前登录用户未匹配到员工目录,暂无法形成用户画像。", + ) + + service = EmployeeBehaviorProfileService(db) + latest = service.get_latest_profile( + employee_id=employee.id, + scene=scene, + window_days=window_days, + expense_type_scope=expense_type_scope, + ) + if latest.empty_reason: + service.refresh_employee_profiles( + employee_id=employee.id, + window_days=(window_days,), + expense_type_scope=expense_type_scope, + source_task_type="workbench_on_demand", + ) + latest = service.get_latest_profile( + employee_id=employee.id, + scene=scene, + window_days=window_days, + expense_type_scope=expense_type_scope, + ) + return latest + + @router.get( "/{employee_id}/latest", response_model=EmployeeProfileLatestRead, @@ -37,3 +86,32 @@ def get_employee_latest_profile( window_days=window_days, expense_type_scope=expense_type_scope, ) + + +def _resolve_current_employee( + db: Session, + current_user: CurrentUserContext, +) -> Employee | None: + identities = [ + str(current_user.username or "").strip(), + str(current_user.name or "").strip(), + ] + normalized = [item for item in dict.fromkeys(identities) if item] + if not normalized: + return None + + email_values = [item.lower() for item in normalized if "@" in item] + exact_values = [item for item in normalized if "@" not in item] + conditions = [] + + if email_values: + conditions.append(func.lower(Employee.email).in_(email_values)) + if exact_values: + conditions.append(Employee.name.in_(exact_values)) + conditions.append(Employee.employee_no.in_(exact_values)) + + if not conditions: + return None + + stmt = select(Employee).where(or_(*conditions)).order_by(Employee.created_at.asc()).limit(1) + return db.scalars(stmt).first() diff --git a/server/src/app/models/system_setting.py b/server/src/app/models/system_setting.py index 94557be..2db3838 100644 --- a/server/src/app/models/system_setting.py +++ b/server/src/app/models/system_setting.py @@ -18,6 +18,7 @@ class SystemSetting(Base): company_code: Mapped[str] = mapped_column(String(64), default="XF-001") record_number: Mapped[str] = mapped_column(String(120), default="") copyright_text: Mapped[str] = mapped_column(String(255), default="") + theme_skin: Mapped[str] = mapped_column(String(64), default="sky") admin_account: Mapped[str] = mapped_column(String(120), default="superadmin") admin_email: Mapped[str] = mapped_column(String(255), default="") diff --git a/server/src/app/schemas/settings.py b/server/src/app/schemas/settings.py index a3554ac..9ec888b 100644 --- a/server/src/app/schemas/settings.py +++ b/server/src/app/schemas/settings.py @@ -21,6 +21,17 @@ class SettingsCompanyForm(BaseModel): return value.strip() +class SettingsAppearanceForm(BaseModel): + themeSkin: str = Field(default="sky", max_length=64) + + @field_validator("themeSkin", mode="before") + @classmethod + def strip_string(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() + + class SettingsAdminForm(BaseModel): adminAccount: str = Field(min_length=1, max_length=120) adminEmail: str = Field(default="", max_length=255) @@ -162,6 +173,7 @@ class SettingsMailForm(BaseModel): class SettingsRead(BaseModel): companyForm: SettingsCompanyForm + appearanceForm: SettingsAppearanceForm = Field(default_factory=SettingsAppearanceForm) adminForm: SettingsAdminForm sessionForm: SettingsSessionForm hermesForm: dict @@ -173,6 +185,7 @@ class SettingsRead(BaseModel): class SettingsWrite(BaseModel): companyForm: SettingsCompanyForm + appearanceForm: SettingsAppearanceForm = Field(default_factory=SettingsAppearanceForm) adminForm: SettingsAdminForm sessionForm: SettingsSessionForm hermesForm: dict diff --git a/server/src/app/services/employee_behavior_profile_helpers.py b/server/src/app/services/employee_behavior_profile_helpers.py index c5af8b5..82fe453 100644 --- a/server/src/app/services/employee_behavior_profile_helpers.py +++ b/server/src/app/services/employee_behavior_profile_helpers.py @@ -5,6 +5,7 @@ from collections import defaultdict from decimal import Decimal from typing import Any +from app.algorithem.employee_behavior_profile import ALGORITHM_VERSION from app.models.agent_run import AgentRun from app.models.employee import Employee from app.models.financial_record import ExpenseClaim @@ -122,6 +123,33 @@ class EmployeeBehaviorProfileMetricHelpers: return max(1, int(digits)) return 0 + def _resolve_scope_from_claim(self, claim_id: str | None, expense_type_scope: str) -> str: + normalized = str(expense_type_scope or "overall").strip() or "overall" + if normalized != "overall" or not claim_id: + return normalized + claim = self.db.get(ExpenseClaim, claim_id) + return str(claim.expense_type or "overall").strip() if claim is not None else normalized + + def _is_claim_in_scope(self, claim: ExpenseClaim, expense_type_scope: str) -> bool: + scope = str(expense_type_scope or "overall").strip() + if scope == "overall": + return True + if scope == "entertainment": + return claim.expense_type in ENTERTAINMENT_EXPENSE_TYPES + if scope == "travel": + return claim.expense_type in TRAVEL_EXPENSE_TYPES + return claim.expense_type == scope + + def _common_metrics(self, context: dict[str, Any]) -> dict[str, Any]: + return { + "window_days": context["window_days"], + "expense_type_scope": context["expense_type_scope"], + "peer_group_key": context["peer_group_key"], + "peer_group_fallback_level": context["peer_group_fallback_level"], + "peer_sample_size": context["peer_sample_size"], + "algorithm_version": ALGORITHM_VERSION, + } + def _estimate_tokens(self, runs: list[AgentRun]) -> int: total = 0 for run in runs: diff --git a/server/src/app/services/employee_behavior_profile_service.py b/server/src/app/services/employee_behavior_profile_service.py index ab608ab..d984a3c 100644 --- a/server/src/app/services/employee_behavior_profile_service.py +++ b/server/src/app/services/employee_behavior_profile_service.py @@ -33,8 +33,6 @@ from app.schemas.employee_profile import ( EmployeeProfileRead, ) from app.services.employee_behavior_profile_helpers import ( - ENTERTAINMENT_EXPENSE_TYPES, - TRAVEL_EXPENSE_TYPES, EmployeeBehaviorProfileMetricHelpers, ) from app.services.employee_behavior_profile_response import ( @@ -787,30 +785,3 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): if len({self._claim_employee_key(claim) for claim in department_claims}) >= 3: return department_claims, 0 return claims, 3 - - def _resolve_scope_from_claim(self, claim_id: str | None, expense_type_scope: str) -> str: - normalized = str(expense_type_scope or "overall").strip() or "overall" - if normalized != "overall" or not claim_id: - return normalized - claim = self.db.get(ExpenseClaim, claim_id) - return str(claim.expense_type or "overall").strip() if claim is not None else normalized - - def _is_claim_in_scope(self, claim: ExpenseClaim, expense_type_scope: str) -> bool: - scope = str(expense_type_scope or "overall").strip() - if scope == "overall": - return True - if scope == "entertainment": - return claim.expense_type in ENTERTAINMENT_EXPENSE_TYPES - if scope == "travel": - return claim.expense_type in TRAVEL_EXPENSE_TYPES - return claim.expense_type == scope - - def _common_metrics(self, context: dict[str, Any]) -> dict[str, Any]: - return { - "window_days": context["window_days"], - "expense_type_scope": context["expense_type_scope"], - "peer_group_key": context["peer_group_key"], - "peer_group_fallback_level": context["peer_group_fallback_level"], - "peer_sample_size": context["peer_sample_size"], - "algorithm_version": ALGORITHM_VERSION, - } diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index 10c3e98..e849ab6 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -208,28 +208,29 @@ class SettingsService: def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead: settings_row, secrets_row = self.ensure_settings_ready() model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) - if payload.adminForm.newPassword: - if len(payload.adminForm.newPassword) < 5: - raise ValueError("管理员密码至少需要 5 位。") - if payload.adminForm.newPassword != payload.adminForm.confirmPassword: - raise ValueError("两次输入的管理员密码不一致。") - secrets_row.admin_password_hash = hash_password(payload.adminForm.newPassword) - - settings_row.company_name = payload.companyForm.companyName - settings_row.display_name = payload.companyForm.displayName - settings_row.company_code = payload.companyForm.companyCode - settings_row.record_number = payload.companyForm.recordNumber - settings_row.copyright_text = payload.companyForm.copyright - - settings_row.admin_account = payload.adminForm.adminAccount - settings_row.admin_email = payload.adminForm.adminEmail + if payload.adminForm.newPassword: + if len(payload.adminForm.newPassword) < 5: + raise ValueError("管理员密码至少需要 5 位。") + if payload.adminForm.newPassword != payload.adminForm.confirmPassword: + raise ValueError("两次输入的管理员密码不一致。") + secrets_row.admin_password_hash = hash_password(payload.adminForm.newPassword) + + settings_row.company_name = payload.companyForm.companyName + settings_row.display_name = payload.companyForm.displayName + settings_row.company_code = payload.companyForm.companyCode + settings_row.record_number = payload.companyForm.recordNumber + settings_row.copyright_text = payload.companyForm.copyright + settings_row.theme_skin = payload.appearanceForm.themeSkin + + settings_row.admin_account = payload.adminForm.adminAccount + settings_row.admin_email = payload.adminForm.adminEmail settings_row.session_timeout = payload.adminForm.sessionTimeout settings_row.conversation_retention_days = payload.sessionForm.conversationRetentionDays - settings_row.notice_email = payload.adminForm.noticeEmail - settings_row.mfa_enabled = payload.adminForm.mfaEnabled - settings_row.strong_password = payload.adminForm.strongPassword - settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled - + settings_row.notice_email = payload.adminForm.noticeEmail + settings_row.mfa_enabled = payload.adminForm.mfaEnabled + settings_row.strong_password = payload.adminForm.strongPassword + settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled + self._apply_model_setting( model_rows["main"], payload.llmForm.mainProvider, @@ -242,8 +243,8 @@ class SettingsService: payload.llmForm.backupProvider, payload.llmForm.backupModel, payload.llmForm.backupEndpoint, - payload.llmForm.backupApiKey, - ) + payload.llmForm.backupApiKey, + ) self._apply_model_setting( model_rows["embedding"], payload.llmForm.embeddingProvider, @@ -287,17 +288,17 @@ class SettingsService: settings_row.retention_days = payload.logForm.retentionDays settings_row.archive_cycle = payload.logForm.archiveCycle settings_row.log_path = payload.logForm.logPath - settings_row.alert_email = payload.logForm.alertEmail - settings_row.operation_audit = payload.logForm.operationAudit - settings_row.login_audit = payload.logForm.loginAudit - settings_row.mask_sensitive = payload.logForm.maskSensitive - - settings_row.smtp_host = payload.mailForm.smtpHost - settings_row.smtp_port = payload.mailForm.port - settings_row.smtp_encryption = payload.mailForm.encryption - settings_row.sender_name = payload.mailForm.senderName - settings_row.sender_address = payload.mailForm.senderAddress - settings_row.smtp_username = payload.mailForm.username + settings_row.alert_email = payload.logForm.alertEmail + settings_row.operation_audit = payload.logForm.operationAudit + settings_row.login_audit = payload.logForm.loginAudit + settings_row.mask_sensitive = payload.logForm.maskSensitive + + settings_row.smtp_host = payload.mailForm.smtpHost + settings_row.smtp_port = payload.mailForm.port + settings_row.smtp_encryption = payload.mailForm.encryption + settings_row.sender_name = payload.mailForm.senderName + settings_row.sender_address = payload.mailForm.senderAddress + settings_row.smtp_username = payload.mailForm.username settings_row.alert_enabled = payload.mailForm.alertEnabled settings_row.digest_enabled = payload.mailForm.digestEnabled settings_row.digest_time = payload.mailForm.digestTime @@ -341,7 +342,7 @@ class SettingsService: model_rows, self._build_hermes_form_snapshot(), ) - + def load_saved_model_api_key(self, slot: str | None) -> str: if not slot or slot not in MODEL_SLOT_CONFIGS: return "" @@ -378,92 +379,93 @@ class SettingsService: primary_route=self._build_hermes_model_route(model_rows["main"]), fallback_route=self._build_hermes_model_route(model_rows["backup"]), ) - - def get_admin_credentials(self) -> AdminCredentialRecord | None: - settings_row, secrets_row = self.ensure_settings_ready() - - if secrets_row.admin_password_hash: - return AdminCredentialRecord( - account=settings_row.admin_account, - email=settings_row.admin_email, - password_hash=secrets_row.admin_password_hash, - ) - - legacy_record = read_admin_secret() - if legacy_record is None: - return None - - username = str(legacy_record.get("username", "")).strip() - email = str(settings_row.admin_email or self.runtime_settings.admin_email or "").strip() - password_hash = "" - - # Legacy admin.json uses scrypt fields rather than the app password format. - # The auth flow handles this file separately when no DB-backed admin password exists. - if username or email: - return AdminCredentialRecord(account=username, email=email, password_hash=password_hash) - - return None - - def verify_admin_login(self, identifier: str, password: str) -> AdminCredentialRecord | None: - settings_row, secrets_row = self.ensure_settings_ready() - normalized_identifier = identifier.casefold() - - if secrets_row.admin_password_hash: - allowed_identifiers = { - value.casefold() - for value in [settings_row.admin_account, settings_row.admin_email] - if value - } - - if normalized_identifier not in allowed_identifiers: - return None - - if not verify_password(password, secrets_row.admin_password_hash): - return None - - return AdminCredentialRecord( - account=settings_row.admin_account, - email=settings_row.admin_email, - password_hash=secrets_row.admin_password_hash, - ) - - legacy_record = read_admin_secret() - if legacy_record is None: - return None - - admin_username = str(legacy_record.get("username", "")).strip() - admin_email = str(settings_row.admin_email or self.runtime_settings.admin_email or "").strip() - allowed_identifiers = { - value.casefold() - for value in [admin_username, admin_email] - if value - } - - if normalized_identifier not in allowed_identifiers: - return None - - if not verify_admin_secret(password, legacy_record): - return None - - return AdminCredentialRecord(account=admin_username, email=admin_email, password_hash="") - + + def get_admin_credentials(self) -> AdminCredentialRecord | None: + settings_row, secrets_row = self.ensure_settings_ready() + + if secrets_row.admin_password_hash: + return AdminCredentialRecord( + account=settings_row.admin_account, + email=settings_row.admin_email, + password_hash=secrets_row.admin_password_hash, + ) + + legacy_record = read_admin_secret() + if legacy_record is None: + return None + + username = str(legacy_record.get("username", "")).strip() + email = str(settings_row.admin_email or self.runtime_settings.admin_email or "").strip() + password_hash = "" + + # Legacy admin.json uses scrypt fields rather than the app password format. + # The auth flow handles this file separately when no DB-backed admin password exists. + if username or email: + return AdminCredentialRecord(account=username, email=email, password_hash=password_hash) + + return None + + def verify_admin_login(self, identifier: str, password: str) -> AdminCredentialRecord | None: + settings_row, secrets_row = self.ensure_settings_ready() + normalized_identifier = identifier.casefold() + + if secrets_row.admin_password_hash: + allowed_identifiers = { + value.casefold() + for value in [settings_row.admin_account, settings_row.admin_email] + if value + } + + if normalized_identifier not in allowed_identifiers: + return None + + if not verify_password(password, secrets_row.admin_password_hash): + return None + + return AdminCredentialRecord( + account=settings_row.admin_account, + email=settings_row.admin_email, + password_hash=secrets_row.admin_password_hash, + ) + + legacy_record = read_admin_secret() + if legacy_record is None: + return None + + admin_username = str(legacy_record.get("username", "")).strip() + admin_email = str(settings_row.admin_email or self.runtime_settings.admin_email or "").strip() + allowed_identifiers = { + value.casefold() + for value in [admin_username, admin_email] + if value + } + + if normalized_identifier not in allowed_identifiers: + return None + + if not verify_admin_secret(password, legacy_record): + return None + + return AdminCredentialRecord(account=admin_username, email=admin_email, password_hash="") + def _build_default_settings(self) -> SystemSetting: current_year = datetime.now().year company_name = str(self.runtime_settings.company_name or "X-Financial").strip() or "X-Financial" company_code = str(self.runtime_settings.company_code or "XF-001").strip() or "XF-001" admin_email = str(self.runtime_settings.admin_email or "").strip() - legacy_admin = read_admin_secret() or {} - admin_account = str(legacy_admin.get("username", "")).strip() or "superadmin" - + legacy_admin = read_admin_secret() or {} + admin_account = str(legacy_admin.get("username", "")).strip() or "superadmin" + return SystemSetting( - id=SETTINGS_ROW_ID, - company_name=company_name, - display_name=company_name, - company_code=company_code, - record_number="", - copyright_text=f"Copyright © 2024-{current_year} {company_name}. All Rights Reserved.", - admin_account=admin_account, - admin_email=admin_email, + id=SETTINGS_ROW_ID, + company_name=company_name, + display_name=company_name, + company_code=company_code, + record_number="", + copyright_text=f"Copyright © 2024-{current_year} {company_name}. All Rights Reserved.", + theme_skin="sky", + admin_account=admin_account, + admin_email=admin_email, session_timeout=30, conversation_retention_days=3, notice_email=admin_email, @@ -561,6 +563,10 @@ class SettingsService: migration_statements.append( "ALTER TABLE system_settings ADD COLUMN conversation_retention_days INTEGER DEFAULT 3" ) + if "theme_skin" not in settings_columns: + migration_statements.append( + "ALTER TABLE system_settings ADD COLUMN theme_skin VARCHAR(64) DEFAULT 'sky'" + ) if "onlyoffice_enabled" not in settings_columns: migration_statements.append( "ALTER TABLE system_settings ADD COLUMN onlyoffice_enabled BOOLEAN DEFAULT FALSE" @@ -735,6 +741,9 @@ class SettingsService: "recordNumber": settings_row.record_number, "copyright": settings_row.copyright_text, }, + appearanceForm={ + "themeSkin": settings_row.theme_skin or "sky", + }, adminForm={ "adminAccount": settings_row.admin_account, "adminEmail": settings_row.admin_email, diff --git a/server/tests/test_employee_behavior_profile_service.py b/server/tests/test_employee_behavior_profile_service.py index b928c2d..699bc71 100644 --- a/server/tests/test_employee_behavior_profile_service.py +++ b/server/tests/test_employee_behavior_profile_service.py @@ -234,6 +234,40 @@ def test_latest_profile_endpoint_returns_approval_payload() -> None: } +def test_current_employee_profile_endpoint_resolves_login_user() -> None: + session_factory = build_session_factory() + with session_factory() as db: + seed_profile_data(db) + + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + client = TestClient(app) + response = client.get( + "/api/v1/employee-profiles/me/latest", + params={ + "scene": "operations", + "window_days": 90, + "expense_type_scope": "overall", + }, + headers={"x-auth-username": "zhangsan@example.com"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["employee_id"] == "emp-main" + assert {item["profile_type"] for item in payload["profiles"]} >= {"expense", "ai_usage"} + assert payload["profile_tags"] + assert payload["radar"]["dimensions"] + + def test_hermes_scheduler_parses_weekly_profile_cron() -> None: scheduler = HermesScheduler() diff --git a/web/package-lock.json b/web/package-lock.json index ab6bdcd..efae457 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "@vitejs/plugin-vue": "^5.2.4", "@vueuse/motion": "^3.0.3", "chart.js": "^4.5.1", + "echarts": "^6.1.0", "element-plus": "^2.14.0", "markdown-it": "^14.1.1", "pg": "^8.13.1", @@ -2029,6 +2030,22 @@ "url": "https://dotenvx.com" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/element-plus": { "version": "2.14.0", "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz", @@ -3122,6 +3139,21 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/web/package.json b/web/package.json index 69b2264..1fdabb9 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@vitejs/plugin-vue": "^5.2.4", "@vueuse/motion": "^3.0.3", "chart.js": "^4.5.1", + "echarts": "^6.1.0", "element-plus": "^2.14.0", "markdown-it": "^14.1.1", "pg": "^8.13.1", diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 771b684..4844f7d 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -24,6 +24,7 @@ display: flex; align-items: stretch; background: var(--bg); + position: relative; } .app-sidebar { @@ -57,6 +58,100 @@ min-width: 0; } +.login-entry-veil { + position: absolute; + inset: 0; + z-index: 380; + display: grid; + place-items: center; + background: rgba(248, 250, 252, 0.9); + backdrop-filter: blur(3px); + pointer-events: none; +} + +.login-entry-card { + width: min(360px, calc(100% - 48px)); + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 12px 14px; + align-items: center; + padding: 22px 24px 20px; + border: 1px solid rgba(148, 163, 184, 0.26); + border-radius: 4px; + background: #fff; + box-shadow: 0 20px 46px rgba(15, 23, 42, 0.14); + animation: loginEntryCardIn 360ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.login-entry-mark { + width: 42px; + height: 42px; + display: inline-grid; + place-items: center; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2); + border-radius: 4px; + background: var(--theme-primary-soft); + color: var(--theme-primary-active); + font-size: 22px; +} + +.login-entry-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.login-entry-copy strong { + color: var(--ink); + font-size: 16px; + line-height: 1.35; + font-weight: 750; +} + +.login-entry-copy span { + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.login-entry-progress { + grid-column: 1 / -1; + height: 3px; + overflow: hidden; + background: #edf2f7; +} + +.login-entry-progress::after { + content: ''; + display: block; + width: 100%; + height: 100%; + background: var(--theme-primary); + transform-origin: left center; + animation: loginEntryProgress 840ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.login-entry-veil-enter-active { + transition: opacity 180ms var(--ease); +} + +.login-entry-veil-leave-active { + transition: opacity 260ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.login-entry-veil-enter-from, +.login-entry-veil-leave-to { + opacity: 0; +} + +.app.login-entry-active .app-sidebar { + animation: loginEntrySidebarIn 520ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.app.login-entry-active > .main { + animation: loginEntryMainIn 620ms 90ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + .boot-state { min-height: var(--desktop-stage-height, 100dvh); display: grid; @@ -168,13 +263,59 @@ .workarea.workbench-workarea { overflow-x: hidden; overflow-y: auto; - padding: 12px 14px 14px; + padding: 20px 24px; } .workarea.settings-workarea { padding: 0; background: #fff; } +@keyframes loginEntryCardIn { + from { + opacity: 0; + transform: scale3d(0.92, 0.92, 1); + } + + to { + opacity: 1; + transform: scale3d(1, 1, 1); + } +} + +@keyframes loginEntryProgress { + from { + transform: scaleX(0); + } + + to { + transform: scaleX(1); + } +} + +@keyframes loginEntrySidebarIn { + from { + opacity: 0; + transform: translateX(-18px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes loginEntryMainIn { + from { + opacity: 0; + transform: scale3d(0.985, 0.985, 1) translateY(10px); + } + + to { + opacity: 1; + transform: scale3d(1, 1, 1) translateY(0); + } +} + @media (max-width: 1180px) { .app-sidebar { width: var(--sidebar-expanded-width); @@ -207,13 +348,17 @@ transform: translateX(0); } + .app.login-entry-active .app-sidebar { + animation: none; + } + .app > .main { flex: 1 1 100%; width: 100vw; } - .workarea { padding: 18px 16px 28px; } - .workarea.workbench-workarea { overflow: auto; padding: 14px; } + .workarea { padding: 16px; } + .workarea.workbench-workarea { overflow: auto; padding: 16px; } .mobile-overlay { position: fixed; @@ -258,4 +403,16 @@ flex-basis 120ms ease-out !important; transition-duration: 120ms, 120ms !important; } + + .login-entry-card, + .login-entry-progress::after, + .app.login-entry-active .app-sidebar, + .app.login-entry-active > .main { + animation: none !important; + } + + .login-entry-veil-enter-active, + .login-entry-veil-leave-active { + transition: opacity 120ms ease-out !important; + } } diff --git a/web/src/assets/styles/components/digital-employee-work-records.css b/web/src/assets/styles/components/digital-employee-work-records.css new file mode 100644 index 0000000..358264a --- /dev/null +++ b/web/src/assets/styles/components/digital-employee-work-records.css @@ -0,0 +1,548 @@ +.digital-work-records { + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; +} + +.work-records-head { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(420px, 0.8fr); + align-items: start; + gap: 16px; +} + +.work-records-head h3 { + margin: 0; + color: #0f172a; + font-size: 16px; +} + +.work-records-head p { + margin: 6px 0 0; + color: #64748b; + font-size: 13px; +} + +.work-records-kpis { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + justify-self: end; + width: min(100%, 480px); +} + +.work-record-kpi { + min-height: 58px; + padding: 10px 12px; + border: 1px solid #dfe7ef; + border-radius: 4px; + background: #fff; +} + +.work-record-kpi span { + display: block; + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.work-record-kpi strong { + display: block; + margin-top: 6px; + color: #0f172a; + font-size: 22px; + line-height: 1; +} + +.work-record-kpi.success strong { + color: var(--success-active); +} + +.work-record-kpi.danger strong { + color: #dc2626; +} + +.work-records-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #64748b; + font-size: 13px; +} + +.work-records-toolbar button { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 12px; + border: 1px solid #d8e1eb; + border-radius: 4px; + background: #fff; + color: #0f172a; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.work-records-toolbar button:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +.work-records-table-wrap { + min-height: 400px; + overflow: auto; + border: 1px solid #edf2f7; + border-radius: 10px; + background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%); +} + +.work-records-table-wrap.is-empty { + display: grid; + align-items: center; + justify-content: center; +} + +.digital-work-records-table { + width: 100%; + min-width: 1180px; + table-layout: fixed; + border-collapse: collapse; +} + +.digital-work-records-table .col-time { width: 14%; } + +.digital-work-records-table .col-module { width: 12%; } + +.digital-work-records-table .col-source { width: 10%; } + +.digital-work-records-table .col-status { width: 17%; } + +.digital-work-records-table .col-summary { width: 31%; } + +.digital-work-records-table .col-trace { width: 16%; } + +.digital-work-records-table thead th { + position: sticky; + top: 0; + z-index: 1; + padding: 13px 12px; + border-bottom: 1px solid #edf2f7; + background: #f7fafc; + color: #64748b; + font-size: 13px; + font-weight: 800; + line-height: 1.35; + text-align: center; + white-space: nowrap; +} + +.digital-work-records-table tbody td { + padding: 13px 12px; + border-bottom: 1px solid #edf2f7; + color: #24324a; + font-size: 14px; + line-height: 1.35; + text-align: center; + vertical-align: middle; +} + +.digital-work-records-table tbody tr { + cursor: pointer; + outline: none; +} + +.digital-work-records-table tbody tr:hover, +.digital-work-records-table tbody tr:focus-visible { + background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03)); +} + +.digital-work-records-table tbody tr:focus-visible { + box-shadow: inset 0 0 0 2px rgba(58, 124, 165, .28); +} + +.digital-work-records-table tbody tr:last-child td { + border-bottom: 0; +} + +.work-record-status-stack { + display: grid; + gap: 5px; + justify-items: center; +} + +.work-record-status-stack > span:last-child { + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.work-record-summary-cell { + text-align: left !important; +} + +.work-record-summary-cell strong, +.work-record-summary-cell span, +.work-record-summary-cell em { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.work-record-summary-cell strong { + color: #0f172a; + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} + +.work-record-summary-cell span { + margin-top: 4px; + color: #64748b; + font-size: 13px; + line-height: 1.5; + white-space: nowrap; +} + +.work-record-summary-cell em { + margin-top: 6px; + color: #94a3b8; + font-size: 12px; + font-style: normal; + white-space: nowrap; +} + +.work-record-trace-cell { + color: #2563eb !important; + word-break: break-all; +} + +.status-pill { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 6px; + font-size: 12px; + font-weight: 750; + white-space: nowrap; +} + +.status-pill.success { + border-color: var(--success-line); + background: var(--success-soft); + color: var(--success-active); +} + +.status-pill.warning { + border-color: #fed7aa; + background: #fff7ed; + color: #f97316; +} + +.status-pill.danger { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; +} + +.status-pill.info { + border-color: #bfdbfe; + background: #eff6ff; + color: #2563eb; +} + +.status-pill.muted { + border-color: #cbd5e1; + background: #f8fafc; + color: #475569; +} + +.table-state, +.work-records-empty { + width: 100%; + min-height: 260px; + display: grid; + place-items: center; + gap: 10px; + padding: 28px 20px; + color: #64748b; + font-size: 13px; + text-align: center; +} + +.table-state.error { + background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%); +} + +.table-state.error .mdi { + color: #ef4444; + font-size: 28px; +} + +.table-state.error strong { + color: #0f172a; + font-size: 15px; +} + +.table-state.error p { + margin: 0; +} + +.work-record-detail-mask { + position: fixed; + inset: 0; + z-index: 2800; + display: flex; + justify-content: flex-end; + background: rgba(15, 23, 42, .28); +} + +.work-record-detail-panel { + width: min(720px, calc(100vw - 32px)); + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border-left: 1px solid #dfe7ef; + background: #fff; + box-shadow: -18px 0 42px rgba(15, 23, 42, .18); +} + +.work-record-detail-head { + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 16px 18px; + border-bottom: 1px solid #edf2f7; +} + +.work-record-detail-head span { + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.work-record-detail-head h3 { + margin: 5px 0 0; + color: #0f172a; + font-size: 17px; + line-height: 1.35; +} + +.work-record-detail-head button { + width: 34px; + height: 34px; + display: grid; + flex: 0 0 auto; + place-items: center; + border: 1px solid #d8e1eb; + border-radius: 4px; + background: #fff; + color: #64748b; +} + +.work-record-detail-head button:hover { + color: var(--theme-primary-active); +} + +.work-record-detail-body { + min-height: 0; + display: grid; + align-content: start; + gap: 12px; + padding: 16px 18px 22px; + overflow-y: auto; + background: #f8fafc; +} + +.work-record-detail-section, +.work-record-detail-state { + border: 1px solid #e5edf5; + border-radius: 6px; + background: #fff; +} + +.work-record-detail-section { + padding: 14px; +} + +.work-record-detail-state { + min-height: 100%; + display: grid; + place-items: center; + gap: 10px; + padding: 30px 18px; + color: #64748b; + text-align: center; +} + +.work-record-detail-state.error .mdi { + color: #dc2626; + font-size: 30px; +} + +.work-record-detail-state.error strong { + color: #0f172a; +} + +.work-record-detail-state.error p { + margin: 0; +} + +.work-record-detail-state.error button { + height: 34px; + padding: 0 12px; + border: 1px solid #fecaca; + border-radius: 4px; + background: #fff; + color: #dc2626; + font-weight: 750; +} + +.work-record-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.work-record-section-head h4 { + margin: 0; + color: #0f172a; + font-size: 15px; +} + +.work-record-section-head > span:not(.status-pill) { + color: #94a3b8; + font-size: 12px; + font-weight: 750; +} + +.work-record-info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.work-record-info-grid div { + min-width: 0; + padding: 10px; + border: 1px solid #edf2f7; + border-radius: 4px; + background: #f8fafc; +} + +.work-record-info-grid span { + display: block; + color: #64748b; + font-size: 12px; + font-weight: 750; +} + +.work-record-info-grid strong { + display: block; + margin-top: 5px; + overflow-wrap: anywhere; + color: #0f172a; + font-size: 13px; +} + +.work-record-result-text, +.work-record-error-text, +.work-record-inline-empty { + margin: 0; + color: #475569; + font-size: 13px; + line-height: 1.65; +} + +.work-record-error-text { + margin-top: 10px; + padding: 10px 12px; + border: 1px solid #fecaca; + border-radius: 4px; + background: #fef2f2; + color: #b91c1c; +} + +.work-record-tool-list { + display: grid; + gap: 8px; +} + +.work-record-tool-list article { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid #edf2f7; + border-radius: 4px; + background: #f8fafc; +} + +.work-record-tool-list strong { + color: #0f172a; + font-size: 13px; +} + +.work-record-tool-list span { + color: #64748b; + font-size: 12px; +} + +.work-record-code-block { + max-height: 320px; + margin: 0; + padding: 12px; + overflow: auto; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #0f172a; + color: #e2e8f0; + font-size: 12px; + line-height: 1.55; +} + +.work-record-detail-enter-active, +.work-record-detail-leave-active { + transition: opacity 180ms ease; +} + +.work-record-detail-enter-active .work-record-detail-panel, +.work-record-detail-leave-active .work-record-detail-panel { + transition: transform 220ms ease; +} + +.work-record-detail-enter-from, +.work-record-detail-leave-to { + opacity: 0; +} + +.work-record-detail-enter-from .work-record-detail-panel, +.work-record-detail-leave-to .work-record-detail-panel { + transform: translateX(24px); +} + +@media (max-width: 980px) { + .work-records-head { + grid-template-columns: 1fr; + } + + .work-records-kpis { + justify-self: stretch; + width: 100%; + } + + .work-record-info-grid { + grid-template-columns: 1fr; + } +} diff --git a/web/src/assets/styles/components/personal-workbench-responsive.css b/web/src/assets/styles/components/personal-workbench-responsive.css index bc81d0d..15cd9e1 100644 --- a/web/src/assets/styles/components/personal-workbench-responsive.css +++ b/web/src/assets/styles/components/personal-workbench-responsive.css @@ -2,7 +2,7 @@ @media (max-height: 980px) and (min-width: 761px) { .workbench { --hero-padding-top: 20px; - --hero-padding-bottom: 12px; + --hero-padding-bottom: 20px; --hero-title-size: 28px; --hero-copy-gap: 5px; --hero-title-bottom-gap: 14px; @@ -44,6 +44,7 @@ @media (min-width: 1920px) and (max-height: 1100px) { .workbench { --hero-padding-top: 22px; + --hero-padding-bottom: 22px; --hero-title-size: 29px; --composer-min-height: 114px; --composer-textarea-height: 50px; @@ -116,8 +117,10 @@ --assistant-art-x: 36px; --assistant-art-y: -8px; background: - linear-gradient(90deg, rgba(255, 255, 255, 0.97) 0%, rgba(255, 255, 255, 0.9) 56%, rgba(255, 255, 255, 0.22) 100%), - linear-gradient(135deg, #ffffff 0%, color-mix(in srgb, var(--workbench-primary-soft) 48%, #ffffff) 58%, color-mix(in srgb, var(--workbench-secondary) 8%, #ffffff) 100%); + linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.6) 56%, rgba(255, 255, 255, 0.22) 100%), + linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, color-mix(in srgb, var(--workbench-primary-soft) 40%, rgba(255, 255, 255, 0.5)) 58%, color-mix(in srgb, var(--workbench-secondary) 15%, rgba(255, 255, 255, 0.1)) 100%); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); } .assistant-copy { @@ -154,10 +157,12 @@ --assistant-art-width: min(380px, 78vw); --assistant-art-x: 12px; --assistant-art-y: -6px; - padding: 24px 18px 20px; + padding: 24px 18px 24px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.94) 100%), - color-mix(in srgb, var(--workbench-primary-soft) 22%, #ffffff); + linear-gradient(180deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.7) 100%), + color-mix(in srgb, var(--workbench-primary-soft) 22%, rgba(255, 255, 255, 0.5)); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); } .assistant-copy { @@ -259,7 +264,7 @@ .assistant-hero { --assistant-art-width: min(280px, 70vw); - padding: 20px 14px 16px; + padding: 20px 14px 20px; } } diff --git a/web/src/assets/styles/components/personal-workbench.css b/web/src/assets/styles/components/personal-workbench.css index 7a975f8..92b0330 100644 --- a/web/src/assets/styles/components/personal-workbench.css +++ b/web/src/assets/styles/components/personal-workbench.css @@ -1,6 +1,6 @@ .workbench { --hero-padding-top: 26px; - --hero-padding-bottom: 14px; + --hero-padding-bottom: 26px; --hero-title-size: 30px; --hero-copy-gap: 6px; --hero-title-bottom-gap: 18px; @@ -64,9 +64,11 @@ border: 1px solid color-mix(in srgb, var(--workbench-primary) 14%, var(--workbench-line)); border-radius: 4px; background: - linear-gradient(90deg, rgba(255, 255, 255, 0.97) 0%, rgba(255, 255, 255, 0.9) 44%, rgba(255, 255, 255, 0.16) 66%, rgba(255, 255, 255, 0.02) 100%), - linear-gradient(135deg, #ffffff 0%, color-mix(in srgb, var(--workbench-primary-soft) 56%, #ffffff) 62%, color-mix(in srgb, var(--workbench-secondary) 8%, #ffffff) 100%); - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); + linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.6) 44%, rgba(255, 255, 255, 0.2) 66%, rgba(255, 255, 255, 0.05) 100%), + linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, color-mix(in srgb, var(--workbench-primary-soft) 40%, rgba(255, 255, 255, 0.5)) 62%, color-mix(in srgb, var(--workbench-secondary) 15%, rgba(255, 255, 255, 0.1)) 100%); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.6); isolation: isolate; } diff --git a/web/src/assets/styles/global.css b/web/src/assets/styles/global.css index 580779e..4bde223 100644 --- a/web/src/assets/styles/global.css +++ b/web/src/assets/styles/global.css @@ -182,3 +182,22 @@ h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; fon transform: rotate(360deg); } } + +/* Global Scrollbar Styles */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} diff --git a/web/src/assets/styles/views/budget-center-dialog.css b/web/src/assets/styles/views/budget-center-dialog.css index cb4579c..8d164db 100644 --- a/web/src/assets/styles/views/budget-center-dialog.css +++ b/web/src/assets/styles/views/budget-center-dialog.css @@ -20,9 +20,9 @@ max-height: calc(100vh - 56px); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; - border-radius: 8px; + border-radius: 4px; background: #fff; - box-shadow: 0 24px 72px rgba(15, 23, 42, .28); + box-shadow: 0 18px 54px rgba(15, 23, 42, .2); overflow: hidden; } @@ -47,7 +47,7 @@ display: grid; place-items: center; border: 0; - border-radius: 8px; + border-radius: 4px; background: transparent; color: #64748b; font-size: 20px; @@ -118,7 +118,7 @@ .budget-edit-table input { width: 100%; border: 1px solid #dbe4ee; - border-radius: 6px; + border-radius: 4px; background: #fff; color: #111827; font-size: 14px; @@ -161,7 +161,7 @@ .budget-edit-table-wrap { border: 1px solid #edf1f6; - border-radius: 8px; + border-radius: 4px; overflow-x: auto; overflow-y: auto; flex: 1; @@ -229,7 +229,7 @@ display: inline-grid; place-items: center; border: 0; - border-radius: 8px; + border-radius: 4px; background: transparent; color: #64748b; font-size: 18px; @@ -249,7 +249,7 @@ align-items: center; gap: 5px; border: 1px solid rgba(var(--theme-primary-rgb), .42); - border-radius: 6px; + border-radius: 4px; background: #fff; color: var(--theme-primary-active); font-size: 13px; @@ -267,7 +267,7 @@ gap: 12px; align-items: center; border: 1px solid #edf1f6; - border-radius: 8px; + border-radius: 4px; background: #fbfcfe; flex-shrink: 0; } @@ -297,7 +297,7 @@ .budget-edit-foot button { height: 36px; min-width: 96px; - border-radius: 8px; + border-radius: 4px; font-size: 13px; font-weight: 800; cursor: pointer; @@ -317,9 +317,9 @@ .budget-edit-publish { border: 0; - background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); + background: var(--theme-primary-active); color: #fff; - box-shadow: 0 10px 24px var(--theme-primary-shadow); + box-shadow: none; } .budget-dialog-fade-enter-active, @@ -329,7 +329,7 @@ .budget-dialog-fade-enter-active .budget-edit-dialog, .budget-dialog-fade-leave-active .budget-edit-dialog { - transition: transform 200ms ease, opacity 180ms ease; + transition: transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease; } .budget-dialog-fade-enter-from, @@ -340,7 +340,7 @@ .budget-dialog-fade-enter-from .budget-edit-dialog, .budget-dialog-fade-leave-to .budget-edit-dialog { opacity: 0; - transform: translateY(12px); + transform: scale3d(.96, .96, 1); } @media (max-width: 860px) { diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index c61d3b5..8b90bd7 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -22,7 +22,7 @@ flex-direction: column; border: 1px solid #dbe4ee; border-left: 3px solid var(--accent); - border-radius: 8px; + border-radius: 4px; background: #fff; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); animation: dashboardItemIn 520ms var(--ease) both; @@ -31,7 +31,7 @@ } .budget-summary-card:hover { - box-shadow: 0 4px 20px rgba(0, 0, 0, .06); + box-shadow: 0 4px 12px rgba(15, 23, 42, .055); transform: translateY(-1px); } @@ -58,7 +58,7 @@ .summary-icon { width: 26px; height: 26px; - border-radius: 7px; + border-radius: 4px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 10%, white); @@ -145,7 +145,7 @@ .budget-filter-bar { border: 1px solid #e2e8f0; - border-radius: 8px; + border-radius: 4px; background: #fff; padding: 14px 16px; display: flex; @@ -180,8 +180,8 @@ .budget-primary-btn { min-height: 40px; border: 0; - border-radius: 8px; - background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); + border-radius: 4px; + background: var(--theme-primary-active); color: #fff; padding: 0 18px; display: inline-flex; @@ -192,20 +192,19 @@ font-weight: 800; white-space: nowrap; cursor: pointer; - box-shadow: 0 10px 24px var(--theme-primary-shadow); - transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease; + box-shadow: none; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; } .budget-primary-btn:hover { - transform: translateY(-1px); - box-shadow: 0 14px 28px rgba(var(--theme-primary-rgb), .24); - filter: saturate(1.02); + background: var(--theme-primary-hover); + box-shadow: none; } .budget-ghost-btn { min-height: 38px; border: 1px solid #d7e0ea; - border-radius: 8px; + border-radius: 4px; background: #fff; color: #334155; padding: 0 14px; @@ -217,13 +216,13 @@ font-weight: 750; white-space: nowrap; cursor: pointer; - transition: border-color 160ms ease, color 160ms ease, box-shadow 160ms ease; + transition: border-color 160ms ease, color 160ms ease, background 160ms ease; } .budget-ghost-btn:hover { border-color: rgba(var(--theme-primary-rgb), .32); + background: var(--theme-primary-soft); color: var(--theme-primary-active); - box-shadow: 0 1px 4px rgba(15, 23, 42, .08); } .budget-work-grid { @@ -241,7 +240,7 @@ .budget-chart-panel, .budget-alert-panel { border: 1px solid #e5eaf1; - border-radius: 8px; + border-radius: 4px; background: #fff; overflow: hidden; } @@ -266,66 +265,25 @@ } .budget-table-search { - position: relative; width: min(260px, 42%); min-width: 190px; - display: flex; - align-items: center; } -.budget-table-search i { - position: absolute; - left: 11px; - color: #94a3b8; - font-size: 15px; - pointer-events: none; -} - -.budget-table-search input { - width: 100%; - height: 32px; - border: 1px solid #dbe4ee; - border-radius: 6px; - padding: 0 11px 0 32px; - background: #fff; - color: #1f2937; - font-size: 13px; - font-weight: 650; - outline: none; - transition: border-color 160ms ease, box-shadow 160ms ease; -} - -.budget-table-search input::placeholder { - color: #94a3b8; - font-weight: 500; -} - -.budget-table-search input:focus { - border-color: rgba(var(--theme-primary-rgb), .48); - box-shadow: 0 0 0 3px rgba(var(--theme-primary-rgb), .1); -} - -.department-search { - position: relative; +.department-search-input { + width: calc(100% - 28px); margin: 12px 14px 8px; } -.department-search i { - position: absolute; - left: 12px; - top: 50%; - transform: translateY(-50%); - color: #9aa5b5; +.budget-table-search :deep(.el-input__wrapper), +.department-search-input :deep(.el-input__wrapper) { + min-height: 34px; + border-radius: 4px; + background: #fff; } -.department-search input { - width: 100%; - height: 36px; - border: 1px solid #e2e8f0; - border-radius: 5px; - padding: 0 12px 0 34px; - background: #fff; - color: #1f2937; +.budget-table-search :deep(.el-input__prefix), +.department-search-input :deep(.el-input__prefix) { + color: #94a3b8; } .department-list { @@ -334,73 +292,107 @@ padding: 8px 12px 16px; } -.department-list button { +.department-switch-btn { + width: 100%; height: 38px; border: 0; - border-radius: 5px; + border-radius: 4px; background: transparent; color: #4b5563; padding: 0 12px; display: flex; align-items: center; + justify-content: flex-start; gap: 10px; font-size: 14px; font-weight: 700; text-align: left; + margin-left: 0; + transition: + background 160ms ease, + color 160ms ease; } -.department-list button.active { +.department-switch-btn.active { background: var(--theme-primary-soft); color: var(--theme-primary-active); } +.department-switch-btn + .department-switch-btn { + margin-left: 0; +} + .budget-table-wrap { - overflow-x: auto; -} - -.budget-table-panel table { width: 100%; - min-width: 1460px; - border-collapse: collapse; + overflow-x: auto; + padding-bottom: 8px; } -.budget-table-panel th, -.budget-table-panel td { - padding: 13px 18px; - border-bottom: 1px solid #edf1f6; - border-right: 1px solid #edf1f6; +.budget-data-table { + width: 100%; + min-width: 1540px; +} + +.budget-table-wrap :deep(.el-table) { + --el-table-border-color: #edf1f6; + --el-table-header-bg-color: #f8fafc; + --el-table-row-hover-bg-color: var(--theme-primary-soft); + --el-table-current-row-bg-color: var(--theme-primary-soft); color: #273142; font-size: 14px; - text-align: center; - white-space: nowrap; } -.budget-table-panel th:last-child, -.budget-table-panel td:last-child { - border-right: 0; +.budget-table-wrap :deep(.el-table .el-scrollbar__bar.is-horizontal) { + display: none !important; } -.budget-table-panel th { - background: #fafbfd; +.budget-table-wrap :deep(.el-table__inner-wrapper::before), +.budget-table-wrap :deep(.el-table__border-left-patch) { + display: none; +} + +.budget-table-wrap :deep(.el-table th.el-table__cell) { + background: #f8fafc; color: #1f2937; font-weight: 800; } +.budget-table-wrap :deep(.el-table td.el-table__cell) { + color: #273142; +} + +.budget-table-wrap :deep(.el-table .cell) { + padding: 0 12px; + white-space: nowrap; +} + +.budget-table-wrap :deep(.el-table--border .el-table__cell) { + border-right-color: #edf1f6; +} + .budget-rate { - width: 96px; - display: grid; - gap: 6px; + width: 100%; + max-width: 110px; + display: flex; + align-items: center; + gap: 8px; margin: 0 auto; } .budget-rate span { + flex: 0 0 auto; color: #273142; font-size: 13px; + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 32px; } .budget-rate div { + flex: 1; + min-width: 0; height: 6px; - border-radius: 999px; + border-radius: 4px; background: #e9edf3; overflow: hidden; } @@ -423,26 +415,21 @@ background: var(--danger); } -.budget-threshold-cell { - padding-left: 12px !important; - padding-right: 12px !important; -} - .budget-threshold-badge { min-width: 58px; display: inline-flex; align-items: center; justify-content: center; padding: 4px 9px; - border-radius: 999px; + border-radius: 4px; font-size: 12px; font-weight: 800; line-height: 1.2; } .budget-threshold-badge.reminder { - background: rgba(37, 99, 235, .1); - color: #2563eb; + background: var(--theme-primary-soft); + color: var(--theme-primary-active); } .budget-threshold-badge.alert { @@ -476,35 +463,37 @@ gap: 6px; padding: 4px; border: 1px solid #e2e8f0; - border-radius: 12px; + border-radius: 4px; background: #f8fafc; } -.budget-pager button { - width: 32px; +.budget-pager :deep(.btn-prev), +.budget-pager :deep(.btn-next), +.budget-pager :deep(.el-pager li) { + min-width: 32px; height: 32px; - border: 0; - border-radius: 9px; + border-radius: 4px; background: transparent; color: #334155; font-size: 14px; font-weight: 800; - transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; + transition: background 160ms ease, color 160ms ease; } -.budget-pager button:hover:not(.active):not(:disabled) { +.budget-pager :deep(.btn-prev:hover:not(:disabled)), +.budget-pager :deep(.btn-next:hover:not(:disabled)), +.budget-pager :deep(.el-pager li:hover:not(.is-active)) { background: #fff; color: var(--theme-primary-active); - box-shadow: 0 1px 4px rgba(15, 23, 42, .08); } -.budget-pager button.active { +.budget-pager :deep(.el-pager li.is-active) { background: var(--theme-primary-active); color: #fff; - box-shadow: 0 8px 16px var(--theme-primary-shadow); } -.budget-pager button:disabled { +.budget-pager :deep(.btn-prev:disabled), +.budget-pager :deep(.btn-next:disabled) { color: #94a3b8; cursor: not-allowed; } @@ -518,7 +507,7 @@ gap: 9px; padding: 0 14px; border: 1px solid #d7e0ea; - border-radius: 10px; + border-radius: 4px; background: #fff; color: #334155; font-size: 14px; @@ -539,14 +528,20 @@ gap: 10px; } -.budget-card-head button { - border: 0; - background: transparent; +.budget-link-btn { + height: 30px; + padding: 0 8px; + border-radius: 4px; color: var(--theme-primary-active); font-size: 14px; font-weight: 800; } +.budget-link-btn:hover { + background: var(--theme-primary-soft); + color: var(--theme-primary-active); +} + .budget-chart-legend { display: flex; align-items: center; @@ -603,7 +598,7 @@ .budget-alert-empty-icon { width: 44px; height: 44px; - border-radius: 8px; + border-radius: 4px; display: grid; place-items: center; background: var(--theme-primary-soft); @@ -641,7 +636,7 @@ .budget-alert-row i { width: 8px; height: 8px; - border-radius: 999px; + border-radius: 2px; } .budget-alert-row i.danger { diff --git a/web/src/assets/styles/views/digital-employees-view.css b/web/src/assets/styles/views/digital-employees-view.css index 0ff95cc..c882611 100644 --- a/web/src/assets/styles/views/digital-employees-view.css +++ b/web/src/assets/styles/views/digital-employees-view.css @@ -7,6 +7,10 @@ height: 100%; } +.digital-employees-list > .status-tabs { + flex: 0 0 auto; +} + .digital-employees-list .table-wrap { min-height: 0; } @@ -73,6 +77,10 @@ color: #ea580c; } +.digital-work-records-section { + min-height: 0; +} + @media (max-width: 980px) { .digital-employees-table { min-width: 1040px; diff --git a/web/src/assets/styles/views/employee-management-view.css b/web/src/assets/styles/views/employee-management-view.css index 13e668d..01b1210 100644 --- a/web/src/assets/styles/views/employee-management-view.css +++ b/web/src/assets/styles/views/employee-management-view.css @@ -604,7 +604,7 @@ tbody tr:last-child td { height: 38px; display: grid; place-items: center; - border-radius: 11px; + border-radius: 999px; background: var(--theme-gradient-primary); color: #fff; font-size: 13px; @@ -714,7 +714,7 @@ tbody tr:last-child td { height: 64px; display: grid; place-items: center; - border-radius: var(--employee-detail-radius); + border-radius: 999px; background: var(--theme-gradient-primary); color: #fff; font-size: 24px; diff --git a/web/src/assets/styles/views/logs-view.css b/web/src/assets/styles/views/logs-view.css index 7984053..077aeed 100644 --- a/web/src/assets/styles/views/logs-view.css +++ b/web/src/assets/styles/views/logs-view.css @@ -6,353 +6,404 @@ background: transparent; } -.panel { - background: rgba(255, 255, 255, 0.98); - border: 1px solid #dfe7ef; - border-radius: 10px; - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05); +.logs-empty { + display: grid; + align-content: center; + justify-items: center; + min-height: 0; + padding: 28px 20px; + text-align: center; } -.logs-empty, -.logs-console { - padding: 16px 18px; -} - -.logs-empty h2, -.panel-heading h3, -.event-summary h4, -.section-head h4, -.inspector-empty h4 { +.logs-empty h2 { margin: 0; color: #0f172a; + font-size: 18px; } -.logs-empty p, -.panel-heading p, -.event-summary p, -.section-head p, -.inspector-empty p { +.logs-empty p { margin: 8px 0 0; color: #64748b; + font-size: 13px; } -.logs-console { - display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr); - gap: 14px; +.system-logs-list.panel { min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + padding: 16px 18px; overflow: hidden; + border: 0; + border-radius: var(--radius); + background: var(--surface); + box-shadow: 0 1px 3px rgba(0, 0, 0, .10), 0 1px 2px rgba(0, 0, 0, .06); } -.logs-console.without-toolbar { - grid-template-rows: auto auto minmax(0, 1fr); -} - -.console-tabs { +.system-logs-list .document-toolbar { display: flex; align-items: center; - gap: 24px; - padding-bottom: 10px; - border-bottom: 1px solid #e7edf4; -} - -.console-tabs button { - position: relative; - padding: 0 0 10px; - border: none; - background: transparent; - color: #64748b; - font-size: 14px; - font-weight: 700; - cursor: pointer; - transition: color 0.2s ease; -} - -.console-tabs button:hover, -.console-tabs button.active { - color: var(--theme-primary-active); -} - -.console-tabs button.active::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: -11px; - height: 2px; - border-radius: 999px; - background: var(--theme-primary); -} - -.console-toolbar { - --logs-filter-control-height: 38px; - display: grid; - grid-template-columns: minmax(220px, 1.35fr) repeat(2, minmax(138px, 0.7fr)) auto; - gap: 10px; - align-items: end; -} - -.filter-field { - display: grid; - gap: 5px; -} - -.filter-field span { - color: #64748b; - font-size: 12px; - font-weight: 700; -} - -.field-input { - min-height: var(--logs-filter-control-height); - border: 1px solid #d8e1eb; - border-radius: 4px; - background: #fff; -} - -.field-input { - display: flex; - align-items: center; - gap: 8px; - padding: 0 12px; -} - -.field-input .pi { - color: #94a3b8; - font-size: 13px; -} - -.field-input input { - width: 100%; - border: none; - background: transparent; - color: #0f172a; - font-size: 13px; - outline: none; -} - -.console-toolbar :deep(.enterprise-select) { - width: 100%; -} - -.console-toolbar :deep(.el-select__wrapper) { - min-height: var(--logs-filter-control-height); - height: var(--logs-filter-control-height); - padding: 0 12px; - border-radius: 4px; - box-shadow: 0 0 0 1px #d8e1eb inset; -} - -.console-toolbar :deep(.el-select__wrapper:hover) { - box-shadow: 0 0 0 1px #b8c2d2 inset; -} - -.console-toolbar :deep(.el-select__wrapper.is-focused) { - box-shadow: - 0 0 0 1px var(--theme-primary) inset, - 0 0 0 3px var(--theme-focus-ring); -} - -.console-toolbar :deep(.el-select__placeholder), -.console-toolbar :deep(.el-select__selected-item) { - font-size: 13px; - line-height: var(--logs-filter-control-height); -} - -.toolbar-btn { - min-height: var(--logs-filter-control-height); - padding: 0 14px; - border-radius: 4px; - border: 1px solid #d8e1eb; - font-size: 13px; - font-weight: 700; - cursor: pointer; -} - -.toolbar-btn.primary { - background: var(--theme-gradient-primary); - border-color: transparent; - color: #fff; -} - -.toolbar-btn.ghost { - background: #f8fafc; - color: #0f172a; -} - -.toolbar-btn:disabled { - cursor: not-allowed; - opacity: 0.7; -} - -.hint { - display: inline-flex; - align-items: center; - gap: 7px; - color: #64748b; - font-size: 13px; -} - -.console-layout { - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - gap: 12px; - min-height: 0; -} - -.list-panel, -.analytics-card { - display: grid; - gap: 12px; - min-height: 0; - padding: 14px; - border-radius: 10px; - border: 1px solid #e5edf5; - background: #fff; -} - -.list-panel { - grid-template-rows: auto minmax(0, 1fr) auto; -} - -.panel-heading { - display: flex; - align-items: flex-start; justify-content: space-between; + gap: 16px; +} + +.system-logs-list .filter-set, +.system-logs-list .document-actions { + display: flex; + align-items: center; gap: 12px; + flex-wrap: wrap; } -.panel-refresh { - flex: 0 0 auto; +.system-logs-list .list-search { + position: relative; + width: 280px; } -.panel-heading h3 { +.system-logs-list .list-search .mdi { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #64748b; font-size: 15px; } -.panel-heading p { - font-size: 12px; -} - -.table-shell { - min-height: 0; - overflow: auto; - border: 1px solid #e5edf5; - border-radius: 8px; - background: #fff; -} - -.log-table { +.system-logs-list .list-search input { width: 100%; - min-width: 980px; - border-collapse: separate; - border-spacing: 0; + height: 38px; + padding: 0 12px 0 36px; + border: 1px solid #d7e0ea; + border-radius: 4px; + background: #fff; + color: #0f172a; + font-size: 13px; + transition: border-color 160ms ease, box-shadow 160ms ease; } -.log-table thead th { - position: sticky; - top: 0; - z-index: 1; - padding: 10px 12px; - border-bottom: 1px solid #e5edf5; - background: #f8fbfd; - color: #475569; - font-size: 12px; - font-weight: 800; - text-align: center; +.system-logs-list .list-search input::placeholder { + color: #8da0b4; +} + +.system-logs-list .list-search input:focus { + border-color: var(--theme-primary); + box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14); + outline: none; +} + +.system-logs-list .document-filter { + position: relative; +} + +.system-logs-list .filter-btn { + min-width: 120px; + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 9px; + padding: 0 14px; + border: 1px solid #d7e0ea; + border-radius: 4px; + background: #fff; + color: #334155; + font-size: 14px; + font-weight: 750; white-space: nowrap; } -.log-table tbody tr { - cursor: pointer; - transition: background 0.2s ease; +.system-logs-list .filter-btn:hover, +.system-logs-list .document-filter.open .filter-btn { + border-color: rgba(58, 124, 165, .32); + color: var(--theme-primary-active); } -.log-table tbody tr:hover { - background: rgba(var(--theme-primary-rgb), 0.04); +.system-logs-list .status-dropdown-filter, +.system-logs-list .status-filter-trigger, +.system-logs-list .status-filter-menu { + min-width: 154px; } -.log-table tbody tr.active { - background: rgba(var(--theme-primary-rgb), 0.08); - box-shadow: inset 3px 0 0 var(--theme-primary); +.system-logs-list .status-filter-trigger > .mdi:first-child { + color: var(--theme-primary); } -.log-table tbody td { - padding: 10px 12px; - border-bottom: 1px solid #eef3f8; - color: #0f172a; +.system-logs-list .document-filter-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 40; + min-width: 150px; + max-height: 280px; + padding: 6px; + overflow-y: auto; + border: 1px solid #d7e0ea; + border-radius: 4px; + background: #fff; + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12); +} + +.system-logs-list .document-filter-menu button { + display: block; + width: 100%; + min-height: 36px; + padding: 0 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: #334155; font-size: 13px; + font-weight: 650; + text-align: left; + white-space: nowrap; +} + +.system-logs-list .document-filter-menu button:hover, +.system-logs-list .document-filter-menu button.active { + background: rgba(58, 124, 165, 0.1); + color: var(--theme-primary-active); +} + +.system-logs-list .create-request-btn { + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 18px; + border: 0; + border-radius: 4px; + background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); + color: #fff; + font-size: 14px; + font-weight: 800; + white-space: nowrap; + box-shadow: 0 10px 24px var(--theme-primary-shadow); + transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease; +} + +.system-logs-list .create-request-btn.secondary { + border: 1px solid #d7e0ea; + background: #fff; + color: #334155; + box-shadow: none; +} + +.system-logs-list .create-request-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 14px 28px var(--theme-primary-shadow); + filter: saturate(1.02); +} + +.system-logs-list .create-request-btn.secondary:hover:not(:disabled) { + border-color: rgba(58, 124, 165, .32); + color: var(--theme-primary-active); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); +} + +.system-logs-list .create-request-btn:disabled { + cursor: not-allowed; + opacity: .72; + transform: none; +} + +.system-logs-list .hint { + display: inline-flex; + align-items: center; + gap: 7px; + margin: 10px 0 0; + color: #64748b; + font-size: 13px; +} + +.system-logs-list .table-wrap { + min-height: 400px; + margin-top: 10px; + display: flex; + flex-direction: column; + overflow: auto; + border: 1px solid #edf2f7; + border-radius: 10px; + background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%); +} + +.system-logs-list .table-wrap.is-empty { + align-items: center; + justify-content: center; +} + +.system-logs-list .table-state { + width: 100%; + min-height: 260px; + display: grid; + place-items: center; + gap: 10px; + padding: 28px 20px; + text-align: center; + color: #64748b; + background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%); +} + +.system-logs-list .system-log-table { + width: 100%; + min-width: 1260px; + align-self: flex-start; + border-collapse: collapse; + table-layout: fixed; +} + +.system-logs-list .col-time { width: 13%; } + +.system-logs-list .col-level { width: 8%; } + +.system-logs-list .col-event { width: 13%; } + +.system-logs-list .col-module { width: 15%; } + +.system-logs-list .col-outcome { width: 8%; } + +.system-logs-list .col-summary { width: 27%; } + +.system-logs-list .col-request { width: 16%; } + +.system-logs-list th, +.system-logs-list td { + padding: 13px 12px; + border-bottom: 1px solid #edf2f7; + color: #24324a; + font-size: 14px; + line-height: 1.35; text-align: center; vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.summary-cell { - min-width: 220px; +.system-logs-list th { + position: sticky; + top: 0; + z-index: 1; + background: #f7fafc; + color: #64748b; + font-size: 13px; + font-weight: 800; +} + +.system-logs-list tbody tr { + cursor: pointer; +} + +.system-logs-list tbody tr:hover { + background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03)); +} + +.system-logs-list tbody tr:last-child td { + border-bottom: 0; +} + +.system-logs-list .summary-cell { text-align: left; + white-space: normal; } -.summary-cell strong, -.file-cell strong { +.system-logs-list .summary-cell strong, +.system-logs-list .summary-cell span { display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.system-logs-list .summary-cell strong { color: #0f172a; font-size: 13px; - font-weight: 700; + font-weight: 800; + white-space: nowrap; } -.summary-cell span { - display: block; +.system-logs-list .summary-cell span { margin-top: 4px; color: #64748b; + font-size: 13px; line-height: 1.5; + white-space: nowrap; } -.summary-meta { - display: block; - margin-top: 6px; - color: #94a3b8; - font-size: 12px; - font-style: normal; - line-height: 1.5; -} - -.trace-cell { - max-width: 180px; +.system-logs-list .trace-cell { color: #2563eb; word-break: break-all; + white-space: normal; } -.status-stack { - display: grid; - gap: 5px; - align-content: start; - justify-items: center; -} - -.status-note { - color: #64748b; +.system-logs-list .status-pill, +.system-logs-list .level-pill { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 6px; font-size: 12px; - line-height: 1.5; - word-break: break-word; + font-weight: 750; + white-space: nowrap; } -.system-table .summary-cell { - min-width: 260px; +.system-logs-list .status-pill.success { + border-color: var(--success-line); + background: var(--success-soft); + color: var(--success-active); } -.system-table .trace-cell { - min-width: 148px; +.system-logs-list .status-pill.warning, +.system-logs-list .level-pill.warning { + border-color: #fed7aa; + background: #fff7ed; + color: #f97316; } -.table-foot { +.system-logs-list .status-pill.danger, +.system-logs-list .level-pill.danger { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; +} + +.system-logs-list .status-pill.muted, +.system-logs-list .level-pill.muted { + border-color: #cbd5e1; + background: #f8fafc; + color: #475569; +} + +.system-logs-list .level-pill.info { + border-color: #bfdbfe; + background: #eff6ff; + color: #2563eb; +} + +.system-logs-list .inline-empty { + padding: 36px 16px; + background: transparent; + color: #64748b; + font-size: 13px; + text-align: center; +} + +.system-logs-list .list-foot { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; - color: #64748b; - font-size: 13px; + margin-top: 12px; } -.pager { +.system-logs-list .page-summary { + color: #64748b; + font-size: 14px; + font-weight: 650; +} + +.system-logs-list .pager { display: inline-flex; justify-content: center; gap: 6px; @@ -362,9 +413,15 @@ background: #f8fafc; } -.pager button { +.system-logs-list .pager button, +.system-logs-list .page-ellipsis { width: 32px; height: 32px; + display: inline-grid; + place-items: center; +} + +.system-logs-list .pager button { border: 0; border-radius: 9px; background: transparent; @@ -373,386 +430,70 @@ font-weight: 800; } -.page-ellipsis { - width: 24px; - height: 32px; - display: inline-grid; - place-items: center; +.system-logs-list .page-ellipsis { color: #94a3b8; font-size: 13px; font-weight: 800; } -.pager button:hover:not(.active):not(:disabled) { +.system-logs-list .pager button:hover:not(.active):not(:disabled) { background: #fff; color: var(--theme-primary-active); - box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08); + box-shadow: 0 1px 4px rgba(15, 23, 42, .08); } -.pager button.active { - background: var(--theme-primary); +.system-logs-list .pager button.active { + background: var(--theme-primary-active); color: #fff; box-shadow: 0 8px 16px var(--theme-primary-shadow); } -.page-nav:disabled { +.system-logs-list .pager button:disabled { + color: #cbd5e1; cursor: not-allowed; - opacity: 0.45; + opacity: 1; } -.page-size-select { - width: 112px; +.system-logs-list .page-size-select { + width: 118px; justify-self: end; } -.analytics-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(260px, 0.48fr); - gap: 12px; - align-items: start; -} +@media (max-width: 1200px) { + .system-logs-list .document-toolbar { + align-items: stretch; + flex-direction: column; + } -.analytics-card { - align-content: start; - gap: 8px; - padding: 10px 12px; -} + .system-logs-list .document-actions { + justify-content: flex-start; + } -.analytics-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; -} - -.analytics-head h3 { - margin: 0; - color: #0f172a; - font-size: 15px; -} - -.analytics-head p { - margin: 4px 0 0; - color: #64748b; - font-size: 12px; -} - -.analytics-head > span { - display: inline-flex; - min-height: 24px; - align-items: center; - padding: 0 9px; - border-radius: 999px; - background: var(--theme-primary-light-9); - color: var(--theme-primary-active); - font-size: 12px; - font-weight: 700; -} - -.trend-card { - gap: 8px; -} - -.trend-card :deep(.log-trend-chart) { - height: 118px; -} - -.trend-card :deep(.chart-legend) { - margin-bottom: 6px; -} - -.distribution-card { - gap: 8px; -} - -.distribution-card :deep(.donut-chart) { - min-height: 118px; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.distribution-card :deep(.donut-body) { - width: 108px; - height: 96px; - flex: 0 0 108px; - margin: 0; -} - -.distribution-card :deep(.donut-legend) { - flex: 1; - grid-template-columns: 1fr; - gap: 5px; -} - -.trend-plot { - height: 102px; - padding: 8px 2px 0; - border-top: 1px solid #f1f5f9; -} - -.trend-plot svg { - width: 100%; - height: 100%; - overflow: visible; -} - -.trend-area { - fill: url(#logsTrendFill); -} - -.trend-line { - fill: none; - stroke-width: 2.4; - stroke-linecap: round; - stroke-linejoin: round; -} - -.trend-line.total { - stroke: var(--theme-primary); -} - -.trend-line.failed { - stroke: #ef4444; -} - -.trend-axis { - display: grid; - grid-template-columns: repeat(8, minmax(0, 1fr)); - color: #94a3b8; - font-size: 11px; -} - -.trend-axis span { - text-align: center; -} - -.distribution-body { - display: grid; - grid-template-columns: 104px minmax(0, 1fr); - align-items: center; - gap: 16px; -} - -.distribution-donut { - position: relative; - width: 104px; - height: 104px; - display: grid; - place-content: center; - border-radius: 999px; -} - -.distribution-donut::before { - content: ''; - position: absolute; - inset: 18px; - border-radius: inherit; - background: #fff; -} - -.distribution-donut strong, -.distribution-donut span { - position: relative; - z-index: 1; - display: block; - text-align: center; -} - -.distribution-donut strong { - color: #0f172a; - font-size: 22px; - line-height: 1; -} - -.distribution-donut span { - margin-top: 3px; - color: #64748b; - font-size: 11px; -} - -.distribution-list { - display: grid; - gap: 10px; -} - -.distribution-list div { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 8px; -} - -.distribution-list i { - width: 8px; - height: 8px; - border-radius: 999px; -} - -.distribution-list span { - color: #475569; - font-size: 12px; -} - -.distribution-list strong { - color: #0f172a; - font-size: 13px; -} - -.status-pill, -.level-pill { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 3px 8px; - border-radius: 6px; - font-size: 12px; - font-weight: 600; - white-space: nowrap; - border: 1px solid transparent; -} - -.status-pill.success { - background: rgba(var(--success-rgb), 0.12); - border-color: rgba(var(--success-rgb), 0.2); - color: var(--success-active); -} - -.status-pill.warning { - background: rgba(245, 158, 11, 0.14); - border-color: rgba(245, 158, 11, 0.25); - color: #b45309; -} - -.status-pill.danger { - background: rgba(239, 68, 68, 0.14); - border-color: rgba(239, 68, 68, 0.25); - color: #b91c1c; -} - -.status-pill.muted { - background: rgba(148, 163, 184, 0.14); - border-color: rgba(148, 163, 184, 0.25); - color: #475569; -} - -.status-pill.info, -.level-pill.info { - background: rgba(37, 99, 235, 0.12); - border-color: rgba(37, 99, 235, 0.25); - color: #1d4ed8; -} - -.level-pill.warning { - background: rgba(245, 158, 11, 0.14); - border-color: rgba(245, 158, 11, 0.25); - color: #b45309; -} - -.level-pill.danger { - background: rgba(239, 68, 68, 0.14); - border-color: rgba(239, 68, 68, 0.25); - color: #b91c1c; -} - -.level-pill.muted { - background: rgba(148, 163, 184, 0.14); - border-color: rgba(148, 163, 184, 0.25); - color: #475569; -} - -.detail-alert { - margin-top: 14px; - padding: 12px 14px; - border-radius: 14px; - font-size: 13px; - line-height: 1.6; -} - -.detail-alert.danger { - background: rgba(254, 226, 226, 0.9); - border: 1px solid rgba(248, 113, 113, 0.26); - color: #b91c1c; -} - -.inline-empty, -.inspector-empty { - padding: 16px; - border-radius: 8px; - background: #f8fafc; - color: #64748b; -} - -.inline-empty { - text-align: center; -} - -.inline-empty.is-loading { - padding: 0; - background: transparent; -} - -.inline-empty.is-loading > .table-loading { - min-height: 220px; -} - -.inspector-empty { - display: grid; - align-content: center; - justify-items: center; - min-height: 100%; - text-align: center; -} - -@media (max-width: 1380px) { - .analytics-row { + .system-logs-list .list-foot { grid-template-columns: 1fr; } } -@media (max-width: 980px) { +@media (max-width: 760px) { .logs-view { - padding: 18px 16px 24px; + padding: 0; } - .logs-empty, - .logs-console { - padding: 18px; + .system-logs-list.panel { + padding: 16px; } - .console-toolbar { - grid-template-columns: 1fr; + .system-logs-list .filter-set, + .system-logs-list .document-actions, + .system-logs-list .list-search, + .system-logs-list .filter-btn, + .system-logs-list .page-size-select { + width: 100%; } - .table-foot { - grid-template-columns: 1fr; - justify-items: stretch; - } - - .pager, - .page-size-select { - justify-self: stretch; + .system-logs-list .document-filter-menu { + width: 100%; } } -@media (max-width: 720px) { - .console-tabs { - gap: 14px; - overflow-x: auto; - } - .log-table thead th, - .log-table tbody td { - padding: 12px 10px; - } - - .summary-cell { - min-width: 220px; - } - - .trace-cell { - min-width: 140px; - } - -} diff --git a/web/src/assets/styles/views/travel-request-detail-view-part2.css b/web/src/assets/styles/views/travel-request-detail-view-part2.css index e7593df..c323e4e 100644 --- a/web/src/assets/styles/views/travel-request-detail-view-part2.css +++ b/web/src/assets/styles/views/travel-request-detail-view-part2.css @@ -1,4 +1,4 @@ -.validation-pill.pending { +.validation-pill.pending { background: #fff7ed; border-color: #fed7aa; color: #c2410c; @@ -50,7 +50,7 @@ content: ''; width: 6px; height: 6px; - border-radius: 999px; + border-radius: 4px; background: var(--success); box-shadow: 0 0 0 3px rgba(var(--success-rgb), 0.12); } @@ -96,7 +96,7 @@ display: inline-flex; align-items: center; padding: 0 8px; - border-radius: 999px; + border-radius: 4px; background: #fef2f2; color: #b91c1c; font-size: 10px; @@ -140,7 +140,7 @@ display: grid; gap: 4px; padding: 8px 9px; - border-radius: 8px; + border-radius: 4px; background: #f8fafc; } @@ -172,7 +172,7 @@ display: inline-flex; align-items: center; padding: 0 9px; - border-radius: 999px; + border-radius: 4px; background: #fee2e2; color: #b91c1c; font-size: 11px; @@ -221,7 +221,7 @@ display: grid; gap: 6px; padding: 10px; - border-radius: 8px; + border-radius: 4px; background: rgba(255, 255, 255, .72); } @@ -267,7 +267,7 @@ max-height: 960px; display: grid; grid-template-rows: auto minmax(0, 1fr); - border-radius: 28px; + border-radius: 4px; background: #f8fafc; box-shadow: 0 0 0 1px rgba(15, 23, 42, .08), @@ -286,7 +286,7 @@ justify-content: space-between; gap: 20px; padding: 24px 28px; - background: linear-gradient(135deg, #fff 0%, #f9fbff 100%); + background: #fff; border-bottom: 1px solid #e8eef6; } @@ -298,7 +298,7 @@ .req-badge { padding: 6px 14px; - border-radius: 999px; + border-radius: 4px; background: #eff6ff; border: 1px solid rgba(29, 78, 216, .16); color: #1d4ed8; @@ -332,7 +332,7 @@ display: grid; place-items: center; border: 1px solid #e2e8f0; - border-radius: 999px; + border-radius: 4px; background: #fff; color: #64748b; font-size: 18px; @@ -363,7 +363,7 @@ .ai-chat-card, .ai-preview-card { min-height: 0; - border-radius: 22px; + border-radius: 4px; background: #fff; border: 1px solid #edf2f7; box-shadow: 0 1px 3px rgba(0, 0, 0, .04); @@ -382,9 +382,7 @@ gap: 12px; padding: 18px; overflow: auto; - background: - linear-gradient(180deg, color-mix(in srgb, var(--theme-primary-soft) 55%, transparent) 0%, rgba(255, 255, 255, 0) 140px), - #fff; + background: #fff; } .ai-chat-bubble { @@ -423,7 +421,7 @@ .ai-chat-content { max-width: min(100%, 640px); padding: 12px 14px; - border-radius: 18px; + border-radius: 4px; background: #f8fafc; border: 1px solid #edf2f7; } @@ -449,7 +447,7 @@ gap: 12px; padding: 14px 16px 16px; border-top: 1px solid #edf2f7; - background: linear-gradient(180deg, #fff, #fbfdff); + background: #fff; } .ai-file-input { @@ -464,8 +462,8 @@ gap: 12px; padding: 8px 8px 8px 14px; border: 1px solid #cbd8e5; - border-radius: 22px; - background: linear-gradient(180deg, #fff, #fbfdff); + border-radius: 4px; + background: #fff; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; } @@ -514,7 +512,7 @@ gap: 6px; min-height: 32px; padding: 0 12px; - border-radius: 999px; + border-radius: 4px; background: #eef6ff; border: 1px solid #d7e8fb; color: #334155; @@ -533,7 +531,7 @@ display: grid; place-items: center; padding: 0; - border-radius: 12px; + border-radius: 4px; font-size: 20px; transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, color 160ms ease; } @@ -602,7 +600,7 @@ .preview-field { padding: 12px 14px; - border-radius: 18px; + border-radius: 4px; background: #f8fafc; border: 1px solid #edf2f7; } @@ -643,7 +641,7 @@ gap: 10px; padding: 24px; border: 1px dashed #cbd5e1; - border-radius: 20px; + border-radius: 4px; color: #94a3b8; text-align: center; } @@ -667,7 +665,7 @@ justify-content: center; gap: 7px; padding: 0 20px; - border-radius: 999px; + border-radius: 4px; font-size: 13px; font-weight: 800; transition: all 180ms ease; @@ -893,7 +891,7 @@ width: min(calc(100vw - 28px), 920px); max-height: calc(100vh - 28px); padding: 18px; - border-radius: 20px; + border-radius: 4px; } .attachment-preview-head { @@ -950,7 +948,7 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; border: 1px solid transparent; font-size: 11px; font-weight: 800; diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 469de7d..20dff57 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -1,4 +1,4 @@ -.approval-page { +.approval-page { width: 100%; height: 100%; min-height: 0; @@ -35,17 +35,14 @@ gap: 10px; padding: 18px 24px 18px; border: 1px solid #edf2f7; - background: - radial-gradient(circle at 100% 100%, rgba(45, 212, 191, .18), transparent 18%), - radial-gradient(circle at 92% 92%, rgba(125, 211, 252, .14), transparent 24%), - linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); + background: #fff; overflow: visible; } .progress-card { padding: 18px 22px 20px; border: 1px solid #edf2f7; - background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); + background: #fff; } .hero-banner { @@ -75,7 +72,7 @@ overflow: hidden; border: 1px solid #e2e8f0; border-radius: 999px; - background: linear-gradient(180deg, #eff6ff 0%, #ecfeff 100%); + background: #f8fafc; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9); } @@ -111,7 +108,7 @@ align-items: center; min-height: 24px; padding: 0 9px; - border-radius: 999px; + border-radius: 4px; background: var(--theme-primary-soft); border: 1px solid rgba(var(--theme-primary-rgb), .16); color: var(--theme-primary-active); @@ -156,7 +153,7 @@ } .applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item::before { - content: "•"; + content: "/"; position: absolute; left: -10px; color: #cbd5e1; @@ -401,7 +398,7 @@ justify-content: center; padding: 0 9px; border: 1px solid #e2e8f0; - border-radius: 999px; + border-radius: 4px; background: #f8fafc; color: #64748b; font-size: 11px; @@ -525,7 +522,7 @@ gap: 6px; padding: 0 14px; border: 1px solid #dbe4ee; - border-radius: 999px; + border-radius: 4px; background: #f8fafc; color: #0f172a; font-size: 13px; @@ -552,7 +549,7 @@ min-width: 102px; min-height: 34px; padding: 0 12px; - border-radius: 999px; + border-radius: 4px; background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 14px; @@ -564,7 +561,7 @@ min-height: 84px; resize: none; border: 1px solid #d7e0ea; - border-radius: 8px; + border-radius: 4px; padding: 12px; font-size: 13px; line-height: 1.5; @@ -574,8 +571,8 @@ min-height: 84px; padding: 12px 14px; border: 1px solid #d7e0ea; - border-radius: 8px; - background: linear-gradient(180deg, #fbfdff 0%, #f8fafc 100%); + border-radius: 4px; + background: #f8fafc; color: #334155; font-size: 13px; line-height: 1.7; @@ -598,7 +595,7 @@ margin-top: 4px; overflow: hidden; border: 1px solid #e2e8f0; - border-radius: 8px; + border-radius: 4px; background: #fff; } @@ -751,7 +748,7 @@ gap: 8px; padding: 12px 14px; border: 1px solid #dbe4ee; - border-radius: 8px; + border-radius: 4px; background: #ffffff; } @@ -920,7 +917,7 @@ min-height: 34px; padding: 0 10px; border: 1px solid #d7e0ea; - border-radius: 8px; + border-radius: 4px; background: #fff; color: #0f172a; font-size: 12px; @@ -938,7 +935,7 @@ display: grid; place-items: center; border: 1px solid #d7e0ea; - border-radius: 8px; + border-radius: 4px; background: #f8fafc; color: #334155; font-size: 12px; @@ -1008,7 +1005,7 @@ align-items: center; margin-top: 6px; padding: 2px 8px; - border-radius: 999px; + border-radius: 4px; font-size: 11px; font-weight: 800; white-space: nowrap; @@ -1032,7 +1029,7 @@ margin-top: 12px; padding: 12px 14px; border: 1px solid rgba(var(--theme-primary-rgb), .22); - border-radius: 8px; + border-radius: 4px; background: var(--theme-primary-soft); color: var(--theme-primary-active); } @@ -1090,7 +1087,7 @@ align-items: center; justify-content: center; padding: 4px 10px; - border-radius: 999px; + border-radius: 4px; font-size: 11px; font-weight: 700; line-height: 1.2; @@ -1142,7 +1139,7 @@ justify-content: center; width: 30px; height: 30px; - border-radius: 999px; + border-radius: 4px; font-size: 14px; font-weight: 800; } @@ -1179,7 +1176,7 @@ min-height: 28px; padding: 0 10px; border: 1px solid #dbe4ee; - border-radius: 999px; + border-radius: 4px; background: #fff; color: #334155; font-size: 11px; @@ -1232,7 +1229,7 @@ gap: 5px; min-height: 28px; padding: 0 9px; - border-radius: 8px; + border-radius: 4px; background: var(--success-soft); color: var(--success-hover); font-size: 11px; @@ -1247,7 +1244,7 @@ gap: 5px; min-height: 28px; padding: 0 9px; - border-radius: 8px; + border-radius: 4px; background: var(--success-soft); color: var(--success-hover); font-size: 11px; @@ -1339,10 +1336,8 @@ gap: 14px; padding: 22px; border: 1px solid rgba(var(--theme-primary-rgb), .14); - border-radius: 24px; - background: - radial-gradient(circle at top left, rgba(var(--theme-primary-rgb), .12), transparent 36%), - linear-gradient(180deg, rgba(255, 255, 255, .98), rgba(247, 250, 252, .98)); + border-radius: 4px; + background: #fff; box-shadow: 0 28px 56px rgba(15, 23, 42, .2); } @@ -1368,7 +1363,7 @@ align-items: center; justify-content: center; border: 1px solid #d7e0ea; - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, .9); color: #475569; } @@ -1391,7 +1386,7 @@ align-items: center; min-height: 28px; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 12px; @@ -1420,9 +1415,9 @@ .attachment-insight-pane { min-height: 0; border: 1px solid #e2e8f0; - border-radius: 20px; + border-radius: 4px; overflow: hidden; - background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); + background: #f8fafc; } .attachment-source-pane { @@ -1485,7 +1480,7 @@ display: grid; gap: 8px; padding: 12px; - border-radius: 14px; + border-radius: 4px; background: #f8fafc; } @@ -1504,7 +1499,7 @@ gap: 6px; padding: 10px; border: 1px solid #fee2e2; - border-radius: 12px; + border-radius: 4px; background: #fff7f7; } @@ -1572,7 +1567,7 @@ align-items: center; justify-content: center; gap: 6px; - border-radius: 8px; + border-radius: 4px; font-size: 13px; font-weight: 760; } @@ -1632,7 +1627,7 @@ gap: 8px; padding: 12px 14px; border: 1px solid #e2e8f0; - border-radius: 8px; + border-radius: 4px; background: #f8fafc; } @@ -1681,7 +1676,7 @@ align-items: center; justify-content: center; border: 1px solid #e2e8f0; - border-radius: 999px; + border-radius: 4px; background: #fff; color: #475569; } @@ -1696,7 +1691,7 @@ gap: 10px; padding: 12px; border: 1px solid #fecaca; - border-radius: 10px; + border-radius: 4px; background: #fffafa; } @@ -1713,7 +1708,7 @@ display: inline-flex; align-items: center; padding: 0 8px; - border-radius: 999px; + border-radius: 4px; background: #fef2f2; color: #dc2626; font-size: 11px; @@ -1759,7 +1754,7 @@ gap: 8px; padding: 12px 12px 11px; border: 1px solid #e5e7eb; - border-radius: 10px; + border-radius: 4px; background: #ffffff; box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03); } diff --git a/web/src/components/audit/DigitalEmployeeWorkRecords.vue b/web/src/components/audit/DigitalEmployeeWorkRecords.vue new file mode 100644 index 0000000..82b90d9 --- /dev/null +++ b/web/src/components/audit/DigitalEmployeeWorkRecords.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/web/src/components/business/ExpenseProfileDetailModal.vue b/web/src/components/business/ExpenseProfileDetailModal.vue index c250420..a41a367 100644 --- a/web/src/components/business/ExpenseProfileDetailModal.vue +++ b/web/src/components/business/ExpenseProfileDetailModal.vue @@ -17,15 +17,15 @@ -
-
+
+
+ + {{ profileStatusText }} +
+ +
{{ metric.label }} {{ metric.value }}{{ metric.unit }} @@ -51,48 +56,43 @@ -
-
-
- {{ tag.displayLabel || tag.label }} - {{ tag.reason }} -
- - {{ tag.score }} - -
-
+ +

暂无可展示的画像标签。

行为雷达 - 使用项目图表组件组织,分数越高特征越明显 + 分数越高,行为特征越明显
-
+
+
+

暂无可展示的雷达维度。

-
    -
  • - {{ dimension.label }} - {{ dimension.score }} -
  • -
+
+ 行为标签: +
+ + {{ tag.label || tag.displayLabel }} + +
@@ -105,7 +105,7 @@ -
+
@@ -121,24 +121,23 @@
+

暂无最近操作记录。

diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index 84e73df..222de70 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -221,7 +221,7 @@
-

费用画像

+

用户画像

-
+
@@ -281,20 +283,26 @@ import { useToast } from '../../composables/useToast.js' import { assistantCapabilities, buildExpenseStatItems, - expenseProfileOperations, - expenseProfileRadarDimensions, - expenseProfileTags, progressItems, progressSteps, quickPromptItems, todoItems, - usageProfileMetrics } from '../../data/personalWorkbench.js' +import { fetchAgentRuns } from '../../services/agentAssets.js' import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js' +import { fetchCurrentEmployeeLatestProfile } from '../../services/reimbursements.js' import { ASSISTANT_SESSION_SNAPSHOT_EVENT, hasAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js' +import { + buildProfileOperationsFromAgentRuns, + buildUserProfileMetricCards, + buildUserProfileSummaryMetrics, + normalizeUserProfileRadarDimensions, + normalizeUserProfileTags, + resolveCurrentUserProfileError +} from '../../utils/employeeProfileViewModel.js' const props = defineProps({ showHeader: { type: Boolean, default: true }, @@ -313,6 +321,11 @@ const pendingAction = ref('') const latestExpenseConversation = ref(null) const hasLocalExpenseSnapshot = ref(false) const expenseProfileModalOpen = ref(false) +const employeeProfile = ref(null) +const employeeProfileRuns = ref([]) +const employeeProfileLoading = ref(false) +const employeeProfileError = ref('') +let employeeProfileLoadSeq = 0 const MAX_ATTACHMENTS = 10 const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_KNOWLEDGE = 'knowledge' @@ -359,16 +372,34 @@ const visibleExpenseStatItems = computed(() => { .filter(Boolean) }) const visibleUsageProfileMetrics = computed(() => { - const preferredKeys = ['ai-usage', 'submit-efficiency', 'auto-pass-rate', 'audit-duration'] - return preferredKeys - .map((key) => usageProfileMetrics.find((item) => item.key === key)) - .filter(Boolean) + return buildUserProfileMetricCards( + employeeProfile.value, + employeeProfileRuns.value, + currentUser.value + ).slice(0, 4) }) const expenseProfileModalMetrics = computed(() => { - const preferredKeys = ['stay-duration', 'ai-usage', 'auto-pass-rate', 'audit-duration'] - return preferredKeys - .map((key) => usageProfileMetrics.find((item) => item.key === key)) - .filter(Boolean) + return buildUserProfileSummaryMetrics( + employeeProfile.value, + employeeProfileRuns.value, + currentUser.value + ) +}) +const expenseProfileTags = computed(() => normalizeUserProfileTags(employeeProfile.value)) +const expenseProfileRadarDimensions = computed(() => normalizeUserProfileRadarDimensions(employeeProfile.value)) +const expenseProfileOperations = computed(() => + buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value) +) +const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.empty_reason || '').trim()) +const currentUserProfileKey = computed(() => { + const user = currentUser.value || {} + return [ + user.username, + user.email, + user.name, + user.employeeNo, + user.employee_no + ].map((item) => String(item || '').trim()).filter(Boolean).join('|') }) const visibleTodoItems = computed(() => todoItems.slice(0, 5)) const visibleProgressItems = computed(() => progressItems.slice(0, 5)) @@ -469,19 +500,46 @@ function openPromptAssistant(prompt) { }) } +async function loadCurrentEmployeeProfile() { + const sequence = ++employeeProfileLoadSeq + employeeProfileLoading.value = true + employeeProfileError.value = '' + + const [profileResult, runsResult] = await Promise.allSettled([ + fetchCurrentEmployeeLatestProfile({ + scene: 'operations', + window_days: 90, + expense_type_scope: 'overall' + }), + fetchAgentRuns({ limit: 100 }) + ]) + + if (sequence !== employeeProfileLoadSeq) { + return + } + + if (profileResult.status === 'fulfilled') { + employeeProfile.value = profileResult.value || null + } else { + employeeProfile.value = null + employeeProfileError.value = resolveCurrentUserProfileError(profileResult.reason) + } + + employeeProfileRuns.value = runsResult.status === 'fulfilled' ? runsResult.value || [] : [] + employeeProfileLoading.value = false +} + function openExpenseProfileModal() { expenseProfileModalOpen.value = true + if (!employeeProfile.value && !employeeProfileLoading.value) { + void loadCurrentEmployeeProfile() + } } function closeExpenseProfileModal() { expenseProfileModalOpen.value = false } -function explainExpenseProfile() { - closeExpenseProfileModal() - openPromptAssistant('请根据我的费用画像标签、行为雷达和最近 5 次操作,解释我的费用使用特点和可以优化的地方。') -} - function handleWorkbenchEnter(event) { if (event.isComposing) { return @@ -570,6 +628,7 @@ async function handleExpenseConversationAction() { onMounted(() => { refreshLocalExpenseSnapshot() refreshLatestExpenseConversation() + loadCurrentEmployeeProfile() window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange) }) @@ -585,6 +644,12 @@ watch( } } ) + +watch(currentUserProfileKey, (nextKey, previousKey) => { + if (nextKey && nextKey !== previousKey) { + loadCurrentEmployeeProfile() + } +}) diff --git a/web/src/components/charts/BarChart.vue b/web/src/components/charts/BarChart.vue index 2e28329..d8fd392 100644 --- a/web/src/components/charts/BarChart.vue +++ b/web/src/components/charts/BarChart.vue @@ -1,7 +1,7 @@ diff --git a/web/src/components/charts/BudgetTrendChart.vue b/web/src/components/charts/BudgetTrendChart.vue index 2d1cef2..fc016b8 100644 --- a/web/src/components/charts/BudgetTrendChart.vue +++ b/web/src/components/charts/BudgetTrendChart.vue @@ -36,6 +36,8 @@ const progress = useAnimationProgress([ () => props.available ], 1000) const themeColors = useThemeColors() +const prefersReducedMotion = () => + typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches const currency = (value) => Number(value || 0).toLocaleString('zh-CN', { @@ -80,7 +82,7 @@ const chartData = computed(() => ({ label: '已使用', data: scaleSeries(usedPercent.value), backgroundColor: themeColors.value.chartPrimary, - borderRadius: 5, + borderRadius: 4, borderSkipped: false, stack: 'budgetUsage', amounts: props.used @@ -89,7 +91,7 @@ const chartData = computed(() => ({ label: '已占用', data: scaleSeries(occupiedPercent.value), backgroundColor: themeColors.value.warning, - borderRadius: 5, + borderRadius: 4, borderSkipped: false, stack: 'budgetUsage', amounts: props.occupied @@ -98,7 +100,7 @@ const chartData = computed(() => ({ label: '剩余可用', data: scaleSeries(availablePercent.value), backgroundColor: '#e5edf3', - borderRadius: 5, + borderRadius: 4, borderSkipped: false, stack: 'budgetUsage', amounts: props.available @@ -114,7 +116,7 @@ const chartOptions = computed(() => ({ intersect: false }, animation: { - duration: 760, + duration: prefersReducedMotion() ? 0 : 760, easing: 'easeOutQuart' }, plugins: { @@ -127,6 +129,7 @@ const chartOptions = computed(() => ({ borderWidth: 1, bodyColor: '#475569', titleColor: '#0f172a', + cornerRadius: 4, padding: 12, displayColors: true, callbacks: { diff --git a/web/src/components/charts/DonutChart.vue b/web/src/components/charts/DonutChart.vue index f3667d3..374a876 100644 --- a/web/src/components/charts/DonutChart.vue +++ b/web/src/components/charts/DonutChart.vue @@ -1,14 +1,14 @@ + + diff --git a/web/src/components/charts/TrendChart.vue b/web/src/components/charts/TrendChart.vue index da09b71..c7ce9ed 100644 --- a/web/src/components/charts/TrendChart.vue +++ b/web/src/components/charts/TrendChart.vue @@ -5,30 +5,22 @@ 审批完成量(单) 平均审批时长(小时) -
- -
+ diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue index 675abbe..2623f24 100644 --- a/web/src/components/layout/SidebarRail.vue +++ b/web/src/components/layout/SidebarRail.vue @@ -159,7 +159,7 @@ const sidebarMeta = { policies: { label: '知识管理' }, audit: { label: '规则中心' }, digitalEmployees: { label: '数字员工' }, - logs: { label: '日志管理' }, + logs: { label: '系统日志' }, employees: { label: '员工管理' }, settings: { label: '系统设置' } } diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue index fb3c2d0..390016d 100644 --- a/web/src/components/layout/TopBar.vue +++ b/web/src/components/layout/TopBar.vue @@ -290,20 +290,20 @@ const documentKpis = computed(() => { ] }) -const logsKpis = computed(() => { - const summary = props.logsSummary ?? {} - const total = Number(summary.total ?? 0) - const running = Number(summary.running ?? 0) - const completed = Number(summary.completed ?? 0) - const failed = Number(summary.failed ?? 0) - - return [ - { label: 'Hermes 总任务', value: total, unit: '条', meta: '当前', trend: 'up', color: 'var(--theme-primary)' }, - { label: '运行中', value: running, unit: '条', meta: running > 0 ? '实时执行' : '暂无执行', trend: running > 0 ? 'up' : 'down', color: '#3b82f6' }, - { label: '已完成', value: completed, unit: '条', meta: total ? `占比 ${Math.round((completed / total) * 100)}%` : '等待数据', trend: 'up', color: 'var(--success)' }, - { label: '失败数', value: failed, unit: '条', meta: failed > 0 ? '需要关注' : '运行正常', trend: failed > 0 ? 'down' : 'up', color: '#ef4444' } - ] -}) +const logsKpis = computed(() => { + const summary = props.logsSummary ?? {} + const total = Number(summary.total ?? 0) + const errors = Number(summary.errors ?? 0) + const warnings = Number(summary.warnings ?? 0) + const info = Number(summary.info ?? 0) + + return [ + { label: '系统日志', value: total, unit: '条', meta: '当前', trend: 'up', color: 'var(--theme-primary)' }, + { label: '错误数量', value: errors, unit: '条', meta: errors > 0 ? '需要关注' : '运行正常', trend: errors > 0 ? 'down' : 'up', color: '#ef4444' }, + { label: '告警数量', value: warnings, unit: '条', meta: warnings > 0 ? '建议排查' : '暂无告警', trend: warnings > 0 ? 'down' : 'up', color: '#f59e0b' }, + { label: '正常数量', value: info, unit: '条', meta: total ? `占比 ${Math.round((info / total) * 100)}%` : '等待数据', trend: 'up', color: 'var(--success)' } + ] +}) const chatKpis = [ { label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' }, diff --git a/web/src/components/travel/BudgetAssistantReport.vue b/web/src/components/travel/BudgetAssistantReport.vue index e2c67ea..d611616 100644 --- a/web/src/components/travel/BudgetAssistantReport.vue +++ b/web/src/components/travel/BudgetAssistantReport.vue @@ -113,19 +113,19 @@ const summaryCards = computed(() => [ label: '上季度开销', value: props.report.summary?.totalSpend || '—', hint: '按四类预算口径汇总', - color: 'var(--chart-blue)' + color: 'var(--theme-secondary)' }, { label: '预算使用率', value: props.report.summary?.usageRate || '—', hint: '未触达风险线', - color: 'var(--chart-amber)' + color: 'var(--warning)' }, { label: '建议编制额', value: props.report.summary?.recommendedTotal || '—', hint: '含业务增长预留', - color: 'var(--chart-purple)' + color: 'var(--info)' } ]) @@ -145,9 +145,9 @@ const summaryCards = computed(() => [ .budget-report-action-panel, .budget-report-summary-card { border: 1px solid #dbe4ee; - border-radius: 8px; + border-radius: 4px; background: #fff; - box-shadow: 0 8px 20px rgba(15, 23, 42, .05); + box-shadow: 0 1px 2px rgba(15, 23, 42, .04); } .budget-report-head { @@ -187,7 +187,7 @@ const summaryCards = computed(() => [ align-items: center; gap: 5px; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 12px; @@ -291,7 +291,7 @@ const summaryCards = computed(() => [ padding: 12px; border: 1px solid #e2e8f0; border-left: 3px solid var(--accent); - border-radius: 8px; + border-radius: 4px; background: #fbfdff; animation: budgetReportItemIn 460ms var(--ease, ease) both; animation-delay: var(--delay, 0ms); @@ -326,7 +326,7 @@ const summaryCards = computed(() => [ .budget-report-expense-card header em { margin-left: auto; padding: 1px 7px; - border-radius: 999px; + border-radius: 4px; font-size: 11px; font-style: normal; font-weight: 850; @@ -357,7 +357,7 @@ const summaryCards = computed(() => [ display: inline-flex; align-items: center; padding: 0 7px; - border-radius: 6px; + border-radius: 4px; background: #f1f5f9; color: #475569; font-size: 11px; @@ -389,7 +389,7 @@ const summaryCards = computed(() => [ .budget-report-expense-card li { padding: 2px 7px; - border-radius: 999px; + border-radius: 4px; background: #fff; border: 1px solid #e2e8f0; } diff --git a/web/src/composables/useEcharts.js b/web/src/composables/useEcharts.js new file mode 100644 index 0000000..05f9524 --- /dev/null +++ b/web/src/composables/useEcharts.js @@ -0,0 +1,79 @@ +import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue' +import { init } from 'echarts/core' + +export function useEcharts(chartElement, chartOptions) { + let chartInstance = null + let resizeObserver = null + let renderFrame = 0 + + function renderChart() { + if (!chartElement.value) { + return + } + if (!chartInstance) { + chartInstance = init(chartElement.value, null, { renderer: 'canvas' }) + chartInstance.resize() + } + chartInstance.setOption(chartOptions.value, true) + } + + function handleResize() { + chartInstance?.resize() + } + + function scheduleRender() { + if (typeof window === 'undefined') { + renderChart() + return + } + if (renderFrame) { + window.cancelAnimationFrame(renderFrame) + } + renderFrame = window.requestAnimationFrame(() => { + renderFrame = 0 + renderChart() + }) + } + + function bindResize() { + if (!chartElement.value) { + return + } + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(handleResize) + resizeObserver.observe(chartElement.value) + } + window.addEventListener('resize', handleResize) + } + + function unbindResize() { + resizeObserver?.disconnect() + resizeObserver = null + window.removeEventListener('resize', handleResize) + } + + onMounted(() => { + renderChart() + bindResize() + }) + + onBeforeUnmount(() => { + unbindResize() + if (renderFrame && typeof window !== 'undefined') { + window.cancelAnimationFrame(renderFrame) + renderFrame = 0 + } + if (chartInstance) { + chartInstance.dispose() + chartInstance = null + } + }) + + watch(chartOptions, () => { + nextTick(scheduleRender) + }, { deep: true }) + + return { + renderChart + } +} diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js index 4f2604f..9834864 100644 --- a/web/src/composables/useNavigation.js +++ b/web/src/composables/useNavigation.js @@ -83,11 +83,11 @@ export const navItems = [ }, { id: 'logs', - label: '日志管理', - navHint: '查看 Hermes 调用与系统运行日志', + label: '系统日志', + navHint: '查看系统运行日志', icon: icons.logs, - title: '日志管理', - desc: '集中查看 Hermes 归纳任务进度、调用明细与系统运行日志。' + title: '系统日志', + desc: '集中查看系统运行日志、结构化事件和请求追踪信息。' }, { id: 'settings', diff --git a/web/src/composables/useSettings.js b/web/src/composables/useSettings.js index c57da99..ade1050 100644 --- a/web/src/composables/useSettings.js +++ b/web/src/composables/useSettings.js @@ -107,6 +107,10 @@ export function useSettings() { pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState)) persistSettings(pageState.value) updateBrandPreviewFromState(pageState.value) + + if (nextState.appearanceForm?.themeSkin) { + setThemeSkin(nextState.appearanceForm.themeSkin) + } } async function loadSettingsSnapshot() { @@ -123,6 +127,7 @@ export function useSettings() { function buildSettingsPayload() { return { companyForm: { ...pageState.value.companyForm }, + appearanceForm: { ...pageState.value.appearanceForm }, adminForm: { ...pageState.value.adminForm }, sessionForm: { ...pageState.value.sessionForm }, llmForm: buildLlmPayload(pageState.value.llmForm), @@ -307,10 +312,16 @@ export function useSettings() { function selectThemeSkin(skinId) { setThemeSkin(skinId) + pageState.value.appearanceForm.themeSkin = skinId } - function saveAppearanceSection() { - toast('界面皮肤已应用到当前浏览器。') + async function saveAppearanceSection() { + await persistRemoteSettings('界面皮肤已保存并应用到企业配置。', { + preserveModelApiKeys: true, + preserveAdminPasswords: true, + preserveRenderSecret: true, + preserveMailPassword: true + }) } async function saveLlmSection() { diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index aa30453..52da9a3 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -13,6 +13,8 @@ import { setRuntimeApiBaseUrl } from '../services/api.js' import { checkBackendHealth } from './useBackendHealth.js' import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js' import { useToast } from './useToast.js' +import { fetchSettings } from '../services/settings.js' +import { setThemeSkin } from './useThemeSkin.js' const AUTH_STORAGE_KEY = 'x-financial-authenticated' const AUTH_USERNAME_KEY = 'x-financial-auth-username' @@ -86,68 +88,68 @@ function readStoredUsername() { } function buildAnonymousUser() { - return { - username: '', - name: '', - role: '', - department: '', - departmentName: '', - position: '', - grade: '', - employeeNo: '', - managerName: '', - location: '', - costCenter: '', - financeOwnerName: '', - riskProfile: {}, - roleCodes: [], - email: '', - avatar: '', - isAdmin: false + return { + username: '', + name: '', + role: '', + department: '', + departmentName: '', + position: '', + grade: '', + employeeNo: '', + managerName: '', + location: '', + costCenter: '', + financeOwnerName: '', + riskProfile: {}, + roleCodes: [], + email: '', + avatar: '', + isAdmin: false } } -function buildLegacyAdminUser(username = '') { +function buildLegacyAdminUser(username = '') { const normalized = String(username || '').trim() const name = normalized || DEFAULT_USER_NAME - return { - username: normalized, - name, - role: DEFAULT_USER_ROLE, - department: '', - departmentName: '', - position: DEFAULT_USER_ROLE, - grade: '', - employeeNo: '', - managerName: '', - location: '', - costCenter: '', - financeOwnerName: '', - riskProfile: {}, - roleCodes: ['manager'], - email: '', - avatar: name.slice(0, 1).toUpperCase(), - isAdmin: true - } -} - -function resolvePlatformAdminFlag(payload, roleCodes = []) { - const username = String(payload?.username || payload?.account || '').trim().toLowerCase() - const role = String(payload?.role || '').trim().toLowerCase() - const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) - - return ( - Boolean(payload?.isAdmin) - || username === 'admin' - || role === 'admin' - || role === '管理员' - || role === '系统管理员' - || normalizedRoleCodes.includes('admin') - ) -} - -function readStoredUser() { + return { + username: normalized, + name, + role: DEFAULT_USER_ROLE, + department: '', + departmentName: '', + position: DEFAULT_USER_ROLE, + grade: '', + employeeNo: '', + managerName: '', + location: '', + costCenter: '', + financeOwnerName: '', + riskProfile: {}, + roleCodes: ['manager'], + email: '', + avatar: name.slice(0, 1).toUpperCase(), + isAdmin: true + } +} + +function resolvePlatformAdminFlag(payload, roleCodes = []) { + const username = String(payload?.username || payload?.account || '').trim().toLowerCase() + const role = String(payload?.role || '').trim().toLowerCase() + const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) + + return ( + Boolean(payload?.isAdmin) + || username === 'admin' + || role === 'admin' + || role === '管理员' + || role === '系统管理员' + || normalizedRoleCodes.includes('admin') + ) +} + +function readStoredUser() { if (typeof window === 'undefined') { return buildAnonymousUser() } @@ -162,26 +164,26 @@ function readStoredUser() { const name = String(payload.name || username || DEFAULT_USER_NAME).trim() const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : [] - return { - username, - name, - role: String(payload.role || DEFAULT_USER_ROLE), - department: String(payload.department || payload.departmentName || ''), - departmentName: String(payload.departmentName || payload.department || ''), - position: String(payload.position || ''), - grade: String(payload.grade || ''), - employeeNo: String(payload.employeeNo || payload.employee_no || ''), - managerName: String(payload.managerName || payload.manager_name || ''), - location: String(payload.location || ''), - costCenter: String(payload.costCenter || payload.cost_center || ''), - financeOwnerName: String(payload.financeOwnerName || payload.finance_owner_name || ''), - riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {}, - roleCodes, - email: String(payload.email || ''), - avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), - isAdmin: resolvePlatformAdminFlag(payload, roleCodes) - } - } + return { + username, + name, + role: String(payload.role || DEFAULT_USER_ROLE), + department: String(payload.department || payload.departmentName || ''), + departmentName: String(payload.departmentName || payload.department || ''), + position: String(payload.position || ''), + grade: String(payload.grade || ''), + employeeNo: String(payload.employeeNo || payload.employee_no || ''), + managerName: String(payload.managerName || payload.manager_name || ''), + location: String(payload.location || ''), + costCenter: String(payload.costCenter || payload.cost_center || ''), + financeOwnerName: String(payload.financeOwnerName || payload.finance_owner_name || ''), + riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {}, + roleCodes, + email: String(payload.email || ''), + avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), + isAdmin: resolvePlatformAdminFlag(payload, roleCodes) + } + } } catch { return buildLegacyAdminUser(readStoredUsername()) } @@ -359,6 +361,15 @@ export function installSessionNavigation(router) { .then((state) => { applyBootstrapState(state) setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) + fetchSettings() + .then((snapshot) => { + if (snapshot?.appearanceForm?.themeSkin) { + setThemeSkin(snapshot.appearanceForm.themeSkin) + } + }) + .catch((error) => { + console.warn('Failed to load remote theme settings:', error) + }) router.isReady().then(() => reconcileEntryRoute(router)) }) .catch(() => { @@ -624,14 +635,14 @@ async function handleLogin(credentials) { password: credentials.password }) - const responseUser = response?.user || buildAnonymousUser() - const responseRoleCodes = Array.isArray(responseUser.roleCodes) ? responseUser.roleCodes.filter(Boolean) : [] - const user = { - ...responseUser, - roleCodes: responseRoleCodes, - isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes) - } - loggedIn.value = true + const responseUser = response?.user || buildAnonymousUser() + const responseRoleCodes = Array.isArray(responseUser.roleCodes) ? responseUser.roleCodes.filter(Boolean) : [] + const user = { + ...responseUser, + roleCodes: responseRoleCodes, + isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes) + } + loggedIn.value = true persistAuthState(true, user) currentUser.value = user touchAuthActivity(true) diff --git a/web/src/data/personalWorkbench.js b/web/src/data/personalWorkbench.js index 60616fe..895ec6f 100644 --- a/web/src/data/personalWorkbench.js +++ b/web/src/data/personalWorkbench.js @@ -207,7 +207,7 @@ export function buildExpenseStatItems(summary = {}) { ] } -/** 费用画像:待后端接入后替换为真实用户行为统计 */ +/** 用户画像历史示例数据:首页已切换为后端真实画像,仅保留给旧演示入口兜底。 */ export const usageProfileMetrics = [ { key: 'stay-duration', diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index 81b86a6..a4e9efd 100644 --- a/web/src/services/reimbursements.js +++ b/web/src/services/reimbursements.js @@ -32,6 +32,18 @@ export function fetchEmployeeLatestProfile(employeeId, params = {}) { return apiRequest(`/employee-profiles/${encodeURIComponent(String(employeeId || '').trim())}/latest${suffix}`) } +export function fetchCurrentEmployeeLatestProfile(params = {}) { + const query = new URLSearchParams() + Object.entries(params || {}).forEach(([key, value]) => { + const normalized = String(value ?? '').trim() + if (normalized) { + query.set(key, normalized) + } + }) + const suffix = query.toString() ? `?${query.toString()}` : '' + return apiRequest(`/employee-profiles/me/latest${suffix}`) +} + export function updateExpenseClaim(claimId, payload = {}) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, { method: 'PATCH', diff --git a/web/src/utils/employeeProfileViewModel.js b/web/src/utils/employeeProfileViewModel.js new file mode 100644 index 0000000..8202ba4 --- /dev/null +++ b/web/src/utils/employeeProfileViewModel.js @@ -0,0 +1,445 @@ +const PROFILE_TYPE_LABELS = { + expense: '费用申请', + process_quality: '流程质量', + ai_usage: 'AI 协作', + approval: '审核行为' +} + +const STATUS_LABELS = { + succeeded: '已完成', + success: '已完成', + running: '进行中', + blocked: '待确认', + failed: '失败' +} + +const STATUS_TONES = { + succeeded: 'success', + success: 'success', + running: 'warning', + blocked: 'warning', + failed: 'danger' +} + +const AGENT_LABELS = { + hermes: 'Hermes 数字员工', + user_agent: '智能问答助手', + orchestrator: '智能编排服务', + system: '系统服务' +} + +const AGENT_SHORT_LABELS = { + hermes: 'Hermes', + user_agent: '问答助手', + orchestrator: '编排服务', + system: '系统服务' +} + +const RADAR_COLORS = [ + '#3a7ca5', + '#0f9f8f', + '#f59e0b', + '#7c3aed', + '#dc2626', + '#2563eb', + '#16a34a', + '#db2777' +] + +const TAG_ACCENT_COUNT = 8 + +const SOURCE_LABELS = { + user_message: '用户对话', + schedule: '定时任务', + system_event: '系统事件', + workbench: '个人工作台', + detail: '单据详情', + documents_application: '单据中心' +} + +const SCENARIO_LABELS = { + knowledge: '知识库问答', + expense: '费用报销', + reimbursement: '费用报销', + expense_application: '费用申请', + application: '费用申请', + budget: '预算查询', + audit: '风险审核', + approval: '审批处理', + policy: '制度问答', + travel: '差旅费用', + entertainment: '业务招待', + accounts_receivable: '应收查询', + accounts_payable: '应付查询' +} + +const INTENT_LABELS = { + query: '查询', + explain: '解释', + compare: '对比', + risk_check: '风险检查', + draft: '草稿生成', + operate: '操作办理', + review: '审核', + submit: '提交' +} + +const JOB_TYPE_LABELS = { + knowledge_index_sync: '知识库索引同步', + llm_wiki_sync: '知识库归纳同步', + employee_behavior_profile_scan: '用户画像测算', + workbench_on_demand: '工作台画像测算', + global_risk_scan: '全局风险巡检', + weekly_expense_report: '周费用报告' +} + +export function buildUserProfileMetricCards(profile, runs = [], currentUser = {}) { + const index = indexProfiles(profile) + const aiMetrics = metricsOf(index.ai_usage) + const userRuns = filterRunsByCurrentUser(runs, currentUser) + const durationDisplay = formatDurationMetric(sumRunDurationMs(userRuns)) + const commonAgent = resolveCommonAgent(userRuns) + const tokenCount = resolveNumber(aiMetrics.exact_token_count) || resolveNumber(aiMetrics.estimated_token_count) + const tokenDisplay = formatTokenCount(tokenCount) + const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || userRuns.length + + return [ + { + key: 'usage-duration', + label: '使用时长', + value: durationDisplay.value, + unit: durationDisplay.unit, + hint: `近${resolveWindowDays(profile)}天智能体运行累计`, + icon: 'mdi mdi-timer-sand', + tone: 'primary' + }, + { + key: 'common-agent', + label: '常用智能体', + value: commonAgent.label, + unit: '', + hint: commonAgent.count ? `${commonAgent.count} 次调用,占比 ${commonAgent.share}` : '暂无智能体调用记录', + icon: 'mdi mdi-account-tie-voice-outline', + tone: 'cyan' + }, + { + key: 'ai-usage', + label: 'AI 使用次数', + value: formatNumber(aiRunCount), + unit: '次', + hint: `近${resolveWindowDays(profile)}天智能协作记录`, + icon: 'mdi mdi-robot-outline', + tone: 'violet' + }, + { + key: 'token-usage', + label: 'Token 消耗', + value: tokenDisplay.value, + unit: tokenDisplay.unit, + hint: resolveTokenHint(aiMetrics), + icon: 'mdi mdi-lightning-bolt-outline', + tone: 'amber' + } + ] +} + +export function buildUserProfileSummaryMetrics(profile, runs = [], currentUser = {}) { + return buildUserProfileMetricCards(profile, runs, currentUser).slice(0, 4) +} + +export function normalizeUserProfileTags(profile, limit = 8) { + return (Array.isArray(profile?.profile_tags) ? profile.profile_tags : []) + .map((tag) => ({ + code: normalizeText(tag.code || tag.label), + label: normalizeText(tag.label), + displayLabel: normalizeText(tag.display_label || tag.displayLabel || tag.label), + tone: resolveTagTone(tag), + score: clampScore(tag.score), + reason: normalizeText(tag.reason) || '画像算法已识别该行为特征。', + confidence: resolveNumber(tag.confidence) + })) + .filter((tag) => tag.code && tag.displayLabel) + .sort((left, right) => right.score - left.score) + .slice(0, limit) + .map((tag, index) => ({ + ...tag, + colorIndex: index % TAG_ACCENT_COUNT + })) +} + +export function normalizeUserProfileRadarDimensions(profile) { + const dimensions = Array.isArray(profile?.radar?.dimensions) ? profile.radar.dimensions : [] + if (dimensions.length) { + return withRadarColors( + dimensions.map((item) => ({ + code: normalizeText(item.code || item.label), + label: normalizeText(item.label || item.code), + score: clampScore(item.score) + })) + ) + } + + return withRadarColors( + (Array.isArray(profile?.profiles) ? profile.profiles : []) + .map((item) => ({ + code: normalizeText(item.profile_type), + label: PROFILE_TYPE_LABELS[item.profile_type] || normalizeText(item.profile_label || item.profile_type), + score: clampScore(item.score) + })) + .filter((item) => item.code && item.label) + ) +} + +export function buildProfileOperationsFromAgentRuns(runs, currentUser, limit = 5) { + const identities = resolveCurrentUserIdentities(currentUser) + return (Array.isArray(runs) ? runs : []) + .filter((run) => belongsToCurrentUser(run, identities)) + .sort((left, right) => Date.parse(right.started_at || 0) - Date.parse(left.started_at || 0)) + .slice(0, limit) + .map((run, index) => ({ + id: normalizeText(run.run_id || run.id) || `operation-${index + 1}`, + time: formatOperationTime(run.started_at), + action: resolveOperationAction(run), + target: resolveOperationTarget(run), + channel: resolveOperationChannel(run), + status: STATUS_LABELS[normalizeCode(run.status)] || normalizeText(run.status) || '未知', + tone: STATUS_TONES[normalizeCode(run.status)] || 'info' + })) +} + +export function resolveCurrentUserProfileError(error) { + return normalizeText(error?.message) || '用户画像读取失败,请稍后重试。' +} + +function indexProfiles(profile) { + return Object.fromEntries( + (Array.isArray(profile?.profiles) ? profile.profiles : []) + .map((item) => [normalizeText(item.profile_type), item]) + .filter(([key]) => key) + ) +} + +function metricsOf(profile) { + return profile?.metrics && typeof profile.metrics === 'object' ? profile.metrics : {} +} + +function filterRunsByCurrentUser(runs, currentUser) { + const identities = resolveCurrentUserIdentities(currentUser) + return (Array.isArray(runs) ? runs : []).filter((run) => belongsToCurrentUser(run, identities)) +} + +function belongsToCurrentUser(run, identities) { + if (!identities.size) { + return false + } + const userId = normalizeText(run?.user_id).toLowerCase() + return Boolean(userId && identities.has(userId)) +} + +function resolveCurrentUserIdentities(user = {}) { + return new Set( + [ + user.username, + user.email, + user.name, + user.employeeNo, + user.employee_no + ] + .map((item) => normalizeText(item).toLowerCase()) + .filter(Boolean) + ) +} + +function resolveCommonAgent(runs) { + const counts = new Map() + for (const run of runs) { + const code = normalizeCode(run?.agent || run?.route_json?.selected_agent || 'system') || 'system' + counts.set(code, (counts.get(code) || 0) + 1) + } + + const [code = '', count = 0] = Array.from(counts.entries()) + .sort((left, right) => right[1] - left[1])[0] || [] + + if (!code || !count) { + return { label: '暂无', count: 0, share: '0%' } + } + + return { + label: AGENT_SHORT_LABELS[code] || translateKnownValue(code, AGENT_LABELS, '智能体') || '智能体', + count, + share: formatPercent(count / Math.max(1, runs.length)) + } +} + +function sumRunDurationMs(runs) { + return runs.reduce((total, run) => total + resolveRunDurationMs(run), 0) +} + +function resolveRunDurationMs(run) { + const startedAt = Date.parse(run?.started_at || '') + const finishedAt = Date.parse(run?.finished_at || '') + if (Number.isFinite(startedAt) && Number.isFinite(finishedAt) && finishedAt > startedAt) { + return Math.min(finishedAt - startedAt, 24 * 60 * 60 * 1000) + } + + return (Array.isArray(run?.tool_calls) ? run.tool_calls : []).reduce( + (total, tool) => total + Math.max(0, resolveNumber(tool?.duration_ms)), + 0 + ) +} + +function resolveOperationAction(run) { + const semanticText = normalizeText(run?.semantic_parse?.raw_query) + if (semanticText) { + return `${resolveOperationBusinessLabel(run)}:${semanticText}` + } + return translateKnownValue(run?.result_summary, JOB_TYPE_LABELS, '') + || translateKnownValue(run?.route_json?.job_type, JOB_TYPE_LABELS, '执行系统任务') + || '执行智能财务任务' +} + +function resolveOperationTarget(run) { + return translateKnownValue(run?.route_json?.task_title, JOB_TYPE_LABELS, '') + || translateKnownValue(run?.route_json?.asset_name, JOB_TYPE_LABELS, '') + || translateKnownValue(run?.semantic_parse?.scenario, SCENARIO_LABELS, '业务操作') + || translateKnownValue(run?.task_id, JOB_TYPE_LABELS, '系统任务') + || '个人工作台' +} + +function resolveOperationChannel(run) { + const agent = translateKnownValue(run?.agent, AGENT_LABELS, '智能服务') || 'Hermes 数字员工' + const source = translateKnownValue(run?.source, SOURCE_LABELS, '系统入口') + return source ? `${agent} · ${source}` : agent +} + +function resolveOperationBusinessLabel(run) { + const scenario = translateKnownValue(run?.semantic_parse?.scenario, SCENARIO_LABELS, '业务操作') + const intent = translateKnownValue(run?.semantic_parse?.intent, INTENT_LABELS, '') + if (scenario && intent) { + return `${scenario}${intent}` + } + return scenario || intent || '发起' +} + +function resolveTagTone(tag) { + const polarity = normalizeText(tag?.polarity || tag?.tone).toLowerCase() + if (['risk', 'danger', 'negative'].includes(polarity)) { + return 'risk' + } + if (['positive', 'success'].includes(polarity)) { + return 'positive' + } + return 'behavior' +} + +function resolveWindowDays(profile) { + const days = Number(profile?.window_days || 90) + return Number.isFinite(days) && days > 0 ? Math.round(days) : 90 +} + +function resolveTokenHint(metrics) { + const mode = normalizeText(metrics.token_count_mode) + return mode === 'estimated_token_count' ? '按运行载荷估算' : '模型调用累计' +} + +function formatTokenCount(value) { + const count = resolveNumber(value) + if (count >= 10000) { + return { value: trimNumber(count / 10000, 2), unit: '万' } + } + return { value: formatNumber(count), unit: 'tokens' } +} + +function formatDurationMetric(totalMs) { + const seconds = Math.round(Math.max(0, resolveNumber(totalMs)) / 1000) + if (seconds < 60) { + return { value: formatNumber(seconds), unit: '秒' } + } + const minutes = seconds / 60 + if (minutes < 60) { + return { value: trimNumber(minutes, minutes >= 10 ? 0 : 1), unit: '分钟' } + } + const hours = minutes / 60 + return { value: trimNumber(hours, hours >= 10 ? 0 : 1), unit: '小时' } +} + +function withRadarColors(items) { + return items.map((item, index) => ({ + ...item, + color: item.color || RADAR_COLORS[index % RADAR_COLORS.length] + })) +} + +function formatOperationTime(value) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '时间未知' + } + const now = new Date() + const sameDay = date.toDateString() === now.toDateString() + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + const time = `${pad(date.getHours())}:${pad(date.getMinutes())}` + if (sameDay) { + return `今天 ${time}` + } + if (date.toDateString() === yesterday.toDateString()) { + return `昨天 ${time}` + } + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}` +} + +function formatNumber(value) { + return String(Math.round(resolveNumber(value))) +} + +function formatPercent(value) { + return `${Math.round(resolveNumber(value) * 100)}%` +} + +function formatMoney(value) { + return `¥${trimNumber(resolveNumber(value), 2)}` +} + +function trimNumber(value, digits = 0) { + const number = Number(value || 0) + return number.toFixed(digits).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1') +} + +function clampScore(value) { + const score = Math.round(resolveNumber(value)) + return Math.max(0, Math.min(100, score)) +} + +function resolveNumber(value) { + const number = Number(value || 0) + return Number.isFinite(number) ? number : 0 +} + +function normalizeText(value) { + return String(value || '').trim() +} + +function normalizeCode(value) { + return normalizeText(value).toLowerCase() +} + +function translateKnownValue(value, labels, internalFallback = '') { + const raw = normalizeText(value) + if (!raw) { + return '' + } + const mapped = labels[normalizeCode(raw)] + if (mapped) { + return mapped + } + return isInternalCode(raw) ? internalFallback : raw +} + +function isInternalCode(value) { + return /^[a-z][a-z0-9_:-]*$/i.test(normalizeText(value)) +} + +function pad(value) { + return String(value).padStart(2, '0') +} diff --git a/web/src/utils/loginEntryTransition.js b/web/src/utils/loginEntryTransition.js new file mode 100644 index 0000000..086bab0 --- /dev/null +++ b/web/src/utils/loginEntryTransition.js @@ -0,0 +1,35 @@ +const LOGIN_ENTRY_TRANSITION_KEY = 'x-financial-login-entry-transition' +const LOGIN_ENTRY_TRANSITION_MAX_AGE_MS = 5000 + +function canUseSessionStorage() { + return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined' +} + +export function markLoginEntryTransition() { + if (!canUseSessionStorage()) { + return + } + + window.sessionStorage.setItem(LOGIN_ENTRY_TRANSITION_KEY, String(Date.now())) +} + +export function consumeLoginEntryTransition() { + if (!canUseSessionStorage()) { + return false + } + + const rawTimestamp = window.sessionStorage.getItem(LOGIN_ENTRY_TRANSITION_KEY) + window.sessionStorage.removeItem(LOGIN_ENTRY_TRANSITION_KEY) + + if (!rawTimestamp) { + return false + } + + const timestamp = Number(rawTimestamp) + + if (!Number.isFinite(timestamp)) { + return true + } + + return Date.now() - timestamp <= LOGIN_ENTRY_TRANSITION_MAX_AGE_MS +} diff --git a/web/src/utils/settingsModelHelper.js b/web/src/utils/settingsModelHelper.js index e25059c..3033b48 100644 --- a/web/src/utils/settingsModelHelper.js +++ b/web/src/utils/settingsModelHelper.js @@ -206,6 +206,9 @@ export function buildDefaultState(companyProfile, currentUser) { recordNumber: '', copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.` }, + appearanceForm: { + themeSkin: 'sky' + }, adminForm: { adminAccount, adminEmail, @@ -310,6 +313,7 @@ export function mergeState(baseState, overrideState) { return { companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) }, + appearanceForm: { ...baseState.appearanceForm, ...(overrideState?.appearanceForm || {}) }, adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) }, sessionForm: { ...baseState.sessionForm, ...(overrideState?.sessionForm || {}) }, hermesForm: mergeHermesEmployeeForm({ @@ -326,6 +330,7 @@ export function mergeState(baseState, overrideState) { export function sanitizeForStorage(state) { return { companyForm: { ...state.companyForm }, + appearanceForm: { ...state.appearanceForm }, adminForm: { ...state.adminForm, newPassword: '', diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 1cfc2fe..0b18098 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -1,6 +1,27 @@