diff --git a/rbac/init.py b/rbac/init.py
index eb14b49..7fef733 100644
--- a/rbac/init.py
+++ b/rbac/init.py
@@ -5,6 +5,7 @@ from .orgs import (
get_platform_providers
)
from .userperm import UserPermissions
+from .user_stats import get_user_stats
from rbac.check_perm import (
objcheckperm,
get_org_users,
@@ -76,6 +77,7 @@ def load_rbac():
env.sor_get_org_users = sor_get_org_users
env.get_owner_orgid = get_owner_orgid
env.sor_add_user_roles = sor_add_user_roles
+ env.get_user_stats = get_user_stats
# Cache invalidation methods for use after role/permission changes
env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache
env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches
diff --git a/rbac/user_stats.py b/rbac/user_stats.py
new file mode 100644
index 0000000..b4fdb69
--- /dev/null
+++ b/rbac/user_stats.py
@@ -0,0 +1,59 @@
+from sqlor.dbpools import get_sor_context
+from appPublic.timeUtils import curDateString, timestampstr
+from datetime import datetime, timedelta
+from appPublic.log import debug, exception
+
+async def get_user_stats(request):
+ """Get user statistics for the platform"""
+ env = request._run_ns
+ today = curDateString()
+ tomorrow = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
+ month_start = datetime.now().strftime('%Y-%m-01')
+
+ stats = {
+ 'total_users': 0,
+ 'active_users_today': 0,
+ 'new_users_this_month': 0,
+ 'total_orgs': 0
+ }
+
+ async with get_sor_context(env, 'rbac') as sor:
+ # Total users
+ sql_users = """
+ SELECT COUNT(*) as cnt FROM users
+ """
+ recs = await sor.sqlExe(sql_users, {})
+ if recs:
+ stats['total_users'] = int(recs[0].cnt or 0)
+
+ # Active users today (users with llmusage records today)
+ sql_active = """
+ SELECT COUNT(DISTINCT userid) as cnt FROM llmusage
+ WHERE use_date >= ${today}$
+ AND use_date < ${tomorrow}$
+ """
+ recs = await sor.sqlExe(sql_active, {
+ 'today': today,
+ 'tomorrow': tomorrow
+ })
+ if recs:
+ stats['active_users_today'] = int(recs[0].cnt or 0)
+
+ # New users this month
+ sql_new = """
+ SELECT COUNT(*) as cnt FROM users
+ WHERE created_date >= ${month_start}$
+ """
+ recs = await sor.sqlExe(sql_new, {'month_start': month_start})
+ if recs:
+ stats['new_users_this_month'] = int(recs[0].cnt or 0)
+
+ # Total organizations
+ sql_orgs = """
+ SELECT COUNT(*) as cnt FROM organization
+ """
+ recs = await sor.sqlExe(sql_orgs, {})
+ if recs:
+ stats['total_orgs'] = int(recs[0].cnt or 0)
+
+ return stats
diff --git a/script/register_rbac_tools_perm.py b/script/register_rbac_tools_perm.py
new file mode 100644
index 0000000..e978d50
--- /dev/null
+++ b/script/register_rbac_tools_perm.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+"""
+注册 RBAC 工具的权限到数据库。
+运行在 Sage Python 虚拟环境中。
+
+用法:
+ ./py3/bin/python ../rbac/script/register_rbac_tools_perm.py
+
+或在 Sage 根目录执行:
+ cd ~/repos/sage && ./py3/bin/python ../rbac/script/register_rbac_tools_perm.py
+"""
+import os
+import sys
+import asyncio
+
+# 确保 Sage 路径在 sys.path 中
+sage_root = os.environ.get('SAGE_ROOT')
+if sage_root and sage_root not in sys.path:
+ sys.path.insert(0, sage_root)
+
+from sqlor.dbpools import DBPools
+from appPublic.jsonConfig import getConfig
+from appPublic.uniqueID import getID
+
+# 需要注册的权限列表: (path, role)
+permissions = [
+ ('/rbac/list_path_roles.ui', 'owner.superuser'),
+ ('/rbac/list_path_roles.dspy', 'owner.superuser'),
+ ('/rbac/find_unauth_files.dspy', 'owner.superuser'),
+ ('/rbac/admin_menu.ui', 'owner.superuser'),
+]
+
+
+async def main():
+ config = getConfig('.')
+ db = DBPools(config.databases)
+ registered = 0
+
+ async with db.sqlorContext('sage') as sor:
+ # 查找 superuser 角色 ID
+ role_recs = await sor.sqlExe(
+ "SELECT id FROM role WHERE orgtypeid='owner' AND name='superuser'", {}
+ )
+ if not role_recs:
+ print("错误: 未找到 owner.superuser 角色")
+ sys.exit(1)
+ superuser_id = role_recs[0].id
+ print(f"superuser role_id: {superuser_id}")
+
+ for path, role in permissions:
+ # 检查 permission 是否已存在
+ existing_perm = await sor.sqlExe(
+ "SELECT id FROM permission WHERE path=${path}$", {'path': path}
+ )
+ if existing_perm:
+ perm_id = existing_perm[0].id
+ print(f" permission 已存在: {path} (id={perm_id})")
+ else:
+ perm_id = getID()
+ await sor.C('permission', {'id': perm_id, 'path': path})
+ print(f" + permission: {path}")
+
+ # 检查 rolepermission 是否已存在
+ existing_rp = await sor.sqlExe(
+ "SELECT id FROM rolepermission WHERE roleid=${roleid}$ AND permid=${permid}$",
+ {'roleid': superuser_id, 'permid': perm_id}
+ )
+ if existing_rp:
+ print(f" rolepermission 已存在")
+ else:
+ await sor.C('rolepermission', {
+ 'id': getID(),
+ 'roleid': superuser_id,
+ 'permid': perm_id
+ })
+ registered += 1
+ print(f" + rolepermission: superuser -> {path}")
+
+ print(f"\n共注册 {registered} 条新权限。")
+ if registered > 0:
+ print("请重启 Sage 以刷新权限缓存。")
+ else:
+ print("所有权限已存在,无需操作。")
+
+
+if __name__ == '__main__':
+ asyncio.get_event_loop().run_until_complete(main())
diff --git a/wwwroot/find_unauth_files.dspy b/wwwroot/find_unauth_files.dspy
index 003bca3..6cf69b4 100644
--- a/wwwroot/find_unauth_files.dspy
+++ b/wwwroot/find_unauth_files.dspy
@@ -2,25 +2,36 @@
import os
wwwroot = params_kw.get('wwwroot', '').strip()
-if not wwwroot:
- wwwroot = None
-
-from appPublic.dictObject import DictObject
# 定位 wwwroot
if not wwwroot:
- # 默认: 当前模块 wwwroot 的父目录 wwwroot
- # rbac/wwwroot -> 找 sage/wwwroot
- # 如果 sage_root 环境变量存在
sage_root = os.environ.get('SAGE_ROOT')
if sage_root:
wwwroot = os.path.join(sage_root, 'wwwroot')
else:
- # 尝试从当前文件路径推断: rbac/wwwroot/xxx.dspy -> sage/wwwroot
+ # 从当前文件路径向上找 wwwroot
+ # 生产: ~/token/sage/wwwroot/rbac/find_unauth_files.dspy
+ # 开发: ~/repos/sage/wwwroot/rbac/find_unauth_files.dspy (通过symlink)
+ # 或: ~/repos/rbac/wwwroot/find_unauth_files.dspy (rbac repo自身)
this_file = os.path.abspath(__file__)
- # 通常在 repos/sage/wwwroot/rbac/find_unauth_files.dspy
- # wwwroot 是上一层
- wwwroot = os.path.dirname(os.path.dirname(this_file))
+ # 如果路径中包含 /wwwroot/ 段,直接截断
+ if '/wwwroot/' in this_file:
+ idx = this_file.index('/wwwroot/')
+ wwwroot = this_file[:idx + len('/wwwroot')]
+ else:
+ # 尝试找 wwwroot 子目录
+ for level in range(5):
+ candidate = this_file
+ for _ in range(level):
+ candidate = os.path.dirname(candidate)
+ if not candidate or candidate == '/':
+ break
+ wr = os.path.join(candidate, 'wwwroot')
+ if os.path.isdir(wr):
+ wwwroot = wr
+ break
+ if not wwwroot:
+ return UiError(title='错误', message='无法自动定位 wwwroot 目录,请通过参数传入 wwwroot 路径,或设置 SAGE_ROOT 环境变量。')
wwwroot = os.path.abspath(wwwroot)
if not os.path.isdir(wwwroot):
diff --git a/wwwroot/stat_active_users.ui b/wwwroot/stat_active_users.ui
new file mode 100644
index 0000000..bf92739
--- /dev/null
+++ b/wwwroot/stat_active_users.ui
@@ -0,0 +1,53 @@
+{% set stats = get_user_stats(request) %}
+{
+ "widgettype": "VBox",
+ "options": {
+ "bgcolor": "#1E293B",
+ "padding": "20px",
+ "borderRadius": "12px",
+ "border": "1px solid #334155",
+ "flex": "1",
+ "minHeight": "110px"
+ },
+ "subwidgets": [
+ {
+ "widgettype": "HBox",
+ "options": {
+ "alignItems": "center",
+ "marginBottom": "12px"
+ },
+ "subwidgets": [
+ {
+ "widgettype": "Svg",
+ "options": {
+ "svg": "",
+ "width": "24px",
+ "height": "24px"
+ }
+ },
+ {
+ "widgettype": "Filler"
+ }
+ ]
+ },
+ {
+ "widgettype": "Text",
+ "options": {
+ "text": "{{stats.active_users_today}}",
+ "fontSize": "32px",
+ "fontWeight": "700",
+ "color": "#F1F5F9",
+ "lineHeight": "1.1"
+ }
+ },
+ {
+ "widgettype": "Text",
+ "options": {
+ "text": "今日活跃用户",
+ "fontSize": "14px",
+ "color": "#94A3B8",
+ "marginTop": "4px"
+ }
+ }
+ ]
+}
diff --git a/wwwroot/stat_total_orgs.ui b/wwwroot/stat_total_orgs.ui
new file mode 100644
index 0000000..109d514
--- /dev/null
+++ b/wwwroot/stat_total_orgs.ui
@@ -0,0 +1,53 @@
+{% set stats = get_user_stats(request) %}
+{
+ "widgettype": "VBox",
+ "options": {
+ "bgcolor": "#1E293B",
+ "padding": "20px",
+ "borderRadius": "12px",
+ "border": "1px solid #334155",
+ "flex": "1",
+ "minHeight": "110px"
+ },
+ "subwidgets": [
+ {
+ "widgettype": "HBox",
+ "options": {
+ "alignItems": "center",
+ "marginBottom": "12px"
+ },
+ "subwidgets": [
+ {
+ "widgettype": "Svg",
+ "options": {
+ "svg": "",
+ "width": "24px",
+ "height": "24px"
+ }
+ },
+ {
+ "widgettype": "Filler"
+ }
+ ]
+ },
+ {
+ "widgettype": "Text",
+ "options": {
+ "text": "{{stats.total_orgs}}",
+ "fontSize": "32px",
+ "fontWeight": "700",
+ "color": "#F1F5F9",
+ "lineHeight": "1.1"
+ }
+ },
+ {
+ "widgettype": "Text",
+ "options": {
+ "text": "组织机构数",
+ "fontSize": "14px",
+ "color": "#94A3B8",
+ "marginTop": "4px"
+ }
+ }
+ ]
+}
diff --git a/wwwroot/stat_total_users.ui b/wwwroot/stat_total_users.ui
new file mode 100644
index 0000000..4c139c5
--- /dev/null
+++ b/wwwroot/stat_total_users.ui
@@ -0,0 +1,53 @@
+{% set stats = get_user_stats(request) %}
+{
+ "widgettype": "VBox",
+ "options": {
+ "bgcolor": "#1E293B",
+ "padding": "20px",
+ "borderRadius": "12px",
+ "border": "1px solid #334155",
+ "flex": "1",
+ "minHeight": "110px"
+ },
+ "subwidgets": [
+ {
+ "widgettype": "HBox",
+ "options": {
+ "alignItems": "center",
+ "marginBottom": "12px"
+ },
+ "subwidgets": [
+ {
+ "widgettype": "Svg",
+ "options": {
+ "svg": "",
+ "width": "24px",
+ "height": "24px"
+ }
+ },
+ {
+ "widgettype": "Filler"
+ }
+ ]
+ },
+ {
+ "widgettype": "Text",
+ "options": {
+ "text": "{{stats.total_users}}",
+ "fontSize": "32px",
+ "fontWeight": "700",
+ "color": "#F1F5F9",
+ "lineHeight": "1.1"
+ }
+ },
+ {
+ "widgettype": "Text",
+ "options": {
+ "text": "总用户数",
+ "fontSize": "14px",
+ "color": "#94A3B8",
+ "marginTop": "4px"
+ }
+ }
+ ]
+}