154 lines
9.7 KiB
TypeScript
154 lines
9.7 KiB
TypeScript
'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>
|
||
)
|
||
}
|