🔀 merge: main into develop

This commit is contained in:
web-ppanel 2026-01-27 18:16:34 +00:00
commit 830c18c9e4
61 changed files with 1330 additions and 817 deletions

View File

@ -19,6 +19,87 @@ This document records all notable changes to ShadCN Admin.
--- ---
## [1.3.6](https://github.com/perfect-panel/frontend/compare/v1.3.5...v1.3.6) (2025-12-30)
### 🐛 Bug Fixes / 问题修复
* Update server and web version update descriptions to remove version prefix for clarity ([cbd6e29](https://github.com/perfect-panel/frontend/commit/cbd6e29deda1c7a913fae4edb169e0a0a43a3dd9))
## [1.3.5](https://github.com/perfect-panel/frontend/compare/v1.3.4...v1.3.5) (2025-12-30)
### 🐛 Bug Fixes / 问题修复
* Update getUserSubscribe function to accept short and token parameters for improved URL generation ([39ebd09](https://github.com/perfect-panel/frontend/commit/39ebd09f0989b37308c1c803d8e380f967ab0db5))
## [1.3.4](https://github.com/perfect-panel/frontend/compare/v1.3.3...v1.3.4) (2025-12-29)
### 🐛 Bug Fixes / 问题修复
* Only update internalValue in MonacoEditor if propValue has changed ([fe14002](https://github.com/perfect-panel/frontend/commit/fe1400235967f2ad0c1ffbc05d9e15bab370c664))
* Set modal prop to false for DropdownMenu in multiple components for improved user experience ([0253b62](https://github.com/perfect-panel/frontend/commit/0253b62b6365476591121b6f18dcb3f3261cb4ff))
* Update HTMLEditor onChange event and set DropdownMenu modal to false for better user experience ([ba65588](https://github.com/perfect-panel/frontend/commit/ba65588fdfcfba146cd76bfe7d14e053291f5926))
* Update SendCode parameters to use form.watch for email and telephone fields ([51a98af](https://github.com/perfect-panel/frontend/commit/51a98afcae2c8b83c7176898ad8ac2c8b11ad3d9))
## [1.3.3](https://github.com/perfect-panel/frontend/compare/v1.3.2...v1.3.3) (2025-12-29)
### 🐛 Bug Fixes / 问题修复
* Update user_subscribe_id reference in RowMoreActions to use row.id for correct token reset and status toggle ([2156a7f](https://github.com/perfect-panel/frontend/commit/2156a7f1df52970b5ab0816dd1da5dc7cefc1dae))
## [1.3.2](https://github.com/perfect-panel/frontend/compare/v1.3.1...v1.3.2) (2025-12-29)
### 🐛 Bug Fixes / 问题修复
* Refactor update dialog logic in SystemVersionCard for better clarity and handling of update states ([0690deb](https://github.com/perfect-panel/frontend/commit/0690debf6c1bab7472468c742f9b15639f904c20))
* Update default inventory value in SubscribeForm to -1 for better handling of inventory state ([2f50c6d](https://github.com/perfect-panel/frontend/commit/2f50c6df3345e26bc92f5730bc8b891d0515a367))
## [1.3.1](https://github.com/perfect-panel/frontend/compare/v1.3.0...v1.3.1) (2025-12-29)
### 🐛 Bug Fixes / 问题修复
* : Update localization files and improve display logic for inventory handling ([83d821a](https://github.com/perfect-panel/frontend/commit/83d821a2dc24d97838af43ab610b65ff0d0d0d15))
* Update quantity handling in Purchase and Renewal components based on showOriginalPrice prop ([a274607](https://github.com/perfect-panel/frontend/commit/a2746073a636706b0e214eca84055735417680f3))
* Update SubscribeTable to display inventory using Display component for better clarity ([cc52e36](https://github.com/perfect-panel/frontend/commit/cc52e3614d9b6723f4ce8ab6385d626f0fcc54d7))
## [1.3.0](https://github.com/perfect-panel/frontend/compare/v1.2.4...v1.3.0) (2025-12-29)
### ✨ Features / 新功能
* Add original price display option and enhance inventory messages in subscription components ([543a7b9](https://github.com/perfect-panel/frontend/commit/543a7b9eb9b2cd278a70f668b0a0e0e9e261fe57))
* Added localized support for user subscription and deletion status, and optimized the subscription form and user interface. ([9f95cec](https://github.com/perfect-panel/frontend/commit/9f95cec876c9ac8bf00a6ca12a5c40243c7171af))
* Added reset and pause subscription functionality, and updated the status display. ([bc451ee](https://github.com/perfect-panel/frontend/commit/bc451eea16b51f1ab81b6cacf65201886866e1cc))
* Added the option to restore subscription, and updated the relevant description and confirmation information. ([5f5c339](https://github.com/perfect-panel/frontend/commit/5f5c33987e54533c5348223b3cebe31aef91aa25))
* Enhance DatePicker component with clear button and improved value handling ([b27b928](https://github.com/perfect-panel/frontend/commit/b27b9287be0eb26fe5d5189b618a32db340c506c))
* Refactor SubscriptionForm layout for improved readability and maintainability ([f432ba0](https://github.com/perfect-panel/frontend/commit/f432ba06f9d3a86ec00bfd316d1c020ddf779de6))
* Update API proxy target to use environment variable for improved configurability ([8d514df](https://github.com/perfect-panel/frontend/commit/8d514dfd8f0a8fb9acc97a288addd1b1cbc882b8))
* Update queryKey structure in Purchase and Renewal components for improved order creation ([9559e00](https://github.com/perfect-panel/frontend/commit/9559e003b7821303f7d0ac7f4da5fae4bf1ccad4))
* Update Vite configuration to load environment variables for improved API proxy setup ([d36a2c9](https://github.com/perfect-panel/frontend/commit/d36a2c902b7943441282278009c5e5210d30c746))
### 🐛 Bug Fixes / 问题修复
* Replace tag removal icon with a button for better accessibility and event handling ([3751f64](https://github.com/perfect-panel/frontend/commit/3751f64f73cd70eaddd44cb650dd38949f0ca069))
* Uncomment navigation to dashboard for authenticated users ([4d15b2b](https://github.com/perfect-panel/frontend/commit/4d15b2b6fc8f0d178c7c749aa9e1d9826bb706f8))
### 📚 Documentation / 文档更新
* Update default administrator account information and security recommendations in installation guides ([7279275](https://github.com/perfect-panel/frontend/commit/7279275532ad0b73994ffe5cf2e4c2f8dabc280b))
## [1.2.4](https://github.com/perfect-panel/frontend/compare/v1.2.3...v1.2.4) (2025-12-28)
### 🐛 Bug Fixes / 问题修复
* Add onSuccess callback to Unsubscribe component and conditionally render Renewal component ([7b5367a](https://github.com/perfect-panel/frontend/commit/7b5367a9a99ac8ae608a765b79a66c1f7380dcd8))
* Remove the system log dialog component from the system version card ([71cb827](https://github.com/perfect-panel/frontend/commit/71cb827918ee3250f0c9d06d46d876ce6799b8ac))
* Update invite link format in auth forms and sidebar to include hash fragment for routing. ([7a8c010](https://github.com/perfect-panel/frontend/commit/7a8c0102958a859c9e7476810d5c9b822f882692))
### 📚 Documentation / 文档更新
* Add one-click installation script for PPanel with Docker support ([912c5c4](https://github.com/perfect-panel/frontend/commit/912c5c4cb63eeb0ecbc33bef6b31bd50d83d6491))
### 🔧 Chores / 其他变更
* **release:** Release 1.2.4-dev.1 / 发布版本 1.2.4-dev.1 [skip ci] ([62d45bb](https://github.com/perfect-panel/frontend/commit/62d45bbac17fab9656c1cce029a379ce8bb757d6))
## [1.2.4-dev.1](https://github.com/perfect-panel/frontend/compare/v1.2.3...v1.2.4-dev.1) (2025-12-22) ## [1.2.4-dev.1](https://github.com/perfect-panel/frontend/compare/v1.2.3...v1.2.4-dev.1) (2025-12-22)
### 🐛 Bug Fixes / 问题修复 ### 🐛 Bug Fixes / 问题修复

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.",
@ -61,5 +63,5 @@
"system": "System", "system": "System",
"toggle": "Toggle theme" "toggle": "Toggle theme"
}, },
"unlimited": "unlimited" "unlimited": "Unlimited"
} }

View File

@ -30,6 +30,7 @@
"discountPercent": "Discount Percentage", "discountPercent": "Discount Percentage",
"Hour": "Hour", "Hour": "Hour",
"inventory": "Subscription Limit", "inventory": "Subscription Limit",
"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",
@ -56,6 +57,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

@ -13,9 +13,9 @@
"systemServices": "System Services", "systemServices": "System Services",
"update": "Update", "update": "Update",
"updateFailed": "Update failed", "updateFailed": "Update failed",
"updateServerDescription": "Are you sure you want to update the server version from V{{current}} to V{{latest}}?", "updateServerDescription": "Are you sure you want to update the server version from {{current}} to {{latest}}?",
"updateSuccess": "Update completed successfully", "updateSuccess": "Update completed successfully",
"updateWebDescription": "Are you sure you want to update the web version from V{{current}} to V{{latest}}?", "updateWebDescription": "Are you sure you want to update the web version from {{current}} to {{latest}}?",
"userUpdateSuccess": "User updated successfully", "userUpdateSuccess": "User updated successfully",
"webVersion": "Web Version" "webVersion": "Web Version"
} }

View File

@ -25,9 +25,11 @@
"createSuccess": "Created successfully", "createSuccess": "Created successfully",
"createUser": "Create User", "createUser": "Create User",
"delete": "Delete", "delete": "Delete",
"deleted": "Deleted",
"deleteDescription": "This action cannot be undone.", "deleteDescription": "This action cannot be undone.",
"deleteSubscriptionDescription": "This action cannot be undone.", "deleteSubscriptionDescription": "This action cannot be undone.",
"deleteSuccess": "Deleted successfully", "deleteSuccess": "Deleted successfully",
"isDeleted": "Status",
"deviceLimit": "Device Limit", "deviceLimit": "Device Limit",
"download": "Download", "download": "Download",
"downloadTraffic": "Download Traffic", "downloadTraffic": "Download Traffic",
@ -51,6 +53,7 @@
"loginStatus": "Login Status", "loginStatus": "Login Status",
"manager": "Administrator", "manager": "Administrator",
"more": "More", "more": "More",
"normal": "Normal",
"notifySettingsTitle": "Notify Settings", "notifySettingsTitle": "Notify Settings",
"offline": "Offline", "offline": "Offline",
"online": "Online", "online": "Online",
@ -80,6 +83,25 @@
"toggleSubscriptionStatus": "Toggle Status", "toggleSubscriptionStatus": "Toggle Status",
"toggleSubscriptionStatusDescription": "This will toggle the subscription status.", "toggleSubscriptionStatusDescription": "This will toggle the subscription status.",
"resetTime": "Reset Time", "resetTime": "Reset Time",
"resetToken": "Reset Subscription Address",
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
"resetTokenSuccess": "Subscription address reset successfully",
"confirmResetToken": "Confirm Reset Subscription Address",
"stopSubscribe": "Stop Subscription",
"stopSubscribeDescription": "This will stop the subscription temporarily. User will not be able to use it.",
"stopSubscribeSuccess": "Subscription stopped successfully",
"confirmStopSubscribe": "Confirm Stop Subscription",
"resumeSubscribe": "Resume Subscription",
"resumeSubscribeDescription": "This will resume the subscription and allow the user to use it.",
"resumeSubscribeSuccess": "Subscription resumed successfully",
"confirmResumeSubscribe": "Confirm Resume Subscription",
"status": "Status",
"statusPending": "Pending",
"statusActive": "Active",
"statusFinished": "Finished",
"statusExpired": "Expired",
"statusDeducted": "Deducted",
"statusStopped": "Stopped",
"save": "Save", "save": "Save",
"speedLimit": "Speed Limit", "speedLimit": "Speed Limit",
"startTime": "startTime", "startTime": "startTime",

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

View File

@ -13,9 +13,9 @@
"systemServices": "系统服务", "systemServices": "系统服务",
"update": "更新", "update": "更新",
"updateFailed": "更新失败", "updateFailed": "更新失败",
"updateServerDescription": "确定要将服务器版本从 V{{current}} 更新到 V{{latest}} 吗?", "updateServerDescription": "确定要将服务器版本从 {{current}} 更新到 {{latest}} 吗?",
"updateSuccess": "更新成功", "updateSuccess": "更新成功",
"updateWebDescription": "确定要将前端版本从 V{{current}} 更新到 V{{latest}} 吗?", "updateWebDescription": "确定要将前端版本从 {{current}} 更新到 {{latest}} 吗?",
"userUpdateSuccess": "用户端更新成功", "userUpdateSuccess": "用户端更新成功",
"webVersion": "前端版本" "webVersion": "前端版本"
} }

View File

@ -25,9 +25,11 @@
"createSuccess": "创建成功", "createSuccess": "创建成功",
"createUser": "创建用户", "createUser": "创建用户",
"delete": "删除", "delete": "删除",
"deleted": "已删除",
"deleteDescription": "此操作无法撤销。", "deleteDescription": "此操作无法撤销。",
"deleteSubscriptionDescription": "此操作无法撤销。", "deleteSubscriptionDescription": "此操作无法撤销。",
"deleteSuccess": "删除成功", "deleteSuccess": "删除成功",
"isDeleted": "状态",
"deviceLimit": "IP限制", "deviceLimit": "IP限制",
"download": "下载", "download": "下载",
"downloadTraffic": "下载流量", "downloadTraffic": "下载流量",
@ -51,6 +53,7 @@
"loginStatus": "登录状态", "loginStatus": "登录状态",
"manager": "管理员", "manager": "管理员",
"more": "更多", "more": "更多",
"normal": "正常",
"notifySettingsTitle": "通知设置", "notifySettingsTitle": "通知设置",
"offline": "离线", "offline": "离线",
"online": "在线", "online": "在线",
@ -80,6 +83,25 @@
"toggleSubscriptionStatus": "切换状态", "toggleSubscriptionStatus": "切换状态",
"toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。", "toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。",
"resetTime": "重置时间", "resetTime": "重置时间",
"resetToken": "重置订阅地址",
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
"resetTokenSuccess": "订阅地址重置成功",
"confirmResetToken": "确认重置订阅地址",
"stopSubscribe": "暂停订阅",
"stopSubscribeDescription": "这将暂时停止订阅。用户将无法使用。",
"stopSubscribeSuccess": "订阅已暂停",
"confirmStopSubscribe": "确认暂停订阅",
"resumeSubscribe": "恢复订阅",
"resumeSubscribeDescription": "这将恢复订阅,允许用户继续使用。",
"resumeSubscribeSuccess": "订阅已恢复",
"confirmResumeSubscribe": "确认恢复订阅",
"status": "状态",
"statusPending": "待处理",
"statusActive": "活跃",
"statusFinished": "已完成",
"statusExpired": "已过期",
"statusDeducted": "已扣除",
"statusStopped": "已停止",
"save": "保存", "save": "保存",
"speedLimit": "速度限制", "speedLimit": "速度限制",
"startTime": "开始时间", "startTime": "开始时间",

View File

@ -28,7 +28,7 @@ export function Display<T extends number | undefined | null>({
if ( if (
["traffic", "trafficSpeed", "number"].includes(type) && ["traffic", "trafficSpeed", "number"].includes(type) &&
unlimited && unlimited &&
!value (value === 0 || value === null || value === undefined)
) { ) {
return t("unlimited"); return t("unlimited");
} }
@ -42,7 +42,7 @@ export function Display<T extends number | undefined | null>({
} }
if (type === "number") { if (type === "number") {
return value ? value.toString() : "0"; return value !== null && value !== undefined ? value.toString() : "0";
} }
return "0"; return "0";

View File

@ -538,7 +538,7 @@ export default function EmailSettingsForm() {
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<HTMLEditor <HTMLEditor
onBlur={field.onChange} onChange={field.onChange}
placeholder={t( placeholder={t(
"email.inputPlaceholder", "email.inputPlaceholder",
"Please enter" "Please enter"
@ -642,7 +642,7 @@ export default function EmailSettingsForm() {
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<HTMLEditor <HTMLEditor
onBlur={field.onChange} onChange={field.onChange}
placeholder={t( placeholder={t(
"email.inputPlaceholder", "email.inputPlaceholder",
"Please enter" "Please enter"
@ -713,7 +713,7 @@ export default function EmailSettingsForm() {
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<HTMLEditor <HTMLEditor
onBlur={field.onChange} onChange={field.onChange}
placeholder={t( placeholder={t(
"email.inputPlaceholder", "email.inputPlaceholder",
"Please enter" "Please enter"
@ -795,7 +795,7 @@ export default function EmailSettingsForm() {
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<HTMLEditor <HTMLEditor
onBlur={field.onChange} onChange={field.onChange}
placeholder={t( placeholder={t(
"email.inputPlaceholder", "email.inputPlaceholder",
"Please enter" "Please enter"

View File

@ -179,7 +179,7 @@ export default function RegisterForm({
/> />
<SendCode <SendCode
params={{ params={{
...form.getValues(), email: form.watch("email"),
type: 1, type: 1,
}} }}
type="email" type="email"

View File

@ -98,7 +98,7 @@ export default function ResetForm({
/> />
<SendCode <SendCode
params={{ params={{
...form.getValues(), email: form.watch("email"),
type: 2, type: 2,
}} }}
type="email" type="email"

View File

@ -15,7 +15,7 @@ export default function Auth() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (user) { if (user) {
// navigate({ to: "/dashboard" }); navigate({ to: "/dashboard" });
} }
}, [navigate, user]); }, [navigate, user]);

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"));
@ -188,52 +188,52 @@ export default function SystemVersionCard() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge>V{packageJson.version}</Badge> <Badge>V{packageJson.version}</Badge>
{hasWebNewVersion && webVersionInfo && ( <AlertDialog onOpenChange={setOpenUpdateWeb} open={openUpdateWeb}>
<AlertDialog onOpenChange={setOpenUpdateWeb} open={openUpdateWeb}> <AlertDialogTrigger asChild>
<AlertDialogTrigger asChild> <Button
<Button className="h-6 px-2 text-xs"
className="h-6 px-2 text-xs" disabled={!hasWebNewVersion || isUpdatingWeb}
disabled={isUpdatingWeb} size="sm"
size="sm" variant="outline"
variant="outline" >
> <Icon className="mr-1 h-3 w-3" icon="mdi:download" />
<Icon className="mr-1 h-3 w-3" icon="mdi:download" /> {hasWebNewVersion && webVersionInfo
{t("update", "Update")} V{webVersionInfo.latest_version} ? `${t("update", "Update")} ${webVersionInfo.latest_version}`
: t("update", "Update")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("confirmUpdate", "Confirm Update")}
</AlertDialogTitle>
<AlertDialogDescription>
{webVersionInfo
? t(
"updateWebDescription",
"Are you sure you want to update the web version from {{current}} to {{latest}}?",
{
current: webVersionInfo.current_version,
latest: webVersionInfo.latest_version,
}
)
: t(
"updateDescription",
"Are you sure you want to update?"
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel", "Cancel")}</AlertDialogCancel>
<Button disabled={isUpdatingWeb} onClick={handleUpdateWeb}>
{isUpdatingWeb && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("confirmUpdate", "Confirm Update")}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogFooter>
<AlertDialogContent> </AlertDialogContent>
<AlertDialogHeader> </AlertDialog>
<AlertDialogTitle>
{t("confirmUpdate", "Confirm Update")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"updateWebDescription",
"Are you sure you want to update the web version from V{{current}} to V{{latest}}?",
{
current: packageJson.version,
latest: webVersionInfo.latest_version,
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<Button disabled={isUpdatingWeb} onClick={handleUpdateWeb}>
{isUpdatingWeb && (
<Icon
className="mr-2 animate-spin"
icon="mdi:loading"
/>
)}
{t("confirmUpdate", "Confirm Update")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div> </div>
</div> </div>
@ -251,62 +251,63 @@ export default function SystemVersionCard() {
serverVersionInfo?.current_version || serverVersionInfo?.current_version ||
"1.0.0"} "1.0.0"}
</Badge> </Badge>
{hasServerNewVersion && serverVersionInfo && moduleConfig && ( <AlertDialog
<AlertDialog onOpenChange={setOpenUpdateServer}
onOpenChange={setOpenUpdateServer} open={openUpdateServer}
open={openUpdateServer} >
> <AlertDialogTrigger asChild>
<AlertDialogTrigger asChild> <Button
className="h-6 px-2 text-xs"
disabled={!hasServerNewVersion || isUpdatingServer}
size="sm"
variant="outline"
>
<Icon className="mr-1 h-3 w-3" icon="mdi:download" />
{hasServerNewVersion && serverVersionInfo
? `${t("update", "Update")} ${serverVersionInfo.latest_version}`
: t("update", "Update")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("confirmUpdate", "Confirm Update")}
</AlertDialogTitle>
<AlertDialogDescription>
{serverVersionInfo && moduleConfig
? t(
"updateServerDescription",
"Are you sure you want to update the server version from {{current}} to {{latest}}?",
{
current:
moduleConfig.service_version ||
serverVersionInfo.current_version,
latest: serverVersionInfo.latest_version,
}
)
: t(
"updateDescription",
"Are you sure you want to update?"
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel", "Cancel")}</AlertDialogCancel>
<Button <Button
className="h-6 px-2 text-xs" disabled={isUpdatingServer || !moduleConfig}
disabled={isUpdatingServer} onClick={() =>
size="sm" moduleConfig &&
variant="outline" updateServerMutation.mutate(moduleConfig.service_name)
}
> >
<Icon className="mr-1 h-3 w-3" icon="mdi:download" /> {isUpdatingServer && (
{t("update", "Update")} V{serverVersionInfo.latest_version} <Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("confirmUpdate", "Confirm Update")}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogFooter>
<AlertDialogContent> </AlertDialogContent>
<AlertDialogHeader> </AlertDialog>
<AlertDialogTitle>
{t("confirmUpdate", "Confirm Update")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"updateServerDescription",
"Are you sure you want to update the server version from V{{current}} to V{{latest}}?",
{
current:
moduleConfig.service_version ||
serverVersionInfo.current_version,
latest: serverVersionInfo.latest_version,
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<Button
disabled={isUpdatingServer}
onClick={() =>
updateServerMutation.mutate(moduleConfig.service_name)
}
>
{isUpdatingServer && (
<Icon
className="mr-2 animate-spin"
icon="mdi:loading"
/>
)}
{t("confirmUpdate", "Confirm Update")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@ -63,7 +63,7 @@ interface SubscribeFormProps<T> {
} }
const defaultValues = { const defaultValues = {
inventory: 0, inventory: -1,
speed_limit: 0, speed_limit: 0,
device_limit: 0, device_limit: 0,
traffic: 0, traffic: 0,
@ -278,7 +278,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
{trigger} {trigger}
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="w-[800px] max-w-full gap-0 md:max-w-screen-md"> <SheetContent className="w-[800px] max-w-full gap-0 md:max-w-3xl">
<SheetHeader> <SheetHeader>
<SheetTitle>{title}</SheetTitle> <SheetTitle>{title}</SheetTitle>
</SheetHeader> </SheetHeader>
@ -446,11 +446,10 @@ 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}
@ -901,6 +900,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 <Display type="number" unlimited value={0} />
value={ ) : (
row.getValue("inventory") === -1 ? 0 : row.getValue("inventory") <Display type="number" unlimited value={inventory} />
} );
/> },
),
}, },
{ {
accessorKey: "quota", accessorKey: "quota",

View File

@ -98,7 +98,7 @@ function DynamicField({
field.generate ? ( field.generate ? (
field.generate.functions && field.generate.functions &&
field.generate.functions.length > 0 ? ( field.generate.functions.length > 0 ? (
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="sm" type="button" variant="ghost"> <Button size="sm" type="button" variant="ghost">
<Icon className="h-4 w-4" icon="mdi:key" /> <Icon className="h-4 w-4" icon="mdi:key" />

View File

@ -92,7 +92,7 @@ export default function User() {
<Button variant="destructive">{t("delete", "Delete")}</Button> <Button variant="destructive">{t("delete", "Delete")}</Button>
} }
/>, />,
<DropdownMenu key="more"> <DropdownMenu key="more" modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button> <Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -176,6 +176,18 @@ export default function User() {
accessorKey: "id", accessorKey: "id",
header: "ID", header: "ID",
}, },
{
accessorKey: "deleted_at",
header: t("isDeleted", "Deleted"),
cell: ({ row }) => {
const deletedAt = row.getValue("deleted_at") as number | undefined;
return deletedAt ? (
<Badge variant="destructive">{t("deleted", "Deleted")}</Badge>
) : (
<Badge variant="outline">{t("normal", "Normal")}</Badge>
);
},
},
{ {
accessorKey: "auth_methods", accessorKey: "auth_methods",
header: t("userName", "Username"), header: t("userName", "Username"),
@ -371,16 +383,13 @@ function SubscriptionSheet({ userId }: { userId: number }) {
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="secondary">{t("subscription", "Subscription")}</Button> <Button variant="secondary">{t("subscription", "Subscription")}</Button>
</SheetTrigger> </SheetTrigger>
<SheetContent <SheetContent className="w-[1000px] max-w-full md:max-w-7xl" side="right">
className="w-[1000px] max-w-full md:max-w-screen-xl"
side="right"
>
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>
{t("subscriptionList", "Subscription List")} · ID: {userId} {t("subscriptionList", "Subscription List")} · ID: {userId}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="mt-2"> <div className="mt-2 px-4">
<UserSubscription userId={userId} /> <UserSubscription userId={userId} />
</div> </div>
</SheetContent> </SheetContent>

View File

@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button"; import { Button } from "@workspace/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
@ -16,7 +17,6 @@ import {
deleteUserSubscribe, deleteUserSubscribe,
getUserSubscribe, getUserSubscribe,
resetUserSubscribeToken, resetUserSubscribeToken,
resetUserSubscribeTraffic,
toggleUserSubscribeStatus, toggleUserSubscribeStatus,
updateUserSubscribe, updateUserSubscribe,
} from "@workspace/ui/services/admin/user"; } from "@workspace/ui/services/admin/user";
@ -33,7 +33,6 @@ export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null); const ref = useRef<ProTableActions>(null);
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
return ( return (
<ProTable<API.UserSubscribe, Record<string, unknown>> <ProTable<API.UserSubscribe, Record<string, unknown>>
@ -59,100 +58,14 @@ export default function UserSubscription({ userId }: { userId: number }) {
title={t("editSubscription", "Edit Subscription")} title={t("editSubscription", "Edit Subscription")}
trigger={t("edit", "Edit")} trigger={t("edit", "Edit")}
/>, />,
<Button <RowMoreActions
key="copy" key="more"
onClick={async () => { refresh={() => ref.current?.refresh()}
await navigator.clipboard.writeText( row={row}
getUserSubscribeUrls(row.token)[0] || "" token={row.token}
); userId={userId}
toast.success(t("copySuccess", "Copied successfully")); />
}}
variant="secondary"
>
{t("copySubscription", "Copy Subscription")}
</Button>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"resetSubscriptionTokenDescription",
"This will reset the subscription token. Old links will become invalid."
)}
key="reset-token"
onConfirm={async () => {
await resetUserSubscribeToken({ user_subscribe_id: row.id });
toast.success(t("resetSuccess", "Reset successfully"));
ref.current?.refresh();
}}
title={t("resetSubscriptionToken", "Reset Token")}
trigger={
<Button variant="outline">
{t("resetToken", "Reset Token")}
</Button>
}
/>, />,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"resetSubscriptionTrafficDescription",
"This will reset the subscription traffic counters."
)}
key="reset-traffic"
onConfirm={async () => {
await resetUserSubscribeTraffic({ user_subscribe_id: row.id });
toast.success(t("resetSuccess", "Reset successfully"));
ref.current?.refresh();
}}
title={t("resetSubscriptionTraffic", "Reset Traffic")}
trigger={
<Button variant="outline">
{t("resetTraffic", "Reset Traffic")}
</Button>
}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"toggleSubscriptionStatusDescription",
"This will toggle the subscription status."
)}
key="toggle-status"
onConfirm={async () => {
await toggleUserSubscribeStatus({ user_subscribe_id: row.id });
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
}}
title={t("toggleSubscriptionStatus", "Toggle Status")}
trigger={
<Button variant="outline">
{t("toggleStatus", "Toggle Status")}
</Button>
}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteSubscriptionDescription",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
<RowMoreActions key="more" subId={row.id} userId={userId} />,
], ],
}} }}
columns={[ columns={[
@ -165,6 +78,51 @@ export default function UserSubscription({ userId }: { userId: number }) {
header: t("subscriptionName", "Subscription Name"), header: t("subscriptionName", "Subscription Name"),
cell: ({ row }) => row.original.subscribe.name, cell: ({ row }) => row.original.subscribe.name,
}, },
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) => {
const status = row.getValue("status") as number;
const expireTime = row.original.expire_time;
// 如果过期时间为0说明是永久订阅应该显示为激活状态
const displayStatus = status === 3 && expireTime === 0 ? 1 : status;
const statusMap: Record<
number,
{
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
0: { label: t("statusPending", "Pending"), variant: "outline" },
1: { label: t("statusActive", "Active"), variant: "default" },
2: {
label: t("statusFinished", "Finished"),
variant: "secondary",
},
3: {
label: t("statusExpired", "Expired"),
variant: "destructive",
},
4: {
label: t("statusDeducted", "Deducted"),
variant: "secondary",
},
5: {
label: t("statusStopped", "Stopped"),
variant: "destructive",
},
};
const statusInfo = statusMap[displayStatus] || {
label: "Unknown",
variant: "outline",
};
return (
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
);
},
},
{ {
accessorKey: "upload", accessorKey: "upload",
header: t("upload", "Upload"), header: t("upload", "Upload"),
@ -216,10 +174,12 @@ export default function UserSubscription({ userId }: { userId: number }) {
{ {
accessorKey: "expire_time", accessorKey: "expire_time",
header: t("expireTime", "Expire Time"), header: t("expireTime", "Expire Time"),
cell: ({ row }) => cell: ({ row }) => {
row.getValue("expire_time") const expireTime = row.getValue("expire_time") as number;
? formatDate(row.getValue("expire_time")) return expireTime && expireTime !== 0
: t("permanent", "Permanent"), ? formatDate(expireTime)
: t("permanent", "Permanent");
},
}, },
{ {
accessorKey: "created_at", accessorKey: "created_at",
@ -263,19 +223,72 @@ export default function UserSubscription({ userId }: { userId: number }) {
); );
} }
function RowMoreActions({ userId, subId }: { userId: number; subId: number }) { function RowMoreActions({
userId,
row,
token,
refresh,
}: {
userId: number;
row: API.UserSubscribe;
token: string;
refresh: () => void;
}) {
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const resetTokenRef = useRef<HTMLButtonElement>(null);
const toggleStatusRef = useRef<HTMLButtonElement>(null);
const deleteRef = useRef<HTMLButtonElement>(null);
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
return ( return (
<div className="inline-flex"> <div className="inline-flex">
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button> <Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async (e) => {
e.preventDefault();
await navigator.clipboard.writeText(
getUserSubscribeUrls(row.short, token)[0] || ""
);
toast.success(t("copySuccess", "Copied successfully"));
}}
>
{t("copySubscription", "Copy Subscription")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
resetTokenRef.current?.click();
}}
>
{t("resetToken", "Reset Subscription Address")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
toggleStatusRef.current?.click();
}}
>
{row.status === 5
? t("resumeSubscribe", "Resume Subscription")
: t("stopSubscribe", "Stop Subscription")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={(e) => {
e.preventDefault();
deleteRef.current?.click();
}}
>
{t("delete", "Delete")}
</DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: userId, user_subscribe_id: subId }} search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe" to="/dashboard/log/subscribe"
> >
{t("subscriptionLogs", "Subscription Logs")} {t("subscriptionLogs", "Subscription Logs")}
@ -283,7 +296,7 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: userId, user_subscribe_id: subId }} search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/reset-subscribe" to="/dashboard/log/reset-subscribe"
> >
{t("resetLogs", "Reset Logs")} {t("resetLogs", "Reset Logs")}
@ -291,7 +304,7 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: userId, user_subscribe_id: subId }} search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe-traffic" to="/dashboard/log/subscribe-traffic"
> >
{t("trafficStats", "Traffic Stats")} {t("trafficStats", "Traffic Stats")}
@ -299,7 +312,7 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: userId, subscribe_id: subId }} search={{ user_id: userId, subscribe_id: row.id }}
to="/dashboard/log/traffic-details" to="/dashboard/log/traffic-details"
> >
{t("trafficDetails", "Traffic Details")} {t("trafficDetails", "Traffic Details")}
@ -316,8 +329,78 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Hidden triggers for confirm dialogs */}
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"resetTokenDescription",
"This will reset the subscription address and regenerate a new token."
)}
onConfirm={async () => {
await resetUserSubscribeToken({
user_subscribe_id: row.id,
});
toast.success(
t("resetTokenSuccess", "Subscription address reset successfully")
);
refresh();
}}
title={t("confirmResetToken", "Confirm Reset Subscription Address")}
trigger={<Button className="hidden" ref={resetTokenRef} />}
/>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={
row.status === 5
? t(
"resumeSubscribeDescription",
"This will resume the subscription and allow the user to use it."
)
: t(
"stopSubscribeDescription",
"This will stop the subscription temporarily. User will not be able to use it."
)
}
onConfirm={async () => {
await toggleUserSubscribeStatus({
user_subscribe_id: row.id,
});
toast.success(
row.status === 5
? t("resumeSubscribeSuccess", "Subscription resumed successfully")
: t("stopSubscribeSuccess", "Subscription stopped successfully")
);
refresh();
}}
title={
row.status === 5
? t("confirmResumeSubscribe", "Confirm Resume Subscription")
: t("confirmStopSubscribe", "Confirm Stop Subscription")
}
trigger={<Button className="hidden" ref={toggleStatusRef} />}
/>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteSubscriptionDescription",
"This action cannot be undone."
)}
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={<Button className="hidden" ref={deleteRef} />}
/>
<SubscriptionDetail <SubscriptionDetail
subscriptionId={subId} subscriptionId={row.id}
trigger={<Button className="hidden" ref={triggerRef} />} trigger={<Button className="hidden" ref={triggerRef} />}
userId={userId} userId={userId}
/> />

View File

@ -95,145 +95,149 @@ export function SubscriptionForm({
<SheetHeader> <SheetHeader>
<SheetTitle>{title}</SheetTitle> <SheetTitle>{title}</SheetTitle>
</SheetHeader> </SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]"> <ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-4">
<Form {...form}> <div className="pr-4">
<form <Form {...form}>
className="mt-4 space-y-4" <form
onSubmit={form.handleSubmit(handleSubmit)} className="mt-4 space-y-4"
> onSubmit={form.handleSubmit(handleSubmit)}
<FormField >
control={form.control} <FormField
name="subscribe_id" control={form.control}
render={({ field }) => ( name="subscribe_id"
<FormItem> render={({ field }) => (
<FormLabel>{t("subscription", "Subscription")}</FormLabel> <FormItem>
<FormControl> <FormLabel>{t("subscription", "Subscription")}</FormLabel>
<Combobox<number, false> <FormControl>
onChange={(value) => { <Combobox<number, false>
form.setValue(field.name, value); onChange={(value) => {
}} form.setValue(field.name, value);
options={subscribes?.map((item) => ({ }}
value: item.id!, options={subscribes?.map((item) => ({
label: item.name!, value: item.id!,
}))} label: item.name!,
placeholder="Select Subscription" }))}
value={field.value} placeholder="Select Subscription"
/> value={field.value}
</FormControl> />
<FormMessage /> </FormControl>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
<FormField />
control={form.control} <FormField
name="traffic" control={form.control}
render={({ field }) => ( name="traffic"
<FormItem> render={({ field }) => (
<FormLabel>{t("trafficLimit", "Traffic Limit")}</FormLabel> <FormItem>
<FormControl> <FormLabel>
<EnhancedInput {t("trafficLimit", "Traffic Limit")}
placeholder={t("unlimited", "Unlimited")} </FormLabel>
type="number" <FormControl>
{...field} <EnhancedInput
formatInput={(value) => placeholder={t("unlimited", "Unlimited")}
unitConversion("bytesToGb", value) type="number"
} {...field}
formatOutput={(value) => formatInput={(value) =>
unitConversion("gbToBytes", value) unitConversion("bytesToGb", value)
}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="GB"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="upload"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("uploadTraffic", "Upload Traffic")}
</FormLabel>
<FormControl>
<EnhancedInput
placeholder="0"
type="number"
{...field}
formatInput={(value) =>
unitConversion("bytesToGb", value)
}
formatOutput={(value) =>
unitConversion("gbToBytes", value)
}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="GB"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="download"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("downloadTraffic", "Download Traffic")}
</FormLabel>
<FormControl>
<EnhancedInput
placeholder="0"
type="number"
{...field}
formatInput={(value) =>
unitConversion("bytesToGb", value)
}
formatOutput={(value) =>
unitConversion("gbToBytes", value)
}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="GB"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expired_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("expiredAt", "Expired At")}</FormLabel>
<FormControl>
<DatePicker
onChange={(value: number | null | undefined) => {
if (value === field.value) {
form.setValue(field.name, 0);
} else {
form.setValue(field.name, value!);
} }
}} formatOutput={(value) =>
placeholder={t("permanent", "Permanent")} unitConversion("gbToBytes", value)
value={field.value ?? undefined} }
/> onValueChange={(value) => {
</FormControl> form.setValue(field.name, value as number);
<FormMessage /> }}
</FormItem> suffix="GB"
)} />
/> </FormControl>
</form> <FormMessage />
</Form> </FormItem>
)}
/>
<FormField
control={form.control}
name="upload"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("uploadTraffic", "Upload Traffic")}
</FormLabel>
<FormControl>
<EnhancedInput
placeholder="0"
type="number"
{...field}
formatInput={(value) =>
unitConversion("bytesToGb", value)
}
formatOutput={(value) =>
unitConversion("gbToBytes", value)
}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="GB"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="download"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("downloadTraffic", "Download Traffic")}
</FormLabel>
<FormControl>
<EnhancedInput
placeholder="0"
type="number"
{...field}
formatInput={(value) =>
unitConversion("bytesToGb", value)
}
formatOutput={(value) =>
unitConversion("gbToBytes", value)
}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="GB"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expired_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("expiredAt", "Expired At")}</FormLabel>
<FormControl>
<DatePicker
onChange={(value: number | null | undefined) => {
form.setValue(field.name, value || 0);
}}
placeholder={t("permanent", "Permanent")}
value={
field.value && field.value > 0
? field.value
: undefined
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
</ScrollArea> </ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3"> <SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button <Button

View File

@ -8,7 +8,7 @@ export interface GlobalStore {
setCommon: (common: Partial<API.GetGlobalConfigResponse>) => void; setCommon: (common: Partial<API.GetGlobalConfigResponse>) => void;
setUser: (user?: API.User) => void; setUser: (user?: API.User) => void;
getUserInfo: () => Promise<void>; getUserInfo: () => Promise<void>;
getUserSubscribe: (uuid: string, type?: string) => string[]; getUserSubscribe: (short: string, token: string, type?: string) => string[];
getAppSubLink: (url: string, schema?: string) => string; getAppSubLink: (url: string, schema?: string) => string;
} }
@ -120,7 +120,7 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
console.error("Failed to refresh user:", error); console.error("Failed to refresh user:", error);
} }
}, },
getUserSubscribe: (uuid: string, type?: string) => { getUserSubscribe: (short: string, token: string, type?: string) => {
const { pan_domain, subscribe_domain, subscribe_path } = const { pan_domain, subscribe_domain, subscribe_path } =
get().common.subscribe || {}; get().common.subscribe || {};
const domains = subscribe_domain const domains = subscribe_domain
@ -129,12 +129,13 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
return domains.map((domain) => { return domains.map((domain) => {
if (pan_domain) { if (pan_domain) {
if (type) return `https://${uuid}.${type}.${domain}`; if (type)
return `https://${uuid}.${domain}`; return `https://${short}.${type}.${domain}${subscribe_path}?token=${token}&type=${type}`;
return `https://${short}.${domain}${subscribe_path}?token=${token}`;
} }
if (type) if (type)
return `https://${domain}${subscribe_path}?token=${uuid}&type=${type}`; return `https://${domain}${subscribe_path}?token=${token}&type=${type}`;
return `https://${domain}${subscribe_path}?token=${uuid}`; return `https://${domain}${subscribe_path}?token=${token}`;
}); });
}, },
getAppSubLink: (url: string, schema?: string) => { getAppSubLink: (url: string, schema?: string) => {

View File

@ -4,7 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
import { devtools } from "@tanstack/devtools-vite"; import { devtools } from "@tanstack/devtools-vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite";
import viteReact from "@vitejs/plugin-react"; import viteReact from "@vitejs/plugin-react";
import { defineConfig, type Plugin } from "vite"; import { defineConfig, loadEnv, type Plugin } from "vite";
// Plugin to generate version.lock file after build // Plugin to generate version.lock file after build
function versionLockPlugin(): Plugin { function versionLockPlugin(): Plugin {
@ -24,34 +24,37 @@ function versionLockPlugin(): Plugin {
} }
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
base: "./", const env = loadEnv(mode, process.cwd(), "");
plugins: [
devtools({ eventBusConfig: { port: 42_070 } }), return {
tanstackRouter({ base: "./",
target: "react", plugins: [
autoCodeSplitting: true, devtools({ eventBusConfig: { port: 42_070 } }),
}), tanstackRouter({
viteReact(), target: "react",
tailwindcss(), autoCodeSplitting: true,
versionLockPlugin(), }),
], viteReact(),
resolve: { tailwindcss(),
alias: { versionLockPlugin(),
"@": fileURLToPath(new URL("./src", import.meta.url)), ],
}, resolve: {
}, alias: {
server: { "@": fileURLToPath(new URL("./src", import.meta.url)),
proxy: {
"/api": {
target: "https://api.ppanel.dev",
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, ""),
}, },
}, },
}, server: {
build: { proxy: {
assetsDir: "static", "/api": {
}, target: env.VITE_API_BASE_URL || "https://api.ppanel.dev",
changeOrigin: true,
secure: false,
},
},
},
build: {
assetsDir: "static",
},
};
}); });

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

@ -33,7 +33,7 @@ export function UserNav() {
if (user) { if (user) {
return ( return (
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className="flex cursor-pointer items-center gap-2 rounded-full border bg-background px-2 py-1.5 transition-colors duration-200 hover:bg-accent"> <div className="flex cursor-pointer items-center gap-2 rounded-full border bg-background px-2 py-1.5 transition-colors duration-200 hover:bg-accent">
<Avatar className="h-6 w-6"> <Avatar className="h-6 w-6">

View File

@ -173,7 +173,7 @@ export default function RegisterForm({
/> />
<SendCode <SendCode
params={{ params={{
...form.getValues(), email: form.watch("email"),
type: 1, type: 1,
}} }}
type="email" type="email"

View File

@ -98,7 +98,7 @@ export default function ResetForm({
/> />
<SendCode <SendCode
params={{ params={{
...form.getValues(), email: form.watch("email"),
type: 2, type: 2,
}} }}
type="email" type="email"

View File

@ -127,7 +127,10 @@ export default function LoginForm({
{mode === "code" && ( {mode === "code" && (
<SendCode <SendCode
params={{ params={{
...form.getValues(), telephone: form.watch("telephone"),
telephone_area_code: form.watch(
"telephone_area_code"
),
type: 2, type: 2,
}} }}
type="phone" type="phone"

View File

@ -182,7 +182,10 @@ export default function RegisterForm({
<SendCode <SendCode
params={{ params={{
...form.getValues(), telephone: form.watch("telephone"),
telephone_area_code: form.watch(
"telephone_area_code"
),
type: 1, type: 1,
}} }}
type="phone" type="phone"

View File

@ -124,7 +124,8 @@ export default function ResetForm({
/> />
<SendCode <SendCode
params={{ params={{
...form.getValues(), telephone: form.watch("telephone"),
telephone_area_code: form.watch("telephone_area_code"),
type: 2, type: 2,
}} }}
type="phone" type="phone"

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

@ -15,6 +15,7 @@ interface DurationSelectorProps {
unitTime?: string; unitTime?: string;
discounts?: Array<{ quantity: number; discount: number }>; discounts?: Array<{ quantity: number; discount: number }>;
onChange: (value: number) => void; onChange: (value: number) => void;
showOriginalPrice?: boolean;
} }
const DurationSelector: React.FC<DurationSelectorProps> = ({ const DurationSelector: React.FC<DurationSelectorProps> = ({
@ -22,6 +23,7 @@ const DurationSelector: React.FC<DurationSelectorProps> = ({
unitTime = "Month", unitTime = "Month",
discounts = [], discounts = [],
onChange, onChange,
showOriginalPrice = true,
}) => { }) => {
const { t } = useTranslation("subscribe"); const { t } = useTranslation("subscribe");
const handleChange = useCallback( const handleChange = useCallback(
@ -61,7 +63,7 @@ const DurationSelector: React.FC<DurationSelectorProps> = ({
onValueChange={handleChange} onValueChange={handleChange}
value={String(quantity)} value={String(quantity)}
> >
{unitTime !== "Minute" && ( {showOriginalPrice && unitTime !== "Minute" && (
<DurationOption label={`1 / ${t(unitTime)}`} value="1" /> <DurationOption label={`1 / ${t(unitTime)}`} value="1" />
)} )}
{discounts?.map((item) => ( {discounts?.map((item) => (
@ -78,7 +80,7 @@ const DurationSelector: React.FC<DurationSelectorProps> = ({
</span> </span>
{discountPercentage > 0 ? ( {discountPercentage > 0 ? (
<Badge className="h-6 text-sm" variant="destructive"> <Badge className="h-6 text-sm" variant="destructive">
-{discountPercentage}% {t("discount", "Discount")} -{discountPercentage.toFixed(2)}% {t("discount", "Discount")}
</Badge> </Badge>
) : ( ) : (
<span className="h-6 text-muted-foreground text-sm">--</span> <span className="h-6 text-muted-foreground text-sm">--</span>

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

@ -45,7 +45,13 @@ export default function Purchase({
const { data: order } = useQuery({ const { data: order } = useQuery({
enabled: !!subscribe?.id, enabled: !!subscribe?.id,
queryKey: ["preCreateOrder", params], queryKey: [
"preCreateOrder",
subscribe?.id,
params.quantity,
params.payment,
params.coupon,
],
queryFn: async () => { queryFn: async () => {
try { try {
const { data } = await preCreateOrder({ const { data } = await preCreateOrder({
@ -68,9 +74,13 @@ export default function Purchase({
useEffect(() => { useEffect(() => {
if (subscribe) { if (subscribe) {
const defaultQuantity =
subscribe.show_original_price === false && subscribe.discount?.[0]
? subscribe.discount[0].quantity
: 1;
setParams((prev) => ({ setParams((prev) => ({
...prev, ...prev,
quantity: 1, quantity: defaultQuantity,
subscribe_id: subscribe?.id, subscribe_id: subscribe?.id,
})); }));
} }
@ -127,6 +137,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>
@ -139,6 +150,7 @@ export default function Purchase({
handleChange("quantity", value); handleChange("quantity", value);
}} }}
quantity={params.quantity as number} quantity={params.quantity as number}
showOriginalPrice={subscribe?.show_original_price}
unitTime={subscribe?.unit_time} unitTime={subscribe?.unit_time}
/> />
<CouponInput <CouponInput

View File

@ -44,7 +44,13 @@ export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
const { data: order } = useQuery({ const { data: order } = useQuery({
enabled: !!subscribe.id && open, enabled: !!subscribe.id && open,
queryKey: ["preCreateOrder", params], queryKey: [
"preCreateOrder",
subscribe.id,
params.quantity,
params.payment,
params.coupon,
],
queryFn: async () => { queryFn: async () => {
try { try {
const { data } = await preCreateOrder({ const { data } = await preCreateOrder({
@ -66,9 +72,13 @@ export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
useEffect(() => { useEffect(() => {
if (subscribe.id && id) { if (subscribe.id && id) {
const defaultQuantity =
subscribe.show_original_price === false && subscribe.discount?.[0]
? subscribe.discount[0].quantity
: 1;
setParams((prev) => ({ setParams((prev) => ({
...prev, ...prev,
quantity: 1, quantity: defaultQuantity,
subscribe_id: subscribe.id, subscribe_id: subscribe.id,
user_subscribe_id: id, user_subscribe_id: id,
})); }));
@ -126,6 +136,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>
@ -138,6 +149,7 @@ export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
handleChange("quantity", value); handleChange("quantity", value);
}} }}
quantity={params.quantity!} quantity={params.quantity!}
showOriginalPrice={subscribe?.show_original_price}
unitTime={subscribe?.unit_time} unitTime={subscribe?.unit_time}
/> />
<CouponInput <CouponInput

View File

@ -218,337 +218,354 @@ export default function Content() {
</Tabs> </Tabs>
)} )}
</div> </div>
{userSubscribe.map((item) => ( {userSubscribe.map((item) => {
<Card // 如果过期时间为0说明是永久订阅不应该显示过期状态
className={cn("relative", { const isActuallyExpired =
"relative opacity-80 grayscale": item.status === 3, item.status === 3 && item.expire_time !== 0;
"relative hidden opacity-60 blur-[0.3px] grayscale": const shouldShowWatermark =
item.status === 4, item.status === 2 || item.status === 4 || isActuallyExpired;
})}
key={item.id}
>
{item.status >= 2 && (
<div
className={cn(
"pointer-events-none absolute top-0 left-0 z-10 h-full w-full overflow-hidden mix-blend-difference brightness-150 contrast-200 invert-[0.2]",
{
"text-destructive": item.status === 2,
"text-white": item.status === 3 || item.status === 4,
}
)}
>
<div className="absolute inset-0">
{Array.from({ length: 16 }).map((_, i) => {
const row = Math.floor(i / 4);
const col = i % 4;
const top = 10 + row * 25 + (col % 2 === 0 ? 5 : -5);
const left = 5 + col * 30 + (row % 2 === 0 ? 0 : 10);
return ( return (
<span <Card
className="absolute rotate-[-30deg] whitespace-nowrap font-black text-lg opacity-40 shadow-[0px_0px_1px_rgba(255,255,255,0.5)]" className={cn("relative", {
key={i} "relative opacity-80 grayscale": isActuallyExpired,
style={{ "relative hidden opacity-60 blur-[0.3px] grayscale":
top: `${top}%`, item.status === 4,
left: `${left}%`, })}
}} key={item.id}
> >
{ {shouldShowWatermark && (
statusWatermarks[ <div
item.status as keyof typeof statusWatermarks className={cn(
] "pointer-events-none absolute top-0 left-0 z-10 h-full w-full overflow-hidden mix-blend-difference brightness-150 contrast-200 invert-[0.2]",
} {
</span> "text-destructive": item.status === 2,
); "text-white": isActuallyExpired || item.status === 4,
})} }
</div> )}
</div> >
)} <div className="absolute inset-0">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0"> {Array.from({ length: 16 }).map((_, i) => {
<CardTitle className="font-medium"> const row = Math.floor(i / 4);
{item.subscribe.name} const col = i % 4;
<p className="mt-1 text-foreground/50 text-sm"> const top = 10 + row * 25 + (col % 2 === 0 ? 5 : -5);
{formatDate(item.start_time)} const left = 5 + col * 30 + (row % 2 === 0 ? 0 : 10);
</p>
</CardTitle> return (
{item.status !== 4 && ( <span
<div className="flex flex-wrap gap-2"> className="absolute rotate-[-30deg] whitespace-nowrap font-black text-lg opacity-40 shadow-[0px_0px_1px_rgba(255,255,255,0.5)]"
<AlertDialog> key={i}
<AlertDialogTrigger asChild> style={{
<Button size="sm" variant="destructive"> top: `${top}%`,
{t("resetSubscription", "Reset Subscription")} left: `${left}%`,
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("prompt", "Prompt")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"confirmResetSubscription",
"Are you sure you want to reset your subscription?"
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await resetUserSubscribeToken({
user_subscribe_id: item.id,
});
await refetch();
toast.success(t("resetSuccess", "Reset Success"));
}} }}
> >
{t("confirm", "Confirm")} {
</AlertDialogAction> statusWatermarks[
</AlertDialogFooter> item.status as keyof typeof statusWatermarks
</AlertDialogContent> ]
</AlertDialog> }
<ResetTraffic </span>
id={item.id} );
replacement={item.subscribe.replacement} })}
/> </div>
{item.expire_time !== 0 && (
<Renewal id={item.id} subscribe={item.subscribe} />
)}
<Unsubscribe
allowDeduction={item.subscribe.allow_deduction}
id={item.id}
onSuccess={refetch}
/>
</div> </div>
)} )}
</CardHeader> <CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0">
<CardContent> <CardTitle className="font-medium">
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4"> {item.subscribe.name}
<li> <p className="mt-1 text-foreground/50 text-sm">
<span className="text-muted-foreground"> {formatDate(item.start_time)}
{t("used", "Used")} </p>
</span> </CardTitle>
<span className="font-bold text-2xl"> {item.status !== 4 && (
<Display <div className="flex flex-wrap gap-2">
type="traffic" <AlertDialog>
unlimited={!item.traffic} <AlertDialogTrigger asChild>
value={item.upload + item.download} <Button size="sm" variant="destructive">
/> {t("resetSubscription", "Reset Subscription")}
</span> </Button>
</li> </AlertDialogTrigger>
<li> <AlertDialogContent>
<span className="text-muted-foreground"> <AlertDialogHeader>
{t("totalTraffic", "Total Traffic")} <AlertDialogTitle>
</span> {t("prompt", "Prompt")}
<span className="font-bold text-2xl"> </AlertDialogTitle>
<Display <AlertDialogDescription>
type="traffic" {t(
unlimited={!item.traffic} "confirmResetSubscription",
value={item.traffic} "Are you sure you want to reset your subscription?"
/> )}
</span> </AlertDialogDescription>
</li> </AlertDialogHeader>
<li> <AlertDialogFooter>
<span className="text-muted-foreground"> <AlertDialogCancel>
{t("nextResetDays", "Next Reset Days")} {t("cancel", "Cancel")}
</span> </AlertDialogCancel>
<span className="font-semibold text-2xl"> <AlertDialogAction
{item.reset_time onClick={async () => {
? differenceInDays( await resetUserSubscribeToken({
new Date(item.reset_time), user_subscribe_id: item.id,
new Date() });
) await refetch();
: t("noReset", "No Reset")} toast.success(
</span> t("resetSuccess", "Reset Success")
</li> );
<li> }}
<span className="text-muted-foreground">
{t("expirationDays", "Expiration Days")}
</span>
<span className="font-semibold text-2xl">
{}
{item.expire_time
? differenceInDays(
new Date(item.expire_time),
new Date()
) || t("unknown", "Unknown")
: t("noLimit", "No Limit")}
</span>
</li>
</ul>
<Separator className="mt-4" />
<Accordion
className="w-full"
collapsible
defaultValue="0"
type="single"
>
{getUserSubscribe(item.token, protocol)?.map((url, index) => (
<AccordionItem key={url} value={String(index)}>
<AccordionTrigger className="hover:no-underline">
<div className="flex w-full flex-row items-center justify-between">
<CardTitle className="font-medium text-sm">
{t("subscriptionUrl", "Subscription URL")}{" "}
{index + 1}
</CardTitle>
<CopyToClipboard
onCopy={(_, result) => {
if (result) {
toast.success(t("copySuccess", "Copy Success"));
}
}}
text={url}
>
<span
className="mr-4 flex cursor-pointer rounded p-2 text-primary text-sm hover:bg-accent"
onClick={(e) => e.stopPropagation()}
> >
<Icon className="mr-2 size-5" icon="uil:copy" /> {t("confirm", "Confirm")}
{t("copy", "Copy")} </AlertDialogAction>
</span> </AlertDialogFooter>
</CopyToClipboard> </AlertDialogContent>
</div> </AlertDialog>
</AccordionTrigger> <ResetTraffic
<AccordionContent> id={item.id}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> replacement={item.subscribe.replacement}
{applications />
?.filter( {item.expire_time !== 0 && (
(application) => <Renewal id={item.id} subscribe={item.subscribe} />
!!( )}
application.download_link?.[platform] && <Unsubscribe
application.scheme allowDeduction={item.subscribe.allow_deduction}
) id={item.id}
onSuccess={refetch}
/>
</div>
)}
</CardHeader>
<CardContent>
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4">
<li>
<span className="text-muted-foreground">
{t("used", "Used")}
</span>
<span className="font-bold text-2xl">
<Display
type="traffic"
unlimited={!item.traffic}
value={item.upload + item.download}
/>
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("totalTraffic", "Total Traffic")}
</span>
<span className="font-bold text-2xl">
<Display
type="traffic"
unlimited={!item.traffic}
value={item.traffic}
/>
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("nextResetDays", "Next Reset Days")}
</span>
<span className="font-semibold text-2xl">
{item.reset_time
? differenceInDays(
new Date(item.reset_time),
new Date()
) )
.map((application) => { : t("noReset", "No Reset")}
const downloadUrl = </span>
application.download_link?.[platform]; </li>
<li>
<span className="text-muted-foreground">
{t("expirationDays", "Expiration Days")}
</span>
<span className="font-semibold text-2xl">
{}
{item.expire_time
? differenceInDays(
new Date(item.expire_time),
new Date()
) || t("unknown", "Unknown")
: t("noLimit", "No Limit")}
</span>
</li>
</ul>
<Separator className="mt-4" />
<Accordion
className="w-full"
collapsible
defaultValue="0"
type="single"
>
{getUserSubscribe(item.short, item.token, protocol)?.map(
(url, index) => (
<AccordionItem key={url} value={String(index)}>
<AccordionTrigger className="hover:no-underline">
<div className="flex w-full flex-row items-center justify-between">
<CardTitle className="font-medium text-sm">
{t("subscriptionUrl", "Subscription URL")}{" "}
{index + 1}
</CardTitle>
const handleCopy = ( <CopyToClipboard
_: string, onCopy={(_, result) => {
result: boolean if (result) {
) => {
if (result) {
const href = getAppSubLink(
url,
application.scheme
);
const showSuccessMessage = () => {
toast.success( toast.success(
<> t("copySuccess", "Copy Success")
<p>
{t("copySuccess", "Copy Success")}
</p>
<br />
<p>
{t(
"manualImportMessage",
"Please import manually"
)}
</p>
</>
); );
}
}}
text={url}
>
<span
className="mr-4 flex cursor-pointer rounded p-2 text-primary text-sm hover:bg-accent"
onClick={(e) => e.stopPropagation()}
>
<Icon
className="mr-2 size-5"
icon="uil:copy"
/>
{t("copy", "Copy")}
</span>
</CopyToClipboard>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{applications
?.filter(
(application) =>
!!(
application.download_link?.[platform] &&
application.scheme
)
)
.map((application) => {
const downloadUrl =
application.download_link?.[platform];
const handleCopy = (
_: string,
result: boolean
) => {
if (result) {
const href = getAppSubLink(
url,
application.scheme
);
const showSuccessMessage = () => {
toast.success(
<>
<p>
{t("copySuccess", "Copy Success")}
</p>
<br />
<p>
{t(
"manualImportMessage",
"Please import manually"
)}
</p>
</>
);
};
if (isBrowser() && href) {
window.location.href = href;
const checkRedirect = setTimeout(() => {
if (window.location.href !== href) {
showSuccessMessage();
}
clearTimeout(checkRedirect);
}, 1000);
return;
}
showSuccessMessage();
}
}; };
if (isBrowser() && href) { return (
window.location.href = href; <div
const checkRedirect = setTimeout(() => { className="flex size-full flex-col items-center justify-between gap-2 text-muted-foreground text-xs"
if (window.location.href !== href) { key={application.name}
showSuccessMessage(); >
} <span>{application.name}</span>
clearTimeout(checkRedirect);
}, 1000);
return;
}
showSuccessMessage(); {application.icon && (
} <img
}; alt={application.name}
className="p-1"
return ( height={64}
<div src={application.icon}
className="flex size-full flex-col items-center justify-between gap-2 text-muted-foreground text-xs" width={64}
key={application.name} />
> )}
<span>{application.name}</span> <div className="flex">
{downloadUrl && (
{application.icon && ( <Button
<img asChild
alt={application.name} className={
className="p-1" application.scheme
height={64} ? "rounded-r-none px-1.5"
src={application.icon} : "px-1.5"
width={64} }
/> size="sm"
)} variant="secondary"
<div className="flex"> >
{downloadUrl && ( <a
<Button href={downloadUrl}
asChild rel="noopener noreferrer"
className={ target="_blank"
application.scheme >
? "rounded-r-none px-1.5" {t("download", "Download")}
: "px-1.5" </a>
} </Button>
size="sm"
variant="secondary"
>
<a
href={downloadUrl}
rel="noopener noreferrer"
target="_blank"
>
{t("download", "Download")}
</a>
</Button>
)}
{application.scheme && (
<CopyToClipboard
onCopy={handleCopy}
text={getAppSubLink(
url,
application.scheme
)} )}
>
<Button {application.scheme && (
className={ <CopyToClipboard
downloadUrl onCopy={handleCopy}
? "rounded-l-none p-2" text={getAppSubLink(
: "p-2" url,
} application.scheme
size="sm" )}
> >
{t("import", "Import")} <Button
</Button> className={
</CopyToClipboard> downloadUrl
)} ? "rounded-l-none p-2"
</div> : "p-2"
</div> }
); size="sm"
})} >
<div className="hidden size-full flex-col items-center justify-between gap-2 text-muted-foreground text-sm lg:flex"> {t("import", "Import")}
<span>{t("qrCode", "QR Code")}</span> </Button>
<QRCodeCanvas </CopyToClipboard>
bgColor="transparent" )}
fgColor="rgb(59, 130, 246)" </div>
size={80} </div>
value={url} );
/> })}
<span className="text-center"> <div className="hidden size-full flex-col items-center justify-between gap-2 text-muted-foreground text-sm lg:flex">
{t("scanToSubscribe", "Scan to Subscribe")} <span>{t("qrCode", "QR Code")}</span>
</span> <QRCodeCanvas
</div> bgColor="transparent"
</div> fgColor="rgb(59, 130, 246)"
</AccordionContent> size={80}
</AccordionItem> value={url}
))} />
</Accordion> <span className="text-center">
</CardContent> {t("scanToSubscribe", "Scan to Subscribe")}
</Card> </span>
))} </div>
</div>
</AccordionContent>
</AccordionItem>
)
)}
</Accordion>
</CardContent>
</Card>
);
})}
</> </>
) : ( ) : (
<> <>

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

@ -147,8 +147,8 @@ function MobileBindDialog({
/> />
<SendCode <SendCode
params={{ params={{
telephone_area_code: form.getValues().area_code, telephone_area_code: form.watch("area_code"),
telephone: form.getValues().mobile, telephone: form.watch("mobile"),
type: 1, type: 1,
}} }}
type="phone" type="phone"

View File

@ -8,7 +8,7 @@ export interface GlobalStore {
setCommon: (common: Partial<API.GetGlobalConfigResponse>) => void; setCommon: (common: Partial<API.GetGlobalConfigResponse>) => void;
setUser: (user?: API.User) => void; setUser: (user?: API.User) => void;
getUserInfo: () => Promise<void>; getUserInfo: () => Promise<void>;
getUserSubscribe: (uuid: string, type?: string) => string[]; getUserSubscribe: (short: string, token: string, type?: string) => string[];
getAppSubLink: (url: string, schema?: string) => string; getAppSubLink: (url: string, schema?: string) => string;
} }
@ -120,7 +120,7 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
console.error("Failed to refresh user:", error); console.error("Failed to refresh user:", error);
} }
}, },
getUserSubscribe: (uuid: string, type?: string) => { getUserSubscribe: (short: string, token: string, type?: string) => {
const { pan_domain, subscribe_domain, subscribe_path } = const { pan_domain, subscribe_domain, subscribe_path } =
get().common.subscribe || {}; get().common.subscribe || {};
const domains = subscribe_domain const domains = subscribe_domain
@ -129,12 +129,13 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
return domains.map((domain) => { return domains.map((domain) => {
if (pan_domain) { if (pan_domain) {
if (type) return `https://${uuid}.${type}.${domain}`; if (type)
return `https://${uuid}.${domain}`; return `https://${short}.${type}.${domain}${subscribe_path}?token=${token}&type=${type}`;
return `https://${short}.${domain}${subscribe_path}?token=${token}`;
} }
if (type) if (type)
return `https://${domain}${subscribe_path}?token=${uuid}&type=${type}`; return `https://${domain}${subscribe_path}?token=${token}&type=${type}`;
return `https://${domain}${subscribe_path}?token=${uuid}`; return `https://${domain}${subscribe_path}?token=${token}`;
}); });
}, },
getAppSubLink: (url: string, schema?: string) => { getAppSubLink: (url: string, schema?: string) => {

View File

@ -4,7 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
import { devtools } from "@tanstack/devtools-vite"; import { devtools } from "@tanstack/devtools-vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite";
import viteReact from "@vitejs/plugin-react"; import viteReact from "@vitejs/plugin-react";
import { defineConfig, type Plugin } from "vite"; import { defineConfig, loadEnv, type Plugin } from "vite";
// Plugin to generate version.lock file after build // Plugin to generate version.lock file after build
function versionLockPlugin(): Plugin { function versionLockPlugin(): Plugin {
@ -24,33 +24,37 @@ function versionLockPlugin(): Plugin {
} }
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
base: "./", const env = loadEnv(mode, process.cwd(), "");
plugins: [
devtools({ eventBusConfig: { port: 42_069 } }), return {
tanstackRouter({ base: "./",
target: "react", plugins: [
autoCodeSplitting: true, devtools({ eventBusConfig: { port: 42_069 } }),
}), tanstackRouter({
viteReact(), target: "react",
tailwindcss(), autoCodeSplitting: true,
versionLockPlugin(), }),
], viteReact(),
resolve: { tailwindcss(),
alias: { versionLockPlugin(),
"@": fileURLToPath(new URL("./src", import.meta.url)), ],
}, resolve: {
}, alias: {
server: { "@": fileURLToPath(new URL("./src", import.meta.url)),
proxy: {
"/api": {
target: "https://api.ppanel.dev",
changeOrigin: true,
secure: false,
}, },
}, },
}, server: {
build: { proxy: {
assetsDir: "static", "/api": {
}, target: env.VITE_API_BASE_URL || "https://api.ppanel.dev",
changeOrigin: true,
secure: false,
},
},
},
build: {
assetsDir: "static",
},
};
}); });

View File

@ -211,8 +211,19 @@ After successful installation, you can access:
- **User Panel**: `http://your-server-ip:8080` - **User Panel**: `http://your-server-ip:8080`
- **Admin Panel**: `http://your-server-ip:8080/admin/` - **Admin Panel**: `http://your-server-ip:8080/admin/`
::: warning Default Credentials ::: warning Administrator Account
Please change the default admin password immediately after first login for security. **Default Administrator Account** (if not configured in config file):
- **Email**: `admin@ppanel.dev`
- **Password**: `password`
**One-Click Installation Script** will automatically generate random administrator credentials displayed at the end:
- **Email**: `admin-[8 random characters]@ppanel.dev`
- **Password**: `[Randomly generated 16-character password]`
**Security Recommendations**:
- When using the one-click script, save the generated credentials securely
- Change your password immediately after first login
- If using default credentials, **must** change both email and password after first login
::: :::
### Configure Reverse Proxy (Optional) ### Configure Reverse Proxy (Optional)

View File

@ -309,6 +309,14 @@ ps aux | grep ppanel
- **User Panel**: `http://your-server-ip:8080` - **User Panel**: `http://your-server-ip:8080`
- **Admin Panel**: `http://your-server-ip:8080/admin/` - **Admin Panel**: `http://your-server-ip:8080/admin/`
::: warning Default Credentials
**Default Administrator Account**:
- **Email**: `admin@ppanel.dev`
- **Password**: `password`
**Security**: Change the default credentials immediately after first login.
:::
### Configure Firewall ### Configure Firewall
```bash ```bash

View File

@ -223,7 +223,11 @@ After successful installation, you can access:
- **Admin Panel**: `http://your-server-ip:8080/admin/` - **Admin Panel**: `http://your-server-ip:8080/admin/`
::: warning Default Credentials ::: warning Default Credentials
Please change the default admin password immediately after first login for security. **Default Administrator Account** (if not configured):
- **Email**: `admin@ppanel.dev`
- **Password**: `password`
**Security**: Change the default credentials immediately after first login.
::: :::
### Configure Reverse Proxy (Recommended) ### Configure Reverse Proxy (Recommended)

View File

@ -232,6 +232,14 @@ docker volume rm ppanel-data
``` ```
::: :::
::: warning Default Credentials
**Default Administrator Account**:
- **Email**: `admin@ppanel.dev`
- **Password**: `password`
**Security**: Change the default credentials immediately after first login.
:::
## Upgrading ## Upgrading
Upgrade PPanel directly from the **Admin Dashboard**. On the dashboard homepage, you can check for new versions and upgrade with one click. Upgrade PPanel directly from the **Admin Dashboard**. On the dashboard homepage, you can check for new versions and upgrade with one click.

View File

@ -113,6 +113,12 @@ get_user_input() {
log_info "✓ Redis password generated" log_info "✓ Redis password generated"
# Administrator configuration
ADMIN_EMAIL="admin-$(generate_secret | cut -c1-8)@ppanel.dev"
ADMIN_PASSWORD=$(generate_secret | cut -c1-16)
log_info "✓ Administrator password generated"
# Generate JWT secret # Generate JWT secret
JWT_SECRET=$(generate_secret) JWT_SECRET=$(generate_secret)
log_info "✓ JWT secret generated" log_info "✓ JWT secret generated"
@ -145,6 +151,10 @@ JwtAuth:
AccessSecret: $JWT_SECRET AccessSecret: $JWT_SECRET
AccessExpire: 604800 AccessExpire: 604800
Administrator:
Email: $ADMIN_EMAIL
Password: "$ADMIN_PASSWORD"
Logger: Logger:
ServiceName: ApiService ServiceName: ApiService
Mode: console Mode: console
@ -343,8 +353,10 @@ show_access_info() {
else else
log_warn "Unable to get public IP, please configure manually for public access" log_warn "Unable to get public IP, please configure manually for public access"
fi fi
echo "" echo "" log_info "Administrator Account:"
log_info "Database Information:" echo " Email: $ADMIN_EMAIL"
echo " Password: $ADMIN_PASSWORD"
echo "" log_info "Database Information:"
echo " MySQL (Container Network):" echo " MySQL (Container Network):"
echo " Address: mysql:3306" echo " Address: mysql:3306"
echo " User: $MYSQL_USER" echo " User: $MYSQL_USER"

View File

@ -113,6 +113,12 @@ get_user_input() {
log_info "✓ 已生成 Redis 密码" log_info "✓ 已生成 Redis 密码"
# 管理员配置
ADMIN_EMAIL="admin-$(generate_secret | cut -c1-8)@ppanel.dev"
ADMIN_PASSWORD=$(generate_secret | cut -c1-16)
log_info "✓ 已生成管理员密码"
# 生成 JWT 密钥 # 生成 JWT 密钥
JWT_SECRET=$(generate_secret) JWT_SECRET=$(generate_secret)
log_info "✓ 已生成 JWT 密钥" log_info "✓ 已生成 JWT 密钥"
@ -145,6 +151,10 @@ JwtAuth:
AccessSecret: $JWT_SECRET AccessSecret: $JWT_SECRET
AccessExpire: 604800 AccessExpire: 604800
Administrator:
Email: $ADMIN_EMAIL
Password: "$ADMIN_PASSWORD"
Logger: Logger:
ServiceName: ApiService ServiceName: ApiService
Mode: console Mode: console
@ -346,6 +356,10 @@ show_access_info() {
log_warn "未能获取公网IP如需外网访问请手动配置" log_warn "未能获取公网IP如需外网访问请手动配置"
fi fi
echo "" echo ""
log_info "管理员账户:"
echo " 邮箱: $ADMIN_EMAIL"
echo " 密码: $ADMIN_PASSWORD"
echo ""
log_info "数据库信息:" log_info "数据库信息:"
echo " MySQL (容器间通信):" echo " MySQL (容器间通信):"
echo " 地址: mysql:3306" echo " 地址: mysql:3306"

View File

@ -211,8 +211,19 @@ docker compose ps
- **用户面板**: `http://your-server-ip:8080` - **用户面板**: `http://your-server-ip:8080`
- **管理后台**: `http://your-server-ip:8080/admin` - **管理后台**: `http://your-server-ip:8080/admin`
::: warning 默认凭据 ::: warning 管理员账户
为了安全起见,首次登录后请立即修改默认管理员密码。 **默认管理员账户**(如果配置文件未设置):
- **邮箱**: `admin@ppanel.dev`
- **密码**: `password`
**一键安装脚本**会自动生成随机的管理员账户并在安装结束时显示:
- **邮箱**: `admin-[8位随机字符]@ppanel.dev`
- **密码**: `[随机生成的16位密码]`
**安全建议**
- 使用一键安装脚本时,请妥善保管脚本生成的凭据
- 首次登录后立即修改密码
- 如果使用默认账户,**必须**在首次登录后修改邮箱和密码
::: :::
### 配置反向代理(可选) ### 配置反向代理(可选)

View File

@ -309,6 +309,14 @@ ps aux | grep ppanel
- **用户面板**: `http://your-server-ip:8080` - **用户面板**: `http://your-server-ip:8080`
- **管理后台**: `http://your-server-ip:8080/admin/` - **管理后台**: `http://your-server-ip:8080/admin/`
::: warning 默认凭据
**默认管理员账号**:
- **邮箱**: `admin@ppanel.dev`
- **密码**: `password`
**安全提醒**: 首次登录后请立即修改默认凭据。
:::
### 配置防火墙 ### 配置防火墙
```bash ```bash

View File

@ -219,7 +219,11 @@ docker compose logs -f ppanel
- **管理后台**: `http://your-server-ip:8080/admin/` - **管理后台**: `http://your-server-ip:8080/admin/`
::: warning 默认凭据 ::: warning 默认凭据
为了安全起见,首次登录后请立即修改默认管理员密码。 **默认管理员账号**(如果未配置时):
- **邮箱**: `admin@ppanel.dev`
- **密码**: `password`
**安全提醒**: 首次登录后请立即修改默认凭据。
::: :::
### 配置反向代理(推荐) ### 配置反向代理(推荐)

View File

@ -232,6 +232,14 @@ docker volume rm ppanel-data
``` ```
::: :::
::: warning 默认凭据
**默认管理员账号**:
- **邮箱**: `admin@ppanel.dev`
- **密码**: `password`
**安全提醒**: 首次登录后请立即修改默认凭据。
:::
## 升级 ## 升级
直接从**管理后台**主页升级 PPanel。在仪表盘主页可以检查新版本并一键升级。 直接从**管理后台**主页升级 PPanel。在仪表盘主页可以检查新版本并一键升级。

View File

@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.2.4-dev.1", "version": "1.3.6",
"private": true, "private": true,
"homepage": "https://github.com/perfect-panel/frontend", "homepage": "https://github.com/perfect-panel/frontend",
"bugs": { "bugs": {

View File

@ -9,7 +9,7 @@ import {
} from "@workspace/ui/components/popover"; } from "@workspace/ui/components/popover";
import { cn } from "@workspace/ui/lib/utils"; import { cn } from "@workspace/ui/lib/utils";
import { intlFormat } from "date-fns"; import { intlFormat } from "date-fns";
import { CalendarIcon } from "lucide-react"; import { CalendarIcon, X } from "lucide-react";
import * as React from "react"; import * as React from "react";
import type { DayPicker } from "react-day-picker"; import type { DayPicker } from "react-day-picker";
@ -34,6 +34,15 @@ export function DatePicker({
} }
}; };
const handleClear = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setDate(undefined);
if (onChange) {
onChange(0);
}
};
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -45,7 +54,19 @@ export function DatePicker({
variant="outline" variant="outline"
> >
{value ? intlFormat(value) : <span>{placeholder}</span>} {value ? intlFormat(value) : <span>{placeholder}</span>}
<CalendarIcon className="size-4" /> <div className="flex items-center gap-2">
{value && (
<button
className="flex items-center"
onClick={handleClear}
onMouseDown={handleClear}
type="button"
>
<X className="size-4 opacity-50 hover:opacity-100" />
</button>
)}
<CalendarIcon className="size-4" />
</div>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0"> <PopoverContent align="start" className="w-auto p-0">

View File

@ -60,7 +60,10 @@ export function MonacoEditor({
const size = useSize(ref); const size = useSize(ref);
useEffect(() => { useEffect(() => {
setInternalValue(propValue); // Only update internalValue if propValue has actually changed and is different from current value
if (propValue !== internalValue) {
setInternalValue(propValue);
}
}, [propValue]); }, [propValue]);
const debouncedOnChange = useRef( const debouncedOnChange = useRef(

View File

@ -128,13 +128,18 @@ export function TagInput({
variant="outline" variant="outline"
> >
{tag} {tag}
<X <button
className="size-4 cursor-pointer rounded-sm hover:text-destructive" className="ml-1 inline-flex items-center justify-center"
onClick={(e) => { onClick={(e) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleRemoveTag(index); handleRemoveTag(index);
}} }}
/> onMouseDown={(e) => e.preventDefault()}
type="button"
>
<X className="size-4 cursor-pointer rounded-sm hover:text-destructive" />
</button>
</Badge> </Badge>
))} ))}
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">

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."
@ -185,7 +193,7 @@ request.interceptors.request.use(
request.interceptors.response.use( request.interceptors.response.use(
(response) => { (response) => {
const { code } = response.data; const { code } = response.data;
if (code !== 200) { if (code !== 200 && code !== 0) {
handleError({ handleError({
data: response.data, data: response.data,
config: { config: {