Compare commits

..
2 Commits
Author SHA1 Message Date
hz4th_coder eb92a4e591 V3.0 交付体系 + 精准权限系统
交付环节优化:
- 新建项目必填送达者(人)邮箱,项目完成/遇到无法绕开的难关时自动邮件及时通知
- 每个项目独立工作目录 data/workspace/project_<id>/,交付物分类存放互不污染(上传/下载/删除)
- 网页交付物一键部署到 /demo/<id>/ 免登录公开 Demo,送达者直接打开链接查看
- 非网页交付物 zip 打包 data/packages/,随邮件附件发送送达者(含手动交付/完成交付/自动交付)
- 任务全部完成自动收尾交付;失败任务触发难关通知(30分钟限频去重)

用户(人)管理 + 精准权限:
- 管理员增删改用户,管理用户项目所属与 Worker 权限(授权弹窗 + 授权总览矩阵)
- 角色体系:admin 全部 / auditor 全量只读 / member 按授权
- 项目授权 view/manage/admin;Worker 授权 view/use/manage;创建者自动成为项目管理员
- 仪表盘/成本报表/日志/协作/评估全部按权限过滤,越权访问 403

新表:project_deliverables / user_projects / user_workers;新模块 delivery.py
2026-08-13 12:38:38 +08:00
hz4th_coder 78ccca61c3 DAG画布连线编辑:新建/重连/删除 + 环检测
- 连线操作三件套:
  * 新建:拖节点右侧输出口到目标节点(幽灵线预览 + 合法/非法高亮)
  * 重连:单击连线选中 → 操作条「🔗重连」→ 点击新目标节点(自动移除旧边)
  * 删除:双击连线 / 悬停中点✕按钮 / 选中后 Delete 键 / 操作条删除按钮
- 单击连线选中高亮 + 顶部操作条(重连/删除/取消),点击空白取消选中
- 前端防环校验(DFS)+ 非法目标标红,后端 PUT/POST depends_on 全面校验:
  依赖存在性、非自依赖、全图环检测(返回环路径)
- 节点新增左右连接端口(悬停/连线模式显示);拖节点时连线与端口同步更新
- 修复:控件条点击不再误触发取消选中/视图复位
2026-08-13 11:31:40 +08:00
9 changed files with 1779 additions and 85 deletions
+34 -8
View File
@@ -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 APIV1 + V2 路由)
├── app.py # Flask 应用 + REST APIV1 + 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/ 网页Demopackages/ 打包件;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)
+672 -26
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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
+60
View File
@@ -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)')
# V3projects 交付字段
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
View File
@@ -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()
+27 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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, '&quot;')})">编辑项目</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, '&quot;')})">编辑项目</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,'&quot;')})"> 注册 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,'&quot;')})"> 注册 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,'&quot;')}, ${JSON.stringify(ps.data).replace(/"/g,'&quot;')})">编辑</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,'&quot;')}, ${JSON.stringify(ps.data).replace(/"/g,'&quot;')})">编辑</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
View File
@@ -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}