实现价格折扣动态调整

This commit is contained in:
2026-03-24 17:14:50 +08:00
parent 8751ac19a6
commit 523d46874b
13 changed files with 1189 additions and 56 deletions

View File

@@ -19,3 +19,49 @@ export async function getPageProductSku(params: {
params,
)
}
export async function createProductSku(data: {
product_id: number
code: string
name: string
price: string
discount_id?: number
}) {
return callByUser<ProductSku>("/api/admin/product/sku/create", {
product_id: data.product_id,
code: data.code,
name: data.name,
price: data.price,
discount_id: data.discount_id,
})
}
export async function updateProductSku(data: {
id: number
code?: string
name?: string
price?: string
discount_id?: number | null
}) {
return callByUser<ProductSku>("/api/admin/product/sku/update", {
id: data.id,
code: data.code,
name: data.name,
price: data.price,
discount_id: data.discount_id,
})
}
export async function deleteProductSku(id: number) {
return callByUser<ProductSku>("/api/admin/product/sku/remove", { id })
}
export async function batchUpdateProductSkuDiscount(data: {
product_id: number
discount_id: number | null
}) {
return callByUser<void>("/api/admin/product/sku/update/discount/batch", {
product_id: data.product_id,
discount_id: data.discount_id,
})
}

View File

@@ -0,0 +1,47 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { ProductDiscount } from "@/models/product_discount"
import { callByUser } from "./base"
export async function getAllProductDiscount() {
return callByUser<ProductDiscount[]>("/api/admin/product/discount/all")
}
export async function getPageProductDiscount(params: {
page: number
size: number
}) {
return callByUser<PageRecord<ProductDiscount>>(
"/api/admin/product/discount/page",
params,
)
}
export async function createProductDiscount(data: {
name: string
discount: string
}) {
return callByUser<ProductDiscount>("/api/admin/product/discount/create", {
name: data.name,
discount: Number(data.discount),
})
}
export async function updateProductDiscount(data: {
id: number
name?: string
discount?: string
}) {
return callByUser<ProductDiscount>("/api/admin/product/discount/update", {
id: data.id,
name: data.name,
discount: data.discount ? Number(data.discount) : undefined,
})
}
export async function deleteProductDiscount(id: number) {
return callByUser<ProductDiscount>("/api/admin/product/discount/remove", {
id,
})
}

View File

