feat: Add original price display option and enhance inventory messages in subscription components

This commit is contained in:
web@ppanel 2025-12-29 05:34:01 +00:00
parent 7279275532
commit 543a7b9eb9
25 changed files with 241 additions and 61 deletions

View File

@ -44,6 +44,8 @@
"60003": "An existing subscription is detected. Please cancel it before proceeding.", "60003": "An existing subscription is detected. Please cancel it before proceeding.",
"60004": "Unable to delete at the moment as the subscription has active users.", "60004": "Unable to delete at the moment as the subscription has active users.",
"60005": "Single subscription mode has exceeded user limit", "60005": "Single subscription mode has exceeded user limit",
"60006": "User quota limit has been reached, unable to continue.",
"60007": "Insufficient inventory, please try again later or contact the administrator.",
"70001": "Incorrect verification code, please re-enter.", "70001": "Incorrect verification code, please re-enter.",
"80001": "Task was not successfully queued, please try again later.", "80001": "Task was not successfully queued, please try again later.",
"90001": "Please disable DEBUG mode and try again.", "90001": "Please disable DEBUG mode and try again.",

View File

@ -30,6 +30,8 @@
"discountPercent": "Discount Percentage", "discountPercent": "Discount Percentage",
"Hour": "Hour", "Hour": "Hour",
"inventory": "Subscription Limit", "inventory": "Subscription Limit",
"inventoryDescription": "Set to -1 for unlimited inventory",
"unlimitedInventory": "Unlimited (enter -1)",
"language": "Language", "language": "Language",
"languageDescription": "Leave empty for default without language restriction", "languageDescription": "Leave empty for default without language restriction",
"languagePlaceholder": "Language identifier for the subscription, e.g., en-US, zh-CN", "languagePlaceholder": "Language identifier for the subscription, e.g., en-US, zh-CN",
@ -54,6 +56,8 @@
"resetOn1st": "Reset on the 1st", "resetOn1st": "Reset on the 1st",
"selectResetCycle": "Please select a reset cycle", "selectResetCycle": "Please select a reset cycle",
"selectUnitTime": "Please select a unit of time", "selectUnitTime": "Please select a unit of time",
"showOriginalPrice": "Show Original Price",
"showOriginalPriceDescription": "When enabled, the subscription card will display both the original price and the discounted price to help users understand the discount amount",
"speedLimit": "Speed Limit ", "speedLimit": "Speed Limit ",
"traffic": "Traffic", "traffic": "Traffic",
"unitPrice": "Unit Price", "unitPrice": "Unit Price",

View File

@ -44,6 +44,8 @@
"60003": "检测到现有订阅,请先取消后再继续。", "60003": "检测到现有订阅,请先取消后再继续。",
"60004": "由于订阅有活跃用户,暂时无法删除。", "60004": "由于订阅有活跃用户,暂时无法删除。",
"60005": "单一订阅模式已超过用户限制", "60005": "单一订阅模式已超过用户限制",
"60006": "用户配额已达到限制,无法继续操作。",
"60007": "库存不足,请稍后再试或联系管理员。",
"70001": "验证码不正确,请重新输入。", "70001": "验证码不正确,请重新输入。",
"80001": "任务未成功加入队列,请稍后再试。", "80001": "任务未成功加入队列,请稍后再试。",
"90001": "请禁用 DEBUG 模式后再试。", "90001": "请禁用 DEBUG 模式后再试。",

View File

@ -30,6 +30,8 @@
"discountPercent": "折扣百分比", "discountPercent": "折扣百分比",
"Hour": "小时", "Hour": "小时",
"inventory": "订阅库存", "inventory": "订阅库存",
"inventoryDescription": "设置为 -1 表示不限制库存",
"unlimitedInventory": "无限制(输入 -1",
"language": "语言", "language": "语言",
"languageDescription": "留空为默认无语言限制", "languageDescription": "留空为默认无语言限制",
"languagePlaceholder": "订阅的语言标识符,例如 en-US、zh-CN", "languagePlaceholder": "订阅的语言标识符,例如 en-US、zh-CN",
@ -54,6 +56,8 @@
"resetOn1st": "每月1日重置", "resetOn1st": "每月1日重置",
"selectResetCycle": "请选择重置周期", "selectResetCycle": "请选择重置周期",
"selectUnitTime": "请选择时间单位", "selectUnitTime": "请选择时间单位",
"showOriginalPrice": "显示原价",
"showOriginalPriceDescription": "开启后,在订阅卡片上将会显示原价和折后价,帮助用户了解优惠幅度",
"speedLimit": "速度限制", "speedLimit": "速度限制",
"traffic": "流量", "traffic": "流量",
"unitPrice": "单价", "unitPrice": "单价",

