feat: 引入 ECharts 统一图表并完善员工画像标签分页
后端优化员工行为画像服务和辅助函数,完善系统设置模型和 配置持久化,前端引入 ECharts 替换所有图表组件实现统一 渲染,新增员工画像标签分页器和数字员工工作记录组件,优 化工作台响应式布局和登录页过渡动画,完善预算中心和数字 员工页面样式细节。
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user