feat: 引入 ECharts 统一图表并完善员工画像标签分页

后端优化员工行为画像服务和辅助函数,完善系统设置模型和
配置持久化,前端引入 ECharts 替换所有图表组件实现统一
渲染,新增员工画像标签分页器和数字员工工作记录组件,优
化工作台响应式布局和登录页过渡动画,完善预算中心和数字
员工页面样式细节。
This commit is contained in:
caoxiaozhu
2026-05-28 16:24:59 +08:00
parent 8a4a777be7
commit e384318046
53 changed files with 4698 additions and 2468 deletions

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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,