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:
duanshuwen
2026-07-02 23:05:45 +08:00
parent 50be3cbfce
commit 9fd91c84e5
14 changed files with 1732 additions and 21 deletions

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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}
</>
);
}

View 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;
}