feat(admin): add hotel groups and vehicle options site modules
Add support for two new standalone site content modules in the admin dashboard: hotel groups and vehicle options. This commit includes: - Added TypeScript types for HotelGroup and VehicleOption, extended SiteConfig and SiteModule enum - Updated admin utilities (primary key lookup, empty drafts, payload compaction) for the new modules - Added management UI panels and editors in the structure page for configuring the new modules - Adjusted module rail styling and interactive states for consistent UI across the admin - Updated ctaBanners type definition to include missing sortOrder, createdAt, and updatedAt fields - Updated API and admin documentation to cover the new modules' CRUD endpoints and field contracts
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
import type { ProductInput } from "@/api";
|
||||
import type { Product, ProductInput } from "@/api";
|
||||
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,32 +21,57 @@ type QuickRouteDraft = {
|
||||
sortWeight: number;
|
||||
};
|
||||
|
||||
const emptyDraft: QuickRouteDraft = {
|
||||
title: "",
|
||||
summary: "",
|
||||
destinationName: "",
|
||||
priceAmount: null,
|
||||
priceUnit: "起/人",
|
||||
tags: ["", ""],
|
||||
coverImage: "",
|
||||
published: true,
|
||||
sortWeight: 0,
|
||||
};
|
||||
function createEmptyDraft(): QuickRouteDraft {
|
||||
return {
|
||||
title: "",
|
||||
summary: "",
|
||||
destinationName: "",
|
||||
priceAmount: null,
|
||||
priceUnit: "起/人",
|
||||
tags: ["", ""],
|
||||
coverImage: "",
|
||||
published: true,
|
||||
sortWeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createDraftFromProduct(product?: Product | null): QuickRouteDraft {
|
||||
if (!product) return createEmptyDraft();
|
||||
|
||||
const tags = product.tags.length ? product.tags.slice(0, 3) : ["", ""];
|
||||
|
||||
return {
|
||||
title: product.title,
|
||||
summary: product.summary ?? "",
|
||||
destinationName: product.subtitle ?? product.destination?.name ?? "",
|
||||
priceAmount: product.priceAmount ?? null,
|
||||
priceUnit: product.priceUnit || "起/人",
|
||||
tags: tags.length === 1 ? [...tags, ""] : tags,
|
||||
coverImage: product.coverImage ?? "",
|
||||
published: product.status === "published",
|
||||
sortWeight: product.sortWeight ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function RouteProductQuickCreateForm({
|
||||
product,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onSubmitRef,
|
||||
}: {
|
||||
product?: Product | null;
|
||||
onCreate: (input: ProductInput) => Promise<void>;
|
||||
onUpdate: (productId: string, input: ProductInput) => Promise<void>;
|
||||
onSubmitRef: MutableRefObject<(() => Promise<void>) | null>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<QuickRouteDraft>(emptyDraft);
|
||||
const [draft, setDraft] = useState<QuickRouteDraft>(() => createDraftFromProduct(product));
|
||||
const editing = Boolean(product);
|
||||
|
||||
const submit = async () => {
|
||||
const title = draft.title.trim();
|
||||
if (!title) return;
|
||||
|
||||
await onCreate({
|
||||
const input: ProductInput = {
|
||||
title,
|
||||
subtitle: draft.destinationName.trim(),
|
||||
destinationId: null,
|
||||
@@ -55,20 +80,31 @@ export function RouteProductQuickCreateForm({
|
||||
tags: draft.tags.map((tag) => tag.trim()).filter(Boolean).slice(0, 3),
|
||||
coverImage: draft.coverImage.trim() || null,
|
||||
summary: draft.summary.trim(),
|
||||
images: [],
|
||||
detailSections: [],
|
||||
images: product?.images ?? [],
|
||||
detailSections: product?.detailSections ?? [],
|
||||
status: draft.published ? "published" : "draft",
|
||||
sortWeight: draft.sortWeight,
|
||||
});
|
||||
setDraft(emptyDraft);
|
||||
};
|
||||
|
||||
if (editing && product) {
|
||||
await onUpdate(product.id, input);
|
||||
return;
|
||||
}
|
||||
|
||||
await onCreate(input);
|
||||
setDraft(createEmptyDraft());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(createDraftFromProduct(product));
|
||||
}, [product?.id, product?.updatedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = submit;
|
||||
return () => {
|
||||
onSubmitRef.current = null;
|
||||
};
|
||||
}, [draft, onSubmitRef]);
|
||||
}, [draft, product?.id, product?.updatedAt, onSubmitRef]);
|
||||
|
||||
return (
|
||||
<div className="admin-disclosure-stack route-product-create-form">
|
||||
@@ -153,7 +189,7 @@ export function RouteProductQuickCreateForm({
|
||||
checked={draft.published}
|
||||
onCheckedChange={(checked) => setDraft({ ...draft, published: checked })}
|
||||
/>
|
||||
创建后上架
|
||||
{editing ? "保存为上架" : "创建后上架"}
|
||||
</label>
|
||||
</AdminDisclosure>
|
||||
</div>
|
||||
|
||||
@@ -3,60 +3,39 @@ 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 type { RouteSectionDraft } from "@/components/admin/route-sections/helpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function RouteSectionProductEditor({
|
||||
draft,
|
||||
products,
|
||||
productUsage,
|
||||
product,
|
||||
saving,
|
||||
onCreateProduct,
|
||||
onProductIdsChange,
|
||||
onUpdateProduct,
|
||||
onClose,
|
||||
}: {
|
||||
draft: RouteSectionDraft;
|
||||
products: Product[];
|
||||
productUsage: Map<string, ProductUsage>;
|
||||
product?: Product | null;
|
||||
saving: boolean;
|
||||
onCreateProduct: (input: ProductInput) => Promise<void>;
|
||||
onProductIdsChange: (productIds: string[]) => Promise<void>;
|
||||
onUpdateProduct: (productId: string, input: ProductInput) => 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);
|
||||
};
|
||||
const editing = Boolean(product);
|
||||
|
||||
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>
|
||||
<h3 id="route-section-product-editor-title">
|
||||
{editing ? "编辑线路" : "关联线路"}
|
||||
</h3>
|
||||
<small>
|
||||
{editing
|
||||
? `${draft.title} 的首页展示线路内容。`
|
||||
: `${draft.title} 的首页展示线路,创建后会自动关联到当前分组。`}
|
||||
</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -68,17 +47,11 @@ export function RouteSectionProductEditor({
|
||||
</Button>
|
||||
</header>
|
||||
<RouteProductQuickCreateForm
|
||||
product={product}
|
||||
onCreate={onCreateProduct}
|
||||
onUpdate={onUpdateProduct}
|
||||
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}>
|
||||
取消
|
||||
@@ -89,7 +62,7 @@ export function RouteSectionProductEditor({
|
||||
disabled={saving}
|
||||
>
|
||||
<Save size={17} />
|
||||
{saving ? "创建中" : "创建并关联"}
|
||||
{saving ? (editing ? "保存中" : "创建中") : editing ? "保存线路" : "创建并关联"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
createSiteConfigItem,
|
||||
deleteSiteConfigItem,
|
||||
reorderSiteConfigItems,
|
||||
updateProduct,
|
||||
updateSiteConfigItem,
|
||||
} from "@/api";
|
||||
import type { ProductInput } from "@/api";
|
||||
import { RouteSectionEditor } from "@/components/admin/route-sections/RouteSectionEditor";
|
||||
import {
|
||||
buildProductUsageMap,
|
||||
compactRouteSectionDraft,
|
||||
createEmptyRouteSectionDraft,
|
||||
createRouteSectionDraft,
|
||||
@@ -46,13 +46,17 @@ export function RouteSectionsPanel({
|
||||
const [draft, setDraft] = useState<RouteSectionDraft | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [productEditorOpen, setProductEditorOpen] = useState(false);
|
||||
const [editingProductId, setEditingProductId] = useState("");
|
||||
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].sort(
|
||||
(left, right) => left.sortOrder - right.sortOrder,
|
||||
),
|
||||
[routeSections],
|
||||
);
|
||||
const productById = useMemo(
|
||||
@@ -62,10 +66,9 @@ export function RouteSectionsPanel({
|
||||
const selectedSection =
|
||||
sortedSections.find((section) => section.id === selectedId) ??
|
||||
sortedSections[0];
|
||||
const productUsage = useMemo(
|
||||
() => buildProductUsageMap(routeSections, draft?.id ?? selectedSection?.id ?? ""),
|
||||
[draft?.id, routeSections, selectedSection?.id],
|
||||
);
|
||||
const editingProduct = editingProductId
|
||||
? (productById.get(editingProductId) ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const firstSection = sortedSections[0];
|
||||
@@ -75,18 +78,23 @@ export function RouteSectionsPanel({
|
||||
setDraft(null);
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSection =
|
||||
sortedSections.find((section) => section.id === selectedId) ?? firstSection;
|
||||
sortedSections.find((section) => section.id === selectedId) ??
|
||||
firstSection;
|
||||
setSelectedId(nextSection.id);
|
||||
setExpandedId((current) =>
|
||||
sortedSections.some((section) => section.id === current) ? current : nextSection.id,
|
||||
sortedSections.some((section) => section.id === current)
|
||||
? current
|
||||
: nextSection.id,
|
||||
);
|
||||
setDraft(createRouteSectionDraft(nextSection));
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(false);
|
||||
setMessage("");
|
||||
}, [routeSections]);
|
||||
@@ -99,6 +107,7 @@ export function RouteSectionsPanel({
|
||||
setDraft(createEmptyRouteSectionDraft(sortedSections.length));
|
||||
setEditorOpen(true);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(true);
|
||||
setMessage("");
|
||||
onDirtyChange(false);
|
||||
@@ -111,14 +120,16 @@ export function RouteSectionsPanel({
|
||||
setMessage("");
|
||||
setEditorOpen(true);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
};
|
||||
|
||||
const openProductEditor = (section: RouteSection) => {
|
||||
const openProductEditor = (section: RouteSection, product?: Product) => {
|
||||
setSelectedId(section.id);
|
||||
setExpandedId(section.id);
|
||||
setDraft(createRouteSectionDraft(section));
|
||||
setEditingProductId(product?.id ?? "");
|
||||
setMessage("");
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(true);
|
||||
@@ -132,6 +143,7 @@ export function RouteSectionsPanel({
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(false);
|
||||
setMessage("");
|
||||
onDirtyChange(false);
|
||||
@@ -190,40 +202,6 @@ export function RouteSectionsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -263,10 +241,94 @@ export function RouteSectionsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const saveLinkedProduct = async (productId: string, input: ProductInput) => {
|
||||
if (saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const product = await updateProduct(
|
||||
productId,
|
||||
compactProductPayload(input),
|
||||
);
|
||||
setEditingProductId(product.id);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "线路已保存",
|
||||
message: `${product.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 unlinkProductFromSection = async (
|
||||
section: RouteSection,
|
||||
product: Product,
|
||||
) => {
|
||||
if (saving) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`确认从「${section.title}」移除「${product.title}」?商品本体不会被删除。`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const nextDraft = {
|
||||
...createRouteSectionDraft(section),
|
||||
productIds: section.productIds.filter(
|
||||
(productId) => productId !== product.id,
|
||||
),
|
||||
};
|
||||
await updateSiteConfigItem(
|
||||
"routeSections",
|
||||
section.id,
|
||||
compactRouteSectionDraft(nextDraft),
|
||||
);
|
||||
if (draft?.id === section.id) {
|
||||
setDraft(nextDraft);
|
||||
}
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "关联线路已移除",
|
||||
message: `${product.title} 已从 ${section.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 currentIndex = sortedSections.findIndex(
|
||||
(candidate) => candidate.id === section.id,
|
||||
);
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length) return;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= sortedSections.length)
|
||||
return;
|
||||
|
||||
const nextIds = sortedSections.map((candidate) => candidate.id);
|
||||
[nextIds[currentIndex], nextIds[nextIndex]] = [
|
||||
@@ -317,6 +379,7 @@ export function RouteSectionsPanel({
|
||||
setDraft(null);
|
||||
setEditorOpen(false);
|
||||
setProductEditorOpen(false);
|
||||
setEditingProductId("");
|
||||
setCreating(false);
|
||||
onDirtyChange(false);
|
||||
await onReload();
|
||||
@@ -345,7 +408,10 @@ export function RouteSectionsPanel({
|
||||
<div className="route-section-card-list">
|
||||
{sortedSections.map((section, index) => {
|
||||
const previewProducts = sectionProducts(section);
|
||||
const missingProductCount = Math.max(0, section.productIds.length - previewProducts.length);
|
||||
const missingProductCount = Math.max(
|
||||
0,
|
||||
section.productIds.length - previewProducts.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -353,54 +419,91 @@ export function RouteSectionsPanel({
|
||||
key={section.id}
|
||||
>
|
||||
<div className="route-section-accordion-head">
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className="route-section-accordion-toggle"
|
||||
onClick={() =>
|
||||
setExpandedId((current) => (current === section.id ? "" : section.id))
|
||||
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 className="route-section-card-copy">
|
||||
<span className="route-section-card-titleline">
|
||||
<b>{section.title}</b>
|
||||
</span>
|
||||
</button>
|
||||
<small>{section.subtitle || "未填写副文案"}</small>
|
||||
</span>
|
||||
</div>
|
||||
</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}>
|
||||
{previewProducts.map((product) => (
|
||||
<div
|
||||
className="route-section-preview-item"
|
||||
key={product.id}
|
||||
>
|
||||
{product.coverImage ? (
|
||||
<img src={product.coverImage} alt="" />
|
||||
) : (
|
||||
<i />
|
||||
)}
|
||||
<b>{product.title}</b>
|
||||
</span>
|
||||
<b title={product.title}>{product.title}</b>
|
||||
<span className="route-section-preview-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="route-section-preview-edit"
|
||||
onClick={() =>
|
||||
openProductEditor(section, product)
|
||||
}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="route-section-preview-delete"
|
||||
onClick={() =>
|
||||
unlinkProductFromSection(section, product)
|
||||
}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
删除
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!previewProducts.length ? (
|
||||
<p>暂无关联线路</p>
|
||||
) : null}
|
||||
{!previewProducts.length ? <p>暂无关联线路</p> : null}
|
||||
{missingProductCount ? (
|
||||
<p>{missingProductCount} 条已配置商品暂未从商品库返回详情</p>
|
||||
<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="flex min-w-0 items-center gap-2">
|
||||
<Badge
|
||||
variant={section.isActive ? "success" : "muted"}
|
||||
className={`status-chip ${section.isActive ? "published" : "archived"}`}
|
||||
>
|
||||
{section.isActive ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={section.productIds.length > 0 ? "success" : "muted"}
|
||||
className="shrink-0"
|
||||
>
|
||||
已关联 {section.productIds.length} 条线路
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="route-section-card-action-tools">
|
||||
<div className="route-section-card-primary-actions">
|
||||
<Button
|
||||
@@ -460,9 +563,7 @@ export function RouteSectionsPanel({
|
||||
</p>
|
||||
)}
|
||||
{message ? (
|
||||
<p className="mapped-empty route-section-message">
|
||||
{message}
|
||||
</p>
|
||||
<p className="mapped-empty route-section-message">{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{editorOpen && draft ? (
|
||||
@@ -506,11 +607,10 @@ export function RouteSectionsPanel({
|
||||
>
|
||||
<RouteSectionProductEditor
|
||||
draft={draft}
|
||||
products={products}
|
||||
productUsage={productUsage}
|
||||
product={editingProduct}
|
||||
saving={saving}
|
||||
onCreateProduct={createAndLinkProduct}
|
||||
onProductIdsChange={updateProductLinks}
|
||||
onUpdateProduct={saveLinkedProduct}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user