实现价格折扣动态调整
This commit is contained in:
@@ -19,3 +19,49 @@ export async function getPageProductSku(params: {
|
|||||||
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
47
src/actions/product_discount.ts
Normal file
47
src/actions/product_discount.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
122
src/app/(root)/discount/create.tsx
Normal file
122
src/app/(root)/discount/create.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
src/app/(root)/discount/page.tsx
Normal file
131
src/app/(root)/discount/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/app/(root)/discount/update.tsx
Normal file
140
src/app/(root)/discount/update.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Shield,
|
Shield,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
|
SquarePercent,
|
||||||
|
SquarePercentIcon,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@@ -194,6 +196,11 @@ export default function Navigation() {
|
|||||||
{/* 运营 */}
|
{/* 运营 */}
|
||||||
<NavGroup title="运营">
|
<NavGroup title="运营">
|
||||||
<NavItem href="/product" icon={ShoppingBag} label="产品管理" />
|
<NavItem href="/product" icon={ShoppingBag} label="产品管理" />
|
||||||
|
<NavItem
|
||||||
|
href="/discount"
|
||||||
|
icon={SquarePercent}
|
||||||
|
label="折扣管理"
|
||||||
|
/>
|
||||||
<NavItem href="/resources" icon={Package} label="套餐管理" />
|
<NavItem href="/resources" icon={Package} label="套餐管理" />
|
||||||
<NavItem href="/batch" icon={ClipboardList} label="使用记录" />
|
<NavItem href="/batch" icon={ClipboardList} label="使用记录" />
|
||||||
<NavItem href="/channel" icon={Code} label="IP管理" />
|
<NavItem href="/channel" icon={Code} label="IP管理" />
|
||||||
|
|||||||
144
src/app/(root)/product/batch-discount.tsx
Normal file
144
src/app/(root)/product/batch-discount.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
src/app/(root)/product/create.tsx
Normal file
220
src/app/(root)/product/create.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { EyeIcon, EyeOffIcon, PlusIcon, TrashIcon } from "lucide-react"
|
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "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 { 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 { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
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 { BatchUpdateDiscount } from "./batch-discount"
|
||||||
|
import { CreateProductSku } from "./create"
|
||||||
|
import { UpdateProductSku } from "./update"
|
||||||
|
|
||||||
export default function ProductPage() {
|
export default function ProductPage() {
|
||||||
const [selected, setSelected] = useState<number | undefined>(undefined)
|
const [selected, setSelected] = useState<number | undefined>(undefined)
|
||||||
@@ -36,7 +50,6 @@ function Products(props: {
|
|||||||
const resp = await getAllProduct()
|
const resp = await getAllProduct()
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
setList(resp.data)
|
setList(resp.data)
|
||||||
console.log(resp.data)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -52,46 +65,10 @@ function Products(props: {
|
|||||||
<section className="flex-none basis-64 bg-background rounded-lg">
|
<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">
|
<header className="pl-3 pr-1 h-10 border-b flex items-center justify-between">
|
||||||
<h3 className="text-sm">产品列表</h3>
|
<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>
|
</header>
|
||||||
<ul>
|
<ul className="flex flex-col gap-1 py-1">
|
||||||
{list.map(item => (
|
{list.map(item => (
|
||||||
<li key={item.id} className="p-1">
|
<li key={item.id} className="px-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -125,11 +102,18 @@ 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>
|
<div className="flex gap-3">
|
||||||
<Button>新建套餐</Button>
|
<CreateProductSku
|
||||||
|
productId={props.selected ?? 0}
|
||||||
|
onSuccess={table.refresh}
|
||||||
|
/>
|
||||||
|
<BatchUpdateDiscount
|
||||||
|
productId={props.selected ?? 0}
|
||||||
|
onSuccess={table.refresh}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<DataTable
|
<DataTable<ProductSku>
|
||||||
classNames={{
|
classNames={{
|
||||||
root: "overflow-auto",
|
root: "overflow-auto",
|
||||||
}}
|
}}
|
||||||
@@ -138,10 +122,13 @@ function ProductSkus(props: { selected?: number }) {
|
|||||||
{ header: "套餐编码", accessorKey: "code" },
|
{ header: "套餐编码", accessorKey: "code" },
|
||||||
{ header: "套餐名称", accessorKey: "name" },
|
{ header: "套餐名称", accessorKey: "name" },
|
||||||
{ header: "单价", accessorKey: "price" },
|
{ header: "单价", accessorKey: "price" },
|
||||||
{ header: "折扣", accessorKey: "discount" },
|
{ header: "折扣", accessorFn: row => row.discount?.name ?? "—" },
|
||||||
{
|
{
|
||||||
header: "最终价格",
|
header: "最终价格",
|
||||||
accessorFn: row => Number(row.price) * row.discount,
|
accessorFn: row =>
|
||||||
|
row.discount
|
||||||
|
? (Number(row.price) * Number(row.discount.discount)) / 100
|
||||||
|
: Number(row.price),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "创建时间",
|
header: "创建时间",
|
||||||
@@ -151,10 +138,67 @@ function ProductSkus(props: { selected?: number }) {
|
|||||||
header: "更新时间",
|
header: "更新时间",
|
||||||
accessorFn: row => format(row.updated_at, "yyyy-MM-dd HH:mm"),
|
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>
|
</Suspense>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
225
src/app/(root)/product/update.tsx
Normal file
225
src/app/(root)/product/update.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
@@ -33,7 +33,7 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
|
|||||||
6
src/models/product_discount.ts
Normal file
6
src/models/product_discount.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Model } from "./base/model"
|
||||||
|
|
||||||
|
export type ProductDiscount = Model & {
|
||||||
|
name: string
|
||||||
|
discount: number
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Model } from "./base/model"
|
import type { Model } from "./base/model"
|
||||||
import type { Product } from "./product"
|
import type { Product } from "./product"
|
||||||
|
import type { ProductDiscount } from "./product_discount"
|
||||||
|
|
||||||
export type ProductSku = Model & {
|
export type ProductSku = Model & {
|
||||||
product_id: number
|
product_id: number
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
price: string
|
price: string
|
||||||
discount: number
|
|
||||||
|
|
||||||
product?: Product
|
product?: Product
|
||||||
|
discount?: ProductDiscount
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user