Compare commits

..
1 Commits
Author SHA1 Message Date
hz4th_coder 8be01b5af7 V3.2 送达者=用户 + 用户邮箱必填
- users 表新增 email 列:所有用户(含 admin)必须有邮箱,新建/编辑用户强制校验
- 项目送达者从'邮箱字符串'改为'绑定用户(deliver_user_id)':创建项目必须选择平台用户
- 送达通知实时读取用户最新邮箱:用户改邮箱 → 其送达的所有项目自动跟随,无需改项目
- 项目进行中可随时更换送达者用户(编辑项目 → 送达者下拉)
- 迁移:存量用户自动补 username@tphai.com 默认邮箱;旧项目按 deliver_email 匹配回填 deliver_user_id
- 新增 /api/users 轻量用户列表接口(登录即可,供选送达者);deliver_email 降级为快照缓存
- 前端:项目表单送达者改为用户下拉选择;用户管理增加邮箱列;交付页显示送达者用户名+邮箱
2026-08-13 16:21:35 +08:00
6 changed files with 173 additions and 57 deletions
+13 -2
View File
@@ -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 回归对比视图
+72 -26
View File
@@ -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})
+19 -1
View File
@@ -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.2projects 送达者改为用户 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
View File
@@ -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
View File
@@ -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
View File
@@ -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;