0.3.4 小图标功能增加
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useConvex, useMutation } from "convex/react";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
type SharePermission = "read" | "edit";
|
||||
|
||||
export interface DocumentShareDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentId: string;
|
||||
documentTitle?: string | null;
|
||||
workspaceId: string;
|
||||
allowIncludeDescendants?: boolean;
|
||||
onChanged?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function DocumentShareDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentId,
|
||||
documentTitle,
|
||||
workspaceId,
|
||||
allowIncludeDescendants = false,
|
||||
onChanged,
|
||||
}: DocumentShareDialogProps) {
|
||||
const convex = useConvex();
|
||||
const [username, setUsername] = useState("");
|
||||
const [permission, setPermission] = useState<SharePermission>("read");
|
||||
const [includeDescendants, setIncludeDescendants] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [shares, setShares] = useState<any[] | null>(null);
|
||||
const [sharesLoading, setSharesLoading] = useState(false);
|
||||
const [groups, setGroups] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [groupShares, setGroupShares] = useState<any[] | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const [groupIncludeDescendants, setGroupIncludeDescendants] = useState(false);
|
||||
const [groupMembers, setGroupMembers] = useState<Array<{ userId: string; username: string | null; role: string }>>(
|
||||
[],
|
||||
);
|
||||
const [groupEditableUserIds, setGroupEditableUserIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const upsertShare = useMutation(api.documentShares.upsert);
|
||||
const removeShare = useMutation(api.documentShares.remove);
|
||||
const upsertGroupShare = useMutation(api.documentGroupShares.upsert);
|
||||
const removeGroupShare = useMutation(api.documentGroupShares.remove);
|
||||
|
||||
const loadShares = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (!documentId) {
|
||||
setShares([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSharesLoading(true);
|
||||
try {
|
||||
const resp = await convex.query(api.documentShares.listByDocument, { documentId });
|
||||
setShares(Array.isArray(resp) ? resp : []);
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? "加载共享者失败,请重试";
|
||||
// 说明:这类报错通常是 Convex functions 没有部署到当前 backend(尤其是自托管场景)。
|
||||
if (String(msg).includes("Could not find public function for 'documentShares:listByDocument'")) {
|
||||
setError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后重试。");
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
setShares([]);
|
||||
} finally {
|
||||
setSharesLoading(false);
|
||||
}
|
||||
}, [convex, documentId, open]);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (!workspaceId) {
|
||||
setGroups([]);
|
||||
return;
|
||||
}
|
||||
const resp = await convex.query(api.groups.listByWorkspace, { workspaceId });
|
||||
const rows = Array.isArray(resp) ? (resp as any[]) : [];
|
||||
setGroups(rows.map((g) => ({ id: String(g.id), name: String(g.name ?? "") })));
|
||||
}, [convex, open, workspaceId]);
|
||||
|
||||
const loadGroupShares = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (!documentId) {
|
||||
setGroupShares([]);
|
||||
return;
|
||||
}
|
||||
const resp = await convex.query(api.documentGroupShares.listByDocument, { documentId });
|
||||
setGroupShares(Array.isArray(resp) ? resp : []);
|
||||
}, [convex, documentId, open]);
|
||||
|
||||
const loadGroupMembers = useCallback(
|
||||
async (groupId: string) => {
|
||||
if (!open) return;
|
||||
if (!groupId) {
|
||||
setGroupMembers([]);
|
||||
return;
|
||||
}
|
||||
const resp = await convex.query(api.groupMembers.listByGroup, { groupId });
|
||||
const rows = Array.isArray(resp) ? (resp as any[]) : [];
|
||||
setGroupMembers(
|
||||
rows.map((m) => ({
|
||||
userId: String(m.userId),
|
||||
username: m.username ? String(m.username) : null,
|
||||
role: String(m.role ?? ""),
|
||||
})),
|
||||
);
|
||||
},
|
||||
[convex, open],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername("");
|
||||
setPermission("read");
|
||||
setIncludeDescendants(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setShares(null);
|
||||
setSharesLoading(false);
|
||||
setGroups([]);
|
||||
setGroupShares(null);
|
||||
setSelectedGroupId("");
|
||||
setGroupIncludeDescendants(false);
|
||||
setGroupMembers([]);
|
||||
setGroupEditableUserIds(new Set());
|
||||
return;
|
||||
}
|
||||
void loadShares();
|
||||
void loadGroups();
|
||||
void loadGroupShares();
|
||||
}, [loadGroups, loadGroupShares, loadShares, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadGroupMembers(selectedGroupId);
|
||||
}, [loadGroupMembers, open, selectedGroupId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!selectedGroupId) return;
|
||||
const rows = Array.isArray(groupShares) ? groupShares : [];
|
||||
const existing = rows.find((r: any) => String(r.groupId) === String(selectedGroupId));
|
||||
if (existing) {
|
||||
setGroupIncludeDescendants(Boolean(existing.includeDescendants));
|
||||
const editable = new Set<string>(Array.isArray(existing.editableUserIds) ? existing.editableUserIds.map(String) : []);
|
||||
setGroupEditableUserIds(editable);
|
||||
} else {
|
||||
setGroupIncludeDescendants(false);
|
||||
setGroupEditableUserIds(new Set());
|
||||
}
|
||||
}, [groupShares, open, selectedGroupId]);
|
||||
|
||||
const shareRows = useMemo(() => {
|
||||
if (!Array.isArray(shares)) return [];
|
||||
return shares;
|
||||
}, [shares]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
const u = username.trim();
|
||||
if (!u) {
|
||||
setError("请输入对方用户名");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await upsertShare({
|
||||
documentId,
|
||||
username: u,
|
||||
permission,
|
||||
includeDescendants: allowIncludeDescendants ? includeDescendants : false,
|
||||
});
|
||||
setUsername("");
|
||||
await loadShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "共享失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (sharedWithUserId: string) => {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await removeShare({ documentId, sharedWithUserId });
|
||||
await loadShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "移除失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpsertGroupShare = async () => {
|
||||
setError(null);
|
||||
const groupId = selectedGroupId;
|
||||
if (!groupId) {
|
||||
setError("请选择群组");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await upsertGroupShare({
|
||||
documentId,
|
||||
groupId,
|
||||
includeDescendants: allowIncludeDescendants ? groupIncludeDescendants : false,
|
||||
editableUserIds: Array.from(groupEditableUserIds),
|
||||
});
|
||||
await loadGroupShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "公开失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveGroupShare = async (groupId: string) => {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await removeGroupShare({ documentId, groupId });
|
||||
await loadGroupShares();
|
||||
await onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "取消公开失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>共享:{documentTitle ?? "无标题"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">输入用户名进行共享</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="对方用户名"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Button onClick={() => void handleSubmit()} disabled={submitting}>
|
||||
{submitting ? "处理中..." : "共享/更新"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-700">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="permission"
|
||||
checked={permission === "read"}
|
||||
onChange={() => setPermission("read")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
只读
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="permission"
|
||||
checked={permission === "edit"}
|
||||
onChange={() => setPermission("edit")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
可编辑
|
||||
</label>
|
||||
{allowIncludeDescendants && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDescendants}
|
||||
onChange={(e) => setIncludeDescendants(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
包含子页面(共享文件夹)
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-3">
|
||||
<div className="text-sm font-medium text-gray-700">公开到群组(群组内所有人可见)</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm"
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="">请选择群组</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{allowIncludeDescendants && (
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupIncludeDescendants}
|
||||
onChange={(e) => setGroupIncludeDescendants(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
包含子页面(公开文件夹)
|
||||
</label>
|
||||
)}
|
||||
<Button onClick={() => void handleUpsertGroupShare()} disabled={submitting || !selectedGroupId}>
|
||||
{submitting ? "处理中..." : "公开/更新"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||
群组成员权限(默认只读,可单独设为可编辑)
|
||||
</div>
|
||||
<div className="max-h-44 overflow-auto p-2">
|
||||
{!selectedGroupId ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">请选择群组</div>
|
||||
) : groupMembers.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">暂无成员</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groupMembers.map((m) => {
|
||||
const checked = groupEditableUserIds.has(m.userId);
|
||||
return (
|
||||
<label
|
||||
key={m.userId}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-gray-900">
|
||||
{m.username ?? m.userId}
|
||||
<span className="ml-2 text-xs text-gray-400">{m.role === "owner" ? "群主" : "成员"}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<span>{checked ? "可编辑" : "只读"}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const next = new Set(groupEditableUserIds);
|
||||
if (e.target.checked) next.add(m.userId);
|
||||
else next.delete(m.userId);
|
||||
setGroupEditableUserIds(next);
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||
已公开到群组
|
||||
</div>
|
||||
<div className="max-h-44 overflow-auto p-2">
|
||||
{groupShares === null ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">加载中...</div>
|
||||
) : (groupShares ?? []).length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">暂无公开群组</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(groupShares ?? []).map((r: any) => (
|
||||
<div
|
||||
key={String(r.groupId)}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-gray-900">{r.groupName ?? r.groupId}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{r.includeDescendants ? "包含子页面" : "仅当前页面"}
|
||||
{" · "}
|
||||
可编辑:{Array.isArray(r.editableUserIds) ? r.editableUserIds.length : 0} 人
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={submitting}
|
||||
onClick={() => void handleRemoveGroupShare(String(r.groupId))}
|
||||
>
|
||||
取消公开
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||
已共享给
|
||||
</div>
|
||||
<div className="max-h-60 overflow-auto p-2">
|
||||
{sharesLoading && shares === null ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">加载中...</div>
|
||||
) : shareRows.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">暂无共享者</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{shareRows.map((row: any) => (
|
||||
<div
|
||||
key={row.userId}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-gray-900">
|
||||
{row.username ?? row.userId}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
权限:{row.permission === "edit" ? "可编辑" : "只读"}
|
||||
{row.includeDescendants ? " · 包含子页面" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={submitting}
|
||||
onClick={() => void handleRemove(String(row.userId))}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user