oa-ai/src/app/admin/create-user/role-manager.tsx

154 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useCallback } from 'react'
interface SiteUser { username: string; display_name: string; role: string }
interface RoleData {
assetsUsers: SiteUser[]; issueUsers: SiteUser[]
assetsRoles: string[]; issueRoles: string[]
emails: Record<string, string>
}
interface Props { setResult: (ok: boolean, msg: string) => void }
const ss = {
bar: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 } as React.CSSProperties,
hint: { fontSize: 13, color: 'var(--text-muted)', margin: 0 } as React.CSSProperties,
saveBtn: (active: boolean) => ({ padding: '10px 24px', borderRadius: 8, border: 'none', cursor: active ? 'pointer' : 'not-allowed', background: active ? '#d97706' : 'var(--bg-hover)', color: active ? '#fff' : 'var(--text-muted)', fontSize: 13, fontWeight: 600, transition: 'all 0.2s' } as React.CSSProperties),
table: { width: '100%', borderCollapse: 'collapse' as any } as React.CSSProperties,
th: { textAlign: 'left' as any, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '10px 12px', borderBottom: '2px solid var(--border)', textTransform: 'uppercase' as any, letterSpacing: '0.05em' } as React.CSSProperties,
td: { padding: '12px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, color: 'var(--text)' } as React.CSSProperties,
roleSelect: (changed: boolean) => ({ height: 34, padding: '0 10px', borderRadius: 6, fontSize: 12, cursor: 'pointer', border: changed ? '1px solid #d97706' : '1px solid var(--border)', background: changed ? 'rgba(217,119,6,0.06)' : 'var(--bg-card)', color: 'var(--text)', outline: 'none', minWidth: 110 } as React.CSSProperties),
emailInput: { height: 30, padding: '0 8px', borderRadius: 5, border: '1px solid var(--border)', background: 'var(--bg-card)', color: 'var(--text)', fontSize: 12, width: 160, outline: 'none' } as React.CSSProperties,
miniBtn: (primary: boolean) => ({ padding: '4px 12px', borderRadius: 5, border: primary ? 'none' : '1px solid var(--border)', background: primary ? '#2563eb' : 'var(--bg-card)', color: primary ? '#fff' : 'var(--text-secondary)', fontSize: 11, cursor: 'pointer', fontWeight: 500 } as React.CSSProperties),
}
export default function RoleManager({ setResult }: Props) {
const [roleData, setRoleData] = useState<RoleData | null>(null)
const [pending, setPending] = useState<Record<string, { site: string; newRole: string }>>({})
const [saving, setSaving] = useState(false)
const [editingEmail, setEditingEmail] = useState<string | null>(null)
const [editEmailValue, setEditEmailValue] = useState('')
const [savingEmail, setSavingEmail] = useState(false)
const fetchRoleData = useCallback(async () => {
try {
const res = await fetch('/api/admin/user-roles'); const d = await res.json()
if (d.users) setRoleData({ assetsUsers: d.users.assets || [], issueUsers: d.users.issue || [], assetsRoles: d.assetsRoles || [], issueRoles: d.issueRoles || [], emails: d.emails || {} })
} catch {}
}, [])
useEffect(() => { fetchRoleData() }, [fetchRoleData])
async function handleEditEmail(target: string) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editEmailValue)) { setResult(false, '邮箱格式不合法'); return }
setSavingEmail(true)
try {
const res = await fetch('/api/admin/users', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: target, email: editEmailValue }) })
const d = await res.json()
if (res.ok) {
setRoleData(prev => prev ? { ...prev, emails: { ...prev.emails, [target]: d.email } } : null)
setEditingEmail(null); setResult(true, `${target} 邮箱已更新`)
} else { setResult(false, d.error || '修改失败') }
} catch { setResult(false, '网络错误') }
finally { setSavingEmail(false) }
}
function handleSelect(site: string, username: string, newRole: string, originalRole: string) {
if (newRole === originalRole) { const next = { ...pending }; delete next[`${site}:${username}`]; setPending(next) }
else { setPending({ ...pending, [`${site}:${username}`]: { site, newRole } }) }
}
function getCurrentRole(site: string, username: string, originalRole: string): string {
return pending[`${site}:${username}`]?.newRole || originalRole
}
async function handleSave() {
if (Object.keys(pending).length === 0) return
if (!confirm(`确定保存 ${Object.keys(pending).length} 项角色修改?`)) return
setSaving(true); let ok = 0; let fail = 0
for (const [key, { site, newRole }] of Object.entries(pending)) {
const username = key.split(':')[1]
try {
const res = await fetch('/api/admin/user-roles', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, site, role: newRole }) })
if (res.ok) ok++; else fail++
} catch { fail++ }
}
setResult(fail === 0, `已保存:${ok} 项成功${fail > 0 ? `${fail} 项失败` : ''}`)
setPending({}); if (fail === 0) fetchRoleData(); setSaving(false)
}
if (!roleData) return <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>...</p>
const userMap = new Map<string, { displayName: string; email: string; assetsRole: string; issueRole: string }>()
roleData.assetsUsers.forEach(u => userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: u.role, issueRole: '—' }))
roleData.issueUsers.forEach(u => { const e = userMap.get(u.username); if (e) e.issueRole = u.role; else userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: '—', issueRole: u.role }) })
const users = Array.from(userMap.entries())
const changed = Object.keys(pending).length
return (
<div>
<div style={ss.bar}>
<p style={ss.hint}>{changed > 0 && <span style={{ color: '#d97706', fontWeight: 700, marginLeft: 10 }}>{changed} </span>}</p>
<button onClick={handleSave} disabled={changed === 0 || saving} style={ss.saveBtn(changed > 0)}>
{saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`}
</button>
</div>
<div style={{ overflowX: 'auto' }}><table style={ss.table}>
<thead>
<tr>
<th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th>
</tr>
</thead>
<tbody>
{users.map(([uname, info]) => (
<tr key={uname} style={{ transition: 'background 0.15s' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
<td style={{ ...ss.td, fontWeight: 500 }}>{uname}</td>
<td style={{ ...ss.td, color: info.displayName === uname ? 'var(--text-muted)' : 'var(--text)' }}>{info.displayName}</td>
<td style={ss.td}>
{editingEmail === uname ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="email" value={editEmailValue} onChange={e => setEditEmailValue(e.target.value)} placeholder="user@example.com" style={ss.emailInput} />
<button onClick={() => handleEditEmail(uname)} disabled={savingEmail} style={ss.miniBtn(true)}>{savingEmail ? '..' : '保存'}</button>
<button onClick={() => setEditingEmail(null)} style={ss.miniBtn(false)}></button>
</span>
) : (
<span style={{ fontSize: 12, color: info.email ? 'var(--text-secondary)' : 'var(--text-muted)' }}>
{info.email || '未设置'}
{(uname !== 'admin' && uname !== 'localadmin') && (
<button onClick={() => { setEditingEmail(uname); setEditEmailValue(info.email || '') }} style={{ background: 'none', border: 'none', color: '#2563eb', fontSize: 11, cursor: 'pointer', padding: '0 0 0 6px' }}></button>
)}
</span>
)}
</td>
<td style={ss.td}><RoleCell site="assets" username={uname} originalRole={info.assetsRole} roles={roleData.assetsRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} /></td>
<td style={ss.td}><RoleCell site="issue" username={uname} originalRole={info.issueRole} roles={roleData.issueRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} /></td>
</tr>
))}
</tbody>
</table></div>
</div>
)
}
function RoleCell({ site, username, originalRole, roles, pending, onSelect, getCurrentRole }: {
site: string; username: string; originalRole: string; roles: string[]
pending: Record<string, { site: string; newRole: string }>
onSelect: (site: string, username: string, newRole: string, originalRole: string) => void
getCurrentRole: (site: string, username: string, originalRole: string) => string
}) {
const isReserved = username === 'admin' || username === 'localadmin'
const changed = !!pending[`${site}:${username}`]
const currentRole = getCurrentRole(site, username, originalRole)
if (isReserved) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{currentRole}</span>
if (originalRole === '—' && !changed) return <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>
return (
<select value={currentRole} onChange={e => onSelect(site, username, e.target.value, originalRole)} style={ss.roleSelect(changed)}>
{roles.map(r => <option key={r} value={r}>{r}</option>)}
</select>
)
}