351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
"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>
|
||
}
|
||
/>
|
||
);
|
||
}
|