@@ -0,0 +1,122 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { createProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const schema = z.object({
name: z.string().min(1, "请输入折扣名称"),
discount: z.string().min(1, "请输入折扣代码"),
})
export function CreateDiscount(props: { onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: "",
discount: "",
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await createProductDiscount(data)
if (resp.success) {
form.reset()
toast.success("折扣创建成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="discount-create" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="discount-create-name"></FieldLabel>
<Input
id="discount-create-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="discount"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="discount-create-discount">
</FieldLabel>
<Input
id="discount-create-discount"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="discount-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { format } from "date-fns"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import {
deleteProductDiscount,
getPageProductDiscount,
} from "@/actions/product_discount"
import { DataTable, useDataTable } from "@/components/data-table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import type { ProductDiscount } from "@/models/product_discount"
import { CreateDiscount } from "./create"
import { UpdateDiscount } from "./update"
export default function DiscountPage() {
const table = useDataTable((page, size) =>
getPageProductDiscount({ page, size }),
)
return (
<div className="flex flex-col gap-3">
{/* 操作栏 */}
<div className="flex justify-between items-stretch">
<div className="flex gap-3">
<CreateDiscount onSuccess={table.refresh} />
</div>
</div>
{/* 数据表 */}
<Suspense>
<DataTable<ProductDiscount>
{...table}
columns={[
{ header: "名称", accessorKey: "name" },
{ header: "折扣", accessorKey: "discount" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
header: "更新时间",
accessorKey: "updated_at",
cell: ({ row }) =>
format(new Date(row.original.updated_at), "yyyy-MM-dd HH:mm"),
},
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<UpdateDiscount
discount={row.original}
onSuccess={table.refresh}
/>
<DeleteButton
discount={row.original}
onSuccess={table.refresh}
/>
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function DeleteButton({
discount,
onSuccess,
}: {
discount: ProductDiscount
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteProductDiscount(discount.id)
if (resp.success) {
toast.success("删除成功")
onSuccess?.()
} else {
toast.error(resp.message ?? "删除失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{discount.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,140 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { updateProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import type { ProductDiscount } from "@/models/product_discount"
const schema = z.object({
name: z.string().min(1, "请输入折扣名称"),
discount: z.string().min(1, "请输入折扣代码"),
})
export function UpdateDiscount(props: {
discount: ProductDiscount
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: props.discount.name,
discount: String(props.discount.discount),
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await updateProductDiscount({
id: props.discount.id,
...data,
})
if (resp.success) {
toast.success("折扣修改成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
name: props.discount.name,
discount: String(props.discount.discount),
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="discount-update" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="discount-update-name"></FieldLabel>
<Input
id="discount-update-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="discount"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="discount-update-discount">
</FieldLabel>
<Input
id="discount-update-discount"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="discount-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -14,6 +14,8 @@ import {
Package,
Shield,
ShoppingBag,
SquarePercent,
SquarePercentIcon,
Users,
} from "lucide-react"
import Link from "next/link"
@@ -194,6 +196,11 @@ export default function Navigation() {
{/* 运营 */}
<NavGroup title="运营">
<NavItem href="/product" icon={ShoppingBag} label="产品管理" />
<NavItem
href="/discount"
icon={SquarePercent}
label="折扣管理"
/>
<NavItem href="/resources" icon={Package} label="套餐管理" />
<NavItem href="/batch" icon={ClipboardList} label="使用记录" />
<NavItem href="/channel" icon={Code} label="IP管理" />

View File

@@ -0,0 +1,144 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { batchUpdateProductSkuDiscount } from "@/actions/product"
import { getAllProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ProductDiscount } from "@/models/product_discount"
const schema = z.object({
discount_id: z.string().min(1, "请选择折扣"),
})
export function BatchUpdateDiscount(props: {
productId: number
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
discount_id: "",
},
})
useEffect(() => {
if (open) {
getAllProductDiscount().then(resp => {
if (resp.success) {
setDiscounts(resp.data)
}
})
}
}, [open])
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await batchUpdateProductSkuDiscount({
product_id: props.productId,
discount_id:
data.discount_id === "none" ? null : Number(data.discount_id),
})
if (resp.success) {
toast.success("批量配置折扣成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message ?? "操作失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset()
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" disabled={!props.productId}>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="sku-batch-discount" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="discount_id"
render={({ field, fieldState }) => (
<Field>
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue placeholder="请选择要应用的折扣" />
</SelectTrigger>
<SelectContent>
{discounts.map(d => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name}{d.discount}%
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="sku-batch-discount">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,220 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { createProductSku } from "@/actions/product"
import { getAllProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ProductDiscount } from "@/models/product_discount"
const schema = z.object({
code: z.string().min(1, "请输入套餐编码"),
name: z.string().min(1, "请输入套餐名称"),
price: z
.string()
.min(1, "请输入单价")
.refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数单价",
),
discount_id: z.string().optional(),
})
export function CreateProductSku(props: {
productId: number
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
code: "",
name: "",
price: "",
discount_id: "",
},
})
useEffect(() => {
if (open) {
getAllProductDiscount()
.then(resp => {
if (resp.success) {
setDiscounts(resp.data)
}
})
.catch(e => toast.error(e.message))
}
}, [open])
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await createProductSku({
product_id: props.productId,
code: data.code,
name: data.name,
price: data.price,
discount_id:
data.discount_id && data.discount_id !== ""
? Number(data.discount_id)
: undefined,
})
if (resp.success) {
form.reset()
toast.success("套餐创建成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset()
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button disabled={!props.productId}></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="sku-create" onSubmit={form.handleSubmit(onSubmit)}>
<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
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-create-name"></FieldLabel>
<Input
id="sku-create-name"
placeholder="请输入套餐名称"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="price"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-create-price"></FieldLabel>
<Input
id="sku-create-price"
placeholder="请输入单价"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="discount_id"
render={({ field, fieldState }) => (
<Field>
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue placeholder="选择折扣" />
</SelectTrigger>
<SelectContent>
{discounts.map(d => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name}{d.discount}%
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="sku-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,19 +1,33 @@
"use client"
import { format } from "date-fns"
import { EyeIcon, EyeOffIcon, PlusIcon, TrashIcon } from "lucide-react"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { getAllProduct, getPageProductSku } from "@/actions/product"
import { toast } from "sonner"
import {
deleteProductSku,
getAllProduct,
getPageProductSku,
} from "@/actions/product"
import { DataTable, useDataTable } from "@/components/data-table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { Product } from "@/models/product"
import type { ProductSku } from "@/models/product_sku"
import { BatchUpdateDiscount } from "./batch-discount"
import { CreateProductSku } from "./create"
import { UpdateProductSku } from "./update"
export default function ProductPage() {
const [selected, setSelected] = useState<number | undefined>(undefined)
@@ -36,7 +50,6 @@ function Products(props: {
const resp = await getAllProduct()
if (resp.success) {
setList(resp.data)
console.log(resp.data)
}
}, [])
@@ -52,46 +65,10 @@ function Products(props: {
<section className="flex-none basis-64 bg-background rounded-lg">
<header className="pl-3 pr-1 h-10 border-b flex items-center justify-between">
<h3 className="text-sm"></h3>
<div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<PlusIcon />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
{!!selected && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
{selected.status ? <EyeOffIcon /> : <EyeIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{selected.status ? "禁用产品" : "启用产品"}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</div>
</header>
<ul>
<ul className="flex flex-col gap-1 py-1">
{list.map(item => (
<li key={item.id} className="p-1">
<li key={item.id} className="px-1">
<Button
variant="ghost"
className={cn(
@@ -125,11 +102,18 @@ function ProductSkus(props: { selected?: number }) {
return (
<div className="flex-auto overflow-hidden flex flex-col items-stretch gap-3">
<div>
<Button></Button>
<div className="flex gap-3">
<CreateProductSku
productId={props.selected ?? 0}
onSuccess={table.refresh}
/>
<BatchUpdateDiscount
productId={props.selected ?? 0}
onSuccess={table.refresh}
/>
</div>
<Suspense>
<DataTable
<DataTable<ProductSku>
classNames={{
root: "overflow-auto",
}}
@@ -138,10 +122,13 @@ function ProductSkus(props: { selected?: number }) {
{ header: "套餐编码", accessorKey: "code" },
{ header: "套餐名称", accessorKey: "name" },
{ header: "单价", accessorKey: "price" },
{ header: "折扣", accessorKey: "discount" },
{ header: "折扣", accessorFn: row => row.discount?.name ?? "—" },
{
header: "最终价格",
accessorFn: row => Number(row.price) * row.discount,
accessorFn: row =>
row.discount
? (Number(row.price) * Number(row.discount.discount)) / 100
: Number(row.price),
},
{
header: "创建时间",
@@ -151,10 +138,67 @@ function ProductSkus(props: { selected?: number }) {
header: "更新时间",
accessorFn: row => format(row.updated_at, "yyyy-MM-dd HH:mm"),
},
{ header: "操作", cell: () => <div></div> },
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-1">
<UpdateProductSku
sku={row.original}
onSuccess={table.refresh}
/>
<DeleteButton sku={row.original} onSuccess={table.refresh} />
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function DeleteButton(props: { sku: ProductSku; onSuccess?: () => void }) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteProductSku(props.sku.id)
if (resp.success) {
toast.success("删除成功")
props.onSuccess?.()
} else {
toast.error(resp.message ?? "删除失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{props.sku.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,225 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { updateProductSku } from "@/actions/product"
import { getAllProductDiscount } from "@/actions/product_discount"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ProductDiscount } from "@/models/product_discount"
import type { ProductSku } from "@/models/product_sku"
const schema = z.object({
code: z.string().min(1, "请输入套餐编码"),
name: z.string().min(1, "请输入套餐名称"),
price: z
.string()
.min(1, "请输入单价")
.refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数单价",
),
discount_id: z.string().optional(),
})
export function UpdateProductSku(props: {
sku: ProductSku
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
code: props.sku.code,
name: props.sku.name,
price: props.sku.price,
discount_id: props.sku.discount ? String(props.sku.discount.id) : "",
},
})
useEffect(() => {
if (open) {
getAllProductDiscount().then(resp => {
if (resp.success) {
setDiscounts(resp.data)
}
})
}
}, [open])
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await updateProductSku({
id: props.sku.id,
code: data.code,
name: data.name,
price: data.price,
discount_id:
data.discount_id && data.discount_id !== ""
? Number(data.discount_id)
: null,
})
if (resp.success) {
toast.success("套餐修改成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
code: props.sku.code,
name: props.sku.name,
price: props.sku.price,
discount_id: props.sku.discount ? String(props.sku.discount.id) : "",
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="sku-update" onSubmit={form.handleSubmit(onSubmit)}>
<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
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-update-name"></FieldLabel>
<Input
id="sku-update-name"
placeholder="请输入套餐名称"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="price"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-update-price"></FieldLabel>
<Input
id="sku-update-price"
placeholder="请输入单价"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="discount_id"
render={({ field, fieldState }) => (
<Field>
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue placeholder="选择折扣" />
</SelectTrigger>
<SelectContent>
{discounts.map(d => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name}{d.discount}%
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="sku-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react"
import { cn } from "@/lib/utils"
@@ -13,7 +13,7 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
@@ -33,7 +33,7 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
},
)
function Button({

View File

@@ -0,0 +1,6 @@
import type { Model } from "./base/model"
export type ProductDiscount = Model & {
name: string
discount: number
}

View File

@@ -1,12 +1,13 @@
import type { Model } from "./base/model"
import type { Product } from "./product"
import type { ProductDiscount } from "./product_discount"
export type ProductSku = Model & {
product_id: number
code: string
name: string
price: string
discount: number
product?: Product
discount?: ProductDiscount
}