定时任务: - 管理员可查看/编辑/删除/触发全部用户的定时任务(新增 /api/scheduled-tasks/admin/* 接口与管理页) - 卡死任务恢复:单次执行超 30 分钟超时重试(最多 3 次),耗尽后标记 failed;看门狗回收服务重启遗留的「执行中」任务 会话分享: - 新增「只读链接」分享模式,生成公开免登录的只读对话快照页(/share/:code) - 公开接口 /api/public/shares/* 提供对话快照与图片附件 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
329 lines
9.9 KiB
TypeScript
329 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
CheckIcon,
|
|
CopyIcon,
|
|
DownloadIcon,
|
|
ExternalLinkIcon,
|
|
Loader2,
|
|
Trash2Icon,
|
|
} from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import type {
|
|
SharePreview,
|
|
ThreadShare,
|
|
ThreadShareMode,
|
|
} from "@/core/thread-shares/api";
|
|
import { buildPublicShareUrl, extractShareCode } from "@/core/thread-shares/api";
|
|
import {
|
|
useCreateShare,
|
|
useImportShare,
|
|
usePreviewShare,
|
|
useRevokeShare,
|
|
} from "@/core/thread-shares/hooks";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
function errorMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
/**
|
|
* Dialog that produces a share link for a single conversation. Two modes:
|
|
*
|
|
* - `import` — mints a short code; a recipient enters it in the import dialog
|
|
* to copy the conversation into their own account.
|
|
* - `view` — mints a public, read-only link that anyone can open without
|
|
* logging in (a static page; viewers cannot send new messages).
|
|
*/
|
|
export function ShareConversationDialog({
|
|
threadId,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
threadId: string | null;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const tx = t.threadShare;
|
|
const createShare = useCreateShare();
|
|
const revokeShare = useRevokeShare();
|
|
const [mode, setMode] = useState<ThreadShareMode>("view");
|
|
const [share, setShare] = useState<ThreadShare | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Reset to the default mode (read-only link) every time the dialog opens.
|
|
useEffect(() => {
|
|
if (open) setMode("view");
|
|
}, [open, threadId]);
|
|
|
|
// Mint / reuse the link whenever the dialog opens or the mode changes.
|
|
useEffect(() => {
|
|
if (!open || !threadId) return;
|
|
setShare(null);
|
|
setCopied(false);
|
|
createShare
|
|
.mutateAsync({ threadId, mode })
|
|
.then(setShare)
|
|
.catch((err) => toast.error(errorMessage(err)));
|
|
// createShare is stable enough; re-running only on open/threadId/mode is intended.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, threadId, mode]);
|
|
|
|
const isView = mode === "view";
|
|
const publicUrl =
|
|
isView && share ? buildPublicShareUrl(share.share_code) : "";
|
|
|
|
async function handleCopy() {
|
|
if (!share) return;
|
|
const text = isView ? publicUrl : share.share_code;
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
setCopied(true);
|
|
toast.success(isView ? "链接已复制" : tx.codeCopied);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
} catch {
|
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
|
}
|
|
}
|
|
|
|
async function handleRevoke() {
|
|
if (!share || !window.confirm(tx.revokeConfirm)) return;
|
|
try {
|
|
await revokeShare.mutateAsync(share.share_code);
|
|
toast.success(tx.revokeSuccess);
|
|
onOpenChange(false);
|
|
} catch (err) {
|
|
toast.error(errorMessage(err));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{tx.shareTitle}</DialogTitle>
|
|
<DialogDescription>
|
|
{isView
|
|
? "生成一个只读链接,任何人无需登录都能查看这个对话,但不能继续提问。"
|
|
: tx.shareDescription}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Mode selector */}
|
|
<div className="bg-muted flex gap-1 rounded-lg p-1">
|
|
{(
|
|
[
|
|
{ value: "view" as const, label: "只读链接" },
|
|
{ value: "import" as const, label: "导入副本" },
|
|
]
|
|
).map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => setMode(option.value)}
|
|
className={cn(
|
|
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
|
mode === option.value
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{!share ? (
|
|
<div className="text-muted-foreground flex items-center gap-2 py-8 text-sm">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
{tx.generating}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-muted-foreground mb-1 text-xs">
|
|
{isView ? "只读链接" : tx.shareCodeLabel}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
{isView ? (
|
|
<Input
|
|
readOnly
|
|
value={publicUrl}
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
className="flex-1 font-mono text-xs"
|
|
/>
|
|
) : (
|
|
<code className="bg-muted flex-1 rounded-md px-3 py-2 text-center font-mono text-lg tracking-[0.3em]">
|
|
{share.share_code}
|
|
</code>
|
|
)}
|
|
<Button variant="outline" onClick={() => void handleCopy()}>
|
|
{copied ? (
|
|
<CheckIcon className="size-4" />
|
|
) : (
|
|
<CopyIcon className="size-4" />
|
|
)}
|
|
{isView ? "复制链接" : tx.copyCode}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{isView ? (
|
|
<div className="space-y-3">
|
|
<p className="text-muted-foreground text-xs">
|
|
拿到链接的人都能查看这个对话的全部内容(只读),无法继续发送问题。撤销后链接立即失效。
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => window.open(publicUrl, "_blank", "noopener")}
|
|
>
|
|
<ExternalLinkIcon className="size-3.5" />
|
|
在新标签页打开
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<p className="text-muted-foreground text-xs">{tx.shareHint}</p>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
disabled={revokeShare.isPending}
|
|
onClick={() => void handleRevoke()}
|
|
>
|
|
<Trash2Icon className="size-3.5" />
|
|
{tx.revoke}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/** Dialog where a user pastes a share code to import someone's conversation. */
|
|
export function ImportConversationsDialog({
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const tx = t.threadShare;
|
|
const [input, setInput] = useState("");
|
|
const [preview, setPreview] = useState<SharePreview | null>(null);
|
|
const previewShare = usePreviewShare();
|
|
const importShare = useImportShare();
|
|
|
|
// Reset state whenever the dialog closes.
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setInput("");
|
|
setPreview(null);
|
|
}
|
|
}, [open]);
|
|
|
|
async function handlePreview() {
|
|
const code = extractShareCode(input);
|
|
if (!code) {
|
|
toast.error(tx.emptyCode);
|
|
return;
|
|
}
|
|
try {
|
|
setPreview(await previewShare.mutateAsync(code));
|
|
} catch (err) {
|
|
setPreview(null);
|
|
toast.error(errorMessage(err));
|
|
}
|
|
}
|
|
|
|
async function handleImport() {
|
|
const code = preview?.share_code ?? extractShareCode(input);
|
|
if (!code) {
|
|
toast.error(tx.emptyCode);
|
|
return;
|
|
}
|
|
try {
|
|
const result = await importShare.mutateAsync(code);
|
|
if (result.imported > 0) {
|
|
toast.success(tx.importSuccess);
|
|
} else {
|
|
toast.info(tx.alreadyImported);
|
|
}
|
|
onOpenChange(false);
|
|
} catch (err) {
|
|
toast.error(errorMessage(err));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{tx.importTitle}</DialogTitle>
|
|
<DialogDescription>{tx.importDescription}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={input}
|
|
onChange={(e) => {
|
|
setInput(e.target.value);
|
|
setPreview(null);
|
|
}}
|
|
placeholder={tx.codeInputPlaceholder}
|
|
className="font-mono"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => void handlePreview()}
|
|
disabled={previewShare.isPending || !input.trim()}
|
|
>
|
|
{previewShare.isPending && (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
)}
|
|
{tx.preview}
|
|
</Button>
|
|
</div>
|
|
|
|
{preview && (
|
|
<div className="space-y-2 rounded-lg border p-3 text-sm">
|
|
<p className="font-medium">
|
|
{tx.previewConversation.replace("{title}", preview.title)}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{tx.previewFrom.replace("{user}", preview.shared_by)}
|
|
</p>
|
|
<Button
|
|
className="w-full"
|
|
onClick={() => void handleImport()}
|
|
disabled={importShare.isPending}
|
|
>
|
|
{importShare.isPending ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<DownloadIcon className="size-4" />
|
|
)}
|
|
{importShare.isPending ? tx.importing : tx.doImport}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|