feat(admin): add route sections admin management module
This commit adds the complete admin workflow for managing homepage curated route subgroups: - Add `RouteSection` type and extend `SiteConfig`/`SiteModule` to support the new module - Create all required admin components: panel, editors, product picker, and utility functions - Update admin utilities and API types to handle routeSections CRUD operations - Update documentation to include the new routeSections API contract - Add supporting CSS styles for the new UI elements
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
import type { ProductInput } from "@/api";
|
||||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type QuickRouteDraft = {
|
||||
title: string;
|
||||
summary: string;
|
||||
destinationName: string;
|
||||
priceAmount: number | null;
|
||||
priceUnit: string;
|
||||
tags: string[];
|
||||
coverImage: string;
|
||||
published: boolean;
|
||||
sortWeight: number;
|
||||
};
|
||||
|
||||
const emptyDraft: QuickRouteDraft = {
|
||||
title: "",
|
||||
summary: "",
|
||||
destinationName: "",
|
||||
priceAmount: null,
|
||||
priceUnit: "起/人",
|
||||
tags: ["", ""],
|
||||
coverImage: "",
|
||||
published: true,
|
||||
sortWeight: 0,
|
||||
};
|
||||
|
||||
export function RouteProductQuickCreateForm({
|
||||
onCreate,
|
||||
onSubmitRef,
|
||||
}: {
|
||||
onCreate: (input: ProductInput) => Promise<void>;
|
||||
onSubmitRef: MutableRefObject<(() => Promise<void>) | null>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<QuickRouteDraft>(emptyDraft);
|
||||
|
||||
const submit = async () => {
|
||||
const title = draft.title.trim();
|
||||
if (!title) return;
|
||||
|
||||
await onCreate({
|
||||
title,
|
||||
subtitle: draft.destinationName.trim(),
|
||||
destinationId: null,
|
||||
priceAmount: draft.priceAmount,
|
||||
priceUnit: draft.priceUnit.trim() || "起/人",
|
||||
tags: draft.tags.map((tag) => tag.trim()).filter(Boolean).slice(0, 3),
|
||||
coverImage: draft.coverImage.trim() || null,
|
||||
summary: draft.summary.trim(),
|
||||
images: [],
|
||||
detailSections: [],
|
||||
status: draft.published ? "published" : "draft",
|
||||
sortWeight: draft.sortWeight,
|
||||
});
|
||||
setDraft(emptyDraft);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = submit;
|
||||
return () => {
|
||||
onSubmitRef.current = null;
|
||||
};
|
||||
}, [draft, onSubmitRef]);
|
||||
|
||||
return (
|
||||
<div className="admin-disclosure-stack route-product-create-form">
|
||||
<AdminDisclosure title="展示内容">
|
||||
<label>
|
||||
线路标题
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
线路描述
|
||||
<Textarea
|
||||
value={draft.summary}
|
||||
onChange={(event) => setDraft({ ...draft, summary: event.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
目标地点
|
||||
<Input
|
||||
value={draft.destinationName}
|
||||
onChange={(event) => setDraft({ ...draft, destinationName: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</AdminDisclosure>
|
||||
|
||||
<AdminDisclosure title="价格信息">
|
||||
<div className="form-grid compact-grid">
|
||||
<label>
|
||||
价格
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.priceAmount ?? ""}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
priceAmount: event.target.value ? Number(event.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
价格单位
|
||||
<Input
|
||||
value={draft.priceUnit}
|
||||
onChange={(event) => setDraft({ ...draft, priceUnit: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</AdminDisclosure>
|
||||
|
||||
<AdminDisclosure title="线路标签">
|
||||
<QuickTagEditor
|
||||
tags={draft.tags}
|
||||
onChange={(tags) => setDraft({ ...draft, tags })}
|
||||
/>
|
||||
</AdminDisclosure>
|
||||
|
||||
<AdminDisclosure title="线路封面图">
|
||||
<SingleImageUploader
|
||||
value={draft.coverImage}
|
||||
onChange={(coverImage) => setDraft({ ...draft, coverImage: coverImage ?? "" })}
|
||||
group="route-section-products"
|
||||
/>
|
||||
</AdminDisclosure>
|
||||
|
||||
<AdminDisclosure title="显示状态">
|
||||
<label>
|
||||
排序值
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.sortWeight}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, sortWeight: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="switch-line">
|
||||
<Switch
|
||||
checked={draft.published}
|
||||
onCheckedChange={(checked) => setDraft({ ...draft, published: checked })}
|
||||
/>
|
||||
创建后上架
|
||||
</label>
|
||||
</AdminDisclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickTagEditor({
|
||||
tags,
|
||||
onChange,
|
||||
}: {
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
}) {
|
||||
const normalizedTags = tags.slice(0, 3);
|
||||
const updateTag = (index: number, value: string) => {
|
||||
onChange(normalizedTags.map((tag, tagIndex) => (tagIndex === index ? value : tag)));
|
||||
};
|
||||
const removeTag = (index: number) => {
|
||||
onChange(normalizedTags.filter((_, tagIndex) => tagIndex !== index));
|
||||
};
|
||||
const addTag = () => {
|
||||
if (normalizedTags.length >= 3) return;
|
||||
onChange([...normalizedTags, ""]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tag-editor compact-tag-editor">
|
||||
{normalizedTags.map((tag, index) => (
|
||||
<label className="tag-input-row" key={`${index}-${tag}`}>
|
||||
标签 {index + 1}
|
||||
<span>
|
||||
<Input value={tag} onChange={(event) => updateTag(index, event.target.value)} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="icon-action danger-icon"
|
||||
onClick={() => removeTag(index)}
|
||||
aria-label={`删除标签 ${index + 1}`}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="inline-action"
|
||||
onClick={addTag}
|
||||
disabled={normalizedTags.length >= 3}
|
||||
>
|
||||
<Plus size={16} />
|
||||
添加标签
|
||||
</Button>
|
||||
<p className="field-help">最多维护 3 个标签,保存时会自动去掉空标签。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/admin/route-sections/RouteSectionEditor.tsx
Normal file
107
src/components/admin/route-sections/RouteSectionEditor.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||||
import type { RouteSectionDraft } from "@/components/admin/route-sections/helpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function RouteSectionEditor({
|
||||
draft,
|
||||
mode = "edit",
|
||||
saving,
|
||||
onDraft,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
draft: RouteSectionDraft;
|
||||
mode?: "create" | "edit";
|
||||
saving: boolean;
|
||||
onDraft: (draft: RouteSectionDraft) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isCreating = mode === "create";
|
||||
|
||||
return (
|
||||
<div className="mapped-editor route-section-editor">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3 id="route-section-editor-title">
|
||||
{isCreating ? "新增精选线路子分组" : "编辑精选线路子分组"}
|
||||
</h3>
|
||||
<small>
|
||||
{isCreating
|
||||
? "先保存标题和副文案,保存后可继续配置关联线路。"
|
||||
: "标题、副文案、启用状态和关联线路会同步到用户侧首页。"}
|
||||
</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="关闭精选线路编辑抽屉"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="admin-disclosure-stack">
|
||||
<AdminDisclosure title="分组内容">
|
||||
<label>
|
||||
分组标题
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => onDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
副文案
|
||||
<Textarea
|
||||
value={draft.subtitle}
|
||||
onChange={(event) => onDraft({ ...draft, subtitle: event.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
</AdminDisclosure>
|
||||
<AdminDisclosure title="排序与状态">
|
||||
<label>
|
||||
分组顺序
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.sortOrder}
|
||||
onChange={(event) =>
|
||||
onDraft({ ...draft, sortOrder: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="switch-line">
|
||||
<Switch
|
||||
checked={draft.isActive}
|
||||
onCheckedChange={(checked) => onDraft({ ...draft, isActive: checked })}
|
||||
/>
|
||||
用户侧启用
|
||||
</label>
|
||||
<p className="field-help">
|
||||
{isCreating
|
||||
? "按填写的顺序保存;关闭启用后,后台可继续维护,用户侧暂不展示。"
|
||||
: "按运营任务维护精选线路分组;停用后用户侧不展示,后台仍保留配置。"}
|
||||
</p>
|
||||
</AdminDisclosure>
|
||||
</div>
|
||||
<footer className="drawer-editor-footer">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="primary-action compact"
|
||||
onClick={onSave}
|
||||
disabled={saving || !draft.title.trim()}
|
||||
>
|
||||
<Save size={17} />
|
||||
{saving ? "保存中" : "保存"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Save, X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import type { Product, ProductInput } from "@/api";
|
||||
import { RouteProductQuickCreateForm } from "@/components/admin/route-sections/RouteProductQuickCreateForm";
|
||||
import { RouteSectionProductPicker } from "@/components/admin/route-sections/RouteSectionProductPicker";
|
||||
import type {
|
||||
ProductUsage,
|
||||
RouteSectionDraft,
|
||||
} from "@/components/admin/route-sections/helpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function RouteSectionProductEditor({
|
||||
draft,
|
||||
products,
|
||||
productUsage,
|
||||
saving,
|
||||
onCreateProduct,
|
||||
onProductIdsChange,
|
||||
onClose,
|
||||
}: {
|
||||
draft: RouteSectionDraft;
|
||||
products: Product[];
|
||||
productUsage: Map<string, ProductUsage>;
|
||||
saving: boolean;
|
||||
onCreateProduct: (input: ProductInput) => Promise<void>;
|
||||
onProductIdsChange: (productIds: string[]) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const createSubmitRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const updateProductIds = (productIds: string[]) => {
|
||||
void onProductIdsChange(productIds);
|
||||
};
|
||||
const addProduct = (productId: string) => {
|
||||
if (draft.productIds.includes(productId)) return;
|
||||
updateProductIds([...draft.productIds, productId]);
|
||||
};
|
||||
const removeProduct = (productId: string) => {
|
||||
updateProductIds(draft.productIds.filter((candidate) => candidate !== productId));
|
||||
};
|
||||
const moveProduct = (productId: string, direction: -1 | 1) => {
|
||||
const currentIndex = draft.productIds.indexOf(productId);
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= draft.productIds.length) return;
|
||||
|
||||
const nextProductIds = [...draft.productIds];
|
||||
[nextProductIds[currentIndex], nextProductIds[nextIndex]] = [
|
||||
nextProductIds[nextIndex],
|
||||
nextProductIds[currentIndex],
|
||||
];
|
||||
updateProductIds(nextProductIds);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mapped-editor route-section-editor">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3 id="route-section-product-editor-title">关联线路</h3>
|
||||
<small>{draft.title} 的首页展示线路、快捷创建和排序。</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="关闭关联线路抽屉"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
<RouteProductQuickCreateForm
|
||||
onCreate={onCreateProduct}
|
||||
onSubmitRef={createSubmitRef}
|
||||
/>
|
||||
<RouteSectionProductPicker
|
||||
products={products}
|
||||
productIds={draft.productIds}
|
||||
productUsage={productUsage}
|
||||
onAdd={addProduct}
|
||||
onRemove={removeProduct}
|
||||
onMove={moveProduct}
|
||||
/>
|
||||
<footer className="drawer-editor-footer">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="primary-action compact"
|
||||
onClick={() => createSubmitRef.current?.()}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save size={17} />
|
||||
{saving ? "创建中" : "创建并关联"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ArrowDown, ArrowUp, Lock, Minus, Plus } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Product } from "@/api";
|
||||
import type { ProductUsage } from "@/components/admin/route-sections/helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { productStatusLabels } from "@/lib/admin-utils";
|
||||
|
||||
export function RouteSectionProductPicker({
|
||||
products,
|
||||
productIds,
|
||||
productUsage,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onMove,
|
||||
}: {
|
||||
products: Product[];
|
||||
productIds: string[];
|
||||
productUsage: Map<string, ProductUsage>;
|
||||
onAdd: (productId: string) => void;
|
||||
onRemove: (productId: string) => void;
|
||||
onMove: (productId: string, direction: -1 | 1) => void;
|
||||
}) {
|
||||
const productById = useMemo(
|
||||
() => new Map(products.map((product) => [product.id, product])),
|
||||
[products],
|
||||
);
|
||||
const selectedIdSet = useMemo(() => new Set(productIds), [productIds]);
|
||||
const selectedProducts = productIds
|
||||
.map((productId) => productById.get(productId))
|
||||
.filter((product): product is Product => Boolean(product));
|
||||
const availableProducts = products.filter((product) => product.status === "published");
|
||||
|
||||
return (
|
||||
<div className="route-product-picker">
|
||||
<section className="route-product-block">
|
||||
<div className="route-product-list selected">
|
||||
{selectedProducts.map((product, index) => (
|
||||
<ProductRow
|
||||
key={product.id}
|
||||
product={product}
|
||||
trailing={
|
||||
<div className="mapped-list-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={() => onMove(product.id, -1)}
|
||||
aria-label={`上移 ${product.title}`}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={index === selectedProducts.length - 1}
|
||||
onClick={() => onMove(product.id, 1)}
|
||||
aria-label={`下移 ${product.title}`}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(product.id)}
|
||||
aria-label={`移除 ${product.title}`}
|
||||
>
|
||||
<Minus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="route-product-block">
|
||||
<div className="route-product-list">
|
||||
{availableProducts.map((product) => {
|
||||
const usage = productUsage.get(product.id);
|
||||
const selected = selectedIdSet.has(product.id);
|
||||
const disabled = Boolean(usage) || selected;
|
||||
const disabledText =
|
||||
usage
|
||||
? `已在「${usage.sectionTitle}」`
|
||||
: selected
|
||||
? "已添加"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<ProductRow
|
||||
key={product.id}
|
||||
product={product}
|
||||
disabledText={disabledText}
|
||||
trailing={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => onAdd(product.id)}
|
||||
>
|
||||
{disabledText ? <Lock size={14} /> : <Plus size={14} />}
|
||||
{disabledText || "添加"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductRow({
|
||||
product,
|
||||
disabledText,
|
||||
trailing,
|
||||
}: {
|
||||
product: Product;
|
||||
disabledText?: string;
|
||||
trailing: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="route-product-row">
|
||||
{product.coverImage ? (
|
||||
<img src={product.coverImage} alt="" />
|
||||
) : (
|
||||
<span className="image-placeholder" />
|
||||
)}
|
||||
<span className="route-product-main">
|
||||
<b>{product.title}</b>
|
||||
<small>
|
||||
{product.destination?.name || "未绑定目的地"}
|
||||
{typeof product.priceAmount === "number"
|
||||
? ` / ¥${product.priceAmount}${product.priceUnit}`
|
||||
: ""}
|
||||
</small>
|
||||
<span className="route-product-tags">
|
||||
<Badge
|
||||
className={`status-chip ${product.status}`}
|
||||
variant={product.status === "published" ? "success" : product.status === "draft" ? "warning" : "muted"}
|
||||
>
|
||||
{productStatusLabels[product.status]}
|
||||
</Badge>
|
||||
{product.tags.slice(0, 2).map((tag) => (
|
||||
<em key={tag}>{tag}</em>
|
||||
))}
|
||||
{disabledText ? <em className="muted">{disabledText}</em> : null}
|
||||
</span>
|
||||
</span>
|
||||
{trailing}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
521
src/components/admin/route-sections/RouteSectionsPanel.tsx
Normal file
521
src/components/admin/route-sections/RouteSectionsPanel.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import { ArrowDown, ArrowUp, Link2, Pencil, Route, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Product, RouteSection } from "@/api";
|
||||
import {
|
||||
createProduct,
|
||||
createSiteConfigItem,
|
||||
deleteSiteConfigItem,
|
||||
reorderSiteConfigItems,
|
||||
updateSiteConfigItem,
|
||||
} from "@/api";
|
||||
import type { ProductInput } from "@/api";
|
||||
import { RouteSectionEditor } from "@/components/admin/route-sections/RouteSectionEditor";
|
||||
import {
|
||||
buildProductUsageMap,
|
||||
compactRouteSectionDraft,
|
||||
createEmptyRouteSectionDraft,
|
||||
createRouteSectionDraft,
|
||||
type RouteSectionDraft,
|
||||
} from "@/components/admin/route-sections/helpers";
|
||||
import { RouteSectionProductEditor } from "@/components/admin/route-sections/RouteSectionProductEditor";
|
||||
import { compactProductPayload } from "@/lib/admin-utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DirtyChangeHandler, Notify } from "@/types/admin";
|
||||
|
||||
export function RouteSectionsPanel({
|
||||
routeSections,
|
||||
products,
|
||||
moduleLabel,
|
||||
onDirtyChange,
|
||||
notify,
|
||||
onReload,
|
||||
createRequestKey,
|
||||
}: {
|
||||
routeSections: RouteSection[];
|
||||
products: Product[];
|
||||
moduleLabel: string;
|
||||
onDirtyChange: DirtyChangeHandler;
|
||||
notify: Notify;
|
||||
onReload: () => Promise<unknown>;
|
||||
createRequestKey: number;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [expandedId, setExpandedId] = useState("");
|
||||
const [draft, setDraft] = useState<RouteSectionDraft | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [productEditorOpen, setProductEditorOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const createRequestSeen = useRef(createRequestKey);
|
||||
|
||||
const sortedSections = useMemo(
|
||||
() => [...routeSections].sort((left, right) => left.sortOrder - right.sortOrder),
|
||||
[routeSections],
|
||||
);
|
||||
const productById = useMemo(
|
||||
() => new Map(products.map((product) => [product.id, product])),
|
||||
[products],
|
||||
);
|
||||
const selectedSection =
|
||||
sortedSections.find((section) => section.id === selectedId) ??
|
||||
sortedSections[0];
|
||||
const productUsage = useMemo(
|
||||
() => buildProductUsageMap(routeSections, draft?.id ?? selectedSection?.id ?? ""),
|
||||
[draft?.id, routeSections, selectedSection?.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const firstSection = sortedSections[0];
|
||||
if (!firstSection) {
|
||||
setSelectedId("");
|
||||
setExpandedId("");
|
||||
setDraft(null);
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSection =
|
||||
sortedSections.find((section) => section.id === selectedId) ?? firstSection;
|
||||
setSelectedId(nextSection.id);
|
||||
setExpandedId((current) =>
|
||||
sortedSections.some((section) => section.id === current) ? current : nextSection.id,
|
||||
);
|
||||
setDraft(createRouteSectionDraft(nextSection));
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(false);
|
||||
setMessage("");
|
||||
}, [routeSections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (createRequestSeen.current === createRequestKey) return;
|
||||
createRequestSeen.current = createRequestKey;
|
||||
|
||||
setSelectedId("");
|
||||
setDraft(createEmptyRouteSectionDraft(sortedSections.length));
|
||||
setEditorOpen(true);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(true);
|
||||
setMessage("");
|
||||
onDirtyChange(false);
|
||||
}, [createRequestKey, notify, onDirtyChange, sortedSections.length]);
|
||||
|
||||
const openEditor = (section: RouteSection) => {
|
||||
setSelectedId(section.id);
|
||||
setExpandedId(section.id);
|
||||
setDraft(createRouteSectionDraft(section));
|
||||
setMessage("");
|
||||
setEditorOpen(true);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
};
|
||||
|
||||
const openProductEditor = (section: RouteSection) => {
|
||||
setSelectedId(section.id);
|
||||
setExpandedId(section.id);
|
||||
setDraft(createRouteSectionDraft(section));
|
||||
setMessage("");
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(true);
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
if (!creating && selectedSection) {
|
||||
setDraft(createRouteSectionDraft(selectedSection));
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(false);
|
||||
setMessage("");
|
||||
onDirtyChange(false);
|
||||
};
|
||||
|
||||
const updateDraft = (nextDraft: RouteSectionDraft) => {
|
||||
setDraft(nextDraft);
|
||||
onDirtyChange(true);
|
||||
};
|
||||
|
||||
const saveSection = async () => {
|
||||
if (!draft || saving) return;
|
||||
|
||||
const payload = compactRouteSectionDraft(draft);
|
||||
if (!payload.title) {
|
||||
notify({
|
||||
tone: "warning",
|
||||
title: "内容未保存",
|
||||
message: "请先填写精选线路子分组标题。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const saved = creating
|
||||
? await createSiteConfigItem("routeSections", payload)
|
||||
: await updateSiteConfigItem("routeSections", draft.id, payload);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: creating ? "子分组已新增" : "精选线路已保存",
|
||||
message: creating
|
||||
? `${draft.title} 已保存,可继续配置关联线路。`
|
||||
: `${draft.title} 的标题、状态和关联商品已更新。`,
|
||||
});
|
||||
if ("id" in saved) {
|
||||
setSelectedId(saved.id);
|
||||
setExpandedId(saved.id);
|
||||
}
|
||||
setCreating(false);
|
||||
setEditorOpen(false);
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "保存失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "精选线路保存失败",
|
||||
message: messageText,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProductLinks = async (productIds: string[]) => {
|
||||
if (!draft || saving || !draft.id) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const nextDraft = { ...draft, productIds: Array.from(new Set(productIds)) };
|
||||
setDraft(nextDraft);
|
||||
await updateSiteConfigItem(
|
||||
"routeSections",
|
||||
draft.id,
|
||||
compactRouteSectionDraft(nextDraft),
|
||||
);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "关联线路已保存",
|
||||
message: `${draft.title} 的关联线路已更新。`,
|
||||
});
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "保存失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "关联线路保存失败",
|
||||
message: messageText,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createAndLinkProduct = async (input: ProductInput) => {
|
||||
if (!draft || saving || !draft.id) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const product = await createProduct(compactProductPayload(input));
|
||||
const nextDraft = {
|
||||
...draft,
|
||||
productIds: Array.from(new Set([...draft.productIds, product.id])),
|
||||
};
|
||||
|
||||
await updateSiteConfigItem(
|
||||
"routeSections",
|
||||
draft.id,
|
||||
compactRouteSectionDraft(nextDraft),
|
||||
);
|
||||
setDraft(nextDraft);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "关联线路已创建",
|
||||
message: `${product.title} 已创建并加入 ${draft.title}。`,
|
||||
});
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "创建失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "关联线路创建失败",
|
||||
message: messageText,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveSection = async (section: RouteSection, direction: -1 | 1) => {
|
||||
const currentIndex = sortedSections.findIndex((candidate) => candidate.id === section.id);
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length) return;
|
||||
|
||||
const nextIds = sortedSections.map((candidate) => candidate.id);
|
||||
[nextIds[currentIndex], nextIds[nextIndex]] = [
|
||||
nextIds[nextIndex],
|
||||
nextIds[currentIndex],
|
||||
];
|
||||
|
||||
try {
|
||||
await reorderSiteConfigItems("routeSections", nextIds);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "精选线路顺序已更新",
|
||||
message: `${moduleLabel} 已按新的分组顺序保存。`,
|
||||
});
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "排序失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "精选线路排序失败",
|
||||
message: messageText,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSection = async (section: RouteSection) => {
|
||||
if (saving) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`确认删除「${section.title}」?删除后该分组不会在首页展示,已创建的线路商品不会被删除。`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
await deleteSiteConfigItem("routeSections", section.id);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "精选线路已删除",
|
||||
message: `${section.title} 已从精选线路分组中移除。`,
|
||||
});
|
||||
setSelectedId("");
|
||||
setExpandedId("");
|
||||
setDraft(null);
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
} catch (err) {
|
||||
const messageText = err instanceof Error ? err.message : "删除失败";
|
||||
setMessage(messageText);
|
||||
notify({
|
||||
tone: "danger",
|
||||
title: "精选线路删除失败",
|
||||
message: messageText,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sectionProducts = (section: RouteSection) =>
|
||||
section.productIds
|
||||
.map((productId) => productById.get(productId))
|
||||
.filter((product): product is Product => Boolean(product));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="route-section-console">
|
||||
{sortedSections.length ? (
|
||||
<div className="route-section-card-list">
|
||||
{sortedSections.map((section, index) => {
|
||||
const previewProducts = sectionProducts(section);
|
||||
const missingProductCount = Math.max(0, section.productIds.length - previewProducts.length);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`route-section-accordion ${expandedId === section.id ? "open" : ""}`}
|
||||
key={section.id}
|
||||
>
|
||||
<div className="route-section-accordion-head">
|
||||
<button
|
||||
type="button"
|
||||
className="route-section-accordion-toggle"
|
||||
onClick={() =>
|
||||
setExpandedId((current) => (current === section.id ? "" : section.id))
|
||||
}
|
||||
>
|
||||
<span className="route-section-order">
|
||||
<Route size={18} />
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="route-section-card-copy">
|
||||
<span className="route-section-card-titleline">
|
||||
<b>{section.title}</b>
|
||||
</span>
|
||||
<small>{section.subtitle || "未填写副文案"}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{expandedId === section.id ? (
|
||||
<div className="route-section-accordion-body">
|
||||
<div className="route-section-preview">
|
||||
{previewProducts.slice(0, 3).map((product) => (
|
||||
<span key={product.id}>
|
||||
{product.coverImage ? (
|
||||
<img src={product.coverImage} alt="" />
|
||||
) : (
|
||||
<i />
|
||||
)}
|
||||
<b>{product.title}</b>
|
||||
</span>
|
||||
))}
|
||||
{!previewProducts.length ? (
|
||||
<p>暂无关联线路</p>
|
||||
) : null}
|
||||
{missingProductCount ? (
|
||||
<p>{missingProductCount} 条已配置商品暂未从商品库返回详情</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<footer className="route-section-card-actions">
|
||||
<Badge
|
||||
variant={section.isActive ? "success" : "muted"}
|
||||
className={`status-chip ${section.isActive ? "published" : "archived"}`}
|
||||
>
|
||||
{section.isActive ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
<div className="route-section-card-action-tools">
|
||||
<div className="route-section-card-primary-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="route-section-delete-text"
|
||||
onClick={() => deleteSection(section)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="route-section-edit-text"
|
||||
onClick={() => openEditor(section)}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="route-section-link-text"
|
||||
onClick={() => openProductEditor(section)}
|
||||
>
|
||||
<Link2 size={13} />
|
||||
关联线路
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mapped-list-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveSection(section, -1)}
|
||||
aria-label={`上移 ${section.title}`}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={index === sortedSections.length - 1}
|
||||
onClick={() => moveSection(section, 1)}
|
||||
aria-label={`下移 ${section.title}`}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mapped-empty route-section-message">
|
||||
暂无已保存子分组,点击标题右侧“新增”创建第一个。
|
||||
</p>
|
||||
)}
|
||||
{message ? (
|
||||
<p className="mapped-empty route-section-message">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{editorOpen && draft ? (
|
||||
<div className="edit-drawer-layer">
|
||||
<button
|
||||
className="edit-drawer-backdrop"
|
||||
type="button"
|
||||
aria-label="关闭精选线路编辑抽屉"
|
||||
onClick={closeEditor}
|
||||
/>
|
||||
<section
|
||||
className="edit-rail edit-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="route-section-editor-title"
|
||||
>
|
||||
<RouteSectionEditor
|
||||
draft={draft}
|
||||
mode={creating ? "create" : "edit"}
|
||||
saving={saving}
|
||||
onDraft={updateDraft}
|
||||
onSave={saveSection}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
{productEditorOpen && draft ? (
|
||||
<div className="edit-drawer-layer">
|
||||
<button
|
||||
className="edit-drawer-backdrop"
|
||||
type="button"
|
||||
aria-label="关闭关联线路抽屉"
|
||||
onClick={closeEditor}
|
||||
/>
|
||||
<section
|
||||
className="edit-rail edit-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="route-section-product-editor-title"
|
||||
>
|
||||
<RouteSectionProductEditor
|
||||
draft={draft}
|
||||
products={products}
|
||||
productUsage={productUsage}
|
||||
saving={saving}
|
||||
onCreateProduct={createAndLinkProduct}
|
||||
onProductIdsChange={updateProductLinks}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
src/components/admin/route-sections/helpers.ts
Normal file
63
src/components/admin/route-sections/helpers.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RouteSection, SiteItemPatch } from "@/api";
|
||||
|
||||
export type RouteSectionDraft = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
productIds: string[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type ProductUsage = {
|
||||
sectionId: string;
|
||||
sectionTitle: string;
|
||||
};
|
||||
|
||||
export function createRouteSectionDraft(section: RouteSection): RouteSectionDraft {
|
||||
return {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
subtitle: section.subtitle ?? "",
|
||||
productIds: [...section.productIds],
|
||||
isActive: section.isActive,
|
||||
sortOrder: section.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyRouteSectionDraft(sortOrder: number): RouteSectionDraft {
|
||||
return {
|
||||
id: "",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
productIds: [],
|
||||
isActive: true,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactRouteSectionDraft(draft: RouteSectionDraft): SiteItemPatch {
|
||||
return {
|
||||
title: draft.title.trim(),
|
||||
subtitle: draft.subtitle.trim(),
|
||||
productIds: Array.from(new Set(draft.productIds.filter(Boolean))),
|
||||
isActive: draft.isActive,
|
||||
sortOrder: draft.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProductUsageMap(routeSections: RouteSection[], currentSectionId: string) {
|
||||
const usage = new Map<string, ProductUsage>();
|
||||
|
||||
routeSections.forEach((section) => {
|
||||
if (section.id === currentSectionId) return;
|
||||
section.productIds.forEach((productId) => {
|
||||
usage.set(productId, {
|
||||
sectionId: section.id,
|
||||
sectionTitle: section.title,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return usage;
|
||||
}
|
||||
Reference in New Issue
Block a user