添加AI指挥部全员为超管,添加使用统计功能

This commit is contained in:
db_huang 2026-10-07 11:22:53 +08:00
parent 0b83663f77
commit 61039682e9
8 changed files with 343 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 拼接用户链接。

View File

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

View File

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

View File

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