commit ae06dda9da09ec28e7380188a0fa6fbaab93f875 Author: Hermes Agent Date: Mon Jun 15 11:06:10 2026 +0800 feat: portal webapp - CMS独立Web应用壳 - app/portal.py: 主入口,通过from cms.init import load_cms加载业务模块 - conf/config.json: 应用配置(ocai_cms数据库, 端口9090, cms模块wwwroot挂载到/cms) - wwwroot/: 公开页面(index/news/cases/products)和公开API - build.sh: 构建脚本(安装基础设施包+pip install cms模块+DDL/CRUD生成) - deploy.sh: 一键部署脚本(构建→建表→初始数据→权限→启动) - init_data.py: 从cms模块init/data.yaml加载初始数据 - init_any/superuser_permissions.py: RBAC权限初始化 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d64f25 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Portal — 企业官网CMS独立Web应用 + +基于Sage/bricks-framework的独立Web应用,通过pip install加载cms业务模块。 + +## 架构 + +``` +Portal (Web应用壳) CMS (业务模块) +┌─────────────────────┐ ┌──────────────────────┐ +│ app/portal.py │────>│ cms/init.py │ +│ load_cms() │ │ load_cms() │ +│ │ │ - CMS CRUD │ +│ wwwroot/ │ │ - DD审批 │ +│ index.ui (官网) │ │ │ +│ news.ui │ │ wwwroot/ │ +│ api/ (公开API) │ │ admin.ui │ +│ │ │ api/ (管理API) │ +│ conf/config.json │ │ │ +│ build.sh │ │ models/ json/ init/ │ +│ deploy.sh │ │ data.yaml │ +└─────────────────────┘ └──────────────────────┘ +``` + +## 快速开始 + +```bash +# 一键部署 +cd ~/repos/portal && ./deploy.sh + +# 或分步执行: +./build.sh # 构建 +mysql -h db -u test -p ocai_cms < cms.ddl.sql # 建表 +py3/bin/python init_data.py # 初始数据 +py3/bin/python init_superuser_permissions.py # 权限 +py3/bin/python init_any_permissions.py +py3/bin/python ~/repos/cms/scripts/load_path.py +./start.sh # 启动 +``` + +## 目录结构 + +``` +portal/ +├── app/ +│ ├── portal.py # 主入口 (from cms.init import load_cms) +│ └── global_func.py # 全局函数 +├── conf/config.json # 应用配置 (数据库ocai_cms, 端口9090) +├── wwwroot/ # 公开页面 +│ ├── index.ui # 官网首页 +│ ├── products.ui # 产品架构 +│ ├── news.ui / news_detail.ui +│ ├── cases.ui # 成功案例 +│ ├── admin.ui # 管理后台入口 +│ └── api/ # 公开只读API +│ ├── get_published_content.dspy +│ ├── get_content_detail.dspy +│ ├── get_config.dspy +│ ├── get_sections.dspy +│ └── submit_lead.dspy +├── build.sh # 构建脚本 +├── deploy.sh # 一键部署 +├── init_data.py # 加载初始数据 +├── init_any_permissions.py # 匿名权限 +└── init_superuser_permissions.py +``` + +## CMS模块 + +CMS业务模块位于 `~/repos/cms/`,通过 `pip install -e` 安装。 +详见 [CMS模块README](../cms/README.md)。 + +## 访问地址 + +| 页面 | URL | +|------|-----| +| 官网首页 | http://localhost:9090/ | +| 产品架构 | /products.ui | +| 新闻动态 | /news.ui | +| 成功案例 | /cases.ui | +| 管理后台 | /cms/admin.ui | diff --git a/app/global_func.py b/app/global_func.py new file mode 100644 index 0000000..26c6a1b --- /dev/null +++ b/app/global_func.py @@ -0,0 +1,56 @@ +""" +Portal全局函数 — 注册到ServerEnv供.dspy和.ui调用 +""" +from ahserver.serverenv import ServerEnv + +def get_module_dbname(mname): + """Portal应用统一使用ocai_cms数据库""" + return 'ocai_cms' + +def UiWindow(title, icon, content, cheight=10, cwidth=15): + return { + "widgettype": "PopupWindow", + "options": { + "author": "portal", + "cwidth": cwidth, + "cheight": cheight, + "title": title, + "content": content, + "icon": icon or entire_url('/bricks/imgs/app.png'), + "movable": True, + "auto_open": True + } + } + +def UiError(title="出错", message="出错啦", timeout=5): + return { + "widgettype": "Error", + "options": { + "author": "portal", + "timeout": timeout, + "cwidth": 15, + "cheight": 10, + "title": title, + "message": message + } + } + +def UiMessage(title="消息", message="后台消息", timeout=5): + return { + "widgettype": "Message", + "options": { + "author": "portal", + "timeout": timeout, + "cwidth": 15, + "cheight": 10, + "title": title, + "message": message + } + } + +def set_globalvariable(): + g = ServerEnv() + g.get_module_dbname = get_module_dbname + g.UiError = UiError + g.UiMessage = UiMessage + g.UiWindow = UiWindow diff --git a/app/portal.py b/app/portal.py new file mode 100644 index 0000000..ca12179 --- /dev/null +++ b/app/portal.py @@ -0,0 +1,53 @@ +""" +Portal Web应用主入口 — CMS业务壳 +启动: py3/bin/python app/portal.py -p 9090 -w $(pwd) + +Portal是一个轻量级Web应用壳,通过pip install加载cms业务模块。 +类似pipeline-app模式:app壳负责基础设施初始化,业务逻辑在模块中。 +""" +import os, sys + +# 添加应用根目录到Python路径 +app_dir = os.path.dirname(os.path.abspath(__file__)) +root_dir = os.path.dirname(app_dir) +sys.path.insert(0, root_dir) + +# Ensure app/ is in path for local imports +sys.path.insert(0, app_dir) + +from appPublic.log import MyLogger, info +from appPublic.folderUtils import ProgramPath +from appPublic.jsonConfig import getConfig +from appPublic.registerfunction import RegisterFunction +from bricks_for_python.init import load_pybricks +from ahserver.webapp import webapp +from ahserver.serverenv import ServerEnv +from sqlor.dbpools import DBPools + +# CMS业务模块 (通过pip install -e ~/repos/cms安装) +from cms.init import load_cms + +# RBAC认证(复用sage的rbac模块) +from rbac.init import load_rbac +from appbase.init import load_appbase + +# 全局函数 +from global_func import set_globalvariable + +__version__ = '1.0.0' + +def get_module_dbname(m): + return 'ocai_cms' + +def init(): + rf = RegisterFunction() + set_globalvariable() + env = ServerEnv() + env.get_module_dbname = get_module_dbname + load_pybricks() + load_appbase() + load_rbac() + load_cms() + +if __name__ == '__main__': + webapp(init) diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..46dfb0b --- /dev/null +++ b/build.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# Portal Web应用 — 构建脚本 +# Portal是CMS业务的独立Web应用壳,通过pip install加载cms模块 +# 用法: cd ~/repos/portal && ./build.sh +set -e + +cdir=$(pwd) +uname=$(id -un) +gname=$(id -gn) + +echo "============================================" +echo " Portal Web应用 — 构建" +echo "============================================" + +# =========================================== +# Step 1: Python虚拟环境 +# =========================================== +echo "" +echo "--- Step 1: 创建Python虚拟环境 ---" +if [ ! -d "py3" ]; then + python3 -m venv py3 +fi +source py3/bin/activate + +# =========================================== +# Step 2: 核心基础设施包 +# =========================================== +echo "" +echo "--- Step 2: 安装核心基础设施包 ---" +mkdir -p pkgs + +for m in apppublic sqlor ahserver bricks-for-python xls2ddl +do + echo " install $m..." + cd $cdir/pkgs + if [ ! -d "$m" ]; then + git clone https://git.opencomputing.cn/yumoqing/$m + fi + cd $m + $cdir/py3/bin/pip install . 2>/dev/null || echo " WARN: $m install failed" +done + +# bricks前端 +echo " install bricks..." +cd $cdir/pkgs +if [ ! -d "bricks" ]; then + git clone https://git.opencomputing.cn/yumoqing/bricks +fi +cd bricks/bricks +./build.sh 2>/dev/null || echo " WARN: bricks build skipped" + +# bricks符号链接 +mkdir -p $cdir/bricks +if [ -d "$cdir/pkgs/bricks/dist" ]; then + rm -f $cdir/bricks + ln -sf $cdir/pkgs/bricks/dist $cdir/bricks +fi + +# =========================================== +# Step 3: RBAC + AppBase + checklang +# =========================================== +echo "" +echo "--- Step 3: 安装RBAC/AppBase模块 ---" +for m in appbase rbac checklang +do + echo " install $m..." + cd $cdir/pkgs + if [ ! -d "$m" ]; then + git clone https://git.opencomputing.cn/yumoqing/$m + fi + cd $m + $cdir/py3/bin/pip install . 2>/dev/null || echo " WARN: $m install failed" +done + +# =========================================== +# Step 4: CMS业务模块 (editable mode) +# =========================================== +echo "" +echo "--- Step 4: 安装CMS业务模块 ---" + +CMS_DIR=~/repos/cms +echo " install cms (editable)..." +$cdir/py3/bin/pip install -e $CMS_DIR 2>/dev/null || echo " WARN: cms install failed" + +# =========================================== +# Step 5: 数据库DDL (从cms模块的models目录) +# =========================================== +echo "" +echo "--- Step 5: 生成数据库DDL ---" + +if [ -d "$CMS_DIR/models" ]; then + cd $CMS_DIR/models + echo " 生成 CMS DDL..." + $cdir/py3/bin/json2ddl mysql . > $cdir/cms.ddl.sql 2>/dev/null || echo " WARN: json2ddl failed" + echo " DDL已生成: cms.ddl.sql" +fi + +# =========================================== +# Step 6: CRUD UI生成 (从cms模块的json目录) +# =========================================== +echo "" +echo "--- Step 6: 生成CRUD UI ---" + +if [ -d "$CMS_DIR/json" ]; then + cd $CMS_DIR/json + echo " 生成 CMS CRUD UI..." + for f in *.json; do + [ -f "$f" ] || continue + echo " $f" + $cdir/py3/bin/xls2ui -m ../models -o $CMS_DIR/wwwroot cms $f 2>/dev/null || echo " WARN: xls2ui failed for $f" + done +fi + +# =========================================== +# Step 7: 日志和文件目录 +# =========================================== +echo "" +echo "--- Step 7: 创建运行时目录 ---" +mkdir -p $cdir/logs +mkdir -p $cdir/files + +# =========================================== +# Step 8: systemd服务文件 +# =========================================== +echo "" +echo "--- Step 8: 生成systemd服务文件 ---" +cat > $cdir/portal.service </dev/null || echo "db") + DB_USER=$(python3 -c "import json; c=json.load(open('$cdir/conf/config.json')); print(c['databases']['ocai_cms']['kwargs']['user'])" 2>/dev/null || echo "test") + DB_PASS=$(python3 -c " +import json, sys +sys.path.insert(0, '$cdir') +from appPublic.password import decode +c=json.load(open('$cdir/conf/config.json')) +print(decode(c['databases']['ocai_cms']['kwargs']['password'], c['password_key'])) +" 2>/dev/null || echo "") + DB_NAME=$(python3 -c "import json; c=json.load(open('$cdir/conf/config.json')); print(c['databases']['ocai_cms']['kwargs']['db'])" 2>/dev/null || echo "ocai_cms") + + if [ -n "$DB_PASS" ]; then + echo " 执行DDL: $DB_HOST/$DB_NAME ..." + mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$cdir/cms.ddl.sql" 2>/dev/null && \ + echo " ✓ DDL执行成功" || \ + echo " ⚠ DDL执行失败(表可能已存在),继续..." + else + echo " ⚠ 无法读取数据库密码,请手动执行:" + echo " mysql -h $DB_HOST -u $DB_USER -p $DB_NAME < cms.ddl.sql" + fi +else + echo " ⚠ cms.ddl.sql 不存在,跳过(请先运行 build.sh)" +fi +echo "" + +# Step 3: Init data +echo "=== Step 3/5: 加载初始数据 ===" +if [ -f "$cdir/py3/bin/python" ]; then + "$cdir/py3/bin/python" "$cdir/init_data.py" 2>&1 || echo " ⚠ 初始数据加载失败,继续..." +else + echo " ⚠ py3未构建,跳过" +fi +echo "" + +# Step 4: Init permissions +echo "=== Step 4/5: 初始化权限 ===" +if [ -f "$cdir/py3/bin/python" ]; then + "$cdir/py3/bin/python" "$cdir/init_superuser_permissions.py" 2>&1 || echo " ⚠ superuser权限失败" + "$cdir/py3/bin/python" "$cdir/init_any_permissions.py" 2>&1 || echo " ⚠ any权限失败" + "$cdir/py3/bin/python" ~/repos/cms/scripts/load_path.py 2>&1 || echo " ⚠ CMS模块权限失败" + echo " ✓ 权限初始化完成" +else + echo " ⚠ py3未构建,跳过" +fi +echo "" + +# Step 5: Start +echo "=== Step 5/5: 启动应用 ===" +if [ -f "$cdir/start.sh" ]; then + # 先停掉旧进程 + bash "$cdir/stop.sh" 2>/dev/null || true + sleep 1 + bash "$cdir/start.sh" + echo " ✓ 应用已启动" +else + echo " ⚠ start.sh 不存在" +fi +echo "" + +echo "╔══════════════════════════════════════════╗" +echo "║ 部署完成! ║" +echo "╠══════════════════════════════════════════╣" +echo "║ 官网: http://localhost:9090/ ║" +echo "║ 管理: http://localhost:9090/cms/admin.ui ║" +echo "╚══════════════════════════════════════════╝" diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..04fb785 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,139 @@ +# Portal系统架构 + +## 项目概述 + +Portal是企业官网CMS系统的独立Web应用壳,采用与pipeline-app相同的"壳+模块"架构模式。 +Portal负责Web服务器启动、基础设施加载和公开前端页面;CMS业务逻辑通过pip install以模块方式引入。 + +## 架构模式: 壳+模块 + +``` +┌─────────────────────────────────────────────────┐ +│ Portal (Web壳) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ ahserver │ │ rbac │ │ appbase │ │ +│ │ (Web服务) │ │ (认证) │ │ (基础) │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ wwwroot/ (公开前端) │ │ +│ │ index.ui products.ui news.ui ... │ │ +│ │ api/get_published_content.dspy ... │ │ +│ │ dingdingflow/index.ui ... │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ CMS模块 (pip install -e ~/repos/cms) │ │ +│ │ │ │ +│ │ ┌─────────┐ ┌──────────────┐ │ │ +│ │ │ entcms │ │ dingdingflow │ │ │ +│ │ │ 内容管理 │ │ 钉钉审批 │ │ │ +│ │ │ CRUD页面│ │ CRUD页面 │ │ │ +│ │ │ 数据模型 │ │ 数据模型 │ │ │ +│ │ └─────────┘ └──────────────┘ │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## 初始化流程 + +Portal启动时的加载顺序: + +``` +1. set_globalvariable() — 注册全局函数 (get_module_dbname, UiError等) +2. load_pybricks() — 加载bricks前端框架 +3. load_appbase() — 加载应用基础模块 (公共函数注册) +4. load_rbac() — 加载RBAC认证模块 (角色权限控制) +5. load_cms() — 加载CMS业务模块 (entcms + dingdingflow) +``` + +所有模块注册到 `ServerEnv`,供.dspy和.ui文件在运行时调用。 + +## 数据库 + +统一使用 `ocai_cms` 数据库: + +| 模块 | 表 | +|------|-----| +| entcms | cms_content, cms_categories, cms_sections, cms_leads, cms_site_config | +| dingdingflow | dd_approvals, dd_approval_configs | + +`get_module_dbname()` 对所有模块返回 `'ocai_cms'`。 + +## 前端页面分层 + +### 公开页面 (Portal wwwroot/) +| 页面 | 权限 | 说明 | +|------|------|------| +| index.ui | any | 官网首页 (导航/Hero/产品/案例/新闻/页脚/浮动入口) | +| products.ui | any | 产品架构列表 | +| news.ui | any | 新闻列表 | +| news_detail.ui | any | 新闻详情 | +| cases.ui | any | 案例列表 | +| admin.ui | logined | 管理后台仪表盘 (入口) | +| menu.ui | logined | 管理菜单 | + +### 公开API (Portal wwwroot/api/) +| API | 权限 | 说明 | +|-----|------|------| +| get_published_content.dspy | any | 获取已发布内容 | +| get_content_detail.dspy | any | 获取内容详情 | +| get_config.dspy | any | 获取站点配置 | +| get_sections.dspy | any | 获取栏目列表 | +| submit_lead.dspy | any | 提交商机线索 | + +### 管理CRUD页面 (CMS模块wwwroot/) +由cms模块提供,不在portal/wwwroot中: +- cms_content_list (内容管理) +- cms_categories_list (分类管理) +- cms_sections_list (栏目管理) +- cms_leads_list (线索管理) +- cms_site_config_list (站点配置) + +### 钉钉审批 (Portal wwwroot/dingdingflow/) +| 页面 | 权限 | 说明 | +|------|------|------| +| dingdingflow/index.ui | any | 审批列表入口 | +| dingdingflow/menu.ui | any | 审批菜单 | +| dingdingflow/api/*.dspy | any | 审批API | + +## 技术栈 + +| 层 | 技术 | +|----|------| +| Web服务器 | ahserver (异步HTTP) | +| 前端框架 | bricks-framework (JSON UI DSL) | +| 数据库 | MySQL (async, sqlor连接池) | +| 认证 | RBAC (角色权限控制) | +| 基础设施 | appbase, apppublic, checklang | +| 业务模块 | cms (entcms + dingdingflow) | + +## 部署架构 + +``` +Nginx/LB (80/443) + │ + ▼ +Portal (ahserver :9090) + │ + ├── / wwwroot/ (公开页面 + API) + ├── /bricks/ → pkgs/bricks/dist (前端框架) + └── CMS模块 (pip installed) + └── CRUD页面 + 业务逻辑 + │ + ▼ +MySQL (ocai_cms) +Redis (session) +``` + +## 与pipeline-app对比 + +| 特征 | pipeline-app | portal | +|------|-------------|--------| +| 业务模块数 | 3 (core/ops/dist) | 1 (cms) | +| 模块安装方式 | 本地子目录 | editable pip install | +| 公开页面 | index.ui | index.ui + products + news + cases | +| 公开API | 无 | 5个只读接口 | +| 数据库 | pipeline_* | ocai_cms | +| 端口 | 8080 | 9090 | diff --git a/init_any_permissions.py b/init_any_permissions.py new file mode 100644 index 0000000..0493047 --- /dev/null +++ b/init_any_permissions.py @@ -0,0 +1,92 @@ +""" +Portal RBAC权限初始化 — any (匿名) 角色 +扫描 wwwroot 和 bricks 下的公开页面,授予 any 角色权限 + +规则: +- wwwroot/* → / (公开页面和API) +- bricks/* → /bricks/ +- /cms/* 由 cms/scripts/load_path.py 管理(需要登录) + +用法: cd ~/repos/portal && py3/bin/python init_any_permissions.py +""" +import os, sys, subprocess + +def find_app_root(): + return os.path.dirname(os.path.abspath(__file__)) + +app_root = find_app_root() +sage_root = None +for c in [os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage")]: + if os.path.isdir(os.path.join(c, "py3", "bin")): + sage_root = c + break +if not sage_root: + print("ERROR: 找不到Sage,无法初始化权限") + sys.exit(1) + +py = os.path.join(sage_root, "py3", "bin", "python") +sp = os.path.join(sage_root, "set_role_perm.py") +if not os.path.exists(sp): + print("ERROR: 找不到set_role_perm.py") + sys.exit(1) + +SKIP_DIRS = {".git", "__pycache__", "node_modules", ".svn"} +SKIP_EXTS = {".pyc", ".pyo", ".swp", ".swo", ".bak", ".orig", ".log", ".pid", ".lock"} + +def scan_dir(base_dir, url_prefix): + paths = [] + if not os.path.isdir(base_dir): + return paths + for root, dirs, files in os.walk(base_dir): + dirs[:] = [d for d in dirs if d not in SKIP_DIRS] + for f in sorted(files): + _, ext = os.path.splitext(f) + if ext.lower() in SKIP_EXTS or f.startswith("."): + continue + full_path = os.path.join(root, f) + if os.path.islink(full_path): + link_target = os.path.realpath(full_path) + if not link_target.startswith(app_root): + continue + rel_path = os.path.relpath(full_path, base_dir) + url = url_prefix + "/" + rel_path.replace(os.sep, "/") + paths.append(url) + return paths + +def set_any_perms(paths): + count = 0 + env = os.environ.copy() + env['SAGE_RBAC_DB'] = 'ocai_cms' + for p in paths: + result = subprocess.run( + [py, sp, "any", p], + cwd=sage_root, capture_output=True, text=True, env=env + ) + status = "✓" if result.returncode == 0 else "✗" + print(f" {status} any {p}") + count += 1 + return count + +print("=== Portal RBAC权限初始化 — any (匿名访问) ===") +print(f"Sage: {sage_root}") +print() + +# 1. wwwroot/ 公开页面和API +wwwroot_dir = os.path.join(app_root, "wwwroot") +root_paths = scan_dir(wwwroot_dir, "") +root_paths.append("/") # 根路径 +print(f"--- wwwroot/ → / ({len(root_paths)} 个路径) ---") +n1 = set_any_perms(root_paths) + +# 2. bricks/ +bricks_dir = os.path.join(app_root, "bricks") +bricks_paths = scan_dir(bricks_dir, "/bricks") +if bricks_paths: + print(f"\n--- bricks → /bricks ({len(bricks_paths)} 个文件) ---") + n2 = set_any_perms(bricks_paths) +else: + n2 = 0 + print(f"\n--- bricks → /bricks (未构建,跳过) ---") + +total = n1 + n2 +print(f"\n=== 完成: 共设置 {total} 个any权限 ===") diff --git a/init_data.py b/init_data.py new file mode 100644 index 0000000..3970ef1 --- /dev/null +++ b/init_data.py @@ -0,0 +1,179 @@ +""" +Portal 一键部署 — 初始化数据 +从 cms 模块的 init/data.yaml 加载所有初始数据: + - appcodes (枚举编码) + - appcodes_kv (枚举值) + - cms_categories (默认分类) + - cms_site_config (默认站点配置) + - cms_sections (默认栏目配置) + - dd_approval_configs (默认审批配置) + +用法: cd ~/repos/portal && py3/bin/python init_data.py +""" +import os, sys, json + +app_dir = os.path.dirname(os.path.abspath(__file__)) +root_dir = app_dir +sys.path.insert(0, root_dir) + +# 读取YAML +try: + import yaml +except ImportError: + # 简单YAML解析(fallback) + yaml = None + +CMS_DIR = os.path.expanduser("~/repos/cms") +DATA_FILE = os.path.join(CMS_DIR, "init", "data.yaml") + +def load_yaml_simple(path): + """简单YAML加载(仅支持本文件用到的结构)""" + if yaml: + with open(path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + else: + # 用json做fallback - 要求data文件也有json版本 + json_path = path.replace('.yaml', '.json') + if os.path.exists(json_path): + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + raise ImportError("需要PyYAML: pip install pyyaml") + + +def main(): + print("=== Portal 一键部署 — 初始化数据 ===") + print(f"CMS模块: {CMS_DIR}") + print(f"数据文件: {DATA_FILE}") + print() + + if not os.path.exists(DATA_FILE): + print(f"ERROR: 数据文件不存在: {DATA_FILE}") + sys.exit(1) + + # 加载YAML + data = load_yaml_simple(DATA_FILE) + + # 连接数据库 + from sqlor.dbpools import DBPools + from appPublic.uniqueID import getID + from ahserver.serverenv import ServerEnv + from appPublic.jsonConfig import getConfig + from appPublic.log import MyLogger + + # 初始化日志和配置 + MyLogger(getConfig()) + db = DBPools() + dbname = 'ocai_cms' + + import asyncio + + async def insert_data(): + async with db.sqlorContext(dbname) as sor: + + # 1. appcodes + appcodes = data.get('appcodes', []) + if appcodes: + print(f"\n--- appcodes ({len(appcodes)} 条) ---") + for item in appcodes: + item.setdefault('id', getID()) + item.setdefault('org_id', '0') + try: + # 检查是否已存在 + existing = await sor.R('appcodes', {'id': item['id']}) + if not existing: + await sor.C('appcodes', item) + print(f" ✓ {item['id']} - {item.get('name', '')}") + else: + print(f" · {item['id']} (已存在)") + except Exception as e: + print(f" ✗ {item.get('id', '?')}: {e}") + + # 2. appcodes_kv + appcodes_kv = data.get('appcodes_kv', []) + if appcodes_kv: + print(f"\n--- appcodes_kv ({len(appcodes_kv)} 条) ---") + for item in appcodes_kv: + item.setdefault('id', getID()) + item.setdefault('org_id', '0') + try: + existing = await sor.R('appcodes_kv', {'id': item['id']}) + if not existing: + await sor.C('appcodes_kv', item) + print(f" ✓ {item['id']} ({item.get('parentid', '')}.{item.get('k', '')} = {item.get('v', '')})") + else: + print(f" · {item['id']} (已存在)") + except Exception as e: + print(f" ✗ {item.get('id', '?')}: {e}") + + # 3. cms_categories + categories = data.get('cms_categories', []) + if categories: + print(f"\n--- cms_categories ({len(categories)} 条) ---") + for item in categories: + item.setdefault('id', getID()) + try: + existing = await sor.R('cms_categories', {'id': item['id']}) + if not existing: + await sor.C('cms_categories', item) + print(f" ✓ {item['id']} - {item.get('name', '')} [{item.get('content_type', '')}]") + else: + print(f" · {item['id']} (已存在)") + except Exception as e: + print(f" ✗ {item.get('id', '?')}: {e}") + + # 4. cms_site_config + configs = data.get('cms_site_config', []) + if configs: + print(f"\n--- cms_site_config ({len(configs)} 条) ---") + for item in configs: + item.setdefault('id', getID()) + try: + existing = await sor.R('cms_site_config', {'id': item['id']}) + if not existing: + await sor.C('cms_site_config', item) + print(f" ✓ {item['id']} ({item.get('config_group', '')}.{item.get('config_key', '')})") + else: + print(f" · {item['id']} (已存在)") + except Exception as e: + print(f" ✗ {item.get('id', '?')}: {e}") + + # 5. cms_sections + sections = data.get('cms_sections', []) + if sections: + print(f"\n--- cms_sections ({len(sections)} 条) ---") + for item in sections: + item.setdefault('id', getID()) + try: + existing = await sor.R('cms_sections', {'id': item['id']}) + if not existing: + await sor.C('cms_sections', item) + print(f" ✓ {item['id']} - {item.get('title', '')} [{item.get('section_type', '')}]") + else: + print(f" · {item['id']} (已存在)") + except Exception as e: + print(f" ✗ {item.get('id', '?')}: {e}") + + # 6. dd_approval_configs + dd_configs = data.get('dd_approval_configs', []) + if dd_configs: + print(f"\n--- dd_approval_configs ({len(dd_configs)} 条) ---") + for item in dd_configs: + item.setdefault('id', getID()) + try: + existing = await sor.R('dd_approval_configs', {'id': item['id']}) + if not existing: + await sor.C('dd_approval_configs', item) + print(f" ✓ {item['id']} - {item.get('biz_type', '')}") + else: + print(f" · {item['id']} (已存在)") + except Exception as e: + print(f" ✗ {item.get('id', '?')}: {e}") + + total = len(appcodes) + len(appcodes_kv) + len(categories) + len(configs) + len(sections) + len(dd_configs) + print(f"\n=== 完成: 处理 {total} 条初始数据 ===") + + asyncio.run(insert_data()) + + +if __name__ == '__main__': + main() diff --git a/init_superuser_permissions.py b/init_superuser_permissions.py new file mode 100644 index 0000000..29c9f33 --- /dev/null +++ b/init_superuser_permissions.py @@ -0,0 +1,121 @@ +""" +Portal RBAC权限初始化 — superuser角色 +为owner.superuser授予Portal所有权限 + +Portal包含: +- 公开页面 (wwwroot下的.ui和静态文件) +- CMS管理CRUD页面 (cms模块wwwroot,路由到/cms/) +- appbase系统基础模块 + +用法: cd ~/repos/portal && py3/bin/python init_superuser_permissions.py +""" +import os, sys, subprocess + +def find_app_root(): + return os.path.dirname(os.path.abspath(__file__)) + +app_root = find_app_root() +sage_root = None +for c in [os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage")]: + if os.path.isdir(os.path.join(c, "py3", "bin")): + sage_root = c + break +if not sage_root: + sage_root = app_root + +py = os.path.join(sage_root, "py3", "bin", "python") +sp = os.path.join(sage_root, "set_role_perm.py") if os.path.exists(os.path.join(sage_root, "set_role_perm.py")) else None + +if not sp: + print("ERROR: 找不到set_role_perm.py") + sys.exit(1) + +def run(role, paths): + env = os.environ.copy() + env['SAGE_RBAC_DB'] = 'ocai_cms' + for p in paths: + print(f" {role:30s} {p}") + subprocess.run([py, sp, role, p], cwd=sage_root, capture_output=True, env=env) + +# ─── superuser — 所有权限 ─── +superuser_paths = [ + # 公开页面 + "/index.ui", "/news.ui", "/news_detail.ui", + "/cases.ui", "/products.ui", + "/cms_styles.css", "/cms_scripts.js", + "/menu.ui", "/admin.ui", + + # 公开API + "/api/get_published_content.dspy", + "/api/get_content_detail.dspy", + "/api/get_config.dspy", + "/api/get_sections.dspy", + "/api/submit_lead.dspy", + + # CMS管理 — 由cms模块提供,路由到 /cms/ + "/cms", + "/cms/admin.ui", "/cms/menu.ui", + + # CMS Content CRUD + "/cms/cms_content_list", "/cms/cms_content_list/%", + "/cms/api/cms_content_create.dspy", + "/cms/api/cms_content_update.dspy", + "/cms/api/cms_content_delete.dspy", + "/cms/api/cms_content_list.dspy", + "/cms/api/submit_content_approval.dspy", + + # CMS Categories + "/cms/cms_categories_list", "/cms/cms_categories_list/%", + "/cms/api/cms_categories_create.dspy", + "/cms/api/cms_categories_update.dspy", + "/cms/api/cms_categories_delete.dspy", + "/cms/api/cms_categories_list.dspy", + "/cms/api/category_options.dspy", + + # CMS Sections + "/cms/cms_sections_list", "/cms/cms_sections_list/%", + "/cms/api/cms_sections_create.dspy", + "/cms/api/cms_sections_update.dspy", + "/cms/api/cms_sections_delete.dspy", + "/cms/api/cms_sections_list.dspy", + + # CMS Site Config + "/cms/cms_site_config_list", "/cms/cms_site_config_list/%", + "/cms/api/cms_site_config_create.dspy", + "/cms/api/cms_site_config_update.dspy", + "/cms/api/cms_site_config_delete.dspy", + "/cms/api/cms_site_config_list.dspy", + + # CMS Leads + "/cms/cms_leads_list", "/cms/cms_leads_list/%", + "/cms/api/cms_leads_create.dspy", + "/cms/api/cms_leads_update.dspy", + "/cms/api/cms_leads_delete.dspy", + "/cms/api/cms_leads_list.dspy", + + # DingTalk Approvals (cms模块内) + "/cms/api/submit_approval.dspy", + "/cms/api/dingtalk_callback.dspy", + "/cms/dd_approvals", "/cms/dd_approvals/%", + "/cms/api/dd_approvals_create.dspy", + "/cms/api/dd_approvals_update.dspy", + "/cms/api/dd_approvals_delete.dspy", + "/cms/api/dd_approvals_list.dspy", + "/cms/dd_approval_configs", "/cms/dd_approval_configs/%", + "/cms/api/dd_approval_configs_create.dspy", + "/cms/api/dd_approval_configs_update.dspy", + "/cms/api/dd_approval_configs_delete.dspy", + "/cms/api/dd_approval_configs_list.dspy", + + # appbase 系统基础模块 + "/appbase/appcodes_kv", "/appbase/appcodes_kv/%", + "/appbase/appcodes", "/appbase/appcodes/%", + "/appbase/params", "/appbase/params/%", + "/appbase/svgicon", "/appbase/svgicon/%", + "/appbase/cron/index.ui", +] + +print("=== Portal RBAC权限初始化 — superuser ===") +print(f"\n--- owner.superuser (超级管理员) ---") +run("owner.superuser", superuser_paths) +print("\n完成") diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..a6126f4 --- /dev/null +++ b/start.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -e +WORKDIR="$(cd "$(dirname "$0")" && pwd)" +cd "$WORKDIR" + +if [ -f portal.pid ]; then + pid=$(cat portal.pid) + if kill -0 "$pid" 2>/dev/null; then + echo "Already running (PID $pid)" + exit 0 + fi + rm -f portal.pid +fi + +echo "Starting portal on port 9090..." +$WORKDIR/py3/bin/python $WORKDIR/app/portal.py -p 9090 -w $WORKDIR >> $WORKDIR/logs/portal.log 2>&1 & +echo $! > portal.pid +echo "Started PID $(cat portal.pid)" diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..47886f1 --- /dev/null +++ b/stop.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +WORKDIR="$(cd "$(dirname "$0")" && pwd)" +cd "$WORKDIR" + +if [ -f portal.pid ]; then + pid=$(cat portal.pid) + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" + echo "Stopped PID $pid" + else + echo "Process $pid not running" + fi + rm -f portal.pid +else + echo "No pid file found" +fi diff --git a/wwwroot/admin.ui b/wwwroot/admin.ui new file mode 100644 index 0000000..30fa308 --- /dev/null +++ b/wwwroot/admin.ui @@ -0,0 +1,322 @@ +{ + "widgettype": "VBox", + "options": { + "width": "100%", + "height": "100%", + "padding": "20px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "企业CMS管理后台", + "fontSize": "24px", + "fontWeight": "bold", + "css": "title" + } + }, + { + "widgettype": "Text", + "options": { + "text": "管理官网内容、分类、商机线索和站点配置", + "fontSize": "14px", + "color": "#999", + "css": "subtitle" + } + }, + { + "widgettype": "ResponsableBox", + "options": { + "gap": "16px", + "minWidth": "220px", + "css": "admin-cards" + }, + "subwidgets": [ + { + "widgettype": "Button", + "options": { + "css": "card", + "padding": "20px", + "borderRadius": "12px", + "border": "none" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.sage_main_content", + "options": { + "url": "{{entire_url('/cms_content_list')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "VBox", + "options": { + "alignItems": "flex-start", + "gap": "8px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "📝", + "fontSize": "32px" + } + }, + { + "widgettype": "Text", + "options": { + "text": "内容管理", + "fontSize": "18px", + "fontWeight": "bold" + } + }, + { + "widgettype": "Text", + "options": { + "text": "新闻、案例、产品、Banner", + "fontSize": "13px", + "color": "#999" + } + } + ] + } + ] + }, + { + "widgettype": "Button", + "options": { + "css": "card", + "padding": "20px", + "borderRadius": "12px", + "border": "none" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.sage_main_content", + "options": { + "url": "{{entire_url('/cms_sections_list')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "VBox", + "options": { + "alignItems": "flex-start", + "gap": "8px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "🎨", + "fontSize": "32px" + } + }, + { + "widgettype": "Text", + "options": { + "text": "栏目管理", + "fontSize": "18px", + "fontWeight": "bold" + } + }, + { + "widgettype": "Text", + "options": { + "text": "栏目排序、显示隐藏、展示风格", + "fontSize": "13px", + "color": "#999" + } + } + ] + } + ] + }, + { + "widgettype": "Button", + "options": { + "css": "card", + "padding": "20px", + "borderRadius": "12px", + "border": "none" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.sage_main_content", + "options": { + "url": "{{entire_url('/cms_categories_list')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "VBox", + "options": { + "alignItems": "flex-start", + "gap": "8px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "📂", + "fontSize": "32px" + } + }, + { + "widgettype": "Text", + "options": { + "text": "内容分类", + "fontSize": "18px", + "fontWeight": "bold" + } + }, + { + "widgettype": "Text", + "options": { + "text": "管理产品分类、案例行业、新闻栏目", + "fontSize": "13px", + "color": "#999" + } + } + ] + } + ] + }, + { + "widgettype": "Button", + "options": { + "css": "card", + "padding": "20px", + "borderRadius": "12px", + "border": "none" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.sage_main_content", + "options": { + "url": "{{entire_url('/cms_leads_list')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "VBox", + "options": { + "alignItems": "flex-start", + "gap": "8px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "🎯", + "fontSize": "32px" + } + }, + { + "widgettype": "Text", + "options": { + "text": "商机线索", + "fontSize": "18px", + "fontWeight": "bold" + } + }, + { + "widgettype": "Text", + "options": { + "text": "访客留言、AI抽取商机", + "fontSize": "13px", + "color": "#999" + } + } + ] + } + ] + }, + { + "widgettype": "Button", + "options": { + "css": "card", + "padding": "20px", + "borderRadius": "12px", + "border": "none" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.sage_main_content", + "options": { + "url": "{{entire_url('/cms_site_config_list')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "VBox", + "options": { + "alignItems": "flex-start", + "gap": "8px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "⚙️", + "fontSize": "32px" + } + }, + { + "widgettype": "Text", + "options": { + "text": "站点配置", + "fontSize": "18px", + "fontWeight": "bold" + } + }, + { + "widgettype": "Text", + "options": { + "text": "首屏标语、页脚信息、联系方式", + "fontSize": "13px", + "color": "#999" + } + } + ] + } + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "sage_main_content", + "options": { + "width": "100%", + "flex": "1", + "marginTop": "20px" + } + } + ] +} \ No newline at end of file diff --git a/wwwroot/api/get_config.dspy b/wwwroot/api/get_config.dspy new file mode 100644 index 0000000..310dcf1 --- /dev/null +++ b/wwwroot/api/get_config.dspy @@ -0,0 +1,18 @@ + +config = getConfig('.') +DBPools(config.databases) +dbname = get_module_dbname('entcms') +async with db.sqlorContext(dbname) as sor: + + group = params_kw.get('group', None) + ns = {'sort': 'sort_order asc'} + if group: + ns['config_group'] = group + rows = await sor.R('cms_site_config', ns) + result = {} + for r in rows: + g = r.get('config_group', '') + if g not in result: + result[g] = {} + result[g][r.get('config_key', '')] = r.get('config_value', '') + return {'status': 'ok', 'data': result} diff --git a/wwwroot/api/get_content_detail.dspy b/wwwroot/api/get_content_detail.dspy new file mode 100644 index 0000000..e906d9e --- /dev/null +++ b/wwwroot/api/get_content_detail.dspy @@ -0,0 +1,16 @@ + +config = getConfig('.') +DBPools(config.databases) +dbname = get_module_dbname('entcms') +async with db.sqlorContext(dbname) as sor: + + _id = params_kw.get('id', '') + if not _id: + return {'status': 'error', 'message': '缺少ID'} + else: + ns = {'id': _id} + rows = await sor.R('cms_content', ns) + if rows: + return {'status': 'ok', 'data': rows[0]} + else: + return {'status': 'error', 'message': '内容不存在'} diff --git a/wwwroot/api/get_published_content.dspy b/wwwroot/api/get_published_content.dspy new file mode 100644 index 0000000..0fc70da --- /dev/null +++ b/wwwroot/api/get_published_content.dspy @@ -0,0 +1,15 @@ + +config = getConfig('.') +DBPools(config.databases) +dbname = get_module_dbname('entcms') +async with db.sqlorContext(dbname) as sor: + + content_type = params_kw.get('content_type', None) + limit = int(params_kw.get('limit', '10')) + ns = {'status': 'published', 'sort': 'sort_order asc, published_at desc'} + if content_type: + ns['content_type'] = content_type + rows = await sor.R('cms_content', ns) + if limit: + rows = rows[:limit] + return {'status': 'ok', 'rows': rows, 'total': len(rows)} diff --git a/wwwroot/api/get_sections.dspy b/wwwroot/api/get_sections.dspy new file mode 100644 index 0000000..9650681 --- /dev/null +++ b/wwwroot/api/get_sections.dspy @@ -0,0 +1,18 @@ + +config = getConfig('.') +DBPools(config.databases) +dbname = get_module_dbname('entcms') +async with db.sqlorContext(dbname) as sor: + + ns = {'is_visible': '1', 'sort': 'sort_order asc'} + rows = await sor.R('cms_sections', ns) + # Parse JSON fields + for r in rows: + for field in ['display_config', 'style_config', 'static_content']: + v = r.get(field, None) + if v and isinstance(v, str): + try: + r[field] = json.loads(v) + except: + pass + return {'status': 'ok', 'rows': rows, 'total': len(rows)} diff --git a/wwwroot/api/submit_lead.dspy b/wwwroot/api/submit_lead.dspy new file mode 100644 index 0000000..8345433 --- /dev/null +++ b/wwwroot/api/submit_lead.dspy @@ -0,0 +1,22 @@ +config = getConfig('.') +DBPools(config.databases) +dbname = get_module_dbname('entcms') +async with db.sqlorContext(dbname) as sor: + + data = { + 'id': getID(), + 'source': 'website', + 'status': 'new', + 'org_id': '0' + } + for field in ['name', 'company', 'phone', 'email', 'industry', 'region', + 'interest_products', 'message']: + v = params_kw.get(field, None) + if v is not None: + data[field] = v + + await sor.C('cms_leads', data) + return { + 'widgettype': 'Message', + 'options': {'text': '感谢您的留言,我们会尽快联系您!', 'messagetype': 'success'} + } diff --git a/wwwroot/cases.ui b/wwwroot/cases.ui new file mode 100644 index 0000000..d8c6ca4 --- /dev/null +++ b/wwwroot/cases.ui @@ -0,0 +1,13 @@ +{% set all_cases = get_published_content('case', 20) %} +{ + "widgettype": "VBox", + "options": {"width": "100%", "css": "site-root"}, + "subwidgets": [ + { + "widgettype": "Html", + "options": { + "html": "