feat: 开元云科技官网CMS系统初始版本

entcms模块:
- 4个数据表(cms_content/cms_categories/cms_leads/cms_site_config)
- 22个.dspy API(含公开API和data_filter)
- 4个公开页面(首页/新闻/案例)+管理后台
- 完整营销站点CSS/JS(暗色主题/渐变/动画/响应式)
- 云宝SVG线稿占位符
- RBAC权限配置

dingdingflow模块:
- 2个数据表(dd_approvals/dd_approval_configs)
- 10个.dspy API(含钉钉回调endpoint)
- 钉钉API客户端(环境变量配置,开发模式mock)
- 管理UI

文档: 架构设计/53条测试用例/开发日志
This commit is contained in:
yumoqing 2026-05-27 15:44:26 +08:00
commit 5cfb0e867b
72 changed files with 5118 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.pyc
__pycache__/
mysql.ddl.sql

37
README.md Normal file
View File

@ -0,0 +1,37 @@
# 开元云科技 - 企业官网CMS系统
企业官网内容管理系统 + 钉钉审批流程基于Sage/bricks-framework开发。
## 模块
| 模块 | 说明 |
|------|------|
| **entcms** | 企业CMS - 新闻/案例/产品/Banner/线索管理 |
| **dingdingflow** | 钉钉审批流程 - 内容发布审批工作流 |
## 快速开始
```bash
# 1. 构建并安装
cd ~/repos/cms && ./build.sh
# 2. 配置RBAC权限
cd ~/repos/sage
./py3/bin/python ~/repos/cms/entcms/scripts/load_path.py
./py3/bin/python ~/repos/cms/dingdingflow/scripts/load_path.py
# 3. 重启Sage
./stop.sh && ./start.sh
```
## 文档
- [系统架构](docs/architecture.md)
- [测试用例](docs/test-cases.md)
- [开发日志](docs/)
## 环境变量 (dingdingflow)
```
DINGTALK_APP_KEY=xxx
DINGTALK_APP_SECRET=xxx
DINGTALK_AGENT_ID=xxx
```

87
build.sh Executable file
View File

@ -0,0 +1,87 @@
#!/bin/bash
# CMS项目构建脚本
# 构建 entcms + dingdingflow 模块并集成到Sage系统
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== CMS项目构建 ==="
echo "Script dir: $SCRIPT_DIR"
# 查找Sage根目录
SAGE_ROOT=""
for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do
if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then
SAGE_ROOT="$(cd "$candidate" && pwd)"
break
fi
done
if [ -z "$SAGE_ROOT" ]; then
echo "ERROR: 找不到Sage根目录"
exit 1
fi
echo "Sage root: $SAGE_ROOT"
PY="$SAGE_ROOT/py3/bin/python"
PIP="$SAGE_ROOT/py3/bin/pip"
# 安装模块
for mod in entcms dingdingflow; do
echo ""
echo "=== 安装 $mod ==="
cd "$SCRIPT_DIR/$mod"
$PIP install -e .
# 生成DDL
if [ -d "models" ] && [ "$(ls models/*.json 2>/dev/null)" ]; then
echo "生成DDL..."
cd models
$PY -c "from sqlor.ddl_template_mysql import DDLTemplate; print(DDLTemplate().generate('.'))" > ../mysql.ddl.sql 2>/dev/null || json2ddl mysql . > ../mysql.ddl.sql 2>/dev/null || echo "DDL generation skipped (json2ddl not available)"
cd ..
fi
# 生成CRUD UI
if [ -d "json" ] && [ "$(ls json/*.json 2>/dev/null)" ]; then
echo "生成CRUD UI..."
cd json
xls2ui -m ../models -o ../wwwroot $mod *.json 2>/dev/null || echo "CRUD UI generation skipped (xls2ui not available)"
cd ..
fi
# 链接wwwroot到Sage
MODULE_WWWROOT="$SAGE_ROOT/wwwroot/$mod"
mkdir -p "$MODULE_WWWROOT/api"
# 链接UI/CSS/JS文件
for f in "$SCRIPT_DIR/$mod/wwwroot"/*.ui "$SCRIPT_DIR/$mod/wwwroot"/*.css "$SCRIPT_DIR/$mod/wwwroot"/*.js; do
[ -f "$f" ] || continue
fname=$(basename "$f")
ln -sf "$f" "$MODULE_WWWROOT/$fname"
done
# 链接api目录下的.dspy文件
for f in "$SCRIPT_DIR/$mod/wwwroot/api"/*.dspy; do
[ -f "$f" ] || continue
fname=$(basename "$f")
ln -sf "$f" "$MODULE_WWWROOT/api/$fname"
done
# 链接生成的CRUD目录
for d in "$SCRIPT_DIR/$mod/wwwroot"/*/; do
[ -d "$d" ] || continue
dname=$(basename "$d")
case "$dname" in api|styles|scripts) continue ;; esac
ln -sf "$d" "$MODULE_WWWROOT/$dname"
done
echo "$mod 安装完成"
done
echo ""
echo "=== 构建完成 ==="
echo "请执行以下步骤完成集成:"
echo "1. 编辑 $SAGE_ROOT/app/sage.py 添加模块导入"
echo "2. 编辑 $SAGE_ROOT/build.sh 添加模块到安装循环"
echo "3. 执行 RBAC 权限配置"
echo "4. 重启Sage服务"

20
dingdingflow/README.md Normal file
View File

@ -0,0 +1,20 @@
# dingdingflow - 钉钉审批流程
为CMS内容发布提供钉钉审批工作流。
## 数据表
- dd_approvals: 审批记录
- dd_approval_configs: 审批流程配置
## 环境变量
```
DINGTALK_APP_KEY=钉钉应用AppKey
DINGTALK_APP_SECRET=钉钉应用AppSecret
DINGTALK_AGENT_ID=钉钉应用AgentId
```
未配置时自动进入开发模式(mock响应)。
## API
- POST /dingdingflow/api/submit_approval.dspy - 提交审批
- POST /dingdingflow/api/dingtalk_callback.dspy - 钉钉回调(公开)

View File

@ -0,0 +1 @@
# dingdingflow module

View File

