refactor(home-api): 统一首页API响应格式并迁移配套代码
完成首页数据模型的统一重构,具体变更如下: 1. 重构公共首页API接口,移除`playRecommendations`字段,将玩法推荐数据统一放入`experiences`字段 2. 更新前后端类型定义、序列化逻辑与前端页面组件,适配新的API响应结构 3. 为微信登录相关接口添加`trust_env=True`配置,支持企业代理环境并新增`socksio`依赖 4. 新增图片画廊上传组件,重构SingleImageUploader组件支持自定义宽高比 5. 重构Toast与Select组件的实现与样式,统一后台UI设计系统 6. 移除个人中心页面不必要的返回事件与顶部标题组件,优化详情卡片布局 7. 新增微信接口单元测试,更新官方文档与测试用例适配变更 8. 删除过期的文档图片资源
This commit is contained in:
1 parent
d3a246a873
commit
6296392e80
27 files changed
+1055
-218
No files matched your search
@@ -89,6 +89,7 @@ export function ConciergeAdvisorEditor({
|
||||
value={draft.avatar}
|
||||
onChange={(value) => update("avatar", value ?? "")}
|
||||
group="concierge"
|
||||
aspectRatio="square"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -97,6 +98,7 @@ export function ConciergeAdvisorEditor({
|
||||
value={draft.qrImage}
|
||||
onChange={(value) => update("qrImage", value ?? "")}
|
||||
group="concierge"
|
||||
aspectRatio="square"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import type { HomeWanfaRecommendation, WanfaCategory } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NativeSelect } from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function HomeWanfaRecommendationRail({
|
||||
@@ -35,20 +35,23 @@ export function HomeWanfaRecommendationRail({
|
||||
<p>关联玩法分类,维护首页推荐入口和展示顺序。</p>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<NativeSelect
|
||||
<Select
|
||||
aria-label="选择要推荐的玩法分类"
|
||||
className="h-10 min-w-[148px] py-1.5 text-xs"
|
||||
value={selectedCategoryId}
|
||||
disabled={!availableCategories.length || savingKey === "add"}
|
||||
onChange={(event) => setSelectedCategoryId(event.target.value)}
|
||||
onValueChange={setSelectedCategoryId}
|
||||
>
|
||||
<option value="">关联分类</option>
|
||||
{availableCategories.map((category) => (
|
||||
<option value={category.id} key={category.id}>
|
||||
{category.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<SelectTrigger className="h-10 min-w-[148px] py-1.5 text-xs">
|
||||
<SelectValue placeholder="关联分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{availableCategories.map((category) => (
|
||||
<SelectItem value={category.id} key={category.id}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
className="primary-action compact"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ChangeEvent, useRef, useState } from "react";
|
||||
import { ImagePlus, LoaderCircle, Trash2 } from "lucide-react";
|
||||
|
||||
import { uploadMediaAsset } from "@/api";
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
|
||||
export function ImageGalleryUploader({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
group = "wanfa-detail",
|
||||
}: {
|
||||
id: string;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
group?: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||
const [uploadingTotal, setUploadingTotal] = useState(0);
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
if (!files.length) return;
|
||||
|
||||
const invalidFile = files.find(
|
||||
(file) => !ALLOWED_IMAGE_TYPES.has(file.type) || file.size > MAX_IMAGE_BYTES,
|
||||
);
|
||||
if (invalidFile) {
|
||||
setError(
|
||||
ALLOWED_IMAGE_TYPES.has(invalidFile.type)
|
||||
? `「${invalidFile.name}」超过 5MB,请压缩后重新上传。`
|
||||
: `「${invalidFile.name}」不是支持的图片格式,请选择 JPG、PNG、WebP 或 GIF。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
let nextGallery = [...value];
|
||||
setUploadingTotal(files.length);
|
||||
try {
|
||||
for (const [index, file] of files.entries()) {
|
||||
setUploadingIndex(index);
|
||||
const asset = await uploadMediaAsset(file, group);
|
||||
nextGallery = [...nextGallery, asset.url];
|
||||
onChange(nextGallery);
|
||||
}
|
||||
} catch (uploadError) {
|
||||
setError(uploadError instanceof Error ? uploadError.message : "图片上传失败,请重试。");
|
||||
} finally {
|
||||
setUploadingIndex(null);
|
||||
setUploadingTotal(0);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
onChange(value.filter((_, itemIndex) => itemIndex !== index));
|
||||
};
|
||||
|
||||
const uploadTile = (
|
||||
<li className="single-image-card">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`single-image-preview ${uploadingIndex !== null ? "pointer-events-none opacity-60" : ""}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
className="single-image-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
disabled={uploadingIndex !== null}
|
||||
/>
|
||||
<span className="single-image-placeholder">
|
||||
{uploadingIndex === null ? <ImagePlus size={30} /> : <LoaderCircle className="animate-spin" size={30} />}
|
||||
<b>
|
||||
{uploadingIndex === null
|
||||
? value.length
|
||||
? "继续上传图片"
|
||||
: "暂无图片"
|
||||
: `上传中 ${uploadingIndex + 1}/${uploadingTotal}`}
|
||||
</b>
|
||||
<small>
|
||||
{uploadingIndex === null ? "上传后在这里预览详情素材" : "正在上传到媒体资源库"}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 rounded-xl border border-slate-200 bg-white p-3.5 shadow-sm">
|
||||
<div className="border-b border-slate-100 bg-slate-50/80 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-900">详情图片画廊</p>
|
||||
<p className="mt-1 text-xs font-normal leading-5 text-slate-500">
|
||||
支持多选上传,单张不超过 5MB;图片会按当前顺序保存到路线详情。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="grid gap-3 px-1" aria-label="详情图片列表">
|
||||
{value.map((image, index) => (
|
||||
<li className="single-image-card has-image" key={`${image}-${index}`}>
|
||||
<div className="single-image-preview has-image">
|
||||
<img src={image} alt={`详情图片 ${index + 1}`} loading="lazy" />
|
||||
</div>
|
||||
<div className="single-image-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="single-image-remove-button"
|
||||
onClick={() => removeImage(index)}
|
||||
aria-label={`移除第 ${index + 1} 张图片`}
|
||||
title={`移除第 ${index + 1} 张图片`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{uploadTile}
|
||||
</ul>
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs font-medium leading-5 text-rose-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,12 @@ export function SingleImageUploader({
|
||||
value,
|
||||
onChange,
|
||||
group = "site-config",
|
||||
aspectRatio = "landscape",
|
||||
}: {
|
||||
value?: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
group?: string;
|
||||
aspectRatio?: "landscape" | "square";
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState("");
|
||||
@@ -53,7 +55,7 @@ export function SingleImageUploader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="single-image-uploader">
|
||||
<div className={`single-image-uploader single-image-uploader--${aspectRatio}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="single-image-input"
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Toast } from "@/types/admin";
|
||||
import { Toast, ToastClose, ToastDescription, ToastTitle, ToastViewport } from "@/components/ui/toast";
|
||||
import type { Toast as AdminToast } from "@/types/admin";
|
||||
|
||||
export function ToastStack({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
||||
export function ToastStack({ toasts, onClose }: { toasts: AdminToast[]; onClose: (id: string) => void }) {
|
||||
if (!toasts.length) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-toast-stack" role="status" aria-live="polite">
|
||||
<ToastViewport>
|
||||
{toasts.map((toast) => (
|
||||
<article className={`admin-toast ${toast.tone}`} key={toast.id}>
|
||||
{toast.tone === "success" ? <CheckCircle2 size={20} /> : <AlertTriangle size={20} />}
|
||||
<span>
|
||||
<strong>{toast.title}</strong>
|
||||
<small>{toast.message}</small>
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => onClose(toast.id)} aria-label="关闭提示">
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</article>
|
||||
<Toast tone={toast.tone} key={toast.id}>
|
||||
{toast.tone === "success" ? (
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-700" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
className={`mt-0.5 size-5 shrink-0 ${toast.tone === "danger" ? "text-red-700" : "text-amber-700"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<ToastTitle>{toast.title}</ToastTitle>
|
||||
<ToastDescription>{toast.message}</ToastDescription>
|
||||
</div>
|
||||
<ToastClose onClick={() => onClose(toast.id)} />
|
||||
</Toast>
|
||||
))}
|
||||
</div>
|
||||
</ToastViewport>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { DetailCreate } from "@/api";
|
||||
import { ImageGalleryUploader } from "@/components/admin/ImageGalleryUploader";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const detailArrayFields = [
|
||||
@@ -7,7 +9,6 @@ const detailArrayFields = [
|
||||
["included", "费用包含", "每行填写一项包含内容"],
|
||||
["excluded", "费用不含", "每行填写一项不包含内容"],
|
||||
["notes", "注意事项", "每行填写一项注意事项"],
|
||||
["gallery", "详情图片画廊", "每行填写一个 http(s) 图片地址"],
|
||||
] as const;
|
||||
|
||||
type DetailArrayField = (typeof detailArrayFields)[number][0];
|
||||
@@ -100,21 +101,39 @@ export function WanfaRouteDetailFields({
|
||||
<Textarea
|
||||
id={`wanfa-detail-${key}`}
|
||||
value={detail[key].join("\n")}
|
||||
rows={key === "gallery" ? 3 : 4}
|
||||
rows={4}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => updateLines(key, event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-3 text-sm font-medium text-slate-800 sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300 text-teal-700 focus:ring-teal-600"
|
||||
checked={detail.isActive}
|
||||
onChange={(event) => onChange("isActive", event.target.checked)}
|
||||
<div className="sm:col-span-2">
|
||||
<ImageGalleryUploader
|
||||
id="wanfa-detail-gallery"
|
||||
value={detail.gallery}
|
||||
onChange={(value) => onChange("gallery", value)}
|
||||
/>
|
||||
启用路线详情
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2 rounded-xl border border-slate-200 bg-white p-3.5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p id="wanfa-detail-active-label" className="text-sm font-semibold text-slate-900">
|
||||
启用路线详情
|
||||
</p>
|
||||
<p id="wanfa-detail-active-description" className="mt-1 text-xs leading-5 text-slate-500">
|
||||
{detail.isActive ? "前台详情页会展示这条路线的完整内容。" : "暂不对前台展示路线详情,可随时重新启用。"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="wanfa-detail-active"
|
||||
checked={detail.isActive}
|
||||
onCheckedChange={(checked) => onChange("isActive", checked)}
|
||||
aria-labelledby="wanfa-detail-active-label"
|
||||
aria-describedby="wanfa-detail-active-description"
|
||||
className="data-[state=checked]:bg-teal-700 data-[state=unchecked]:bg-slate-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,129 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center justify-between gap-2 rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm font-medium text-slate-950 shadow-sm outline-none transition-[background-color,border-color,box-shadow] duration-150 hover:border-slate-400 focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20 data-[placeholder]:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500 [&>span]:line-clamp-1 [&>span]:flex [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex h-6 cursor-default items-center justify-center text-slate-500", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex h-6 cursor-default items-center justify-center text-slate-500", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
position={position}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white text-slate-950 shadow-lg outline-none data-[state=closed]:opacity-0 data-[state=open]:opacity-100",
|
||||
position === "popper" && "translate-y-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-xs font-semibold text-slate-500", className)} {...props} />
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-4 w-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-teal-700" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-slate-100", className)} {...props} />
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
const NativeSelect = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(({ className, children, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
@@ -17,3 +139,16 @@ const NativeSelect = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttribu
|
||||
NativeSelect.displayName = "NativeSelect";
|
||||
|
||||
export { NativeSelect };
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toastVariants = cva(
|
||||
"pointer-events-auto relative grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 rounded-xl border p-4 text-sm shadow-lg",
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-950",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-950",
|
||||
danger: "border-red-200 bg-red-50 text-red-950",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
tone: "success",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & VariantProps<typeof toastVariants>>(
|
||||
({ className, tone, ...props }, ref) => <article ref={ref} role="status" className={cn(toastVariants({ tone }), className)} {...props} />,
|
||||
);
|
||||
Toast.displayName = "Toast";
|
||||
|
||||
const ToastTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("font-semibold leading-5", className)} {...props} />
|
||||
));
|
||||
ToastTitle.displayName = "ToastTitle";
|
||||
|
||||
const ToastDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("mt-1 text-sm leading-5 text-slate-600", className)} {...props} />
|
||||
));
|
||||
ToastDescription.displayName = "ToastDescription";
|
||||
|
||||
const ToastClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(({ className, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label="关闭提示"
|
||||
className={cn(
|
||||
"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-black/5 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
));
|
||||
ToastClose.displayName = "ToastClose";
|
||||
|
||||
const ToastViewport = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn("fixed right-4 top-4 z-[80] grid w-[min(360px,calc(100vw-2rem))] gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = "ToastViewport";
|
||||
|
||||
export { Toast, ToastClose, ToastDescription, ToastTitle, ToastViewport };
|
||||
Reference in new issue
Block a user