套餐编码可视化改造

This commit is contained in:
2026-04-08 15:41:32 +08:00
parent 0789462a8d
commit 30af977543
8 changed files with 332 additions and 56 deletions

View File

@@ -3,7 +3,7 @@
"version": "1.1.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0 --turbopack", "dev": "next dev -H 0.0.0.0 -p 3001 --turbopack",
"build": "next build --turbopack", "build": "next build --turbopack",
"lint": "biome check --write" "lint": "biome check --write"
}, },

View File

@@ -5,6 +5,7 @@ import { toast } from "sonner"
import z from "zod" import z from "zod"
import { createProductSku } from "@/actions/product" import { createProductSku } from "@/actions/product"
import { getAllProductDiscount } from "@/actions/product_discount" import { getAllProductDiscount } from "@/actions/product_discount"
import { ProductCodeField } from "@/components/products"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -20,6 +21,7 @@ import {
FieldError, FieldError,
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
FieldSeparator,
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@@ -30,6 +32,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import type { ProductDiscount } from "@/models/product_discount" import type { ProductDiscount } from "@/models/product_discount"
import type { SelectedProduct } from "./type"
const schema = z.object({ const schema = z.object({
code: z.string().min(1, "请输入套餐编码"), code: z.string().min(1, "请输入套餐编码"),
@@ -45,7 +48,7 @@ const schema = z.object({
}) })
export function CreateProductSku(props: { export function CreateProductSku(props: {
productId: number product?: SelectedProduct
onSuccess?: () => void onSuccess?: () => void
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -74,9 +77,11 @@ export function CreateProductSku(props: {
}, [open]) }, [open])
const onSubmit = async (data: z.infer<typeof schema>) => { const onSubmit = async (data: z.infer<typeof schema>) => {
if (!props.product) return
try { try {
const resp = await createProductSku({ const resp = await createProductSku({
product_id: props.productId, product_id: props.product.id,
code: data.code, code: data.code,
name: data.name, name: data.name,
price: data.price, price: data.price,
@@ -109,7 +114,7 @@ export function CreateProductSku(props: {
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button disabled={!props.productId}></Button> <Button disabled={!props.product}></Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
@@ -119,25 +124,6 @@ export function CreateProductSku(props: {
<form id="sku-create" onSubmit={form.handleSubmit(onSubmit)}> <form id="sku-create" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup> <FieldGroup>
<Controller
control={form.control}
name="code"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-create-code"></FieldLabel>
<Input
id="sku-create-code"
placeholder="请输入套餐编码"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller <Controller
control={form.control} control={form.control}
name="name" name="name"
@@ -203,6 +189,16 @@ export function CreateProductSku(props: {
</Field> </Field>
)} )}
/> />
<FieldSeparator />
{props.product && (
<ProductCodeField
control={form.control}
name="code"
code={props.product.code}
/>
)}
</FieldGroup> </FieldGroup>
</form> </form>

View File

@@ -9,6 +9,7 @@ import {
getPageProductSku, getPageProductSku,
} from "@/actions/product" } from "@/actions/product"
import { DataTable, useDataTable } from "@/components/data-table" import { DataTable, useDataTable } from "@/components/data-table"
import { SkuCodeBadge } from "@/components/products/format"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -22,15 +23,19 @@ import {
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { ProductCode } from "@/lib/base"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { Product } from "@/models/product" import type { Product } from "@/models/product"
import type { ProductSku } from "@/models/product_sku" import type { ProductSku } from "@/models/product_sku"
import { BatchUpdateDiscount } from "./batch-discount" import { BatchUpdateDiscount } from "./batch-discount"
import { CreateProductSku } from "./create" import { CreateProductSku } from "./create"
import type { SelectedProduct } from "./type"
import { UpdateProductSku } from "./update" import { UpdateProductSku } from "./update"
export default function ProductPage() { export default function ProductPage() {
const [selected, setSelected] = useState<number | undefined>(undefined) const [selected, setSelected] = useState<SelectedProduct | undefined>(
undefined,
)
return ( return (
<div className="size-full flex gap-6 items-stretch"> <div className="size-full flex gap-6 items-stretch">
@@ -41,8 +46,8 @@ export default function ProductPage() {
} }
function Products(props: { function Products(props: {
selected?: number selected?: SelectedProduct
onSelect?: (id: number) => void onSelect?: (id: SelectedProduct) => void
}) { }) {
const [list, setList] = useState<Product[]>([]) const [list, setList] = useState<Product[]>([])
@@ -54,7 +59,7 @@ function Products(props: {
}, []) }, [])
const selected = useMemo(() => { const selected = useMemo(() => {
return list.find(item => item.id === props.selected) return list.find(item => item.id === props.selected?.id)
}, [list, props.selected]) }, [list, props.selected])
useEffect(() => { useEffect(() => {
@@ -75,7 +80,7 @@ function Products(props: {
"size-full box-border p-2 rounded-md flex justify-between items-center select-none", "size-full box-border p-2 rounded-md flex justify-between items-center select-none",
selected?.id === item.id && "bg-primary/20", selected?.id === item.id && "bg-primary/20",
)} )}
onClick={() => props.onSelect?.(item.id)} onClick={() => props.onSelect?.({ id: item.id, code: item.code })}
> >
<div> <div>
<p>{item.name}</p> <p>{item.name}</p>
@@ -90,10 +95,15 @@ function Products(props: {
) )
} }
function ProductSkus(props: { selected?: number }) { function ProductSkus(props: {
selected?: {
id: number
code: ProductCode
}
}) {
const action = useCallback( const action = useCallback(
(page: number, size: number) => (page: number, size: number) =>
getPageProductSku({ page, size, product_id: props.selected }), getPageProductSku({ page, size, product_id: props.selected?.id }),
[props.selected], [props.selected],
) )
@@ -102,12 +112,9 @@ function ProductSkus(props: { selected?: number }) {
return ( return (
<div className="flex-auto overflow-hidden flex flex-col items-stretch gap-3"> <div className="flex-auto overflow-hidden flex flex-col items-stretch gap-3">
<div className="flex gap-3"> <div className="flex gap-3">
<CreateProductSku <CreateProductSku product={props.selected} onSuccess={table.refresh} />
productId={props.selected ?? 0}
onSuccess={table.refresh}
/>
<BatchUpdateDiscount <BatchUpdateDiscount
productId={props.selected ?? 0} productId={props.selected?.id ?? 0}
onSuccess={table.refresh} onSuccess={table.refresh}
/> />
</div> </div>
@@ -118,7 +125,18 @@ function ProductSkus(props: { selected?: number }) {
}} }}
{...table} {...table}
columns={[ columns={[
{ header: "套餐编码", accessorKey: "code" }, {
header: "套餐编码",
cell: ({ row }) =>
row.original.product ? (
<SkuCodeBadge
productCode={row.original.product.code}
skuCode={row.original.code}
/>
) : (
row.original.code
),
},
{ header: "套餐名称", accessorKey: "name" }, { header: "套餐名称", accessorKey: "name" },
{ header: "单价", accessorFn: row => Number(row.price).toFixed(2) }, { header: "单价", accessorFn: row => Number(row.price).toFixed(2) },
{ header: "折扣", accessorFn: row => row.discount?.name ?? "—" }, { header: "折扣", accessorFn: row => row.discount?.name ?? "—" },

View File

@@ -0,0 +1,6 @@
import type { ProductCode } from "@/lib/base"
export type SelectedProduct = {
id: number
code: ProductCode
}

View File

@@ -5,6 +5,7 @@ import { toast } from "sonner"
import z from "zod" import z from "zod"
import { updateProductSku } from "@/actions/product" import { updateProductSku } from "@/actions/product"
import { getAllProductDiscount } from "@/actions/product_discount" import { getAllProductDiscount } from "@/actions/product_discount"
import { ProductCodeField } from "@/components/products"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -20,6 +21,7 @@ import {
FieldError, FieldError,
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
FieldSeparator,
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@@ -124,25 +126,6 @@ export function UpdateProductSku(props: {
<form id="sku-update" onSubmit={form.handleSubmit(onSubmit)}> <form id="sku-update" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup> <FieldGroup>
<Controller
control={form.control}
name="code"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-update-code"></FieldLabel>
<Input
id="sku-update-code"
placeholder="请输入套餐编码"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller <Controller
control={form.control} control={form.control}
name="name" name="name"
@@ -208,6 +191,16 @@ export function UpdateProductSku(props: {
</Field> </Field>
)} )}
/> />
<FieldSeparator />
{props.sku.product && (
<ProductCodeField
control={form.control}
name="code"
code={props.sku.product.code}
/>
)}
</FieldGroup> </FieldGroup>
</form> </form>

View File

@@ -0,0 +1,62 @@
import type { ReactNode } from "react"
import { Badge } from "@/components/ui/badge"
import { ProductCode } from "@/lib/base"
interface SkuCodeBadgeProps {
productCode: ProductCode
skuCode: string
}
export function SkuCodeBadge({
productCode,
skuCode,
}: SkuCodeBadgeProps): ReactNode {
switch (productCode) {
case ProductCode.Short:
case ProductCode.Long:
return <ParsedSkuCodeBadge skuCode={skuCode} />
default:
return <Badge variant="outline">{skuCode}</Badge>
}
}
function ParsedSkuCodeBadge({ skuCode }: { skuCode: string }): ReactNode {
const params = new URLSearchParams(skuCode)
const modeStr = params.get("mode")
let mode: string | undefined
let modeClass: string | undefined
switch (modeStr) {
case "time":
mode = "包时"
modeClass = "bg-green-50"
break
case "quota":
mode = "包量"
modeClass = "bg-blue-50"
break
}
const live = params.get("live")
const expire = params.get("expire")
if (!mode || !live || !expire) {
return (
<Badge variant="secondary" className="bg-red-50">
{skuCode}
</Badge>
)
}
return (
<div className="flex flex-wrap gap-1">
<Badge variant="secondary" className={modeClass}>
{mode}
</Badge>
<Badge variant="secondary">{live} </Badge>
{expire !== "0" && (
<Badge variant="secondary">{expire} </Badge>
)}
</div>
)
}

View File

@@ -0,0 +1,200 @@
import type { ChangeEvent } from "react"
import {
type Control,
type Path,
type UseControllerReturn,
useController,
} from "react-hook-form"
import { ProductCode } from "@/lib/base"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
} from "../ui/field"
import { Input } from "../ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select"
export function ProductCodeField<
T extends {
code: string
},
>(props: { control: Control<T>; name: Path<T>; code: ProductCode }) {
const rt = useController(props)
switch (props.code) {
case ProductCode.Short:
return <ProductShortCode {...rt} />
case ProductCode.Long:
return <ProductLongCode {...rt} />
}
return null
}
function ProductShortCode<T extends { code: string }>(
props: UseControllerReturn<T>,
) {
const { field, fieldState } = props
const params = new URLSearchParams(field.value)
const setParams = (data: {
mode?: string
live?: string
expire?: string
}) => {
if (data.mode) params.set("mode", data.mode)
if (data.live) params.set("live", data.live)
if (data.expire) params.set("expire", data.expire)
field.onChange(params.toString())
}
const onModeChange = (value: string) => {
setParams({ mode: value })
}
const onLiveChange = (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value || "0"
if (value.length > 1 && value[0] === "0") {
value = value.substring(1, value.length)
}
if (!/^([0-9]+)$/.test(value)) return
setParams({ live: value })
}
const onExpireChange = (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value || "0"
if (value.length > 1 && value[0] === "0") {
value = value.substring(1, value.length)
}
if (!/^([0-9]+)$/.test(value)) return
setParams({ expire: value })
}
return (
<FieldLegend>
<FieldLegend></FieldLegend>
<FieldGroup>
<Field>
<FieldLabel></FieldLabel>
<Select
defaultValue={params.get("mode") ?? "quota"}
onValueChange={onModeChange}
>
<SelectTrigger>
<SelectValue placeholder="请选择套餐类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="time"></SelectItem>
<SelectItem value="quota"></SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel></FieldLabel>
<Input
type="number"
value={params.get("live") ?? "0"}
onChange={onLiveChange}
/>
</Field>
{params.get("mode") === "time" && (
<Field>
<FieldLabel></FieldLabel>
<Input
type="number"
value={params.get("expire") ?? "0"}
onChange={onExpireChange}
/>
</Field>
)}
{fieldState.error && <FieldError errors={[fieldState.error]} />}
</FieldGroup>
</FieldLegend>
)
}
function ProductLongCode<T extends { code: string }>(
props: UseControllerReturn<T>,
) {
const { field, fieldState } = props
const params = new URLSearchParams(field.value)
const setParams = (data: {
mode?: string
live?: string
expire?: string
}) => {
if (data.mode) params.set("mode", data.mode)
if (data.live) params.set("live", data.live)
if (data.expire) params.set("expire", data.expire)
field.onChange(params.toString())
}
const onModeChange = (value: string) => {
setParams({ mode: value })
}
const onLiveChange = (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value || "0"
if (value.length > 1 && value[0] === "0") {
value = value.substring(1, value.length)
}
if (!/^([0-9]+)$/.test(value)) return
setParams({ live: value })
}
const onExpireChange = (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value || "0"
if (value.length > 1 && value[0] === "0") {
value = value.substring(1, value.length)
}
if (!/^([0-9]+)$/.test(value)) return
setParams({ expire: value })
}
return (
<FieldLegend>
<FieldLegend></FieldLegend>
<FieldGroup>
<Field>
<FieldLabel></FieldLabel>
<Select
defaultValue={params.get("mode") ?? "quota"}
onValueChange={onModeChange}
>
<SelectTrigger>
<SelectValue placeholder="请选择套餐类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="time"></SelectItem>
<SelectItem value="quota"></SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel></FieldLabel>
<Input
type="number"
value={params.get("live") ?? "0"}
onChange={onLiveChange}
/>
</Field>
{params.get("mode") === "time" && (
<Field>
<FieldLabel></FieldLabel>
<Input
type="number"
value={params.get("expire") ?? "0"}
onChange={onExpireChange}
/>
</Field>
)}
{fieldState.error && <FieldError errors={[fieldState.error]} />}
</FieldGroup>
</FieldLegend>
)
}

View File

@@ -1,8 +1,9 @@
import type { ProductCode } from "@/lib/base"
import type { Model } from "./base/model" import type { Model } from "./base/model"
import type { ProductSku } from "./product_sku" import type { ProductSku } from "./product_sku"
export type Product = Model & { export type Product = Model & {
code: string code: ProductCode
name: string name: string
description?: string description?: string
sort: number sort: number