From 5cfb0e867b279ef087fb26cf79153cc556b09988 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 27 May 2026 15:44:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=80=E5=85=83=E4=BA=91=E7=A7=91?= =?UTF-8?q?=E6=8A=80=E5=AE=98=E7=BD=91CMS=E7=B3=BB=E7=BB=9F=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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条测试用例/开发日志 --- .gitignore | 3 + README.md | 37 + build.sh | 87 +++ dingdingflow/README.md | 20 + dingdingflow/dingdingflow/__init__.py | 1 + dingdingflow/dingdingflow/dingtalk_client.py | 240 +++++++ dingdingflow/dingdingflow/init.py | 432 ++++++++++++ dingdingflow/json/dd_approval_configs.json | 25 + dingdingflow/json/dd_approvals.json | 42 ++ dingdingflow/models/dd_approval_configs.json | 85 +++ dingdingflow/models/dd_approvals.json | 114 ++++ dingdingflow/pyproject.toml | 18 + dingdingflow/scripts/load_path.py | 60 ++ .../api/dd_approval_configs_create.dspy | 41 ++ .../api/dd_approval_configs_delete.dspy | 20 + .../wwwroot/api/dd_approval_configs_list.dspy | 58 ++ .../api/dd_approval_configs_update.dspy | 36 + .../wwwroot/api/dd_approvals_create.dspy | 43 ++ .../wwwroot/api/dd_approvals_delete.dspy | 20 + .../wwwroot/api/dd_approvals_list.dspy | 76 +++ .../wwwroot/api/dd_approvals_update.dspy | 36 + .../wwwroot/api/dingtalk_callback.dspy | 62 ++ dingdingflow/wwwroot/api/submit_approval.dspy | 36 + dingdingflow/wwwroot/index.ui | 77 +++ dingdingflow/wwwroot/menu.ui | 25 + docs/architecture.md | 156 +++++ docs/test-cases.md | 93 +++ docs/work-log-2026-05-27.md | 46 ++ entcms/README.md | 22 + entcms/entcms/__init__.py | 0 entcms/entcms/init.py | 260 +++++++ entcms/init/data.json | 116 ++++ entcms/json/cms_categories_list.json | 37 + entcms/json/cms_content_list.json | 97 +++ entcms/json/cms_leads_list.json | 93 +++ entcms/json/cms_site_config_list.json | 63 ++ entcms/models/cms_categories.json | 80 +++ entcms/models/cms_content.json | 154 +++++ entcms/models/cms_leads.json | 137 ++++ entcms/models/cms_site_config.json | 75 ++ entcms/pyproject.toml | 17 + entcms/scripts/load_path.py | 89 +++ entcms/wwwroot/admin.ui | 267 ++++++++ entcms/wwwroot/api/category_options.dspy | 15 + entcms/wwwroot/api/cms_categories_create.dspy | 36 + entcms/wwwroot/api/cms_categories_delete.dspy | 14 + entcms/wwwroot/api/cms_categories_list.dspy | 32 + entcms/wwwroot/api/cms_categories_update.dspy | 39 ++ entcms/wwwroot/api/cms_content_create.dspy | 60 ++ entcms/wwwroot/api/cms_content_delete.dspy | 14 + entcms/wwwroot/api/cms_content_list.dspy | 36 + entcms/wwwroot/api/cms_content_update.dspy | 71 ++ entcms/wwwroot/api/cms_leads_create.dspy | 68 ++ entcms/wwwroot/api/cms_leads_delete.dspy | 14 + entcms/wwwroot/api/cms_leads_list.dspy | 36 + entcms/wwwroot/api/cms_leads_update.dspy | 71 ++ .../wwwroot/api/cms_site_config_create.dspy | 36 + .../wwwroot/api/cms_site_config_delete.dspy | 14 + entcms/wwwroot/api/cms_site_config_list.dspy | 32 + .../wwwroot/api/cms_site_config_update.dspy | 39 ++ entcms/wwwroot/api/get_config.dspy | 20 + entcms/wwwroot/api/get_content_detail.dspy | 18 + entcms/wwwroot/api/get_published_content.dspy | 17 + .../wwwroot/api/submit_content_approval.dspy | 28 + entcms/wwwroot/api/submit_lead.dspy | 25 + entcms/wwwroot/cases.ui | 13 + entcms/wwwroot/cms_scripts.js | 153 +++++ entcms/wwwroot/cms_styles.css | 640 ++++++++++++++++++ entcms/wwwroot/index.ui | 17 + entcms/wwwroot/menu.ui | 38 ++ entcms/wwwroot/news.ui | 13 + entcms/wwwroot/news_detail.ui | 13 + 72 files changed, 5118 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 build.sh create mode 100644 dingdingflow/README.md create mode 100644 dingdingflow/dingdingflow/__init__.py create mode 100644 dingdingflow/dingdingflow/dingtalk_client.py create mode 100644 dingdingflow/dingdingflow/init.py create mode 100644 dingdingflow/json/dd_approval_configs.json create mode 100644 dingdingflow/json/dd_approvals.json create mode 100644 dingdingflow/models/dd_approval_configs.json create mode 100644 dingdingflow/models/dd_approvals.json create mode 100644 dingdingflow/pyproject.toml create mode 100644 dingdingflow/scripts/load_path.py create mode 100644 dingdingflow/wwwroot/api/dd_approval_configs_create.dspy create mode 100644 dingdingflow/wwwroot/api/dd_approval_configs_delete.dspy create mode 100644 dingdingflow/wwwroot/api/dd_approval_configs_list.dspy create mode 100644 dingdingflow/wwwroot/api/dd_approval_configs_update.dspy create mode 100644 dingdingflow/wwwroot/api/dd_approvals_create.dspy create mode 100644 dingdingflow/wwwroot/api/dd_approvals_delete.dspy create mode 100644 dingdingflow/wwwroot/api/dd_approvals_list.dspy create mode 100644 dingdingflow/wwwroot/api/dd_approvals_update.dspy create mode 100644 dingdingflow/wwwroot/api/dingtalk_callback.dspy create mode 100644 dingdingflow/wwwroot/api/submit_approval.dspy create mode 100644 dingdingflow/wwwroot/index.ui create mode 100644 dingdingflow/wwwroot/menu.ui create mode 100644 docs/architecture.md create mode 100644 docs/test-cases.md create mode 100644 docs/work-log-2026-05-27.md create mode 100644 entcms/README.md create mode 100644 entcms/entcms/__init__.py create mode 100644 entcms/entcms/init.py create mode 100644 entcms/init/data.json create mode 100644 entcms/json/cms_categories_list.json create mode 100644 entcms/json/cms_content_list.json create mode 100644 entcms/json/cms_leads_list.json create mode 100644 entcms/json/cms_site_config_list.json create mode 100644 entcms/models/cms_categories.json create mode 100644 entcms/models/cms_content.json create mode 100644 entcms/models/cms_leads.json create mode 100644 entcms/models/cms_site_config.json create mode 100644 entcms/pyproject.toml create mode 100644 entcms/scripts/load_path.py create mode 100644 entcms/wwwroot/admin.ui create mode 100644 entcms/wwwroot/api/category_options.dspy create mode 100644 entcms/wwwroot/api/cms_categories_create.dspy create mode 100644 entcms/wwwroot/api/cms_categories_delete.dspy create mode 100644 entcms/wwwroot/api/cms_categories_list.dspy create mode 100644 entcms/wwwroot/api/cms_categories_update.dspy create mode 100644 entcms/wwwroot/api/cms_content_create.dspy create mode 100644 entcms/wwwroot/api/cms_content_delete.dspy create mode 100644 entcms/wwwroot/api/cms_content_list.dspy create mode 100644 entcms/wwwroot/api/cms_content_update.dspy create mode 100644 entcms/wwwroot/api/cms_leads_create.dspy create mode 100644 entcms/wwwroot/api/cms_leads_delete.dspy create mode 100644 entcms/wwwroot/api/cms_leads_list.dspy create mode 100644 entcms/wwwroot/api/cms_leads_update.dspy create mode 100644 entcms/wwwroot/api/cms_site_config_create.dspy create mode 100644 entcms/wwwroot/api/cms_site_config_delete.dspy create mode 100644 entcms/wwwroot/api/cms_site_config_list.dspy create mode 100644 entcms/wwwroot/api/cms_site_config_update.dspy create mode 100644 entcms/wwwroot/api/get_config.dspy create mode 100644 entcms/wwwroot/api/get_content_detail.dspy create mode 100644 entcms/wwwroot/api/get_published_content.dspy create mode 100644 entcms/wwwroot/api/submit_content_approval.dspy create mode 100644 entcms/wwwroot/api/submit_lead.dspy create mode 100644 entcms/wwwroot/cases.ui create mode 100644 entcms/wwwroot/cms_scripts.js create mode 100644 entcms/wwwroot/cms_styles.css create mode 100644 entcms/wwwroot/index.ui create mode 100644 entcms/wwwroot/menu.ui create mode 100644 entcms/wwwroot/news.ui create mode 100644 entcms/wwwroot/news_detail.ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3707b02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +__pycache__/ +mysql.ddl.sql diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f442ed --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..17d2d44 --- /dev/null +++ b/build.sh @@ -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服务" diff --git a/dingdingflow/README.md b/dingdingflow/README.md new file mode 100644 index 0000000..25ec18f --- /dev/null +++ b/dingdingflow/README.md @@ -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 - 钉钉回调(公开) diff --git a/dingdingflow/dingdingflow/__init__.py b/dingdingflow/dingdingflow/__init__.py new file mode 100644 index 0000000..d2f8ab5 --- /dev/null +++ b/dingdingflow/dingdingflow/__init__.py @@ -0,0 +1 @@ +# dingdingflow module diff --git a/dingdingflow/dingdingflow/dingtalk_client.py b/dingdingflow/dingdingflow/dingtalk_client.py new file mode 100644 index 0000000..d648d07 --- /dev/null +++ b/dingdingflow/dingdingflow/dingtalk_client.py @@ -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 diff --git a/dingdingflow/dingdingflow/init.py b/dingdingflow/dingdingflow/init.py new file mode 100644 index 0000000..624bce7 --- /dev/null +++ b/dingdingflow/dingdingflow/init.py @@ -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 diff --git a/dingdingflow/json/dd_approval_configs.json b/dingdingflow/json/dd_approval_configs.json new file mode 100644 index 0000000..e8a4f23 --- /dev/null +++ b/dingdingflow/json/dd_approval_configs.json @@ -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')}}" + } + } +} diff --git a/dingdingflow/json/dd_approvals.json b/dingdingflow/json/dd_approvals.json new file mode 100644 index 0000000..0ed9bf0 --- /dev/null +++ b/dingdingflow/json/dd_approvals.json @@ -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')}}" + } + } +} diff --git a/dingdingflow/models/dd_approval_configs.json b/dingdingflow/models/dd_approval_configs.json new file mode 100644 index 0000000..26e8597 --- /dev/null +++ b/dingdingflow/models/dd_approval_configs.json @@ -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": [] +} \ No newline at end of file diff --git a/dingdingflow/models/dd_approvals.json b/dingdingflow/models/dd_approvals.json new file mode 100644 index 0000000..f01dcf7 --- /dev/null +++ b/dingdingflow/models/dd_approvals.json @@ -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" + ] + } + ] +} \ No newline at end of file diff --git a/dingdingflow/pyproject.toml b/dingdingflow/pyproject.toml new file mode 100644 index 0000000..2a31ecc --- /dev/null +++ b/dingdingflow/pyproject.toml @@ -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*"] diff --git a/dingdingflow/scripts/load_path.py b/dingdingflow/scripts/load_path.py new file mode 100644 index 0000000..c4fcbc3 --- /dev/null +++ b/dingdingflow/scripts/load_path.py @@ -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("完成") diff --git a/dingdingflow/wwwroot/api/dd_approval_configs_create.dspy b/dingdingflow/wwwroot/api/dd_approval_configs_create.dspy new file mode 100644 index 0000000..c026e14 --- /dev/null +++ b/dingdingflow/wwwroot/api/dd_approval_configs_create.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/dd_approval_configs_delete.dspy b/dingdingflow/wwwroot/api/dd_approval_configs_delete.dspy new file mode 100644 index 0000000..6742780 --- /dev/null +++ b/dingdingflow/wwwroot/api/dd_approval_configs_delete.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/dd_approval_configs_list.dspy b/dingdingflow/wwwroot/api/dd_approval_configs_list.dspy new file mode 100644 index 0000000..59ced83 --- /dev/null +++ b/dingdingflow/wwwroot/api/dd_approval_configs_list.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/dd_approval_configs_update.dspy b/dingdingflow/wwwroot/api/dd_approval_configs_update.dspy new file mode 100644 index 0000000..972ebe6 --- /dev/null +++ b/dingdingflow/wwwroot/api/dd_approval_configs_update.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/dd_approvals_create.dspy b/dingdingflow/wwwroot/api/dd_approvals_create.dspy new file mode 100644 index 0000000..4b9572f --- /dev/null +++ b/dingdingflow/wwwroot/api/dd_approvals_create.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/dd_approvals_delete.dspy b/dingdingflow/wwwroot/api/dd_approvals_delete.dspy new file mode 100644 index 0000000..b4d517c --- /dev/null +++ b/dingdingflow/wwwroot/api/dd_approvals_delete.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/dd_approvals_list.dspy b/dingdingflow/wwwroot/api/dd_approvals_list.dspy new file mode 100644 index 0000000..62db08a --- /dev/null +++ b/dingdingflow/wwwroot/api/dd_approvals_list.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/dd_approvals_update.dspy b/dingdingflow/wwwroot/api/dd_approvals_update.dspy new file mode 100644 index 0000000..86dbf4d --- /dev/null +++ b/dingdingflow/wwwroot/api/dd_approvals_update.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/dingtalk_callback.dspy b/dingdingflow/wwwroot/api/dingtalk_callback.dspy new file mode 100644 index 0000000..8199368 --- /dev/null +++ b/dingdingflow/wwwroot/api/dingtalk_callback.dspy @@ -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) diff --git a/dingdingflow/wwwroot/api/submit_approval.dspy b/dingdingflow/wwwroot/api/submit_approval.dspy new file mode 100644 index 0000000..b97d47b --- /dev/null +++ b/dingdingflow/wwwroot/api/submit_approval.dspy @@ -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) diff --git a/dingdingflow/wwwroot/index.ui b/dingdingflow/wwwroot/index.ui new file mode 100644 index 0000000..75e0141 --- /dev/null +++ b/dingdingflow/wwwroot/index.ui @@ -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"} + } + ] +} diff --git a/dingdingflow/wwwroot/menu.ui b/dingdingflow/wwwroot/menu.ui new file mode 100644 index 0000000..22cc057 --- /dev/null +++ b/dingdingflow/wwwroot/menu.ui @@ -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')}}" + } + ] + } + ] + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..319ffdd --- /dev/null +++ b/docs/architecture.md @@ -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 +``` diff --git a/docs/test-cases.md b/docs/test-cases.md new file mode 100644 index 0000000..d0e9969 --- /dev/null +++ b/docs/test-cases.md @@ -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 +- 通过率: 待部署后统计 diff --git a/docs/work-log-2026-05-27.md b/docs/work-log-2026-05-27.md new file mode 100644 index 0000000..e7bb3fe --- /dev/null +++ b/docs/work-log-2026-05-27.md @@ -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 (首次提交) diff --git a/entcms/README.md b/entcms/README.md new file mode 100644 index 0000000..f974f03 --- /dev/null +++ b/entcms/README.md @@ -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 - 配置管理 diff --git a/entcms/entcms/__init__.py b/entcms/entcms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/entcms/entcms/init.py b/entcms/entcms/init.py new file mode 100644 index 0000000..a1d2bb9 --- /dev/null +++ b/entcms/entcms/init.py @@ -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 diff --git a/entcms/init/data.json b/entcms/init/data.json new file mode 100644 index 0000000..63774e1 --- /dev/null +++ b/entcms/init/data.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/entcms/json/cms_categories_list.json b/entcms/json/cms_categories_list.json new file mode 100644 index 0000000..c688bcc --- /dev/null +++ b/entcms/json/cms_categories_list.json @@ -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')}}" + } + } +} \ No newline at end of file diff --git a/entcms/json/cms_content_list.json b/entcms/json/cms_content_list.json new file mode 100644 index 0000000..6837c16 --- /dev/null +++ b/entcms/json/cms_content_list.json @@ -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')}}" + } + } +} \ No newline at end of file diff --git a/entcms/json/cms_leads_list.json b/entcms/json/cms_leads_list.json new file mode 100644 index 0000000..5b88372 --- /dev/null +++ b/entcms/json/cms_leads_list.json @@ -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')}}" + } + } +} \ No newline at end of file diff --git a/entcms/json/cms_site_config_list.json b/entcms/json/cms_site_config_list.json new file mode 100644 index 0000000..02b1d34 --- /dev/null +++ b/entcms/json/cms_site_config_list.json @@ -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')}}" + } + } +} \ No newline at end of file diff --git a/entcms/models/cms_categories.json b/entcms/models/cms_categories.json new file mode 100644 index 0000000..e2b282e --- /dev/null +++ b/entcms/models/cms_categories.json @@ -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" + ] + } + ] +} \ No newline at end of file diff --git a/entcms/models/cms_content.json b/entcms/models/cms_content.json new file mode 100644 index 0000000..327c005 --- /dev/null +++ b/entcms/models/cms_content.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/entcms/models/cms_leads.json b/entcms/models/cms_leads.json new file mode 100644 index 0000000..5eac4cb --- /dev/null +++ b/entcms/models/cms_leads.json @@ -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" + ] + } + ] +} \ No newline at end of file diff --git a/entcms/models/cms_site_config.json b/entcms/models/cms_site_config.json new file mode 100644 index 0000000..195ce3b --- /dev/null +++ b/entcms/models/cms_site_config.json @@ -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" + ] + } + ] +} \ No newline at end of file diff --git a/entcms/pyproject.toml b/entcms/pyproject.toml new file mode 100644 index 0000000..4f98e6e --- /dev/null +++ b/entcms/pyproject.toml @@ -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*"] diff --git a/entcms/scripts/load_path.py b/entcms/scripts/load_path.py new file mode 100644 index 0000000..532133a --- /dev/null +++ b/entcms/scripts/load_path.py @@ -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("完成") diff --git a/entcms/wwwroot/admin.ui b/entcms/wwwroot/admin.ui new file mode 100644 index 0000000..8c1bb6e --- /dev/null +++ b/entcms/wwwroot/admin.ui @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/entcms/wwwroot/api/category_options.dspy b/entcms/wwwroot/api/category_options.dspy new file mode 100644 index 0000000..c4b8f0e --- /dev/null +++ b/entcms/wwwroot/api/category_options.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_categories_create.dspy b/entcms/wwwroot/api/cms_categories_create.dspy new file mode 100644 index 0000000..2eb64f5 --- /dev/null +++ b/entcms/wwwroot/api/cms_categories_create.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_categories_delete.dspy b/entcms/wwwroot/api/cms_categories_delete.dspy new file mode 100644 index 0000000..96fa018 --- /dev/null +++ b/entcms/wwwroot/api/cms_categories_delete.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_categories_list.dspy b/entcms/wwwroot/api/cms_categories_list.dspy new file mode 100644 index 0000000..dde93a7 --- /dev/null +++ b/entcms/wwwroot/api/cms_categories_list.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_categories_update.dspy b/entcms/wwwroot/api/cms_categories_update.dspy new file mode 100644 index 0000000..f69ca9b --- /dev/null +++ b/entcms/wwwroot/api/cms_categories_update.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_content_create.dspy b/entcms/wwwroot/api/cms_content_create.dspy new file mode 100644 index 0000000..a6e90f7 --- /dev/null +++ b/entcms/wwwroot/api/cms_content_create.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_content_delete.dspy b/entcms/wwwroot/api/cms_content_delete.dspy new file mode 100644 index 0000000..d122bee --- /dev/null +++ b/entcms/wwwroot/api/cms_content_delete.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_content_list.dspy b/entcms/wwwroot/api/cms_content_list.dspy new file mode 100644 index 0000000..25de280 --- /dev/null +++ b/entcms/wwwroot/api/cms_content_list.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_content_update.dspy b/entcms/wwwroot/api/cms_content_update.dspy new file mode 100644 index 0000000..204f018 --- /dev/null +++ b/entcms/wwwroot/api/cms_content_update.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_leads_create.dspy b/entcms/wwwroot/api/cms_leads_create.dspy new file mode 100644 index 0000000..7b8071b --- /dev/null +++ b/entcms/wwwroot/api/cms_leads_create.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_leads_delete.dspy b/entcms/wwwroot/api/cms_leads_delete.dspy new file mode 100644 index 0000000..649de95 --- /dev/null +++ b/entcms/wwwroot/api/cms_leads_delete.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_leads_list.dspy b/entcms/wwwroot/api/cms_leads_list.dspy new file mode 100644 index 0000000..d394b07 --- /dev/null +++ b/entcms/wwwroot/api/cms_leads_list.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_leads_update.dspy b/entcms/wwwroot/api/cms_leads_update.dspy new file mode 100644 index 0000000..496bb61 --- /dev/null +++ b/entcms/wwwroot/api/cms_leads_update.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_site_config_create.dspy b/entcms/wwwroot/api/cms_site_config_create.dspy new file mode 100644 index 0000000..897fc17 --- /dev/null +++ b/entcms/wwwroot/api/cms_site_config_create.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_site_config_delete.dspy b/entcms/wwwroot/api/cms_site_config_delete.dspy new file mode 100644 index 0000000..4f77371 --- /dev/null +++ b/entcms/wwwroot/api/cms_site_config_delete.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_site_config_list.dspy b/entcms/wwwroot/api/cms_site_config_list.dspy new file mode 100644 index 0000000..8ea29ab --- /dev/null +++ b/entcms/wwwroot/api/cms_site_config_list.dspy @@ -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)) diff --git a/entcms/wwwroot/api/cms_site_config_update.dspy b/entcms/wwwroot/api/cms_site_config_update.dspy new file mode 100644 index 0000000..b273c47 --- /dev/null +++ b/entcms/wwwroot/api/cms_site_config_update.dspy @@ -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)) diff --git a/entcms/wwwroot/api/get_config.dspy b/entcms/wwwroot/api/get_config.dspy new file mode 100644 index 0000000..5dc0410 --- /dev/null +++ b/entcms/wwwroot/api/get_config.dspy @@ -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)) diff --git a/entcms/wwwroot/api/get_content_detail.dspy b/entcms/wwwroot/api/get_content_detail.dspy new file mode 100644 index 0000000..65555dd --- /dev/null +++ b/entcms/wwwroot/api/get_content_detail.dspy @@ -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)) diff --git a/entcms/wwwroot/api/get_published_content.dspy b/entcms/wwwroot/api/get_published_content.dspy new file mode 100644 index 0000000..1bb08f6 --- /dev/null +++ b/entcms/wwwroot/api/get_published_content.dspy @@ -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)) diff --git a/entcms/wwwroot/api/submit_content_approval.dspy b/entcms/wwwroot/api/submit_content_approval.dspy new file mode 100644 index 0000000..cdc7021 --- /dev/null +++ b/entcms/wwwroot/api/submit_content_approval.dspy @@ -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)) diff --git a/entcms/wwwroot/api/submit_lead.dspy b/entcms/wwwroot/api/submit_lead.dspy new file mode 100644 index 0000000..c3daf08 --- /dev/null +++ b/entcms/wwwroot/api/submit_lead.dspy @@ -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)) diff --git a/entcms/wwwroot/cases.ui b/entcms/wwwroot/cases.ui new file mode 100644 index 0000000..d8c6ca4 --- /dev/null +++ b/entcms/wwwroot/cases.ui @@ -0,0 +1,13 @@ +{% set all_cases = get_published_content('case', 20) %} +{ + "widgettype": "VBox", + "options": {"width": "100%", "css": "site-root"}, + "subwidgets": [ + { + "widgettype": "Html", + "options": { + "html": "