Compare commits

...

No commits in common. "main" and "97541f1fd5b87e54e25e2875e1620c0d8ced1534" have entirely different histories.

26 changed files with 2456 additions and 1 deletions

View File

@ -1,2 +1,80 @@
# portial
# 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 |

58
app/global_func.py Normal file
View File

@ -0,0 +1,58 @@
"""
Portal全局函数 注册到ServerEnv供.dspy和.ui调用
"""
from ahserver.serverenv import ServerEnv
from appPublic.jsonConfig import getConfig
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.getConfig = getConfig
g.get_module_dbname = get_module_dbname
g.UiError = UiError
g.UiMessage = UiMessage
g.UiWindow = UiWindow

58
app/portal.py Normal file
View File

@ -0,0 +1,58 @@
"""
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
# Initialize DBPools and register db on ServerEnv for dspy use
config = getConfig('.')
env.db = DBPools(config.databases)
load_pybricks()
load_appbase()
load_rbac()
load_cms()
if __name__ == '__main__':
webapp(init)

171
build.sh Executable file
View File

@ -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 <<EOF
[Unit]
Description=Portal CMS Web Application
After=network.target
[Service]
User=$uname
Group=$gname
Type=forking
WorkingDirectory=$cdir
ExecStart=$cdir/start.sh
ExecStop=$cdir/stop.sh
StandardOutput=append:$cdir/logs/portal.log
StandardError=append:$cdir/logs/portal.log
SyslogIdentifier=portal
[Install]
WantedBy=multi-user.target
EOF
echo " portal.service 已生成"
# ===========================================
# Done
# ===========================================
cd $cdir
echo ""
echo "============================================"
echo " 构建完成!"
echo "============================================"
echo ""
echo "后续步骤:"
echo " 1. 编辑 conf/config.json 填入数据库密码"
echo " 2. 执行DDL创建CMS业务表:"
echo " mysql -h HOST -u USER -pPASS ocai_cms < cms.ddl.sql"
echo " 3. 初始化数据(appcodes/分类/栏目/配置):"
echo " py3/bin/python init_data.py"
echo " 4. 初始化权限:"
echo " py3/bin/python init_superuser_permissions.py"
echo " py3/bin/python init_any_permissions.py"
echo " py3/bin/python ~/repos/cms/scripts/load_path.py"
echo " 5. 启动应用:"
echo " ./start.sh"
echo ""
echo "访问地址: http://localhost:9090/"
echo "管理后台: http://localhost:9090/admin.ui"

76
conf/config.json Normal file
View File

@ -0,0 +1,76 @@
{
"password_key": "!@#$%^&*(*&^%$QWERTYUIqwertyui234567",
"logger": {
"name": "portal",
"levelname": "info",
"logfile": "$[workdir]$/logs/portal.log"
},
"filesroot": "$[workdir]$/files",
"databases": {
"ocai_cms": {
"driver": "mysql",
"async_mode": true,
"coding": "utf8",
"dbname": "ocai_cms",
"kwargs": {
"user": "test",
"db": "ocai_cms",
"password": "SS+C1MDMJrslBwGzYIv3nQ==",
"host": "db"
}
}
},
"website": {
"paths": [
[
"$[workdir]$/wwwroot",
""
],
[
"$[workdir]$/../cms/wwwroot",
"/cms"
]
],
"host": "0.0.0.0",
"port": 9090,
"coding": "utf-8",
"session_redis": {
"url": "redis://127.0.0.1:6379/0"
},
"indexes": [
"index.ui",
"index.html",
"index.tmpl"
],
"processors": [
[
".xlsxds",
"xlsxds"
],
[
".sqlds",
"sqlds"
],
[
".tmpl",
"tmpl"
],
[
".dspy",
"dspy"
],
[
".ui",
"bui"
],
[
".md",
"md"
]
]
},
"langMapping": {
"zh-Hans-CN": "zh-cn",
"en-US": "en"
}
}

88
deploy.sh Executable file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Portal 一键部署脚本
# 完成从构建到启动的全流程
# 用法: cd ~/repos/portal && ./deploy.sh
set -e
cdir=$(cd "$(dirname "$0")" && pwd)
cd "$cdir"
echo "╔══════════════════════════════════════════╗"
echo "║ Portal CMS — 一键部署 ║"
echo "╚══════════════════════════════════════════╝"
echo ""
# Step 1: Build
echo "=== Step 1/5: 构建 ==="
bash "$cdir/build.sh"
echo ""
# Step 2: Database DDL
echo "=== Step 2/5: 创建数据库表 ==="
if [ -f "$cdir/cms.ddl.sql" ]; then
# 从config.json读取数据库连接信息
DB_HOST=$(python3 -c "import json; c=json.load(open('$cdir/conf/config.json')); print(c['databases']['ocai_cms']['kwargs']['host'])" 2>/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 "╚══════════════════════════════════════════╝"

139
docs/architecture.md Normal file
View File

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

114
init_any_permissions.py Normal file
View File

@ -0,0 +1,114 @@
"""
Portal RBAC权限初始化 any (匿名) 角色
扫描 wwwroot bricks 下的公开页面授予 any 角色权限
规则:
- wwwroot/* /<file> (公开页面和API)
- bricks/* /bricks/<file>
- /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 (未构建,跳过) ---")
# 3. rbac模块公开路径 (登录页、注册、验证码等)
# 从rbac的load_path.py导入PATHS_ANY列表
rbac_load_path = os.path.join(os.path.dirname(app_root), "rbac", "scripts", "load_path.py")
rbac_any_paths = []
if os.path.exists(rbac_load_path):
import importlib.util
spec = importlib.util.spec_from_file_location("rbac_load_path", rbac_load_path)
mod = importlib.util.module_from_spec(spec)
# 阻止register_paths自动执行
mod.__name__ = "rbac_load_path"
spec.loader.exec_module(mod)
rbac_any_paths = getattr(mod, 'PATHS_ANY', [])
else:
print("WARNING: 找不到rbac/scripts/load_path.py跳过rbac路径注册")
if rbac_any_paths:
print(f"\n--- rbac模块 → any ({len(rbac_any_paths)} 个路径) ---")
n3 = set_any_perms(rbac_any_paths)
else:
n3 = 0
print("\n--- rbac模块 → any (无路径,跳过) ---")
total = n1 + n2 + n3
print(f"\n=== 完成: 共设置 {total} 个any权限 ===")

179
init_data.py Normal file
View File

@ -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("~/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(getConfig().databases)
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()

View File

@ -0,0 +1,143 @@
"""
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",
# rbac模块 (登录后管理页面)
"/rbac",
"/rbac/index.ui", "/rbac/admin_menu.ui", "/rbac/usermenu.ui",
"/rbac/add_adminuser.dspy", "/rbac/add_adminuser.ui",
"/rbac/add_provider.dspy", "/rbac/add_provider.ui",
"/rbac/add_reseller.dspy", "/rbac/add_superuser.dspy",
"/rbac/find_unauth_files.dspy",
"/rbac/get_all_roles.dspy", "/rbac/get_normal_roles.dspy",
"/rbac/get_provider.dspy", "/rbac/get_reseller.dspy",
"/rbac/list_path_roles.dspy", "/rbac/list_path_roles.ui",
"/rbac/organization", "/rbac/orgtypes",
"/rbac/permission", "/rbac/provider", "/rbac/reseller",
"/rbac/refresh_userperm.dspy",
"/rbac/role", "/rbac/rolepermission",
"/rbac/stat_active_users.ui", "/rbac/stat_total_orgs.ui", "/rbac/stat_total_users.ui",
"/rbac/user", "/rbac/user/myrole.ui", "/rbac/user/user.ui", "/rbac/user/user_panel.ui",
"/rbac/user/userapikey", "/rbac/user/userapikey/%",
"/rbac/user/userinfo.ui", "/rbac/user/edit_profile.dspy", "/rbac/user/save_profile.dspy",
"/rbac/user/wechat_login.ui",
"/rbac/userapp", "/rbac/userdepartment", "/rbac/userrole",
"/rbac/users", "/rbac/usersync", "/rbac/usersync/index.dspy",
]
print("=== Portal RBAC权限初始化 — superuser ===")
print(f"\n--- owner.superuser (超级管理员) ---")
run("owner.superuser", superuser_paths)
print("\n完成")

18
start.sh Executable file
View File

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

16
stop.sh Executable file
View File

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

322
wwwroot/admin.ui Normal file
View File

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

View File

@ -0,0 +1,18 @@
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('cms')
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}

View File

@ -0,0 +1,16 @@
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('cms')
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': '内容不存在'}

View File

@ -0,0 +1,15 @@
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('cms')
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)}

View File

@ -0,0 +1,18 @@
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('cms')
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)}

View File

@ -0,0 +1,22 @@
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('cms')
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'}
}

13
wwwroot/cases.ui Normal file
View File

@ -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": "<nav class=\"nav-bar\"><a class=\"nav-logo\" href=\"{{entire_url('index.ui')}}\">开元云科技<\/a><ul class=\"nav-links\"><li><a href=\"{{entire_url('index.ui')}}#products\">产品架构<\/a><\/li><li><a href=\"{{entire_url('index.ui')}}#cases\">成功案例<\/a><\/li><li><a href=\"{{entire_url('news.ui')}}\">企业动态<\/a><\/li><\/ul><\/nav><section class=\"section\" style=\"padding-top:100px\"><h2 class=\"section-title\">成功案例<\/h2><p class=\"section-desc\">AI正在改变千行百业<\/p><div class=\"cases-grid\">{% for c in all_cases %}<div class=\"case-card\"><div class=\"case-tag\">{{c.tags or '行业案例'}}<\/div><div class=\"case-title\">{{c.title}}<\/div><div class=\"case-desc\">{{c.summary_text}}<\/div><\/div>{% endfor %}<\/div><div class=\"cta-banner\" style=\"margin-top:40px\"><div class=\"cta-text\">想了解这些方案如何落地?<\/div><a class=\"btn-primary\" href=\"{{entire_url('index.ui')}}#contact\">了解更多 → 联系销售<\/a><\/div><\/section><footer class=\"site-footer\"><p>© 2026 开元云科技 · 国家级高新技术企业 · 专精特新企业<\/p><\/footer>"
}
}
]
}

153
wwwroot/cms_scripts.js Normal file
View File

@ -0,0 +1,153 @@
/* ===== 开元云科技 官网交互脚本 ===== */
document.addEventListener('DOMContentLoaded', function() {
initScrollAnimations();
initProductCards();
initFloatingWidget();
initSmoothScroll();
});
/* === Scroll Fade-in Animations === */
function initScrollAnimations() {
var elements = document.querySelectorAll('.fade-in');
if (!elements.length) return;
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.1 });
elements.forEach(function(el) { observer.observe(el); });
}
/* === Product Card Expand === */
function initProductCards() {
var cards = document.querySelectorAll('.product-card');
cards.forEach(function(card) {
card.addEventListener('click', function() {
var wasActive = card.classList.contains('active');
// Close all
cards.forEach(function(c) { c.classList.remove('active'); });
// Toggle current
if (!wasActive) {
card.classList.add('active');
}
});
});
}
/* === Floating Contact Widget === */
function initFloatingWidget() {
var avatar = document.querySelector('.float-avatar');
var panel = document.querySelector('.float-panel');
if (!avatar || !panel) return;
avatar.addEventListener('click', function(e) {
e.stopPropagation();
panel.classList.toggle('active');
});
// Close panel on outside click
document.addEventListener('click', function(e) {
if (!panel.contains(e.target) && !avatar.contains(e.target)) {
panel.classList.remove('active');
}
});
// Panel option clicks -> show form
var options = panel.querySelectorAll('.panel-option');
options.forEach(function(opt) {
opt.addEventListener('click', function() {
var formType = opt.getAttribute('data-form');
var panelBody = panel.querySelector('.panel-body');
var panelForm = panel.querySelector('.panel-form');
if (panelBody) panelBody.style.display = 'none';
if (panelForm) {
panelForm.classList.add('active');
panelForm.setAttribute('data-type', formType);
}
});
});
// Back button
var backBtn = panel.querySelector('.back-btn');
if (backBtn) {
backBtn.addEventListener('click', function() {
var panelBody = panel.querySelector('.panel-body');
var panelForm = panel.querySelector('.panel-form');
if (panelBody) panelBody.style.display = 'block';
if (panelForm) panelForm.classList.remove('active');
});
}
// Form submit
var submitBtn = panel.querySelector('.submit-btn');
if (submitBtn) {
submitBtn.addEventListener('click', function(e) {
e.preventDefault();
var form = panel.querySelector('.panel-form');
var name = form.querySelector('[name="name"]');
var phone = form.querySelector('[name="phone"]');
var company = form.querySelector('[name="company"]');
var message = form.querySelector('[name="message"]');
if (!phone || !phone.value.trim()) {
alert('请填写联系电话');
return;
}
var data = {
name: name ? name.value : '',
phone: phone ? phone.value : '',
company: company ? company.value : '',
message: message ? message.value : '',
interest_products: form.getAttribute('data-type') || ''
};
// Submit via fetch
var submitUrl = form.getAttribute('data-submit-url');
if (!submitUrl) {
alert('提交成功,我们会尽快联系您!');
panel.classList.remove('active');
return;
}
fetch(submitUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
})
.then(function(r) { return r.json(); })
.then(function(resp) {
alert('感谢您的留言,我们会尽快联系您!');
panel.classList.remove('active');
// Reset form
if (name) name.value = '';
if (phone) phone.value = '';
if (company) company.value = '';
if (message) message.value = '';
})
.catch(function(err) {
console.error('Submit error:', err);
alert('提交失败,请稍后重试');
});
});
}
}
/* === Smooth Scroll for Nav Links === */
function initSmoothScroll() {
var links = document.querySelectorAll('.nav-links a[href^="#"]');
links.forEach(function(link) {
link.addEventListener('click', function(e) {
var target = document.querySelector(this.getAttribute('href'));
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
}

640
wwwroot/cms_styles.css Normal file
View File

@ -0,0 +1,640 @@
/* ===== 开元云科技 官网样式系统 ===== */
/* 设计参考: OpenAI风格, 极简科技感 */
/* === CSS Variables === */
:root {
--brand-primary: #6C5CE7;
--brand-light: #A29BFE;
--brand-sky: #74B9FF;
--brand-gradient: linear-gradient(135deg, #6C5CE7, #A29BFE, #74B9FF);
--bg-dark: #0a0a0a;
--bg-card: #1A1A1A;
--bg-card-hover: #222222;
--border-dark: #222;
--border-active: #6C5CE7;
--text-primary: #FFFFFF;
--text-secondary: #999999;
--text-muted: #666666;
--max-width: 1100px;
--font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--transition: all 0.3s ease;
}
/* === Reset & Base === */
* { margin: 0; padding: 0; box-sizing: border-box; }
body, html {
font-family: var(--font-family);
background: var(--bg-dark);
color: var(--text-primary);
line-height: 1.6;
overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
/* === Navigation === */
.nav-bar {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: rgba(10, 10, 10, 0.8);
border-bottom: 1px solid rgba(255,255,255,0.06);
padding: 0 48px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
font-size: 18px;
font-weight: 700;
background: var(--brand-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
font-size: 14px;
color: var(--text-secondary);
transition: var(--transition);
}
.nav-links a:hover { color: var(--text-primary); }
.nav-cta {
display: inline-flex;
align-items: center;
padding: 8px 20px;
background: var(--brand-primary);
color: white;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
transition: var(--transition);
border: none;
cursor: pointer;
}
.nav-cta:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(108, 92, 231, 0.4);
}
/* === Hero Section === */
.hero-section {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 120px 48px 80px;
position: relative;
overflow: hidden;
}
.hero-bg-glow {
position: absolute;
width: 800px;
height: 800px;
border-radius: 50%;
background: radial-gradient(circle, rgba(108, 92, 231, 0.15) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.hero-content {
max-width: var(--max-width);
width: 100%;
position: relative;
z-index: 1;
}
.hero-tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
background: rgba(108, 92, 231, 0.15);
border: 1px solid rgba(108, 92, 231, 0.3);
border-radius: 20px;
font-size: 13px;
color: var(--brand-light);
margin-bottom: 24px;
}
.hero-tag .pulse-dot {
width: 8px;
height: 8px;
background: var(--brand-primary);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.hero-title {
font-size: 56px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 20px;
}
.hero-title .gradient-text {
background: var(--brand-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 18px;
color: var(--text-secondary);
font-weight: 300;
max-width: 600px;
margin-bottom: 40px;
}
.hero-buttons {
display: flex;
gap: 16px;
}
.btn-primary {
display: inline-flex;
align-items: center;
padding: 12px 28px;
background: var(--brand-primary);
color: white;
border-radius: 10px;
font-size: 16px;
font-weight: 500;
transition: var(--transition);
border: none;
cursor: pointer;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(108, 92, 231, 0.4);
}
.btn-outline {
display: inline-flex;
align-items: center;
padding: 12px 28px;
background: transparent;
color: var(--text-primary);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 10px;
font-size: 16px;
font-weight: 500;
transition: var(--transition);
cursor: pointer;
}
.btn-outline:hover {
border-color: var(--brand-primary);
transform: translateY(-2px);
}
.hero-mascot {
position: absolute;
right: 0;
bottom: 10%;
opacity: 0.12;
pointer-events: none;
}
/* === Section Common === */
.section {
padding: 80px 48px;
max-width: var(--max-width);
margin: 0 auto;
}
.section-title {
font-size: 36px;
font-weight: 700;
margin-bottom: 12px;
}
.section-desc {
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 48px;
font-weight: 300;
}
/* === Products (1+N+X) === */
.products-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.product-card {
background: var(--bg-card);
border: 1px solid var(--border-dark);
border-radius: 12px;
padding: 32px 24px;
cursor: pointer;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.product-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: transparent;
transition: var(--transition);
}
.product-card:hover {
transform: translateX(4px);
border-color: var(--brand-primary);
}
.product-card:hover::before {
background: var(--brand-primary);
}
.product-card.active {
background: rgba(108, 92, 231, 0.1);
border-color: var(--brand-primary);
}
.product-card.active::before {
background: var(--brand-primary);
}
.product-card .card-icon {
font-size: 32px;
margin-bottom: 16px;
}
.product-card .card-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.product-card .card-desc {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
.product-detail {
display: none;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-dark);
font-size: 14px;
color: var(--text-secondary);
line-height: 1.8;
}
.product-card.active .product-detail {
display: block;
}
/* === Cases === */
.cases-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 40px;
}
.case-card {
background: var(--bg-card);
border: 1px solid var(--border-dark);
border-radius: 12px;
padding: 24px;
transition: var(--transition);
cursor: pointer;
}
.case-card:hover {
transform: translateY(-4px);
border-color: var(--brand-primary);
}
.case-tag {
font-size: 11px;
color: var(--brand-light);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
font-weight: 500;
}
.case-title {
font-size: 17px;
font-weight: 600;
margin-bottom: 8px;
}
.case-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
/* === CTA Banner === */
.cta-banner {
background: linear-gradient(135deg, rgba(108, 92, 231, 0.15), rgba(116, 185, 255, 0.1));
border: 1px solid rgba(108, 92, 231, 0.2);
border-radius: 16px;
padding: 40px;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 40px;
}
.cta-text {
font-size: 20px;
font-weight: 600;
}
/* === News === */
.news-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.news-view-all {
font-size: 14px;
color: var(--brand-light);
transition: var(--transition);
}
.news-view-all:hover { color: var(--brand-primary); }
.news-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.news-item {
display: flex;
align-items: flex-start;
gap: 20px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border-dark);
border-radius: 12px;
transition: var(--transition);
cursor: pointer;
}
.news-item:hover {
border-color: var(--brand-primary);
}
.news-date {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
min-width: 80px;
}
.news-title {
font-size: 14px;
font-weight: 500;
}
/* === Footer === */
.site-footer {
border-top: 1px solid rgba(255,255,255,0.06);
padding: 40px 48px;
text-align: center;
font-size: 13px;
color: var(--text-muted);
}
/* === Floating Contact Widget === */
.float-contact {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 200;
}
.float-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--brand-gradient);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 20px rgba(108, 92, 231, 0.3);
transition: var(--transition);
}
.float-avatar:hover {
transform: translateY(-3px) scale(1.05);
}
.float-avatar svg {
width: 32px;
height: 32px;
}
.float-bubble {
position: absolute;
right: 68px;
bottom: 12px;
background: white;
color: #333;
padding: 10px 16px;
border-radius: 12px;
font-size: 14px;
white-space: nowrap;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
opacity: 0;
transform: translateX(10px);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: none;
}
.float-contact:hover .float-bubble {
opacity: 1;
transform: translateX(0);
}
.float-panel {
position: absolute;
bottom: 70px;
right: 0;
width: 300px;
background: white;
border-radius: 16px;
box-shadow: 0 8px 40px rgba(0,0,0,0.2);
overflow: hidden;
display: none;
animation: panelSlideUp 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.float-panel.active { display: block; }
@keyframes panelSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.panel-header {
background: var(--brand-gradient);
padding: 20px;
color: white;
font-size: 16px;
font-weight: 600;
}
.panel-body {
padding: 16px;
}
.panel-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f5f5f5;
border-radius: 10px;
margin-bottom: 8px;
cursor: pointer;
transition: var(--transition);
color: #333;
font-size: 14px;
}
.panel-option:hover {
background: var(--brand-primary);
color: white;
}
.panel-option svg {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.panel-form {
padding: 16px;
display: none;
}
.panel-form.active { display: block; }
.panel-form input,
.panel-form textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
margin-bottom: 10px;
font-family: var(--font-family);
outline: none;
transition: var(--transition);
}
.panel-form input:focus,
.panel-form textarea:focus {
border-color: var(--brand-primary);
}
.panel-form textarea { resize: vertical; min-height: 60px; }
.panel-form .submit-btn {
width: 100%;
padding: 10px;
background: var(--brand-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.panel-form .submit-btn:hover {
background: #5a4bd1;
}
.panel-form .back-btn {
background: none;
border: none;
color: var(--brand-primary);
font-size: 13px;
cursor: pointer;
margin-bottom: 12px;
padding: 0;
}
/* === Responsive === */
@media (max-width: 768px) {
.nav-bar { padding: 0 20px; }
.nav-links { display: none; }
.hero-section { padding: 100px 20px 60px; }
.hero-title { font-size: 32px; }
.hero-subtitle { font-size: 16px; }
.hero-buttons { flex-direction: column; }
.section { padding: 60px 20px; }
.section-title { font-size: 28px; }
.products-grid { grid-template-columns: 1fr; }
.cases-grid { grid-template-columns: 1fr; }
.cta-banner {
flex-direction: column;
text-align: center;
gap: 20px;
}
.news-item { flex-direction: column; gap: 8px; }
.site-footer { padding: 30px 20px; }
.float-panel { width: 280px; right: -8px; }
}
/* === Scroll Animations === */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}

17
wwwroot/index.ui Normal file

File diff suppressed because one or more lines are too long

44
wwwroot/menu.ui Normal file
View File

@ -0,0 +1,44 @@
{
"widgettype": "Menu",
"id": "entcms_menu",
"options": {
"items": [
{
"name": "cms_content_list",
"label": "内容管理",
"url": "{{entire_url('/cms_content_list')}}",
"target": "app.sage_main_content"
},
{
"name": "cms_sections_list",
"label": "栏目管理",
"url": "{{entire_url('/cms_sections_list')}}",
"target": "app.sage_main_content"
},
{
"name": "cms_categories_list",
"label": "内容分类",
"url": "{{entire_url('/cms_categories_list')}}",
"target": "app.sage_main_content"
},
{
"name": "cms_leads_list",
"label": "商机线索",
"url": "{{entire_url('/cms_leads_list')}}",
"target": "app.sage_main_content"
},
{
"name": "cms_site_config_list",
"label": "站点配置",
"url": "{{entire_url('/cms_site_config_list')}}",
"target": "app.sage_main_content"
},
{
"name": "public_site",
"label": "官网预览",
"url": "{{entire_url('/index.ui')}}",
"target": "app.sage_main_content"
}
]
}
}

13
wwwroot/news.ui Normal file
View File

@ -0,0 +1,13 @@
{% set news_items = get_published_content('news', 50) %}
{
"widgettype": "VBox",
"options": {"width": "100%", "css": "site-root"},
"subwidgets": [
{
"widgettype": "Html",
"options": {
"html": "<nav class=\"nav-bar\"><a class=\"nav-logo\" href=\"{{entire_url('index.ui')}}\">开元云科技<\/a><ul class=\"nav-links\"><li><a href=\"{{entire_url('index.ui')}}#products\">产品架构<\/a><\/li><li><a href=\"{{entire_url('index.ui')}}#cases\">成功案例<\/a><\/li><li><a href=\"{{entire_url('index.ui')}}#news\">企业动态<\/a><\/li><\/ul><\/nav><section class=\"section\" style=\"padding-top:100px\"><h2 class=\"section-title\">企业动态<\/h2><p class=\"section-desc\">了解开元云最新资讯与行业洞察<\/p><div class=\"news-list\">{% for item in news_items %}<a class=\"news-item\" href=\"{{entire_url('news_detail.ui')}}?id={{item.id}}\"><span class=\"news-date\">{{item.published_at or item.created_at}}<\/span><div><span class=\"news-title\">{{item.title}}<\/span>{% if item.summary_text %}<p style=\"font-size:13px;color:#666;margin-top:4px\">{{item.summary_text}}<\/p>{% endif %}<\/div><\/a>{% endfor %}<\/div><\/section><footer class=\"site-footer\"><p>© 2026 开元云科技 · 国家级高新技术企业 · 专精特新企业<\/p><\/footer>"
}
}
]
}

13
wwwroot/news_detail.ui Normal file
View File

@ -0,0 +1,13 @@
{% set article = get_content_detail(params_kw.get('id', '')) %}
{
"widgettype": "VBox",
"options": {"width": "100%", "css": "site-root"},
"subwidgets": [
{
"widgettype": "Html",
"options": {
"html": "<nav class=\"nav-bar\"><a class=\"nav-logo\" href=\"{{entire_url('index.ui')}}\">开元云科技<\/a><ul class=\"nav-links\"><li><a href=\"{{entire_url('index.ui')}}#products\">产品架构<\/a><\/li><li><a href=\"{{entire_url('index.ui')}}#cases\">成功案例<\/a><\/li><li><a href=\"{{entire_url('news.ui')}}\">企业动态<\/a><\/li><\/ul><\/nav><section class=\"section\" style=\"padding-top:100px;max-width:800px\">{% if article %}<a href=\"{{entire_url('news.ui')}}\" style=\"color:#A29BFE;font-size:14px;margin-bottom:24px;display:inline-block\">← 返回新闻列表<\/a><h1 class=\"section-title\" style=\"margin-bottom:12px\">{{article.title}}<\/h1><p style=\"font-size:13px;color:#666;margin-bottom:32px\">{{article.published_at or article.created_at}}{% if article.tags %} · {{article.tags}}{% endif %}<\/p>{% if article.image_url %}<img src=\"{{article.image_url}}\" style=\"width:100%;border-radius:12px;margin-bottom:32px\" /><\/img>{% endif %}<div style=\"font-size:16px;line-height:1.8;color:#ccc\">{{article.body or article.summary_text or ''}}<\/div>{% else %}<p style=\"color:#999\">文章不存在或已下线<\/p><a href=\"{{entire_url('news.ui')}}\" style=\"color:#A29BFE\">← 返回新闻列表<\/a>{% endif %}<\/section><footer class=\"site-footer\"><p>© 2026 开元云科技 · 国家级高新技术企业 · 专精特新企业<\/p><\/footer>"
}
}
]
}

13
wwwroot/products.ui Normal file
View File

@ -0,0 +1,13 @@
{% set products = get_published_content('product', 50) %}
{
"widgettype": "VBox",
"options": {"width": "100%", "css": "site-root"},
"subwidgets": [
{
"widgettype": "Html",
"options": {
"html": "<nav class=\"nav-bar\"><a class=\"nav-logo\" href=\"{{entire_url('index.ui')}}\">开元云科技<\/a><ul class=\"nav-links\"><li><a href=\"{{entire_url('index.ui')}}#products\">产品架构<\/a><\/li><li><a href=\"{{entire_url('index.ui')}}#cases\">成功案例<\/a><\/li><li><a href=\"{{entire_url('news.ui')}}\">企业动态<\/a><\/li><\/ul><\/nav><section class=\"section\" style=\"padding-top:100px\"><h2 class=\"section-title\">产品架构<\/h2><p class=\"section-desc\">AI基础设施全栈解决方案<\/p><div class=\"cases-grid\">{% for p in products %}<div class=\"case-card\"><div class=\"case-tag\">{{p.tags or '核心产品'}}<\/div><div class=\"case-title\">{{p.title}}<\/div><div class=\"case-desc\">{{p.summary_text}}<\/div><\/div>{% endfor %}<\/div><div class=\"cta-banner\" style=\"margin-top:40px\"><div class=\"cta-text\">需要定制化方案?<\/div><a class=\"btn-primary\" href=\"{{entire_url('index.ui')}}#contact\">联系解决方案团队 →<\/a><\/div><\/section><footer class=\"site-footer\"><p>© 2026 开元云科技 · 国家级高新技术企业 · 专精特新企业<\/p><\/footer>"
}
}
]
}