Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c4cbf8994 | ||
|
|
3b5c06fdd9 |
@@ -3,7 +3,21 @@
|
||||
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
||||
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
||||
|
||||
**当前版本:V3.3**(AI 主管自动开工 / 项目创建即开工 / 失败诊断重试)
|
||||
**当前版本:V3.4**(负责人验收制 / 邮件通道修复 / 事件记录面板 / 真实网页交付)
|
||||
|
||||
---
|
||||
|
||||
## 🏁 V3.4 负责人验收制 + 交付增强(新增)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 🔔 项目级验收 | AI 团队全部完工后**不自动宣布完成**,项目进入「待验收」状态,邮件通知负责人(送达者);负责人验收通过项目才算正式完成 |
|
||||
| 🔗 公开验收链接 | 验收邮件含免登录链接 `/review/<token>`,负责人一键「验收通过 / 打回返工」(打回必填原因) |
|
||||
| 🔁 打回自动返工 | 打回后自动生成「返工任务」(含负责人意见),AI 主管立即按意见重做,再次提交验收;平台内也可直接验收/打回 |
|
||||
| 🌐 真实网页交付 | 任务的 HTML 产出自动落盘工作目录(自动剥离代码围栏/前置叙述),Demo 展示**真实页面**而非占位说明;返工产出自动覆盖入口页 |
|
||||
| 📋 事件记录面板 | 项目页「事件记录」可展开/收起(记忆状态):合并 AI 主管动态 + 任务日志 + 交付记录,任务事件可点击定位到对应任务 |
|
||||
| 📮 邮件通道修复 | 修复邮件头缺 Date 导致 amavisd 退信(`invalid header section`)的问题,打包附件邮件/各类通知恢复正常送达 |
|
||||
| 🔁 规划重试 | 任务拆解解析失败自动重试 3 次(每次重新调用 LLM),仍失败则邮件通知负责人并中断 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def require_admin(fn):
|
||||
|
||||
@app.route('/api/health')
|
||||
def health():
|
||||
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.3.0'})
|
||||
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.4.0'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1094,6 +1094,177 @@ def project_autologs(pid):
|
||||
return jsonify({'ok': True, 'data': rows})
|
||||
|
||||
|
||||
@app.route('/api/projects/<int:pid>/events')
|
||||
@require_auth
|
||||
def project_events(pid):
|
||||
"""V3.4 项目事件流水(合并:AI主管动态 + 任务日志 + 告警 + 交付记录),按时间倒序"""
|
||||
err = _check_project_perm(pid, 'view')
|
||||
if err:
|
||||
return err
|
||||
limit = min(int(request.args.get('limit', 200)), 500)
|
||||
rows = []
|
||||
# AI 主管动态
|
||||
for r in db.q('SELECT * FROM project_logs WHERE project_id=?', (pid,)):
|
||||
rows.append({'id': f'p{r["id"]}', 'kind': 'autolog', 'level': r['level'],
|
||||
'message': r['message'], 'created_at': r['created_at'], 'task_id': None,
|
||||
'task_title': None})
|
||||
# 任务日志(带任务标题,可定位)
|
||||
for r in db.q('SELECT l.*, t.title AS task_title FROM task_logs l '
|
||||
'JOIN tasks t ON t.id=l.task_id WHERE t.project_id=? AND t.deleted=0', (pid,)):
|
||||
rows.append({'id': f'l{r["id"]}', 'kind': 'task', 'level': r['level'],
|
||||
'message': r['message'], 'created_at': r['created_at'],
|
||||
'task_id': r['task_id'], 'task_title': r['task_title']})
|
||||
# 交付记录
|
||||
for r in db.q('SELECT * FROM project_deliverables WHERE project_id=?', (pid,)):
|
||||
rows.append({'id': f'd{r["id"]}', 'kind': 'deliver', 'level': 'success',
|
||||
'message': f'📦 {r["note"]}({r["name"]})', 'created_at': r['created_at'],
|
||||
'task_id': None, 'task_title': None})
|
||||
rows.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
return jsonify({'ok': True, 'data': rows[:limit], 'total': len(rows)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3.4 负责人验收(公开链接免登录 + 平台内验收)
|
||||
# ---------------------------------------------------------------------------
|
||||
REVIEW_PAGE = '''<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>项目验收 · AI Worker 平台</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,'PingFang SC','Microsoft YaHei',sans-serif;background:#0f1420;color:#e6ebf5;margin:0;padding:32px 16px;display:flex;justify-content:center}}
|
||||
.box{{max-width:560px;width:100%;background:#171e2e;border:1px solid #2a3550;border-radius:14px;padding:28px}}
|
||||
h1{{font-size:19px;margin:0 0 4px}} .sub{{color:#8b97ad;font-size:13px;margin-bottom:18px}}
|
||||
.kv{{font-size:13px;line-height:1.9;color:#aeb9d4}} .kv b{{color:#e6ebf5}}
|
||||
.demo{{display:inline-block;margin:14px 0;background:#4f8cff;color:#fff;padding:9px 18px;border-radius:8px;text-decoration:none;font-size:13px}}
|
||||
.btns{{display:flex;gap:10px;margin-top:20px}}
|
||||
button{{flex:1;padding:11px;border-radius:8px;border:1px solid #2a3550;cursor:pointer;font-size:14px}}
|
||||
.approve{{background:#38d39f;border-color:#38d39f;color:#06281c;font-weight:600}}
|
||||
.reject{{background:transparent;border-color:#ff6b6b;color:#ff6b6b}}
|
||||
textarea{{width:100%;background:#1d2638;border:1px solid #2a3550;color:#e6ebf5;padding:10px;border-radius:8px;font-size:13px;margin-top:10px;min-height:70px;box-sizing:border-box}}
|
||||
.msg{{margin-top:14px;font-size:13px;color:#38d39f}} .msg.err{{color:#ff6b6b}}
|
||||
.hint{{color:#8b97ad;font-size:12px;margin-top:14px}}
|
||||
</style></head><body>
|
||||
<div class="box">
|
||||
<h1>🔔 项目验收:{name}</h1>
|
||||
<div class="sub">AI 团队已全部完工,等待您(项目负责人)验收</div>
|
||||
<div class="kv">
|
||||
<div><b>项目状态:</b>待验收</div>
|
||||
<div><b>目标:</b>{objective}</div>
|
||||
<div><b>任务完成:</b>{done}/{total}(失败 {failed})</div>
|
||||
<div><b>累计成本:</b>¥{cost}</div>
|
||||
</div>
|
||||
{demo_html}
|
||||
<label style="font-size:12px;color:#8b97ad">验收意见(打回时必填)</label>
|
||||
<textarea id="reason" placeholder="填写验收意见…(通过可不填)"></textarea>
|
||||
<div class="btns">
|
||||
<button class="approve" onclick="submitReview('approve')">✅ 验收通过</button>
|
||||
<button class="reject" onclick="submitReview('reject')">⛔ 打回返工</button>
|
||||
</div>
|
||||
<div class="msg" id="msg"></div>
|
||||
<div class="hint">验收通过后项目正式完成并自动打包交付;打回后 AI 主管将按意见返工。</div>
|
||||
</div>
|
||||
<script>
|
||||
async function submitReview(action) {{
|
||||
const reason = document.getElementById('reason').value.trim();
|
||||
if (action === 'reject' && !reason) {{ alert('打回必须填写原因'); return; }}
|
||||
const m = document.getElementById('msg');
|
||||
m.className = 'msg'; m.textContent = '提交中…';
|
||||
try {{
|
||||
const r = await fetch('/api/review/{token}', {{method:'POST', headers:{{'Content-Type':'application/json'}},
|
||||
body: JSON.stringify({{action, reason, reviewer: '负责人'}})}});
|
||||
const d = await r.json();
|
||||
if (d.ok) {{ m.className = 'msg'; m.textContent = action === 'approve' ? '✅ 验收通过!项目已完成并开始交付。' : '⛔ 已打回,AI 主管将按意见返工。'; }}
|
||||
else {{ m.className = 'msg err'; m.textContent = d.error || '提交失败'; }}
|
||||
}} catch (e) {{ m.className = 'msg err'; m.textContent = '网络错误:' + e.message; }}
|
||||
}}
|
||||
</script></body></html>'''
|
||||
|
||||
|
||||
@app.route('/review/<token>')
|
||||
def review_page(token):
|
||||
"""V3.4 负责人公开验收页(免登录)"""
|
||||
p = db.q('SELECT * FROM projects WHERE review_token=?', (token,), one=True)
|
||||
if not p or p['status'] != 'review':
|
||||
return '<h2 style="font-family:system-ui;color:#8b97ad;padding:40px;text-align:center">验收链接无效或已处理</h2>', 404
|
||||
s = delivery.project_summary(p)
|
||||
demo_html = f'<a class="demo" href="{p.get("demo_url") or delivery.demo_url_of(p)}" target="_blank">👀 查看交付 Demo</a>' \
|
||||
if p.get('deliver_type') == 'web' else ''
|
||||
return REVIEW_PAGE.format(
|
||||
name=esc_html(p['name']), objective=esc_html(p.get('objective') or '—'),
|
||||
done=s['done'], total=s['total'], failed=s['failed'], cost=f'{s["cost"]:.4f}',
|
||||
demo_html=demo_html, token=token)
|
||||
|
||||
|
||||
def esc_html(s):
|
||||
return str(s).replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||
|
||||
|
||||
@app.route('/api/review/<token>', methods=['POST'])
|
||||
def review_submit(token):
|
||||
"""V3.4 负责人公开提交验收结果"""
|
||||
p = db.q('SELECT * FROM projects WHERE review_token=?', (token,), one=True)
|
||||
if not p or p['status'] != 'review':
|
||||
return jsonify({'ok': False, 'error': '验收链接无效或已处理'}), 404
|
||||
d = request.get_json(silent=True) or {}
|
||||
action = d.get('action')
|
||||
reason = (d.get('reason') or '').strip()
|
||||
reviewer = (d.get('reviewer') or '负责人').strip()[:50]
|
||||
if action == 'approve':
|
||||
r = delivery.approve_project(p['id'], request.host_url.rstrip('/'), reviewer=reviewer)
|
||||
enterprise.audit('review:' + reviewer, 'project.approve', f'project#{p["id"]}',
|
||||
f'公开链接验收通过:{p["name"]}', request.remote_addr or '')
|
||||
return jsonify({'ok': r['ok'], 'error': None if r['ok'] else r['msg']})
|
||||
if action == 'reject':
|
||||
if not reason:
|
||||
return jsonify({'ok': False, 'error': '打回必须填写原因'}), 400
|
||||
r = delivery.reject_project(p['id'], reason)
|
||||
enterprise.audit('review:' + reviewer, 'project.reject', f'project#{p["id"]}',
|
||||
f'公开链接打回:{reason[:100]}', request.remote_addr or '')
|
||||
# 打回后自动让 AI 主管按意见返工
|
||||
try:
|
||||
autostart.launch(p['id'])
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'ok': True})
|
||||
return jsonify({'ok': False, 'error': '未知操作'}), 400
|
||||
|
||||
|
||||
@app.route('/api/projects/<int:pid>/review', methods=['POST'])
|
||||
@require_auth
|
||||
def project_review(pid):
|
||||
"""V3.4 平台内验收:项目管理员/负责人操作"""
|
||||
err = _check_project_perm(pid, 'manage')
|
||||
if err:
|
||||
return err
|
||||
p = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||||
if not p:
|
||||
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
||||
if p['status'] != 'review':
|
||||
return jsonify({'ok': False, 'error': f'项目当前状态「{p["status"]}」不可验收'}), 400
|
||||
d = request.get_json(silent=True) or {}
|
||||
action = d.get('action')
|
||||
reason = (d.get('reason') or '').strip()
|
||||
u = _session_user()
|
||||
reviewer = (u.get('display_name') or u.get('username')) if u else '管理员'
|
||||
if action == 'approve':
|
||||
r = delivery.approve_project(pid, request.host_url.rstrip('/'), reviewer=reviewer)
|
||||
enterprise.audit(enterprise.current_actor(), 'project.approve', f'project#{pid}',
|
||||
f'平台内验收通过:{p["name"]}', request.remote_addr or '')
|
||||
return jsonify({'ok': r['ok'], 'error': None if r['ok'] else r['msg']})
|
||||
if action == 'reject':
|
||||
if not reason:
|
||||
return jsonify({'ok': False, 'error': '打回必须填写原因'}), 400
|
||||
r = delivery.reject_project(pid, reason)
|
||||
enterprise.audit(enterprise.current_actor(), 'project.reject', f'project#{pid}',
|
||||
f'平台内打回:{reason[:100]}', request.remote_addr or '')
|
||||
# 打回后自动让 AI 主管按意见返工
|
||||
try:
|
||||
autostart.launch(pid)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'ok': True})
|
||||
return jsonify({'ok': False, 'error': '未知操作'}), 400
|
||||
|
||||
|
||||
@app.route('/api/projects/<int:pid>/dag')
|
||||
@require_auth
|
||||
def project_dag(pid):
|
||||
|
||||
+51
-21
@@ -150,6 +150,7 @@ def set_team(pid, worker_ids):
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 规划:AI 主管拆解 WBS → 建任务(轮询分派团队 Worker)
|
||||
# V3.4:解析失败自动重试 3 次(每次重新调用 LLM),仍失败抛出异常由上层通知负责人
|
||||
# ---------------------------------------------------------------------------
|
||||
def plan_tasks(pid, manager, team, review_required=0):
|
||||
"""AI 主管规划并创建任务。返回 (created_ids, plan_message)"""
|
||||
@@ -157,14 +158,23 @@ def plan_tasks(pid, manager, team, review_required=0):
|
||||
goal = (proj.get('objective') or proj.get('name') or '').strip()
|
||||
if not goal:
|
||||
raise ValueError('项目缺少目标(Objective),AI 主管无法规划')
|
||||
r = llm_gateway.chat(
|
||||
manager['provider'], manager['model'],
|
||||
[{'role': 'system', 'content': '你只输出 JSON,不输出任何解释文字。'},
|
||||
{'role': 'user', 'content': WBS_PROMPT.format(
|
||||
goal=goal, accept=proj.get('acceptance_criteria') or '—')}],
|
||||
temperature=0.3, max_tokens=3000,
|
||||
base_url=manager['base_url'] or None, api_key=manager['api_key'] or None)
|
||||
tasks = parse_wbs(r['text'])
|
||||
last_err = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
r = llm_gateway.chat(
|
||||
manager['provider'], manager['model'],
|
||||
[{'role': 'system', 'content': '你只输出 JSON,不输出任何解释文字。'},
|
||||
{'role': 'user', 'content': WBS_PROMPT.format(
|
||||
goal=goal, accept=proj.get('acceptance_criteria') or '—')}],
|
||||
temperature=0.3, max_tokens=3000 * attempt,
|
||||
base_url=manager['base_url'] or None, api_key=manager['api_key'] or None)
|
||||
tasks = parse_wbs(r['text'])
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
_log(pid, 'warn', f'任务拆解第 {attempt}/3 次失败({str(e)[:100]}),重新规划中…')
|
||||
else:
|
||||
raise ValueError(f'AI 主管连续 3 次拆解任务失败({str(last_err)[:120]}),已中断并通知负责人')
|
||||
created = []
|
||||
for i, t in enumerate(tasks):
|
||||
dep_idx = t.get('depends_on') or []
|
||||
@@ -270,14 +280,7 @@ def monitor(pid, manager, team, timeout=1800):
|
||||
|
||||
def _finalize(pid, status, message):
|
||||
_set_auto(pid, status, message, finished=True)
|
||||
if status == 'done':
|
||||
try:
|
||||
delivery.auto_complete_if_ready(pid)
|
||||
except Exception:
|
||||
pass
|
||||
_log(pid, 'success', message)
|
||||
else:
|
||||
_log(pid, 'warn', message)
|
||||
_log(pid, 'success' if status == 'done' else 'warn', message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -327,7 +330,7 @@ def autostart_project(pid, review_required=0):
|
||||
if result == 'timeout':
|
||||
raise ValueError('监控超时,仍有任务未完成(可在任务就绪后点「继续开工」)')
|
||||
|
||||
# 4) 收尾
|
||||
# 4) 收尾:全部任务完成 → 提交负责人验收(AI 无权宣布项目完成)
|
||||
by = {r['status']: r['c'] for r in db.q(
|
||||
'SELECT status, COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0 GROUP BY status', (pid,))}
|
||||
done = by.get('done', 0)
|
||||
@@ -335,16 +338,43 @@ def autostart_project(pid, review_required=0):
|
||||
review = by.get('review', 0)
|
||||
total = sum(by.values())
|
||||
costs = db.q('SELECT COALESCE(SUM(cost),0) c FROM cost_records WHERE project_id=?', (pid,))[0]['c']
|
||||
msg = f'AI 主管收工:{done}/{total} 个任务完成' + (f',{failed} 个失败' if failed else '') + \
|
||||
(f',{review} 个待人工审核' if review else '') + f',总成本 ¥{costs:.4f}'
|
||||
_finalize(pid, 'done' if failed == 0 else 'failed', msg)
|
||||
if failed == 0 and review == 0 and total > 0:
|
||||
# 转待验收:delivery.auto_complete_if_ready → enter_review(发邮件给负责人)
|
||||
try:
|
||||
r = delivery.auto_complete_if_ready(pid)
|
||||
if r and r.get('ok'):
|
||||
msg = f'AI 主管收工:{done}/{total} 个任务完成,已提交负责人验收,总成本 ¥{costs:.4f}'
|
||||
else:
|
||||
msg = f'AI 主管收工:{done}/{total} 个任务完成,但验收通知异常:{((r or {}).get("msg") or "")[:80]}'
|
||||
except Exception as e:
|
||||
msg = f'AI 主管收工:{done}/{total} 个任务完成,验收提交异常:{str(e)[:80]}'
|
||||
_finalize(pid, 'done', msg)
|
||||
else:
|
||||
msg = f'AI 主管收工:{done}/{total} 个任务完成' + (f',{failed} 个失败' if failed else '') + \
|
||||
(f',{review} 个待人工审核' if review else '') + f',总成本 ¥{costs:.4f}'
|
||||
_finalize(pid, 'done' if failed == 0 else 'failed', msg)
|
||||
try:
|
||||
notify.notify('task_done', f'项目自动开工完成:{proj["name"]}', msg, save_alert=False)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
_finalize(pid, 'failed', f'自动开工中断:{str(e)[:200]}')
|
||||
msg = f'自动开工中断:{str(e)[:200]}'
|
||||
_finalize(pid, 'failed', msg)
|
||||
_log(pid, 'error', traceback.format_exc())
|
||||
# V3.4:连续失败/中断 → 邮件通知项目负责人(送达者)
|
||||
try:
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||||
if proj:
|
||||
email = delivery.deliver_email_of(proj)
|
||||
if email:
|
||||
delivery.notify_deliverer(
|
||||
proj, f'⚠️ 项目自动开工中断:{proj["name"]}',
|
||||
f'AI 主管自动开工中断:{msg}\n\n'
|
||||
f'请到平台查看项目详情(任务/日志),或稍后在项目页点击「🚀 AI 主管开工」重试。')
|
||||
else:
|
||||
_log(pid, 'warn', '项目未配置送达者邮箱,中断通知无法发送')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_threads = {}
|
||||
|
||||
@@ -28,6 +28,8 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
auto_message TEXT DEFAULT '', -- V3.3 自动开工最新动态
|
||||
auto_started_at INTEGER, -- V3.3 最近一次自动开工时间
|
||||
auto_finished_at INTEGER, -- V3.3 最近一次自动收尾时间
|
||||
review_token TEXT DEFAULT '', -- V3.4 项目验收令牌(发给负责人,公开链接免登录验收)
|
||||
review_required INTEGER DEFAULT 1, -- V3.4 是否需负责人验收后才算完成(默认需要)
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
@@ -428,6 +430,14 @@ def _migrate():
|
||||
):
|
||||
if col not in pcols:
|
||||
conn.execute(ddl)
|
||||
# V3.4:负责人验收
|
||||
pcols = {r['name'] for r in conn.execute('PRAGMA table_info(projects)')}
|
||||
for col, ddl in (
|
||||
('review_token', "ALTER TABLE projects ADD COLUMN review_token TEXT DEFAULT ''"),
|
||||
('review_required', 'ALTER TABLE projects ADD COLUMN review_required INTEGER DEFAULT 1'),
|
||||
):
|
||||
if col not in pcols:
|
||||
conn.execute(ddl)
|
||||
conn.execute('''CREATE TABLE IF NOT EXISTS project_team_workers (
|
||||
project_id INTEGER NOT NULL,
|
||||
worker_id INTEGER NOT NULL,
|
||||
|
||||
+164
-2
@@ -207,13 +207,19 @@ def auto_complete_if_ready(project_id, base_url=''):
|
||||
返回 {'ok','msg'} 或 None(未满足条件/异常)。引擎线程与审核接口共用。"""
|
||||
try:
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
|
||||
if not proj or proj['status'] == 'done':
|
||||
# V3.4:done=已验收完成;review=已提交待验收(避免 engine 与 autostart 双触发重复通知)
|
||||
if not proj:
|
||||
return None
|
||||
if proj['status'] in ('done', 'review'):
|
||||
return {'ok': True, 'msg': '项目已处于待验收/完成状态'}
|
||||
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
|
||||
# V3.4:AI 不能替负责人拍板 → 全部任务完成后进入「待负责人验收」
|
||||
if proj.get('review_required', 1):
|
||||
return enter_review(proj, base_url)
|
||||
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}
|
||||
@@ -221,6 +227,157 @@ def auto_complete_if_ready(project_id, base_url=''):
|
||||
return None
|
||||
|
||||
|
||||
def enter_review(project, base_url=''):
|
||||
"""V3.4 全部任务完成 → 项目置「待验收」:生成验收令牌 + 邮件通知负责人。
|
||||
返回 {'ok','msg'}。"""
|
||||
import secrets
|
||||
proj = project if isinstance(project, dict) else db.q('SELECT * FROM projects WHERE id=?', (project,), one=True)
|
||||
if not proj:
|
||||
return {'ok': False, 'msg': '项目不存在'}
|
||||
token = (proj.get('review_token') or '').strip() or secrets.token_urlsafe(16)
|
||||
db.w('UPDATE projects SET status="review", review_token=?, updated_at=? WHERE id=?',
|
||||
(token, db.now(), proj['id']))
|
||||
base = (base_url or PUBLIC_BASE_URL).rstrip('/')
|
||||
link = f'{base}/review/{token}'
|
||||
s = project_summary(proj)
|
||||
demo = proj.get('demo_url') or (demo_url_of(proj, base) if proj.get('deliver_type') == 'web' else '')
|
||||
extra = (
|
||||
f'🤖 AI 团队已全部完工({s["done"]}/{s["total"]} 个任务,失败 {s["failed"]},成本 ¥{s["cost"]:.4f})\n'
|
||||
f'现在是您(项目负责人)验收的时候了!请打开下面的链接审核交付成果:\n'
|
||||
f'👉 验收链接:{link}\n'
|
||||
+ (f'👀 在线 Demo:{demo}\n' if demo else '')
|
||||
+ '验收通过后项目才算正式完成,并自动打包交付。'
|
||||
)
|
||||
email = deliver_email_of(proj)
|
||||
ok, msg = True, '已通知负责人验收'
|
||||
if email:
|
||||
ok, msg = send_mail(email, f'🔔 待您验收:{proj["name"]}(AI 团队已完工)',
|
||||
_email_body(proj, extra=extra))
|
||||
if not ok:
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('notify','warn',?,?,0,?)",
|
||||
(f'验收通知邮件失败:{proj["name"]}', msg[:300], db.now()))
|
||||
else:
|
||||
ok, msg = False, '项目未配置送达者邮箱,无法通知验收'
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('notify','warn',?,?,0,?)",
|
||||
(f'验收通知失败:{proj["name"]} 未配置送达者', '', db.now()))
|
||||
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
|
||||
(proj['id'], 'info', f'项目进入待验收状态,已通知负责人{("("+email+")") if email else ""}:{link}', db.now()))
|
||||
return {'ok': ok, 'msg': msg}
|
||||
|
||||
|
||||
def approve_project(project_id, base_url='', reviewer='负责人'):
|
||||
"""V3.4 负责人验收通过 → 部署 Demo/打包 + 完成交付邮件 + 项目置 done"""
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
|
||||
if not proj:
|
||||
return {'ok': False, 'msg': '项目不存在'}
|
||||
if proj['status'] != 'review':
|
||||
return {'ok': False, 'msg': f'项目当前状态「{proj["status"]}」不可验收'}
|
||||
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
|
||||
ok, msg = notify_project_complete(proj, base_url)
|
||||
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
|
||||
(project_id, 'success', f'负责人({reviewer})验收通过 ✅,项目正式完成并交付', db.now()))
|
||||
return {'ok': ok, 'msg': msg}
|
||||
|
||||
|
||||
def reject_project(project_id, reason=''):
|
||||
"""V3.4 负责人打回 → 项目回到进行中,记录原因,自动生成返工任务交给 AI 主管重做"""
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
|
||||
if not proj:
|
||||
return {'ok': False, 'msg': '项目不存在'}
|
||||
db.w('UPDATE projects SET status="active", review_token=\'\', updated_at=? WHERE id=?',
|
||||
(db.now(), project_id))
|
||||
msg = f'负责人打回:{reason or "未填写原因"}'
|
||||
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
|
||||
(project_id, 'warn', msg, db.now()))
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('reject','warn',?,?,0,?)",
|
||||
(f'项目被打回:{proj["name"]}', msg[:500], db.now()))
|
||||
# 自动生成返工任务(AI 主管按负责人意见重做),上层负责触发 autostart.launch
|
||||
team = db.q('SELECT w.* FROM project_team_workers t JOIN workers w ON w.id=t.worker_id '
|
||||
'WHERE t.project_id=? ORDER BY t.rowid', (project_id,))
|
||||
wid = team[0]['id'] if team else None
|
||||
desc = (f'负责人验收后提出修改意见,请按以下意见返工整个交付物:\n'
|
||||
f'【负责人意见】{reason or "未填写"}\n\n'
|
||||
f'项目目标:{proj.get("objective") or proj.get("name") or ""}\n'
|
||||
f'验收标准:{proj.get("acceptance_criteria") or "—"}\n'
|
||||
f'请直接输出修改后的完整成果(网页项目请输出完整 HTML 文档)。')
|
||||
tid = db.w('INSERT INTO tasks (project_id, worker_id, title, description, priority, '
|
||||
'review_required, deadline, depends_on, created_at, updated_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||
(project_id, wid, '返工:按负责人意见修改', desc, 'high', 0, '', '[]',
|
||||
db.now(), db.now()))
|
||||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||||
(tid, 'warn', f'负责人打回,AI 主管返工任务已创建:{reason[:120]}', db.now()))
|
||||
return {'ok': True, 'msg': msg, 'rework_task_id': tid}
|
||||
|
||||
|
||||
def _strip_fence(text):
|
||||
"""去掉 LLM 输出外层 markdown 代码围栏(```html ... ```),还原纯 HTML"""
|
||||
t = text.strip()
|
||||
if not t.startswith('```'):
|
||||
return text
|
||||
lines = t.split('\n')
|
||||
if lines and lines[0].startswith('```'):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == '```':
|
||||
lines = lines[:-1]
|
||||
return '\n'.join(lines).strip()
|
||||
|
||||
|
||||
def _extract_html(text):
|
||||
"""从 LLM 输出中提取完整 HTML 文档:全文本定位 <!doctype html/<html 标签,
|
||||
截取到 </html>;自动剥离 markdown 代码围栏与前置叙述。返回纯 HTML 或 None。"""
|
||||
if not text or not isinstance(text, str):
|
||||
return None
|
||||
import re as _re
|
||||
m = _re.search(r'<!doctype\s+html', text, _re.I)
|
||||
if not m:
|
||||
m = _re.search(r'<html[\s>]', text, _re.I)
|
||||
if not m:
|
||||
return None
|
||||
html = text[m.start():]
|
||||
end = html.lower().rfind('</html>')
|
||||
if end > 0:
|
||||
html = html[:end + 7]
|
||||
# 剥离可能残留的围栏(如 ```html 前缀 / 结尾 ```)
|
||||
html = _strip_fence(html)
|
||||
return html.strip()
|
||||
|
||||
|
||||
def save_task_output(task, text):
|
||||
"""V3.4 任务产出若为完整网页文档 → 落盘到项目工作目录,供 Demo 真实展示。
|
||||
返回保存的文件名列表。"""
|
||||
content = _extract_html(text)
|
||||
if content is None:
|
||||
return []
|
||||
saved = []
|
||||
root = workspace_path(task['project_id'])
|
||||
title = (task.get('title') or '').strip()
|
||||
is_rework = '返工' in title
|
||||
index = os.path.join(root, 'index.html')
|
||||
index_bad = False
|
||||
if os.path.isfile(index):
|
||||
try:
|
||||
with open(index, 'r', encoding='utf-8') as fh:
|
||||
index_bad = fh.read().lstrip().startswith('```')
|
||||
except Exception:
|
||||
index_bad = True
|
||||
if not os.path.isfile(index) or index_bad or is_rework:
|
||||
fn = 'index.html'
|
||||
else:
|
||||
safe = re.sub(r'[\\/:*?"<>|\s]+', '_', title or f'task{task["id"]}')[:40]
|
||||
fn = f'{safe}-{task["id"]}.html'
|
||||
try:
|
||||
with open(os.path.join(root, fn), 'w', encoding='utf-8') as fh:
|
||||
fh.write(content)
|
||||
saved.append(fn)
|
||||
except Exception:
|
||||
pass
|
||||
return saved
|
||||
|
||||
|
||||
def project_summary(project):
|
||||
"""项目交付摘要:任务统计 + 成本"""
|
||||
rows = db.q('SELECT status, COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0 GROUP BY status',
|
||||
@@ -238,15 +395,20 @@ def project_summary(project):
|
||||
# 邮件发送(支持附件)
|
||||
# ---------------------------------------------------------------------------
|
||||
def send_mail(to_addr, subject, text, attachments=None, html=None):
|
||||
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)"""
|
||||
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)
|
||||
注意:邮件头必须带 Date/Message-ID,否则 mail.tphai.com 的 amavisd 内容过滤器
|
||||
会以 "invalid header section / Missing required header field: Date" 退信。"""
|
||||
if not EMAIL.get('host'):
|
||||
return False, '邮件服务未配置(config.EMAIL.host 为空)'
|
||||
if not to_addr:
|
||||
return False, '收件邮箱为空'
|
||||
from email.utils import formatdate, make_msgid
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
|
||||
msg['To'] = to_addr
|
||||
msg['Subject'] = subject
|
||||
msg['Date'] = formatdate(localtime=True)
|
||||
msg['Message-ID'] = make_msgid(domain='tphai.com')
|
||||
if html:
|
||||
msg.attach(MIMEText(html, 'html', 'utf-8'))
|
||||
else:
|
||||
|
||||
@@ -253,6 +253,14 @@ def run_task(task_id):
|
||||
f'执行完成:{usage["total_tokens"]} tokens(输入 {usage["prompt_tokens"]} / 输出 {usage["completion_tokens"]}),'
|
||||
f'成本 ¥{usage["cost"]:.6f}')
|
||||
|
||||
# V3.4:产出若为完整网页文档 → 落盘工作目录,供 Demo 真实展示
|
||||
try:
|
||||
saved = delivery.save_task_output(task, usage['text'])
|
||||
if saved:
|
||||
_log(task_id, 'info', f'🌐 网页产出已保存到工作目录:' + '、'.join(saved))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_status = 'review' if task['review_required'] else 'done'
|
||||
fields = {
|
||||
'status': new_status,
|
||||
|
||||
+1
-1
@@ -304,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.3.0'}
|
||||
'platform': 'ai-worker-platform', 'version': 'v3.4.0'}
|
||||
for t in tables:
|
||||
try:
|
||||
out[t] = db.q(f'SELECT * FROM {t}')
|
||||
|
||||
@@ -55,15 +55,21 @@ def _send_email(ch, title, text):
|
||||
from config import EMAIL # 邮件配置在 config.py
|
||||
if not EMAIL.get('host'):
|
||||
return None
|
||||
msg = f'From: {EMAIL["from_name"]} <{EMAIL["user"]}>\nTo: {ch["email"]}\n' \
|
||||
f'Subject: 【AI Worker 平台】{title}\nContent-Type: text/plain; charset=utf-8\n\n{text}'
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
msg = MIMEText(text, 'plain', 'utf-8')
|
||||
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
|
||||
msg['To'] = ch['email']
|
||||
msg['Subject'] = f'【AI Worker 平台】{title}'
|
||||
msg['Date'] = formatdate(localtime=True)
|
||||
msg['Message-ID'] = make_msgid(domain='tphai.com')
|
||||
try:
|
||||
s = smtplib.SMTP(EMAIL['host'], EMAIL['port'])
|
||||
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'], [ch['email']], msg.encode('utf-8'))
|
||||
s.sendmail(EMAIL['user'], [ch['email']], msg.as_string())
|
||||
s.quit()
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
+71
-11
@@ -163,7 +163,7 @@ async function pageProjects() {
|
||||
${list.map(p => `
|
||||
<div class="card proj-card" onclick="location.hash='#/project/${p.id}'">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>${esc(p.name)}</b><span class="badge st-${p.status === 'active' ? 'running' : p.status === 'done' ? 'done' : p.status === 'archived' ? 'cancelled' : 'todo'}">${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}</span>
|
||||
<b>${esc(p.name)}</b><span class="badge st-${p.status === 'active' ? 'running' : p.status === 'review' ? 'review' : p.status === 'done' ? 'done' : p.status === 'archived' ? 'cancelled' : 'todo'}">${{active:'进行中', review:'待验收', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}</span>
|
||||
</div>
|
||||
<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}
|
||||
@@ -187,7 +187,7 @@ function openProjectModal(p = {}) {
|
||||
<label>项目描述</label><textarea id="pm-desc">${esc(p.description || '')}</textarea>
|
||||
<div class="row">
|
||||
<div><label>状态</label><select id="pm-status">
|
||||
${['planning','active','done','archived'].map(s => `<option value="${s}" ${p.status === s ? 'selected' : ''}>${{planning:'规划中', active:'进行中', done:'已完成', archived:'已归档'}[s]}</option>`).join('')}
|
||||
${['planning','active','review','done','archived'].map(s => `<option value="${s}" ${p.status === s ? 'selected' : ''}>${{planning:'规划中', active:'进行中', review:'待验收', done:'已完成', archived:'已归档'}[s]}</option>`).join('')}
|
||||
</select></div>
|
||||
<div><label>预算上限(元,0=不限)</label><input id="pm-budget" type="number" value="${p.budget_limit || 0}"></div>
|
||||
</div>
|
||||
@@ -277,8 +277,8 @@ async function pageProject([pid, tab]) {
|
||||
const p = (await api(`/api/projects/${pid}`)).data;
|
||||
const tasks = (await api(`/api/tasks?project_id=${pid}`)).data;
|
||||
const workers = (await api('/api/workers')).data;
|
||||
const autoLogs = (await api(`/api/projects/${pid}/autologs`)).data;
|
||||
projCtx = {pid, p, tasks, workers, tab: tab || 'kanban', autoLogs};
|
||||
const events = (await api(`/api/projects/${pid}/events`)).data;
|
||||
projCtx = {pid, p, tasks, workers, tab: tab || 'kanban', events};
|
||||
renderProjectShell();
|
||||
renderActiveTab();
|
||||
startProjAutoPoll();
|
||||
@@ -291,7 +291,11 @@ function renderActiveTab() {
|
||||
else renderKanban();
|
||||
}
|
||||
|
||||
/* V3.3:AI 主管自动开工 —— 轮询刷新状态/动态/看板 */
|
||||
/* V3.3:AI 主管自动开工 —— 轮询刷新状态/事件/看板 */
|
||||
async function refreshProjEvents() {
|
||||
try { projCtx.events = (await api(`/api/projects/${projCtx.pid}/events`)).data; } catch (e) {}
|
||||
}
|
||||
|
||||
function startProjAutoPoll() {
|
||||
clearInterval(projAutoTimer);
|
||||
renderAutoPanel();
|
||||
@@ -301,7 +305,7 @@ function startProjAutoPoll() {
|
||||
const wasRunning = projCtx.p.auto_status === 'running';
|
||||
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
|
||||
projCtx.p = p;
|
||||
projCtx.autoLogs = (await api(`/api/projects/${projCtx.pid}/autologs`)).data;
|
||||
await refreshProjEvents();
|
||||
const running = p.auto_status === 'running';
|
||||
// 状态变化(开工结束/中断)或运行中:都刷新一次任务视图
|
||||
if (running || wasRunning) {
|
||||
@@ -320,12 +324,43 @@ async function autoStartProj() {
|
||||
toast('🚀 AI 主管已开工,正在拆解派活…', 'ok');
|
||||
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
|
||||
projCtx.p = p;
|
||||
projCtx.autoLogs = (await api(`/api/projects/${projCtx.pid}/autologs`)).data;
|
||||
await refreshProjEvents();
|
||||
renderAutoPanel();
|
||||
startProjAutoPoll();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
/* V3.4:负责人验收(平台内) */
|
||||
function openReviewModal() {
|
||||
const {p} = projCtx;
|
||||
openModal(`
|
||||
<h3>🔔 项目验收:${esc(p.name)}<button class="close" data-close>×</button></h3>
|
||||
<p style="color:var(--muted);font-size:13px">AI 团队已全部完工,由您(负责人)最终验收。<br>验收通过后项目才算正式完成,将自动打包交付。</p>
|
||||
${p.demo_url ? `<p><a href="${esc(p.demo_url)}" target="_blank" style="color:var(--accent)">👀 查看交付 Demo</a></p>` : ''}
|
||||
<label>验收意见(打回必填)</label><textarea id="rv-reason" placeholder="填写验收意见…"></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn danger" onclick="submitProjectReview('reject')">⛔ 打回返工</button>
|
||||
<button class="btn primary" onclick="submitProjectReview('approve')">✅ 验收通过</button>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
async function submitProjectReview(action) {
|
||||
const reason = $('#rv-reason').value.trim();
|
||||
if (action === 'reject' && !reason) return toast('打回必须填写原因', 'err');
|
||||
try {
|
||||
await api(`/api/projects/${projCtx.pid}/review`, {method: 'POST', body: {action, reason}});
|
||||
closeModal();
|
||||
toast(action === 'approve' ? '✅ 验收通过,项目完成并交付' : '⛔ 已打回,可重新开工返工', 'ok');
|
||||
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
|
||||
projCtx.p = p;
|
||||
await refreshProjEvents();
|
||||
renderProjectShell();
|
||||
renderActiveTab();
|
||||
renderAutoPanel();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
function renderAutoPanel() {
|
||||
const el = $('#auto-panel');
|
||||
if (!el || !projCtx) return;
|
||||
@@ -334,7 +369,15 @@ function renderAutoPanel() {
|
||||
const st = {none:['未开工','todo'], running:['🚀 AI 主管工作中','running'], done:['✅ 自动开工完成','done'], failed:['⚠️ 开工中断','failed']}[p.auto_status] || ['—','todo'];
|
||||
const mgr = p.manager_worker ? esc(p.manager_worker.name) : '<span style="color:var(--warn)">未设置</span>';
|
||||
const team = (p.team_workers || []).map(w => `<span class="pill" style="color:var(--accent)">${esc(w.name)}</span>`).join(' ') || '<span style="color:var(--warn)">未设置</span>';
|
||||
const logs = (projCtx.autoLogs || []).map(l => `<div class="al-item"><span class="al-lv">${l.level === 'error' ? '❌' : l.level === 'warn' ? '⚠️' : l.level === 'success' ? '✅' : '🤖'}</span><span class="al-msg">${esc(l.message)}</span><span class="al-time">${fmtTime(l.created_at)}</span></div>`).join('');
|
||||
const evs = projCtx.events || [];
|
||||
const collapsed = localStorage.getItem('aw_ev_collapsed_' + projCtx.pid) === '1';
|
||||
const icon = l => ({error:'❌', warn:'⚠️', success:'✅', info:'🤖'}[l] || '•');
|
||||
const items = evs.map(e => `
|
||||
<div class="al-item ${e.task_id ? 'al-task' : ''}" ${e.task_id ? `onclick="openTaskDetail(${e.task_id})" title="点击定位任务 #${e.task_id}"` : ''}>
|
||||
<span class="al-lv">${icon(e.level)}</span>
|
||||
<span class="al-msg">${esc(e.message)}${e.task_id ? `<span class="al-go">任务 #${e.task_id} ›</span>` : ''}</span>
|
||||
<span class="al-time">${fmtTime(e.created_at)}</span>
|
||||
</div>`).join('') || '<div class="empty" style="padding:12px">暂无事件</div>';
|
||||
el.innerHTML = `
|
||||
<div class="auto-bar">
|
||||
<span class="badge st-${st[1]}">${st[0]}</span>
|
||||
@@ -342,12 +385,29 @@ function renderAutoPanel() {
|
||||
${running ? '<span class="spin"></span>' : ''}
|
||||
</div>
|
||||
<div class="auto-meta">👔 AI 主管:${mgr} | 👷 干活团队:${team} | ⏱ ${p.auto_started_at ? '开工于 ' + fmtTime(p.auto_started_at) : '—'}</div>
|
||||
${logs ? `<div class="auto-logs">${logs}</div>` : ''}`;
|
||||
<div class="ev-panel ${collapsed ? 'collapsed' : ''}">
|
||||
<div class="ev-head" onclick="toggleEvents()">
|
||||
<span class="ev-arrow">${collapsed ? '▸' : '▾'}</span>
|
||||
<span>📋 事件记录</span>
|
||||
<span class="ev-count">${evs.length} 条</span>
|
||||
<span class="ev-hint">${collapsed ? '点击展开' : '点击收起'}</span>
|
||||
</div>
|
||||
<div class="ev-body">${items}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleEvents() {
|
||||
const panel = $('#auto-panel .ev-panel');
|
||||
if (!panel) return;
|
||||
const collapsed = panel.classList.contains('collapsed');
|
||||
localStorage.setItem('aw_ev_collapsed_' + projCtx.pid, collapsed ? '0' : '1');
|
||||
renderAutoPanel();
|
||||
}
|
||||
|
||||
function renderProjectShell() {
|
||||
const {p, pid, tab} = projCtx;
|
||||
const running = p.auto_status === 'running';
|
||||
const inReview = p.status === 'review';
|
||||
$('#main').innerHTML = `
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="#/projects">← 返回</a>
|
||||
@@ -355,7 +415,7 @@ function renderProjectShell() {
|
||||
${p.perm === 'view' ? '' : `<button class="btn" onclick="openProjectModal(${JSON.stringify(p).replace(/"/g, '"')})">编辑项目</button>`}
|
||||
${p.perm === 'view' ? '' : `<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>`}
|
||||
${p.perm === 'view' ? '' : `<button class="btn" onclick="openTaskModal()">+ 新建任务</button>`}
|
||||
${p.perm === 'view' || running ? '' : `<button class="btn primary" onclick="autoStartProj()">🚀 AI 主管开工</button>`}
|
||||
${p.perm === 'view' || running ? '' : (inReview ? `<button class="btn primary" onclick="openReviewModal()">🔔 验收项目</button>` : `<button class="btn primary" onclick="autoStartProj()">🚀 AI 主管开工</button>`)}
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<a href="#/project/${pid}/kanban" class="${tab === 'kanban' ? 'active' : ''}">看板</a>
|
||||
@@ -2542,7 +2602,7 @@ async function entGrantUser(uid, uname) {
|
||||
<div class="sec-title" style="margin-top:14px">📁 项目授权(${projects.length} 个)</div>
|
||||
<table style="max-height:220px;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>
|
||||
<tr><td>#${p.id} ${esc(p.name)} <span class="tag">${{active:'进行中',review:'待验收',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>'}
|
||||
|
||||
+12
-1
@@ -208,9 +208,20 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
|
||||
.pick-item small{color:var(--muted)}
|
||||
.auto-bar{display:flex;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:10px 14px;margin-bottom:8px}
|
||||
.auto-meta{font-size:12px;color:var(--muted);padding:0 4px 8px}
|
||||
.auto-logs{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:8px 12px;margin-bottom:14px;max-height:180px;overflow-y:auto}
|
||||
.ev-panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;margin-bottom:14px;overflow:hidden}
|
||||
.ev-head{display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;font-size:13px;font-weight:600;user-select:none}
|
||||
.ev-head:hover{background:var(--panel2)}
|
||||
.ev-arrow{color:var(--accent);font-size:12px;transition:transform .15s}
|
||||
.ev-count{background:var(--panel2);border:1px solid var(--border);border-radius:20px;padding:0 8px;font-size:11px;color:var(--muted)}
|
||||
.ev-hint{margin-left:auto;color:var(--muted);font-size:11px;font-weight:400}
|
||||
.ev-body{max-height:320px;overflow-y:auto;padding:6px 12px 10px;border-top:1px solid var(--border)}
|
||||
.ev-panel.collapsed .ev-body{display:none}
|
||||
.ev-panel.collapsed .ev-arrow{transform:rotate(-90deg)}
|
||||
.al-item{display:flex;gap:8px;align-items:baseline;font-size:12px;padding:4px 0;border-bottom:1px dashed var(--border)}
|
||||
.al-item:last-child{border-bottom:none}
|
||||
.al-lv{flex:none}
|
||||
.al-msg{flex:1;word-break:break-all}
|
||||
.al-time{flex:none;color:var(--muted);font-size:11px}
|
||||
.al-task{cursor:pointer}
|
||||
.al-task:hover .al-msg{color:var(--accent)}
|
||||
.al-go{color:var(--accent);font-size:11px;margin-left:6px;border:1px solid var(--border);border-radius:4px;padding:0 5px}
|
||||
Reference in New Issue
Block a user