Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb92a4e591 | ||
|
|
78ccca61c3 |
@@ -3,7 +3,7 @@
|
||||
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
||||
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
||||
|
||||
**当前版本:V2.0**(多 Agent 协作 / 自动评估 / 模板市场 / 企业版)
|
||||
**当前版本:V3.0**(交付体系 / 精准权限 / 多 Agent 协作 / 自动评估 / 模板市场 / 企业版)
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +49,24 @@
|
||||
| ⚖️ 合规 | 全量数据导出(JSON)、审计 CSV 导出、数据保留期清理、PII 脱敏(邮箱/手机/身份证)、数据使用同意凭证 |
|
||||
| 🔒 私有化 | 单机 SQLite + 无 CDN 前端,完全离线可用;Docker 一键部署(见下) |
|
||||
|
||||
### 📦 交付体系(V3 新增)
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 📬 送达者邮箱 | 新建项目**必填**送达者(人)邮箱;项目**完成**或遇到**无法绕开的难关**时自动邮件及时通知 |
|
||||
| 🗂️ 项目工作目录 | 每个项目独立工作目录 `data/workspace/project_<id>/`,中间产物/交付物分类存放,互不污染;支持网页上传/下载/删除 |
|
||||
| 🌐 网页交付物 | 一键部署到 `data/demo/<id>/`,经 `/demo/<id>/` **免登录公开访问**,送达者直接打开链接查看 |
|
||||
| 🗜️ 打包交付 | 工作目录一键 zip 打包(`data/packages/`),随邮件附件发送给送达者 |
|
||||
| ✉️ 手动通知 | 难关说明/进展可随时手动邮件通知送达者;交付全流程留痕(交付记录) |
|
||||
|
||||
### 🔑 精准权限(V3 新增)
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 👥 用户管理 | 管理员增删改用户,管理用户的项目所属与 Worker 权限(企业版 → 用户与权限 → 🔑 授权) |
|
||||
| 📁 项目授权 | view 查看 / manage 管理(建任务/执行/上传交付物/发送)/ admin 管理员;创建者自动成为项目管理员 |
|
||||
| 🤖 Worker 授权 | view 查看档案 / use 使用(可指派任务)/ manage 管理;Worker 的注册/删除仍仅管理员 |
|
||||
| 🎭 角色体系 | 管理员=全部;审计员=全量**只读**;成员=仅可见被授权内容,仪表盘/报表/日志/协作/评估全部按权限过滤 |
|
||||
| 🗺️ 授权总览 | 一键查看所有用户的 项目×Worker 授权矩阵,杜绝越权 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
@@ -84,22 +102,23 @@ docker run -d --name aiworker -p 16071:16071 \
|
||||
|
||||
```
|
||||
ai-worker-platform/
|
||||
├── app.py # Flask 应用 + REST API(V1 + V2 路由)
|
||||
├── app.py # Flask 应用 + REST API(V1 + V2 + V3 路由)
|
||||
├── config.py # 供应商/定价/鉴权/告警阈值/邮件 SMTP
|
||||
├── db.py # SQLite 数据层 + V2 迁移(users/audit/agent/eval/templates)
|
||||
├── engine.py # V1 执行引擎:DAG/自动触发/RAG 注入/告警
|
||||
├── db.py # SQLite 数据层 + V2/V3 迁移(users/audit/agent/eval/templates/deliverables/grants)
|
||||
├── engine.py # V1 执行引擎:DAG/自动触发/RAG 注入/告警/难关通知
|
||||
├── agents.py # V2 多 Agent 协作引擎:supervisor/review/debate
|
||||
├── eval.py # V2 自动评估:数据集/LLM 评委/沉淀/排行榜
|
||||
├── templates.py # V2 模板市场:三类模板 + 占位符渲染 + 应用
|
||||
├── enterprise.py # V2 企业版:用户/RBAC/OIDC/LDAP/审计/合规
|
||||
├── enterprise.py # V2/V3 企业版:用户/RBAC/SSO/审计/合规 + 项目/Worker 精准授权
|
||||
├── delivery.py # V3 交付体系:工作目录/Demo 部署/打包/邮件送达/难关通知
|
||||
├── llm_gateway.py # 统一模型网关
|
||||
├── rag.py # 知识库:分块 + BM25 检索
|
||||
├── notify.py # 飞书/企微/邮件通知
|
||||
├── dag_verify.py # DAG 全链路验证脚本
|
||||
├── seed.py # 演示数据
|
||||
├── start.sh # 启停脚本
|
||||
├── static/ # 前端 SPA(含 V2 四页)
|
||||
└── data/ # SQLite 库;logs/ 运行日志
|
||||
├── static/ # 前端 SPA(含 V3 交付页/授权管理)
|
||||
└── data/ # SQLite 库;workspace/ 项目工作目录;demo/ 网页Demo;packages/ 打包件;logs/ 运行日志
|
||||
```
|
||||
|
||||
## V2 开放 API 摘要
|
||||
@@ -120,12 +139,19 @@ ai-worker-platform/
|
||||
|
||||
企业版:
|
||||
- `GET/POST /api/enterprise/users`(管理员)| `PUT/DELETE /api/enterprise/users/<id>`
|
||||
- `GET/PUT /api/enterprise/users/<id>/grants` 精准授权(项目/Worker 权限)| `GET /api/enterprise/grants/overview` 授权总览
|
||||
- `GET/PUT /api/enterprise/sso` SSO 配置 | `POST /api/enterprise/sso/oidc/login` 发起 OIDC | `GET /api/enterprise/sso/oidc/callback` 回调
|
||||
- `POST /api/enterprise/sso/ldap/test` LDAP 连通测试
|
||||
- `GET /api/enterprise/audit` 审计日志 | `GET /api/enterprise/audit/export` CSV 导出
|
||||
- `GET /api/enterprise/export` 全量数据导出(JSON)| `POST /api/enterprise/compliance` 保留期/脱敏设置
|
||||
|
||||
交付体系(V3):
|
||||
- `GET /api/projects/<pid>/workspace` 工作目录文件列表 | `POST .../workspace/upload` 上传(multipart)| `GET .../workspace/download?path=` 下载 | `DELETE .../workspace?path=` 删除
|
||||
- `POST /api/projects/<pid>/deploy` 网页交付物部署 Demo | `POST .../package` zip 打包 | `POST .../deliver` 邮件交付(Demo 链接 + 附件)
|
||||
- `POST /api/projects/<pid>/complete` 完成项目并交付 | `POST .../notify_deliverer` 手动通知送达者 | `GET /api/projects/<pid>/deliverables` 交付记录
|
||||
- `GET /demo/<pid>/` 公开 Demo 地址(送达者免登录访问)
|
||||
|
||||
## 路线图
|
||||
|
||||
- **V2.1**:Temporal 持久执行、多 Agent 协作接入项目任务、eval 回归对比视图
|
||||
- **V3**:多租户 SaaS 化、工作流画布(拖拽编排)、插件市场
|
||||
- **V3**:多租户 SaaS 化、工作流画布(拖拽编排)、插件市场 ✅ 交付体系 + 精准权限(V3.0)
|
||||
@@ -98,5 +98,8 @@ EMAIL = {
|
||||
'from_name': 'AI Worker 平台',
|
||||
}
|
||||
|
||||
# 公网访问地址(Demo 链接/邮件中的回链基准;留空则用请求 host)
|
||||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://121.40.164.32:16071')
|
||||
|
||||
# 自动路由:按模型单价升序挑选可用 Worker
|
||||
AUTO_ROUTE_POOL = 'enabled' # enabled | all
|
||||
@@ -16,6 +16,12 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
acceptance_criteria TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'active', -- planning/active/done/archived
|
||||
budget_limit REAL DEFAULT 0, -- 项目预算上限(元),0=不限
|
||||
deliver_email TEXT DEFAULT '', -- 送达者(人)邮箱,新建项目必填
|
||||
deliver_type TEXT DEFAULT 'web', -- 交付物类型 web=网页 / file=文件包
|
||||
deliver_note TEXT DEFAULT '', -- 交付说明
|
||||
workspace_dir TEXT DEFAULT '', -- 项目工作目录(相对 data/ 的目录名)
|
||||
demo_url TEXT DEFAULT '', -- 网页交付物 Demo 访问地址
|
||||
delivered_at INTEGER, -- 最近一次交付/送达时间
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
@@ -258,6 +264,44 @@ CREATE TABLE IF NOT EXISTS enterprise_settings (
|
||||
value TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
-- ===================================================================
|
||||
-- V3 表结构:交付体系(工作目录/交付物/Demo/邮件送达) + 用户授权(项目/Worker 权限)
|
||||
-- ===================================================================
|
||||
CREATE TABLE IF NOT EXISTS project_deliverables (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT DEFAULT 'file', -- file/dir/webpage/package
|
||||
path TEXT DEFAULT '', -- 相对项目工作目录路径 / 打包文件名
|
||||
demo_url TEXT DEFAULT '', -- 网页交付物的 Demo 访问地址
|
||||
size INTEGER DEFAULT 0,
|
||||
note TEXT DEFAULT '',
|
||||
created_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
perm TEXT DEFAULT 'view', -- view 查看 / manage 管理 / admin 管理员
|
||||
created_at INTEGER,
|
||||
UNIQUE(user_id, project_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_workers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
worker_id INTEGER NOT NULL,
|
||||
perm TEXT DEFAULT 'view', -- view 查看 / use 使用(可指派任务)/ manage 管理(可改配置)
|
||||
created_at INTEGER,
|
||||
UNIQUE(user_id, worker_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deliverables_project ON project_deliverables(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_projects_user ON user_projects(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_projects_project ON user_projects(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_workers_user ON user_workers(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_workers_worker ON user_workers(worker_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_steps_run ON agent_steps(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_cases_ds ON eval_cases(dataset_id);
|
||||
@@ -286,6 +330,22 @@ def _migrate():
|
||||
conn.execute('ALTER TABLE tasks ADD COLUMN deleted INTEGER DEFAULT 0')
|
||||
conn.execute('ALTER TABLE tasks ADD COLUMN deleted_at INTEGER')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted)')
|
||||
# V3:projects 交付字段
|
||||
pcols = {r['name'] for r in conn.execute('PRAGMA table_info(projects)')}
|
||||
for col, ddl in (
|
||||
('deliver_email', "ALTER TABLE projects ADD COLUMN deliver_email TEXT DEFAULT ''"),
|
||||
('deliver_type', "ALTER TABLE projects ADD COLUMN deliver_type TEXT DEFAULT 'web'"),
|
||||
('deliver_note', "ALTER TABLE projects ADD COLUMN deliver_note TEXT DEFAULT ''"),
|
||||
('workspace_dir', "ALTER TABLE projects ADD COLUMN workspace_dir TEXT DEFAULT ''"),
|
||||
('demo_url', "ALTER TABLE projects ADD COLUMN demo_url TEXT DEFAULT ''"),
|
||||
('delivered_at', 'ALTER TABLE projects ADD COLUMN delivered_at INTEGER'),
|
||||
):
|
||||
if col not in pcols:
|
||||
conn.execute(ddl)
|
||||
# V3:老项目补齐工作目录名
|
||||
for r in conn.execute("SELECT id, workspace_dir FROM projects WHERE workspace_dir IS NULL OR workspace_dir=''"):
|
||||
conn.execute('UPDATE projects SET workspace_dir=? WHERE id=?',
|
||||
('project_%d' % r['id'], r['id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
V3 交付体系
|
||||
- 每个项目独立工作目录:data/workspace/project_<id>/(中间产物与交付物隔离存放)
|
||||
- 网页交付物 → data/demo/<id>/ 部署,经 /demo/<id>/ 公开访问(送达者无需登录)
|
||||
- 文件包交付物 → zip 打包到 data/packages/,随邮件附件发送
|
||||
- 送达者通知:项目完成 / 遇到无法绕开的难关时,邮件及时通知 deliver_email
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import shutil
|
||||
import zipfile
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.utils import formataddr
|
||||
|
||||
import db
|
||||
from config import DATA_DIR, EMAIL, PUBLIC_BASE_URL
|
||||
|
||||
WORKSPACE_ROOT = os.path.join(DATA_DIR, 'workspace')
|
||||
DEMO_ROOT = os.path.join(DATA_DIR, 'demo')
|
||||
PACKAGE_ROOT = os.path.join(DATA_DIR, 'packages')
|
||||
|
||||
# 项目维度 blocker 通知去重窗口(秒):同一项目短时间内不重复打扰送达者
|
||||
BLOCKER_DEDUP_SECONDS = 1800
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
for d in (WORKSPACE_ROOT, DEMO_ROOT, PACKAGE_ROOT):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
|
||||
def workspace_path(project):
|
||||
"""项目工作目录绝对路径(不存在则创建)"""
|
||||
pid = project['id'] if isinstance(project, dict) else project
|
||||
d = os.path.join(WORKSPACE_ROOT, f'project_{pid}')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def demo_path(project):
|
||||
"""Demo 部署目录绝对路径"""
|
||||
pid = project['id'] if isinstance(project, dict) else project
|
||||
return os.path.join(DEMO_ROOT, f'project_{pid}')
|
||||
|
||||
|
||||
def package_dir():
|
||||
os.makedirs(PACKAGE_ROOT, exist_ok=True)
|
||||
return PACKAGE_ROOT
|
||||
|
||||
|
||||
def _safe_relpath(relpath):
|
||||
"""路径穿越防护:仅允许工作目录内的相对路径"""
|
||||
relpath = (relpath or '').replace('\\', '/').strip('/')
|
||||
if not relpath:
|
||||
return ''
|
||||
if '..' in relpath.split('/') or relpath.startswith('/'):
|
||||
raise ValueError('非法路径')
|
||||
return relpath
|
||||
|
||||
|
||||
def list_workspace(project):
|
||||
"""递归列出工作目录文件:相对路径 + 类型 + 大小 + 修改时间"""
|
||||
root = workspace_path(project)
|
||||
out = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
# 忽略临时目录
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
||||
for fn in sorted(filenames):
|
||||
if fn.startswith('.'):
|
||||
continue
|
||||
full = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(full, root).replace(os.sep, '/')
|
||||
try:
|
||||
size = os.path.getsize(full)
|
||||
mtime = int(os.path.getmtime(full))
|
||||
except OSError:
|
||||
size, mtime = 0, 0
|
||||
out.append({'path': rel, 'name': fn, 'size': size,
|
||||
'ext': os.path.splitext(fn)[1].lstrip('.').lower(),
|
||||
'mtime': mtime})
|
||||
out.sort(key=lambda x: x['path'])
|
||||
return out
|
||||
|
||||
|
||||
def save_upload(project, file_storage, subdir=''):
|
||||
"""保存上传文件到工作目录,返回相对路径"""
|
||||
fn = os.path.basename(file_storage.filename or '')
|
||||
fn = re.sub(r'[\\/:*?"<>|]', '_', fn).strip()
|
||||
if not fn:
|
||||
raise ValueError('文件名为空')
|
||||
rel = _safe_relpath(subdir)
|
||||
target_dir = os.path.join(workspace_path(project), rel) if rel else workspace_path(project)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
target = os.path.join(target_dir, fn)
|
||||
file_storage.save(target)
|
||||
return (rel + '/' if rel else '') + fn
|
||||
|
||||
|
||||
def delete_workspace_file(project, relpath):
|
||||
rel = _safe_relpath(relpath)
|
||||
if not rel:
|
||||
raise ValueError('请指定要删除的文件')
|
||||
full = os.path.join(workspace_path(project), rel)
|
||||
if not os.path.isfile(full):
|
||||
raise ValueError('文件不存在')
|
||||
os.remove(full)
|
||||
return rel
|
||||
|
||||
|
||||
def demo_url_of(project, base_url=''):
|
||||
"""生成 Demo 访问地址"""
|
||||
base = (base_url or PUBLIC_BASE_URL).rstrip('/')
|
||||
return f'{base}/demo/{project["id"]}/'
|
||||
|
||||
|
||||
def deploy_demo(project, base_url=''):
|
||||
"""把项目工作目录部署为可公开访问的 Demo(网页交付物)
|
||||
- 将工作目录文件复制到 data/demo/project_<id>/
|
||||
- 无 index.html 时生成一个简易索引页
|
||||
- 记录 demo_url 到项目
|
||||
"""
|
||||
src = workspace_path(project)
|
||||
dst = demo_path(project)
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
# 清空旧内容,避免残留文件污染
|
||||
for item in os.listdir(dst):
|
||||
p = os.path.join(dst, item)
|
||||
if os.path.isdir(p):
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
else:
|
||||
os.remove(p)
|
||||
copied = 0
|
||||
for dirpath, dirnames, filenames in os.walk(src):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
||||
rel = os.path.relpath(dirpath, src)
|
||||
if rel == '.':
|
||||
rel = ''
|
||||
for fn in filenames:
|
||||
if fn.startswith('.') or fn.endswith('.zip'):
|
||||
continue
|
||||
sub = os.path.join(dst, rel) if rel else dst
|
||||
os.makedirs(sub, exist_ok=True)
|
||||
shutil.copy2(os.path.join(dirpath, fn), os.path.join(sub, fn))
|
||||
copied += 1
|
||||
index = os.path.join(dst, 'index.html')
|
||||
if not os.path.isfile(index):
|
||||
files = sorted(list_workspace(project), key=lambda x: x['path'])
|
||||
links = '\n'.join(
|
||||
f'<li><a href="{os.path.basename(f["path"])}">{os.path.basename(f["path"])}</a>'
|
||||
f' <small>({f["size"]} B)</small></li>'
|
||||
for f in files if f['ext'] in ('html', 'htm') or '/' not in f['path'])
|
||||
if not links:
|
||||
links = '<li>(工作目录中暂无网页文件)</li>'
|
||||
with open(index, 'w', encoding='utf-8') as fh:
|
||||
fh.write(f'''<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8">
|
||||
<title>{project['name']} · Demo</title>
|
||||
<style>body{{font-family:system-ui;max-width:720px;margin:40px auto;padding:0 16px;color:#333}}
|
||||
h1{{font-size:20px}} li{{margin:8px 0}} a{{color:#2f6fed}}</style></head>
|
||||
<body><h1>📦 {project['name']} · 交付 Demo</h1>
|
||||
<p>本页面由 AI Worker 平台自动生成,展示项目工作目录中的交付文件:</p>
|
||||
<ul>{links}</ul></body></html>''')
|
||||
url = demo_url_of(project, base_url)
|
||||
db.w('UPDATE projects SET demo_url=?, updated_at=? WHERE id=?', (url, db.now(), project['id']))
|
||||
return {'copied': copied, 'demo_url': url}
|
||||
|
||||
|
||||
def package_project(project, name=''):
|
||||
"""把项目工作目录打包为 zip,落盘到 data/packages/,返回 {path, size, relname}"""
|
||||
ensure_dirs()
|
||||
src = workspace_path(project)
|
||||
ts = time.strftime('%Y%m%d_%H%M%S')
|
||||
base = name or f'project_{project["id"]}_deliverable'
|
||||
zip_name = f'{base}_{ts}.zip'
|
||||
zip_path = os.path.join(PACKAGE_ROOT, zip_name)
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for dirpath, dirnames, filenames in os.walk(src):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
||||
for fn in filenames:
|
||||
if fn.startswith('.'):
|
||||
continue
|
||||
full = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(full, src)
|
||||
zf.write(full, os.path.join(os.path.basename(src), rel))
|
||||
size = os.path.getsize(zip_path)
|
||||
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, size, note, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(project['id'], zip_name, 'package', zip_name, size,
|
||||
'交付物打包(zip)', db.now()))
|
||||
return {'path': zip_path, 'size': size, 'name': zip_name}
|
||||
|
||||
|
||||
def record_file_deliverables(project, files):
|
||||
"""把工作目录文件登记为交付物记录"""
|
||||
for f in files:
|
||||
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, size, note, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(project['id'], f['name'], 'file', f['path'], f['size'], '工作目录交付物', db.now()))
|
||||
|
||||
|
||||
def auto_complete_if_ready(project_id, base_url=''):
|
||||
"""项目全部任务完成后自动收尾:置 done + 打包 + 通知送达者。
|
||||
返回 {'ok','msg'} 或 None(未满足条件/异常)。引擎线程与审核接口共用。"""
|
||||
try:
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
|
||||
if not proj or proj['status'] == 'done':
|
||||
return None
|
||||
total = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0', (project_id,))[0]['c']
|
||||
done = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
|
||||
(project_id,))[0]['c']
|
||||
if total == 0 or done < total:
|
||||
return None
|
||||
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
|
||||
ok, msg = notify_project_complete(proj, base_url)
|
||||
return {'ok': ok, 'msg': msg}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def project_summary(project):
|
||||
"""项目交付摘要:任务统计 + 成本"""
|
||||
rows = db.q('SELECT status, COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0 GROUP BY status',
|
||||
(project['id'],))
|
||||
by = {r['status']: r['c'] for r in rows}
|
||||
total = sum(by.values())
|
||||
done = by.get('done', 0)
|
||||
cost = db.q('SELECT COALESCE(SUM(cost),0) t FROM cost_records WHERE project_id=?',
|
||||
(project['id'],))[0]['t']
|
||||
return {'total': total, 'done': done, 'failed': by.get('failed', 0),
|
||||
'review': by.get('review', 0), 'cost': round(cost, 4)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 邮件发送(支持附件)
|
||||
# ---------------------------------------------------------------------------
|
||||
def send_mail(to_addr, subject, text, attachments=None, html=None):
|
||||
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)"""
|
||||
if not EMAIL.get('host'):
|
||||
return False, '邮件服务未配置(config.EMAIL.host 为空)'
|
||||
if not to_addr:
|
||||
return False, '收件邮箱为空'
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
|
||||
msg['To'] = to_addr
|
||||
msg['Subject'] = subject
|
||||
if html:
|
||||
msg.attach(MIMEText(html, 'html', 'utf-8'))
|
||||
else:
|
||||
msg.attach(MIMEText(text, 'plain', 'utf-8'))
|
||||
for f in (attachments or []):
|
||||
if not f or not os.path.isfile(f):
|
||||
continue
|
||||
with open(f, 'rb') as fh:
|
||||
subtype = os.path.splitext(f)[1].lstrip('.').lower() or 'octet-stream'
|
||||
part = MIMEApplication(fh.read(), _subtype=subtype)
|
||||
part.add_header('Content-Disposition', 'attachment',
|
||||
filename=('utf-8', '', os.path.basename(f)))
|
||||
msg.attach(part)
|
||||
try:
|
||||
s = smtplib.SMTP(EMAIL['host'], EMAIL['port'], timeout=30)
|
||||
if EMAIL.get('starttls'):
|
||||
s.starttls()
|
||||
if EMAIL.get('user'):
|
||||
s.login(EMAIL['user'], EMAIL['password'])
|
||||
s.sendmail(EMAIL['user'], [to_addr], msg.as_string())
|
||||
s.quit()
|
||||
return True, '已发送'
|
||||
except Exception as e:
|
||||
return False, f'邮件发送失败: {e}'
|
||||
|
||||
|
||||
def _email_body(project, extra=''):
|
||||
p = project
|
||||
lines = [
|
||||
f'项目名称:{p["name"]}',
|
||||
f'项目状态:{ {"planning":"规划中","active":"进行中","done":"已完成","archived":"已归档"}.get(p["status"], p["status"]) }',
|
||||
f'项目目标:{p.get("objective") or "—"}',
|
||||
]
|
||||
if p.get('demo_url'):
|
||||
lines.append(f'在线 Demo(可直接打开查看):{p["demo_url"]}')
|
||||
if extra:
|
||||
lines.append('')
|
||||
lines.append(extra)
|
||||
lines.append('')
|
||||
lines.append('—— 来自 AI Worker 项目管理平台')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def notify_deliverer(project, subject, text, attach=None, base_url=''):
|
||||
"""发邮件给送达者,写交付记录。返回 (ok, msg)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
if not email:
|
||||
return False, '项目未配置送达者邮箱'
|
||||
ok, msg = send_mail(email, subject, text, attachments=[attach] if attach else None)
|
||||
if ok:
|
||||
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
|
||||
return ok, msg
|
||||
|
||||
|
||||
def notify_blocker(project, task_title, detail):
|
||||
"""项目遇到无法绕开的难关 → 及时邮件通知送达者(同项目限频防打扰)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
if not email:
|
||||
return
|
||||
# 去重:同项目 30 分钟内只提醒一次(detail 带项目标记)
|
||||
dup = db.q("SELECT COUNT(*) c FROM alerts WHERE type='blocker' AND detail LIKE ? AND created_at>?",
|
||||
(f'[project:{project["id"]}]%', db.now() - BLOCKER_DEDUP_SECONDS))
|
||||
if dup and dup[0]['c'] > 0:
|
||||
return
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('blocker','warn',?,?,0,?)",
|
||||
(f'项目难关:{project["name"]} · {task_title}',
|
||||
f'[project:{project["id"]}] {detail[:500]}', db.now()))
|
||||
subject = f'⚠️ 项目遇到难关:{project["name"]}'
|
||||
body = _email_body(project, extra=f'任务「{task_title}」遇到无法绕开的难关:\n{detail[:800]}')
|
||||
ok, msg = send_mail(email, subject, body)
|
||||
if ok:
|
||||
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
|
||||
else:
|
||||
# 邮件失败也留痕
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('notify','warn',?,?,0,?)",
|
||||
(f'难关通知邮件发送失败:{project["name"]}', msg[:300], db.now()))
|
||||
|
||||
|
||||
def notify_project_complete(project, base_url=''):
|
||||
"""项目完成 → 打包 + 邮件送达(含 Demo 链接与附件)。返回 (ok, msg)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
if not email:
|
||||
return False, '项目未配置送达者邮箱'
|
||||
# 网页交付物:确保已部署 Demo
|
||||
if project.get('deliver_type') == 'web' and not project.get('demo_url'):
|
||||
try:
|
||||
deploy_demo(project, base_url)
|
||||
project = db.q('SELECT * FROM projects WHERE id=?', (project['id'],), one=True)
|
||||
except Exception as e:
|
||||
pass
|
||||
# 打包工作目录
|
||||
attach = None
|
||||
try:
|
||||
pkg = package_project(project)
|
||||
attach = pkg['path']
|
||||
except Exception as e:
|
||||
pkg = None
|
||||
s = project_summary(project)
|
||||
extra = (f'项目已完成 ✅\n任务完成情况:{s["done"]}/{s["total"]}(失败 {s["failed"]})\n'
|
||||
f'累计成本:¥{s["cost"]:.4f}\n交付物打包:{"已生成附件(见邮件附件)" if attach else "无工作目录文件"}')
|
||||
subject = f'✅ 项目完成交付:{project["name"]}'
|
||||
body = _email_body(project, extra=extra)
|
||||
ok, msg = send_mail(email, subject, body, attachments=[attach] if attach else None)
|
||||
if ok:
|
||||
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
|
||||
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, demo_url, size, note, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?)',
|
||||
(project['id'], f'完成交付邮件 → {email}', 'email',
|
||||
project.get('demo_url') or '', project.get('demo_url') or '',
|
||||
pkg['size'] if pkg else 0, '项目完成通知(含附件)', db.now()))
|
||||
else:
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('notify','warn',?,?,0,?)",
|
||||
(f'完成交付邮件失败:{project["name"]}', msg[:300], db.now()))
|
||||
return ok, msg
|
||||
|
||||
|
||||
ensure_dirs()
|
||||
@@ -14,6 +14,20 @@ import llm_gateway
|
||||
import config
|
||||
import rag
|
||||
import notify
|
||||
import delivery
|
||||
|
||||
|
||||
def _notify_failed(task, message):
|
||||
"""任务失败:写告警 + 推送渠道 + 邮件通知送达者(项目难关)"""
|
||||
notify.notify('task_failed', f'任务失败:{task["title"]}',
|
||||
f'项目 #{task["project_id"]} 任务「{task["title"]}」{message}',
|
||||
save_alert=True, level='warn', atype='task_failed')
|
||||
try:
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (task['project_id'],), one=True)
|
||||
if proj and (proj.get('deliver_email') or '').strip():
|
||||
delivery.notify_blocker(proj, task['title'], message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log(task_id, level, message):
|
||||
@@ -182,9 +196,7 @@ def run_task(task_id):
|
||||
_set_task(task_id, status='failed', error='前置任务未完成:' + '、'.join(blockers),
|
||||
finished_at=db.now())
|
||||
_log(task_id, 'error', '❌ 依赖未满足,无法执行:' + '、'.join(blockers))
|
||||
notify.notify('task_failed', f'任务失败:{task["title"]}',
|
||||
f'项目 #{task["project_id"]} 任务「{task["title"]}」因依赖未完成被拒绝执行:'
|
||||
+ '、'.join(blockers), save_alert=True, level='warn', atype='task_failed')
|
||||
_notify_failed(task, '因依赖未完成被拒绝执行:' + '、'.join(blockers))
|
||||
return
|
||||
|
||||
# 确定 Worker
|
||||
@@ -195,9 +207,7 @@ def run_task(task_id):
|
||||
_set_task(task_id, status='failed', error='指定 Worker 不存在或已停用',
|
||||
finished_at=db.now())
|
||||
_log(task_id, 'error', '指定 Worker 不存在或已停用')
|
||||
notify.notify('worker_alert', f'Worker 异常:任务「{task["title"]}」',
|
||||
f'指定 Worker #{task["worker_id"]} 不存在或已停用', save_alert=True,
|
||||
level='warn', atype='worker_alert')
|
||||
_notify_failed(task, '指定 Worker 不存在或已停用,无法执行')
|
||||
return
|
||||
else:
|
||||
worker = pick_worker_auto(task)
|
||||
@@ -205,9 +215,7 @@ def run_task(task_id):
|
||||
_set_task(task_id, status='failed', error='无可用 Worker(自动路由失败)',
|
||||
finished_at=db.now())
|
||||
_log(task_id, 'error', '自动路由失败:无可用 Worker')
|
||||
notify.notify('worker_alert', f'Worker 异常:任务「{task["title"]}」',
|
||||
'自动路由失败:没有可用的 Worker', save_alert=True,
|
||||
level='warn', atype='worker_alert')
|
||||
_notify_failed(task, '自动路由失败:没有可用的 Worker')
|
||||
return
|
||||
_set_task(task_id, worker_id=worker['id'])
|
||||
_log(task_id, 'info', f'自动路由 → Worker「{worker["name"]}」({worker["provider"]}/{worker["model"]})')
|
||||
@@ -217,15 +225,13 @@ def run_task(task_id):
|
||||
if not ok:
|
||||
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
|
||||
_log(task_id, 'error', reason)
|
||||
notify.notify('budget_alert', f'成本上限拦截:任务「{task["title"]}」', reason,
|
||||
save_alert=True, level='warn', atype='budget')
|
||||
_notify_failed(task, reason)
|
||||
return
|
||||
ok, reason = _check_project_budget(task)
|
||||
if not ok:
|
||||
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
|
||||
_log(task_id, 'error', reason)
|
||||
notify.notify('budget_alert', f'预算拦截:任务「{task["title"]}」', reason,
|
||||
save_alert=True, level='warn', atype='budget')
|
||||
_notify_failed(task, reason)
|
||||
return
|
||||
|
||||
_set_task(task_id, status='running', started_at=db.now(), error='')
|
||||
@@ -239,9 +245,7 @@ def run_task(task_id):
|
||||
except Exception as e:
|
||||
_set_task(task_id, status='failed', error=str(e), finished_at=db.now())
|
||||
_log(task_id, 'error', f'执行失败: {e}')
|
||||
notify.notify('task_failed', f'任务失败:{task["title"]}',
|
||||
f'项目 #{task["project_id"]} 任务「{task["title"]}」执行出错:{str(e)[:300]}',
|
||||
save_alert=True, level='warn', atype='task_failed')
|
||||
_notify_failed(task, f'执行出错:{str(e)[:300]}')
|
||||
return
|
||||
|
||||
_cost_record(task, worker, usage)
|
||||
@@ -272,6 +276,13 @@ def run_task(task_id):
|
||||
|
||||
_budget_alert(task['project_id'])
|
||||
|
||||
if new_status == 'done':
|
||||
# V3:无需审核的任务直接完成后,检查项目是否全部完成 → 自动交付并通知送达者
|
||||
try:
|
||||
delivery.auto_complete_if_ready(task['project_id'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# DAG:触发下游就绪任务
|
||||
downstream = _trigger_downstream(task)
|
||||
for t in downstream:
|
||||
|
||||
+91
-1
@@ -293,7 +293,8 @@ def export_all():
|
||||
"""全量数据导出(JSON)"""
|
||||
tables = ['projects', 'workers', 'tasks', 'task_logs', 'cost_records', 'documents',
|
||||
'agent_runs', 'agent_steps', 'eval_datasets', 'eval_cases', 'eval_runs',
|
||||
'eval_results', 'templates', 'users', 'audit_logs']
|
||||
'eval_results', 'templates', 'users', 'audit_logs',
|
||||
'project_deliverables', 'user_projects', 'user_workers']
|
||||
out = {'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'platform': 'ai-worker-platform', 'version': 'v2.0.0'}
|
||||
for t in tables:
|
||||
@@ -337,3 +338,92 @@ def generate_consent_token():
|
||||
tok = uuid.uuid4().hex[:12]
|
||||
audit('system', 'compliance.consent', '数据使用同意', f'consent_token={tok}')
|
||||
return tok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 授权:用户 ↔ 项目 / 用户 ↔ Worker(精准权限)
|
||||
# ---------------------------------------------------------------------------
|
||||
PERM_LEVEL = {'view': 0, 'use': 1, 'manage': 2, 'admin': 3}
|
||||
PROJECT_PERMS = ('view', 'manage', 'admin')
|
||||
WORKER_PERMS = ('view', 'use', 'manage')
|
||||
|
||||
|
||||
def perm_ok(have, need):
|
||||
"""have 权限是否满足 need 权限(None 视为无权限)"""
|
||||
if not have:
|
||||
return False
|
||||
return PERM_LEVEL.get(have, -1) >= PERM_LEVEL.get(need, 99)
|
||||
|
||||
|
||||
def user_project_perm(user_id, project_id):
|
||||
"""用户在项目上的权限:None / view / manage / admin"""
|
||||
r = db.q('SELECT perm FROM user_projects WHERE user_id=? AND project_id=?',
|
||||
(user_id, project_id), one=True)
|
||||
return r['perm'] if r else None
|
||||
|
||||
|
||||
def user_worker_perm(user_id, worker_id):
|
||||
"""用户在 Worker 上的权限:None / view / use / manage"""
|
||||
r = db.q('SELECT perm FROM user_workers WHERE user_id=? AND worker_id=?',
|
||||
(user_id, worker_id), one=True)
|
||||
return r['perm'] if r else None
|
||||
|
||||
|
||||
def visible_project_ids(user_id):
|
||||
"""用户可见的项目 id 列表(admin/auditor 返回 None 表示全部)"""
|
||||
u = db.q('SELECT role FROM users WHERE id=?', (user_id,), one=True)
|
||||
if u and u['role'] in ('admin', 'auditor'):
|
||||
return None
|
||||
rows = db.q('SELECT project_id FROM user_projects WHERE user_id=?', (user_id,))
|
||||
return [r['project_id'] for r in rows]
|
||||
|
||||
|
||||
def visible_worker_ids(user_id):
|
||||
"""用户可见的 Worker id 列表(admin/auditor 返回 None 表示全部)"""
|
||||
u = db.q('SELECT role FROM users WHERE id=?', (user_id,), one=True)
|
||||
if u and u['role'] in ('admin', 'auditor'):
|
||||
return None
|
||||
rows = db.q('SELECT worker_id FROM user_workers WHERE user_id=?', (user_id,))
|
||||
return [r['worker_id'] for r in rows]
|
||||
|
||||
|
||||
def set_user_grants(user_id, projects=None, workers=None):
|
||||
"""批量覆盖用户授权。projects=[{project_id, perm}], workers=[{worker_id, perm}]
|
||||
perm 传空/None 表示收回该授权。返回 {'projects': n, 'workers': m}。"""
|
||||
out = {'projects': 0, 'workers': 0}
|
||||
if projects is not None:
|
||||
db.w('DELETE FROM user_projects WHERE user_id=?', (user_id,))
|
||||
for g in projects:
|
||||
perm = (g.get('perm') or '').strip()
|
||||
if perm not in PROJECT_PERMS:
|
||||
continue
|
||||
pid = int(g.get('project_id') or 0)
|
||||
if not db.q('SELECT id FROM projects WHERE id=?', (pid,), one=True):
|
||||
continue
|
||||
db.w('INSERT INTO user_projects (user_id, project_id, perm, created_at) VALUES (?,?,?,?)',
|
||||
(user_id, pid, perm, db.now()))
|
||||
out['projects'] += 1
|
||||
if workers is not None:
|
||||
db.w('DELETE FROM user_workers WHERE user_id=?', (user_id,))
|
||||
for g in workers:
|
||||
perm = (g.get('perm') or '').strip()
|
||||
if perm not in WORKER_PERMS:
|
||||
continue
|
||||
wid = int(g.get('worker_id') or 0)
|
||||
if not db.q('SELECT id FROM workers WHERE id=?', (wid,), one=True):
|
||||
continue
|
||||
db.w('INSERT INTO user_workers (user_id, worker_id, perm, created_at) VALUES (?,?,?,?)',
|
||||
(user_id, wid, perm, db.now()))
|
||||
out['workers'] += 1
|
||||
return out
|
||||
|
||||
|
||||
def user_grants(user_id):
|
||||
"""用户现有授权 + 全部可选项目/Worker,供管理界面展示"""
|
||||
projects = db.q('SELECT p.id, p.name, p.status FROM projects p ORDER BY p.id DESC')
|
||||
workers = db.q('SELECT id, name, provider, model, status FROM workers ORDER BY id DESC')
|
||||
for p in projects:
|
||||
p['perm'] = user_project_perm(user_id, p['id'])
|
||||
for w in workers:
|
||||
w['perm'] = user_worker_perm(user_id, w['id'])
|
||||
return {'projects': projects, 'workers': workers}
|
||||
+502
-33
@@ -43,10 +43,17 @@ function toast(msg, type = '') {
|
||||
/* ---------- Login ---------- */
|
||||
function showLogin() { $('#login-mask').style.display = 'flex'; }
|
||||
function hideLogin() { $('#login-mask').style.display = 'none'; }
|
||||
function applyRoleUI(role) {
|
||||
CURRENT_ROLE = role || 'admin';
|
||||
$$('#sidebar nav a').forEach(a => { a.style.display = (a.dataset.route === 'enterprise' && CURRENT_ROLE !== 'admin') ? 'none' : ''; });
|
||||
}
|
||||
$('#login-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/login', {method:'POST', body:{username: $('#login-user').value, password: $('#login-pwd').value}});
|
||||
hideLogin(); $('#logout-btn').style.display = 'block'; router();
|
||||
hideLogin(); $('#logout-btn').style.display = 'block';
|
||||
const me = await api('/api/me');
|
||||
applyRoleUI(me.user?.role || 'admin');
|
||||
router();
|
||||
} catch (e) { $('#login-err').textContent = e.message; }
|
||||
});
|
||||
$('#login-pwd').addEventListener('keydown', e => { if (e.key === 'Enter') $('#login-btn').click(); });
|
||||
@@ -154,6 +161,10 @@ async function pageProjects() {
|
||||
<div style="color:var(--muted);font-size:12px;margin-top:8px;min-height:32px">${esc(p.objective || p.description || '暂无描述')}</div>
|
||||
<div style="margin-top:10px;font-size:12px;color:var(--muted)">任务 ${p.task_count} · 完成 ${p.done_count}
|
||||
<span style="float:right">${p.budget_limit ? '预算 ¥' + p.budget_limit : ''}</span></div>
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--muted)">📧 ${esc(p.deliver_email || '未配置送达者')}
|
||||
${p.perm ? `<span class="tag" style="float:right">${{view:'查看',manage:'管理',admin:'管理员'}[p.perm] || p.perm}</span>` : ''}
|
||||
${p.demo_url ? `<a href="${esc(p.demo_url)}" target="_blank" style="color:var(--accent);float:right;margin-right:6px" onclick="event.stopPropagation()">🌐 Demo</a>` : ''}
|
||||
</div>
|
||||
</div>`).join('') || '<div class="empty">还没有项目,点右上角新建</div>'}
|
||||
</div>`;
|
||||
}
|
||||
@@ -171,6 +182,15 @@ function openProjectModal(p = {}) {
|
||||
</select></div>
|
||||
<div><label>预算上限(元,0=不限)</label><input id="pm-budget" type="number" value="${p.budget_limit || 0}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div style="flex:2"><label>📧 送达者(人)邮箱 * ${p.id ? '' : '<small style="color:var(--warn)">项目完成/难关时通知此人</small>'}</label>
|
||||
<input id="pm-email" value="${esc(p.deliver_email || '')}" placeholder="如 client@example.com"></div>
|
||||
<div><label>交付物类型</label><select id="pm-dtype">
|
||||
<option value="web" ${p.deliver_type === 'web' || !p.deliver_type ? 'selected' : ''}>🌐 网页(部署 Demo 供在线查看)</option>
|
||||
<option value="file" ${p.deliver_type === 'file' ? 'selected' : ''}>📦 文件包(打包发邮件)</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label>交付说明</label><input id="pm-dnote" value="${esc(p.deliver_note || '')}" placeholder="给送达者的交付说明(可选)">
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="pm-save">保存</button>
|
||||
@@ -179,9 +199,12 @@ function openProjectModal(p = {}) {
|
||||
const body = {
|
||||
name: $('#pm-name').value.trim(), objective: $('#pm-objective').value,
|
||||
acceptance_criteria: $('#pm-accept').value, description: $('#pm-desc').value,
|
||||
status: $('#pm-status').value, budget_limit: parseFloat($('#pm-budget').value || 0)
|
||||
status: $('#pm-status').value, budget_limit: parseFloat($('#pm-budget').value || 0),
|
||||
deliver_email: $('#pm-email').value.trim(), deliver_type: $('#pm-dtype').value,
|
||||
deliver_note: $('#pm-dnote').value.trim()
|
||||
};
|
||||
if (!body.name) return toast('请填写项目名称', 'err');
|
||||
if (!body.deliver_email || !body.deliver_email.includes('@')) return toast('必填:送达者(人)邮箱', 'err');
|
||||
try {
|
||||
await api(p.id ? `/api/projects/${p.id}` : '/api/projects', {method: p.id ? 'PUT' : 'POST', body});
|
||||
toast('已保存', 'ok'); closeModal(); router();
|
||||
@@ -200,6 +223,7 @@ async function pageProject([pid, tab]) {
|
||||
renderProjectShell();
|
||||
if (projCtx.tab === 'dag') renderDag();
|
||||
else if (projCtx.tab === 'kb') renderKb();
|
||||
else if (projCtx.tab === 'deliver') renderDeliver();
|
||||
else renderKanban();
|
||||
}
|
||||
|
||||
@@ -209,14 +233,15 @@ function renderProjectShell() {
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="#/projects">← 返回</a>
|
||||
<h1 class="page-title" style="margin:0;flex:1">${esc(p.name)}<small>${esc(p.objective || '')}</small></h1>
|
||||
<button class="btn" onclick="openProjectModal(${JSON.stringify(p).replace(/"/g, '"')})">编辑项目</button>
|
||||
<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>
|
||||
<button class="btn primary" onclick="openTaskModal()">+ 新建任务</button>
|
||||
${p.perm === 'view' ? '' : `<button class="btn" onclick="openProjectModal(${JSON.stringify(p).replace(/"/g, '"')})">编辑项目</button>`}
|
||||
${p.perm === 'view' ? '' : `<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>`}
|
||||
${p.perm === 'view' ? '' : `<button class="btn primary" onclick="openTaskModal()">+ 新建任务</button>`}
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<a href="#/project/${pid}/kanban" class="${tab === 'kanban' ? 'active' : ''}">看板</a>
|
||||
<a href="#/project/${pid}/dag" class="${tab === 'dag' ? 'active' : ''}">DAG 工作流</a>
|
||||
<a href="#/project/${pid}/kb" class="${tab === 'kb' ? 'active' : ''}">知识库 RAG</a>
|
||||
<a href="#/project/${pid}/deliver" class="${tab === 'deliver' ? 'active' : ''}">📦 交付</a>
|
||||
</div>
|
||||
<div id="tab-body"></div>`;
|
||||
}
|
||||
@@ -390,7 +415,6 @@ function drawDag() {
|
||||
const ready = tasks.filter(t => (t.status === 'todo' || t.status === 'failed') && (!t.depends_on || t.depends_on.every(d => idMap[d]?.status === 'done')));
|
||||
const body = $('#tab-body');
|
||||
if (!body) return;
|
||||
const NW = 140, NHW = 70; // 节点宽 / 半宽
|
||||
const M = DAG_M; // svg 盒边距:节点平移/缩放后仍在盒内可命中
|
||||
const dagText = t => {
|
||||
const raw = `#${t.id} ${t.title}`;
|
||||
@@ -407,28 +431,43 @@ function drawDag() {
|
||||
<button onclick="dagZoomTo(1)" title="一键回到 100% 原始视图">100%</button>
|
||||
</div>
|
||||
<div class="dag-trash" id="dag-trash" onclick="openDagTrash()" title="点击查看回收站">🗑️ 删除区<span class="cnt" id="dag-trash-cnt">0</span></div>
|
||||
<div class="dag-edge-actions" id="dag-edge-actions" style="display:none">
|
||||
<span id="dag-edge-info"></span>
|
||||
<button class="btn sm" onclick="dagRepointSel()" title="点击后选择新的目标节点">🔗 重连</button>
|
||||
<button class="btn sm danger" onclick="dagDelSel()">🗑️ 删除</button>
|
||||
<button class="btn sm" onclick="dagClearSel()" title="取消选中">✖ 取消</button>
|
||||
</div>
|
||||
<svg id="dag-svg" width="${width + M * 2}" height="${Math.max(480, height) + M * 2}" viewBox="${-M} ${-M} ${width + M * 2} ${height + M * 2}" style="transform-origin:0 0">
|
||||
<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#4a5878"/></marker></defs>
|
||||
<rect id="dag-bg" x="${-M - 1000}" y="${-M - 1000}" width="${width + 2 * M + 2000}" height="${height + 2 * M + 2000}" fill="var(--panel)"/>
|
||||
${edges.map(e => {
|
||||
const a = dagState.pos[e.from], b = dagState.pos[e.to];
|
||||
if (!a || !b) return '';
|
||||
const mx = (a.x + b.x) / 2;
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||||
const act = idMap[e.from].status === 'done';
|
||||
return `<path data-e="${e.from}-${e.to}" class="dag-edge${act ? ' act' : ''}" d="M${a.x + NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - NHW},${b.y}"/>`;
|
||||
const sel = dagState.selectedEdge && dagState.selectedEdge.from === e.from && dagState.selectedEdge.to === e.to;
|
||||
return `<g class="dag-edge-g${sel ? ' sel' : ''}" data-e="${e.from}-${e.to}">
|
||||
<path class="dag-edge-hit" d="M${a.x + DAG_NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - DAG_NHW},${b.y}"/>
|
||||
<path class="dag-edge${act ? ' act' : ''}" d="M${a.x + DAG_NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - DAG_NHW},${b.y}"/>
|
||||
<circle class="dag-edge-x" cx="${mx}" cy="${my}" r="9"/>
|
||||
<text class="dag-edge-xt" x="${mx}" y="${my + 3.5}">✕</text>
|
||||
</g>`;
|
||||
}).join('')}
|
||||
<path id="dag-ghost" class="dag-ghost" style="display:none"/>
|
||||
${tasks.map(t => {
|
||||
const p = dagState.pos[t.id];
|
||||
const col = colors[t.status];
|
||||
return `<g class="dag-node" data-id="${t.id}" style="cursor:grab">
|
||||
<rect x="${p.x - NHW}" y="${p.y - 24}" width="${NW}" height="48" fill="${col}" stroke="#5a6b8f"/>
|
||||
<rect x="${p.x - DAG_NHW}" y="${p.y - 24}" width="${DAG_NW}" height="48" fill="${col}" stroke="#5a6b8f"/>
|
||||
<circle class="dag-port in" data-id="${t.id}" cx="${p.x - DAG_NHW}" cy="${p.y}" r="6"/>
|
||||
<circle class="dag-port out" data-id="${t.id}" cx="${p.x + DAG_NHW}" cy="${p.y}" r="6"/>
|
||||
<text class="dag-label" x="${p.x}" y="${p.y - 2}">${esc(dagText(t))}</text>
|
||||
<text class="dag-sub" x="${p.x}" y="${p.y + 15}">${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''}</text>
|
||||
</g>`;
|
||||
}).join('')}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。</div>`;
|
||||
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。连线操作:从节点<b>右侧⚪</b>拖到目标节点=新建依赖;<b>单击连线</b>选中后可重连/删除;<b>双击连线</b>直接删除;连线中点 ✕ 一键删除。</div>`;
|
||||
dagApplyView();
|
||||
bindDagEvents();
|
||||
dagTrashCount();
|
||||
@@ -437,6 +476,140 @@ function drawDag() {
|
||||
let dagBound = false; // window 级监听只绑定一次
|
||||
let dagDrag = null; // 模块级拖拽状态(box 重绑后 window handler 仍可访问)
|
||||
const DAG_M = 3000; // svg 盒边距:节点平移/缩放后仍在盒内可命中
|
||||
const DAG_NW = 140, DAG_NHW = 70; // 节点宽 / 半宽
|
||||
|
||||
/* --- DAG 连线(依赖边)编辑:新建 / 重连 / 删除 --- */
|
||||
function dagCanConnect(from, to, mode, oldTo) {
|
||||
if (from === to) return {ok:false, why:'不能连接到自己'};
|
||||
const t = projCtx.tasks.find(x => x.id === to);
|
||||
if (!t) return {ok:false, why:'目标任务不存在'};
|
||||
if (mode !== 'repoint' && (t.depends_on || []).includes(from)) return {ok:false, why:'该连线已存在'};
|
||||
if (dagWouldCycle(from, to, mode === 'repoint' ? {from, to: oldTo} : null)) return {ok:false, why:'会形成依赖环'};
|
||||
return {ok:true};
|
||||
}
|
||||
|
||||
function dagWouldCycle(from, to, remove) {
|
||||
// 临时加入 from→to(重连时先移除旧边 remove),判断是否成环
|
||||
const adj = {};
|
||||
projCtx.tasks.forEach(t => {
|
||||
adj[t.id] = new Set((t.depends_on || []).filter(d => !(remove && t.id === remove.to && d === remove.from)));
|
||||
});
|
||||
(adj[to] = adj[to] || new Set()).add(from);
|
||||
const seen = new Set(), stack = [...(adj[from] || [])];
|
||||
while (stack.length) {
|
||||
const n = stack.pop();
|
||||
if (n === from) return true;
|
||||
if (seen.has(n)) continue;
|
||||
seen.add(n);
|
||||
(adj[n] || []).forEach(x => { if (!seen.has(x)) stack.push(x); });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function dagApplyEdge(from, to, mode, oldTo) {
|
||||
const chk = dagCanConnect(from, to, mode, oldTo);
|
||||
if (!chk.ok) { toast(chk.why, 'err'); return; }
|
||||
try {
|
||||
if (mode === 'repoint') {
|
||||
const old = projCtx.tasks.find(x => x.id === oldTo);
|
||||
await api(`/api/tasks/${oldTo}`, {method:'PUT', body:{depends_on: (old?.depends_on || []).filter(x => x !== from)}});
|
||||
const nt = projCtx.tasks.find(x => x.id === to);
|
||||
await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: [...new Set([...(nt?.depends_on || []), from])]}});
|
||||
toast(`🔗 连线已重连:#${from} → #${to}`, 'ok');
|
||||
} else {
|
||||
const nt = projCtx.tasks.find(x => x.id === to);
|
||||
await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: [...new Set([...(nt?.depends_on || []), from])]}});
|
||||
toast(`🔗 已新建连线:#${from} → #${to}`, 'ok');
|
||||
}
|
||||
dagClearSel();
|
||||
refreshProj();
|
||||
} catch (e) { toast('连线操作失败:' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function dagDeleteEdge(from, to) {
|
||||
const t = projCtx.tasks.find(x => x.id === to);
|
||||
if (!t) return;
|
||||
if (!confirm(`删除依赖连线 #${from} → #${to} ?\n(#${to} 将不再等待 #${from} 完成)`)) return;
|
||||
try {
|
||||
await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: (t.depends_on || []).filter(x => x !== from)}});
|
||||
toast('连线已删除', 'ok');
|
||||
dagCancelConnect();
|
||||
dagClearSel();
|
||||
refreshProj();
|
||||
} catch (e) { toast('删除失败:' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
function dagSelectEdge(from, to) {
|
||||
dagState.selectedEdge = {from, to};
|
||||
const svg = $('#dag-svg');
|
||||
if (svg) svg.querySelectorAll('.dag-edge-g').forEach(g => g.classList.toggle('sel', g.dataset.e === `${from}-${to}`));
|
||||
const bar = $('#dag-edge-actions');
|
||||
if (bar) { $('#dag-edge-info').innerHTML = `<b>#${from}</b> → <b>#${to}</b>`; bar.style.display = 'flex'; }
|
||||
}
|
||||
|
||||
function dagClearSel() {
|
||||
if (dagState?.connect) dagCancelConnect();
|
||||
if (dagState) dagState.selectedEdge = null;
|
||||
const svg = $('#dag-svg');
|
||||
if (svg) svg.querySelectorAll('.dag-edge-g.sel').forEach(g => g.classList.remove('sel'));
|
||||
const bar = $('#dag-edge-actions');
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
|
||||
function dagRepointSel() {
|
||||
const sel = dagState?.selectedEdge;
|
||||
if (!sel) return;
|
||||
dagState.connect = {from: sel.from, mode: 'repoint', oldTo: sel.to};
|
||||
const box = $('#dag-box'); if (box) box.classList.add('dag-connecting');
|
||||
const bar = $('#dag-edge-actions');
|
||||
if (bar) { $('#dag-edge-info').innerHTML = `<b>#${sel.from}</b> → <b style="color:var(--warn)">?(点击目标节点)</b>`; bar.style.display = 'flex'; }
|
||||
const a = dagState.pos[sel.from];
|
||||
if (a) dagSetGhost(a.x + DAG_NHW, a.y, a.x + DAG_NHW, a.y, true);
|
||||
toast('点击目标节点完成重连,点空白 / Esc 取消', '');
|
||||
}
|
||||
|
||||
function dagDelSel() {
|
||||
const sel = dagState?.selectedEdge;
|
||||
if (sel) dagDeleteEdge(sel.from, sel.to);
|
||||
}
|
||||
|
||||
function dagCancelConnect() {
|
||||
if (!dagState) return;
|
||||
dagState.connect = null;
|
||||
const box = $('#dag-box'); if (box) box.classList.remove('dag-connecting');
|
||||
const g = $('#dag-ghost'); if (g) g.style.display = 'none';
|
||||
const svg = $('#dag-svg');
|
||||
if (svg) svg.querySelectorAll('.dag-node.drop-ok,.dag-node.drop-bad').forEach(n => n.classList.remove('drop-ok', 'drop-bad'));
|
||||
const bar = $('#dag-edge-actions');
|
||||
if (bar) {
|
||||
bar.style.display = dagState.selectedEdge ? 'flex' : 'none';
|
||||
if (dagState.selectedEdge) {
|
||||
const {from, to} = dagState.selectedEdge;
|
||||
$('#dag-edge-info').innerHTML = `<b>#${from}</b> → <b>#${to}</b>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dagSetGhost(x1, y1, x2, y2, valid) {
|
||||
const g = $('#dag-ghost'); if (!g) return;
|
||||
const mx = (x1 + x2) / 2;
|
||||
g.setAttribute('d', `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`);
|
||||
g.style.display = '';
|
||||
g.classList.toggle('bad', !valid);
|
||||
}
|
||||
|
||||
function dagUpdateEdgeEl(svg, from, to) {
|
||||
const g = svg.querySelector(`.dag-edge-g[data-e="${from}-${to}"]`);
|
||||
if (!g) return;
|
||||
const a = dagState.pos[from], b = dagState.pos[to];
|
||||
if (!a || !b) return;
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||||
const d = `M${a.x + DAG_NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - DAG_NHW},${b.y}`;
|
||||
g.querySelectorAll('path').forEach(p => p.setAttribute('d', d));
|
||||
const xc = g.querySelector('.dag-edge-x'); if (xc) { xc.setAttribute('cx', mx); xc.setAttribute('cy', my); }
|
||||
const xt = g.querySelector('.dag-edge-xt'); if (xt) { xt.setAttribute('x', mx); xt.setAttribute('y', my + 3.5); }
|
||||
}
|
||||
|
||||
function bindDagEvents() {
|
||||
const box = $('#dag-box');
|
||||
if (!box) return;
|
||||
@@ -446,13 +619,29 @@ function bindDagEvents() {
|
||||
// box 级监听:每次 DOM 重建后重新绑定(box 是新元素)
|
||||
box.addEventListener('mousedown', e => {
|
||||
if (e.button !== 0) return;
|
||||
const portOut = e.target.closest('.dag-port.out');
|
||||
const nodeEl = e.target.closest('.dag-node');
|
||||
if (portOut) {
|
||||
// 从节点右侧输出口拖出 → 新建连线
|
||||
const id = Number(portOut.dataset.id);
|
||||
const p = s.pos[id];
|
||||
if (!p) return;
|
||||
s.connect = {from: id, mode: 'create'};
|
||||
box.classList.add('dag-connecting');
|
||||
dagSetGhost(p.x + DAG_NHW, p.y, p.x + DAG_NHW, p.y, true);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (nodeEl) {
|
||||
if (e.target.closest('.dag-port.in')) { e.preventDefault(); return; } // 输入口只作落点,不拖节点
|
||||
const id = Number(nodeEl.dataset.id);
|
||||
const p = s.pos[id];
|
||||
dagDrag = {type: 'node', id, sx: e.clientX, sy: e.clientY, ox: p.x, oy: p.y, moved: false};
|
||||
nodeEl.style.cursor = 'grabbing';
|
||||
const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing';
|
||||
} else if (e.target.closest('.dag-edge-g') || e.target.closest('.dag-edge-actions')) {
|
||||
e.preventDefault(); // 点连线 / 操作条不触发平移
|
||||
return;
|
||||
} else {
|
||||
dagDrag = {type: 'pan', sx: e.clientX, sy: e.clientY, ox: s.tx, oy: s.ty, moved: false};
|
||||
const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing';
|
||||
@@ -475,29 +664,82 @@ function bindDagEvents() {
|
||||
saveDagState();
|
||||
}, {passive: false});
|
||||
|
||||
// 双击空白复位视图
|
||||
// 双击:连线=删除,节点=忽略,空白=复位视图(控件条除外)
|
||||
box.addEventListener('dblclick', e => {
|
||||
if (e.target.closest('.dag-edge-actions, .dag-zoom, .dag-trash')) return;
|
||||
const edgeG = e.target.closest('.dag-edge-g');
|
||||
if (edgeG) {
|
||||
const [f, t] = edgeG.dataset.e.split('-').map(Number);
|
||||
dagDeleteEdge(f, t);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.dag-node')) return;
|
||||
s.scale = 1; s.tx = 0; s.ty = 0;
|
||||
dagApplyView();
|
||||
saveDagState();
|
||||
});
|
||||
|
||||
// 点击节点打开详情(拖动超过阈值则不触发)
|
||||
// 点击:连线=选中/删除,节点=详情,空白=取消选中(控件条除外)
|
||||
box.addEventListener('click', e => {
|
||||
if (e.target.closest('.dag-edge-actions, .dag-zoom, .dag-trash')) return;
|
||||
const edgeG = e.target.closest('.dag-edge-g');
|
||||
if (edgeG) {
|
||||
const [f, t] = edgeG.dataset.e.split('-').map(Number);
|
||||
if (e.target.closest('.dag-edge-x')) { dagDeleteEdge(f, t); return; }
|
||||
dagSelectEdge(f, t);
|
||||
return;
|
||||
}
|
||||
const nodeEl = e.target.closest('.dag-node');
|
||||
if (!nodeEl) return;
|
||||
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
|
||||
openTaskDetail(Number(nodeEl.dataset.id));
|
||||
if (nodeEl) {
|
||||
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
|
||||
openTaskDetail(Number(nodeEl.dataset.id));
|
||||
return;
|
||||
}
|
||||
dagClearSel();
|
||||
});
|
||||
|
||||
// window 级监听:只绑定一次(handler 内现查 DOM,兼容画布重建)
|
||||
if (dagBound) return;
|
||||
dagBound = true;
|
||||
|
||||
window.addEventListener('keydown', e => {
|
||||
if (document.activeElement && /input|textarea|select/i.test(document.activeElement.tagName)) return;
|
||||
if (e.key === 'Escape') { if (dagState?.connect) dagCancelConnect(); }
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && dagState?.selectedEdge && !dagState?.connect) {
|
||||
dagDelSel();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (!dagDrag) return;
|
||||
const st = dagState;
|
||||
if (!st) return;
|
||||
// 连线模式:幽灵线 + 目标节点合法性高亮
|
||||
if (st.connect) {
|
||||
const c = st.connect;
|
||||
const boxEl = $('#dag-box');
|
||||
if (!boxEl) return;
|
||||
const rect = boxEl.getBoundingClientRect();
|
||||
const vx = (e.clientX - rect.left - st.tx) / st.scale;
|
||||
const vy = (e.clientY - rect.top - st.ty) / st.scale;
|
||||
const a = st.pos[c.from];
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const nd = el && el.closest ? el.closest('.dag-node') : null;
|
||||
const target = nd ? Number(nd.dataset.id) : null;
|
||||
const svg = svgEl();
|
||||
if (svg) svg.querySelectorAll('.dag-node.drop-ok,.dag-node.drop-bad').forEach(n => n.classList.remove('drop-ok', 'drop-bad'));
|
||||
let valid = false;
|
||||
if (target && target !== c.from) {
|
||||
const chk = dagCanConnect(c.from, target, c.mode, c.oldTo);
|
||||
valid = chk.ok;
|
||||
if (svg) {
|
||||
const tn = svg.querySelector(`.dag-node[data-id="${target}"]`);
|
||||
if (tn) tn.classList.add(valid ? 'drop-ok' : 'drop-bad');
|
||||
}
|
||||
}
|
||||
if (a) dagSetGhost(a.x + DAG_NHW, a.y, vx, vy, valid);
|
||||
return;
|
||||
}
|
||||
if (!dagDrag) return;
|
||||
const dx = e.clientX - dagDrag.sx, dy = e.clientY - dagDrag.sy;
|
||||
if (!dagDrag.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
|
||||
dagDrag.moved = true;
|
||||
@@ -522,8 +764,12 @@ function bindDagEvents() {
|
||||
const nid = Number(g.dataset.id);
|
||||
const pp = st.pos[nid];
|
||||
if (!pp) return;
|
||||
g.querySelector('rect').setAttribute('x', pp.x - 70);
|
||||
g.querySelector('rect').setAttribute('x', pp.x - DAG_NHW);
|
||||
g.querySelector('rect').setAttribute('y', pp.y - 24);
|
||||
g.querySelectorAll('.dag-port').forEach(port => {
|
||||
port.setAttribute('cx', pp.x + (port.classList.contains('out') ? DAG_NHW : -DAG_NHW));
|
||||
port.setAttribute('cy', pp.y);
|
||||
});
|
||||
const ts = g.querySelectorAll('text');
|
||||
ts[0].setAttribute('x', pp.x); ts[0].setAttribute('y', pp.y - 2);
|
||||
ts[1].setAttribute('x', pp.x); ts[1].setAttribute('y', pp.y + 15);
|
||||
@@ -533,17 +779,10 @@ function bindDagEvents() {
|
||||
if (d === dagDrag.id || t.id === dagDrag.id) { touched.add(d); touched.add(t.id); }
|
||||
}));
|
||||
touched.forEach(tid => {
|
||||
const a = st.pos[tid];
|
||||
if (!a) return;
|
||||
projCtx.tasks.forEach(t => {
|
||||
if (t.id !== tid) return;
|
||||
(t.depends_on || []).forEach(d => {
|
||||
const b = st.pos[d];
|
||||
if (!b) return;
|
||||
const path = svgEl2.querySelector(`path[data-e="${d}-${tid}"]`);
|
||||
if (!path) return;
|
||||
const mx = (b.x + a.x) / 2;
|
||||
path.setAttribute('d', `M${b.x + 70},${b.y} C${mx},${b.y} ${mx},${a.y} ${a.x - 70},${a.y}`);
|
||||
if (st.pos[d]) dagUpdateEdgeEl(svgEl2, d, tid);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -551,6 +790,25 @@ function bindDagEvents() {
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', e => {
|
||||
const st = dagState;
|
||||
if (!st) return;
|
||||
// 连线模式结束:落到合法节点则应用,否则取消
|
||||
if (st.connect) {
|
||||
const c = st.connect;
|
||||
const nd = e.target && e.target.closest ? e.target.closest('.dag-node') : null;
|
||||
if (nd) {
|
||||
const target = Number(nd.dataset.id);
|
||||
if (c.mode === 'create') dagApplyEdge(c.from, target, 'create');
|
||||
else if (target !== c.from && target !== c.oldTo) dagApplyEdge(c.from, target, 'repoint', c.oldTo);
|
||||
else toast('重连取消(目标未变化)', '');
|
||||
} else {
|
||||
toast('已取消连线', '');
|
||||
}
|
||||
dagCancelConnect();
|
||||
dagState.suppressClick = true;
|
||||
setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400);
|
||||
return;
|
||||
}
|
||||
if (!dagDrag) return;
|
||||
const wasNode = dagDrag.type === 'node';
|
||||
const trashEl = $('#dag-trash');
|
||||
@@ -669,6 +927,7 @@ function renderDag() {
|
||||
tx: savedView?.tx || 0,
|
||||
ty: savedView?.ty || 0,
|
||||
drag: null, moved: false,
|
||||
selectedEdge: null, connect: null,
|
||||
};
|
||||
const body2 = $('#tab-body');
|
||||
body2.innerHTML = '';
|
||||
@@ -947,13 +1206,161 @@ function openWbsModal() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 📦 交付中心(V3) ---------- */
|
||||
let deliverCtx = null;
|
||||
|
||||
async function renderDeliver() {
|
||||
const {pid} = projCtx;
|
||||
const p = (await api(`/api/projects/${pid}`)).data;
|
||||
const [ws, dels] = await Promise.all([
|
||||
api(`/api/projects/${pid}/workspace`),
|
||||
api(`/api/projects/${pid}/deliverables`)
|
||||
]);
|
||||
projCtx.p = p;
|
||||
deliverCtx = {ws: ws.data, dels: dels.data};
|
||||
const isView = p.perm === 'view';
|
||||
const fmtSize = n => n >= 1048576 ? (n/1048576).toFixed(2) + ' MB' : n >= 1024 ? (n/1024).toFixed(1) + ' KB' : n + ' B';
|
||||
$('#tab-body').innerHTML = `
|
||||
<div class="grid g2" style="align-items:start">
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">📬 交付对象与方式</div>
|
||||
<div class="kv">
|
||||
<dt>送达者邮箱</dt><dd>${esc(p.deliver_email || '—')} <small style="color:var(--muted)">(项目完成 / 难关自动通知)</small></dd>
|
||||
<dt>交付物类型</dt><dd>${p.deliver_type === 'web' ? '🌐 网页交付物(在线 Demo)' : '📦 文件包交付物(邮件附件)'}</dd>
|
||||
<dt>交付说明</dt><dd>${esc(p.deliver_note || '—')}</dd>
|
||||
<dt>Demo 地址</dt><dd>${p.demo_url ? `<a href="${esc(p.demo_url)}" target="_blank" style="color:var(--accent)">${esc(p.demo_url)}</a>` : '<span style="color:var(--muted)">未部署</span>'}</dd>
|
||||
<dt>最近送达</dt><dd>${fmtTime(p.delivered_at)}</dd>
|
||||
<dt>项目状态</dt><dd>${p.status === 'done' ? '✅ 已完成' : '🔄 进行中'}</dd>
|
||||
</div>
|
||||
${isView ? '' : `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:12px">
|
||||
<button class="btn" onclick="deliverAction('deploy')" ${p.deliver_type !== 'web' ? 'disabled' : ''}>🚀 部署 Demo</button>
|
||||
<button class="btn" onclick="deliverAction('package')">🗜️ 打包</button>
|
||||
<button class="btn primary" onclick="deliverAction('deliver')">📧 发送给送达者</button>
|
||||
${p.status === 'done' ? '' : `<button class="btn" style="background:var(--accent2);border-color:var(--accent2)" onclick="deliverAction('complete')">✅ 完成项目并交付</button>`}
|
||||
<button class="btn" onclick="deliverAction('notify')">✉️ 手动通知</button>
|
||||
</div>`}
|
||||
<div class="tab-note" style="margin-top:10px">💡 交付流程:工作目录放交付物 → 网页类点「部署 Demo」生成在线地址 → 「发送给送达者」把 Demo 链接 + 打包附件发邮件;文件类直接「打包+发送」。项目所有任务验收后也会自动完成并通知送达者。</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">🗂️ 项目工作目录
|
||||
<small style="color:var(--muted)">data/workspace/${esc(p.workspace_dir || ('project_' + pid))}/(与其他项目隔离)</small>
|
||||
${isView ? '' : '<button class="btn mini" style="float:right" onclick="$(\'#ws-upload\').click()">⬆️ 上传</button>'}
|
||||
</div>
|
||||
${isView ? '' : `<input type="file" id="ws-upload" multiple style="display:none" onchange="uploadWorkspaceFiles(this)">`}
|
||||
<div id="ws-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div class="sec-title" style="margin-top:0">📋 交付记录</div>
|
||||
<table><thead><tr><th>时间</th><th>类型</th><th>名称</th><th>大小</th><th>备注</th></tr></thead><tbody>
|
||||
${(dels.data || []).map(d => `<tr><td>${fmtTime(d.created_at)}</td>
|
||||
<td>${{file:'📄 文件', package:'🗜️ 打包', email:'📧 邮件', webpage:'🌐 Demo'}[d.kind] || d.kind}</td>
|
||||
<td>${esc(d.name)}${d.demo_url ? ` <a href="${esc(d.demo_url)}" target="_blank" style="color:var(--accent)">查看</a>` : ''}</td>
|
||||
<td class="mono">${d.size ? fmtSize(d.size) : '—'}</td>
|
||||
<td style="color:var(--muted)">${esc(d.note || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty">暂无交付记录</td></tr>'}
|
||||
</tbody></table>
|
||||
</div>`;
|
||||
renderWorkspaceList();
|
||||
}
|
||||
|
||||
function renderWorkspaceList() {
|
||||
const box = $('#ws-list');
|
||||
if (!box) return;
|
||||
const {ws} = deliverCtx;
|
||||
const fmtSize = n => n >= 1048576 ? (n/1048576).toFixed(2) + ' MB' : n >= 1024 ? (n/1024).toFixed(1) + ' KB' : n + ' B';
|
||||
if (!ws.length) { box.innerHTML = '<div class="empty">工作目录为空,上传交付物文件后即可打包/部署</div>'; return; }
|
||||
box.innerHTML = `<table><thead><tr><th>文件</th><th>大小</th><th>修改时间</th><th>操作</th></tr></thead><tbody>
|
||||
${ws.map(f => `<tr>
|
||||
<td>${esc(f.path)}</td>
|
||||
<td class="mono">${fmtSize(f.size)}</td>
|
||||
<td class="mono">${fmtTime(f.mtime)}</td>
|
||||
<td>
|
||||
<a class="btn mini" href="/api/projects/${projCtx.pid}/workspace/download?path=${encodeURIComponent(f.path)}">⬇️</a>
|
||||
<button class="btn mini danger" onclick="deleteWorkspaceFile('${esc(f.path)}')">🗑️</button>
|
||||
</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
async function uploadWorkspaceFiles(input) {
|
||||
const files = [...(input.files || [])];
|
||||
if (!files.length) return;
|
||||
const fd = new FormData();
|
||||
files.forEach(f => fd.append('files', f));
|
||||
toast(`正在上传 ${files.length} 个文件…`);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projCtx.pid}/workspace/upload`, {method: 'POST', body: fd});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.ok === false) throw new Error(data.error || '上传失败');
|
||||
toast(`✅ 已上传 ${data.saved.length} 个文件`, 'ok');
|
||||
input.value = '';
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function deleteWorkspaceFile(path) {
|
||||
if (!confirm(`删除文件 ${path} ?`)) return;
|
||||
try {
|
||||
await api(`/api/projects/${projCtx.pid}/workspace?path=${encodeURIComponent(path)}`, {method: 'DELETE'});
|
||||
toast('已删除', 'ok'); await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function deliverAction(action) {
|
||||
const {pid, p} = projCtx;
|
||||
if (action === 'deploy') {
|
||||
try {
|
||||
toast('正在部署 Demo…');
|
||||
const r = await api(`/api/projects/${pid}/deploy`, {method: 'POST'});
|
||||
toast(`✅ Demo 已部署:${r.demo_url}(${r.copied} 个文件)`, 'ok');
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'package') {
|
||||
try {
|
||||
const r = await api(`/api/projects/${pid}/package`, {method: 'POST'});
|
||||
toast(`✅ 已打包:${r.name}(${(r.size/1024).toFixed(1)} KB)`, 'ok');
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'deliver') {
|
||||
if (!confirm(`将把项目「${p.name}」的交付物(Demo 链接 + 打包附件)发送到 ${p.deliver_email},确定?`)) return;
|
||||
try {
|
||||
toast('正在打包并发送邮件…');
|
||||
const r = await api(`/api/projects/${pid}/deliver`, {method: 'POST'});
|
||||
toast(`✅ 已发送:${r.msg}${r.demo_url ? ' · Demo ' + r.demo_url : ''}`, 'ok');
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'complete') {
|
||||
if (!confirm(`确认完成项目「${p.name}」?将部署 Demo、打包交付物并通过邮件通知送达者 ${p.deliver_email}。`)) return;
|
||||
try {
|
||||
const r = await api(`/api/projects/${pid}/complete`, {method: 'POST'});
|
||||
toast(`✅ 项目已完成并交付:${r.msg}`, 'ok');
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'notify') {
|
||||
openModal(`
|
||||
<h3>✉️ 手动通知送达者<button class="close" data-close>×</button></h3>
|
||||
<label>主题</label><input id="nt-subject" value="项目动态:${esc(p.name)}">
|
||||
<label>消息内容(将随项目信息一并发送)</label><textarea id="nt-msg" rows="5" placeholder="例如:项目遇到阶段性难关,正在协调处理,预计本周内完成…"></textarea>
|
||||
<div class="modal-actions"><button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="nt-send">发送</button></div>`);
|
||||
$('#nt-send').addEventListener('click', async () => {
|
||||
try {
|
||||
await api(`/api/projects/${pid}/notify_deliverer`, {method:'POST', body: {
|
||||
subject: $('#nt-subject').value.trim(), message: $('#nt-msg').value.trim()}});
|
||||
toast('✅ 已通知送达者', 'ok'); closeModal();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Worker 管理 ---------- */
|
||||
async function pageWorkers() {
|
||||
const [ws, ps] = await Promise.all([api('/api/workers'), api('/api/providers')]);
|
||||
const canManage = CURRENT_ROLE === 'admin';
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">AI Worker 管理<small>模型 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案</small></h1>
|
||||
<div class="toolbar"><button class="btn primary" onclick="openWorkerModal(null, ${JSON.stringify(ps.data).replace(/"/g,'"')})">+ 注册 Worker</button></div>
|
||||
<table><thead><tr><th>ID</th><th>名称</th><th>模型</th><th>角色提示词</th><th>温度/上限</th><th>本月成本</th><th>成本上限</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
||||
${canManage ? `<div class="toolbar"><button class="btn primary" onclick="openWorkerModal(null, ${JSON.stringify(ps.data).replace(/"/g,'"')})">+ 注册 Worker</button></div>` : '<div class="toolbar"><span class="tab-note">👁️ 仅显示你有权限查看的 Worker(由管理员授权)</span></div>'}
|
||||
<table><thead><tr><th>ID</th><th>名称</th><th>模型</th><th>角色提示词</th><th>温度/上限</th><th>本月成本</th><th>成本上限</th><th>状态</th><th>权限</th><th>操作</th></tr></thead><tbody>
|
||||
${ws.data.map(w => `
|
||||
<tr>
|
||||
<td class="mono">#${w.id}</td>
|
||||
@@ -964,11 +1371,12 @@ async function pageWorkers() {
|
||||
<td class="mono">${fmtMoney(w.month_cost)}</td>
|
||||
<td class="mono">${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'}<br>${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}</td>
|
||||
<td><span class="badge ${w.status === 'enabled' ? 'st-done' : 'st-cancelled'}">${w.status === 'enabled' ? '启用' : '停用'}</span></td>
|
||||
<td><span class="tag">${{view:'查看',use:'使用',manage:'管理',admin:'管理员'}[w.perm] || w.perm}</span></td>
|
||||
<td><button class="btn sm" onclick="testWorker(${w.id})">测试</button>
|
||||
<button class="btn sm" onclick="visionTest(${w.id})">🖼️ 视觉测试</button>
|
||||
<button class="btn sm" onclick="openWorkerModal(${JSON.stringify(w).replace(/"/g,'"')}, ${JSON.stringify(ps.data).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn sm danger" onclick="delWorker(${w.id})">删除</button></td>
|
||||
</tr>`).join('') || '<tr><td colspan="9" class="empty">还没有 Worker,注册第一个虚拟员工吧</td></tr>'}
|
||||
${canManage ? `<button class="btn sm" onclick="openWorkerModal(${JSON.stringify(w).replace(/"/g,'"')}, ${JSON.stringify(ps.data).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn sm danger" onclick="delWorker(${w.id})">删除</button>` : ''}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="10" class="empty">还没有 Worker,注册第一个虚拟员工吧</td></tr>'}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
@@ -1639,8 +2047,9 @@ function entRender() {
|
||||
const box = $('#ent-body');
|
||||
if (entTab === 'users') {
|
||||
box.innerHTML = `
|
||||
<div class="card"><div class="sec-title" style="margin-top:0">👥 用户列表(RBAC)</div>
|
||||
<div class="card"><div class="sec-title" style="margin-top:0">👥 用户列表(RBAC + 精准授权)</div>
|
||||
<button class="btn mini" onclick="entAddUser()">+ 新建用户</button>
|
||||
<button class="btn mini" style="float:right" onclick="entGrantOverview()">🗺️ 授权总览</button>
|
||||
<table style="margin-top:8px"><thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>来源</th><th>状态</th><th>最近登录</th><th>操作</th></tr></thead><tbody>
|
||||
${(d.users || []).map(u => `
|
||||
<tr><td>${esc(u.username)}</td><td>${esc(u.display_name)}</td>
|
||||
@@ -1648,9 +2057,10 @@ function entRender() {
|
||||
<td>${({local:'本地',oidc:'OIDC',ldap:'LDAP'})[u.source] || u.source}</td>
|
||||
<td><span class="tag">${u.status === 'active' ? '✅ 启用' : '🚫 停用'}</span></td>
|
||||
<td>${fmtTime(u.last_login_at)}</td>
|
||||
<td><button class="btn mini" onclick="entEditUser(${u.id})">编辑</button></td></tr>`).join('')}
|
||||
<td><button class="btn mini" onclick="entGrantUser(${u.id}, '${esc(u.username)}')">🔑 授权</button>
|
||||
<button class="btn mini" onclick="entEditUser(${u.id})">编辑</button></td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
<div class="tab-note">💡 角色说明:管理员=全部权限;成员=看板/任务操作;审计员=只读审计。V1 口令 admin123 兼容登录为 admin。</div>
|
||||
<div class="tab-note">💡 权限体系:<b>角色</b>(管理员=全部;审计员=全量只读;成员=按授权)+ <b>项目授权</b>(查看/管理/管理员)+ <b>Worker 授权</b>(查看/使用/管理)。成员仅能看到被授权的项目与 Worker,创建项目者自动成为该项目管理员。V1 口令 admin123 兼容登录为 admin。</div>
|
||||
</div>`;
|
||||
} else if (entTab === 'sso') {
|
||||
const s = d.sso || {};
|
||||
@@ -1808,11 +2218,70 @@ function entEditUser(id) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 精准授权:项目 / Worker 权限 ---------- */
|
||||
const PROJ_PERMS = [['', '🚫 无'], ['view', '👁️ 查看'], ['manage', '🛠️ 管理'], ['admin', '👑 管理员']];
|
||||
const WORKER_PERMS = [['', '🚫 无'], ['view', '👁️ 查看'], ['use', '⚡ 使用'], ['manage', '🛠️ 管理']];
|
||||
|
||||
async function entGrantUser(uid, uname) {
|
||||
const r = await api(`/api/enterprise/users/${uid}/grants`);
|
||||
const {projects, workers} = r.data;
|
||||
openModal(`
|
||||
<h3>🔑 精准授权:${esc(uname)}<button class="close" data-close>×</button></h3>
|
||||
<div class="tab-note">项目权限:查看=看板/DAG/知识库只读;管理=可建任务/执行/上传交付物/发送;管理员=项目全部操作。<br>
|
||||
Worker 权限:查看=可见档案;使用=可指派给任务;管理=可改配置。授权后用户登录即可见对应内容。</div>
|
||||
<div class="sec-title" style="margin-top:14px">📁 项目授权(${projects.length} 个)</div>
|
||||
<table style="max-height:240px;overflow:auto;display:block"><tbody>
|
||||
${projects.map(p => `
|
||||
<tr><td>#${p.id} ${esc(p.name)} <span class="tag">${{active:'进行中',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}</span></td>
|
||||
<td style="width:140px"><select data-gp="${p.id}">
|
||||
${PROJ_PERMS.map(([v, l]) => `<option value="${v}" ${p.perm === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select></td></tr>`).join('') || '<tr><td class="empty">暂无项目</td></tr>'}
|
||||
</tbody></table>
|
||||
<div class="sec-title" style="margin-top:14px">🤖 Worker 授权(${workers.length} 个)</div>
|
||||
<table style="max-height:240px;overflow:auto;display:block"><tbody>
|
||||
${workers.map(w => `
|
||||
<tr><td>#${w.id} ${esc(w.name)} <span class="pill">${esc(w.provider)}/${esc(w.model)}</span> <span class="tag">${w.status === 'enabled' ? '启用' : '停用'}</span></td>
|
||||
<td style="width:140px"><select data-gw="${w.id}">
|
||||
${WORKER_PERMS.map(([v, l]) => `<option value="${v}" ${w.perm === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select></td></tr>`).join('') || '<tr><td class="empty">暂无 Worker</td></tr>'}
|
||||
</tbody></table>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="eg-save">保存授权</button>
|
||||
</div>`);
|
||||
$('#eg-save').addEventListener('click', async () => {
|
||||
try {
|
||||
await api(`/api/enterprise/users/${uid}/grants`, {method: 'PUT', body: {
|
||||
projects: [...$$('[data-gp]')].map(s => ({project_id: Number(s.dataset.gp), perm: s.value})),
|
||||
workers: [...$$('[data-gw]')].map(s => ({worker_id: Number(s.dataset.gw), perm: s.value}))
|
||||
}});
|
||||
toast('✅ 授权已保存', 'ok'); closeModal(); pageEnterprise();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
|
||||
async function entGrantOverview() {
|
||||
const r = await api('/api/enterprise/grants/overview');
|
||||
openModal(`
|
||||
<h3>🗺️ 授权总览<button class="close" data-close>×</button></h3>
|
||||
<div style="max-height:70vh;overflow:auto">
|
||||
<table><thead><tr><th>用户</th><th>角色</th><th>项目授权</th><th>Worker 授权</th></tr></thead><tbody>
|
||||
${r.data.map(u => `<tr><td><b>${esc(u.username)}</b><br><small style="color:var(--muted)">${esc(u.display_name || '')}</small></td>
|
||||
<td>${{admin:'👑 管理员',member:'成员',auditor:'审计员'}[u.role] || u.role}</td>
|
||||
<td>${(u.projects || []).map(p => `#${p.project_id} ${esc(p.project_name)} <span class="tag">${{view:'查看',manage:'管理',admin:'管理员'}[p.perm]}</span>`).join('<br>') || '<span style="color:var(--muted)">—</span>'}</td>
|
||||
<td>${(u.workers || []).map(w => `#${w.worker_id} ${esc(w.worker_name)} <span class="tag">${{view:'查看',use:'使用',manage:'管理'}[w.perm]}</span>`).join('<br>') || '<span style="color:var(--muted)">—</span>'}</td></tr>`).join('')}
|
||||
</tbody></table></div>
|
||||
<div class="modal-actions"><button class="btn primary" data-close>关闭</button></div>`);
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
let CURRENT_ROLE = 'admin';
|
||||
(async function init() {
|
||||
try {
|
||||
const me = await api('/api/me');
|
||||
if (!me.authed) { showLogin(); $('#main').innerHTML = '<div class="empty">请先登录</div>'; return; }
|
||||
CURRENT_ROLE = me.user?.role || 'admin';
|
||||
applyRoleUI(CURRENT_ROLE);
|
||||
$('#logout-btn').style.display = 'block';
|
||||
} catch (e) { showLogin(); return; }
|
||||
try { await api('/api/health'); } catch (e) { $('#conn-state').textContent = '● 服务异常'; $('#conn-state').className = 'conn err'; }
|
||||
|
||||
+21
-1
@@ -138,8 +138,28 @@ tr:hover td{background:var(--panel2)}
|
||||
.dag-node rect{stroke-width:1.5;rx:10}
|
||||
.dag-label{font-size:13px;font-weight:600;fill:#ffffff;stroke:none;text-anchor:middle;pointer-events:none}
|
||||
.dag-sub{font-size:11px;fill:rgba(255,255,255,.82);stroke:none;text-anchor:middle;pointer-events:none}
|
||||
.dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow)}
|
||||
.dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow);pointer-events:none}
|
||||
.dag-edge.act{stroke:var(--accent)}
|
||||
.dag-edge-g{cursor:pointer}
|
||||
.dag-edge-hit{fill:none;stroke:transparent;stroke-width:14;pointer-events:stroke;cursor:pointer}
|
||||
.dag-edge-g:hover .dag-edge{stroke:var(--accent)}
|
||||
.dag-edge.sel{stroke:var(--accent);stroke-width:2.5}
|
||||
.dag-edge-x{opacity:0;fill:var(--danger);stroke:#fff;stroke-width:1.5;pointer-events:none;cursor:pointer;transition:opacity .12s}
|
||||
.dag-edge-xt{opacity:0;font-size:10px;fill:#fff;text-anchor:middle;pointer-events:none;transition:opacity .12s}
|
||||
.dag-edge-g:hover .dag-edge-x,.dag-edge-g:hover .dag-edge-xt,.dag-edge-g.sel .dag-edge-x,.dag-edge-g.sel .dag-edge-xt{opacity:1}
|
||||
.dag-edge-g:hover .dag-edge-x,.dag-edge-g.sel .dag-edge-x{pointer-events:all}
|
||||
.dag-port{fill:var(--panel2);stroke:var(--accent);stroke-width:2;opacity:0;pointer-events:all;transition:opacity .15s}
|
||||
.dag-node:hover .dag-port{opacity:1}
|
||||
.dag-connecting .dag-port{opacity:1}
|
||||
.dag-port.out{cursor:crosshair}
|
||||
.dag-port.in{cursor:crosshair}
|
||||
.dag-node.drop-ok rect{stroke:var(--accent2);stroke-width:2.5}
|
||||
.dag-node.drop-bad rect{stroke:var(--danger);stroke-width:2.5}
|
||||
.dag-ghost{stroke:var(--accent);stroke-width:2;stroke-dasharray:6 4;fill:none;marker-end:url(#arrow);pointer-events:none}
|
||||
.dag-ghost.bad{stroke:var(--danger)}
|
||||
.dag-edge-actions{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:rgba(23,30,46,.92);border:1px solid var(--border);border-radius:8px;padding:4px 8px;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;z-index:7;backdrop-filter:blur(2px);box-shadow:0 4px 14px rgba(0,0,0,.35)}
|
||||
.dag-edge-actions span{color:var(--muted);white-space:nowrap}
|
||||
.dag-edge-actions b{color:var(--accent)}
|
||||
.dag-trash{position:absolute;left:14px;bottom:14px;background:rgba(255,107,107,.1);border:1.5px dashed var(--danger);color:var(--danger);border-radius:10px;padding:8px 14px;font-size:12px;cursor:pointer;z-index:5;display:flex;align-items:center;gap:6px;backdrop-filter:blur(2px)}
|
||||
.dag-trash:hover,.dag-trash.hover{background:rgba(255,107,107,.25);border-style:solid}
|
||||
.dag-trash .cnt{background:var(--danger);color:#fff;border-radius:8px;padding:0 6px;font-size:11px}
|
||||
|
||||
Reference in New Issue
Block a user