View File

@ -69,7 +69,7 @@ export default function SystemVersionCard() {
queryFn: async () => { queryFn: async () => {
const { data } = await basicCheckServiceVersion( const { data } = await basicCheckServiceVersion(
{ {
service_name: "admin", service_name: "admin-web-with-api",
secret: moduleConfig!.secret, secret: moduleConfig!.secret,
}, },
{ skipErrorHandler: true } { skipErrorHandler: true }
@ -105,13 +105,13 @@ export default function SystemVersionCard() {
setIsUpdatingWeb(true); setIsUpdatingWeb(true);
try { try {
await basicUpdateService({ await basicUpdateService({
service_name: "admin", service_name: "admin-web-with-api",
secret: moduleConfig.secret, secret: moduleConfig.secret,
}); });
toast.success(t("adminUpdateSuccess", "Admin updated successfully")); toast.success(t("adminUpdateSuccess", "Admin updated successfully"));
await basicUpdateService({ await basicUpdateService({
service_name: "user", service_name: "user-web-with-api",
secret: moduleConfig.secret, secret: moduleConfig.secret,
}); });
toast.success(t("userUpdateSuccess", "User updated successfully")); toast.success(t("userUpdateSuccess", "User updated successfully"));

View File

@ -120,6 +120,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
allow_deduction: z.boolean().optional(), allow_deduction: z.boolean().optional(),
reset_cycle: z.number().optional(), reset_cycle: z.number().optional(),
renewal_reset: z.boolean().optional(), renewal_reset: z.boolean().optional(),
show_original_price: z.boolean().optional(),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@ -444,16 +445,18 @@ export default function SubscribeForm<T extends Record<string, any>>({
<FormLabel>{t("form.inventory")}</FormLabel> <FormLabel>{t("form.inventory")}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
min={0}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
}} }}
placeholder={t("form.noLimit")} placeholder={t("form.unlimitedInventory")}
step={1} step={1}
type="number" type="number"
value={field.value} value={field.value}
/> />
</FormControl> </FormControl>
<FormDescription>
{t("form.inventoryDescription")}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -864,6 +867,33 @@ export default function SubscribeForm<T extends Record<string, any>>({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="show_original_price"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>
{t("form.showOriginalPrice")}
</FormLabel>
<FormDescription>
{t("form.showOriginalPriceDescription")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={!!field.value}
onCheckedChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
</TabsContent> </TabsContent>

View File

@ -208,15 +208,14 @@ export default function SubscribeTable() {
{ {
accessorKey: "inventory", accessorKey: "inventory",
header: t("inventory"), header: t("inventory"),
cell: ({ row }) => ( cell: ({ row }) => {
<Display const inventory = row.getValue("inventory") as number;
type="number" return inventory === -1 ? (
unlimited t("unlimited")
value={ ) : (
row.getValue("inventory") === -1 ? 0 : row.getValue("inventory") <Display type="number" unlimited value={inventory} />
} );
/> },
),
}, },
{ {
accessorKey: "quota", accessorKey: "quota",

View File

@ -44,6 +44,8 @@
"60003": "An existing subscription is detected. Please cancel it before proceeding.", "60003": "An existing subscription is detected. Please cancel it before proceeding.",
"60004": "Unable to delete at the moment as the subscription has active users.", "60004": "Unable to delete at the moment as the subscription has active users.",
"60005": "Single subscription mode has exceeded user limit", "60005": "Single subscription mode has exceeded user limit",
"60006": "User quota limit has been reached, unable to continue.",
"60007": "Insufficient inventory, please try again later or contact the administrator.",
"70001": "Incorrect verification code, please re-enter.", "70001": "Incorrect verification code, please re-enter.",
"80001": "Task was not successfully queued, please try again later.", "80001": "Task was not successfully queued, please try again later.",
"90001": "Please disable DEBUG mode and try again.", "90001": "Please disable DEBUG mode and try again.",

View File

@ -6,6 +6,7 @@
"duration": "Duration", "duration": "Duration",
"fee": "Fee", "fee": "Fee",
"gift": "gift Deduction", "gift": "gift Deduction",
"originalPrice": "Original Price (Monthly)",
"price": "Price", "price": "Price",
"productDiscount": "Product Discount", "productDiscount": "Product Discount",
"total": "Total" "total": "Total"

View File

@ -44,6 +44,8 @@
"60003": "检测到现有订阅,请先取消后再继续。", "60003": "检测到现有订阅,请先取消后再继续。",
"60004": "由于订阅有活跃用户,暂时无法删除。", "60004": "由于订阅有活跃用户,暂时无法删除。",
"60005": "单一订阅模式已超过用户限制", "60005": "单一订阅模式已超过用户限制",
"60006": "用户配额已达到限制,无法继续操作。",
"60007": "库存不足,请稍后再试或联系管理员。",
"70001": "验证码不正确,请重新输入。", "70001": "验证码不正确,请重新输入。",
"80001": "任务未成功加入队列,请稍后再试。", "80001": "任务未成功加入队列,请稍后再试。",
"90001": "请禁用 DEBUG 模式后再试。", "90001": "请禁用 DEBUG 模式后再试。",

View File

@ -6,6 +6,7 @@
"duration": "套餐时长", "duration": "套餐时长",
"fee": "手续费", "fee": "手续费",
"gift": "赠金抵扣", "gift": "赠金抵扣",
"originalPrice": "原价(按月)",
"price": "价格", "price": "价格",
"productDiscount": "商品折扣", "productDiscount": "商品折扣",
"total": "总价" "total": "总价"

View File

@ -142,19 +142,44 @@ export function Content({ subscriptionData }: ProductShowcaseProps) {
</CardContent> </CardContent>
<Separator /> <Separator />
<CardFooter className="relative flex flex-col gap-4 p-4"> <CardFooter className="relative flex flex-col gap-4 p-4">
<motion.h2 {(() => {
animate={{ opacity: 1 }} const hasDiscount = item.discount && item.discount.length > 0;
className="pb-4 font-semibold text-2xl sm:text-3xl" const shouldShowOriginal = item.show_original_price !== false;
initial={{ opacity: 0 }}
transition={{ duration: 0.5, delay: 0.2 }} const displayPrice =
> shouldShowOriginal || !hasDiscount
<Display type="currency" value={item.unit_price} /> ? item.unit_price
<span className="font-medium text-base"> : Math.round(
/ item.unit_price *
{unitTimeMap[item.unit_time!] || (item.discount?.[0]?.quantity ?? 1) *
t(item.unit_time || "Month", item.unit_time || "Month")} ((item.discount?.[0]?.discount ?? 100) / 100)
</span> );
</motion.h2>
const displayQuantity =
shouldShowOriginal || !hasDiscount
? 1
: (item.discount?.[0]?.quantity ?? 1);
const unitTime =
unitTimeMap[item.unit_time!] ||
t(item.unit_time || "Month", item.unit_time || "Month");
return (
<motion.h2
animate={{ opacity: 1 }}
className="pb-4 font-semibold text-2xl sm:text-3xl"
initial={{ opacity: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Display type="currency" value={displayPrice} />
<span className="font-medium text-base">
{displayQuantity === 1
? `/${unitTime}`
: `/${displayQuantity} ${unitTime}`}
</span>
</motion.h2>
);
})()}
<motion.div> <motion.div>
<Button <Button
asChild asChild

View File

@ -282,6 +282,7 @@ export default function Content({
...order, ...order,
quantity: params.quantity, quantity: params.quantity,
unit_price: subscription?.unit_price, unit_price: subscription?.unit_price,
show_original_price: subscription?.show_original_price,
}} }}
/> />
</CardContent> </CardContent>

View File

@ -178,6 +178,7 @@ export default function Order() {
order={{ order={{
...data, ...data,
unit_price: data?.subscribe?.unit_price, unit_price: data?.subscribe?.unit_price,
show_original_price: data?.subscribe?.show_original_price,
}} }}
/> />
</CardContent> </CardContent>

View File

@ -10,6 +10,7 @@ interface SubscribeBillingProps {
unit_price: number; unit_price: number;
unit_time: string; unit_time: string;
subscribe_discount: number; subscribe_discount: number;
show_original_price?: boolean;
} }
>; >;
} }
@ -33,7 +34,19 @@ export function SubscribeBilling({ order }: Readonly<SubscribeBillingProps>) {
{t(order?.unit_time || "Month", order?.unit_time || "Month")} {t(order?.unit_time || "Month", order?.unit_time || "Month")}
</span> </span>
</li> </li>
)} )}{" "}
{order?.show_original_price !== false &&
order?.type &&
[1, 2].includes(order?.type) && (
<li>
<span className="text-muted-foreground">
{t("billing.originalPrice", "Original Price (Monthly)")}
</span>
<span>
<Display type="currency" value={order?.unit_price} />
</span>
</li>
)}{" "}
<li> <li>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("billing.price", "Price")} {t("billing.price", "Price")}

View File

@ -122,14 +122,39 @@ export default function Subscribe() {
</CardContent> </CardContent>
<Separator /> <Separator />
<CardFooter className="flex flex-col gap-2"> <CardFooter className="flex flex-col gap-2">
<h2 className="pb-8 font-semibold text-2xl sm:text-3xl"> {(() => {
<Display type="currency" value={item.unit_price} /> const hasDiscount = item.discount && item.discount.length > 0;
<span className="font-medium text-base"> const shouldShowOriginal = item.show_original_price !== false;
/
{unitTimeMap[item.unit_time!] || const displayPrice =
t(item.unit_time || "Month", item.unit_time || "Month")} shouldShowOriginal || !hasDiscount
</span> ? item.unit_price
</h2> : Math.round(
item.unit_price *
(item.discount?.[0]?.quantity ?? 1) *
((item.discount?.[0]?.discount ?? 100) / 100)
);
const displayQuantity =
shouldShowOriginal || !hasDiscount
? 1
: (item.discount?.[0]?.quantity ?? 1);
const unitTime =
unitTimeMap[item.unit_time!] ||
t(item.unit_time || "Month", item.unit_time || "Month");
return (
<h2 className="pb-8 font-semibold text-2xl sm:text-3xl">
<Display type="currency" value={displayPrice} />
<span className="font-medium text-base">
{displayQuantity === 1
? `/${unitTime}`
: `/${displayQuantity} ${unitTime}`}
</span>
</h2>
);
})()}
<Button <Button
className="absolute bottom-0 w-full rounded-t-none rounded-b-xl" className="absolute bottom-0 w-full rounded-t-none rounded-b-xl"
onClick={() => { onClick={() => {

View File

@ -127,6 +127,7 @@ export default function Purchase({
...order, ...order,
quantity: params.quantity, quantity: params.quantity,
unit_price: subscribe?.unit_price, unit_price: subscribe?.unit_price,
show_original_price: subscribe?.show_original_price,
}} }}
/> />
</CardContent> </CardContent>

View File

@ -126,6 +126,7 @@ export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
...order, ...order,
quantity: params.quantity, quantity: params.quantity,
unit_price: subscribe?.unit_price, unit_price: subscribe?.unit_price,
show_original_price: subscribe?.show_original_price,
}} }}
/> />
</CardContent> </CardContent>

View File

@ -164,6 +164,7 @@ export default function Page() {
order={{ order={{
...data, ...data,
unit_price: data?.subscribe?.unit_price, unit_price: data?.subscribe?.unit_price,
show_original_price: data?.subscribe?.show_original_price,
}} }}
/> />
</CardContent> </CardContent>

View File

@ -135,6 +135,14 @@ function handleError(response: {
"components:error.60005", "components:error.60005",
"Single subscription mode has exceeded user limit" "Single subscription mode has exceeded user limit"
), ),
60006: t(
"components:error.60006",
"User quota limit has been reached, unable to continue."
),
60007: t(
"components:error.60007",
"Insufficient inventory, please try again later or contact the administrator."
),
70001: t( 70001: t(
"components:error.70001", "components:error.70001",
"Incorrect verification code, please re-enter." "Incorrect verification code, please re-enter."

View File

@ -55,28 +55,6 @@ export async function filterServerList(
); );
} }
/** Check if there is any server or node to migrate GET /v1/admin/server/migrate/has */
export async function hasMigrateSeverNode(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.HasMigrateSeverNodeResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/server/migrate/has`,
{
method: "GET",
...(options || {}),
}
);
}
/** Migrate server and node data to new database POST /v1/admin/server/migrate/run */
export async function migrateServerNode(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.MigrateServerNodeResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/server/migrate/run`,
{
method: "POST",
...(options || {}),
}
);
}
/** Create Node POST /v1/admin/server/node/create */ /** Create Node POST /v1/admin/server/node/create */
export async function createNode( export async function createNode(
body: API.CreateNodeRequest, body: API.CreateNodeRequest,

View File

@ -355,6 +355,7 @@ declare namespace API {
allow_deduction: boolean; allow_deduction: boolean;
reset_cycle: number; reset_cycle: number;
renewal_reset: boolean; renewal_reset: boolean;
show_original_price: boolean;
}; };
type CreateTicketFollowRequest = { type CreateTicketFollowRequest = {
@ -1127,6 +1128,7 @@ declare namespace API {
size: number; size: number;
search?: string; search?: string;
user_id?: number; user_id?: number;
unscoped?: boolean;
subscribe_id?: number; subscribe_id?: number;
user_subscribe_id?: number; user_subscribe_id?: number;
}; };
@ -1136,6 +1138,7 @@ declare namespace API {
size: number; size: number;
search?: string; search?: string;
user_id?: number; user_id?: number;
unscoped?: boolean;
subscribe_id?: number; subscribe_id?: number;
user_subscribe_id?: number; user_subscribe_id?: number;
}; };
@ -1837,6 +1840,14 @@ declare namespace API {
order_no: string; order_no: string;
}; };
type ResetUserSubscribeTokenRequest = {
user_subscribe_id: number;
};
type ResetUserSubscribeTrafficRequest = {
user_subscribe_id: number;
};
type Response = { type Response = {
/** 状态码 */ /** 状态码 */
code?: number; code?: number;
@ -1993,6 +2004,10 @@ declare namespace API {
id: number; id: number;
}; };
type StopUserSubscribeRequest = {
user_subscribe_id: number;
};
type StripePayment = { type StripePayment = {
method: string; method: string;
client_secret: string; client_secret: string;
@ -2022,6 +2037,7 @@ declare namespace API {
allow_deduction: boolean; allow_deduction: boolean;
reset_cycle: number; reset_cycle: number;
renewal_reset: boolean; renewal_reset: boolean;
show_original_price: boolean;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -2086,6 +2102,7 @@ declare namespace API {
allow_deduction?: boolean; allow_deduction?: boolean;
reset_cycle?: number; reset_cycle?: number;
renewal_reset?: boolean; renewal_reset?: boolean;
show_original_price?: boolean;
created_at?: number; created_at?: number;
updated_at?: number; updated_at?: number;
sold: number; sold: number;
@ -2337,6 +2354,7 @@ declare namespace API {
allow_deduction: boolean; allow_deduction: boolean;
reset_cycle: number; reset_cycle: number;
renewal_reset: boolean; renewal_reset: boolean;
show_original_price: boolean;
}; };
type UpdateTicketStatusRequest = { type UpdateTicketStatusRequest = {
@ -2406,7 +2424,6 @@ declare namespace API {
created_at: number; created_at: number;
updated_at: number; updated_at: number;
deleted_at?: number; deleted_at?: number;
is_del?: boolean;
}; };
type UserAffiliate = { type UserAffiliate = {

View File

@ -425,6 +425,64 @@ export async function getUserSubscribeResetTrafficLogs(
); );
} }
/** Reset user subscribe token POST /v1/admin/user/subscribe/reset/token */
export async function resetUserSubscribeToken(
body: API.ResetUserSubscribeTokenRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${
import.meta.env.VITE_API_PREFIX || ""
}/v1/admin/user/subscribe/reset/token`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Reset user subscribe traffic POST /v1/admin/user/subscribe/reset/traffic */
export async function resetUserSubscribeTraffic(
body: API.ResetUserSubscribeTrafficRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${
import.meta.env.VITE_API_PREFIX || ""
}/v1/admin/user/subscribe/reset/traffic`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Stop user subscribe POST /v1/admin/user/subscribe/stop */
export async function stopUserSubscribe(
body: API.StopUserSubscribeRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/subscribe/stop`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Get user subcribe traffic logs GET /v1/admin/user/subscribe/traffic_logs */ /** Get user subcribe traffic logs GET /v1/admin/user/subscribe/traffic_logs */
export async function getUserSubscribeTrafficLogs( export async function getUserSubscribeTrafficLogs(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象) // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)

View File

@ -750,6 +750,10 @@ declare namespace API {
order_no: string; order_no: string;
}; };
type ResetUserSubscribeTokenRequest = {
user_subscribe_id: number;
};
type Response = { type Response = {
/** 状态码 */ /** 状态码 */
code?: number; code?: number;
@ -863,6 +867,7 @@ declare namespace API {
allow_deduction: boolean; allow_deduction: boolean;
reset_cycle: number; reset_cycle: number;
renewal_reset: boolean; renewal_reset: boolean;
show_original_price: boolean;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -1023,7 +1028,6 @@ declare namespace API {
created_at: number; created_at: number;
updated_at: number; updated_at: number;
deleted_at?: number; deleted_at?: number;
is_del?: boolean;
}; };
type UserAffiliate = { type UserAffiliate = {

View File

@ -985,6 +985,7 @@ declare namespace API {
allow_deduction: boolean; allow_deduction: boolean;
reset_cycle: number; reset_cycle: number;
renewal_reset: boolean; renewal_reset: boolean;
show_original_price: boolean;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -1146,7 +1147,6 @@ declare namespace API {
created_at: number; created_at: number;
updated_at: number; updated_at: number;
deleted_at?: number; deleted_at?: number;
is_del?: boolean;
}; };
type UserAffiliate = { type UserAffiliate = {