From 61039682e9bf610adb2cc375fe0fecbdcf228aec Mon Sep 17 00:00:00 2001 From: db_huang <18006480240@163.com> Date: Wed, 7 Oct 2026 11:22:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0AI=E6=8C=87=E6=8C=A5=E9=83=A8?= =?UTF-8?q?=E5=85=A8=E5=91=98=E4=B8=BA=E8=B6=85=E7=AE=A1=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BD=BF=E7=94=A8=E7=BB=9F=E8=AE=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + backend/app/config.py | 4 ++ backend/app/main.py | 128 ++++++++++++++++++++++++++++++++++--- backend/app/models.py | 13 ++++ backend/schema.sql | 13 ++++ backend/tests/test_auth.py | 16 +++++ frontend/src/admin.css | 120 ++++++++++++++++++++++++++++++++++ frontend/src/main.jsx | 56 +++++++++++++++- 8 files changed, 343 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index b595856..75a45ac 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,5 @@ FRONTEND_URL=http://localhost:5173 DINGTALK_ALLOWED_UNION_IDS= # 可选:指定钉钉 unionId 自动授予超级管理员 DINGTALK_SUPER_ADMIN_UNION_IDS= +DINGTALK_SUPER_ADMIN_DEPT_IDS= +DINGTALK_SUPER_ADMIN_DEPT_NAMES= diff --git a/backend/app/config.py b/backend/app/config.py index 8e695c9..7e0d4c1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -36,6 +36,10 @@ class Settings: 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_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() diff --git a/backend/app/main.py b/backend/app/main.py index d083b47..9faf3ac 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,7 @@ from sqlalchemy.orm import Session, defer, noload, selectinload from .config import settings 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 .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 .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_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"], "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: return { "id": user.id, @@ -376,6 +386,23 @@ def startup(): with engine.begin() as connection: connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)")) 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 # tokens after seeding so the default accounts receive one as well. answer_users = db.scalars( @@ -731,16 +758,17 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) -> username = f"dingtalk_{union_id}"[:64] user = db.scalar(select(AdminUser).where(AdminUser.username == username)) 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 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_path = str(profile.get("_dept_path") or "").strip() or None 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: 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, @@ -762,7 +790,7 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) -> user.dingtalk_dept_path = dept_path user.dingtalk_title = str(profile.get("title") or "").strip()[:255] or user.dingtalk_title 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" elif is_dept_manager: user.role_code = "dept_manager" @@ -4303,6 +4331,90 @@ def logout(response: Response): 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") 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() diff --git a/backend/app/models.py b/backend/app/models.py index ffe0fb8..758ec9e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -51,6 +51,19 @@ class AdminLoginLog(Base): 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): __tablename__ = "qa_library_entries" diff --git a/backend/schema.sql b/backend/schema.sql index 89f1bf6..07df6e0 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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 ) 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;已有环境由应用启动时自动补列。 -- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 2d518d9..5a00a95 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -52,6 +52,22 @@ def test_wrong_password_is_rejected(): 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(): with TestClient(app) as client: login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}) diff --git a/frontend/src/admin.css b/frontend/src/admin.css index d3403a7..2b17984 100644 --- a/frontend/src/admin.css +++ b/frontend/src/admin.css @@ -619,6 +619,126 @@ .assistant-import-progress p b { 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 { color: #c07a00; font-style: italic; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index edc3171..7c1cc86 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -4,6 +4,7 @@ import { BookOpen, ClipboardList, Database, + BarChart3, FileText, LogOut, MessageCircle, @@ -336,6 +337,12 @@ const BASE_ITEMS = [ }, ]; const ADMIN_ITEMS = [ + { + id: "usage", + icon: BarChart3, + label: "使用统计", + group: "系统管理", + }, { id: "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 ( +
+
+
近30天使用次数{data.total_count || 0}
+
今日使用次数{data.today_count || 0}
+
今日使用人数{data.today_users || 0}
+
+
+

近30天使用趋势

每次进入后台计 1 次(10 分钟内不重复)
+
+ {(data.trend || []).map((item) => ( +
+ + {item.date.slice(5).replace("-", "/")} +
+ ))} +
+
+
+

使用人员

按近30天使用次数排序
+ {(data.users || []).length ? ( +
+ {data.users.map((item) => ( +
+
{item.display_name}{item.department_name}
+ {item.count} 次 +
+ ))} +
+ ) :
暂无使用记录
} +
+
+ ); +} + function AdminPage({ type }) { const [data, setData] = useState(null); const [error, setError] = useState(""); const [reload, setReload] = useState(0); const config = { + usage: { + title: "使用统计", + description: "查看运营后台的进入次数、使用人员和所属部门。", + endpoint: "/api/admin/usage/stats", + }, users: { title: "\u7528\u6237\u7ba1\u7406", description: @@ -2622,6 +2671,8 @@ function AdminPage({ type }) {
{error}
) : data === null ? (
{"\u52a0\u8f7d\u4e2d\u2026"}
+ ) : type === "usage" ? ( + ) : type === "users" ? ( { + api("/api/admin/usage/enter", { method: "POST" }).catch(() => {}); + }, [user.id]); async function loadReviewBadge() { try { const result = await api("/api/admin/review-pending-count"); @@ -4286,7 +4340,7 @@ function Dashboard({ user, onLogout }) { window.clearInterval(reviewCountTimer); }; }, []); - const isAdminPage = ["users", "logs", "settings"].includes(active); + const isAdminPage = ["usage", "users", "logs", "settings"].includes(active); const selectPage = (next) => { if (user.permissions.includes(next)) { setActive(next);