geo/frontend/components/brand/BrandFormDialog.tsx

351 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/lib/api";
import { PLATFORMS } from "@/lib/platforms";
import {
FREQUENCY_OPTIONS,
INDUSTRY_OPTIONS,
type CreateBrandRequest,
type UpdateBrandRequest,
} from "@/types/brand";
import { Plus, Loader2 } from "lucide-react";
interface BrandFormDialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void;
editBrand?: {
id: string;
name: string;
aliases: string[];
website: string | null;
industry: string | null;
platforms: string[];
frequency: "daily" | "weekly" | "monthly";
};
trigger?: React.ReactNode;
}
export function BrandFormDialog({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
onSuccess,
editBrand,
trigger,
}: BrandFormDialogProps) {
const { data: session } = useSession();
const [internalOpen, setInternalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 表单状态
const [name, setName] = useState(editBrand?.name || "");
const [aliases, setAliases] = useState(editBrand?.aliases?.join(", ") || "");
const [website, setWebsite] = useState(editBrand?.website || "");
const [industry, setIndustry] = useState(editBrand?.industry || "");
const [platforms, setPlatforms] = useState<string[]>(
editBrand?.platforms || [],
);
const [frequency, setFrequency] = useState<"daily" | "weekly" | "monthly">(
editBrand?.frequency || "weekly",
);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : internalOpen;
const setIsOpen = isControlled ? controlledOnOpenChange : setInternalOpen;
const isEditing = !!editBrand;
const resetForm = () => {
setName("");
setAliases("");
setWebsite("");
setIndustry("");
setPlatforms([]);
setFrequency("weekly");
setError(null);
};
const handleOpenChange = (open: boolean) => {
if (open && editBrand) {
setName(editBrand.name);
setAliases(editBrand.aliases?.join(", ") || "");
setWebsite(editBrand.website || "");
setIndustry(editBrand.industry || "");
setPlatforms(editBrand.platforms);
setFrequency(editBrand.frequency);
}
setIsOpen?.(open);
if (!open) {
resetForm();
}
};
const togglePlatform = (platformKey: string) => {
setPlatforms((prev) =>
prev.includes(platformKey)
? prev.filter((p) => p !== platformKey)
: [...prev, platformKey],
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("请输入品牌名称");
return;
}
if (name.trim().length < 2 || name.trim().length > 50) {
setError("品牌名称长度必须在2-50字符之间");
return;
}
if (platforms.length === 0) {
setError("请至少选择一个监控平台");
return;
}
if (platforms.length > 7) {
setError("最多只能选择7个监控平台");
return;
}
const aliasList = aliases
.split(",")
.map((a) => a.trim())
.filter((a) => a.length >= 2 && a.length <= 20);
if (aliasList.length > 10) {
setError("最多只能添加10个别名");
return;
}
const token = session?.accessToken;
if (!token) {
setError("请先登录");
return;
}
try {
setLoading(true);
if (isEditing) {
const data: UpdateBrandRequest = {
aliases: aliasList.length > 0 ? aliasList : undefined,
website: website.trim() || undefined,
industry: industry || undefined,
platforms: platforms,
frequency: frequency,
};
await api.brands.update(token, editBrand.id, data);
} else {
const data: CreateBrandRequest = {
name: name.trim(),
aliases: aliasList.length > 0 ? aliasList : undefined,
website: website.trim() || undefined,
industry: industry || undefined,
platforms: platforms,
frequency: frequency,
};
await api.brands.create(token, data);
}
handleOpenChange(false);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败,请重试");
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="sm:max-w-[600px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{isEditing ? "编辑品牌" : "添加品牌"}</DialogTitle>
<DialogDescription>
{isEditing
? "修改品牌的基本信息和监控设置"
: "创建一个新的品牌来开始监控其在AI搜索中的表现"}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* 品牌名称 */}
<div className="grid gap-2">
<Label htmlFor="name">
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入品牌名称"
maxLength={50}
disabled={isEditing}
required={!isEditing}
/>
{isEditing && (
<p className="text-xs text-muted-foreground">
</p>
)}
</div>
{/* 品牌别名 */}
<div className="grid gap-2">
<Label htmlFor="aliases"></Label>
<Input
id="aliases"
value={aliases}
onChange={(e) => setAliases(e.target.value)}
placeholder="多个别名用逗号分隔最多10个"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 官方网站 */}
<div className="grid gap-2">
<Label htmlFor="website"></Label>
<Input
id="website"
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://example.com"
/>
</div>
{/* 所属行业 */}
<div className="grid gap-2">
<Label htmlFor="industry"></Label>
<Select value={industry} onValueChange={setIndustry}>
<SelectTrigger>
<SelectValue placeholder="请选择所属行业" />
</SelectTrigger>
<SelectContent>
{INDUSTRY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 监控平台 */}
<div className="grid gap-2">
<Label>
<span className="text-destructive">*</span>
</Label>
<div className="flex flex-wrap gap-2">
{PLATFORMS.map((platform) => (
<Button
key={platform.key}
type="button"
variant={
platforms.includes(platform.key) ? "default" : "outline"
}
size="sm"
onClick={() => togglePlatform(platform.key)}
className="h-8"
>
{platform.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{platforms.length}/7
</p>
</div>
{/* 查询频率 */}
<div className="grid gap-2">
<Label htmlFor="frequency"></Label>
<Select
value={frequency}
onValueChange={(value: "daily" | "weekly" | "monthly") =>
setFrequency(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 错误提示 */}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={loading}
>
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "保存更改" : "确认添加"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// 独立的触发按钮(方便在列表页使用)
export function AddBrandButton({ onSuccess }: { onSuccess?: () => void }) {
return (
<BrandFormDialog
onSuccess={onSuccess}
trigger={
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
);
}