添加AI指挥部全员为超管,添加使用统计功能
This commit is contained in:
parent
0b83663f77
commit
61039682e9
@ -26,3 +26,5 @@ FRONTEND_URL=http://localhost:5173
|
|||||||
DINGTALK_ALLOWED_UNION_IDS=
|
DINGTALK_ALLOWED_UNION_IDS=
|
||||||
# 可选:指定钉钉 unionId 自动授予超级管理员
|
# 可选:指定钉钉 unionId 自动授予超级管理员
|
||||||
DINGTALK_SUPER_ADMIN_UNION_IDS=
|
DINGTALK_SUPER_ADMIN_UNION_IDS=
|
||||||
|
DINGTALK_SUPER_ADMIN_DEPT_IDS=
|
||||||
|
DINGTALK_SUPER_ADMIN_DEPT_NAMES=
|
||||||
|
|||||||
@ -36,6 +36,10 @@ class Settings:
|
|||||||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||||||
dingtalk_allowed_union_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_UNION_IDS", "").split(",") if item.strip()}
|
dingtalk_allowed_union_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_UNION_IDS", "").split(",") if item.strip()}
|
||||||
dingtalk_super_admin_union_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_UNION_IDS", "").split(",") if item.strip()}
|
dingtalk_super_admin_union_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_UNION_IDS", "").split(",") if item.strip()}
|
||||||
|
# Members of these departments receive the same access as super admins.
|
||||||
|
# IDs are preferred; names are supported as a readable fallback.
|
||||||
|
dingtalk_super_admin_dept_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_IDS", "").split(",") if item.strip()}
|
||||||
|
dingtalk_super_admin_dept_names = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_NAMES", "").split(",") if item.strip()}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@ -26,7 +26,7 @@ from sqlalchemy.orm import Session, defer, noload, selectinload
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, SessionLocal, engine, get_db
|
from .database import Base, SessionLocal, engine, get_db
|
||||||
from .llm import extract_questions_from_images, generate_answer_for_question, generate_answers_batch, generate_answers_for_questions, generate_questions, generate_qa
|
from .llm import extract_questions_from_images, generate_answer_for_question, generate_answers_batch, generate_answers_for_questions, generate_questions, generate_qa
|
||||||
from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QABulkGenerationItem, QABulkGenerationJob, QALibraryEntry, RefundUploadSession
|
from .models import AdminLoginLog, AdminUsageEntry, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QABulkGenerationItem, QABulkGenerationJob, QALibraryEntry, RefundUploadSession
|
||||||
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest
|
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest
|
||||||
from .security import create_session_token, decode_session_token, hash_password, verify_password
|
from .security import create_session_token, decode_session_token, hash_password, verify_password
|
||||||
|
|
||||||
@ -241,12 +241,22 @@ DINGTALK_STATES: dict[str, float] = {}
|
|||||||
|
|
||||||
ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"}
|
ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"}
|
||||||
ROLE_PERMISSIONS = {
|
ROLE_PERMISSIONS = {
|
||||||
"super_admin": ["assistant", "library", "create", "board", "review", "users", "logs", "settings"],
|
"super_admin": ["assistant", "library", "create", "board", "review", "usage", "users", "logs", "settings"],
|
||||||
"dept_manager": ["assistant", "library", "create", "board", "review"],
|
"dept_manager": ["assistant", "library", "create", "board", "review"],
|
||||||
"operator": ["assistant", "library", "create", "board", "review"],
|
"operator": ["assistant", "library", "create", "board", "review"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dingtalk_department_is_super_admin(dept_id: str | None, dept_name: str | None, dept_path: str | None = None) -> bool:
|
||||||
|
configured_ids = settings.dingtalk_super_admin_dept_ids
|
||||||
|
configured_names = settings.dingtalk_super_admin_dept_names
|
||||||
|
if dept_id and dept_id in configured_ids:
|
||||||
|
return True
|
||||||
|
if dept_path and any(f",{candidate}," in dept_path for candidate in configured_ids):
|
||||||
|
return True
|
||||||
|
return bool(dept_name and dept_name in configured_names)
|
||||||
|
|
||||||
|
|
||||||
def public_user(user: AdminUser) -> dict:
|
def public_user(user: AdminUser) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
@ -376,6 +386,23 @@ def startup():
|
|||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)"))
|
connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)"))
|
||||||
seed_users(db)
|
seed_users(db)
|
||||||
|
# Apply configured department-wide administrator access to existing
|
||||||
|
# DingTalk accounts. New accounts are handled during SSO login below.
|
||||||
|
configured_dept_ids = settings.dingtalk_super_admin_dept_ids
|
||||||
|
configured_dept_names = settings.dingtalk_super_admin_dept_names
|
||||||
|
if configured_dept_ids or configured_dept_names:
|
||||||
|
department_users = []
|
||||||
|
if configured_dept_ids:
|
||||||
|
department_users.extend(db.scalars(select(AdminUser).where(AdminUser.dingtalk_dept_id.in_(configured_dept_ids))).all())
|
||||||
|
if configured_dept_names:
|
||||||
|
department_users.extend(db.scalars(select(AdminUser).where(AdminUser.dingtalk_dept_name.in_(configured_dept_names))).all())
|
||||||
|
changed = False
|
||||||
|
for department_user in {user.id: user for user in department_users}.values():
|
||||||
|
if department_user.role_code != "super_admin":
|
||||||
|
department_user.role_code = "super_admin"
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
# Every operator keeps one stable public answer-task URL. Generate
|
# Every operator keeps one stable public answer-task URL. Generate
|
||||||
# tokens after seeding so the default accounts receive one as well.
|
# tokens after seeding so the default accounts receive one as well.
|
||||||
answer_users = db.scalars(
|
answer_users = db.scalars(
|
||||||
@ -731,16 +758,17 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) ->
|
|||||||
username = f"dingtalk_{union_id}"[:64]
|
username = f"dingtalk_{union_id}"[:64]
|
||||||
user = db.scalar(select(AdminUser).where(AdminUser.username == username))
|
user = db.scalar(select(AdminUser).where(AdminUser.username == username))
|
||||||
display_name = str(profile.get("nick") or profile.get("name") or "钉钉用户").strip()[:64]
|
display_name = str(profile.get("nick") or profile.get("name") or "钉钉用户").strip()[:64]
|
||||||
role_code = (
|
|
||||||
"super_admin" if union_id in settings.dingtalk_super_admin_union_ids
|
|
||||||
else "dept_manager" if bool(profile.get("_is_dept_manager"))
|
|
||||||
else "operator"
|
|
||||||
)
|
|
||||||
dingtalk_user_id = str(profile.get("userid") or profile.get("userId") or "").strip() or None
|
dingtalk_user_id = str(profile.get("userid") or profile.get("userId") or "").strip() or None
|
||||||
dept_id = str(profile.get("_primary_dept_id") or "").strip() or None
|
dept_id = str(profile.get("_primary_dept_id") or "").strip() or None
|
||||||
dept_name = str(profile.get("_dept_name") or profile.get("dept_name") or "").strip() or None
|
dept_name = str(profile.get("_dept_name") or profile.get("dept_name") or "").strip() or None
|
||||||
dept_path = str(profile.get("_dept_path") or "").strip() or None
|
dept_path = str(profile.get("_dept_path") or "").strip() or None
|
||||||
is_dept_manager = bool(profile.get("_is_dept_manager"))
|
is_dept_manager = bool(profile.get("_is_dept_manager"))
|
||||||
|
department_super_admin = _dingtalk_department_is_super_admin(dept_id, dept_name, dept_path)
|
||||||
|
role_code = (
|
||||||
|
"super_admin" if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin
|
||||||
|
else "dept_manager" if is_dept_manager
|
||||||
|
else "operator"
|
||||||
|
)
|
||||||
if not user:
|
if not user:
|
||||||
user = AdminUser(username=username, password_hash=hash_password(secrets.token_urlsafe(32)), display_name=display_name, role_code=role_code,
|
user = AdminUser(username=username, password_hash=hash_password(secrets.token_urlsafe(32)), display_name=display_name, role_code=role_code,
|
||||||
dingtalk_user_id=dingtalk_user_id, dingtalk_union_id=union_id,
|
dingtalk_user_id=dingtalk_user_id, dingtalk_union_id=union_id,
|
||||||
@ -762,7 +790,7 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) ->
|
|||||||
user.dingtalk_dept_path = dept_path
|
user.dingtalk_dept_path = dept_path
|
||||||
user.dingtalk_title = str(profile.get("title") or "").strip()[:255] or user.dingtalk_title
|
user.dingtalk_title = str(profile.get("title") or "").strip()[:255] or user.dingtalk_title
|
||||||
user.is_dept_manager = is_dept_manager
|
user.is_dept_manager = is_dept_manager
|
||||||
if union_id in settings.dingtalk_super_admin_union_ids:
|
if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin:
|
||||||
user.role_code = "super_admin"
|
user.role_code = "super_admin"
|
||||||
elif is_dept_manager:
|
elif is_dept_manager:
|
||||||
user.role_code = "dept_manager"
|
user.role_code = "dept_manager"
|
||||||
@ -4303,6 +4331,90 @@ def logout(response: Response):
|
|||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/usage/enter")
|
||||||
|
def record_admin_usage_entry(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||||
|
"""Record a console entry, deduplicated per user for ten minutes."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
recent = db.scalar(
|
||||||
|
select(AdminUsageEntry.id)
|
||||||
|
.where(
|
||||||
|
AdminUsageEntry.user_id == user.id,
|
||||||
|
AdminUsageEntry.entered_at >= now - timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
.order_by(AdminUsageEntry.entered_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if recent:
|
||||||
|
return {"success": True, "data": {"recorded": False}}
|
||||||
|
entry = AdminUsageEntry(
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
display_name=user.display_name,
|
||||||
|
department_name=user.dingtalk_dept_name or "未分配部门",
|
||||||
|
entered_at=now,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "data": {"recorded": True}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/usage/stats")
|
||||||
|
def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
start = (now - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
entries = db.scalars(
|
||||||
|
select(AdminUsageEntry)
|
||||||
|
.where(AdminUsageEntry.entered_at >= start)
|
||||||
|
.order_by(AdminUsageEntry.entered_at.desc())
|
||||||
|
).all()
|
||||||
|
today = now.date()
|
||||||
|
today_entries = [entry for entry in entries if entry.entered_at.date() == today]
|
||||||
|
user_totals = {}
|
||||||
|
trend = {}
|
||||||
|
for offset in range(30):
|
||||||
|
day = (start + timedelta(days=offset)).date().isoformat()
|
||||||
|
trend[day] = {"date": day, "count": 0, "users": 0}
|
||||||
|
for entry in entries:
|
||||||
|
day = entry.entered_at.date().isoformat()
|
||||||
|
if day in trend:
|
||||||
|
trend[day]["count"] += 1
|
||||||
|
key = (entry.user_id, entry.username, entry.display_name, entry.department_name)
|
||||||
|
user_totals[key] = user_totals.get(key, 0) + 1
|
||||||
|
for day in trend:
|
||||||
|
trend[day]["users"] = len({entry.user_id for entry in entries if entry.entered_at.date().isoformat() == day})
|
||||||
|
users = [
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"display_name": display_name,
|
||||||
|
"department_name": department_name,
|
||||||
|
"count": count,
|
||||||
|
}
|
||||||
|
for (user_id, username, display_name, department_name), count in sorted(
|
||||||
|
user_totals.items(), key=lambda item: (-item[1], item[0][2])
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"period_days": 30,
|
||||||
|
"total_count": len(entries),
|
||||||
|
"today_count": len(today_entries),
|
||||||
|
"today_users": len({entry.user_id for entry in today_entries}),
|
||||||
|
"trend": list(trend.values()),
|
||||||
|
"users": users,
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"display_name": entry.display_name,
|
||||||
|
"department_name": entry.department_name,
|
||||||
|
"entered_at": entry.entered_at.isoformat(),
|
||||||
|
}
|
||||||
|
for entry in entries[:100]
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/auth/login-logs")
|
@app.get("/api/admin/auth/login-logs")
|
||||||
def login_logs(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
def login_logs(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||||||
logs = db.scalars(select(AdminLoginLog).order_by(AdminLoginLog.created_at.desc()).limit(100)).all()
|
logs = db.scalars(select(AdminLoginLog).order_by(AdminLoginLog.created_at.desc()).limit(100)).all()
|
||||||
|
|||||||
@ -51,6 +51,19 @@ class AdminLoginLog(Base):
|
|||||||
user: Mapped[AdminUser | None] = relationship(back_populates="login_logs")
|
user: Mapped[AdminUser | None] = relationship(back_populates="login_logs")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUsageEntry(Base):
|
||||||
|
"""One deduplicated entry into the operations console."""
|
||||||
|
|
||||||
|
__tablename__ = "admin_usage_entries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
department_name: Mapped[str] = mapped_column(String(255), default="", index=True)
|
||||||
|
entered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
class QALibraryEntry(Base):
|
class QALibraryEntry(Base):
|
||||||
__tablename__ = "qa_library_entries"
|
__tablename__ = "qa_library_entries"
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,19 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
|
|||||||
CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL
|
CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_usage_entries (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
username VARCHAR(64) NOT NULL,
|
||||||
|
display_name VARCHAR(64) NOT NULL,
|
||||||
|
department_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_admin_usage_user_time (user_id, entered_at),
|
||||||
|
KEY idx_admin_usage_entered_at (entered_at),
|
||||||
|
KEY idx_admin_usage_department (department_name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。
|
-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。
|
||||||
-- 新环境建表时请将 owner_id、shop_name 一并加入 ask_tasks;已有环境由应用启动时自动补列。
|
-- 新环境建表时请将 owner_id、shop_name 一并加入 ask_tasks;已有环境由应用启动时自动补列。
|
||||||
-- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。
|
-- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。
|
||||||
|
|||||||
@ -52,6 +52,22 @@ def test_wrong_password_is_rejected():
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_usage_entry_is_deduplicated_and_visible_in_stats():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
||||||
|
first = client.post("/api/admin/usage/enter")
|
||||||
|
second = client.post("/api/admin/usage/enter")
|
||||||
|
assert first.status_code == 200 and first.json()["data"]["recorded"] is True
|
||||||
|
assert second.status_code == 200 and second.json()["data"]["recorded"] is False
|
||||||
|
|
||||||
|
client.post("/api/admin/auth/logout")
|
||||||
|
assert client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}).status_code == 200
|
||||||
|
stats = client.get("/api/admin/usage/stats")
|
||||||
|
assert stats.status_code == 200
|
||||||
|
operator = next(item for item in stats.json()["data"]["users"] if item["username"] == "operator")
|
||||||
|
assert operator["count"] >= 1
|
||||||
|
|
||||||
|
|
||||||
def test_operator_cannot_access_super_admin_apis():
|
def test_operator_cannot_access_super_admin_apis():
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
|
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
|
||||||
|
|||||||
@ -619,6 +619,126 @@
|
|||||||
.assistant-import-progress p b {
|
.assistant-import-progress p b {
|
||||||
color: #173b66;
|
color: #173b66;
|
||||||
}
|
}
|
||||||
|
.usage-statistics {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.usage-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.usage-summary-grid > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid #d9e5f3;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
.usage-summary-grid span,
|
||||||
|
.usage-section-heading span,
|
||||||
|
.usage-user-row span {
|
||||||
|
color: #7187a3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.usage-summary-grid strong {
|
||||||
|
color: #173b66;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
.usage-chart-section,
|
||||||
|
.usage-users-section {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #d9e5f3;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.usage-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.usage-section-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #173b66;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.usage-chart {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(30, minmax(8px, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 190px;
|
||||||
|
padding: 12px 4px 0;
|
||||||
|
border-bottom: 1px solid #d9e5f3;
|
||||||
|
}
|
||||||
|
.usage-chart-column {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 170px;
|
||||||
|
}
|
||||||
|
.usage-chart-column i {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 4px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
background: #3a7af2;
|
||||||
|
}
|
||||||
|
.usage-chart-column small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8b9db3;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.usage-user-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.usage-user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 11px 0;
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
.usage-user-row > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.usage-user-row strong {
|
||||||
|
color: #173b66;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.usage-user-row b {
|
||||||
|
color: #1764e8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.usage-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.usage-chart-section,
|
||||||
|
.usage-users-section {
|
||||||
|
padding: 13px;
|
||||||
|
}
|
||||||
|
.usage-section-heading {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.usage-chart {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.usage-chart-column small {
|
||||||
|
font-size: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.qa-empty-answer {
|
.qa-empty-answer {
|
||||||
color: #c07a00;
|
color: #c07a00;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Database,
|
Database,
|
||||||
|
BarChart3,
|
||||||
FileText,
|
FileText,
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
@ -336,6 +337,12 @@ const BASE_ITEMS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const ADMIN_ITEMS = [
|
const ADMIN_ITEMS = [
|
||||||
|
{
|
||||||
|
id: "usage",
|
||||||
|
icon: BarChart3,
|
||||||
|
label: "使用统计",
|
||||||
|
group: "系统管理",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
@ -2577,11 +2584,53 @@ function ReviewPanel({ records, onRefresh }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function UsagePanel({ data }) {
|
||||||
|
const maxCount = Math.max(1, ...(data.trend || []).map((item) => item.count || 0));
|
||||||
|
return (
|
||||||
|
<div className="usage-statistics">
|
||||||
|
<div className="usage-summary-grid">
|
||||||
|
<div><span>近30天使用次数</span><strong>{data.total_count || 0}</strong></div>
|
||||||
|
<div><span>今日使用次数</span><strong>{data.today_count || 0}</strong></div>
|
||||||
|
<div><span>今日使用人数</span><strong>{data.today_users || 0}</strong></div>
|
||||||
|
</div>
|
||||||
|
<section className="usage-chart-section">
|
||||||
|
<div className="usage-section-heading"><h3>近30天使用趋势</h3><span>每次进入后台计 1 次(10 分钟内不重复)</span></div>
|
||||||
|
<div className="usage-chart" aria-label="近30天使用趋势图">
|
||||||
|
{(data.trend || []).map((item) => (
|
||||||
|
<div className="usage-chart-column" key={item.date} title={`${item.date}:${item.count} 次,${item.users} 人`}>
|
||||||
|
<i style={{ height: `${Math.max(4, (item.count / maxCount) * 100)}%` }} />
|
||||||
|
<small>{item.date.slice(5).replace("-", "/")}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="usage-users-section">
|
||||||
|
<div className="usage-section-heading"><h3>使用人员</h3><span>按近30天使用次数排序</span></div>
|
||||||
|
{(data.users || []).length ? (
|
||||||
|
<div className="usage-user-list">
|
||||||
|
{data.users.map((item) => (
|
||||||
|
<div className="usage-user-row" key={`${item.user_id}-${item.username}`}>
|
||||||
|
<div><strong>{item.display_name}</strong><span>{item.department_name}</span></div>
|
||||||
|
<b>{item.count} 次</b>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <div className="empty-state">暂无使用记录</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AdminPage({ type }) {
|
function AdminPage({ type }) {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [reload, setReload] = useState(0);
|
const [reload, setReload] = useState(0);
|
||||||
const config = {
|
const config = {
|
||||||
|
usage: {
|
||||||
|
title: "使用统计",
|
||||||
|
description: "查看运营后台的进入次数、使用人员和所属部门。",
|
||||||
|
endpoint: "/api/admin/usage/stats",
|
||||||
|
},
|
||||||
users: {
|
users: {
|
||||||
title: "\u7528\u6237\u7ba1\u7406",
|
title: "\u7528\u6237\u7ba1\u7406",
|
||||||
description:
|
description:
|
||||||
@ -2622,6 +2671,8 @@ function AdminPage({ type }) {
|
|||||||
<div className="form-error">{error}</div>
|
<div className="form-error">{error}</div>
|
||||||
) : data === null ? (
|
) : data === null ? (
|
||||||
<div className="empty-state">{"\u52a0\u8f7d\u4e2d\u2026"}</div>
|
<div className="empty-state">{"\u52a0\u8f7d\u4e2d\u2026"}</div>
|
||||||
|
) : type === "usage" ? (
|
||||||
|
<UsagePanel data={data} />
|
||||||
) : type === "users" ? (
|
) : type === "users" ? (
|
||||||
<UserTable
|
<UserTable
|
||||||
users={data}
|
users={data}
|
||||||
@ -4230,6 +4281,9 @@ function Dashboard({ user, onLogout }) {
|
|||||||
const [createMode, setCreateMode] = useState("ask");
|
const [createMode, setCreateMode] = useState("ask");
|
||||||
const [reviewBadge, setReviewBadge] = useState(0);
|
const [reviewBadge, setReviewBadge] = useState(0);
|
||||||
const [librarySelection, setLibrarySelection] = useState([]);
|
const [librarySelection, setLibrarySelection] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
api("/api/admin/usage/enter", { method: "POST" }).catch(() => {});
|
||||||
|
}, [user.id]);
|
||||||
async function loadReviewBadge() {
|
async function loadReviewBadge() {
|
||||||
try {
|
try {
|
||||||
const result = await api("/api/admin/review-pending-count");
|
const result = await api("/api/admin/review-pending-count");
|
||||||
@ -4286,7 +4340,7 @@ function Dashboard({ user, onLogout }) {
|
|||||||
window.clearInterval(reviewCountTimer);
|
window.clearInterval(reviewCountTimer);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
const isAdminPage = ["users", "logs", "settings"].includes(active);
|
const isAdminPage = ["usage", "users", "logs", "settings"].includes(active);
|
||||||
const selectPage = (next) => {
|
const selectPage = (next) => {
|
||||||
if (user.permissions.includes(next)) {
|
if (user.permissions.includes(next)) {
|
||||||
setActive(next);
|
setActive(next);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user