fix(账号): 固化超级管理员身份
This commit is contained in:
@@ -3,8 +3,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'lucide-react';
|
import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'lucide-react';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { maskPhone, generatePassword } from '@/lib/members';
|
import { maskPhone, generatePassword } from '@/lib/members';
|
||||||
import type { Member, Department, PasswordRule } from '@/lib/members';
|
import type { Member, Department, PasswordRule } from '@/lib/members';
|
||||||
|
import { isSystemAdminMember } from '@/lib/member-system';
|
||||||
import { RouteGuard } from '@/components/auth/Guard';
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
|
|
||||||
export default function MembersPage() {
|
export default function MembersPage() {
|
||||||
@@ -17,6 +19,8 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
function MembersPageContent() {
|
function MembersPageContent() {
|
||||||
const { departments, members, roles, passwordRule, fetchMembers, updatePasswordRule, createDepartment, updateDepartment, deleteDepartment, createMember, updateMember, deleteMember } = useMemberStore();
|
const { departments, members, roles, passwordRule, fetchMembers, updatePasswordRule, createDepartment, updateDepartment, deleteDepartment, createMember, updateMember, deleteMember } = useMemberStore();
|
||||||
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||||
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
|
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
|
||||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||||
const [editingMember, setEditingMember] = useState<Member | null>(null);
|
const [editingMember, setEditingMember] = useState<Member | null>(null);
|
||||||
@@ -169,7 +173,12 @@ function MembersPageContent() {
|
|||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<button onClick={() => { setEditingMember(m); setShowMemberModal(true); }} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">编辑</button>
|
<button onClick={() => { setEditingMember(m); setShowMemberModal(true); }} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">编辑</button>
|
||||||
<button onClick={() => deleteMember(m.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
{!isSystemAdminMember(m) && (
|
||||||
|
<button onClick={() => deleteMember(m.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
||||||
|
)}
|
||||||
|
{isSystemAdminMember(m) && (
|
||||||
|
<span className="h-6 px-2 inline-flex items-center rounded text-[11px] font-medium text-amber-700 bg-amber-50">系统账号</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -191,8 +200,12 @@ function MembersPageContent() {
|
|||||||
passwordRule={passwordRule}
|
passwordRule={passwordRule}
|
||||||
onClose={() => setShowMemberModal(false)}
|
onClose={() => setShowMemberModal(false)}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
if (editingMember) updateMember(editingMember.id, data);
|
if (editingMember) {
|
||||||
else createMember(data as any);
|
updateMember(editingMember.id, data);
|
||||||
|
if (editingMember.id === currentUser?.id) void refreshUser();
|
||||||
|
} else {
|
||||||
|
createMember(data as any);
|
||||||
|
}
|
||||||
setShowMemberModal(false);
|
setShowMemberModal(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -239,6 +252,7 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
|
|||||||
const [phone, setPhone] = useState(initial?.phone ?? '');
|
const [phone, setPhone] = useState(initial?.phone ?? '');
|
||||||
const [email, setEmail] = useState(initial?.email ?? '');
|
const [email, setEmail] = useState(initial?.email ?? '');
|
||||||
const [password] = useState(() => initial?.password ?? generatePassword(passwordRule));
|
const [password] = useState(() => initial?.password ?? generatePassword(passwordRule));
|
||||||
|
const isSystemMember = initial ? isSystemAdminMember(initial) : false;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -254,21 +268,26 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
|
|||||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
{isSystemMember && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-700">
|
||||||
|
系统内置超级管理员账号不可删除,姓名、部门、角色固定。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">姓名 *</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">姓名 *</label>
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="请输入姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
<input value={name} onChange={(e) => setName(e.target.value)} required disabled={isSystemMember} placeholder="请输入姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">部门 *</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">部门 *</label>
|
||||||
<select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
<select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)} required disabled={isSystemMember} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]">
|
||||||
<option value="">选择部门</option>
|
<option value="">选择部门</option>
|
||||||
{departments.map((d) => <option key={d.id} value={d.id}>{d.parentId ? ' ' : ''}{d.name}</option>)}
|
{departments.map((d) => <option key={d.id} value={d.id}>{d.parentId ? ' ' : ''}{d.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">角色 *</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">角色 *</label>
|
||||||
<select value={roleId} onChange={(e) => setRoleId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
<select value={roleId} onChange={(e) => setRoleId(e.target.value)} required disabled={isSystemMember} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]">
|
||||||
<option value="">选择角色</option>
|
<option value="">选择角色</option>
|
||||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
|||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { resolveMemberDisplayName } from '@/lib/member-system';
|
||||||
import type { Priority } from '@/lib/derive';
|
import type { Priority } from '@/lib/derive';
|
||||||
import type { BugSeverity } from '@/lib/bug';
|
import type { BugSeverity } from '@/lib/bug';
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export function BugCreateModal({ testCaseId, onClose }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
const operator = user ? resolveMemberDisplayName(user.id, members) : '系统';
|
||||||
createBug({
|
createBug({
|
||||||
versionId: tc?.versionId || '',
|
versionId: tc?.versionId || '',
|
||||||
testCaseId,
|
testCaseId,
|
||||||
@@ -68,10 +70,10 @@ export function BugCreateModal({ testCaseId, onClose }: Props) {
|
|||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
severity,
|
severity,
|
||||||
priority,
|
priority,
|
||||||
reportedBy: user?.name || '系统',
|
reportedBy: operator,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
images: images.length > 0 ? images : undefined,
|
images: images.length > 0 ? images : undefined,
|
||||||
}, user?.name || '系统');
|
}, operator);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useMemberStore } from '@/stores/useMemberStore';
|
|||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { BUG_ALLOWED_TRANSITIONS, BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
import { BUG_ALLOWED_TRANSITIONS, BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
|
||||||
import type { BugStatus } from '@/lib/bug';
|
import type { BugStatus } from '@/lib/bug';
|
||||||
|
|
||||||
const LOG_ACTION_LABEL: Record<string, string> = {
|
const LOG_ACTION_LABEL: Record<string, string> = {
|
||||||
@@ -31,7 +32,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const { members } = useMemberStore();
|
const { members } = useMemberStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const operator = user?.name || '系统';
|
const operator = user ? resolveMemberDisplayName(user.id, members) : '系统';
|
||||||
|
|
||||||
const bug = bugs.find((b) => b.id === bugId);
|
const bug = bugs.find((b) => b.id === bugId);
|
||||||
if (!bug) return null;
|
if (!bug) return null;
|
||||||
@@ -39,6 +40,12 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
const tc = testCases.find((c) => c.id === bug.testCaseId);
|
const tc = testCases.find((c) => c.id === bug.testCaseId);
|
||||||
const requirement = tc ? requirements.find((r) => r.id === tc.requirementId) : null;
|
const requirement = tc ? requirements.find((r) => r.id === tc.requirementId) : null;
|
||||||
const nextStatuses = BUG_ALLOWED_TRANSITIONS[bug.status];
|
const nextStatuses = BUG_ALLOWED_TRANSITIONS[bug.status];
|
||||||
|
const currentMember = user ? members.find((m) => m.id === user.id) : undefined;
|
||||||
|
const assigneeName = resolveMemberDisplayName(bug.assigneeId, members);
|
||||||
|
const reporterName = resolveMemberDisplayName(bug.reportedBy, members);
|
||||||
|
const isCurrentAssignee = currentMember
|
||||||
|
? isMemberReference(bug.assigneeId, currentMember)
|
||||||
|
: operator === assigneeName;
|
||||||
|
|
||||||
const [resolution, setResolution] = useState(bug.resolution || '');
|
const [resolution, setResolution] = useState(bug.resolution || '');
|
||||||
const [showResolutionInput, setShowResolutionInput] = useState(false);
|
const [showResolutionInput, setShowResolutionInput] = useState(false);
|
||||||
@@ -110,7 +117,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
|
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nextStatuses.length > 0 && !showResolutionInput && operator === bug.assigneeId && (
|
{nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && (
|
||||||
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
{nextStatuses.map((s) => (
|
{nextStatuses.map((s) => (
|
||||||
@@ -125,8 +132,8 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{nextStatuses.length > 0 && !showResolutionInput && operator !== bug.assigneeId && (
|
{nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && (
|
||||||
<div className="text-[11px] text-[var(--ink-muted)] pt-1">当前修复人为 {bug.assigneeId},仅修复人可操作</div>
|
<div className="text-[11px] text-[var(--ink-muted)] pt-1">当前修复人为 {assigneeName},仅修复人可操作</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showResolutionInput && (
|
{showResolutionInput && (
|
||||||
@@ -144,7 +151,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
<div className="text-[11px] text-[var(--ink-muted)]">转交给:</div>
|
<div className="text-[11px] text-[var(--ink-muted)]">转交给:</div>
|
||||||
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none">
|
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none">
|
||||||
<option value="">选择接收人</option>
|
<option value="">选择接收人</option>
|
||||||
{members.filter((m) => m.name !== bug.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
{members.filter((m) => !isMemberReference(bug.assigneeId, m)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input value={transferRemark} onChange={(e) => setTransferRemark(e.target.value)} placeholder="转交备注(可选)" className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
<input value={transferRemark} onChange={(e) => setTransferRemark(e.target.value)} placeholder="转交备注(可选)" className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -159,8 +166,8 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
||||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
||||||
<div><span className="text-[var(--ink-muted)]">提交人:</span><span className="text-[var(--ink)]">{bug.reportedBy}</span></div>
|
<div><span className="text-[var(--ink-muted)]">提交人:</span><span className="text-[var(--ink)]">{reporterName}</span></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">修复人:</span><span className="text-[var(--ink)] font-medium">{bug.assigneeId}</span></div>
|
<div><span className="text-[var(--ink-muted)]">修复人:</span><span className="text-[var(--ink)] font-medium">{assigneeName}</span></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">提交:</span><span className="text-[var(--ink)]">{formatDateTime(bug.createdAt)}</span></div>
|
<div><span className="text-[var(--ink-muted)]">提交:</span><span className="text-[var(--ink)]">{formatDateTime(bug.createdAt)}</span></div>
|
||||||
{bug.resolvedAt && <div><span className="text-[var(--ink-muted)]">修复:</span><span className="text-[var(--ink)]">{formatDateTime(bug.resolvedAt)}</span></div>}
|
{bug.resolvedAt && <div><span className="text-[var(--ink-muted)]">修复:</span><span className="text-[var(--ink)]">{formatDateTime(bug.resolvedAt)}</span></div>}
|
||||||
{bug.closedAt && <div><span className="text-[var(--ink-muted)]">关闭:</span><span className="text-emerald-600">{formatDateTime(bug.closedAt)}</span></div>}
|
{bug.closedAt && <div><span className="text-[var(--ink-muted)]">关闭:</span><span className="text-emerald-600">{formatDateTime(bug.closedAt)}</span></div>}
|
||||||
@@ -202,10 +209,10 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
<div key={log.id} className="flex gap-2.5 text-[11px]">
|
<div key={log.id} className="flex gap-2.5 text-[11px]">
|
||||||
<span className="text-[var(--ink-muted)] tabular-nums shrink-0 w-[110px]">{formatDateTime(log.createdAt)}</span>
|
<span className="text-[var(--ink-muted)] tabular-nums shrink-0 w-[110px]">{formatDateTime(log.createdAt)}</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="font-medium text-[var(--ink)]">{log.operator}</span>
|
<span className="font-medium text-[var(--ink)]">{resolveMemberDisplayName(log.operator, members)}</span>
|
||||||
<span className="text-[var(--ink-soft)]"> {LOG_ACTION_LABEL[log.action] || log.action}</span>
|
<span className="text-[var(--ink-soft)]"> {LOG_ACTION_LABEL[log.action] || log.action}</span>
|
||||||
{log.fromValue && log.toValue && (
|
{log.fromValue && log.toValue && (
|
||||||
<span className="text-[var(--ink-muted)]"> {log.fromValue} → {log.toValue}</span>
|
<span className="text-[var(--ink-muted)]"> {resolveMemberDisplayName(log.fromValue, members)} → {resolveMemberDisplayName(log.toValue, members)}</span>
|
||||||
)}
|
)}
|
||||||
{log.remark && <span className="text-[var(--ink-muted)]"> ({log.remark})</span>}
|
{log.remark && <span className="text-[var(--ink-muted)]"> ({log.remark})</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { memo } from 'react';
|
|||||||
import { BugStatusBadge } from './BugStatusBadge';
|
import { BugStatusBadge } from './BugStatusBadge';
|
||||||
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR, getBugActualHours } from '@/lib/bug';
|
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR, getBugActualHours } from '@/lib/bug';
|
||||||
import { formatWorkHours } from '@/lib/work-hours';
|
import { formatWorkHours } from '@/lib/work-hours';
|
||||||
|
import { resolveMemberDisplayName } from '@/lib/member-system';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import type { Bug } from '@/lib/bug';
|
import type { Bug } from '@/lib/bug';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,6 +23,8 @@ const PRIORITY_DOT: Record<string, string> = {
|
|||||||
|
|
||||||
function BugRowImpl({ bug, testCaseNo, onClick }: Props) {
|
function BugRowImpl({ bug, testCaseNo, onClick }: Props) {
|
||||||
const actualHours = getBugActualHours(bug);
|
const actualHours = getBugActualHours(bug);
|
||||||
|
const members = useMemberStore((s) => s.members);
|
||||||
|
const assigneeName = resolveMemberDisplayName(bug.assigneeId, members);
|
||||||
return (
|
return (
|
||||||
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
||||||
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[bug.priority] || 'bg-zinc-300'}`} />
|
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[bug.priority] || 'bg-zinc-300'}`} />
|
||||||
@@ -32,7 +36,7 @@ function BugRowImpl({ bug, testCaseNo, onClick }: Props) {
|
|||||||
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
|
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
|
||||||
)}
|
)}
|
||||||
{testCaseNo && <span className="text-[10px] font-mono text-[var(--ink-muted)] w-14 text-right shrink-0">{testCaseNo}</span>}
|
{testCaseNo && <span className="text-[10px] font-mono text-[var(--ink-muted)] w-14 text-right shrink-0">{testCaseNo}</span>}
|
||||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{bug.assigneeId}</span>
|
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{assigneeName}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useBugStore } from '@/stores/useBugStore';
|
|||||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { BugRow } from './BugRow';
|
import { BugRow } from './BugRow';
|
||||||
import { BugDetailDrawer } from './BugDetailDrawer';
|
import { BugDetailDrawer } from './BugDetailDrawer';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
@@ -13,6 +14,7 @@ import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
|||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, bugIntervals } from '@/lib/bug';
|
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, bugIntervals } from '@/lib/bug';
|
||||||
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||||
|
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
|
||||||
import type { BugStatus, BugSeverity } from '@/lib/bug';
|
import type { BugStatus, BugSeverity } from '@/lib/bug';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,6 +27,7 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
|||||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const members = useMemberStore((s) => s.members);
|
||||||
|
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||||
@@ -48,26 +51,36 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
|||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||||
|
|
||||||
|
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]);
|
||||||
|
const currentMember = useMemo(() => members.find((m) => m.id === user?.id), [members, user?.id]);
|
||||||
|
const currentUserName = currentMember?.name ?? user?.name;
|
||||||
|
|
||||||
|
const assignees = useMemo(() => {
|
||||||
|
const names = new Set(versionBugs.map((b) => resolveMemberDisplayName(b.assigneeId, members)));
|
||||||
|
return Array.from(names);
|
||||||
|
}, [versionBugs, members]);
|
||||||
|
|
||||||
const filteredBugs = useMemo(() => {
|
const filteredBugs = useMemo(() => {
|
||||||
let result = versionBugs;
|
let result = versionBugs;
|
||||||
if (filterAssignee) result = result.filter((b) => b.assigneeId === filterAssignee);
|
if (filterAssignee === '__me') {
|
||||||
|
if (currentMember) {
|
||||||
|
result = result.filter((b) => isMemberReference(b.assigneeId, currentMember));
|
||||||
|
} else {
|
||||||
|
result = [];
|
||||||
|
}
|
||||||
|
} else if (filterAssignee) {
|
||||||
|
result = result.filter((b) => resolveMemberDisplayName(b.assigneeId, members) === filterAssignee);
|
||||||
|
}
|
||||||
if (filterStatus) result = result.filter((b) => b.status === filterStatus);
|
if (filterStatus) result = result.filter((b) => b.status === filterStatus);
|
||||||
if (filterSeverity) result = result.filter((b) => b.severity === filterSeverity);
|
if (filterSeverity) result = result.filter((b) => b.severity === filterSeverity);
|
||||||
if (debouncedKeyword) result = result.filter((b) => matchTitleOrNo({ title: b.title, no: b.bugNo }, debouncedKeyword));
|
if (debouncedKeyword) result = result.filter((b) => matchTitleOrNo({ title: b.title, no: b.bugNo }, debouncedKeyword));
|
||||||
return result;
|
return result;
|
||||||
}, [versionBugs, filterAssignee, filterStatus, filterSeverity, debouncedKeyword]);
|
}, [versionBugs, filterAssignee, filterStatus, filterSeverity, debouncedKeyword, currentMember, members]);
|
||||||
|
|
||||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
|
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
|
||||||
|
|
||||||
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]);
|
|
||||||
|
|
||||||
const assignees = useMemo(() => {
|
|
||||||
const names = new Set(versionBugs.map((b) => b.assigneeId));
|
|
||||||
return Array.from(names);
|
|
||||||
}, [versionBugs]);
|
|
||||||
|
|
||||||
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity || debouncedKeyword);
|
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity || debouncedKeyword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,8 +107,8 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
|||||||
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
||||||
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
<option value="">全部修复人</option>
|
<option value="">全部修复人</option>
|
||||||
{user?.name && <option value={user.name}>我的Bug</option>}
|
{currentMember && <option value="__me">我的Bug</option>}
|
||||||
{assignees.filter((a) => a !== user?.name).map((a) => <option key={a} value={a}>{a}</option>)}
|
{assignees.filter((a) => a !== currentUserName).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
<select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
|
|||||||
57
apps/web/lib/auth-user.test.ts
Normal file
57
apps/web/lib/auth-user.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { resolveAuthUserFromMembers, toAuthUser, type AuthUser } from './auth-user';
|
||||||
|
import type { Member } from './members';
|
||||||
|
|
||||||
|
const member: Member = {
|
||||||
|
id: 'm-8',
|
||||||
|
name: '超级管理员',
|
||||||
|
departmentId: 'dept-1',
|
||||||
|
roleId: 'role-admin',
|
||||||
|
phone: '13200132008',
|
||||||
|
email: 'admin@company.com',
|
||||||
|
password: 'Ftb@2024',
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('toAuthUser removes password and keeps stable member identity', () => {
|
||||||
|
assert.deepEqual(toAuthUser(member), {
|
||||||
|
id: 'm-8',
|
||||||
|
name: '超级管理员',
|
||||||
|
departmentId: 'dept-1',
|
||||||
|
roleId: 'role-admin',
|
||||||
|
phone: '13200132008',
|
||||||
|
email: 'admin@company.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveAuthUserFromMembers refreshes stale display names by member id', () => {
|
||||||
|
const staleUser: AuthUser = {
|
||||||
|
id: 'm-8',
|
||||||
|
name: '陈十',
|
||||||
|
departmentId: 'dept-1',
|
||||||
|
roleId: 'role-admin',
|
||||||
|
phone: '13200132008',
|
||||||
|
email: 'old@company.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = resolveAuthUserFromMembers(staleUser, [member]);
|
||||||
|
|
||||||
|
assert.equal(resolved?.id, 'm-8');
|
||||||
|
assert.equal(resolved?.name, '超级管理员');
|
||||||
|
assert.equal(resolved?.email, 'admin@company.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveAuthUserFromMembers returns null when the account no longer exists', () => {
|
||||||
|
const staleUser: AuthUser = {
|
||||||
|
id: 'm-9',
|
||||||
|
name: '离职用户',
|
||||||
|
departmentId: 'dept-2',
|
||||||
|
roleId: 'role-dev',
|
||||||
|
phone: '13900139000',
|
||||||
|
email: 'old@company.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(resolveAuthUserFromMembers(staleUser, [member]), null);
|
||||||
|
});
|
||||||
26
apps/web/lib/auth-user.ts
Normal file
26
apps/web/lib/auth-user.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Member } from './members';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roleId: string;
|
||||||
|
departmentId: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAuthUser(member: Member): AuthUser {
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
roleId: member.roleId,
|
||||||
|
departmentId: member.departmentId,
|
||||||
|
phone: member.phone,
|
||||||
|
email: member.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthUserFromMembers(user: AuthUser, members: Member[]): AuthUser | null {
|
||||||
|
const member = members.find((m) => m.id === user.id);
|
||||||
|
return member ? toAuthUser(member) : null;
|
||||||
|
}
|
||||||
84
apps/web/lib/member-system.test.ts
Normal file
84
apps/web/lib/member-system.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SYSTEM_ADMIN_MEMBER_ID,
|
||||||
|
SYSTEM_ADMIN_MEMBER_NAME,
|
||||||
|
SYSTEM_ADMIN_ROLE_ID,
|
||||||
|
ensureSystemAdminMember,
|
||||||
|
sanitizeSystemAdminPatch,
|
||||||
|
canDeleteSystemMember,
|
||||||
|
resolveMemberDisplayName,
|
||||||
|
isMemberReference,
|
||||||
|
} from './member-system';
|
||||||
|
import type { Member } from './members';
|
||||||
|
|
||||||
|
const oldAdmin: Member = {
|
||||||
|
id: SYSTEM_ADMIN_MEMBER_ID,
|
||||||
|
name: '陈十',
|
||||||
|
departmentId: 'dept-2',
|
||||||
|
roleId: 'role-dev',
|
||||||
|
phone: '13200132008',
|
||||||
|
email: 'chenshi@company.com',
|
||||||
|
password: 'Ftb@2024',
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('normalizes the built-in admin account when old data named it as a regular user', () => {
|
||||||
|
const result = ensureSystemAdminMember([oldAdmin]);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(result.members.length, 1);
|
||||||
|
assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
|
||||||
|
assert.equal(result.members[0].name, SYSTEM_ADMIN_MEMBER_NAME);
|
||||||
|
assert.equal(result.members[0].roleId, SYSTEM_ADMIN_ROLE_ID);
|
||||||
|
assert.equal(result.members[0].isSystem, true);
|
||||||
|
assert.equal(result.members[0].phone, oldAdmin.phone);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a built-in admin account when member data has none', () => {
|
||||||
|
const result = ensureSystemAdminMember([]);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(result.members.length, 1);
|
||||||
|
assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
|
||||||
|
assert.equal(result.members[0].name, SYSTEM_ADMIN_MEMBER_NAME);
|
||||||
|
assert.equal(result.members[0].roleId, SYSTEM_ADMIN_ROLE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('protects the built-in admin account from identity and role edits', () => {
|
||||||
|
const patch = sanitizeSystemAdminPatch(SYSTEM_ADMIN_MEMBER_ID, {
|
||||||
|
name: '陈十',
|
||||||
|
roleId: 'role-dev',
|
||||||
|
departmentId: 'dept-3',
|
||||||
|
phone: '13900139000',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(patch.name, undefined);
|
||||||
|
assert.equal(patch.roleId, undefined);
|
||||||
|
assert.equal(patch.departmentId, undefined);
|
||||||
|
assert.equal(patch.phone, '13900139000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks deleting the built-in admin account', () => {
|
||||||
|
assert.equal(canDeleteSystemMember(SYSTEM_ADMIN_MEMBER_ID), false);
|
||||||
|
assert.equal(canDeleteSystemMember('m-9'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves legacy Chen Shi labels to the built-in admin display name', () => {
|
||||||
|
const result = ensureSystemAdminMember([oldAdmin]);
|
||||||
|
|
||||||
|
assert.equal(resolveMemberDisplayName('陈十', result.members), SYSTEM_ADMIN_MEMBER_NAME);
|
||||||
|
assert.equal(resolveMemberDisplayName(SYSTEM_ADMIN_MEMBER_ID, result.members), SYSTEM_ADMIN_MEMBER_NAME);
|
||||||
|
assert.equal(resolveMemberDisplayName('张三', result.members), '张三');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches legacy admin names as the built-in admin member', () => {
|
||||||
|
const result = ensureSystemAdminMember([oldAdmin]);
|
||||||
|
const admin = result.members[0];
|
||||||
|
|
||||||
|
assert.equal(isMemberReference('陈十', admin), true);
|
||||||
|
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_ID, admin), true);
|
||||||
|
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_NAME, admin), true);
|
||||||
|
assert.equal(isMemberReference('张三', admin), false);
|
||||||
|
});
|
||||||
92
apps/web/lib/member-system.ts
Normal file
92
apps/web/lib/member-system.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Member } from './members';
|
||||||
|
|
||||||
|
export const SYSTEM_ADMIN_MEMBER_ID = 'm-8';
|
||||||
|
export const SYSTEM_ADMIN_MEMBER_NAME = '超级管理员';
|
||||||
|
export const SYSTEM_ADMIN_ROLE_ID = 'role-admin';
|
||||||
|
export const SYSTEM_ADMIN_DEPARTMENT_ID = 'dept-1';
|
||||||
|
const LEGACY_SYSTEM_ADMIN_NAMES = ['陈十'];
|
||||||
|
|
||||||
|
export const SYSTEM_ADMIN_MEMBER: Member = {
|
||||||
|
id: SYSTEM_ADMIN_MEMBER_ID,
|
||||||
|
name: SYSTEM_ADMIN_MEMBER_NAME,
|
||||||
|
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
|
||||||
|
roleId: SYSTEM_ADMIN_ROLE_ID,
|
||||||
|
phone: '13200132008',
|
||||||
|
email: 'admin@company.com',
|
||||||
|
password: 'Ftb@2024',
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
isSystem: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSystemAdminMember(member: Partial<Member>): Member {
|
||||||
|
return {
|
||||||
|
...SYSTEM_ADMIN_MEMBER,
|
||||||
|
...member,
|
||||||
|
id: SYSTEM_ADMIN_MEMBER_ID,
|
||||||
|
name: SYSTEM_ADMIN_MEMBER_NAME,
|
||||||
|
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
|
||||||
|
roleId: SYSTEM_ADMIN_ROLE_ID,
|
||||||
|
phone: member.phone || SYSTEM_ADMIN_MEMBER.phone,
|
||||||
|
email: member.email || SYSTEM_ADMIN_MEMBER.email,
|
||||||
|
password: member.password || SYSTEM_ADMIN_MEMBER.password,
|
||||||
|
createdAt: member.createdAt || SYSTEM_ADMIN_MEMBER.createdAt,
|
||||||
|
isSystem: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSystemAdminMember(members: Member[]): { members: Member[]; changed: boolean } {
|
||||||
|
const index = members.findIndex((m) => m.id === SYSTEM_ADMIN_MEMBER_ID);
|
||||||
|
if (index === -1) {
|
||||||
|
return { members: [SYSTEM_ADMIN_MEMBER, ...members], changed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = members[index];
|
||||||
|
const normalized = normalizeSystemAdminMember(current);
|
||||||
|
const changed =
|
||||||
|
current.name !== normalized.name ||
|
||||||
|
current.departmentId !== normalized.departmentId ||
|
||||||
|
current.roleId !== normalized.roleId ||
|
||||||
|
current.phone !== normalized.phone ||
|
||||||
|
current.email !== normalized.email ||
|
||||||
|
current.password !== normalized.password ||
|
||||||
|
current.createdAt !== normalized.createdAt ||
|
||||||
|
current.isSystem !== true;
|
||||||
|
|
||||||
|
if (!changed) return { members, changed: false };
|
||||||
|
|
||||||
|
return {
|
||||||
|
members: members.map((m, i) => (i === index ? normalized : m)),
|
||||||
|
changed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeSystemAdminPatch(id: string, data: Partial<Member>): Partial<Member> {
|
||||||
|
if (id !== SYSTEM_ADMIN_MEMBER_ID) return data;
|
||||||
|
const { name, departmentId, roleId, isSystem, ...allowed } = data;
|
||||||
|
return { ...allowed, isSystem: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canDeleteSystemMember(id: string): boolean {
|
||||||
|
return id !== SYSTEM_ADMIN_MEMBER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSystemAdminMember(member: Pick<Member, 'id'>): boolean {
|
||||||
|
return member.id === SYSTEM_ADMIN_MEMBER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMemberDisplayName(ref: string | undefined | null, members: Member[]): string {
|
||||||
|
if (!ref) return '-';
|
||||||
|
const member = members.find((m) => m.id === ref || m.name === ref);
|
||||||
|
if (member) return member.name;
|
||||||
|
if (LEGACY_SYSTEM_ADMIN_NAMES.includes(ref)) {
|
||||||
|
const admin = members.find((m) => m.id === SYSTEM_ADMIN_MEMBER_ID);
|
||||||
|
return admin?.name ?? SYSTEM_ADMIN_MEMBER_NAME;
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMemberReference(ref: string | undefined | null, member: Pick<Member, 'id' | 'name'>): boolean {
|
||||||
|
if (!ref) return false;
|
||||||
|
if (ref === member.id || ref === member.name) return true;
|
||||||
|
return member.id === SYSTEM_ADMIN_MEMBER_ID && LEGACY_SYSTEM_ADMIN_NAMES.includes(ref);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export interface Member {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isSystem?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoleItem {
|
export interface RoleItem {
|
||||||
|
|||||||
@@ -3,17 +3,10 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { loadServerData } from '@/lib/server-data';
|
import { loadServerData } from '@/lib/server-data';
|
||||||
import type { Member } from '@/lib/members';
|
import type { Member } from '@/lib/members';
|
||||||
|
import { SYSTEM_ADMIN_MEMBER, ensureSystemAdminMember } from '@/lib/member-system';
|
||||||
|
import { resolveAuthUserFromMembers, toAuthUser, type AuthUser } from '@/lib/auth-user';
|
||||||
import { useMemberStore } from './useMemberStore';
|
import { useMemberStore } from './useMemberStore';
|
||||||
|
|
||||||
interface AuthUser {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
roleId: string;
|
|
||||||
departmentId: string;
|
|
||||||
phone: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -30,38 +23,42 @@ interface StoredMembersData {
|
|||||||
const SESSION_KEY = 'ftb_auth_session';
|
const SESSION_KEY = 'ftb_auth_session';
|
||||||
const PERSIST_KEY = 'ftb_auth_persist';
|
const PERSIST_KEY = 'ftb_auth_persist';
|
||||||
|
|
||||||
const FALLBACK_MEMBERS: Member[] = [
|
const FALLBACK_MEMBERS: Member[] = [SYSTEM_ADMIN_MEMBER];
|
||||||
{
|
|
||||||
id: 'm-8',
|
function writeAuthStorage(user: AuthUser, remember: boolean) {
|
||||||
name: '\u8d85\u7ea7\u7ba1\u7406\u5458',
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
|
||||||
departmentId: 'dept-1',
|
if (remember) {
|
||||||
roleId: 'role-admin',
|
localStorage.setItem(PERSIST_KEY, JSON.stringify(user));
|
||||||
phone: '13200132008',
|
}
|
||||||
email: 'chenshi@company.com',
|
}
|
||||||
password: 'Ftb@2024',
|
|
||||||
createdAt: '2024-01-01',
|
function clearAuthStorage() {
|
||||||
},
|
sessionStorage.removeItem(SESSION_KEY);
|
||||||
];
|
localStorage.removeItem(PERSIST_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMembersForAuth(): Promise<Member[]> {
|
async function loadMembersForAuth(): Promise<Member[]> {
|
||||||
try {
|
try {
|
||||||
const stored = await loadServerData<StoredMembersData>('members');
|
const stored = await loadServerData<StoredMembersData>('members');
|
||||||
if (stored?.members?.length) return stored.members;
|
if (stored?.members?.length) return ensureSystemAdminMember(stored.members).members;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const inMemoryMembers = useMemberStore.getState().members;
|
const inMemoryMembers = useMemberStore.getState().members;
|
||||||
return inMemoryMembers.length > 0 ? inMemoryMembers : FALLBACK_MEMBERS;
|
return inMemoryMembers.length > 0 ? ensureSystemAdminMember(inMemoryMembers).members : FALLBACK_MEMBERS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAuthUser(member: Member): AuthUser {
|
async function refreshStoredAuthUser(user: AuthUser, remember: boolean, set: (state: Partial<AuthState>) => void) {
|
||||||
return {
|
try {
|
||||||
id: member.id,
|
const members = await loadMembersForAuth();
|
||||||
name: member.name,
|
const next = resolveAuthUserFromMembers(user, members);
|
||||||
roleId: member.roleId,
|
if (!next) {
|
||||||
departmentId: member.departmentId,
|
clearAuthStorage();
|
||||||
phone: member.phone,
|
set({ user: null, isAuthenticated: false });
|
||||||
email: member.email,
|
return;
|
||||||
};
|
}
|
||||||
|
set({ user: next, isAuthenticated: true });
|
||||||
|
writeAuthStorage(next, remember);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
@@ -75,30 +72,29 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
const user = toAuthUser(member);
|
const user = toAuthUser(member);
|
||||||
set({ user, isAuthenticated: true });
|
set({ user, isAuthenticated: true });
|
||||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
|
writeAuthStorage(user, remember);
|
||||||
if (remember) {
|
|
||||||
localStorage.setItem(PERSIST_KEY, JSON.stringify(user));
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
set({ user: null, isAuthenticated: false });
|
set({ user: null, isAuthenticated: false });
|
||||||
sessionStorage.removeItem(SESSION_KEY);
|
clearAuthStorage();
|
||||||
localStorage.removeItem(PERSIST_KEY);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: () => {
|
checkAuth: () => {
|
||||||
const session = sessionStorage.getItem(SESSION_KEY);
|
const session = sessionStorage.getItem(SESSION_KEY);
|
||||||
if (session) {
|
if (session) {
|
||||||
set({ user: JSON.parse(session), isAuthenticated: true });
|
const user = JSON.parse(session);
|
||||||
|
set({ user, isAuthenticated: true });
|
||||||
|
void refreshStoredAuthUser(user, !!localStorage.getItem(PERSIST_KEY), set);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persist = localStorage.getItem(PERSIST_KEY);
|
const persist = localStorage.getItem(PERSIST_KEY);
|
||||||
if (persist) {
|
if (persist) {
|
||||||
const user = JSON.parse(persist);
|
const user = JSON.parse(persist);
|
||||||
set({ user, isAuthenticated: true });
|
set({ user, isAuthenticated: true });
|
||||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
|
writeAuthStorage(user, true);
|
||||||
|
void refreshStoredAuthUser(user, true, set);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -114,10 +110,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
const next = toAuthUser(member);
|
const next = toAuthUser(member);
|
||||||
useAuthStore.setState({ user: next });
|
useAuthStore.setState({ user: next });
|
||||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
writeAuthStorage(next, !!localStorage.getItem(PERSIST_KEY));
|
||||||
if (localStorage.getItem(PERSIST_KEY)) {
|
|
||||||
localStorage.setItem(PERSIST_KEY, JSON.stringify(next));
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
|
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
|
||||||
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
|
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
|
||||||
|
import {
|
||||||
|
SYSTEM_ADMIN_MEMBER,
|
||||||
|
SYSTEM_ADMIN_ROLE_ID,
|
||||||
|
canDeleteSystemMember,
|
||||||
|
ensureSystemAdminMember,
|
||||||
|
sanitizeSystemAdminPatch,
|
||||||
|
} from '@/lib/member-system';
|
||||||
import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions';
|
import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions';
|
||||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||||
|
|
||||||
@@ -24,7 +31,7 @@ const PRESET_ROLES: RoleItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_MEMBERS: Member[] = [
|
const MOCK_MEMBERS: Member[] = [
|
||||||
{ id: 'm-8', name: '超级管理员', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' },
|
SYSTEM_ADMIN_MEMBER,
|
||||||
];
|
];
|
||||||
|
|
||||||
interface MemberState {
|
interface MemberState {
|
||||||
@@ -74,7 +81,22 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
set({ departments: cached.departments, members: cached.members, roles: migratedRoles, passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE });
|
const adminRole = PRESET_ROLES.find((r) => r.id === SYSTEM_ADMIN_ROLE_ID)!;
|
||||||
|
const roles = migratedRoles.some((r) => r.id === SYSTEM_ADMIN_ROLE_ID)
|
||||||
|
? migratedRoles.map((r) => r.id === SYSTEM_ADMIN_ROLE_ID ? { ...adminRole, ...r, isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS[SYSTEM_ADMIN_ROLE_ID] } : r)
|
||||||
|
: [adminRole, ...migratedRoles];
|
||||||
|
const rolesChanged =
|
||||||
|
roles.length !== migratedRoles.length ||
|
||||||
|
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(migratedRoles[index]));
|
||||||
|
const ensured = ensureSystemAdminMember(cached.members);
|
||||||
|
const next = {
|
||||||
|
departments: cached.departments,
|
||||||
|
members: ensured.members,
|
||||||
|
roles,
|
||||||
|
passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE,
|
||||||
|
};
|
||||||
|
set(next);
|
||||||
|
if (ensured.changed || rolesChanged) saveStored(next);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -107,11 +129,14 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||||
},
|
},
|
||||||
updateMember: (id, data) => {
|
updateMember: (id, data) => {
|
||||||
const members = get().members.map((m) => m.id === id ? { ...m, ...data } : m);
|
const safeData = sanitizeSystemAdminPatch(id, data);
|
||||||
set({ members });
|
const updatedMembers = get().members.map((m) => m.id === id ? { ...m, ...safeData } : m);
|
||||||
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
const ensured = ensureSystemAdminMember(updatedMembers);
|
||||||
|
set({ members: ensured.members });
|
||||||
|
saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule });
|
||||||
},
|
},
|
||||||
deleteMember: (id) => {
|
deleteMember: (id) => {
|
||||||
|
if (!canDeleteSystemMember(id)) return;
|
||||||
const members = get().members.filter((m) => m.id !== id);
|
const members = get().members.filter((m) => m.id !== id);
|
||||||
set({ members });
|
set({ members });
|
||||||
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||||
@@ -142,6 +167,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
||||||
},
|
},
|
||||||
deleteRole: (id) => {
|
deleteRole: (id) => {
|
||||||
|
if (id === SYSTEM_ADMIN_ROLE_ID) return;
|
||||||
const roles = get().roles.filter((r) => r.id !== id);
|
const roles = get().roles.filter((r) => r.id !== id);
|
||||||
set({ roles });
|
set({ roles });
|
||||||
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
||||||
|
|||||||
@@ -300,3 +300,18 @@
|
|||||||
**理由**:
|
**理由**:
|
||||||
- 去重不能只依赖模型提示,必须有系统规则兜底。
|
- 去重不能只依赖模型提示,必须有系统规则兜底。
|
||||||
- 标题也参与签名,避免同一需求/QY 下不同真实工作项被误过滤。
|
- 标题也参与签名,避免同一需求/QY 下不同真实工作项被误过滤。
|
||||||
|
|
||||||
|
## 26. 系统内置超级管理员账号不可删除
|
||||||
|
|
||||||
|
**问题**:全新系统需要一个稳定可登录的超级管理员账号。早期默认成员曾以普通姓名展示,用户手动改名后,登录态和 Bug 单据中的人员姓名可能仍保留旧快照,造成“提交人是旧姓名、修复人是新姓名”的错位。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- 固定 `m-8` 为系统内置超级管理员账号,姓名、部门、角色由系统保护,不允许删除。
|
||||||
|
- 读取历史成员数据时自动迁移旧默认账号为“超级管理员”,保留手机号、邮箱、密码。
|
||||||
|
- 登录态以成员 `id` 为稳定身份,页面启动和编辑当前用户后都按成员表刷新姓名、角色和联系方式。
|
||||||
|
- Bug 展示和筛选对历史旧名做兼容解析,旧单据中的默认账号旧姓名显示为当前超级管理员。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 账号身份不能依赖可编辑姓名;姓名只是展示字段。
|
||||||
|
- 内置超管账号避免新系统初始化后被误删或降权导致无法管理。
|
||||||
|
- 历史单据兼容能修复已有数据的显示错位,同时不需要批量改写业务记录。
|
||||||
|
|||||||
Reference in New Issue
Block a user