- 新增家庭共享订阅管理 - 新增用户邀请统计 - 新增签名和订阅模式设置表单 - 更新 API 服务层和国际化文件 - UI 组件优化(enhanced-input、pro-table)
This commit is contained in:
@@ -29,220 +29,223 @@ export default function Redemption() {
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
return (
|
||||
<>
|
||||
<ProTable<API.RedemptionCode, { subscribe_plan: number; unit_time: string; code: string }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<Button
|
||||
key="records"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedCodeId(row.id);
|
||||
setRecordsOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("records", "Records")}
|
||||
</Button>,
|
||||
<RedemptionForm<API.UpdateRedemptionCodeRequest>
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateRedemptionCode({ ...values });
|
||||
toast.success(t("updateSuccess", "Update Success"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("editRedemptionCode", "Edit Redemption Code")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteRedemptionCode({ id: row.id });
|
||||
toast.success(t("deleteSuccess", "Delete Success"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
batchRender: (rows) => [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await batchDeleteRedemptionCode({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t("deleteSuccess", "Delete Success"));
|
||||
ref.current?.reset();
|
||||
}}
|
||||
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: t("code", "Code"),
|
||||
},
|
||||
{
|
||||
accessorKey: "subscribe_plan",
|
||||
header: t("subscribePlan", "Subscribe Plan"),
|
||||
cell: ({ row }) => {
|
||||
const plan = subscribes?.find(
|
||||
(s) => s.id === row.getValue("subscribe_plan")
|
||||
);
|
||||
return plan?.name || "--";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "unit_time",
|
||||
header: t("unitTime", "Unit Time"),
|
||||
cell: ({ row }) => {
|
||||
const unitTime = row.getValue("unit_time") as string;
|
||||
const unitTimeMap: Record<string, string> = {
|
||||
day: t("form.day", "Day"),
|
||||
month: t("form.month", "Month"),
|
||||
quarter: t("form.quarter", "Quarter"),
|
||||
half_year: t("form.halfYear", "Half Year"),
|
||||
year: t("form.year", "Year"),
|
||||
};
|
||||
return unitTimeMap[unitTime] || unitTime;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "quantity",
|
||||
header: t("duration", "Duration"),
|
||||
cell: ({ row }) => `${row.original.quantity}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "total_count",
|
||||
header: t("totalCount", "Total Count"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{t("totalCount", "Total")}: {row.original.total_count}
|
||||
</span>
|
||||
<span>
|
||||
{t("remainingCount", "Remaining")}:{" "}
|
||||
{row.original.total_count - (row.original.used_count || 0)}
|
||||
</span>
|
||||
<span>
|
||||
{t("usedCount", "Used")}: {row.original.used_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status", "Status"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("status") === 1}
|
||||
onCheckedChange={async (checked) => {
|
||||
await toggleRedemptionCodeStatus({
|
||||
id: row.original.id,
|
||||
status: checked ? 1 : 0,
|
||||
});
|
||||
toast.success(
|
||||
checked
|
||||
? t("updateSuccess", "Update Success")
|
||||
: t("updateSuccess", "Update Success")
|
||||
);
|
||||
<ProTable<
|
||||
API.RedemptionCode,
|
||||
{ subscribe_plan: number; unit_time: string; code: string }
|
||||
>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<Button
|
||||
key="records"
|
||||
onClick={() => {
|
||||
setSelectedCodeId(row.id);
|
||||
setRecordsOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("records", "Records")}
|
||||
</Button>,
|
||||
<RedemptionForm<API.UpdateRedemptionCodeRequest>
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateRedemptionCode({ ...values });
|
||||
toast.success(t("updateSuccess", "Update Success"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("editRedemptionCode", "Edit Redemption Code")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteRedemptionCode({ id: row.id });
|
||||
toast.success(t("deleteSuccess", "Delete Success"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
batchRender: (rows) => [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await batchDeleteRedemptionCode({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t("deleteSuccess", "Delete Success"));
|
||||
ref.current?.reset();
|
||||
}}
|
||||
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: t("code", "Code"),
|
||||
},
|
||||
{
|
||||
accessorKey: "subscribe_plan",
|
||||
header: t("subscribePlan", "Subscribe Plan"),
|
||||
cell: ({ row }) => {
|
||||
const plan = subscribes?.find(
|
||||
(s) => s.id === row.getValue("subscribe_plan")
|
||||
);
|
||||
return plan?.name || "--";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "unit_time",
|
||||
header: t("unitTime", "Unit Time"),
|
||||
cell: ({ row }) => {
|
||||
const unitTime = row.getValue("unit_time") as string;
|
||||
const unitTimeMap: Record<string, string> = {
|
||||
day: t("form.day", "Day"),
|
||||
month: t("form.month", "Month"),
|
||||
quarter: t("form.quarter", "Quarter"),
|
||||
half_year: t("form.halfYear", "Half Year"),
|
||||
year: t("form.year", "Year"),
|
||||
};
|
||||
return unitTimeMap[unitTime] || unitTime;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "quantity",
|
||||
header: t("duration", "Duration"),
|
||||
cell: ({ row }) => `${row.original.quantity}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "total_count",
|
||||
header: t("totalCount", "Total Count"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{t("totalCount", "Total")}: {row.original.total_count}
|
||||
</span>
|
||||
<span>
|
||||
{t("remainingCount", "Remaining")}:{" "}
|
||||
{row.original.total_count - (row.original.used_count || 0)}
|
||||
</span>
|
||||
<span>
|
||||
{t("usedCount", "Used")}: {row.original.used_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status", "Status"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("status") === 1}
|
||||
onCheckedChange={async (checked) => {
|
||||
await toggleRedemptionCodeStatus({
|
||||
id: row.original.id,
|
||||
status: checked ? 1 : 0,
|
||||
});
|
||||
toast.success(
|
||||
checked
|
||||
? t("updateSuccess", "Update Success")
|
||||
: t("updateSuccess", "Update Success")
|
||||
);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
toolbar: (
|
||||
<RedemptionForm<API.CreateRedemptionCodeRequest>
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createRedemptionCode(values);
|
||||
toast.success(t("createSuccess", "Create Success"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("createRedemptionCode", "Create Redemption Code")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
toolbar: (
|
||||
<RedemptionForm<API.CreateRedemptionCodeRequest>
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createRedemptionCode(values);
|
||||
toast.success(t("createSuccess", "Create Success"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("createRedemptionCode", "Create Redemption Code")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "subscribe_plan",
|
||||
placeholder: t("subscribePlan", "Subscribe Plan"),
|
||||
options: subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "unit_time",
|
||||
placeholder: t("unitTime", "Unit Time"),
|
||||
options: [
|
||||
{ label: t("form.day", "Day"), value: "day" },
|
||||
{ label: t("form.month", "Month"), value: "month" },
|
||||
{ label: t("form.quarter", "Quarter"), value: "quarter" },
|
||||
{ label: t("form.halfYear", "Half Year"), value: "half_year" },
|
||||
{ label: t("form.year", "Year"), value: "year" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getRedemptionCodeList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
<RedemptionRecords
|
||||
codeId={selectedCodeId}
|
||||
open={recordsOpen}
|
||||
onOpenChange={setRecordsOpen}
|
||||
/>
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "subscribe_plan",
|
||||
placeholder: t("subscribePlan", "Subscribe Plan"),
|
||||
options: subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "unit_time",
|
||||
placeholder: t("unitTime", "Unit Time"),
|
||||
options: [
|
||||
{ label: t("form.day", "Day"), value: "day" },
|
||||
{ label: t("form.month", "Month"), value: "month" },
|
||||
{ label: t("form.quarter", "Quarter"), value: "quarter" },
|
||||
{ label: t("form.halfYear", "Half Year"), value: "half_year" },
|
||||
{ label: t("form.year", "Year"), value: "year" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getRedemptionCodeList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
<RedemptionRecords
|
||||
codeId={selectedCodeId}
|
||||
onOpenChange={setRecordsOpen}
|
||||
open={recordsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,15 +26,24 @@ import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
|
||||
const getFormSchema = (t: (key: string, defaultValue: string) => string) => z.object({
|
||||
id: z.number().optional(),
|
||||
code: z.string().optional(),
|
||||
batch_count: z.number().optional(),
|
||||
total_count: z.number().min(1, t("form.totalCountRequired", "Total count is required")),
|
||||
subscribe_plan: z.number().min(1, t("form.subscribePlanRequired", "Subscribe plan is required")),
|
||||
unit_time: z.string().min(1, t("form.unitTimeRequired", "Unit time is required")),
|
||||
quantity: z.number().min(1, t("form.quantityRequired", "Quantity is required")),
|
||||
});
|
||||
const getFormSchema = (t: (key: string, defaultValue: string) => string) =>
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
code: z.string().optional(),
|
||||
batch_count: z.number().optional(),
|
||||
total_count: z
|
||||
.number()
|
||||
.min(1, t("form.totalCountRequired", "Total count is required")),
|
||||
subscribe_plan: z
|
||||
.number()
|
||||
.min(1, t("form.subscribePlanRequired", "Subscribe plan is required")),
|
||||
unit_time: z
|
||||
.string()
|
||||
.min(1, t("form.unitTimeRequired", "Unit time is required")),
|
||||
quantity: z
|
||||
.number()
|
||||
.min(1, t("form.quantityRequired", "Quantity is required")),
|
||||
});
|
||||
|
||||
interface RedemptionFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
@@ -184,9 +193,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
||||
name="unit_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.unitTime", "Unit Time")}
|
||||
</FormLabel>
|
||||
<FormLabel>{t("form.unitTime", "Unit Time")}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<string, false>
|
||||
onChange={(value) => {
|
||||
@@ -195,8 +202,14 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
||||
options={[
|
||||
{ value: "day", label: t("form.day", "Day") },
|
||||
{ value: "month", label: t("form.month", "Month") },
|
||||
{ value: "quarter", label: t("form.quarter", "Quarter") },
|
||||
{ value: "half_year", label: t("form.halfYear", "Half Year") },
|
||||
{
|
||||
value: "quarter",
|
||||
label: t("form.quarter", "Quarter"),
|
||||
},
|
||||
{
|
||||
value: "half_year",
|
||||
label: t("form.halfYear", "Half Year"),
|
||||
},
|
||||
{ value: "year", label: t("form.year", "Year") },
|
||||
]}
|
||||
placeholder={t(
|
||||
@@ -215,16 +228,11 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
||||
name="quantity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.duration", "Duration")}
|
||||
</FormLabel>
|
||||
<FormLabel>{t("form.duration", "Duration")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={1}
|
||||
placeholder={t(
|
||||
"form.durationPlaceholder",
|
||||
"Duration"
|
||||
)}
|
||||
placeholder={t("form.durationPlaceholder", "Duration")}
|
||||
step={1}
|
||||
type="number"
|
||||
{...field}
|
||||
@@ -242,9 +250,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
||||
name="total_count"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.totalCount", "Total Count")}
|
||||
</FormLabel>
|
||||
<FormLabel>{t("form.totalCount", "Total Count")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={1}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function RedemptionRecords({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [records, setRecords] = useState<API.RedemptionRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pagination, setPagination] = useState({ page: 1, size: 10 });
|
||||
const [pagination, setPagination] = useState({ page: 1, size: 200 });
|
||||
|
||||
const fetchRecords = async () => {
|
||||
if (!codeId) return;
|
||||
@@ -59,12 +59,10 @@ export default function RedemptionRecords({
|
||||
}, [open, codeId, pagination]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("records", "Redemption Records")}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{t("records", "Redemption Records")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
{loading ? (
|
||||
@@ -72,7 +70,7 @@ export default function RedemptionRecords({
|
||||
<span>{t("loading", "Loading...")}</span>
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
{t("noRecords", "No records found")}
|
||||
</div>
|
||||
) : (
|
||||
@@ -102,7 +100,9 @@ export default function RedemptionRecords({
|
||||
<TableCell>{record.id}</TableCell>
|
||||
<TableCell>{record.user_id}</TableCell>
|
||||
<TableCell>{record.subscribe_id}</TableCell>
|
||||
<TableCell>{unitTimeMap[record.unit_time] || record.unit_time}</TableCell>
|
||||
<TableCell>
|
||||
{unitTimeMap[record.unit_time] || record.unit_time}
|
||||
</TableCell>
|
||||
<TableCell>{record.quantity}</TableCell>
|
||||
<TableCell>
|
||||
{record.redeemed_at
|
||||
@@ -115,26 +115,28 @@ export default function RedemptionRecords({
|
||||
</TableBody>
|
||||
</Table>
|
||||
{total > pagination.size && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("total", "Total")}: {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
|
||||
className="rounded border px-3 py-1 text-sm hover:bg-accent disabled:opacity-50"
|
||||
disabled={pagination.page === 1}
|
||||
onClick={() =>
|
||||
setPagination((p) => ({ ...p, page: p.page - 1 }))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t("previous", "Previous")}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
|
||||
className="rounded border px-3 py-1 text-sm hover:bg-accent disabled:opacity-50"
|
||||
disabled={pagination.page * pagination.size >= total}
|
||||
onClick={() =>
|
||||
setPagination((p) => ({ ...p, page: p.page + 1 }))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t("next", "Next")}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user