Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be01b5af7 |
@@ -3,7 +3,7 @@
|
||||
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
||||
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
||||
|
||||
**当前版本:V3.1**(自定义角色 / Worker 权限组 / 交付体系 / 精准权限 / 多 Agent 协作 / 企业版)
|
||||
**当前版本:V3.2**(送达者=用户 / 用户邮箱必填 / 自定义角色 / Worker 权限组 / 交付体系)
|
||||
|
||||
---
|
||||
|
||||
@@ -52,12 +52,19 @@
|
||||
### 📦 交付体系(V3 新增)
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 📬 送达者邮箱 | 新建项目**必填**送达者(人)邮箱;项目**完成**或遇到**无法绕开的难关**时自动邮件及时通知 |
|
||||
| 📬 送达者=用户 | 新建项目必选**送达者用户**(平台用户,含 admin);通知实时发到该用户**最新邮箱**,用户改邮箱自动跟随,项目进行中可随时更换送达者 |
|
||||
| 🗂️ 项目工作目录 | 每个项目独立工作目录 `data/workspace/project_<id>/`,中间产物/交付物分类存放,互不污染;支持网页上传/下载/删除 |
|
||||
| 🌐 网页交付物 | 一键部署到 `data/demo/<id>/`,经 `/demo/<id>/` **免登录公开访问**,送达者直接打开链接查看 |
|
||||
| 🗜️ 打包交付 | 工作目录一键 zip 打包(`data/packages/`),随邮件附件发送给送达者 |
|
||||
| ✉️ 手动通知 | 难关说明/进展可随时手动邮件通知送达者;交付全流程留痕(交付记录) |
|
||||
|
||||
### 👥 用户体系(V3.2)
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 📧 邮箱必填 | 所有用户(含 admin)必须有邮箱,新建/编辑用户强制校验;创建项目选送达者时列出用户供选择 |
|
||||
| 🔄 邮箱变更跟随 | 用户改邮箱 → 其送达的所有项目通知自动发到新邮箱(实时读取,无需改项目) |
|
||||
| 🔀 送达者可变 | 项目进行中可在编辑项目中更换送达者用户,旧数据按邮箱自动回填迁移 |
|
||||
|
||||
### 🔑 精准权限(V3 新增)
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
@@ -160,6 +167,10 @@ ai-worker-platform/
|
||||
- `POST /api/projects/<pid>/complete` 完成项目并交付 | `POST .../notify_deliverer` 手动通知送达者 | `GET /api/projects/<pid>/deliverables` 交付记录
|
||||
- `GET /demo/<pid>/` 公开 Demo 地址(送达者免登录访问)
|
||||
|
||||
用户与送达者(V3.2):
|
||||
- `GET /api/users` 轻量用户列表(登录即可,选送达者用)| `GET/POST /api/enterprise/users` 完整用户管理(邮箱必填)
|
||||
- `PUT /api/enterprise/users/<id>` 改邮箱后项目送达自动跟随;项目 `deliver_user_id` 绑定送达者用户
|
||||
|
||||
## 路线图
|
||||
|
||||
- **V2.1**:Temporal 持久执行、多 Agent 协作接入项目任务、eval 回归对比视图
|
||||
|
||||
@@ -139,7 +139,8 @@ def me():
|
||||
row = enterprise.get_user(uname)
|
||||
uid = row['id'] if row else None
|
||||
u = {'username': uname, 'role': session.get('role') or (row['role'] if row else 'admin'),
|
||||
'display_name': row['display_name'] if row else uname}
|
||||
'display_name': row['display_name'] if row else uname,
|
||||
'email': row['email'] if row else ''}
|
||||
if uid:
|
||||
perms = enterprise.user_perms(uid)
|
||||
u['perms'] = sorted(perms)
|
||||
@@ -229,7 +230,7 @@ def require_admin(fn):
|
||||
|
||||
@app.route('/api/health')
|
||||
def health():
|
||||
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.1.0'})
|
||||
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.2.0'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -331,6 +332,18 @@ def _check_task_perm(tid, need='view'):
|
||||
return None
|
||||
|
||||
|
||||
def _attach_deliver_user(project):
|
||||
"""项目附带送达者用户信息(用户名/邮箱实时取)"""
|
||||
duid = project.get('deliver_user_id')
|
||||
project['deliver_user'] = None
|
||||
if duid:
|
||||
u = db.q('SELECT id, username, display_name, email FROM users WHERE id=?', (duid,), one=True)
|
||||
if u:
|
||||
project['deliver_user'] = dict(u)
|
||||
project['deliver_email'] = u['email'] or project.get('deliver_email', '')
|
||||
return project
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 项目
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -342,18 +355,22 @@ def projects():
|
||||
if err:
|
||||
return err
|
||||
d = request.get_json(force=True)
|
||||
deliver_email = (d.get('deliver_email') or '').strip()
|
||||
if not deliver_email:
|
||||
return jsonify({'ok': False, 'error': '必填:项目送达者(人)邮箱,用于项目完成/难关通知'}), 400
|
||||
if '@' not in deliver_email:
|
||||
return jsonify({'ok': False, 'error': '送达者邮箱格式不正确'}), 400
|
||||
# 送达者:必须选择系统用户(绑定 user_id,邮箱实时取)
|
||||
duid = d.get('deliver_user_id')
|
||||
if not duid:
|
||||
return jsonify({'ok': False, 'error': '必填:选择项目送达者用户(项目完成/难关时通知)'}), 400
|
||||
du = db.q('SELECT id, email FROM users WHERE id=?', (int(duid),), one=True)
|
||||
if not du:
|
||||
return jsonify({'ok': False, 'error': '送达者用户不存在'}), 400
|
||||
if not (du.get('email') or '').strip():
|
||||
return jsonify({'ok': False, 'error': f'送达者用户「{d.get("deliver_username", "")}」未设置邮箱,请先在用户管理中补全'}), 400
|
||||
pid = db.w(
|
||||
'INSERT INTO projects (name, description, objective, acceptance_criteria, '
|
||||
'status, budget_limit, deliver_email, deliver_type, deliver_note, workspace_dir, '
|
||||
'created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'status, budget_limit, deliver_user_id, deliver_email, deliver_type, deliver_note, workspace_dir, '
|
||||
'created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
(d.get('name', '').strip(), d.get('description', ''), d.get('objective', ''),
|
||||
d.get('acceptance_criteria', ''), d.get('status', 'active'),
|
||||
float(d.get('budget_limit') or 0), deliver_email,
|
||||
float(d.get('budget_limit') or 0), int(duid), du['email'],
|
||||
d.get('deliver_type', 'web'), d.get('deliver_note', ''),
|
||||
'', db.now(), db.now()))
|
||||
# 工作目录名依赖自增 id:先拿到 id 再补写
|
||||
@@ -365,7 +382,7 @@ def projects():
|
||||
db.w('INSERT INTO user_projects (user_id, project_id, perm, created_at) VALUES (?,?,?,?)',
|
||||
(u['id'], pid, 'admin', db.now()))
|
||||
enterprise.audit(enterprise.current_actor(), 'project.create', f'project#{pid}',
|
||||
f'「{d.get("name","")}」送达者 {deliver_email}', request.remote_addr or '')
|
||||
f'「{d.get("name","")}」送达者用户#{duid} {du["email"]}', request.remote_addr or '')
|
||||
return jsonify({'ok': True, 'id': pid})
|
||||
err = _check_perm_point('project.view')
|
||||
if err:
|
||||
@@ -379,6 +396,7 @@ def projects():
|
||||
r['done_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
|
||||
(r['id'],))[0]['c']
|
||||
r['perm'] = _project_perm(r['id'])
|
||||
_attach_deliver_user(r)
|
||||
return jsonify({'ok': True, 'data': rows})
|
||||
|
||||
|
||||
@@ -396,6 +414,7 @@ def project_detail(pid):
|
||||
if not p:
|
||||
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
||||
p['perm'] = _project_perm(pid)
|
||||
_attach_deliver_user(p)
|
||||
return jsonify({'ok': True, 'data': p})
|
||||
if request.method == 'DELETE':
|
||||
err = _check_project_perm(pid, 'admin')
|
||||
@@ -432,6 +451,15 @@ def project_detail(pid):
|
||||
if sets:
|
||||
args.append(db.now())
|
||||
db.w(f'UPDATE projects SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, pid))
|
||||
# 送达者变更:校验用户 + 同步邮箱快照
|
||||
if 'deliver_user_id' in d:
|
||||
du = db.q('SELECT id, email FROM users WHERE id=?', (int(d['deliver_user_id']),), one=True)
|
||||
if not du:
|
||||
return jsonify({'ok': False, 'error': '送达者用户不存在'}), 400
|
||||
if not (du.get('email') or '').strip():
|
||||
return jsonify({'ok': False, 'error': '送达者用户未设置邮箱,请先补全'}), 400
|
||||
db.w('UPDATE projects SET deliver_user_id=?, deliver_email=?, updated_at=? WHERE id=?',
|
||||
(int(d['deliver_user_id']), du['email'], db.now(), pid))
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@@ -1279,8 +1307,8 @@ def project_deliver(pid):
|
||||
if err:
|
||||
return err
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||||
if not (proj.get('deliver_email') or '').strip():
|
||||
return jsonify({'ok': False, 'error': '项目未配置送达者邮箱,请先在编辑项目中填写'}), 400
|
||||
if not delivery.deliver_email_of(proj):
|
||||
return jsonify({'ok': False, 'error': '项目未配置送达者用户,请先在编辑项目中指定'}), 400
|
||||
base = request.host_url.rstrip('/')
|
||||
# 网页交付物:确保 Demo 已部署
|
||||
demo = proj.get('demo_url') or ''
|
||||
@@ -1305,17 +1333,17 @@ def project_deliver(pid):
|
||||
f'交付物已打包为附件({pkg["size"]} B),请查收。')
|
||||
subject = f'📦 项目交付:{proj["name"]}'
|
||||
body = delivery._email_body(proj, extra=extra)
|
||||
ok, msg = delivery.send_mail(proj['deliver_email'], subject, body,
|
||||
ok, msg = delivery.send_mail(delivery.deliver_email_of(proj), subject, body,
|
||||
attachments=[attach] if attach else None)
|
||||
if not ok:
|
||||
return jsonify({'ok': False, 'error': msg}), 500
|
||||
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), pid))
|
||||
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, demo_url, size, note, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?)',
|
||||
(pid, f'交付邮件 → {proj["deliver_email"]}', 'email', pkg['name'], demo,
|
||||
(pid, f'交付邮件 → {delivery.deliver_email_of(proj)}', 'email', pkg['name'], demo,
|
||||
pkg['size'], '手动交付(含附件)', db.now()))
|
||||
enterprise.audit(enterprise.current_actor(), 'deliver.send', f'project#{pid}',
|
||||
f'邮件交付 → {proj["deliver_email"]}({pkg["name"]})', request.remote_addr or '')
|
||||
f'邮件交付 → {delivery.deliver_email_of(proj)}({pkg["name"]})', request.remote_addr or '')
|
||||
return jsonify({'ok': True, 'msg': msg, 'demo_url': demo, 'package': pkg['name']})
|
||||
|
||||
|
||||
@@ -1327,8 +1355,8 @@ def project_complete(pid):
|
||||
if err:
|
||||
return err
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||||
if not (proj.get('deliver_email') or '').strip():
|
||||
return jsonify({'ok': False, 'error': '项目未配置送达者邮箱,请先填写'}), 400
|
||||
if not delivery.deliver_email_of(proj):
|
||||
return jsonify({'ok': False, 'error': '项目未配置送达者用户,请先在编辑项目中指定'}), 400
|
||||
if proj['status'] == 'done':
|
||||
return jsonify({'ok': False, 'error': '项目已完成,无需重复交付'}), 400
|
||||
base = request.host_url.rstrip('/')
|
||||
@@ -1337,7 +1365,7 @@ def project_complete(pid):
|
||||
return jsonify({'ok': False, 'error': msg}), 500
|
||||
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), pid))
|
||||
enterprise.audit(enterprise.current_actor(), 'project.complete', f'project#{pid}',
|
||||
f'「{proj["name"]}」完成交付 → {proj["deliver_email"]}', request.remote_addr or '')
|
||||
f'「{proj["name"]}」完成交付 → {delivery.deliver_email_of(proj)}', request.remote_addr or '')
|
||||
return jsonify({'ok': True, 'msg': msg})
|
||||
|
||||
|
||||
@@ -1350,8 +1378,8 @@ def project_notify_deliverer(pid):
|
||||
return err
|
||||
d = request.get_json(force=True) or {}
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||||
if not (proj.get('deliver_email') or '').strip():
|
||||
return jsonify({'ok': False, 'error': '项目未配置送达者邮箱'}), 400
|
||||
if not delivery.deliver_email_of(proj):
|
||||
return jsonify({'ok': False, 'error': '项目未配置送达者用户'}), 400
|
||||
subject = (d.get('subject') or f'项目动态:{proj["name"]}').strip()
|
||||
text = (d.get('message') or '').strip()
|
||||
if not text:
|
||||
@@ -1918,28 +1946,39 @@ def ent_users():
|
||||
if request.method == 'POST':
|
||||
d = request.get_json(force=True)
|
||||
username = (d.get('username') or '').strip()
|
||||
email = (d.get('email') or '').strip()
|
||||
if not username:
|
||||
return jsonify({'ok': False, 'error': '用户名不能为空'}), 400
|
||||
if not email or '@' not in email:
|
||||
return jsonify({'ok': False, 'error': '必填:用户邮箱(用于项目送达/通知)'}), 400
|
||||
if d.get('source') in ('oidc', 'ldap'):
|
||||
if enterprise.get_user(username):
|
||||
return jsonify({'ok': False, 'error': '用户已存在'}), 400
|
||||
db.w('INSERT INTO users (username, password_hash, display_name, role, source, status, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(username, '', d.get('display_name', username), d.get('role', 'member'),
|
||||
db.w('INSERT INTO users (username, password_hash, display_name, email, role, source, status, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?)',
|
||||
(username, '', d.get('display_name', username), email, d.get('role', 'member'),
|
||||
d.get('source'), 'active', db.now()))
|
||||
else:
|
||||
if not d.get('password'):
|
||||
return jsonify({'ok': False, 'error': '本地用户必须设置密码'}), 400
|
||||
uid, err = enterprise.create_local_user(username, d['password'],
|
||||
d.get('display_name', ''), d.get('role', 'member'))
|
||||
d.get('display_name', ''), d.get('role', 'member'), email)
|
||||
if err:
|
||||
return jsonify({'ok': False, 'error': err}), 400
|
||||
return jsonify({'ok': True})
|
||||
rows = db.q('SELECT id, username, display_name, role, source, status, last_login_at, created_at '
|
||||
rows = db.q('SELECT id, username, display_name, email, role, source, status, last_login_at, created_at '
|
||||
'FROM users ORDER BY id')
|
||||
return jsonify({'ok': True, 'data': rows})
|
||||
|
||||
|
||||
@app.route('/api/users')
|
||||
@require_auth
|
||||
def users_light():
|
||||
"""轻量用户列表(登录即可):用于创建项目时选择送达者用户"""
|
||||
rows = db.q('SELECT id, username, display_name, email, role, status FROM users ORDER BY id')
|
||||
return jsonify({'ok': True, 'data': rows})
|
||||
|
||||
|
||||
@app.route('/api/enterprise/users/<int:uid>', methods=['PUT', 'DELETE'])
|
||||
@require_admin
|
||||
def ent_user_detail(uid):
|
||||
@@ -1960,6 +1999,13 @@ def ent_user_detail(uid):
|
||||
db.w('UPDATE users SET status=? WHERE id=?', (d['status'], uid))
|
||||
if 'display_name' in d:
|
||||
db.w('UPDATE users SET display_name=? WHERE id=?', (d['display_name'], uid))
|
||||
if 'email' in d:
|
||||
email = (d['email'] or '').strip()
|
||||
if not email or '@' not in email:
|
||||
return jsonify({'ok': False, 'error': '邮箱格式不正确'}), 400
|
||||
db.w('UPDATE users SET email=? WHERE id=?', (email, uid))
|
||||
# 同步:该用户送达的旧项目邮箱快照刷新
|
||||
db.w('UPDATE projects SET deliver_email=? WHERE deliver_user_id=?', (email, uid))
|
||||
if d.get('password'):
|
||||
db.w('UPDATE users SET password_hash=? WHERE id=?', (db.hash_password(d['password']), uid))
|
||||
return jsonify({'ok': True})
|
||||
|
||||
@@ -16,7 +16,8 @@ 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_user_id INTEGER, -- 送达者用户 id(users.id),实时取该用户邮箱
|
||||
deliver_email TEXT DEFAULT '', -- 送达者邮箱快照(兼容/展示,发送时以用户最新邮箱为准)
|
||||
deliver_type TEXT DEFAULT 'web', -- 交付物类型 web=网页 / file=文件包
|
||||
deliver_note TEXT DEFAULT '', -- 交付说明
|
||||
workspace_dir TEXT DEFAULT '', -- 项目工作目录(相对 data/ 的目录名)
|
||||
@@ -241,6 +242,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT DEFAULT '',
|
||||
display_name TEXT DEFAULT '',
|
||||
email TEXT DEFAULT '', -- 用户邮箱(必填,送达/通知用)
|
||||
role TEXT DEFAULT 'member', -- admin / member / auditor
|
||||
source TEXT DEFAULT 'local', -- local / oidc / ldap
|
||||
status TEXT DEFAULT 'active', -- active / disabled
|
||||
@@ -391,6 +393,22 @@ def _migrate():
|
||||
):
|
||||
if col not in pcols:
|
||||
conn.execute(ddl)
|
||||
# V3.2:projects 送达者改为用户 id(实时取邮箱)
|
||||
if 'deliver_user_id' not in pcols:
|
||||
conn.execute('ALTER TABLE projects ADD COLUMN deliver_user_id INTEGER')
|
||||
# V3.2:users 邮箱列 + 存量用户默认邮箱 + 旧项目按邮箱回填送达者用户
|
||||
ucols = {r['name'] for r in conn.execute('PRAGMA table_info(users)')}
|
||||
if 'email' not in ucols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''")
|
||||
for r in conn.execute('SELECT id, username, email FROM users WHERE email IS NULL OR email=\'\''):
|
||||
conn.execute('UPDATE users SET email=? WHERE id=?',
|
||||
(f'{r["username"]}@tphai.com', r['id']))
|
||||
# 旧项目:按 deliver_email 匹配用户回填 deliver_user_id
|
||||
for r in conn.execute("SELECT id, deliver_email FROM projects WHERE (deliver_user_id IS NULL OR deliver_user_id=0) "
|
||||
"AND deliver_email IS NOT NULL AND deliver_email != ''"):
|
||||
u = conn.execute('SELECT id FROM users WHERE email=?', (r['deliver_email'],)).fetchone()
|
||||
if u:
|
||||
conn.execute('UPDATE projects SET deliver_user_id=? WHERE id=?', (u['id'], r['id']))
|
||||
# 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=?',
|
||||
|
||||
+15
-5
@@ -290,11 +290,21 @@ def _email_body(project, extra=''):
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def deliver_email_of(project):
|
||||
"""送达者邮箱:优先取绑定的用户(deliver_user_id)最新邮箱;无绑定则回退旧快照"""
|
||||
uid = project.get('deliver_user_id')
|
||||
if uid:
|
||||
u = db.q('SELECT email FROM users WHERE id=?', (uid,), one=True)
|
||||
if u and (u.get('email') or '').strip():
|
||||
return u['email'].strip()
|
||||
return (project.get('deliver_email') or '').strip()
|
||||
|
||||
|
||||
def notify_deliverer(project, subject, text, attach=None, base_url=''):
|
||||
"""发邮件给送达者,写交付记录。返回 (ok, msg)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
email = deliver_email_of(project)
|
||||
if not email:
|
||||
return False, '项目未配置送达者邮箱'
|
||||
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']))
|
||||
@@ -303,7 +313,7 @@ def notify_deliverer(project, subject, text, attach=None, base_url=''):
|
||||
|
||||
def notify_blocker(project, task_title, detail):
|
||||
"""项目遇到无法绕开的难关 → 及时邮件通知送达者(同项目限频防打扰)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
email = deliver_email_of(project)
|
||||
if not email:
|
||||
return
|
||||
# 去重:同项目 30 分钟内只提醒一次(detail 带项目标记)
|
||||
@@ -329,9 +339,9 @@ def notify_blocker(project, task_title, detail):
|
||||
|
||||
def notify_project_complete(project, base_url=''):
|
||||
"""项目完成 → 打包 + 邮件送达(含 Demo 链接与附件)。返回 (ok, msg)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
email = deliver_email_of(project)
|
||||
if not email:
|
||||
return False, '项目未配置送达者邮箱'
|
||||
return False, '项目未配置送达者(请在编辑项目中指定送达者用户)'
|
||||
# 网页交付物:确保已部署 Demo
|
||||
if project.get('deliver_type') == 'web' and not project.get('demo_url'):
|
||||
try:
|
||||
|
||||
+19
-11
@@ -110,13 +110,15 @@ def is_admin(username):
|
||||
return role_of(username) == 'admin'
|
||||
|
||||
|
||||
def create_local_user(username, password, display_name='', role='member'):
|
||||
def create_local_user(username, password, display_name='', role='member', email=''):
|
||||
if get_user(username):
|
||||
return None, '用户已存在'
|
||||
if not email:
|
||||
return None, '必填:用户邮箱(用于项目送达/通知)'
|
||||
uid = db.w(
|
||||
'INSERT INTO users (username, password_hash, display_name, role, source, status, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(username, db.hash_password(password), display_name or username, role, 'local', 'active', db.now()))
|
||||
'INSERT INTO users (username, password_hash, display_name, email, role, source, status, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?)',
|
||||
(username, db.hash_password(password), display_name or username, email, role, 'local', 'active', db.now()))
|
||||
return uid, None
|
||||
|
||||
|
||||
@@ -228,14 +230,17 @@ def sso_login(userinfo):
|
||||
return None, '用户未开通(自动开通未启用),请联系管理员'
|
||||
role = 'admin' if cfg.get('oidc_admin_group') and cfg['oidc_admin_group'] in groups else 'member'
|
||||
db.w(
|
||||
'INSERT INTO users (username, password_hash, display_name, role, source, status, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(username, '', display, role, 'oidc', 'active', db.now()))
|
||||
'INSERT INTO users (username, password_hash, display_name, email, role, source, status, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?)',
|
||||
(username, '', display, email or f'{username}@sso.local', role, 'oidc', 'active', db.now()))
|
||||
u = get_user(username)
|
||||
elif u['status'] != 'active':
|
||||
return None, '账号已停用'
|
||||
elif u['source'] != 'oidc':
|
||||
return None, f'用户名 {username} 已被本地账号占用'
|
||||
# SSO 邮箱变更时同步(送达通知用最新邮箱)
|
||||
if email and u.get('email') != email:
|
||||
db.w('UPDATE users SET email=? WHERE id=?', (email, u['id']))
|
||||
db.w('UPDATE users SET last_login_at=? WHERE id=?', (db.now(), u['id']))
|
||||
return u, None
|
||||
|
||||
@@ -276,12 +281,15 @@ def ldap_login(username, password):
|
||||
if sso_config().get('sso_auto_provision') != '1':
|
||||
return None, '用户未开通,请联系管理员'
|
||||
db.w(
|
||||
'INSERT INTO users (username, password_hash, display_name, role, source, status, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(username, '', info.get('display') or username, 'member', 'ldap', 'active', db.now()))
|
||||
'INSERT INTO users (username, password_hash, display_name, email, role, source, status, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?)',
|
||||
(username, '', info.get('display') or username, info.get('email') or f'{username}@ldap.local',
|
||||
'member', 'ldap', 'active', db.now()))
|
||||
u = get_user(username)
|
||||
elif u['status'] != 'active':
|
||||
return None, '账号已停用'
|
||||
if info.get('email') and u.get('email') != info['email']:
|
||||
db.w('UPDATE users SET email=? WHERE id=?', (info['email'], u['id']))
|
||||
db.w('UPDATE users SET last_login_at=? WHERE id=?', (db.now(), u['id']))
|
||||
return u, None
|
||||
|
||||
@@ -296,7 +304,7 @@ def export_all():
|
||||
'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': 'v3.1.0'}
|
||||
'platform': 'ai-worker-platform', 'version': 'v3.2.0'}
|
||||
for t in tables:
|
||||
try:
|
||||
out[t] = db.q(f'SELECT * FROM {t}')
|
||||
|
||||
+35
-12
@@ -166,7 +166,7 @@ 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 || '未配置送达者')}
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--muted)">📬 送达:${p.deliver_user ? esc(p.deliver_user.display_name || p.deliver_user.username) + '(' + esc(p.deliver_user.email || '') + ')' : 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>
|
||||
@@ -175,6 +175,7 @@ async function pageProjects() {
|
||||
}
|
||||
|
||||
function openProjectModal(p = {}) {
|
||||
const usersP = api('/api/users');
|
||||
openModal(`
|
||||
<h3>${p.id ? '编辑项目' : '新建项目'}<button class="close" data-close>×</button></h3>
|
||||
<label>项目名称 *</label><input id="pm-name" value="${esc(p.name || '')}" placeholder="例如:营销内容批量生产">
|
||||
@@ -188,8 +189,9 @@ function openProjectModal(p = {}) {
|
||||
<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 style="flex:2"><label>📬 送达者(人)* ${p.id ? '' : '<small style="color:var(--warn)">项目完成/难关时通知此人</small>'}</label>
|
||||
<select id="pm-email" ${p.id ? '' : 'disabled'}><option value="">加载用户中…</option></select>
|
||||
<small style="color:var(--muted)" id="pm-email-hint">送达者 = 平台用户,其邮箱变更后项目通知自动跟随;可在项目进行中随时更换</small></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>
|
||||
@@ -200,16 +202,26 @@ function openProjectModal(p = {}) {
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="pm-save">保存</button>
|
||||
</div>`);
|
||||
usersP.then(r => {
|
||||
const sel = $('#pm-email');
|
||||
sel.disabled = false;
|
||||
const cur = p.deliver_user ? p.deliver_user.id : (p.deliver_user_id || '');
|
||||
sel.innerHTML = '<option value="">— 请选择送达者用户 —</option>' +
|
||||
r.data.filter(u => u.status === 'active').map(u =>
|
||||
`<option value="${u.id}" ${String(u.id) === String(cur) ? 'selected' : ''}>${esc(u.display_name || u.username)}(${esc(u.username)} · ${esc(u.email || '无邮箱')})</option>`).join('');
|
||||
if (!cur) sel.value = '';
|
||||
}).catch(() => {});
|
||||
$('#pm-save').addEventListener('click', async () => {
|
||||
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),
|
||||
deliver_email: $('#pm-email').value.trim(), deliver_type: $('#pm-dtype').value,
|
||||
deliver_user_id: Number($('#pm-email').value || 0),
|
||||
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');
|
||||
if (!body.deliver_user_id) return toast('必填:选择送达者用户', 'err');
|
||||
try {
|
||||
await api(p.id ? `/api/projects/${p.id}` : '/api/projects', {method: p.id ? 'PUT' : 'POST', body});
|
||||
toast('已保存', 'ok'); closeModal(); router();
|
||||
@@ -1230,7 +1242,8 @@ async function renderDeliver() {
|
||||
<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_user ? esc(p.deliver_user.display_name || p.deliver_user.username) + '(' + esc(p.deliver_user.username) + ')' : '⚠️ 未绑定用户'}
|
||||
<small style="color:var(--muted)">邮箱 ${esc(p.deliver_user?.email || p.deliver_email || '—')}(用户改邮箱后自动跟随)</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>
|
||||
@@ -1327,7 +1340,8 @@ async function deliverAction(action) {
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'deliver') {
|
||||
if (!confirm(`将把项目「${p.name}」的交付物(Demo 链接 + 打包附件)发送到 ${p.deliver_email},确定?`)) return;
|
||||
const du = p.deliver_user ? (p.deliver_user.display_name || p.deliver_user.username) + '(' + p.deliver_user.email + ')' : (p.deliver_email || '');
|
||||
if (!confirm(`将把项目「${p.name}」的交付物(Demo 链接 + 打包附件)发送给送达者 ${du},确定?`)) return;
|
||||
try {
|
||||
toast('正在打包并发送邮件…');
|
||||
const r = await api(`/api/projects/${pid}/deliver`, {method: 'POST'});
|
||||
@@ -1335,7 +1349,8 @@ async function deliverAction(action) {
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'complete') {
|
||||
if (!confirm(`确认完成项目「${p.name}」?将部署 Demo、打包交付物并通过邮件通知送达者 ${p.deliver_email}。`)) return;
|
||||
const du = p.deliver_user ? (p.deliver_user.display_name || p.deliver_user.username) + '(' + p.deliver_user.email + ')' : (p.deliver_email || '');
|
||||
if (!confirm(`确认完成项目「${p.name}」?将部署 Demo、打包交付物并通过邮件通知送达者 ${du}。`)) return;
|
||||
try {
|
||||
const r = await api(`/api/projects/${pid}/complete`, {method: 'POST'});
|
||||
toast(`✅ 项目已完成并交付:${r.msg}`, 'ok');
|
||||
@@ -2186,11 +2201,12 @@ function entRenderUsers() {
|
||||
<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>
|
||||
<table style="margin-top:8px"><thead><tr><th>用户名</th><th>显示名</th><th>邮箱</th><th>角色</th><th>自定义角色/权限组</th><th>来源</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
||||
${(d.users || []).map(u => {
|
||||
const g = gmap[u.id] || {};
|
||||
const extra = [...(g.roles || []).map(rr => `🎭 ${esc(rr.role_name)}`), ...(g.worker_groups || []).map(wg => `🤖 ${esc(wg.group_name)}`)].join('<br>');
|
||||
return `<tr><td>${esc(u.username)}</td><td>${esc(u.display_name)}</td>
|
||||
<td style="font-size:12px">${esc(u.email || '<span style="color:var(--warn)">缺邮箱</span>')}</td>
|
||||
<td>${({admin:'👑 管理员',member:'成员',auditor:'审计员'})[u.role] || u.role}</td>
|
||||
<td style="font-size:12px">${extra || '<span style="color:var(--muted)">—</span>'}</td>
|
||||
<td>${({local:'本地',oidc:'OIDC',ldap:'LDAP'})[u.source] || u.source}</td>
|
||||
@@ -2353,15 +2369,18 @@ function entAddUser() {
|
||||
<h3>新建用户</h3>
|
||||
<label>用户名<input id="eu-name"></label>
|
||||
<label>显示名<input id="eu-display"></label>
|
||||
<label>邮箱 *<input id="eu-email" placeholder="如 zhangsan@company.com(项目送达/通知用)"></label>
|
||||
<label>角色<select id="eu-role"><option value="member">成员</option><option value="admin">管理员</option><option value="auditor">审计员</option></select></label>
|
||||
<label>来源<select id="eu-source"><option value="local">本地</option><option value="oidc">OIDC</option><option value="ldap">LDAP</option></select></label>
|
||||
<label>密码(本地用户必填)<input id="eu-pwd" type="password"></label>
|
||||
<button class="btn primary" id="eu-save">创建</button>`);
|
||||
$('#eu-save').addEventListener('click', async () => {
|
||||
const email = $('#eu-email').value.trim();
|
||||
if (!email || !email.includes('@')) return toast('必填:用户邮箱', 'err');
|
||||
try {
|
||||
await api('/api/enterprise/users', {method: 'POST', body: {
|
||||
username: $('#eu-name').value.trim(), display_name: $('#eu-display').value,
|
||||
role: $('#eu-role').value, source: $('#eu-source').value, password: $('#eu-pwd').value
|
||||
email, role: $('#eu-role').value, source: $('#eu-source').value, password: $('#eu-pwd').value
|
||||
}});
|
||||
toast('已创建', 'ok'); closeModal(); pageEnterprise();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
@@ -2374,17 +2393,21 @@ function entEditUser(id) {
|
||||
openModal(`
|
||||
<h3>编辑用户 ${esc(u.username)}</h3>
|
||||
<label>显示名<input id="eu2-display" value="${esc(u.display_name)}"></label>
|
||||
<label>邮箱 *<input id="eu2-email" value="${esc(u.email || '')}" placeholder="项目送达/通知用"></label>
|
||||
<label>角色<select id="eu2-role"><option ${u.role === 'member' ? 'selected' : ''}>member</option><option ${u.role === 'admin' ? 'selected' : ''}>admin</option><option ${u.role === 'auditor' ? 'selected' : ''}>auditor</option></select></label>
|
||||
<label>状态<select id="eu2-status"><option value="active" ${u.status === 'active' ? 'selected' : ''}>启用</option><option value="disabled" ${u.status === 'disabled' ? 'selected' : ''}>停用</option></select></label>
|
||||
<label>重置密码(留空不改)<input id="eu2-pwd" type="password"></label>
|
||||
<button class="btn primary" id="eu2-save">保存</button>
|
||||
<button class="btn danger" id="eu2-del" style="float:right">删除用户</button>`);
|
||||
$('#eu2-save').addEventListener('click', async () => {
|
||||
const email = $('#eu2-email').value.trim();
|
||||
if (!email || !email.includes('@')) return toast('必填:用户邮箱', 'err');
|
||||
await api(`/api/enterprise/users/${id}`, {method: 'PUT', body: {
|
||||
display_name: $('#eu2-display').value, role: $('#eu2-role').value,
|
||||
display_name: $('#eu2-display').value, email,
|
||||
role: $('#eu2-role').value,
|
||||
status: $('#eu2-status').value, password: $('#eu2-pwd').value
|
||||
}});
|
||||
toast('已保存', 'ok'); closeModal(); pageEnterprise();
|
||||
toast('已保存(送达项目自动跟随新邮箱)', 'ok'); closeModal(); pageEnterprise();
|
||||
});
|
||||
$('#eu2-del').addEventListener('click', async () => {
|
||||
if (!confirm('确定删除该用户?')) return;
|
||||
|
||||
Reference in New Issue
Block a user