V3.2 送达者=用户 + 用户邮箱必填

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