855 lines
30 KiB
TypeScript
855 lines
30 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useEffect, useRef, useState } from "react";
|
|||
|
|
import {
|
|||
|
|
ExternalLink,
|
|||
|
|
ImageIcon,
|
|||
|
|
Loader2,
|
|||
|
|
Pencil,
|
|||
|
|
Send,
|
|||
|
|
ShieldCheck,
|
|||
|
|
Sparkles,
|
|||
|
|
Upload,
|
|||
|
|
Wand2,
|
|||
|
|
X,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import { Badge } from "@/components/ui/badge";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|||
|
|
import { Textarea } from "@/components/ui/textarea";
|
|||
|
|
import { notify } from "@/lib/notifications/store";
|
|||
|
|
import { useCapabilities } from "@/lib/capabilities/context";
|
|||
|
|
import { MAX_DRAFT_IMAGES } from "@/lib/drafts/constants";
|
|||
|
|
import { THREADS_MAX_CHARS, cn } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
export interface DraftData {
|
|||
|
|
id: string;
|
|||
|
|
text: string;
|
|||
|
|
angle?: string | null;
|
|||
|
|
hook?: string | null;
|
|||
|
|
imageBrief?: string | null;
|
|||
|
|
imagePath?: string | null;
|
|||
|
|
imagePaths?: string | null;
|
|||
|
|
draftType?: string | null;
|
|||
|
|
rationale?: string | null;
|
|||
|
|
sources?: string | null;
|
|||
|
|
status: string;
|
|||
|
|
factCheckResult?: string | null;
|
|||
|
|
createdAt: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface DraftCardProps {
|
|||
|
|
draft: DraftData;
|
|||
|
|
onUpdate: () => void;
|
|||
|
|
index?: number;
|
|||
|
|
selectable?: boolean;
|
|||
|
|
selected?: boolean;
|
|||
|
|
onSelectedChange?: (selected: boolean) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type OptimizeMode = "polish" | "hook" | "shorter" | "engaging" | "custom";
|
|||
|
|
|
|||
|
|
const OPTIMIZE_MODES: { value: OptimizeMode; label: string }[] = [
|
|||
|
|
{ value: "polish", label: "整體潤飾" },
|
|||
|
|
{ value: "hook", label: "強化開頭" },
|
|||
|
|
{ value: "shorter", label: "精簡篇幅" },
|
|||
|
|
{ value: "engaging", label: "提升互動" },
|
|||
|
|
{ value: "custom", label: "自訂指令" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const statusLabels: Record<string, { label: string; variant: "warning" | "secondary" | "success" }> = {
|
|||
|
|
PENDING: { label: "待審核", variant: "warning" },
|
|||
|
|
EDITED: { label: "已編輯", variant: "secondary" },
|
|||
|
|
APPROVED: { label: "已核准", variant: "success" },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
interface FactCheckData {
|
|||
|
|
isKnowledgeContent: boolean;
|
|||
|
|
passed: boolean;
|
|||
|
|
summary: string;
|
|||
|
|
issues: string[];
|
|||
|
|
verifiedPoints: string[];
|
|||
|
|
sources: Array<{ title: string; link: string }>;
|
|||
|
|
searchProviderLabel?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseFactCheckResult(raw: string | null | undefined): FactCheckData | null {
|
|||
|
|
if (!raw) return null;
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(raw) as unknown;
|
|||
|
|
if (parsed && typeof parsed === "object" && "isKnowledgeContent" in parsed) {
|
|||
|
|
return parsed as FactCheckData;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseDraftImagePaths(source: Pick<DraftData, "imagePath" | "imagePaths">): string[] {
|
|||
|
|
if (source.imagePaths) {
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(source.imagePaths) as unknown;
|
|||
|
|
if (Array.isArray(parsed)) {
|
|||
|
|
return parsed.filter((item): item is string => typeof item === "string");
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// Fall back to the legacy single-image field.
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return source.imagePath ? [source.imagePath] : [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function DraftCard({
|
|||
|
|
draft,
|
|||
|
|
onUpdate,
|
|||
|
|
index = 0,
|
|||
|
|
selectable = false,
|
|||
|
|
selected = false,
|
|||
|
|
onSelectedChange,
|
|||
|
|
}: DraftCardProps) {
|
|||
|
|
const [editing, setEditing] = useState(false);
|
|||
|
|
const [text, setText] = useState(draft.text);
|
|||
|
|
const [loading, setLoading] = useState<string | null>(null);
|
|||
|
|
const [optimizing, setOptimizing] = useState(false);
|
|||
|
|
const [showOptimize, setShowOptimize] = useState(false);
|
|||
|
|
const [optimizeMode, setOptimizeMode] = useState<OptimizeMode>("polish");
|
|||
|
|
const [customInstruction, setCustomInstruction] = useState("");
|
|||
|
|
const [optimizeSummary, setOptimizeSummary] = useState<string | null>(null);
|
|||
|
|
const [previousText, setPreviousText] = useState<string | null>(null);
|
|||
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|||
|
|
const { isReady } = useCapabilities();
|
|||
|
|
const canPublish = isReady("publish");
|
|||
|
|
|
|||
|
|
const [imagePaths, setImagePaths] = useState<string[]>(() => parseDraftImagePaths(draft));
|
|||
|
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|||
|
|
title: string;
|
|||
|
|
description?: string;
|
|||
|
|
confirmText?: string;
|
|||
|
|
danger?: boolean;
|
|||
|
|
onConfirm: () => void | Promise<void>;
|
|||
|
|
} | null>(null);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
setImagePaths(
|
|||
|
|
parseDraftImagePaths({ imagePath: draft.imagePath, imagePaths: draft.imagePaths })
|
|||
|
|
);
|
|||
|
|
}, [draft.imagePath, draft.imagePaths]);
|
|||
|
|
const [factCheck, setFactCheck] = useState<FactCheckData | null>(() =>
|
|||
|
|
parseFactCheckResult(draft.factCheckResult)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
setFactCheck(parseFactCheckResult(draft.factCheckResult));
|
|||
|
|
}, [draft.factCheckResult]);
|
|||
|
|
|
|||
|
|
const sources: string[] = (() => {
|
|||
|
|
if (!draft.sources) return [];
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(draft.sources);
|
|||
|
|
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
const charCount = text.length;
|
|||
|
|
const overLimit = charCount > THREADS_MAX_CHARS;
|
|||
|
|
const statusInfo = statusLabels[draft.status] ?? { label: draft.status, variant: "secondary" as const };
|
|||
|
|
const angleInitial = (draft.angle ?? "稿").charAt(0);
|
|||
|
|
function imagePreviewUrl(imagePath: string) {
|
|||
|
|
return `/api/drafts/${draft.id}/image?p=${encodeURIComponent(imagePath)}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleUploadImages(files: File[]) {
|
|||
|
|
if (files.length === 0) return;
|
|||
|
|
if (imagePaths.length + files.length > MAX_DRAFT_IMAGES) {
|
|||
|
|
notify({
|
|||
|
|
type: "error",
|
|||
|
|
title: "超過配圖上限",
|
|||
|
|
message: `每篇草稿最多 ${MAX_DRAFT_IMAGES} 張配圖`,
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setLoading("upload-image");
|
|||
|
|
const formData = new FormData();
|
|||
|
|
for (const file of files) formData.append("file", file);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/drafts/${draft.id}/image`, {
|
|||
|
|
method: "POST",
|
|||
|
|
body: formData,
|
|||
|
|
});
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
notify({ type: "error", title: "上傳失敗", message: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setImagePaths(data.imagePaths ?? parseDraftImagePaths(data.draft ?? draft));
|
|||
|
|
notify({
|
|||
|
|
type: "success",
|
|||
|
|
title: "配圖已上傳",
|
|||
|
|
message: files.length > 1 ? `已新增 ${files.length} 張圖片` : undefined,
|
|||
|
|
});
|
|||
|
|
onUpdate();
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "上傳失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleGenerateImage() {
|
|||
|
|
setLoading("gen-image");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/drafts/${draft.id}/generate-image`, {
|
|||
|
|
method: "POST",
|
|||
|
|
});
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
notify({ type: "error", title: "AI 生圖失敗", message: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setImagePaths(data.imagePaths ?? parseDraftImagePaths(data.draft ?? draft));
|
|||
|
|
notify({ type: "success", title: "AI 配圖已生成" });
|
|||
|
|
onUpdate();
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "AI 生圖失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleRemoveImage(imagePath: string) {
|
|||
|
|
setLoading("remove-image");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(
|
|||
|
|
`/api/drafts/${draft.id}/image?p=${encodeURIComponent(imagePath)}`,
|
|||
|
|
{ method: "DELETE" }
|
|||
|
|
);
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
notify({ type: "error", title: "移除配圖失敗", message: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setImagePaths(data.imagePaths ?? []);
|
|||
|
|
onUpdate();
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "移除配圖失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleClearImages() {
|
|||
|
|
setConfirmDialog({
|
|||
|
|
title: "移除全部配圖",
|
|||
|
|
description: "確定要移除這篇草稿的全部配圖?",
|
|||
|
|
confirmText: "移除",
|
|||
|
|
danger: true,
|
|||
|
|
onConfirm: async () => {
|
|||
|
|
setLoading("remove-image");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/drafts/${draft.id}/image`, { method: "DELETE" });
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
notify({ type: "error", title: "移除配圖失敗", message: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setImagePaths([]);
|
|||
|
|
onUpdate();
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "移除配圖失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(null);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleSave() {
|
|||
|
|
setLoading("save");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/drafts/${draft.id}`, {
|
|||
|
|
method: "PATCH",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ text, status: "EDITED" }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
notify({ type: "error", title: "儲存失敗", message: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setEditing(false);
|
|||
|
|
setOptimizeSummary(null);
|
|||
|
|
setPreviousText(null);
|
|||
|
|
onUpdate();
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "儲存失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleOptimize(apply = false) {
|
|||
|
|
setOptimizing(true);
|
|||
|
|
setOptimizeSummary(null);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/optimize", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
draftId: draft.id,
|
|||
|
|
text,
|
|||
|
|
mode: optimizeMode,
|
|||
|
|
instruction: optimizeMode === "custom" ? customInstruction : undefined,
|
|||
|
|
save: apply,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
notify({ type: "error", title: "AI 優化失敗", message: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (apply) {
|
|||
|
|
setText(data.text);
|
|||
|
|
setEditing(false);
|
|||
|
|
setShowOptimize(false);
|
|||
|
|
setPreviousText(null);
|
|||
|
|
setOptimizeSummary(data.summary);
|
|||
|
|
onUpdate();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setPreviousText(text);
|
|||
|
|
setText(data.text);
|
|||
|
|
setEditing(true);
|
|||
|
|
setShowOptimize(false);
|
|||
|
|
setOptimizeSummary(data.summary);
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "AI 優化失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setOptimizing(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleUndoOptimize() {
|
|||
|
|
if (previousText) {
|
|||
|
|
setText(previousText);
|
|||
|
|
setPreviousText(null);
|
|||
|
|
setOptimizeSummary(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleFactCheck() {
|
|||
|
|
setLoading("factcheck");
|
|||
|
|
setFactCheck(null);
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/fact-check", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ draftId: draft.id, text }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
notify({ type: "error", title: "查證失敗", message: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setFactCheck(data.factCheck);
|
|||
|
|
const fc = data.factCheck;
|
|||
|
|
if (fc.isKnowledgeContent) {
|
|||
|
|
notify({
|
|||
|
|
type: fc.passed ? "success" : "warning",
|
|||
|
|
title: fc.passed ? "知識查證通過" : "知識查證未通過",
|
|||
|
|
message: fc.summary,
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
notify({ type: "info", title: "非知識型內容", message: fc.summary });
|
|||
|
|
}
|
|||
|
|
onUpdate();
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "查證失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handlePublish() {
|
|||
|
|
setConfirmDialog({
|
|||
|
|
title: "發布到 Threads",
|
|||
|
|
description: "確定要發布這篇貼文到 Threads?知識型內容會先經網路搜尋查證。",
|
|||
|
|
confirmText: "發布",
|
|||
|
|
onConfirm: async () => {
|
|||
|
|
setLoading("publish");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/publish", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ draftId: draft.id }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
if (data.factCheck) setFactCheck(data.factCheck);
|
|||
|
|
notify({
|
|||
|
|
type: "error",
|
|||
|
|
title: res.status === 422 ? "知識型內容未通過查證" : "發布失敗",
|
|||
|
|
message: data.factCheck?.issues?.join(";") ?? data.error,
|
|||
|
|
href: data.debugRunId ? `/debug` : undefined,
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (data.factCheck) setFactCheck(data.factCheck);
|
|||
|
|
notify({
|
|||
|
|
type: "success",
|
|||
|
|
title: "已發布到你的 Threads",
|
|||
|
|
message: [
|
|||
|
|
data.method === "api" ? "已透過官方 API 發布。" : "貼文已送出。",
|
|||
|
|
data.warning,
|
|||
|
|
"草稿已移至已發布。",
|
|||
|
|
]
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.join(" "),
|
|||
|
|
href: "/published",
|
|||
|
|
});
|
|||
|
|
onUpdate();
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "發布失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(null);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleReject() {
|
|||
|
|
setConfirmDialog({
|
|||
|
|
title: "拒絕草稿",
|
|||
|
|
description: "確定要拒絕這篇草稿?此操作無法復原。",
|
|||
|
|
confirmText: "拒絕",
|
|||
|
|
danger: true,
|
|||
|
|
onConfirm: async () => {
|
|||
|
|
setLoading("reject");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/drafts/${draft.id}`, { method: "DELETE" });
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
notify({ type: "error", title: "拒絕失敗", message: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
onUpdate();
|
|||
|
|
} catch {
|
|||
|
|
notify({ type: "error", title: "拒絕失敗", message: "網路連線異常,請稍後再試" });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(null);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<article
|
|||
|
|
className="animate-fade-in-up py-7 first:pt-0"
|
|||
|
|
style={{ animationDelay: `${index * 40}ms` }}
|
|||
|
|
>
|
|||
|
|
<div className="flex gap-3">
|
|||
|
|
{selectable && (
|
|||
|
|
<label className="mt-2 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
className="h-4 w-4 rounded border-border accent-primary"
|
|||
|
|
checked={selected}
|
|||
|
|
onChange={(event) => onSelectedChange?.(event.target.checked)}
|
|||
|
|
aria-label={`選取 ${draft.angle ?? "草稿"}`}
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
)}
|
|||
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-muted-foreground">
|
|||
|
|
{angleInitial}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="min-w-0 flex-1">
|
|||
|
|
<div className="flex items-start justify-between gap-3">
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
|||
|
|
<span className="text-[15px] font-semibold">{draft.angle ?? "草稿"}</span>
|
|||
|
|
<span className="text-muted-foreground">·</span>
|
|||
|
|
<span className="text-[13px] text-muted-foreground">
|
|||
|
|
{new Date(draft.createdAt).toLocaleDateString("zh-TW", {
|
|||
|
|
month: "short",
|
|||
|
|
day: "numeric",
|
|||
|
|
})}
|
|||
|
|
</span>
|
|||
|
|
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
|||
|
|
{draft.draftType === "viral-replica" && (
|
|||
|
|
<Badge variant="outline">爆款複製</Badge>
|
|||
|
|
)}
|
|||
|
|
{factCheck && (factCheck.passed || !factCheck.isKnowledgeContent) && (
|
|||
|
|
<span
|
|||
|
|
className="relative inline-flex items-center gap-1 rounded-md border border-success-border bg-success-bg px-2 py-0.5 text-[11px] font-medium text-success"
|
|||
|
|
title={
|
|||
|
|
factCheck.isKnowledgeContent
|
|||
|
|
? `已查證通過${factCheck.searchProviderLabel ? `(${factCheck.searchProviderLabel})` : ""}\n${factCheck.summary}${factCheck.verifiedPoints.length > 0 ? `\n\n查證重點:\n${factCheck.verifiedPoints.map((p) => `• ${p}`).join("\n")}` : ""}`
|
|||
|
|
: "非知識型內容,無需查證"
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
<ShieldCheck className="h-3 w-3" />
|
|||
|
|
已查證
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
{factCheck && factCheck.isKnowledgeContent && !factCheck.passed && (
|
|||
|
|
<span
|
|||
|
|
className="relative inline-flex items-center gap-1 rounded-md border border-warning-border bg-warning-bg px-2 py-0.5 text-[11px] font-medium text-warning"
|
|||
|
|
title={`查證未通過\n${factCheck.issues.join(";")}`}
|
|||
|
|
>
|
|||
|
|
<ShieldCheck className="h-3 w-3" />
|
|||
|
|
查證未過
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{draft.rationale && (
|
|||
|
|
<p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">
|
|||
|
|
{draft.rationale}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{showOptimize && (
|
|||
|
|
<div className="mt-3 space-y-3 rounded-lg border border-border bg-muted p-3.5">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Wand2 className="h-4 w-4" />
|
|||
|
|
<p className="text-sm font-semibold">AI 優化</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>優化方向</Label>
|
|||
|
|
<Select value={optimizeMode} onValueChange={(v) => setOptimizeMode(v as OptimizeMode)}>
|
|||
|
|
<SelectTrigger>
|
|||
|
|
<SelectValue />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
{OPTIMIZE_MODES.map((m) => (
|
|||
|
|
<SelectItem key={m.value} value={m.value}>
|
|||
|
|
{m.label}
|
|||
|
|
</SelectItem>
|
|||
|
|
))}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{optimizeMode === "custom" && (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label>自訂指令</Label>
|
|||
|
|
<Input
|
|||
|
|
value={customInstruction}
|
|||
|
|
onChange={(e) => setCustomInstruction(e.target.value)}
|
|||
|
|
placeholder="例:語氣更口語、加入一個反問句"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
<Button size="sm" onClick={() => handleOptimize(false)} disabled={optimizing}>
|
|||
|
|
<Wand2 className="h-3.5 w-3.5" />
|
|||
|
|
{optimizing ? "優化中…" : "預覽優化"}
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="secondary"
|
|||
|
|
onClick={() => handleOptimize(true)}
|
|||
|
|
disabled={optimizing}
|
|||
|
|
>
|
|||
|
|
套用並儲存
|
|||
|
|
</Button>
|
|||
|
|
<Button size="sm" variant="ghost" onClick={() => setShowOptimize(false)}>
|
|||
|
|
取消
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{optimizeSummary && (
|
|||
|
|
<div className="mt-3 rounded-lg border border-border bg-muted px-3.5 py-2.5">
|
|||
|
|
<p className="text-[12px] font-medium text-muted-foreground">AI 調整說明</p>
|
|||
|
|
<p className="mt-1 text-[14px] leading-relaxed">{optimizeSummary}</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{factCheck && (
|
|||
|
|
<div
|
|||
|
|
className={cn(
|
|||
|
|
"mt-3 rounded-lg border px-3.5 py-2.5 text-[13px]",
|
|||
|
|
factCheck.passed || !factCheck.isKnowledgeContent
|
|||
|
|
? "border-border bg-muted"
|
|||
|
|
: "border-warning-border bg-warning-bg text-warning"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<p className="font-medium">
|
|||
|
|
{factCheck.isKnowledgeContent
|
|||
|
|
? factCheck.passed
|
|||
|
|
? "知識查證通過"
|
|||
|
|
: "知識查證未通過"
|
|||
|
|
: "非知識型,可直接發布"}
|
|||
|
|
{factCheck.searchProviderLabel && ` · ${factCheck.searchProviderLabel}`}
|
|||
|
|
</p>
|
|||
|
|
<p className="mt-1 leading-relaxed text-muted-foreground">{factCheck.summary}</p>
|
|||
|
|
{factCheck.issues.length > 0 && (
|
|||
|
|
<ul className="mt-2 list-inside list-disc text-warning">
|
|||
|
|
{factCheck.issues.map((issue) => (
|
|||
|
|
<li key={issue}>{issue}</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
)}
|
|||
|
|
{factCheck.sources.length > 0 && (
|
|||
|
|
<div className="mt-2 space-y-1">
|
|||
|
|
{factCheck.sources.map((s) => (
|
|||
|
|
<a
|
|||
|
|
key={s.link}
|
|||
|
|
href={s.link}
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noopener noreferrer"
|
|||
|
|
className="block truncate text-[12px] underline"
|
|||
|
|
>
|
|||
|
|
{s.title}
|
|||
|
|
</a>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{draft.hook && !editing && (
|
|||
|
|
<p className="mt-2 text-[14px] font-medium leading-snug">{draft.hook}</p>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="mt-2">
|
|||
|
|
{editing ? (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={6} />
|
|||
|
|
<p
|
|||
|
|
className={cn(
|
|||
|
|
"font-mono text-xs",
|
|||
|
|
overLimit ? "text-destructive" : "text-muted-foreground"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{charCount} / {THREADS_MAX_CHARS}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<p className="font-readable whitespace-pre-wrap">{text}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="mt-3 rounded-lg border border-border bg-muted/50 p-3.5">
|
|||
|
|
<div className="mb-2.5 flex flex-wrap items-center justify-between gap-2">
|
|||
|
|
<p className="flex items-center gap-1.5 text-[13px] font-semibold">
|
|||
|
|
<ImageIcon className="h-3.5 w-3.5" />
|
|||
|
|
配圖
|
|||
|
|
</p>
|
|||
|
|
<div className="flex flex-wrap gap-1.5">
|
|||
|
|
<input
|
|||
|
|
ref={fileInputRef}
|
|||
|
|
type="file"
|
|||
|
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
|||
|
|
multiple
|
|||
|
|
className="hidden"
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const files = Array.from(e.target.files ?? []);
|
|||
|
|
if (files.length > 0) void handleUploadImages(files);
|
|||
|
|
e.target.value = "";
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => fileInputRef.current?.click()}
|
|||
|
|
disabled={!!loading || optimizing}
|
|||
|
|
>
|
|||
|
|
{loading === "upload-image" ? (
|
|||
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|||
|
|
) : (
|
|||
|
|
<Upload className="h-3.5 w-3.5" />
|
|||
|
|
)}
|
|||
|
|
上傳圖片
|
|||
|
|
</Button>
|
|||
|
|
{imagePaths.length > 0 && (
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
className="text-destructive hover:text-destructive"
|
|||
|
|
onClick={handleClearImages}
|
|||
|
|
disabled={!!loading || optimizing}
|
|||
|
|
>
|
|||
|
|
全部移除
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={handleGenerateImage}
|
|||
|
|
disabled={!!loading || optimizing}
|
|||
|
|
>
|
|||
|
|
{loading === "gen-image" ? (
|
|||
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|||
|
|
) : (
|
|||
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|||
|
|
)}
|
|||
|
|
AI 生圖
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{imagePaths.length > 0 ? (
|
|||
|
|
<div
|
|||
|
|
className={cn(
|
|||
|
|
"grid gap-2.5",
|
|||
|
|
imagePaths.length > 1 ? "sm:grid-cols-2" : "grid-cols-1"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{imagePaths.map((imagePath, index) => (
|
|||
|
|
<div
|
|||
|
|
key={imagePath}
|
|||
|
|
className="overflow-hidden rounded-lg border border-border bg-background"
|
|||
|
|
>
|
|||
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|||
|
|
<img
|
|||
|
|
src={imagePreviewUrl(imagePath)}
|
|||
|
|
alt={`草稿配圖 ${index + 1}`}
|
|||
|
|
className="max-h-80 w-full object-contain"
|
|||
|
|
/>
|
|||
|
|
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
|||
|
|
<span className="text-[12px] text-muted-foreground">圖片 {index + 1}</span>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
className="h-7 text-destructive hover:text-destructive"
|
|||
|
|
onClick={() => handleRemoveImage(imagePath)}
|
|||
|
|
disabled={!!loading || optimizing}
|
|||
|
|
>
|
|||
|
|
移除
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<p className="text-[13px] text-muted-foreground">
|
|||
|
|
可一次選多張圖片上傳(最多 {MAX_DRAFT_IMAGES} 張),或請 AI 生成。發布時會一併附上。
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{draft.imageBrief && (
|
|||
|
|
<div className="mt-2.5 rounded-md bg-background px-3 py-2">
|
|||
|
|
<p className="text-[11px] font-medium text-muted-foreground">配圖製作說明</p>
|
|||
|
|
<p className="mt-1 whitespace-pre-wrap text-[13px] leading-relaxed">
|
|||
|
|
{draft.imageBrief}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{sources.length > 0 && (
|
|||
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|||
|
|
{sources.map((url) => (
|
|||
|
|
<a
|
|||
|
|
key={url}
|
|||
|
|
href={url}
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noopener noreferrer"
|
|||
|
|
className="inline-flex items-center gap-1 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
|
|||
|
|
>
|
|||
|
|
<ExternalLink className="h-3 w-3" />
|
|||
|
|
參考來源
|
|||
|
|
</a>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|||
|
|
{editing ? (
|
|||
|
|
<>
|
|||
|
|
<Button size="sm" onClick={handleSave} disabled={!!loading || overLimit || optimizing}>
|
|||
|
|
儲存
|
|||
|
|
</Button>
|
|||
|
|
{previousText && (
|
|||
|
|
<Button size="sm" variant="outline" onClick={handleUndoOptimize} disabled={optimizing}>
|
|||
|
|
還原優化前
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
onClick={() => {
|
|||
|
|
setEditing(false);
|
|||
|
|
setText(draft.text);
|
|||
|
|
setPreviousText(null);
|
|||
|
|
setOptimizeSummary(null);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
取消
|
|||
|
|
</Button>
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
|
|||
|
|
<Pencil className="h-3.5 w-3.5" />
|
|||
|
|
編輯
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
onClick={() => setShowOptimize((v) => !v)}
|
|||
|
|
disabled={!!loading || optimizing}
|
|||
|
|
>
|
|||
|
|
<Wand2 className="h-3.5 w-3.5" />
|
|||
|
|
AI 優化
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={handleFactCheck}
|
|||
|
|
disabled={!!loading || overLimit || optimizing}
|
|||
|
|
>
|
|||
|
|
<ShieldCheck className="h-3.5 w-3.5" />
|
|||
|
|
{loading === "factcheck" ? "查證中…" : "知識查證"}
|
|||
|
|
</Button>
|
|||
|
|
<Button size="sm" onClick={handlePublish} disabled={!!loading || overLimit || optimizing || !canPublish} title={!canPublish ? "需先在連線設定綁定 Threads 或同步瀏覽器 Session" : undefined}>
|
|||
|
|
<Send className="h-3.5 w-3.5" />
|
|||
|
|
{loading === "publish" ? "發布中…" : "發布"}
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
className="text-destructive hover:text-destructive"
|
|||
|
|
onClick={handleReject}
|
|||
|
|
disabled={!!loading || optimizing}
|
|||
|
|
>
|
|||
|
|
<X className="h-3.5 w-3.5" />
|
|||
|
|
拒絕
|
|||
|
|
</Button>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<ConfirmDialog
|
|||
|
|
open={confirmDialog !== null}
|
|||
|
|
onOpenChange={(open) => !open && setConfirmDialog(null)}
|
|||
|
|
title={confirmDialog?.title ?? ""}
|
|||
|
|
description={confirmDialog?.description}
|
|||
|
|
confirmText={confirmDialog?.confirmText}
|
|||
|
|
danger={confirmDialog?.danger}
|
|||
|
|
onConfirm={confirmDialog?.onConfirm ?? (() => {})}
|
|||
|
|
/>
|
|||
|
|
</article>
|
|||
|
|
);
|
|||
|
|
}
|