feat: CMS管理后台CRUD基础设施 + 暗色主题修复

- 新增5张CMS表的模型定义(models/)和CRUD定义(json/)
- 新增17个.dspy API端点(create/update/delete + search)
- 新增load_path.py RBAC权限注册脚本
- xls2crud生成5个CRUD管理页面目录
- 修复bricks默认灰色背景覆盖暗色主题(.site-root全局override)
- user_menu.ui添加管理后台入口(按权限显示)
- 初始化CMS种子数据(栏目/分类/内容)
This commit is contained in:
Hermes Agent 2026-06-16 13:32:58 +08:00
parent edcbdc7e03
commit 2d3c74b6ad
31 changed files with 864 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# CRUD auto-generated directories (regenerated via xls2crud)
wwwroot/cms_content_list/
wwwroot/cms_sections_list/
wwwroot/cms_categories_list/
wwwroot/cms_leads_list/
wwwroot/cms_site_config_list/
# Python
__pycache__/
*.pyc
*.pyo
# Virtual environment
py3/
# Logs
logs/*.log
# PID file
portal.pid
# IDE
.idea/
.vscode/
*.swp

View File

@ -0,0 +1,29 @@
{
"tblname": "cms_categories",
"alias": "cms_categories_list",
"title": "内容分类",
"uitype": "tree",
"params": {
"idField": "id",
"textField": "name",
"parentField": "parent_id",
"sortby": ["sort_order asc", "created_at desc"],
"logined_userorgid": "org_id",
"browserfields": {
"exclouded": ["display_config"],
"alters": {
"content_type": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_content_type.dspy')}}",
"datamethod": "GET"
}
}
},
"editexclouded": ["id", "org_id", "created_at"],
"editable": {
"new_data_url": "{{entire_url('../api/cms_categories_create.dspy')}}",
"update_data_url": "{{entire_url('../api/cms_categories_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/cms_categories_delete.dspy')}}"
}
}
}

View File

@ -0,0 +1,38 @@
{
"tblname": "cms_content",
"alias": "cms_content_list",
"title": "内容管理",
"params": {
"sortby": ["sort_order asc", "created_at desc"],
"logined_userorgid": "org_id",
"browserfields": {
"exclouded": ["body", "extra_json", "approval_id", "created_by"],
"alters": {
"status": {
"uitype": "code",
"data": [
{"value": "draft", "text": "草稿"},
{"value": "published", "text": "已发布"},
{"value": "archived", "text": "已归档"}
]
},
"content_type": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_content_type.dspy')}}",
"datamethod": "GET"
},
"category_id": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_cms_categories.dspy')}}",
"datamethod": "GET"
}
}
},
"editexclouded": ["id", "org_id", "created_by", "created_at", "updated_at"],
"editable": {
"new_data_url": "{{entire_url('../api/cms_content_create.dspy')}}",
"update_data_url": "{{entire_url('../api/cms_content_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/cms_content_delete.dspy')}}"
}
}
}

39
json/cms_leads_list.json Normal file
View File

@ -0,0 +1,39 @@
{
"tblname": "cms_leads",
"alias": "cms_leads_list",
"title": "商机线索",
"params": {
"sortby": ["created_at desc"],
"logined_userorgid": "org_id",
"browserfields": {
"exclouded": ["raw_text"],
"alters": {
"status": {
"uitype": "code",
"data": [
{"value": "new", "text": "新线索"},
{"value": "contacting", "text": "联系中"},
{"value": "qualified", "text": "已确认"},
{"value": "converted", "text": "已转化"},
{"value": "closed", "text": "已关闭"}
]
},
"source": {
"uitype": "code",
"data": [
{"value": "website", "text": "官网"},
{"value": "form", "text": "表单"},
{"value": "ai", "text": "AI抽取"},
{"value": "other", "text": "其他"}
]
}
}
},
"editexclouded": ["id", "org_id", "created_at", "updated_at"],
"editable": {
"new_data_url": "{{entire_url('../api/cms_leads_create.dspy')}}",
"update_data_url": "{{entire_url('../api/cms_leads_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/cms_leads_delete.dspy')}}"
}
}
}

View File

@ -0,0 +1,32 @@
{
"tblname": "cms_sections",
"alias": "cms_sections_list",
"title": "栏目管理",
"params": {
"sortby": ["sort_order asc", "created_at desc"],
"logined_userorgid": "org_id",
"browserfields": {
"exclouded": ["static_content", "style_config"],
"alters": {
"is_visible": {
"uitype": "code",
"data": [
{"value": "1", "text": "显示"},
{"value": "0", "text": "隐藏"}
]
},
"content_type": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_content_type.dspy')}}",
"datamethod": "GET"
}
}
},
"editexclouded": ["id", "org_id", "created_at", "updated_at"],
"editable": {
"new_data_url": "{{entire_url('../api/cms_sections_create.dspy')}}",
"update_data_url": "{{entire_url('../api/cms_sections_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/cms_sections_delete.dspy')}}"
}
}
}

View File

@ -0,0 +1,30 @@
{
"tblname": "cms_site_config",
"alias": "cms_site_config_list",
"title": "站点配置",
"params": {
"sortby": ["config_group asc", "sort_order asc"],
"logined_userorgid": "org_id",
"browserfields": {
"exclouded": [],
"alters": {
"config_type": {
"uitype": "code",
"data": [
{"value": "text", "text": "文本"},
{"value": "number", "text": "数字"},
{"value": "json", "text": "JSON"},
{"value": "bool", "text": "布尔"},
{"value": "image", "text": "图片URL"}
]
}
}
},
"editexclouded": ["id", "org_id", "updated_at"],
"editable": {
"new_data_url": "{{entire_url('../api/cms_site_config_create.dspy')}}",
"update_data_url": "{{entire_url('../api/cms_site_config_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/cms_site_config_delete.dspy')}}"
}
}
}

138
load_path.py Normal file
View File

@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Portal CMS CRUD RBAC 权限注册脚本
注册CMS管理后台的所有CRUD路径权限:
- superuser (owner.superuser): CMS管理页面和API
- any: 公开API搜索下拉等
使用方法:
cd ~/repos/sage
./py3/bin/python ~/repos/portal/load_path.py
"""
import subprocess
import os
import sys
def find_sage_root():
candidates = [
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
]
for c in candidates:
if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")):
return c
return None
SAGE_ROOT = find_sage_root()
if not SAGE_ROOT:
print("ERROR: Cannot find Sage root directory")
sys.exit(1)
PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python")
SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, "set_role_perm.py")
# ============================================================
# 权限路径定义
# ============================================================
# any — 无需登录公开API: 搜索下拉、内容类型列表)
PATHS_ANY = [
"/api/get_search_cms_categories.dspy",
"/api/get_search_content_type.dspy",
]
# owner.superuser — CMS管理CRUD页面和API
PATHS_SUPERUSER = [
# CMS Content CRUD
"/cms_content_list", "/cms_content_list/%",
"/api/cms_content_create.dspy",
"/api/cms_content_update.dspy",
"/api/cms_content_delete.dspy",
"/api/cms_content_list.dspy",
"/cms_content_list/get_cms_content.dspy",
"/cms_content_list/add_cms_content.dspy",
"/cms_content_list/update_cms_content.dspy",
"/cms_content_list/delete_cms_content.dspy",
# CMS Sections CRUD
"/cms_sections_list", "/cms_sections_list/%",
"/api/cms_sections_create.dspy",
"/api/cms_sections_update.dspy",
"/api/cms_sections_delete.dspy",
"/api/cms_sections_list.dspy",
"/cms_sections_list/get_cms_sections.dspy",
"/cms_sections_list/add_cms_sections.dspy",
"/cms_sections_list/update_cms_sections.dspy",
"/cms_sections_list/delete_cms_sections.dspy",
# CMS Categories CRUD
"/cms_categories_list", "/cms_categories_list/%",
"/api/cms_categories_create.dspy",
"/api/cms_categories_update.dspy",
"/api/cms_categories_delete.dspy",
"/api/cms_categories_list.dspy",
"/cms_categories_list/get_cms_categories.dspy",
"/cms_categories_list/add_cms_categories.dspy",
"/cms_categories_list/update_cms_categories.dspy",
"/cms_categories_list/delete_cms_categories.dspy",
# CMS Leads CRUD
"/cms_leads_list", "/cms_leads_list/%",
"/api/cms_leads_create.dspy",
"/api/cms_leads_update.dspy",
"/api/cms_leads_delete.dspy",
"/api/cms_leads_list.dspy",
"/cms_leads_list/get_cms_leads.dspy",
"/cms_leads_list/add_cms_leads.dspy",
"/cms_leads_list/update_cms_leads.dspy",
"/cms_leads_list/delete_cms_leads.dspy",
# CMS Site Config CRUD
"/cms_site_config_list", "/cms_site_config_list/%",
"/api/cms_site_config_create.dspy",
"/api/cms_site_config_update.dspy",
"/api/cms_site_config_delete.dspy",
"/api/cms_site_config_list.dspy",
"/cms_site_config_list/get_cms_site_config.dspy",
"/cms_site_config_list/add_cms_site_config.dspy",
"/cms_site_config_list/update_cms_site_config.dspy",
"/cms_site_config_list/delete_cms_site_config.dspy",
]
# ============================================================
# 执行注册
# ============================================================
def run_set_perm(role, path):
env = os.environ.copy()
env['SAGE_RBAC_DB'] = 'ocai_cms'
cmd = [PYTHON, SET_PERM_SCRIPT, role, path]
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
return result.returncode == 0
def register_role_paths(role, paths):
count = 0
for p in paths:
if run_set_perm(role, p):
count += 1
print(f" {role}: {count}/{len(paths)} paths registered")
return count
def main():
print(f"Sage root: {SAGE_ROOT}")
print(f"RBAC DB: ocai_cms")
total = 0
total += register_role_paths("any", PATHS_ANY)
total += register_role_paths("owner.superuser", PATHS_SUPERUSER)
print(f"\nDone. Total {total} permission entries registered.")
print("NOTE: Restart Sage after permission changes to reload RBAC cache.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,25 @@
{
"summary": [
{
"name": "cms_categories",
"title": "CMS分类表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "ID", "type": "str", "length": 32, "nullable": "no", "comments": "主键ID"},
{"name": "org_id", "title": "组织ID", "type": "str", "length": 32, "nullable": "no", "comments": "所属组织"},
{"name": "name", "title": "分类名称", "type": "str", "length": 100, "nullable": "no", "comments": "分类名称"},
{"name": "parent_id", "title": "父分类ID", "type": "str", "length": 32, "nullable": "yes", "comments": "父分类ID空表示顶级"},
{"name": "content_type", "title": "内容类型", "type": "str", "length": 32, "nullable": "yes", "comments": "关联内容类型"},
{"name": "description", "title": "描述", "type": "str", "length": 500, "nullable": "yes", "comments": "分类描述"},
{"name": "sort_order", "title": "排序", "type": "int", "nullable": "yes", "default": "0", "comments": "排序序号"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no", "comments": "创建时间"},
{"name": "display_config", "title": "展示配置", "type": "text", "nullable": "yes", "comments": "展示配置JSON"}
],
"indexes": [
{"name": "idx_categories_org", "idxtype": "index", "idxfields": ["org_id"]},
{"name": "idx_categories_parent", "idxtype": "index", "idxfields": ["parent_id"]},
{"name": "idx_categories_type", "idxtype": "index", "idxfields": ["content_type"]}
]
}

35
models/cms_content.json Normal file
View File

@ -0,0 +1,35 @@
{
"summary": [
{
"name": "cms_content",
"title": "CMS内容表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "ID", "type": "str", "length": 32, "nullable": "no", "comments": "主键ID"},
{"name": "org_id", "title": "组织ID", "type": "str", "length": 32, "nullable": "no", "comments": "所属组织"},
{"name": "content_type", "title": "内容类型", "type": "str", "length": 32, "nullable": "no", "comments": "news/product/case/banner"},
{"name": "category_id", "title": "分类ID", "type": "str", "length": 32, "nullable": "yes", "comments": "关联分类"},
{"name": "title", "title": "标题", "type": "str", "length": 200, "nullable": "no", "comments": "内容标题"},
{"name": "subtitle", "title": "副标题", "type": "str", "length": 200, "nullable": "yes", "comments": "副标题"},
{"name": "summary_text", "title": "摘要", "type": "text", "nullable": "yes", "comments": "内容摘要"},
{"name": "body", "title": "正文", "type": "text", "nullable": "yes", "comments": "正文内容(HTML)"},
{"name": "image_url", "title": "图片URL", "type": "str", "length": 500, "nullable": "yes", "comments": "封面图片URL"},
{"name": "tags", "title": "标签", "type": "str", "length": 500, "nullable": "yes", "comments": "标签,逗号分隔"},
{"name": "sort_order", "title": "排序", "type": "int", "nullable": "yes", "default": "0", "comments": "排序序号"},
{"name": "status", "title": "状态", "type": "str", "length": 20, "nullable": "no", "default": "draft", "comments": "draft/published/archived"},
{"name": "approval_id", "title": "审批ID", "type": "str", "length": 64, "nullable": "yes", "comments": "钉钉审批实例ID"},
{"name": "published_at", "title": "发布时间", "type": "timestamp", "nullable": "yes", "comments": "发布时间"},
{"name": "extra_json", "title": "扩展JSON", "type": "text", "nullable": "yes", "comments": "扩展属性JSON"},
{"name": "created_by", "title": "创建人", "type": "str", "length": 32, "nullable": "yes", "comments": "创建人ID"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no", "comments": "创建时间"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no", "comments": "更新时间"}
],
"indexes": [
{"name": "idx_content_type", "idxtype": "index", "idxfields": ["content_type"]},
{"name": "idx_content_status", "idxtype": "index", "idxfields": ["status"]},
{"name": "idx_content_org", "idxtype": "index", "idxfields": ["org_id"]},
{"name": "idx_content_category", "idxtype": "index", "idxfields": ["category_id"]}
]
}

33
models/cms_leads.json Normal file
View File

@ -0,0 +1,33 @@
{
"summary": [
{
"name": "cms_leads",
"title": "CMS商机线索表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "ID", "type": "str", "length": 32, "nullable": "no", "comments": "主键ID"},
{"name": "org_id", "title": "组织ID", "type": "str", "length": 32, "nullable": "no", "comments": "所属组织"},
{"name": "source", "title": "来源", "type": "str", "length": 50, "nullable": "yes", "comments": "来源: website/form/ai等"},
{"name": "name", "title": "联系人", "type": "str", "length": 100, "nullable": "yes", "comments": "联系人姓名"},
{"name": "company", "title": "公司", "type": "str", "length": 200, "nullable": "yes", "comments": "公司名称"},
{"name": "phone", "title": "电话", "type": "str", "length": 30, "nullable": "yes", "comments": "联系电话"},
{"name": "email", "title": "邮箱", "type": "str", "length": 200, "nullable": "yes", "comments": "电子邮箱"},
{"name": "industry", "title": "行业", "type": "str", "length": 100, "nullable": "yes", "comments": "所属行业"},
{"name": "region", "title": "地区", "type": "str", "length": 100, "nullable": "yes", "comments": "所在地区"},
{"name": "interest_products", "title": "意向产品", "type": "str", "length": 500, "nullable": "yes", "comments": "意向产品"},
{"name": "message", "title": "留言", "type": "text", "nullable": "yes", "comments": "访客留言内容"},
{"name": "raw_text", "title": "原始文本", "type": "text", "nullable": "yes", "comments": "AI抽取前的原始文本"},
{"name": "status", "title": "状态", "type": "str", "length": 20, "nullable": "no", "default": "new", "comments": "new/contacting/qualified/converted/closed"},
{"name": "assigned_to", "title": "负责人", "type": "str", "length": 32, "nullable": "yes", "comments": "分配给的销售人员ID"},
{"name": "notes", "title": "备注", "type": "text", "nullable": "yes", "comments": "跟进备注"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no", "comments": "创建时间"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no", "comments": "更新时间"}
],
"indexes": [
{"name": "idx_leads_org", "idxtype": "index", "idxfields": ["org_id"]},
{"name": "idx_leads_status", "idxtype": "index", "idxfields": ["status"]},
{"name": "idx_leads_source", "idxtype": "index", "idxfields": ["source"]}
]
}

29
models/cms_sections.json Normal file
View File

@ -0,0 +1,29 @@
{
"summary": [
{
"name": "cms_sections",
"title": "CMS栏目表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "ID", "type": "str", "length": 32, "nullable": "no", "comments": "主键ID"},
{"name": "org_id", "title": "组织ID", "type": "str", "length": 32, "nullable": "no", "comments": "所属组织"},
{"name": "section_key", "title": "栏目Key", "type": "str", "length": 64, "nullable": "no", "comments": "栏目唯一标识"},
{"name": "title", "title": "标题", "type": "str", "length": 100, "nullable": "no", "comments": "栏目标题"},
{"name": "subtitle", "title": "副标题", "type": "str", "length": 200, "nullable": "yes", "comments": "栏目副标题"},
{"name": "section_type", "title": "栏目类型", "type": "str", "length": 32, "nullable": "yes", "comments": "栏目类型: hero/features/list/cta等"},
{"name": "content_type", "title": "内容类型", "type": "str", "length": 32, "nullable": "yes", "comments": "关联内容类型: news/product/case/banner"},
{"name": "sort_order", "title": "排序", "type": "int", "nullable": "yes", "default": "0", "comments": "排序序号"},
{"name": "is_visible", "title": "是否可见", "type": "str", "length": 1, "nullable": "no", "default": "1", "comments": "1=显示,0=隐藏"},
{"name": "display_config", "title": "展示配置", "type": "text", "nullable": "yes", "comments": "展示配置JSON"},
{"name": "style_config", "title": "样式配置", "type": "text", "nullable": "yes", "comments": "样式配置JSON"},
{"name": "static_content", "title": "静态内容", "type": "text", "nullable": "yes", "comments": "静态HTML内容"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no", "comments": "创建时间"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no", "comments": "更新时间"}
],
"indexes": [
{"name": "idx_sections_org", "idxtype": "index", "idxfields": ["org_id"]},
{"name": "idx_sections_key", "idxtype": "unique", "idxfields": ["org_id", "section_key"]}
]
}

View File

@ -0,0 +1,23 @@
{
"summary": [
{
"name": "cms_site_config",
"title": "CMS站点配置表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "ID", "type": "str", "length": 32, "nullable": "no", "comments": "主键ID"},
{"name": "org_id", "title": "组织ID", "type": "str", "length": 32, "nullable": "no", "comments": "所属组织"},
{"name": "config_group", "title": "配置组", "type": "str", "length": 50, "nullable": "no", "comments": "配置分组: header/footer/contact/seo等"},
{"name": "config_key", "title": "配置键", "type": "str", "length": 100, "nullable": "no", "comments": "配置键名"},
{"name": "config_value", "title": "配置值", "type": "text", "nullable": "yes", "comments": "配置值"},
{"name": "config_type", "title": "值类型", "type": "str", "length": 20, "nullable": "yes", "default": "text", "comments": "text/number/json/bool/image"},
{"name": "sort_order", "title": "排序", "type": "int", "nullable": "yes", "default": "0", "comments": "排序序号"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no", "comments": "更新时间"}
],
"indexes": [
{"name": "idx_site_config_org", "idxtype": "index", "idxfields": ["org_id"]},
{"name": "idx_site_config_key", "idxtype": "unique", "idxfields": ["org_id", "config_group", "config_key"]}
]
}

View File

@ -0,0 +1,30 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
org_id = params_kw.get('org_id', '')
if not org_id:
org_id = (await get_userorgid()) or '0'
now = curDateString()
data = {
'id': getID(),
'org_id': org_id,
'name': params_kw.get('name', ''),
'parent_id': params_kw.get('parent_id', ''),
'content_type': params_kw.get('content_type', ''),
'description': params_kw.get('description', ''),
'sort_order': int(params_kw.get('sort_order', '0') or '0'),
'created_at': now,
'display_config': params_kw.get('display_config', ''),
}
if not data['name']:
return json.dumps({'status': 'error', 'message': '分类名称不能为空'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.I('cms_categories', data)
return json.dumps({'status': 'ok', 'id': data['id']}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,14 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.D('cms_categories', {'id': rec_id})
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,21 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
data = {'id': rec_id}
for field in ['name', 'parent_id', 'content_type', 'description', 'display_config']:
if field in params_kw:
data[field] = params_kw[field]
if 'sort_order' in params_kw:
data['sort_order'] = int(params_kw['sort_order'] or '0')
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.U('cms_categories', data)
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,39 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
org_id = params_kw.get('org_id', '')
if not org_id:
org_id = (await get_userorgid()) or '0'
now = curDateString()
data = {
'id': getID(),
'org_id': org_id,
'content_type': params_kw.get('content_type', ''),
'category_id': params_kw.get('category_id', ''),
'title': params_kw.get('title', ''),
'subtitle': params_kw.get('subtitle', ''),
'summary_text': params_kw.get('summary_text', ''),
'body': params_kw.get('body', ''),
'image_url': params_kw.get('image_url', ''),
'tags': params_kw.get('tags', ''),
'sort_order': int(params_kw.get('sort_order', '0') or '0'),
'status': params_kw.get('status', 'draft'),
'approval_id': params_kw.get('approval_id', ''),
'published_at': params_kw.get('published_at', '') or None,
'extra_json': params_kw.get('extra_json', ''),
'created_by': user_id,
'created_at': now,
'updated_at': now,
}
if not data['title']:
return json.dumps({'status': 'error', 'message': '标题不能为空'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.I('cms_content', data)
return json.dumps({'status': 'ok', 'id': data['id']}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,14 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.D('cms_content', {'id': rec_id})
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,24 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
data = {'id': rec_id, 'updated_at': curDateString()}
for field in ['content_type', 'category_id', 'title', 'subtitle', 'summary_text',
'body', 'image_url', 'tags', 'status', 'approval_id', 'extra_json']:
if field in params_kw:
data[field] = params_kw[field]
if 'sort_order' in params_kw:
data['sort_order'] = int(params_kw['sort_order'] or '0')
if 'published_at' in params_kw:
data['published_at'] = params_kw['published_at'] or None
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.U('cms_content', data)
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,35 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
org_id = params_kw.get('org_id', '')
if not org_id:
org_id = (await get_userorgid()) or '0'
now = curDateString()
data = {
'id': getID(),
'org_id': org_id,
'source': params_kw.get('source', 'website'),
'name': params_kw.get('name', ''),
'company': params_kw.get('company', ''),
'phone': params_kw.get('phone', ''),
'email': params_kw.get('email', ''),
'industry': params_kw.get('industry', ''),
'region': params_kw.get('region', ''),
'interest_products': params_kw.get('interest_products', ''),
'message': params_kw.get('message', ''),
'raw_text': params_kw.get('raw_text', ''),
'status': params_kw.get('status', 'new'),
'assigned_to': params_kw.get('assigned_to', ''),
'notes': params_kw.get('notes', ''),
'created_at': now,
'updated_at': now,
}
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.I('cms_leads', data)
return json.dumps({'status': 'ok', 'id': data['id']}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,14 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.D('cms_leads', {'id': rec_id})
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,20 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
data = {'id': rec_id, 'updated_at': curDateString()}
for field in ['source', 'name', 'company', 'phone', 'email', 'industry', 'region',
'interest_products', 'message', 'raw_text', 'status', 'assigned_to', 'notes']:
if field in params_kw:
data[field] = params_kw[field]
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.U('cms_leads', data)
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,35 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
org_id = params_kw.get('org_id', '')
if not org_id:
org_id = (await get_userorgid()) or '0'
now = curDateString()
data = {
'id': getID(),
'org_id': org_id,
'section_key': params_kw.get('section_key', ''),
'title': params_kw.get('title', ''),
'subtitle': params_kw.get('subtitle', ''),
'section_type': params_kw.get('section_type', ''),
'content_type': params_kw.get('content_type', ''),
'sort_order': int(params_kw.get('sort_order', '0') or '0'),
'is_visible': params_kw.get('is_visible', '1'),
'display_config': params_kw.get('display_config', ''),
'style_config': params_kw.get('style_config', ''),
'static_content': params_kw.get('static_content', ''),
'created_at': now,
'updated_at': now,
}
if not data['section_key'] or not data['title']:
return json.dumps({'status': 'error', 'message': '栏目Key和标题不能为空'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.I('cms_sections', data)
return json.dumps({'status': 'ok', 'id': data['id']}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,14 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.D('cms_sections', {'id': rec_id})
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,22 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
data = {'id': rec_id, 'updated_at': curDateString()}
for field in ['section_key', 'title', 'subtitle', 'section_type', 'content_type',
'is_visible', 'display_config', 'style_config', 'static_content']:
if field in params_kw:
data[field] = params_kw[field]
if 'sort_order' in params_kw:
data['sort_order'] = int(params_kw['sort_order'] or '0')
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.U('cms_sections', data)
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,29 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
org_id = params_kw.get('org_id', '')
if not org_id:
org_id = (await get_userorgid()) or '0'
now = curDateString()
data = {
'id': getID(),
'org_id': org_id,
'config_group': params_kw.get('config_group', ''),
'config_key': params_kw.get('config_key', ''),
'config_value': params_kw.get('config_value', ''),
'config_type': params_kw.get('config_type', 'text'),
'sort_order': int(params_kw.get('sort_order', '0') or '0'),
'updated_at': now,
}
if not data['config_group'] or not data['config_key']:
return json.dumps({'status': 'error', 'message': '配置组和配置键不能为空'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.I('cms_site_config', data)
return json.dumps({'status': 'ok', 'id': data['id']}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,14 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.D('cms_site_config', {'id': rec_id})
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,21 @@
user_id = await get_user()
if not user_id:
return json.dumps({'status': 'error', 'message': '未登录'}, ensure_ascii=False)
rec_id = params_kw.get('id', '')
if not rec_id:
return json.dumps({'status': 'error', 'message': '缺少id'}, ensure_ascii=False)
data = {'id': rec_id, 'updated_at': curDateString()}
for field in ['config_group', 'config_key', 'config_value', 'config_type']:
if field in params_kw:
data[field] = params_kw[field]
if 'sort_order' in params_kw:
data['sort_order'] = int(params_kw['sort_order'] or '0')
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
await sor.U('cms_site_config', data)
return json.dumps({'status': 'ok'}, ensure_ascii=False)
except Exception as e:
return json.dumps({'status': 'error', 'message': str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,9 @@
result = [{'value': '', 'text': '全部分类'}]
try:
async with get_sor_context(request._run_ns, 'ocai_cms') as sor:
rows = await sor.sqlExe("select id as value, name as text from cms_categories order by sort_order asc, created_at desc", {})
result = [{'value': '', 'text': '全部分类'}] + list(rows)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
debug(f'search categories error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,8 @@
result = [
{'value': '', 'text': '全部类型'},
{'value': 'news', 'text': '新闻'},
{'value': 'product', 'text': '产品'},
{'value': 'case', 'text': '案例'},
{'value': 'banner', 'text': 'Banner'}
]
return json.dumps(result, ensure_ascii=False)

View File

@ -1039,5 +1039,23 @@ a { color: inherit; text-decoration: none; }
transform: translateY(0);
}
/* === Global bricks widget background reset for dark theme === */
.site-root .bricks-vbox,
.site-root .bricks-hbox,
.site-root .bricks-text,
.site-root .bricks-title2,
.site-root .bricks-title3,
.site-root .bricks-title4,
.site-root .bricks-filler,
.site-root .bricks-html {
background: transparent;
color: inherit;
}
.site-root .bricks-button {
background: transparent;
color: var(--text-primary);
}
/* Clickable utility */
.clickable { cursor: pointer; }

View File

@ -8,6 +8,13 @@
"label": "个人信息",
"submenu": "{{entire_url('/rbac/user/userinfo.ui')}}"
},
{% if has_permission('/admin.ui') %}
{
"name": "admin",
"label": "管理后台",
"submenu": "{{entire_url('/admin.ui')}}"
},
{% endif %}
{
"name": "logout",
"label": "退出登录",