添加AI指挥部全员为超管,添加使用统计功能
This commit is contained in:
parent
0b83663f77
commit
61039682e9
@ -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=
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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 拼接用户链接。
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 (
|
||||
<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 }) {
|
||||
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 }) {
|
||||
<div className="form-error">{error}</div>
|
||||
) : data === null ? (
|
||||
<div className="empty-state">{"\u52a0\u8f7d\u4e2d\u2026"}</div>
|
||||
) : type === "usage" ? (
|
||||
<UsagePanel data={data} />
|
||||
) : type === "users" ? (
|
||||
<UserTable
|
||||
users={data}
|
||||
@ -4230,6 +4281,9 @@ function Dashboard({ user, onLogout }) {
|
||||
const [createMode, setCreateMode] = useState("ask");
|
||||
const [reviewBadge, setReviewBadge] = useState(0);
|
||||
const [librarySelection, setLibrarySelection] = useState([]);
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user