0.3.4 小图标功能增加

This commit is contained in:
liaibo
2026-01-22 18:53:20 +08:00
parent 71de56850b
commit 25923f308c
25 changed files with 2781 additions and 141 deletions
@@ -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>
);
}