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

@@ -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()

View File

@@ -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="")

View File

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

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,

View File

@@ -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()