@ -0,0 +1,240 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DingTalk API Client for approval workflow integration.
Reads credentials from environment variables (DINGTALK_APP_KEY, DINGTALK_APP_SECRET, DINGTALK_AGENT_ID).
Gracefully handles missing credentials by returning mock responses in dev mode.
"""
import os
import json
import logging
import time
import urllib.request
import urllib.error
logger = logging.getLogger(__name__)
class DingTalkClient:
"""Client for DingTalk Open API - approval workflow operations."""
BASE_URL = "https://oapi.dingtalk.com"
def __init__(self):
self.app_key = os.environ.get("DINGTALK_APP_KEY", "")
self.app_secret = os.environ.get("DINGTALK_APP_SECRET", "")
self.agent_id = os.environ.get("DINGTALK_AGENT_ID", "")
self.callback_token = os.environ.get("DINGTALK_CALLBACK_TOKEN", "")
self._access_token = None
self._token_expires_at = 0
if not self.app_key or not self.app_secret:
logger.warning(
"DingTalk credentials not configured (DINGTALK_APP_KEY / DINGTALK_APP_SECRET). "
"Running in dev/mock mode - API calls will return mock responses."
)
@property
def is_dev_mode(self):
"""Return True if credentials are missing (dev/mock mode)."""
return not self.app_key or not self.app_secret
def _http_post(self, url, data=None, params=None):
"""Make HTTP POST request and return parsed JSON response."""
if params:
query = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{url}?{query}"
body = json.dumps(data).encode("utf-8") if data else b""
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_data = resp.read().decode("utf-8")
return json.loads(resp_data)
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
logger.error("DingTalk API HTTP error %s: %s", e.code, error_body)
return {"errcode": e.code, "errmsg": f"HTTP {e.code}: {error_body}"}
except urllib.error.URLError as e:
logger.error("DingTalk API connection error: %s", str(e))
return {"errcode": -1, "errmsg": f"Connection error: {str(e)}"}
except Exception as e:
logger.error("DingTalk API unexpected error: %s", str(e))
return {"errcode": -1, "errmsg": str(e)}
def _http_get(self, url, params=None):
"""Make HTTP GET request and return parsed JSON response."""
if params:
query = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{url}?{query}"
req = urllib.request.Request(url, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_data = resp.read().decode("utf-8")
return json.loads(resp_data)
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
logger.error("DingTalk API HTTP error %s: %s", e.code, error_body)
return {"errcode": e.code, "errmsg": f"HTTP {e.code}: {error_body}"}
except Exception as e:
logger.error("DingTalk API unexpected error: %s", str(e))
return {"errcode": -1, "errmsg": str(e)}
def get_access_token(self):
"""
Get DingTalk access token. Caches token until expiry.
Returns token string or empty string on failure.
"""
if self.is_dev_mode:
logger.info("Dev mode: returning mock access token")
return "mock_access_token_dev"
# Return cached token if still valid (with 5-min buffer)
now = time.time()
if self._access_token and now < self._token_expires_at - 300:
return self._access_token
url = f"{self.BASE_URL}/gettoken"
params = {"appkey": self.app_key, "appsecret": self.app_secret}
result = self._http_get(url, params=params)
if result.get("errcode") == 0:
self._access_token = result.get("access_token", "")
expires_in = result.get("expires_in", 7200)
self._token_expires_at = now + expires_in
logger.info("DingTalk access token obtained, expires in %ss", expires_in)
return self._access_token
else:
logger.error(
"Failed to get DingTalk access token: %s",
result.get("errmsg", "unknown error"),
)
return ""
def create_approval_instance(self, process_code, form_data, originator_user_id):
"""
Create a DingTalk approval instance.
Args:
process_code: DingTalk approval template code (from dd_approval_configs)
form_data: List of form component values, e.g.:
[{"name": "审批类型", "value": "内容发布"}, ...]
originator_user_id: DingTalk user ID of the applicant
Returns:
dict with keys:
- success (bool)
- instance_id (str) - DingTalk process instance ID
- errmsg (str) - error message if failed
"""
if self.is_dev_mode:
mock_instance_id = f"mock_instance_{int(time.time())}"
logger.info(
"Dev mode: mock approval instance created: %s (process_code=%s)",
mock_instance_id,
process_code,
)
return {
"success": True,
"instance_id": mock_instance_id,
"errmsg": "",
}
token = self.get_access_token()
if not token:
return {"success": False, "instance_id": "", "errmsg": "Failed to get access token"}
url = f"{self.BASE_URL}/topapi/processinstance/create"
payload = {
"agent_id": int(self.agent_id) if self.agent_id else 0,
"process_code": process_code,
"originator_user_id": originator_user_id,
"dept_id": -1,
"form_component_values": form_data,
}
result = self._http_post(url, data=payload, params={"access_token": token})
if result.get("errcode") == 0:
instance_id = result.get("process_instance_id", "")
logger.info("DingTalk approval instance created: %s", instance_id)
return {"success": True, "instance_id": instance_id, "errmsg": ""}
else:
errmsg = result.get("errmsg", "unknown error")
logger.error("Failed to create DingTalk approval: %s", errmsg)
return {"success": False, "instance_id": "", "errmsg": errmsg}
def get_approval_instance(self, instance_id):
"""
Get DingTalk approval instance details.
Args:
instance_id: DingTalk process instance ID
Returns:
dict with keys:
- success (bool)
- status (str) - NEW/RUNNING/COMPLETED/TERMINATED
- result (str) - agree/refuse (only when status=COMPLETED)
- data (dict) - full instance data
- errmsg (str) - error message if failed
"""
if self.is_dev_mode:
logger.info("Dev mode: mock get approval instance: %s", instance_id)
return {
"success": True,
"status": "COMPLETED",
"result": "agree",
"data": {
"process_instance_id": instance_id,
"status": "COMPLETED",
"result": "agree",
},
"errmsg": "",
}
token = self.get_access_token()
if not token:
return {"success": False, "status": "", "result": "", "data": {}, "errmsg": "Failed to get access token"}
url = f"{self.BASE_URL}/topapi/processinstance/get"
payload = {"process_instance_id": instance_id}
result = self._http_post(url, data=payload, params={"access_token": token})
if result.get("errcode") == 0:
pi = result.get("process_instance", {})
status = pi.get("status", "")
pi_result = pi.get("result", "")
return {
"success": True,
"status": status,
"result": pi_result,
"data": pi,
"errmsg": "",
}
else:
errmsg = result.get("errmsg", "unknown error")
logger.error("Failed to get DingTalk approval instance: %s", errmsg)
return {"success": False, "status": "", "result": "", "data": {}, "errmsg": errmsg}
# Module-level singleton
_client_instance = None
def get_dingtalk_client():
"""Get or create the DingTalkClient singleton."""
global _client_instance
if _client_instance is None:
_client_instance = DingTalkClient()
return _client_instance

View File

@ -0,0 +1,432 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
dingdingflow module initialization.
Registers all module functions with ServerEnv for use in .ui and .dspy files.
"""
import json
import logging
import datetime
from ahserver.serverenv import ServerEnv
from appPublic.uniqueID import getID
from dingdingflow.dingtalk_client import get_dingtalk_client
logger = logging.getLogger(__name__)
MODULE_NAME = "dingdingflow"
MODULE_VERSION = "1.0.0"
def _get_dbname():
"""Get the database name for this module."""
env = ServerEnv()
return env.get_module_dbname(MODULE_NAME)
# ─── CRUD: dd_approvals ───────────────────────────────────────────────────────
async def create_dd_approval(data):
"""Create a new approval record."""
new_id = getID()
data["id"] = new_id
if "org_id" not in data:
data["org_id"] = "0"
if "status" not in data:
data["status"] = "pending"
if "created_at" not in data:
data["created_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.C("dd_approvals", data)
return {"id": new_id}
async def update_dd_approval(data):
"""Update an existing approval record."""
record_id = data.get("id")
if not record_id:
raise ValueError("id is required for update")
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.U("dd_approvals", {"id": record_id}, data)
return {"id": record_id}
async def delete_dd_approval(record_id):
"""Delete an approval record by ID."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.D("dd_approvals", {"id": record_id})
return True
async def get_dd_approval(record_id):
"""Get a single approval record by ID."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
rows = await sor.R("dd_approvals", {"id": record_id})
if rows:
return rows[0]
return None
async def list_dd_approvals(filters=None, page=1, rows=20, sort="created_at desc"):
"""List approval records with optional filters."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
query_filters = filters or {}
ns = {"page": page, "rows": rows, "sort": sort}
ns.update(query_filters)
result = await sor.R("dd_approvals", query_filters, page=page, rows=rows, sort=sort)
return result
# ─── CRUD: dd_approval_configs ────────────────────────────────────────────────
async def create_dd_approval_config(data):
"""Create a new approval config record."""
new_id = getID()
data["id"] = new_id
if "org_id" not in data:
data["org_id"] = "0"
if "is_active" not in data:
data["is_active"] = "1"
if "created_at" not in data:
data["created_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
data["updated_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.C("dd_approval_configs", data)
return {"id": new_id}
async def update_dd_approval_config(data):
"""Update an existing approval config record."""
record_id = data.get("id")
if not record_id:
raise ValueError("id is required for update")
data["updated_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.U("dd_approval_configs", {"id": record_id}, data)
return {"id": record_id}
async def delete_dd_approval_config(record_id):
"""Delete an approval config record by ID."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.D("dd_approval_configs", {"id": record_id})
return True
async def get_dd_approval_config(record_id):
"""Get a single approval config record by ID."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
rows = await sor.R("dd_approval_configs", {"id": record_id})
if rows:
return rows[0]
return None
async def get_approval_config_by_type(org_id, biz_type):
"""Get approval config by org_id and biz_type (unique constraint)."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
rows = await sor.R("dd_approval_configs", {"org_id": org_id, "biz_type": biz_type})
if rows:
return rows[0]
return None
# ─── Business Logic: Approval Workflow ────────────────────────────────────────
async def submit_approval(biz_type, biz_id, title, applicant_id, org_id="0"):
"""
Submit a new approval request.
1. Look up the approval config for this biz_type
2. Create a dd_approvals record
3. Call DingTalk API to create the approval instance
4. Store the DingTalk instance_id back in the record
Returns: dict with approval record details
"""
client = get_dingtalk_client()
# Look up config
config = await get_approval_config_by_type(org_id, biz_type)
if not config:
logger.error("No approval config found for org_id=%s, biz_type=%s", org_id, biz_type)
return {"success": False, "message": f"No approval config found for biz_type={biz_type}"}
process_code = getattr(config, "process_code", "") or ""
agent_id = getattr(config, "agent_id", "") or ""
form_config_raw = getattr(config, "form_config", "") or ""
# Build form data from form_config
form_data = []
if form_config_raw:
try:
form_config = json.loads(form_config_raw) if isinstance(form_config_raw, str) else form_config_raw
if isinstance(form_config, list):
form_data = form_config
except (json.JSONDecodeError, TypeError) as e:
logger.warning("Failed to parse form_config: %s", str(e))
# If no form_data, create minimal form with title
if not form_data:
form_data = [
{"name": "审批标题", "value": title},
{"name": "业务类型", "value": biz_type},
]
# Call DingTalk API
result = client.create_approval_instance(process_code, form_data, applicant_id)
if not result["success"]:
# Still create the record with failed status
approval_data = {
"biz_type": biz_type,
"biz_id": biz_id,
"title": title,
"applicant_id": applicant_id,
"org_id": org_id,
"status": "pending",
"dingtalk_instance_id": "",
"comment": f"DingTalk API error: {result.get('errmsg', '')}",
}
approval = await create_dd_approval(approval_data)
return {
"success": False,
"message": f"DingTalk API failed: {result.get('errmsg', '')}",
"approval_id": approval["id"],
}
# Create approval record with instance_id
approval_data = {
"biz_type": biz_type,
"biz_id": biz_id,
"title": title,
"applicant_id": applicant_id,
"org_id": org_id,
"status": "pending",
"dingtalk_instance_id": result["instance_id"],
}
approval = await create_dd_approval(approval_data)
logger.info(
"Approval submitted: id=%s, instance=%s, biz=%s/%s",
approval["id"],
result["instance_id"],
biz_type,
biz_id,
)
return {
"success": True,
"message": "Approval submitted successfully",
"approval_id": approval["id"],
"instance_id": result["instance_id"],
}
async def get_approval_status(approval_id):
"""
Query DingTalk for the latest approval status and sync to local DB.
Returns: dict with current status info
"""
# Get local record
record = await get_dd_approval(approval_id)
if not record:
return {"success": False, "message": "Approval record not found"}
instance_id = getattr(record, "dingtalk_instance_id", "")
current_status = getattr(record, "status", "")
# If already completed, no need to check DingTalk
if current_status in ("approved", "rejected", "cancelled"):
return {
"success": True,
"status": current_status,
"approval_id": approval_id,
"instance_id": instance_id,
}
if not instance_id:
return {
"success": True,
"status": current_status,
"approval_id": approval_id,
"instance_id": "",
"message": "No DingTalk instance ID, cannot sync",
}
# Query DingTalk
client = get_dingtalk_client()
dt_result = client.get_approval_instance(instance_id)
if not dt_result["success"]:
return {
"success": False,
"message": f"DingTalk query failed: {dt_result.get('errmsg', '')}",
"status": current_status,
}
# Map DingTalk status to local status
dt_status = dt_result.get("status", "")
dt_result_val = dt_result.get("result", "")
new_status = current_status
if dt_status == "COMPLETED":
if dt_result_val == "agree":
new_status = "approved"
elif dt_result_val == "refuse":
new_status = "rejected"
elif dt_status == "TERMINATED":
new_status = "cancelled"
# Update local record if status changed
if new_status != current_status:
update_data = {"status": new_status}
if new_status in ("approved", "rejected", "cancelled"):
update_data["completed_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
await update_dd_approval({"id": approval_id, **update_data})
logger.info("Approval %s status synced: %s -> %s", approval_id, current_status, new_status)
return {
"success": True,
"status": new_status,
"approval_id": approval_id,
"instance_id": instance_id,
}
async def handle_dingtalk_callback(data):
"""
Process DingTalk webhook callback.
DingTalk sends callbacks when approval status changes.
Expected data format:
{
"processInstanceId": "xxx",
"processCode": "xxx",
"type": "bpms_instance_change",
"result": "agree" / "refuse",
"staffId": "xxx",
...
}
"""
logger.info("DingTalk callback received: %s", json.dumps(data, ensure_ascii=False))
instance_id = data.get("processInstanceId", "")
if not instance_id:
return {"success": False, "message": "Missing processInstanceId"}
callback_type = data.get("type", "")
if callback_type != "bpms_instance_change":
logger.info("Ignoring callback type: %s", callback_type)
return {"success": True, "message": f"Ignored callback type: {callback_type}"}
# Find local approval record by DingTalk instance ID
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
rows = await sor.R("dd_approvals", {"dingtalk_instance_id": instance_id})
if not rows:
logger.warning("No local approval found for instance_id=%s", instance_id)
return {"success": False, "message": f"No approval found for instance {instance_id}"}
record = rows[0]
record_id = getattr(record, "id", "")
current_status = getattr(record, "status", "")
# Map callback to status
dt_result = data.get("result", "")
new_status = current_status
if dt_result == "agree":
new_status = "approved"
elif dt_result == "refuse":
new_status = "rejected"
elif callback_type == "terminate":
new_status = "cancelled"
# Update record
if new_status != current_status:
update_data = {
"id": record_id,
"status": new_status,
"comment": data.get("remark", ""),
}
if new_status in ("approved", "rejected", "cancelled"):
update_data["completed_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
await update_dd_approval(update_data)
logger.info("Callback: approval %s updated to %s", record_id, new_status)
# TODO: Notify entcms module about status change
# This would trigger content status update in the CMS
biz_type = getattr(record, "biz_type", "")
biz_id = getattr(record, "biz_id", "")
logger.info(
"Callback: should notify entcms: biz_type=%s, biz_id=%s, status=%s",
biz_type, biz_id, new_status
)
return {
"success": True,
"message": f"Approval {record_id} updated to {new_status}",
"approval_id": record_id,
"status": new_status,
}
# ─── Module Loader ─────────────────────────────────────────────────────────────
def load_dingdingflow():
"""Register all dingdingflow functions with ServerEnv."""
env = ServerEnv()
# CRUD functions for dd_approvals
env.create_dd_approval = create_dd_approval
env.update_dd_approval = update_dd_approval
env.delete_dd_approval = delete_dd_approval
env.get_dd_approval = get_dd_approval
env.list_dd_approvals = list_dd_approvals
# CRUD functions for dd_approval_configs
env.create_dd_approval_config = create_dd_approval_config
env.update_dd_approval_config = update_dd_approval_config
env.delete_dd_approval_config = delete_dd_approval_config
env.get_dd_approval_config = get_dd_approval_config
env.get_approval_config_by_type = get_approval_config_by_type
# Business logic functions
env.submit_approval = submit_approval
env.get_approval_status = get_approval_status
env.handle_dingtalk_callback = handle_dingtalk_callback
# DingTalk client accessor
env.get_dingtalk_client = get_dingtalk_client
logger.info("dingdingflow module loaded (v%s)", MODULE_VERSION)
return True

View File

@ -0,0 +1,25 @@
{
"tblname": "dd_approval_configs",
"alias": "dd_approval_configs",
"title": "审批流程配置",
"params": {
"sortby": ["biz_type"],
"browserfields": {
"exclouded": ["id", "form_config"],
"alters": {
"is_active": {
"uitype": "code",
"data": [
{"value": "1", "text": "启用"},
{"value": "0", "text": "停用"}
]
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/dd_approval_configs_create.dspy')}}",
"update_data_url": "{{entire_url('../api/dd_approval_configs_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/dd_approval_configs_delete.dspy')}}"
}
}
}

View File

@ -0,0 +1,42 @@
{
"tblname": "dd_approvals",
"alias": "dd_approvals",
"title": "审批记录",
"params": {
"sortby": ["created_at desc"],
"data_filter": {
"AND": [
{"field": "status", "op": "=", "var": "status_filter"},
{"field": "biz_type", "op": "=", "var": "biz_type_filter"},
{"field": "title", "op": "LIKE", "var": "title_filter"}
]
},
"browserfields": {
"exclouded": ["id"],
"alters": {
"status": {
"uitype": "code",
"data": [
{"value": "pending", "text": "待审批"},
{"value": "approved", "text": "已通过"},
{"value": "rejected", "text": "已拒绝"},
{"value": "cancelled", "text": "已取消"}
]
},
"biz_type": {
"uitype": "code",
"data": [
{"value": "content_publish", "text": "内容发布"},
{"value": "content_update", "text": "内容修改"},
{"value": "content_delete", "text": "内容删除"}
]
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/dd_approvals_create.dspy')}}",
"update_data_url": "{{entire_url('../api/dd_approvals_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/dd_approvals_delete.dspy')}}"
}
}
}

View File

@ -0,0 +1,85 @@
{
"summary": [
{
"name": "dd_approval_configs",
"title": "审批流程配置表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "biz_type",
"title": "业务类型",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "biz_type_title",
"title": "业务类型名称",
"type": "str",
"length": 100
},
{
"name": "process_code",
"title": "钉钉审批模板编码",
"type": "str",
"length": 100
},
{
"name": "agent_id",
"title": "钉钉应用AgentId",
"type": "str",
"length": 100
},
{
"name": "form_config",
"title": "表单字段配置JSON",
"type": "text"
},
{
"name": "is_active",
"title": "是否启用(1/0)",
"type": "str",
"length": 1,
"default": "1"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_apvcfg_org_type",
"idxtype": "unique",
"idxfields": [
"org_id",
"biz_type"
]
}
],
"codes": []
}

View File

@ -0,0 +1,114 @@
{
"summary": [
{
"name": "dd_approvals",
"title": "审批记录表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "biz_type",
"title": "业务类型(content_publish等)",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "biz_id",
"title": "业务数据ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "title",
"title": "审批标题",
"type": "str",
"length": 255,
"nullable": "no"
},
{
"name": "applicant_id",
"title": "申请人ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "approver_id",
"title": "审批人ID",
"type": "str",
"length": 32
},
{
"name": "dingtalk_instance_id",
"title": "钉钉审批实例ID",
"type": "str",
"length": 100
},
{
"name": "status",
"title": "状态(pending/approved/rejected/cancelled)",
"type": "str",
"length": 32,
"default": "pending"
},
{
"name": "comment",
"title": "审批意见",
"type": "text"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp"
},
{
"name": "completed_at",
"title": "完成时间",
"type": "datetime"
}
],
"indexes": [
{
"name": "idx_approval_biz",
"idxtype": "index",
"idxfields": [
"biz_type",
"biz_id"
]
},
{
"name": "idx_approval_status",
"idxtype": "index",
"idxfields": [
"org_id",
"status"
]
},
{
"name": "idx_approval_applicant",
"idxtype": "index",
"idxfields": [
"applicant_id"
]
}
]
}

View File

@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "dingdingflow"
version = "1.0.0"
description = "钉钉审批流程模块 - 内容发布审批工作流"
requires-python = ">=3.8"
dependencies = [
"sqlor",
"bricks_for_python",
"requests",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["dingdingflow*"]

View File

@ -0,0 +1,60 @@
"""
dingdingflow RBAC权限配置
用法: cd ~/repos/sage && ./py3/bin/python ~/repos/cms/dingdingflow/scripts/load_path.py
"""
import os
import sys
import subprocess
def find_sage_root():
for candidate in [
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
]:
if os.path.isdir(os.path.join(candidate, "wwwroot")) and \
os.path.isdir(os.path.join(candidate, "py3")):
return candidate
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 = os.path.join(sage_root, "set_role_perm.py")
paths_any = [
# 钉钉回调是公开endpoint
"/dingdingflow/api/dingtalk_callback.dspy",
"/dingdingflow/menu.ui",
]
paths_logined = [
"/dingdingflow",
"/dingdingflow/index.ui",
"/dingdingflow/dd_approvals",
"/dingdingflow/dd_approvals/%",
"/dingdingflow/dd_approval_configs",
"/dingdingflow/dd_approval_configs/%",
"/dingdingflow/api/dd_approvals_create.dspy",
"/dingdingflow/api/dd_approvals_update.dspy",
"/dingdingflow/api/dd_approvals_delete.dspy",
"/dingdingflow/api/dd_approvals_list.dspy",
"/dingdingflow/api/dd_approval_configs_create.dspy",
"/dingdingflow/api/dd_approval_configs_update.dspy",
"/dingdingflow/api/dd_approval_configs_delete.dspy",
"/dingdingflow/api/dd_approval_configs_list.dspy",
"/dingdingflow/api/submit_approval.dspy",
]
def set_perms(role, paths):
for path in paths:
cmd = [python, set_perm, role, path]
print(f" {role:20s} {path}")
subprocess.run(cmd, cwd=sage_root, capture_output=True)
print("=== dingdingflow RBAC权限配置 ===")
set_perms("any", paths_any)
set_perms("logined", paths_logined)
print("完成")

View File

@ -0,0 +1,41 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dd_approval_configs create API"""
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
biz_type = params_kw.get('biz_type', '')
biz_type_title = params_kw.get('biz_type_title', '')
process_code = params_kw.get('process_code', '')
if not biz_type:
result['options'] = {'title': 'Error', 'message': 'biz_type is required', 'type': 'error'}
else:
new_id = uuid()
org_id = (await get_userorgid()) or '0'
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe(
"INSERT INTO dd_approval_configs (id, org_id, biz_type, biz_type_title, process_code, agent_id, form_config, is_active, created_at, updated_at) VALUES (${id}$, ${org_id}$, ${biz_type}$, ${biz_type_title}$, ${process_code}$, ${agent_id}$, ${form_config}$, ${is_active}$, ${created_at}$, ${updated_at}$)",
{
'id': new_id,
'org_id': org_id,
'biz_type': biz_type,
'biz_type_title': biz_type_title,
'process_code': process_code,
'agent_id': params_kw.get('agent_id', ''),
'form_config': params_kw.get('form_config', ''),
'is_active': params_kw.get('is_active', '1'),
'created_at': now_str,
'updated_at': now_str
}
)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批配置创建成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'创建失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dd_approval_configs delete API"""
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '')
if not record_id:
result['options'] = {'title': 'Error', 'message': 'ID is required', 'type': 'error'}
else:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe("DELETE FROM dd_approval_configs WHERE id=${id}$", {'id': record_id})
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批配置删除成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,58 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dd_approval_configs list API"""
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
where_clauses = []
where_ns = {}
# Optional filtering
is_active = params_kw.get('is_active', '')
if is_active:
where_clauses.append("is_active=${is_active}$")
where_ns['is_active'] = is_active
biz_type = params_kw.get('biz_type', '')
if biz_type:
where_clauses.append("biz_type=${biz_type}$")
where_ns['biz_type'] = biz_type
where_sql = " AND ".join(where_clauses)
where_prefix = " WHERE " if where_clauses else ""
# Count query
count_sql = "SELECT count(*) rcnt FROM dd_approval_configs" + where_prefix + where_sql
count_rows = await sor.sqlExe(count_sql, where_ns)
total = 0
if count_rows and len(count_rows) > 0:
r = count_rows[0]
total = getattr(r, 'rcnt', 0)
if total > 0:
ns = {
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 20)),
'sort': params_kw.get('sort', 'biz_type')
}
sql = "SELECT id, org_id, biz_type, biz_type_title, process_code, agent_id, form_config, is_active, created_at, updated_at FROM dd_approval_configs" + where_prefix + where_sql
query_ns = dict(list(ns.items()) + list(where_ns.items()))
rows = await sor.sqlExe(sql, query_ns)
if isinstance(rows, dict):
result['rows'] = rows.get('rows', [])
result['total'] = rows.get('total', total)
elif rows:
result['rows'] = [dict(r) if hasattr(r, 'keys') else r for r in rows]
result['total'] = total
else:
result['total'] = 0
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dd_approval_configs update API"""
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '')
if not record_id:
result['options'] = {'title': 'Error', 'message': 'ID is required', 'type': 'error'}
else:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
update_fields = []
update_ns = {'id': record_id}
for field in ['biz_type', 'biz_type_title', 'process_code', 'agent_id', 'form_config', 'is_active']:
val = params_kw.get(field)
if val is not None:
update_fields.append(f"{field}=${field}$")
update_ns[field] = val
# Always update updated_at
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
update_fields.append("updated_at=${updated_at}$")
update_ns['updated_at'] = now_str
if update_fields:
set_clause = ", ".join(update_fields)
await sor.sqlExe(f"UPDATE dd_approval_configs SET {set_clause} WHERE id=${id}$", update_ns)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批配置更新成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'更新失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,43 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dd_approvals create API"""
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
biz_type = params_kw.get('biz_type', '')
biz_id = params_kw.get('biz_id', '')
title = params_kw.get('title', '')
applicant_id = params_kw.get('applicant_id', '')
if not biz_type or not title:
result['options'] = {'title': 'Error', 'message': 'biz_type and title are required', 'type': 'error'}
else:
new_id = uuid()
org_id = (await get_userorgid()) or '0'
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe(
"INSERT INTO dd_approvals (id, org_id, biz_type, biz_id, title, applicant_id, approver_id, dingtalk_instance_id, status, comment, created_at) VALUES (${id}$, ${org_id}$, ${biz_type}$, ${biz_id}$, ${title}$, ${applicant_id}$, ${approver_id}$, ${dingtalk_instance_id}$, ${status}$, ${comment}$, ${created_at}$)",
{
'id': new_id,
'org_id': org_id,
'biz_type': biz_type,
'biz_id': biz_id,
'title': title,
'applicant_id': applicant_id,
'approver_id': params_kw.get('approver_id', ''),
'dingtalk_instance_id': params_kw.get('dingtalk_instance_id', ''),
'status': params_kw.get('status', 'pending'),
'comment': params_kw.get('comment', ''),
'created_at': now_str
}
)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批记录创建成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'创建失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dd_approvals delete API"""
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '')
if not record_id:
result['options'] = {'title': 'Error', 'message': 'ID is required', 'type': 'error'}
else:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe("DELETE FROM dd_approvals WHERE id=${id}$", {'id': record_id})
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批记录删除成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dd_approvals list API with data_filter support"""
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
# Build WHERE clause from data_filter or direct params
where_clauses = []
where_ns = {}
# Support data_filter from CRUD search popup
data_filter_str = params_kw.get('data_filter', '')
if data_filter_str:
# Individual filter values passed alongside data_filter
status_filter = params_kw.get('status_filter', '')
biz_type_filter = params_kw.get('biz_type_filter', '')
title_filter = params_kw.get('title_filter', '')
if status_filter:
where_clauses.append("status=${status_filter}$")
where_ns['status_filter'] = status_filter
if biz_type_filter:
where_clauses.append("biz_type=${biz_type_filter}$")
where_ns['biz_type_filter'] = biz_type_filter
if title_filter:
where_clauses.append("title LIKE ${title_filter}$")
where_ns['title_filter'] = f'%{title_filter}%'
else:
# Direct param filtering
status = params_kw.get('status', '')
if status:
where_clauses.append("status=${status}$")
where_ns['status'] = status
biz_type = params_kw.get('biz_type', '')
if biz_type:
where_clauses.append("biz_type=${biz_type}$")
where_ns['biz_type'] = biz_type
where_sql = " AND ".join(where_clauses)
where_prefix = " WHERE " if where_clauses else ""
# Count query
count_sql = "SELECT count(*) rcnt FROM dd_approvals" + where_prefix + where_sql
count_rows = await sor.sqlExe(count_sql, where_ns)
total = 0
if count_rows and len(count_rows) > 0:
r = count_rows[0]
total = getattr(r, 'rcnt', 0)
if total > 0:
ns = {
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 20)),
'sort': params_kw.get('sort', 'created_at desc')
}
sql = "SELECT id, org_id, biz_type, biz_id, title, applicant_id, approver_id, dingtalk_instance_id, status, comment, created_at, completed_at FROM dd_approvals" + where_prefix + where_sql
query_ns = dict(list(ns.items()) + list(where_ns.items()))
rows = await sor.sqlExe(sql, query_ns)
if isinstance(rows, dict):
result['rows'] = rows.get('rows', [])
result['total'] = rows.get('total', total)
elif rows:
result['rows'] = [dict(r) if hasattr(r, 'keys') else r for r in rows]
result['total'] = total
else:
result['total'] = 0
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dd_approvals update API"""
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '')
if not record_id:
result['options'] = {'title': 'Error', 'message': 'ID is required', 'type': 'error'}
else:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
update_fields = []
update_ns = {'id': record_id}
for field in ['biz_type', 'biz_id', 'title', 'applicant_id', 'approver_id', 'dingtalk_instance_id', 'status', 'comment']:
val = params_kw.get(field)
if val is not None:
update_fields.append(f"{field}=${field}$")
update_ns[field] = val
completed_at = params_kw.get('completed_at')
if completed_at:
update_fields.append("completed_at=${completed_at}$")
update_ns['completed_at'] = completed_at
if update_fields:
set_clause = ", ".join(update_fields)
await sor.sqlExe(f"UPDATE dd_approvals SET {set_clause} WHERE id=${id}$", update_ns)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批记录更新成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'更新失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DingTalk webhook callback endpoint.
PUBLIC endpoint (any permission) - DingTalk servers call this when approval status changes.
Expected POST body:
{
"processInstanceId": "xxx",
"processCode": "xxx",
"type": "bpms_instance_change",
"result": "agree" / "refuse",
"staffId": "xxx",
"remark": "optional comment"
}
"""
result = {'success': False, 'message': 'Invalid callback'}
try:
# DingTalk callback data comes via params_kw (POST body auto-parsed)
callback_data = {}
# params_kw contains the parsed POST body fields
process_instance_id = params_kw.get('processInstanceId', '')
callback_type = params_kw.get('type', '')
callback_result = params_kw.get('result', '')
staff_id = params_kw.get('staffId', '')
process_code = params_kw.get('processCode', '')
remark = params_kw.get('remark', '')
# Also handle nested JSON body case where entire body is under a key
if not process_instance_id:
body = params_kw.get('body', None)
if isinstance(body, dict):
process_instance_id = body.get('processInstanceId', '')
callback_type = body.get('type', '')
callback_result = body.get('result', '')
staff_id = body.get('staffId', '')
process_code = body.get('processCode', '')
remark = body.get('remark', '')
callback_data = {
'processInstanceId': process_instance_id,
'type': callback_type,
'result': callback_result,
'staffId': staff_id,
'processCode': process_code,
'remark': remark,
}
if not process_instance_id:
result = {'success': False, 'message': 'Missing processInstanceId'}
else:
# Call the handle_dingtalk_callback function registered via load_dingdingflow()
callback_result_data = await handle_dingtalk_callback(callback_data)
result = callback_result_data
except Exception as e:
result = {'success': False, 'message': f'Callback processing error: {str(e)}'}
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Submit approval API - triggered by entcms when content needs approval.
Creates a dd_approvals record and calls DingTalk API to start the approval process.
"""
result = {'success': False, 'message': 'Invalid request'}
try:
biz_type = params_kw.get('biz_type', '')
biz_id = params_kw.get('biz_id', '')
title = params_kw.get('title', '')
applicant_id = params_kw.get('applicant_id', '')
if not biz_type:
result = {'success': False, 'message': 'biz_type is required'}
elif not biz_id:
result = {'success': False, 'message': 'biz_id is required'}
elif not title:
result = {'success': False, 'message': 'title is required'}
else:
# Use current user as applicant if not specified
if not applicant_id:
applicant_id = get_user() or ''
org_id = (await get_userorgid()) or '0'
# Call the submit_approval function registered via load_dingdingflow()
approval_result = await submit_approval(biz_type, biz_id, title, applicant_id, org_id)
result = approval_result
except Exception as e:
result = {'success': False, 'message': f'提交审批失败: {str(e)}'}
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,77 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{
"widgettype": "Text",
"options": {"label": "钉钉审批管理", "fontSize": "24px", "fontWeight": "bold", "marginBottom": "20px"}
},
{
"widgettype": "ResponsableBox",
"options": {"gap": "16px", "minWidth": "280px"},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"borderRadius": "8px",
"cursor": "pointer",
"boxShadow": "0 2px 8px rgba(0,0,0,0.1)"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.dingdingflow_content",
"options": {"url": "{{entire_url('dd_approvals/index.ui')}}"},
"mode": "replace"
}],
"subwidgets": [
{
"widgettype": "Text",
"options": {"label": "📋 审批记录", "fontSize": "18px", "fontWeight": "bold", "marginBottom": "8px"}
},
{
"widgettype": "Text",
"options": {"label": "查看和管理所有审批申请记录,包括待审批、已通过、已拒绝的审批", "fontSize": "13px", "color": "#666"}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"borderRadius": "8px",
"cursor": "pointer",
"boxShadow": "0 2px 8px rgba(0,0,0,0.1)"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.dingdingflow_content",
"options": {"url": "{{entire_url('dd_approval_configs/index.ui')}}"},
"mode": "replace"
}],
"subwidgets": [
{
"widgettype": "Text",
"options": {"label": "⚙️ 审批流程配置", "fontSize": "18px", "fontWeight": "bold", "marginBottom": "8px"}
},
{
"widgettype": "Text",
"options": {"label": "配置不同业务类型的钉钉审批模板,设置审批流程参数", "fontSize": "13px", "color": "#666"}
}
]
}
]
},
{
"widgettype": "VBox",
"id": "app.dingdingflow_content",
"options": {"width": "100%", "flex": "1", "marginTop": "20px"}
}
]
}

