106 lines
4.6 KiB
TypeScript
106 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { ShieldCheck, Trash2, UserPlus } from 'lucide-react';
|
|
import { api } from '@/lib/api';
|
|
import { useAuthStore } from '@/stores/useAuthStore';
|
|
import { useMemberStore } from '@/stores/useMemberStore';
|
|
|
|
type ProjectRole = 'owner' | 'admin' | 'member' | 'viewer';
|
|
|
|
interface ProjectMemberRow {
|
|
id: string;
|
|
projectId: string;
|
|
userId: string;
|
|
role: ProjectRole;
|
|
user?: { id: string; name: string; email?: string };
|
|
}
|
|
|
|
const ROLE_LABEL: Record<ProjectRole, string> = {
|
|
owner: 'Owner',
|
|
admin: 'Admin',
|
|
member: 'Member',
|
|
viewer: 'Viewer',
|
|
};
|
|
|
|
export function ProjectMemberPanel({ projectId }: { projectId: string }) {
|
|
const user = useAuthStore((s) => s.user);
|
|
const { members, fetchMembers } = useMemberStore();
|
|
const [rows, setRows] = useState<ProjectMemberRow[]>([]);
|
|
const [selectedUserId, setSelectedUserId] = useState('');
|
|
const [selectedRole, setSelectedRole] = useState<ProjectRole>('member');
|
|
const actorId = user?.id ?? '';
|
|
|
|
const reload = async () => {
|
|
if (!projectId) return;
|
|
const data = await api.get<ProjectMemberRow[]>(`/projects/${projectId}/members`);
|
|
setRows(data);
|
|
};
|
|
|
|
useEffect(() => {
|
|
void fetchMembers().catch(() => {});
|
|
void reload().catch(() => {});
|
|
}, [projectId]);
|
|
|
|
const add = async () => {
|
|
if (!actorId || !selectedUserId) return;
|
|
await api.post(`/projects/${projectId}/members`, { actorId, userId: selectedUserId, role: selectedRole });
|
|
setSelectedUserId('');
|
|
await reload();
|
|
};
|
|
|
|
const updateRole = async (member: ProjectMemberRow, role: ProjectRole) => {
|
|
if (!actorId) return;
|
|
await api.patch(`/projects/${projectId}/members/${member.userId}/role`, { actorId, role });
|
|
await reload();
|
|
};
|
|
|
|
const remove = async (member: ProjectMemberRow) => {
|
|
if (!actorId) return;
|
|
await api.deleteWithBody(`/projects/${projectId}/members/${member.userId}`, { actorId });
|
|
await reload();
|
|
};
|
|
|
|
const existingUserIds = new Set(rows.map((row) => row.userId));
|
|
const candidates = members.filter((member) => !existingUserIds.has(member.id));
|
|
|
|
return (
|
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
|
<div className="mb-3 flex items-center gap-1.5 text-[12px] font-semibold text-[var(--ink)]">
|
|
<ShieldCheck className="h-4 w-4 text-[var(--accent)]" /> 项目成员治理
|
|
</div>
|
|
<div className="mb-3 grid grid-cols-[1fr_108px_auto] gap-2">
|
|
<select value={selectedUserId} onChange={(event) => setSelectedUserId(event.target.value)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
|
|
<option value="">选择成员</option>
|
|
{candidates.map((member) => <option key={member.id} value={member.id}>{member.name}</option>)}
|
|
</select>
|
|
<select value={selectedRole} onChange={(event) => setSelectedRole(event.target.value as ProjectRole)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
|
|
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
|
|
</select>
|
|
<button onClick={add} disabled={!selectedUserId} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
|
<UserPlus className="h-3 w-3" /> 添加
|
|
</button>
|
|
</div>
|
|
|
|
<div className="divide-y divide-[var(--line)] rounded-md border border-[var(--line)]">
|
|
{rows.length === 0 ? (
|
|
<div className="px-3 py-6 text-center text-[12px] text-[var(--ink-muted)]">暂无项目成员</div>
|
|
) : rows.map((row) => (
|
|
<div key={row.id} className="grid grid-cols-[1fr_112px_32px] items-center gap-2 px-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-[12px] font-medium text-[var(--ink)]">{row.user?.name ?? row.userId}</div>
|
|
<div className="truncate text-[10px] text-[var(--ink-muted)]">{row.user?.email ?? row.userId}</div>
|
|
</div>
|
|
<select value={row.role} onChange={(event) => updateRole(row, event.target.value as ProjectRole).catch(() => {})} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[11px]">
|
|
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
|
|
</select>
|
|
<button onClick={() => remove(row).catch(() => {})} className="rounded p-1.5 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600" title="移除成员">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|