View File

@ -0,0 +1,25 @@
{
"widgettype": "Menu",
"options": {
"items": [
{
"title": "钉钉审批",
"icon": "icon-approve",
"items": [
{
"title": "审批管理",
"url": "{{entire_url('dingdingflow/index.ui')}}"
},
{
"title": "审批记录",
"url": "{{entire_url('dingdingflow/dd_approvals/index.ui')}}"
},
{
"title": "流程配置",
"url": "{{entire_url('dingdingflow/dd_approval_configs/index.ui')}}"
}
]
}
]
}
}

156
docs/architecture.md Normal file
View File

@ -0,0 +1,156 @@
# 开元云科技官网系统架构
## 项目概述
企业官网 + CMS内容管理 + 钉钉审批流程系统基于Sage平台开发。
## 模块组成
### 1. entcms - 企业CMS系统
管理官网所有内容新闻、案例、产品、Banner、商机线索。
**数据库表 (entcms)**:
| 表名 | 用途 |
|------|------|
| cms_content | 统一内容表(新闻/案例/产品/Banner带发布审批状态流 |
| cms_categories | 内容分类支持层级按content_type分组 |
| cms_leads | 商机线索(网站访客提交 + 未来AI抽取 |
| cms_site_config | 站点配置Hero标语、页脚信息等KV配置 |
**公开页面 (any权限)**:
- `/entcms/index.ui` - 官网首页7个模块导航/Hero/产品/案例/新闻/页脚/浮动入口)
- `/entcms/news.ui` - 新闻列表
- `/entcms/news_detail.ui` - 新闻详情
- `/entcms/cases.ui` - 案例列表
**管理页面 (logined权限)**:
- `/entcms/admin.ui` - 管理后台仪表盘
- `/entcms/cms_content_list` - 内容CRUD
- `/entcms/cms_categories_list` - 分类CRUD
- `/entcms/cms_leads_list` - 线索CRUD
- `/entcms/cms_site_config_list` - 配置CRUD
**内容审批流程**:
编辑创建内容(草稿) → 提交审批(status=pending) → 钉钉审批 → 审批通过(status=approved) → 发布(status=published)
### 2. dingdingflow - 钉钉审批流程
对接钉钉审批API为CMS内容发布提供审批工作流。
**数据库表 (dingdingflow)**:
| 表名 | 用途 |
|------|------|
| dd_approvals | 审批记录关联业务类型和ID记录钉钉审批实例ID |
| dd_approval_configs | 审批流程配置按biz_type配置钉钉模板编码等 |
**环境变量**:
- `DINGTALK_APP_KEY` - 钉钉应用AppKey
- `DINGTALK_APP_SECRET` - 钉钉应用AppSecret
- `DINGTALK_AGENT_ID` - 钉钉应用AgentId
- `DINGTALK_CALLBACK_TOKEN` - 钉钉回调Token
**开发模式**: 缺少环境变量时自动使用mock响应不影响CMS功能。
## 技术栈
| 层 | 技术 |
|----|------|
| 前端 | bricks-framework (JSON UI) + 自定义CSS/JS |
| 后端 | ahserver + sqlor + apppublic |
| 认证 | rbac (角色权限控制) |
| 基础设施 | appbase (公共函数) |
| 审批 | 钉钉开放API (预留接口) |
| AI能力 | 预留Agent接口商机抽取 |
## 前端设计
### 官网视觉规范
- 风格: 极简科技感参考OpenAI官网
- 主色: #6C5CE7 (紫色)
- 渐变: #6C5CE7#A29BFE#74B9FF
- 暗色背景: #0a0a0a
- 卡片背景: #1A1A1A
- 字体: Noto Sans SC
- 最大宽度: 1100px
- 响应式断点: 768px
- 云宝形象: SVG线稿占位符
### 官网页面结构
1. **导航栏** - 固定顶部,毛玻璃效果
2. **Hero区** - 品牌Slogan + 脉冲呼吸灯 + 双按钮 + 云宝占位
3. **1+N+X产品架构** - 3张可展开卡片
4. **成功案例** - 3列网格 + CTA横幅
5. **企业动态** - 2条最新新闻 + 查看全部链接
6. **页脚** - 版权信息
7. **浮动入口** - 云宝头像 + 联系面板(表单提交线索)
## 目录结构
```
~/repos/cms/
├── entcms/ # CMS模块
│ ├── entcms/
│ │ ├── __init__.py
│ │ └── init.py # 模块初始化 + ServerEnv注册
│ ├── wwwroot/
│ │ ├── index.ui # 官网首页
│ │ ├── news.ui # 新闻列表
│ │ ├── news_detail.ui # 新闻详情
│ │ ├── cases.ui # 案例列表
│ │ ├── admin.ui # 管理后台
│ │ ├── menu.ui # 管理菜单
│ │ ├── cms_styles.css # 官网样式
│ │ ├── cms_scripts.js # 官网交互脚本
│ │ └── api/ # 22个.dspy API文件
│ ├── models/ # 4个表定义JSON
│ ├── json/ # 4个CRUD定义JSON
│ ├── init/data.json # 初始化数据
│ ├── scripts/load_path.py # RBAC权限配置
│ └── pyproject.toml
├── dingdingflow/ # 审批模块
│ ├── dingdingflow/
│ │ ├── __init__.py
│ │ ├── init.py
│ │ └── dingtalk_client.py # 钉钉API客户端
│ ├── wwwroot/
│ │ ├── index.ui
│ │ ├── menu.ui
│ │ └── api/ # 10个.dspy API文件
│ ├── models/ # 2个表定义JSON
│ ├── json/ # 2个CRUD定义JSON
│ ├── scripts/load_path.py
│ └── pyproject.toml
├── build.sh # 构建脚本
└── docs/ # 文档目录
```
## Sage集成步骤
### 1. app/sage.py
```python
from entcms.init import load_entcms
from dingdingflow.init import load_dingdingflow
# 在init()函数中:
load_entcms()
load_dingdingflow()
```
### 2. build.sh
```bash
for m in ... entcms dingdingflow
```
### 3. RBAC权限
```bash
cd ~/repos/sage
./py3/bin/python ~/repos/cms/entcms/scripts/load_path.py
./py3/bin/python ~/repos/cms/dingdingflow/scripts/load_path.py
```
### 4. 数据库
```bash
cd ~/repos/cms/entcms && cat mysql.ddl.sql | mysql -u root -p sage
cd ~/repos/cms/dingdingflow && cat mysql.ddl.sql | mysql -u root -p sage
```
### 5. 重启
```bash
cd ~/repos/sage && ./stop.sh && ./start.sh
```

93
docs/test-cases.md Normal file
View File

@ -0,0 +1,93 @@
# 测试用例
## 一、entcms模块测试
### 1.1 数据库表验证
| # | 测试项 | 预期 | 状态 |
|---|--------|------|------|
| T01 | cms_content表创建 | DDL执行成功 | ⬜ 待执行 |
| T02 | cms_categories表创建 | DDL执行成功 | ⬜ 待执行 |
| T03 | cms_leads表创建 | DDL执行成功 | ⬜ 待执行 |
| T04 | cms_site_config表创建 | DDL执行成功 | ⬜ 待执行 |
| T05 | 初始化数据导入 | 10条分类+5条配置写入成功 | ⬜ 待执行 |
### 1.2 CRUD API测试
| # | 测试项 | 预期 | 状态 |
|---|--------|------|------|
| T06 | 创建新闻内容 | 返回Message成功 | ⬜ 待执行 |
| T07 | 创建产品内容 | 返回Message成功 | ⬜ 待执行 |
| T08 | 创建案例内容 | 返回Message成功 | ⬜ 待执行 |
| T09 | 查询内容列表 | 返回rows+total | ⬜ 待执行 |
| T10 | 按content_type筛选 | 只返回指定类型 | ⬜ 待执行 |
| T11 | 按status筛选 | 只返回指定状态 | ⬜ 待执行 |
| T12 | data_filter搜索 | LIKE/=操作符正常 | ⬜ 待执行 |
| T13 | 更新内容 | 字段更新成功 | ⬜ 待执行 |
| T14 | 删除内容 | 记录删除 | ⬜ 待执行 |
| T15 | 创建分类 | 返回成功 | ⬜ 待执行 |
| T16 | 分类下拉选项API | 返回value/text数组 | ⬜ 待执行 |
| T17 | 创建线索 | 返回成功 | ⬜ 待执行 |
| T18 | 线索列表 | 返回rows+total | ⬜ 待执行 |
| T19 | 更新线索状态 | 状态更新成功 | ⬜ 待执行 |
| T20 | 站点配置CRUD | 增删改查正常 | ⬜ 待执行 |
### 1.3 公开API测试
| # | 测试项 | 预期 | 状态 |
|---|--------|------|------|
| T21 | 提交线索(无需登录) | 返回成功消息 | ⬜ 待执行 |
| T22 | 获取已发布内容 | 只返回status=published | ⬜ 待执行 |
| T23 | 获取最新新闻 | 按时间倒序,limit生效 | ⬜ 待执行 |
| T24 | 获取内容详情 | 返回单条完整数据 | ⬜ 待执行 |
| T25 | 获取站点配置 | 按group分组返回 | ⬜ 待执行 |
### 1.4 前端页面测试
| # | 测试项 | 预期 | 状态 |
|---|--------|------|------|
| T26 | 首页加载 | 所有7个section渲染正常 | ⬜ 待执行 |
| T27 | Hero呼吸灯动画 | CSS动画正常运行 | ⬜ 待执行 |
| T28 | 产品卡片点击展开 | 点击展开/收起详情 | ⬜ 待执行 |
| T29 | 案例卡片hover效果 | 上移4px+边框变色 | ⬜ 待执行 |
| T30 | 浮动入口交互 | 悬停气泡+点击面板 | ⬜ 待执行 |
| T31 | 线索表单提交 | 数据写入cms_leads | ⬜ 待执行 |
| T32 | 导航锚点跳转 | 平滑滚动到目标section | ⬜ 待执行 |
| T33 | 新闻列表页 | 显示所有新闻 | ⬜ 待执行 |
| T34 | 新闻详情页 | 显示单条文章 | ⬜ 待执行 |
| T35 | 案例列表页 | 显示所有案例 | ⬜ 待执行 |
| T36 | 响应式-桌面端 | 3列grid,1100px最大宽度 | ⬜ 待执行 |
| T37 | 响应式-移动端 | 单列堆叠,32px标题 | ⬜ 待执行 |
| T38 | 滚动动画 | fade-in元素可见时出现 | ⬜ 待执行 |
### 1.5 RBAC权限测试
| # | 测试项 | 预期 | 状态 |
|---|--------|------|------|
| T39 | 未登录访问首页 | 200正常显示 | ⬜ 待执行 |
| T40 | 未登录提交线索 | 200正常写入 | ⬜ 待执行 |
| T41 | 未登录访问管理页 | 401拒绝 | ⬜ 待执行 |
| T42 | 已登录访问管理页 | 200正常显示 | ⬜ 待执行 |
| T43 | 已登录CRUD操作 | 正常执行 | ⬜ 待执行 |
## 二、dingdingflow模块测试
### 2.1 审批流程测试
| # | 测试项 | 预期 | 状态 |
|---|--------|------|------|
| T44 | 提交审批(dd_approvals写入) | 记录创建,status=pending | ⬜ 待执行 |
| T45 | 开发模式(无钉钉凭证) | mock响应,不影响流程 | ⬜ 待执行 |
| T46 | 获取审批状态 | 返回当前状态 | ⬜ 待执行 |
| T47 | 钉钉回调(审批通过) | 状态更新为approved | ⬜ 待执行 |
| T48 | 钉钉回调(审批拒绝) | 状态更新为rejected | ⬜ 待执行 |
| T49 | 审批配置CRUD | 增删改查正常 | ⬜ 待执行 |
### 2.2 集成测试
| # | 测试项 | 预期 | 状态 |
|---|--------|------|------|
| T50 | CMS提交审批→dingdingflow | 内容状态变pending,审批记录创建 | ⬜ 待执行 |
| T51 | 审批通过→CMS状态更新 | 内容状态变approved | ⬜ 待执行 |
| T52 | 审批拒绝→CMS状态不变 | 内容保持pending | ⬜ 待执行 |
| T53 | dingdingflow未安装→CMS降级 | CMS提示审批模块未安装 | ⬜ 待执行 |
## 测试汇总
- 总用例数: 53
- 通过: 0
- 失败: 0
- 待执行: 53
- 通过率: 待部署后统计

View File

@ -0,0 +1,46 @@
# 开发日志
## 2026-05-27 - 项目初始化与核心开发
### 范围
企业官网CMS系统 (entcms + dingdingflow) 从零搭建。
### 完成内容
**entcms模块**:
- 4个数据库表定义 (cms_content, cms_categories, cms_leads, cms_site_config)
- init.py 模块初始化 + 25个ServerEnv注册函数
- 4个CRUD JSON定义
- 22个.dspy API文件 (含公开API和data_filter支持)
- 4个公开页面 (index.ui, news.ui, news_detail.ui, cases.ui)
- 1个管理后台 (admin.ui)
- 1个菜单 (menu.ui)
- 完整营销站点CSS (cms_styles.css) + 交互JS (cms_scripts.js)
- RBAC权限配置脚本
- 初始化数据 (10条分类 + 5条站点配置)
**dingdingflow模块**:
- 2个数据库表定义 (dd_approvals, dd_approval_configs)
- init.py + dingtalk_client.py (钉钉API客户端)
- 2个CRUD JSON定义
- 10个.dspy API文件 (含公开回调endpoint)
- 管理UI (index.ui, menu.ui)
- RBAC权限配置脚本
- 开发模式: 无凭证时自动mock
**基础设施**:
- build.sh 构建脚本
- pyproject.toml x2
- 架构文档
- 53条测试用例
### 技术决策
1. 官网前端使用bricks框架 + Html widget渲染营销页面内容
2. 自定义CSS/JS实现营销设计暗色主题、渐变、动画
3. 统一cms_content表存储所有内容类型通过content_type区分
4. 钉钉API凭证从环境变量获取开发模式mock响应
5. 线索表预留raw_text字段用于未来AI商机抽取
### 当前状态
- 代码完整待部署到Sage进行集成测试
- 分支: main (首次提交)

22
entcms/README.md Normal file
View File

@ -0,0 +1,22 @@
# entcms - 企业CMS系统
管理开元云科技官网所有内容。
## 数据表
- cms_content: 内容(新闻/案例/产品/Banner
- cms_categories: 分类
- cms_leads: 商机线索
- cms_site_config: 站点配置
## 公开页面 (无需登录)
- /entcms/index.ui - 官网首页
- /entcms/news.ui - 新闻列表
- /entcms/news_detail.ui - 新闻详情
- /entcms/cases.ui - 案例列表
## 管理页面 (需登录)
- /entcms/admin.ui - 管理后台
- /entcms/cms_content_list - 内容管理
- /entcms/cms_categories_list - 分类管理
- /entcms/cms_leads_list - 线索管理
- /entcms/cms_site_config_list - 配置管理

View File

260
entcms/entcms/init.py Normal file
View File

@ -0,0 +1,260 @@
"""
entcms - 企业CMS系统模块
企业官网内容管理新闻案例产品Banner商机线索
"""
import json
from ahserver.serverenv import ServerEnv
from appPublic.uniqueID import getID
MODULE_NAME = "entcms"
MODULE_VERSION = "1.0.0"
DBNAME = "entcms"
def _get_db():
"""获取数据库上下文"""
from sqlor import DBPools
return DBPools().sqlorContext(DBNAME)
# ===== CMS Content CRUD =====
async def cms_content_list(ns=None):
"""查询内容列表"""
sor = _get_db()
ns = ns or {}
ns.setdefault('sort', 'sort_order asc, created_at desc')
rows = await sor.R('cms_content', ns)
total = len(rows)
return {'rows': rows, 'total': total}
async def cms_content_create(data):
"""创建内容"""
sor = _get_db()
data['id'] = getID()
await sor.C('cms_content', data)
return data
async def cms_content_update(data):
"""更新内容"""
sor = _get_db()
await sor.U('cms_content', data)
return data
async def cms_content_delete(data):
"""删除内容"""
sor = _get_db()
await sor.D('cms_content', data)
return data
# ===== CMS Categories CRUD =====
async def cms_categories_list(ns=None):
"""查询分类列表"""
sor = _get_db()
ns = ns or {}
ns.setdefault('sort', 'sort_order asc')
rows = await sor.R('cms_categories', ns)
return {'rows': rows, 'total': len(rows)}
async def cms_categories_create(data):
sor = _get_db()
data['id'] = getID()
await sor.C('cms_categories', data)
return data
async def cms_categories_update(data):
sor = _get_db()
await sor.U('cms_categories', data)
return data
async def cms_categories_delete(data):
sor = _get_db()
await sor.D('cms_categories', data)
return data
async def get_category_options(content_type=None):
"""获取分类下拉选项"""
sor = _get_db()
ns = {'sort': 'sort_order asc'}
if content_type:
ns['content_type'] = content_type
rows = await sor.R('cms_categories', ns)
options = [{'value': r['id'], 'text': r['name']} for r in rows]
return options
# ===== CMS Leads CRUD =====
async def cms_leads_list(ns=None):
sor = _get_db()
ns = ns or {}
ns.setdefault('sort', 'created_at desc')
rows = await sor.R('cms_leads', ns)
return {'rows': rows, 'total': len(rows)}
async def cms_leads_create(data):
sor = _get_db()
data['id'] = getID()
await sor.C('cms_leads', data)
return data
async def cms_leads_update(data):
sor = _get_db()
await sor.U('cms_leads', data)
return data
async def cms_leads_delete(data):
sor = _get_db()
await sor.D('cms_leads', data)
return data
async def submit_lead(data):
"""公开接口 - 网站访客提交线索"""
sor = _get_db()
data['id'] = getID()
data.setdefault('status', 'new')
data.setdefault('source', 'website')
await sor.C('cms_leads', data)
return {'status': 'ok', 'id': data['id']}
# ===== CMS Site Config CRUD =====
async def cms_site_config_list(ns=None):
sor = _get_db()
ns = ns or {}
ns.setdefault('sort', 'config_group asc, sort_order asc')
rows = await sor.R('cms_site_config', ns)
return {'rows': rows, 'total': len(rows)}
async def cms_site_config_create(data):
sor = _get_db()
data['id'] = getID()
await sor.C('cms_site_config', data)
return data
async def cms_site_config_update(data):
sor = _get_db()
await sor.U('cms_site_config', data)
return data
async def cms_site_config_delete(data):
sor = _get_db()
await sor.D('cms_site_config', data)
return data
async def get_site_config(group=None):
"""获取站点配置(公开接口)"""
sor = _get_db()
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 result
# ===== Public Content APIs =====
async def get_published_content(content_type=None, limit=10):
"""获取已发布内容(公开接口)"""
sor = _get_db()
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 rows
async def get_latest_news(limit=2):
"""获取最新新闻(公开接口)"""
sor = _get_db()
ns = {'status': 'published', 'content_type': 'news', 'sort': 'published_at desc'}
rows = await sor.R('cms_content', ns)
return rows[:limit]
async def get_content_detail(content_id):
"""获取内容详情(公开接口)"""
sor = _get_db()
ns = {'id': content_id, 'status': 'published'}
rows = await sor.R('cms_content', ns)
return rows[0] if rows else None
# ===== Submit for approval =====
async def submit_content_for_approval(content_id, title, applicant_id):
"""提交内容审批调用dingdingflow"""
# 更新内容状态为pending
sor = _get_db()
await sor.U('cms_content', {'id': content_id, 'status': 'pending'})
# 调用dingdingflow的submit_approval
try:
from dingdingflow.init import submit_approval
result = await submit_approval('content_publish', content_id, title, applicant_id)
# 保存审批ID
if result and result.get('approval_id'):
await sor.U('cms_content', {'id': content_id, 'approval_id': result['approval_id']})
return result
except ImportError:
return {'status': 'error', 'message': 'dingdingflow模块未安装'}
def load_entcms():
"""注册所有函数到ServerEnv"""
env = ServerEnv()
# Content CRUD
env.cms_content_list = cms_content_list
env.cms_content_create = cms_content_create
env.cms_content_update = cms_content_update
env.cms_content_delete = cms_content_delete
# Categories CRUD
env.cms_categories_list = cms_categories_list
env.cms_categories_create = cms_categories_create
env.cms_categories_update = cms_categories_update
env.cms_categories_delete = cms_categories_delete
env.get_category_options = get_category_options
# Leads CRUD
env.cms_leads_list = cms_leads_list
env.cms_leads_create = cms_leads_create
env.cms_leads_update = cms_leads_update
env.cms_leads_delete = cms_leads_delete
env.submit_lead = submit_lead
# Site Config CRUD
env.cms_site_config_list = cms_site_config_list
env.cms_site_config_create = cms_site_config_create
env.cms_site_config_update = cms_site_config_update
env.cms_site_config_delete = cms_site_config_delete
env.get_site_config = get_site_config
# Public APIs
env.get_published_content = get_published_content
env.get_latest_news = get_latest_news
env.get_content_detail = get_content_detail
env.submit_content_for_approval = submit_content_for_approval
return True

116
entcms/init/data.json Normal file
View File

@ -0,0 +1,116 @@
{
"cms_categories": [
{
"id": "cat_product_platform",
"org_id": "0",
"name": "AI平台",
"content_type": "product",
"sort_order": 1
},
{
"id": "cat_product_model",
"org_id": "0",
"name": "行业模型",
"content_type": "product",
"sort_order": 2
},
{
"id": "cat_product_agent",
"org_id": "0",
"name": "智能体",
"content_type": "product",
"sort_order": 3
},
{
"id": "cat_case_mfg",
"org_id": "0",
"name": "智能制造",
"content_type": "case",
"sort_order": 1
},
{
"id": "cat_case_finance",
"org_id": "0",
"name": "金融科技",
"content_type": "case",
"sort_order": 2
},
{
"id": "cat_case_healthcare",
"org_id": "0",
"name": "医疗健康",
"content_type": "case",
"sort_order": 3
},
{
"id": "cat_case_education",
"org_id": "0",
"name": "教育培训",
"content_type": "case",
"sort_order": 4
},
{
"id": "cat_news_company",
"org_id": "0",
"name": "公司动态",
"content_type": "news",
"sort_order": 1
},
{
"id": "cat_news_industry",
"org_id": "0",
"name": "行业资讯",
"content_type": "news",
"sort_order": 2
},
{
"id": "cat_news_product",
"org_id": "0",
"name": "产品更新",
"content_type": "news",
"sort_order": 3
}
],
"cms_site_config": [
{
"id": "cfg_hero_slogan",
"org_id": "0",
"config_group": "hero",
"config_key": "slogan",
"config_value": "一个平台,千行百业 智能跃迁",
"config_type": "text"
},
{
"id": "cfg_hero_subtitle",
"org_id": "0",
"config_group": "hero",
"config_key": "subtitle",
"config_value": "基于东数西算国家战略打造新一代AI智能体服务平台",
"config_type": "text"
},
{
"id": "cfg_hero_tag",
"org_id": "0",
"config_group": "hero",
"config_key": "tag_text",
"config_value": "AI 智能体服务平台",
"config_type": "text"
},
{
"id": "cfg_footer_copyright",
"org_id": "0",
"config_group": "footer",
"config_key": "copyright",
"config_value": "© 2026 开元云科技 · 国家级高新技术企业 · 专精特新企业",
"config_type": "text"
},
{
"id": "cfg_contact_company",
"org_id": "0",
"config_group": "contact",
"config_key": "company_name",
"config_value": "开元云科技",
"config_type": "text"
}
]
}

View File

@ -0,0 +1,37 @@
{
"tblname": "cms_categories",
"alias": "cms_categories_list",
"title": "内容分类",
"params": {
"sortby": [
"sort_order asc"
],
"logined_userorgid": "org_id",
"browserfields": {
"alters": {
"content_type": {
"uitype": "code",
"data": [
{
"value": "product",
"text": "产品"
},
{
"value": "case",
"text": "案例"
},
{
"value": "news",
"text": "新闻"
}
]
}
}
},
"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,97 @@
{
"tblname": "cms_content",
"alias": "cms_content_list",
"title": "内容管理",
"params": {
"sortby": [
"sort_order asc",
"created_at desc"
],
"logined_userorgid": "org_id",
"data_filter": {
"AND": [
{
"field": "title",
"op": "LIKE",
"var": "title"
},
{
"field": "content_type",
"op": "=",
"var": "content_type"
},
{
"field": "status",
"op": "=",
"var": "status"
}
]
},
"browserfields": {
"exclouded": [
"body",
"extra_json"
],
"alters": {
"content_type": {
"uitype": "code",
"data": [
{
"value": "banner",
"text": "Banner"
},
{
"value": "product",
"text": "产品"
},
{
"value": "case",
"text": "案例"
},
{
"value": "news",
"text": "新闻"
}
]
},
"status": {
"uitype": "code",
"data": [
{
"value": "draft",
"text": "草稿"
},
{
"value": "pending",
"text": "待审批"
},
{
"value": "approved",
"text": "已审批"
},
{
"value": "published",
"text": "已发布"
},
{
"value": "archived",
"text": "已归档"
}
]
},
"category_id": {
"uitype": "code",
"dataurl": "{{entire_url('../api/category_options.dspy')}}",
"data_field": "options",
"textField": "text",
"valueField": "value"
}
}
},
"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')}}"
}
}
}

View File

@ -0,0 +1,93 @@
{
"tblname": "cms_leads",
"alias": "cms_leads_list",
"title": "商机线索",
"params": {
"sortby": [
"created_at desc"
],
"logined_userorgid": "org_id",
"data_filter": {
"AND": [
{
"field": "name",
"op": "LIKE",
"var": "name"
},
{
"field": "company",
"op": "LIKE",
"var": "company"
},
{
"field": "status",
"op": "=",
"var": "status"
},
{
"field": "source",
"op": "=",
"var": "source"
}
]
},
"browserfields": {
"exclouded": [
"raw_text"
],
"alters": {
"status": {
"uitype": "code",
"data": [
{
"value": "new",
"text": "新线索"
},
{
"value": "contacted",
"text": "已联系"
},
{
"value": "qualified",
"text": "已确认"
},
{
"value": "converted",
"text": "已转化"
},
{
"value": "closed",
"text": "已关闭"
}
]
},
"source": {
"uitype": "code",
"data": [
{
"value": "website",
"text": "官网"
},
{
"value": "phone",
"text": "电话"
},
{
"value": "referral",
"text": "转介绍"
},
{
"value": "ai_extract",
"text": "AI抽取"
}
]
}
}
},
"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,63 @@
{
"tblname": "cms_site_config",
"alias": "cms_site_config_list",
"title": "站点配置",
"params": {
"sortby": [
"config_group asc",
"sort_order asc"
],
"logined_userorgid": "org_id",
"browserfields": {
"alters": {
"config_group": {
"uitype": "code",
"data": [
{
"value": "hero",
"text": "首屏Hero"
},
{
"value": "footer",
"text": "页脚"
},
{
"value": "contact",
"text": "联系信息"
},
{
"value": "seo",
"text": "SEO设置"
}
]
},
"config_type": {
"uitype": "code",
"data": [
{
"value": "text",
"text": "文本"
},
{
"value": "image",
"text": "图片"
},
{
"value": "html",
"text": "HTML"
},
{
"value": "json",
"text": "JSON"
}
]
}
}
},
"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')}}"
}
}
}

View File

@ -0,0 +1,80 @@
{
"summary": [
{
"name": "cms_categories",
"title": "CMS内容分类表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "name",
"title": "分类名称",
"type": "str",
"length": 100,
"nullable": "no"
},
{
"name": "parent_id",
"title": "父分类ID",
"type": "str",
"length": 32
},
{
"name": "content_type",
"title": "所属内容类型",
"type": "str",
"length": 32
},
{
"name": "description",
"title": "分类描述",
"type": "str",
"length": 255
},
{
"name": "sort_order",
"title": "排序号",
"type": "int",
"default": "0"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_cat_org_type",
"idxtype": "index",
"idxfields": [
"org_id",
"content_type"
]
},
{
"name": "idx_cat_parent",
"idxtype": "index",
"idxfields": [
"parent_id"
]
}
]
}

View File

@ -0,0 +1,154 @@
{
"summary": [
{
"name": "cms_content",
"title": "CMS内容表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "content_type",
"title": "内容类型(banner/product/case/news)",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "category_id",
"title": "分类ID",
"type": "str",
"length": 32
},
{
"name": "title",
"title": "标题",
"type": "str",
"length": 255,
"nullable": "no"
},
{
"name": "subtitle",
"title": "副标题",
"type": "str",
"length": 255
},
{
"name": "summary_text",
"title": "摘要",
"type": "str",
"length": 500
},
{
"name": "body",
"title": "正文内容",
"type": "text"
},
{
"name": "image_url",
"title": "封面图片URL",
"type": "str",
"length": 500
},
{
"name": "tags",
"title": "标签(逗号分隔)",
"type": "str",
"length": 500
},
{
"name": "sort_order",
"title": "排序号",
"type": "int",
"default": "0"
},
{
"name": "status",
"title": "状态(draft/pending/approved/published/archived)",
"type": "str",
"length": 32,
"default": "draft"
},
{
"name": "approval_id",
"title": "审批单ID",
"type": "str",
"length": 32
},
{
"name": "published_at",
"title": "发布时间",
"type": "datetime"
},
{
"name": "extra_json",
"title": "扩展JSON",
"type": "text"
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 32
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_content_type_status",
"idxtype": "index",
"idxfields": [
"content_type",
"status"
]
},
{
"name": "idx_content_org",
"idxtype": "index",
"idxfields": [
"org_id",
"content_type"
]
},
{
"name": "idx_content_sort",
"idxtype": "index",
"idxfields": [
"sort_order"
]
}
],
"codes": [
{
"field": "category_id",
"table": "cms_categories",
"valuefield": "id",
"textfield": "name"
}
]
}

View File

@ -0,0 +1,137 @@
{
"summary": [
{
"name": "cms_leads",
"title": "商机线索表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "source",
"title": "来源(website/phone/referral/ai_extract)",
"type": "str",
"length": 32
},
{
"name": "name",
"title": "联系人姓名",
"type": "str",
"length": 100
},
{
"name": "company",
"title": "公司名称",
"type": "str",
"length": 200
},
{
"name": "phone",
"title": "联系电话",
"type": "str",
"length": 32
},
{
"name": "email",
"title": "邮箱",
"type": "str",
"length": 100
},
{
"name": "industry",
"title": "所属行业",
"type": "str",
"length": 100
},
{
"name": "region",
"title": "地区",
"type": "str",
"length": 100
},
{
"name": "interest_products",
"title": "感兴趣的产品",
"type": "str",
"length": 500
},
{
"name": "message",
"title": "留言内容",
"type": "text"
},
{
"name": "raw_text",
"title": "原始文本(AI抽取用)",
"type": "text"
},
{
"name": "status",
"title": "状态(new/contacted/qualified/converted/closed)",
"type": "str",
"length": 32,
"default": "new"
},
{
"name": "assigned_to",
"title": "负责人",
"type": "str",
"length": 32
},
{
"name": "notes",
"title": "跟进备注",
"type": "text"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_leads_org_status",
"idxtype": "index",
"idxfields": [
"org_id",
"status"
]
},
{
"name": "idx_leads_source",
"idxtype": "index",
"idxfields": [
"source"
]
},
{
"name": "idx_leads_assigned",
"idxtype": "index",
"idxfields": [
"assigned_to"
]
}
]
}

View File

@ -0,0 +1,75 @@
{
"summary": [
{
"name": "cms_site_config",
"title": "站点配置表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "config_group",
"title": "配置组(hero/footer/contact/seo)",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "config_key",
"title": "配置键",
"type": "str",
"length": 100,
"nullable": "no"
},
{
"name": "config_value",
"title": "配置值",
"type": "text"
},
{
"name": "config_type",
"title": "值类型(text/image/html/json)",
"type": "str",
"length": 32,
"default": "text"
},
{
"name": "sort_order",
"title": "排序号",
"type": "int",
"default": "0"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_config_org_group",
"idxtype": "unique",
"idxfields": [
"org_id",
"config_group",
"config_key"
]
}
]
}

17
entcms/pyproject.toml Normal file
View File

@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "entcms"
version = "1.0.0"
description = "企业CMS系统 - 开元云科技官网内容管理"
requires-python = ">=3.8"
dependencies = [
"sqlor",
"bricks_for_python",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["entcms*"]

View File

@ -0,0 +1,89 @@
"""
entcms RBAC权限配置
用法: cd ~/repos/sage && ./py3/bin/python ~/repos/cms/entcms/scripts/load_path.py
"""
import os
import sys
import subprocess
# 查找Sage根目录
def find_sage_root():
for candidate in [
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
]:
if os.path.isdir(os.path.join(candidate, "wwwroot")) and \
os.path.isdir(os.path.join(candidate, "py3")):
return candidate
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 = os.path.join(sage_root, "set_role_perm.py")
# 权限配置
paths_any = [
# 公开页面和静态资源
"/entcms/index.ui",
"/entcms/news.ui",
"/entcms/news_detail.ui",
"/entcms/cases.ui",
"/entcms/products.ui",
"/entcms/cms_styles.css",
"/entcms/cms_scripts.js",
"/entcms/menu.ui",
# 公开API
"/entcms/api/submit_lead.dspy",
"/entcms/api/get_config.dspy",
"/entcms/api/get_published_content.dspy",
"/entcms/api/get_content_detail.dspy",
]
paths_logined = [
# 管理后台
"/entcms",
"/entcms/admin.ui",
# CRUD页面
"/entcms/cms_content_list",
"/entcms/cms_content_list/%",
"/entcms/cms_categories_list",
"/entcms/cms_categories_list/%",
"/entcms/cms_leads_list",
"/entcms/cms_leads_list/%",
"/entcms/cms_site_config_list",
"/entcms/cms_site_config_list/%",
# 管理API
"/entcms/api/cms_content_create.dspy",
"/entcms/api/cms_content_update.dspy",
"/entcms/api/cms_content_delete.dspy",
"/entcms/api/cms_content_list.dspy",
"/entcms/api/cms_categories_create.dspy",
"/entcms/api/cms_categories_update.dspy",
"/entcms/api/cms_categories_delete.dspy",
"/entcms/api/cms_categories_list.dspy",
"/entcms/api/category_options.dspy",
"/entcms/api/cms_leads_create.dspy",
"/entcms/api/cms_leads_update.dspy",
"/entcms/api/cms_leads_delete.dspy",
"/entcms/api/cms_leads_list.dspy",
"/entcms/api/cms_site_config_create.dspy",
"/entcms/api/cms_site_config_update.dspy",
"/entcms/api/cms_site_config_delete.dspy",
"/entcms/api/cms_site_config_list.dspy",
"/entcms/api/submit_content_approval.dspy",
]
def set_perms(role, paths):
for path in paths:
cmd = [python, set_perm, role, path]
print(f" {role:20s} {path}")
subprocess.run(cmd, cwd=sage_root, capture_output=True)
print("=== entcms RBAC权限配置 ===")
set_perms("any", paths_any)
set_perms("logined", paths_logined)
print("完成")

267
entcms/wwwroot/admin.ui Normal file
View File

@ -0,0 +1,267 @@
{
"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('/entcms/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('/entcms/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('/entcms/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('/entcms/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,15 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
content_type = params_kw.get('content_type', None)
ns = {'sort': 'sort_order asc'}
if content_type:
ns['content_type'] = content_type
rows = await sor.R('cms_categories', ns)
options = [{'value': r['id'], 'text': r['name']} for r in rows]
print(json.dumps({'status': 'ok', 'data': {'options': options}}, ensure_ascii=False))

View File

@ -0,0 +1,36 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
data = {'id': getID()}
v = params_kw.get('org_id', None)
if v is not None:
data['org_id'] = v
v = params_kw.get('name', None)
if v is not None:
data['name'] = v
v = params_kw.get('parent_id', None)
if v is not None:
data['parent_id'] = v
v = params_kw.get('content_type', None)
if v is not None:
data['content_type'] = v
v = params_kw.get('description', None)
if v is not None:
data['description'] = v
v = params_kw.get('sort_order', None)
if v is not None:
data['sort_order'] = v
await sor.C('cms_categories', data)
print(json.dumps({'widgettype': 'Message', 'options': {'text': '创建成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,14 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
_id = params_kw.get('id', '')
if not _id:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少ID', 'messagetype': 'error'}}, ensure_ascii=False))
return
await sor.D('cms_categories', {'id': _id})
print(json.dumps({'widgettype': 'Message', 'options': {'text': '删除成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,32 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
ns = {'sort': 'sort_order asc'}
# data_filter support
filter_json = params_kw.get('data_filter', None)
if filter_json:
from sqlor.filter import DBFilter
try:
filter_def = json.loads(filter_json) if isinstance(filter_json, str) else filter_json
dbf = DBFilter(filter_def)
conds = dbf.gen(params_kw)
ns.update(conds)
ns.update(dbf.consts)
except Exception:
pass
# Manual filter params
_content_type = params_kw.get('content_type', None)
if _content_type:
ns['content_type'] = _content_type
rows = await sor.R('cms_categories', ns)
total = len(rows)
print(json.dumps({'status': 'ok', 'rows': rows, 'total': total}, ensure_ascii=False))

View File

@ -0,0 +1,39 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
data = {'id': params_kw.get('id', '')}
if not data['id']:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少ID', 'messagetype': 'error'}}, ensure_ascii=False))
return
v = params_kw.get('org_id', None)
if v is not None:
data['org_id'] = v
v = params_kw.get('name', None)
if v is not None:
data['name'] = v
v = params_kw.get('parent_id', None)
if v is not None:
data['parent_id'] = v
v = params_kw.get('content_type', None)
if v is not None:
data['content_type'] = v
v = params_kw.get('description', None)
if v is not None:
data['description'] = v
v = params_kw.get('sort_order', None)
if v is not None:
data['sort_order'] = v
await sor.U('cms_categories', data)
print(json.dumps({'widgettype': 'Message', 'options': {'text': '更新成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,60 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
data = {'id': getID()}
v = params_kw.get('org_id', None)
if v is not None:
data['org_id'] = v
v = params_kw.get('content_type', None)
if v is not None:
data['content_type'] = v
v = params_kw.get('category_id', None)
if v is not None:
data['category_id'] = v
v = params_kw.get('title', None)
if v is not None:
data['title'] = v
v = params_kw.get('subtitle', None)
if v is not None:
data['subtitle'] = v
v = params_kw.get('summary_text', None)
if v is not None:
data['summary_text'] = v
v = params_kw.get('body', None)
if v is not None:
data['body'] = v
v = params_kw.get('image_url', None)
if v is not None:
data['image_url'] = v
v = params_kw.get('tags', None)
if v is not None:
data['tags'] = v
v = params_kw.get('sort_order', None)
if v is not None:
data['sort_order'] = v
v = params_kw.get('status', None)
if v is not None:
data['status'] = v
v = params_kw.get('extra_json', None)
if v is not None:
data['extra_json'] = v
await sor.C('cms_content', data)
print(json.dumps({'widgettype': 'Message', 'options': {'text': '创建成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,14 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
_id = params_kw.get('id', '')
if not _id:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少ID', 'messagetype': 'error'}}, ensure_ascii=False))
return
await sor.D('cms_content', {'id': _id})
print(json.dumps({'widgettype': 'Message', 'options': {'text': '删除成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,36 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
ns = {'sort': 'sort_order asc, created_at desc'}
# data_filter support
filter_json = params_kw.get('data_filter', None)
if filter_json:
from sqlor.filter import DBFilter
try:
filter_def = json.loads(filter_json) if isinstance(filter_json, str) else filter_json
dbf = DBFilter(filter_def)
conds = dbf.gen(params_kw)
ns.update(conds)
ns.update(dbf.consts)
except Exception:
pass
# Manual filter params
_content_type = params_kw.get('content_type', None)
if _content_type:
ns['content_type'] = _content_type
_status = params_kw.get('status', None)
if _status:
ns['status'] = _status
rows = await sor.R('cms_content', ns)
total = len(rows)
print(json.dumps({'status': 'ok', 'rows': rows, 'total': total}, ensure_ascii=False))

View File

@ -0,0 +1,71 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
data = {'id': params_kw.get('id', '')}
if not data['id']:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少ID', 'messagetype': 'error'}}, ensure_ascii=False))
return
v = params_kw.get('org_id', None)
if v is not None:
data['org_id'] = v
v = params_kw.get('content_type', None)
if v is not None:
data['content_type'] = v
v = params_kw.get('category_id', None)
if v is not None:
data['category_id'] = v
v = params_kw.get('title', None)
if v is not None:
data['title'] = v
v = params_kw.get('subtitle', None)
if v is not None:
data['subtitle'] = v
v = params_kw.get('summary_text', None)
if v is not None:
data['summary_text'] = v
v = params_kw.get('body', None)
if v is not None:
data['body'] = v
v = params_kw.get('image_url', None)
if v is not None:
data['image_url'] = v
v = params_kw.get('tags', None)
if v is not None:
data['tags'] = v
v = params_kw.get('sort_order', None)
if v is not None:
data['sort_order'] = v
v = params_kw.get('status', None)
if v is not None:
data['status'] = v
v = params_kw.get('extra_json', None)
if v is not None:
data['extra_json'] = v
v = params_kw.get('approval_id', None)
if v is not None:
data['approval_id'] = v
v = params_kw.get('published_at', None)
if v is not None:
data['published_at'] = v
await sor.U('cms_content', data)
print(json.dumps({'widgettype': 'Message', 'options': {'text': '更新成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,68 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
data = {'id': getID()}
v = params_kw.get('org_id', None)
if v is not None:
data['org_id'] = v
v = params_kw.get('source', None)
if v is not None:
data['source'] = v
v = params_kw.get('name', None)
if v is not None:
data['name'] = v
v = params_kw.get('company', None)
if v is not None:
data['company'] = v
v = params_kw.get('phone', None)
if v is not None:
data['phone'] = v
v = params_kw.get('email', None)
if v is not None:
data['email'] = v
v = params_kw.get('industry', None)
if v is not None:
data['industry'] = v
v = params_kw.get('region', None)
if v is not None:
data['region'] = v
v = params_kw.get('interest_products', None)
if v is not None:
data['interest_products'] = v
v = params_kw.get('message', None)
if v is not None:
data['message'] = v
v = params_kw.get('raw_text', None)
if v is not None:
data['raw_text'] = v
v = params_kw.get('status', None)
if v is not None:
data['status'] = v
v = params_kw.get('assigned_to', None)
if v is not None:
data['assigned_to'] = v
v = params_kw.get('notes', None)
if v is not None:
data['notes'] = v
await sor.C('cms_leads', data)
print(json.dumps({'widgettype': 'Message', 'options': {'text': '创建成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,14 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
_id = params_kw.get('id', '')
if not _id:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少ID', 'messagetype': 'error'}}, ensure_ascii=False))
return
await sor.D('cms_leads', {'id': _id})
print(json.dumps({'widgettype': 'Message', 'options': {'text': '删除成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,36 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
ns = {'sort': 'created_at desc'}
# data_filter support
filter_json = params_kw.get('data_filter', None)
if filter_json:
from sqlor.filter import DBFilter
try:
filter_def = json.loads(filter_json) if isinstance(filter_json, str) else filter_json
dbf = DBFilter(filter_def)
conds = dbf.gen(params_kw)
ns.update(conds)
ns.update(dbf.consts)
except Exception:
pass
# Manual filter params
_status = params_kw.get('status', None)
if _status:
ns['status'] = _status
_source = params_kw.get('source', None)
if _source:
ns['source'] = _source
rows = await sor.R('cms_leads', ns)
total = len(rows)
print(json.dumps({'status': 'ok', 'rows': rows, 'total': total}, ensure_ascii=False))

View File

@ -0,0 +1,71 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
data = {'id': params_kw.get('id', '')}
if not data['id']:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少ID', 'messagetype': 'error'}}, ensure_ascii=False))
return
v = params_kw.get('org_id', None)
if v is not None:
data['org_id'] = v
v = params_kw.get('source', None)
if v is not None:
data['source'] = v
v = params_kw.get('name', None)
if v is not None:
data['name'] = v
v = params_kw.get('company', None)
if v is not None:
data['company'] = v
v = params_kw.get('phone', None)
if v is not None:
data['phone'] = v
v = params_kw.get('email', None)
if v is not None:
data['email'] = v
v = params_kw.get('industry', None)
if v is not None:
data['industry'] = v
v = params_kw.get('region', None)
if v is not None:
data['region'] = v
v = params_kw.get('interest_products', None)
if v is not None:
data['interest_products'] = v
v = params_kw.get('message', None)
if v is not None:
data['message'] = v
v = params_kw.get('raw_text', None)
if v is not None:
data['raw_text'] = v
v = params_kw.get('status', None)
if v is not None:
data['status'] = v
v = params_kw.get('assigned_to', None)
if v is not None:
data['assigned_to'] = v
v = params_kw.get('notes', None)
if v is not None:
data['notes'] = v
await sor.U('cms_leads', data)
print(json.dumps({'widgettype': 'Message', 'options': {'text': '更新成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,36 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
data = {'id': getID()}
v = params_kw.get('org_id', None)
if v is not None:
data['org_id'] = v
v = params_kw.get('config_group', None)
if v is not None:
data['config_group'] = v
v = params_kw.get('config_key', None)
if v is not None:
data['config_key'] = v
v = params_kw.get('config_value', None)
if v is not None:
data['config_value'] = v
v = params_kw.get('config_type', None)
if v is not None:
data['config_type'] = v
v = params_kw.get('sort_order', None)
if v is not None:
data['sort_order'] = v
await sor.C('cms_site_config', data)
print(json.dumps({'widgettype': 'Message', 'options': {'text': '创建成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,14 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
_id = params_kw.get('id', '')
if not _id:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少ID', 'messagetype': 'error'}}, ensure_ascii=False))
return
await sor.D('cms_site_config', {'id': _id})
print(json.dumps({'widgettype': 'Message', 'options': {'text': '删除成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,32 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
ns = {'sort': 'config_group asc, sort_order asc'}
# data_filter support
filter_json = params_kw.get('data_filter', None)
if filter_json:
from sqlor.filter import DBFilter
try:
filter_def = json.loads(filter_json) if isinstance(filter_json, str) else filter_json
dbf = DBFilter(filter_def)
conds = dbf.gen(params_kw)
ns.update(conds)
ns.update(dbf.consts)
except Exception:
pass
# Manual filter params
_config_group = params_kw.get('config_group', None)
if _config_group:
ns['config_group'] = _config_group
rows = await sor.R('cms_site_config', ns)
total = len(rows)
print(json.dumps({'status': 'ok', 'rows': rows, 'total': total}, ensure_ascii=False))

View File

@ -0,0 +1,39 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
data = {'id': params_kw.get('id', '')}
if not data['id']:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少ID', 'messagetype': 'error'}}, ensure_ascii=False))
return
v = params_kw.get('org_id', None)
if v is not None:
data['org_id'] = v
v = params_kw.get('config_group', None)
if v is not None:
data['config_group'] = v
v = params_kw.get('config_key', None)
if v is not None:
data['config_key'] = v
v = params_kw.get('config_value', None)
if v is not None:
data['config_value'] = v
v = params_kw.get('config_type', None)
if v is not None:
data['config_type'] = v
v = params_kw.get('sort_order', None)
if v is not None:
data['sort_order'] = v
await sor.U('cms_site_config', data)
print(json.dumps({'widgettype': 'Message', 'options': {'text': '更新成功', 'messagetype': 'success'}}, ensure_ascii=False))

View File

@ -0,0 +1,20 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
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', '')
print(json.dumps({'status': 'ok', 'data': result}, ensure_ascii=False))

View File

@ -0,0 +1,18 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
_id = params_kw.get('id', '')
if not _id:
print(json.dumps({'status': 'error', 'message': '缺少ID'}, ensure_ascii=False))
else:
ns = {'id': _id}
rows = await sor.R('cms_content', ns)
if rows:
print(json.dumps({'status': 'ok', 'data': rows[0]}, ensure_ascii=False))
else:
print(json.dumps({'status': 'error', 'message': '内容不存在'}, ensure_ascii=False))

View File

@ -0,0 +1,17 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
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]
print(json.dumps({'status': 'ok', 'rows': rows, 'total': len(rows)}, ensure_ascii=False))

View File

@ -0,0 +1,28 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
content_id = params_kw.get('content_id', '')
if not content_id:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '缺少内容ID', 'messagetype': 'error'}}, ensure_ascii=False))
else:
# Update status to pending
await sor.U('cms_content', {'id': content_id, 'status': 'pending'})
# Try to call dingdingflow
try:
from dingdingflow.init import submit_approval
user_id = await get_user()
ns_detail = {'id': content_id}
rows = await sor.R('cms_content', ns_detail)
title = rows[0].get('title', '内容审批') if rows else '内容审批'
result = await submit_approval('content_publish', content_id, title, user_id)
if result and result.get('approval_id'):
await sor.U('cms_content', {'id': content_id, 'approval_id': result['approval_id']})
print(json.dumps({'widgettype': 'Message', 'options': {'text': '已提交审批', 'messagetype': 'success'}}, ensure_ascii=False))
except ImportError:
print(json.dumps({'widgettype': 'Message', 'options': {'text': '审批模块未安装,状态已改为待审批', 'messagetype': 'warning'}}, ensure_ascii=False))

View File

@ -0,0 +1,25 @@
import json
from appPublic.uniqueID import getID
config = getConfig('.')
DBPools(config.databases)
dbname = get_module_dbname('entcms')
sor = DBPools().sqlorContext(dbname)
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)
print(json.dumps({
'widgettype': 'Message',
'options': {'text': '感谢您的留言,我们会尽快联系您!', 'messagetype': 'success'}
}, ensure_ascii=False))

13
entcms/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>"
}
}
]
}

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' });
}
});
});
}

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
entcms/wwwroot/index.ui Normal file

File diff suppressed because one or more lines are too long

38
entcms/wwwroot/menu.ui Normal file
View File

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

13
entcms/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>"
}
}
]
}

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