Compare commits

..

137 Commits

Author SHA1 Message Date
semantic-release-bot 7023875548 🔖 chore(release): v1.0.0-beta.34 [skip ci]
# [1.0.0-beta.34](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.33...v1.0.0-beta.34) (2025-04-02)

###  Features

* **admin**: Add application and rule management entries to localization files ([8b43e69](https://github.com/perfect-panel/ppanel-web/commit/8b43e69))
* **api**: Add an interface to obtain user subscription details, update related type definitions and localized text ([cf5c39c](https://github.com/perfect-panel/ppanel-web/commit/cf5c39c))
* **user**: Integrate subscription list into user management, update request parameters and types ([8d49dac](https://github.com/perfect-panel/ppanel-web/commit/8d49dac))

### 🐛 Bug Fixes

* **admin**: Hidden versions and system upgrades ([64cd842](https://github.com/perfect-panel/ppanel-web/commit/64cd842))
* **admin**: Modify the label type in the rule form to a string array ([a7aa5fe](https://github.com/perfect-panel/ppanel-web/commit/a7aa5fe))
* **node**: Handle potential null value for online users count ([fa2fb28](https://github.com/perfect-panel/ppanel-web/commit/fa2fb28))
* **subscribe**: Add value prop to field in subscription form for proper state management ([328838d](https://github.com/perfect-panel/ppanel-web/commit/328838d))
* **subscribe**: Refactor discount calculations and default selection logic in subscription forms ([423b240](https://github.com/perfect-panel/ppanel-web/commit/423b240))
* **subscribe**: Update default selection logic in subscription form to ensure proper state management ([ef15374](https://github.com/perfect-panel/ppanel-web/commit/ef15374))
2025-04-02 08:24:17 +00:00
web@ppanel a7aa5fee3e 🐛 fix(admin): Modify the label type in the rule form to a string array 2025-04-02 11:52:38 +07:00
web@ppanel 64cd842926 🐛 fix(admin): Hidden versions and system upgrades 2025-04-02 00:15:15 +07:00
web@ppanel 8b43e69bfe feat(admin): Add application and rule management entries to localization files 2025-04-02 00:12:02 +07:00
web@ppanel ef153747bd 🐛 fix(subscribe): Update default selection logic in subscription form to ensure proper state management 2025-04-01 12:37:17 +07:00
web@ppanel 328838d754 🐛 fix(subscribe): Add value prop to field in subscription form for proper state management 2025-03-30 20:27:03 +07:00
web@ppanel 423b24077f 🐛 fix(subscribe): Refactor discount calculations and default selection logic in subscription forms 2025-03-30 00:53:47 +07:00
web@ppanel fa2fb2864f 🐛 fix(node): Handle potential null value for online users count 2025-03-29 23:43:21 +07:00
web@ppanel 8d49daca21 feat(user): Integrate subscription list into user management, update request parameters and types 2025-03-26 15:01:43 +07:00
web@ppanel cf5c39cfe5 feat(api): Add an interface to obtain user subscription details, update related type definitions and localized text 2025-03-25 23:55:31 +07:00
semantic-release-bot 383638f702 🔖 chore(release): v1.0.0-beta.33 [skip ci]
# [1.0.0-beta.33](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.32...v1.0.0-beta.33) (2025-03-18)

### 🐛 Bug Fixes

* **subscribe**: Handle optional values in price and discount calculations ([5939763](https://github.com/perfect-panel/ppanel-web/commit/5939763))
2025-03-18 07:53:53 +00:00
web@ppanel 5939763b57 🐛 fix(subscribe): Handle optional values in price and discount calculations 2025-03-18 14:50:48 +07:00
semantic-release-bot fa56eb8293 🔖 chore(release): v1.0.0-beta.32 [skip ci]
# [1.0.0-beta.32](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.31...v1.0.0-beta.32) (2025-03-17)

### 🐛 Bug Fixes

* **forms**: Add step attribute to number inputs for better value control ([b8f4f1e](https://github.com/perfect-panel/ppanel-web/commit/b8f4f1e))
* **locales**: Update invite code text to indicate it's optional ([6a34bfb](https://github.com/perfect-panel/ppanel-web/commit/6a34bfb))
2025-03-17 15:53:15 +00:00
web@ppanel 6a34bfb78d 🐛 fix(locales): Update invite code text to indicate it's optional 2025-03-17 22:50:21 +07:00
web@ppanel b8f4f1e694 🐛 fix(forms): Add step attribute to number inputs for better value control 2025-03-17 22:47:42 +07:00
semantic-release-bot aa1d42651d 🔖 chore(release): v1.0.0-beta.31 [skip ci]
# [1.0.0-beta.31](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.30...v1.0.0-beta.31) (2025-03-15)

### 🐛 Bug Fixes

* **site**: Se ref to store site configuration for updates ([0c8f091](https://github.com/perfect-panel/ppanel-web/commit/0c8f091))
2025-03-15 15:51:45 +00:00
web@ppanel 0c8f0911c7 🐛 fix(site): Se ref to store site configuration for updates 2025-03-15 22:47:19 +07:00
semantic-release-bot db0d9e003e 🔖 chore(release): v1.0.0-beta.30 [skip ci]
# [1.0.0-beta.30](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.29...v1.0.0-beta.30) (2025-03-15)

###  Features

* **api**: Add CheckoutOrder request and response types, and update user purchase request parameters ([48a1b97](https://github.com/perfect-panel/ppanel-web/commit/48a1b97))
* **email**: Add traffic exhaustion template ([bb3bd7b](https://github.com/perfect-panel/ppanel-web/commit/bb3bd7b))
* **formatting**: Update differenceInDays function to return whole days or two decimal places ([bf58f25](https://github.com/perfect-panel/ppanel-web/commit/bf58f25))
* **global**: Add custom data ([6dbebd1](https://github.com/perfect-panel/ppanel-web/commit/6dbebd1))
* **input**: Add minimum value constraint and enhance number handling in EnhancedInput ([ce31972](https://github.com/perfect-panel/ppanel-web/commit/ce31972))
* **loading**: Replace loading animation with a simpler spinner and loading text ([f72df3a](https://github.com/perfect-panel/ppanel-web/commit/f72df3a))
* **node-form**: Update number input fields to enforce step, min, and max values ([3f7b6d1](https://github.com/perfect-panel/ppanel-web/commit/3f7b6d1))
* **payment**: Add isEdit prop to PaymentForm and disable fields when editing ([85f55de](https://github.com/perfect-panel/ppanel-web/commit/85f55de))
* **timeline**: Simplify timeline component layout and remove commented-out code ([fbad3b0](https://github.com/perfect-panel/ppanel-web/commit/fbad3b0))

### 🎫 Chores

* **release**: V1.0.0-beta.27 [skip ci] ([092477b](https://github.com/perfect-panel/ppanel-web/commit/092477b))
* **release**: V1.0.0-beta.28 [skip ci] ([786ba0e](https://github.com/perfect-panel/ppanel-web/commit/786ba0e))
* Merge branch 'beta' into develop ([f219c52](https://github.com/perfect-panel/ppanel-web/commit/f219c52))

### 🐛 Bug Fixes

* **dashboard**:  Update date display to use start_time if available ([e551232](https://github.com/perfect-panel/ppanel-web/commit/e551232))
2025-03-15 09:02:24 +00:00
web@ppanel f219c52306 🔧 chore: Merge branch 'beta' into develop 2025-03-15 15:58:37 +07:00
web@ppanel 3f7b6d16ed feat(node-form): Update number input fields to enforce step, min, and max values 2025-03-15 15:48:59 +07:00
web@ppanel 6dbebd152d feat(global): Add custom data 2025-03-15 15:39:55 +07:00
web@ppanel bb3bd7b50c feat(email): Add traffic exhaustion template 2025-03-14 23:28:36 +07:00
web@ppanel fbad3b0e51 feat(timeline): Simplify timeline component layout and remove commented-out code 2025-03-14 23:21:17 +07:00
web@ppanel e551232564 🐛 fix(dashboard): Update date display to use start_time if available 2025-03-14 22:10:24 +07:00
web@ppanel bf58f25072 feat(formatting): Update differenceInDays function to return whole days or two decimal places 2025-03-14 20:51:17 +07:00
web@ppanel 85f55def2e feat(payment): Add isEdit prop to PaymentForm and disable fields when editing 2025-03-14 14:04:10 +07:00
semantic-release-bot 29bc3c7722 🔖 chore(release): v1.0.0-beta.29 [skip ci]
# [1.0.0-beta.29](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.28...v1.0.0-beta.29) (2025-03-14)

###  Features

* **api**: Add CheckoutOrder request and response types, and update user purchase request parameters ([dddc21c](https://github.com/perfect-panel/ppanel-web/commit/dddc21c))
* **loading**: Replace loading animation with a simpler spinner and loading text ([b8316bb](https://github.com/perfect-panel/ppanel-web/commit/b8316bb))
2025-03-14 06:43:47 +00:00
web@ppanel f72df3a5e8 feat(loading): Replace loading animation with a simpler spinner and loading text 2025-03-14 13:42:58 +07:00
web@ppanel 48a1b97051 feat(api): Add CheckoutOrder request and response types, and update user purchase request parameters 2025-03-14 13:42:58 +07:00
semantic-release-bot 786ba0e41c 🔖 chore(release): v1.0.0-beta.28 [skip ci]
# [1.0.0-beta.28](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.27...v1.0.0-beta.28) (2025-03-13)

###  Features

* **input**: Add minimum value constraint and enhance number handling in EnhancedInput ([94822d9](https://github.com/perfect-panel/ppanel-web/commit/94822d9))
2025-03-14 13:42:58 +07:00
web@ppanel ce3197298d feat(input): Add minimum value constraint and enhance number handling in EnhancedInput 2025-03-14 13:42:58 +07:00
semantic-release-bot 092477b2d3 🔖 chore(release): v1.0.0-beta.27 [skip ci]
* **payment**: Reconstruct the payment page ([7109472](https://github.com/perfect-panel/ppanel-web/commit/7109472))
* Enhance user navigation dropdown ui and styling ([d2732e6](https://github.com/perfect-panel/ppanel-web/commit/d2732e6))

* **cdn**: Add CDN URL configuration and update related references ([0c90733](https://github.com/perfect-panel/ppanel-web/commit/0c90733))
* **payment**: Add bank card payment ([7fa3a57](https://github.com/perfect-panel/ppanel-web/commit/7fa3a57))
* **subscription**:  Improve layout and organization of subscription detail tabs ([e4630f8](https://github.com/perfect-panel/ppanel-web/commit/e4630f8))
* **subscription**: Refactor subscription handling and update imports for better organization ([2215c7f](https://github.com/perfect-panel/ppanel-web/commit/2215c7f))

* **merge**: Bump version to 1.0.0-beta.26 and update changelog ([3222016](https://github.com/perfect-panel/ppanel-web/commit/3222016))

* **affiliate**: Update user identifier ([35f92c9](https://github.com/perfect-panel/ppanel-web/commit/35f92c9))
* **changelog**: Update change log style ([cfa3fc0](https://github.com/perfect-panel/ppanel-web/commit/cfa3fc0))
* **payment**: Add notification URL field to payment management interface ([5c710e1](https://github.com/perfect-panel/ppanel-web/commit/5c710e1))
* **payment**: Fix payment related type definitions and update payment method references ([c3138a8](https://github.com/perfect-panel/ppanel-web/commit/c3138a8))
* **payment**: Refactor purchaseCheckout usage and remove redundant code ([a5e2079](https://github.com/perfect-panel/ppanel-web/commit/a5e2079))
* **payment**: Update checkout type from 'link' to 'url' for consistency ([136a1ab](https://github.com/perfect-panel/ppanel-web/commit/136a1ab))
* **payment**: Update payment information ([70d6a38](https://github.com/perfect-panel/ppanel-web/commit/70d6a38))
* **payment**: Update payment method update logic to include row data ([6752420](https://github.com/perfect-panel/ppanel-web/commit/6752420))
* **purchasing**: Update payment type to lowercase and add optional chaining for discounts ([c06ea49](https://github.com/perfect-panel/ppanel-web/commit/c06ea49))
* **ui**: Improve dashboard layout and enhance button functionality; open checkout URLs in a new tab ([fc0da76](https://github.com/perfect-panel/ppanel-web/commit/fc0da76))
* **ui**: Multiple display bugs ([f5d8fd3](https://github.com/perfect-panel/ppanel-web/commit/f5d8fd3))
2025-03-14 13:42:58 +07:00
web@ppanel b8316bba33 feat(loading): Replace loading animation with a simpler spinner and loading text 2025-03-14 13:40:56 +07:00
web@ppanel dddc21c686 feat(api): Add CheckoutOrder request and response types, and update user purchase request parameters 2025-03-14 13:34:36 +07:00
semantic-release-bot d10ecc9fdf 🔖 chore(release): v1.0.0-beta.28 [skip ci]
# [1.0.0-beta.28](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.27...v1.0.0-beta.28) (2025-03-13)

###  Features

* **input**: Add minimum value constraint and enhance number handling in EnhancedInput ([94822d9](https://github.com/perfect-panel/ppanel-web/commit/94822d9))
2025-03-13 10:35:19 +00:00
web@ppanel 94822d902f feat(input): Add minimum value constraint and enhance number handling in EnhancedInput 2025-03-13 17:32:36 +07:00
semantic-release-bot 85fdc36c14 🔖 chore(release): v1.0.0-beta.27 [skip ci]
# [1.0.0-beta.27](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.26...v1.0.0-beta.27) (2025-03-13)

### ♻ Code Refactoring

* **payment**: Reconstruct the payment page ([7109472](https://github.com/perfect-panel/ppanel-web/commit/7109472))
* Enhance user navigation dropdown ui and styling ([d2732e6](https://github.com/perfect-panel/ppanel-web/commit/d2732e6))

###  Features

* **cdn**: Add CDN URL configuration and update related references ([0c90733](https://github.com/perfect-panel/ppanel-web/commit/0c90733))
* **payment**: Add bank card payment ([7fa3a57](https://github.com/perfect-panel/ppanel-web/commit/7fa3a57))
* **subscription**:  Improve layout and organization of subscription detail tabs ([e4630f8](https://github.com/perfect-panel/ppanel-web/commit/e4630f8))
* **subscription**: Refactor subscription handling and update imports for better organization ([2215c7f](https://github.com/perfect-panel/ppanel-web/commit/2215c7f))

### 🎫 Chores

* **merge**: Bump version to 1.0.0-beta.26 and update changelog ([3222016](https://github.com/perfect-panel/ppanel-web/commit/3222016))

### 🐛 Bug Fixes

* **affiliate**: Update user identifier ([35f92c9](https://github.com/perfect-panel/ppanel-web/commit/35f92c9))
* **changelog**: Update change log style ([cfa3fc0](https://github.com/perfect-panel/ppanel-web/commit/cfa3fc0))
* **payment**: Add notification URL field to payment management interface ([5c710e1](https://github.com/perfect-panel/ppanel-web/commit/5c710e1))
* **payment**: Fix payment related type definitions and update payment method references ([c3138a8](https://github.com/perfect-panel/ppanel-web/commit/c3138a8))
* **payment**: Refactor purchaseCheckout usage and remove redundant code ([a5e2079](https://github.com/perfect-panel/ppanel-web/commit/a5e2079))
* **payment**: Update checkout type from 'link' to 'url' for consistency ([136a1ab](https://github.com/perfect-panel/ppanel-web/commit/136a1ab))
* **payment**: Update payment information ([70d6a38](https://github.com/perfect-panel/ppanel-web/commit/70d6a38))
* **payment**: Update payment method update logic to include row data ([6752420](https://github.com/perfect-panel/ppanel-web/commit/6752420))
* **purchasing**: Update payment type to lowercase and add optional chaining for discounts ([c06ea49](https://github.com/perfect-panel/ppanel-web/commit/c06ea49))
* **ui**: Improve dashboard layout and enhance button functionality; open checkout URLs in a new tab ([fc0da76](https://github.com/perfect-panel/ppanel-web/commit/fc0da76))
* **ui**: Multiple display bugs ([f5d8fd3](https://github.com/perfect-panel/ppanel-web/commit/f5d8fd3))
2025-03-13 04:27:12 +00:00
web@ppanel cfa3fc024d 🐛 fix(changelog): Update change log style 2025-03-13 11:23:14 +07:00
web@ppanel c06ea49d6f 🐛 fix(purchasing): Update payment type to lowercase and add optional chaining for discounts 2025-03-13 00:06:42 +07:00
web@ppanel c3138a863d 🐛 fix(payment): Fix payment related type definitions and update payment method references 2025-03-12 21:17:04 +07:00
web@ppanel 7fa3a57df4 feat(payment): Add bank card payment 2025-03-12 17:15:32 +07:00
web@ppanel 70d6a38a29 🐛 fix(payment): Update payment information 2025-03-12 13:43:06 +07:00
web@ppanel 5c710e1add 🐛 fix(payment): Add notification URL field to payment management interface 2025-03-11 21:22:18 +07:00
web@ppanel 6752420ba5 🐛 fix(payment): Update payment method update logic to include row data 2025-03-11 21:15:07 +07:00
web@ppanel 710947209a ♻️ refactor(payment): Reconstruct the payment page 2025-03-11 20:06:12 +07:00
web@ppanel fc0da761b5 🐛 fix(ui): Improve dashboard layout and enhance button functionality; open checkout URLs in a new tab 2025-03-10 18:32:07 +07:00
web@ppanel 136a1abd8b 🐛 fix(payment): Update checkout type from 'link' to 'url' for consistency 2025-03-08 20:10:45 +07:00
web@ppanel a5e2079ddc 🐛 fix(payment): Refactor purchaseCheckout usage and remove redundant code 2025-03-08 20:04:33 +07:00
web@ppanel 35f92c9329 🐛 fix(affiliate): Update user identifier 2025-03-08 15:43:20 +07:00
web@ppanel f5d8fd3b65 🐛 fix(ui): Multiple display bugs 2025-03-08 14:46:20 +07:00
web@ppanel 0c907337e1 feat(cdn): Add CDN URL configuration and update related references 2025-03-08 12:54:58 +07:00
web@ppanel e4630f8ca9 feat(subscription): Improve layout and organization of subscription detail tabs 2025-03-08 12:43:57 +07:00
web@ppanel 2215c7f2b9 feat(subscription): Refactor subscription handling and update imports for better organization 2025-03-08 12:36:25 +07:00
semantic-release-bot 3222016799 🔧 chore(merge): Bump version to 1.0.0-beta.26 and update changelog 2025-03-08 12:36:02 +07:00
web@ppanel d0a04679ce Merge pull request #12 from turbolnk-com/develop
♻️ refactor: Enhance user navigation dropdown ui and styling
2025-03-07 11:46:01 +07:00
turbolnk d2732e650b ♻️ refactor: Enhance user navigation dropdown ui and styling 2025-03-06 09:34:39 +08:00
semantic-release-bot 79edea7973 🔖 chore(release): v1.0.0-beta.26 [skip ci]
# [1.0.0-beta.26](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.25...v1.0.0-beta.26) (2025-03-02)

### 🐛 Bug Fixes

* **icon**: Comment out unused icon collection imports ([f17bf8d](https://github.com/perfect-panel/ppanel-web/commit/f17bf8d))
2025-03-02 16:40:10 +00:00
web@ppanel f17bf8db6e 🐛 fix(icon): Comment out unused icon collection imports 2025-03-02 23:37:15 +07:00
semantic-release-bot 047a69886e 🔖 chore(release): v1.0.0-beta.25 [skip ci]
# [1.0.0-beta.25](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.24...v1.0.0-beta.25) (2025-03-01)

###  Features

* **auth**: Add privacy policy link to the footer ([8e16ef1](https://github.com/perfect-panel/ppanel-web/commit/8e16ef1))

### 🐛 Bug Fixes

* **dashboard**: Display subscription creation date in user dashboard ([d0e6df0](https://github.com/perfect-panel/ppanel-web/commit/d0e6df0))
* **request**: Add error code 40005 to trigger logout ([71bf002](https://github.com/perfect-panel/ppanel-web/commit/71bf002))
* **subscribe**: Update payment return URL ([2b80496](https://github.com/perfect-panel/ppanel-web/commit/2b80496))
2025-03-01 12:59:06 +00:00
web@ppanel 71bf002370 🐛 fix(request): Add error code 40005 to trigger logout 2025-03-01 19:55:55 +07:00
web@ppanel d0e6df044e 🐛 fix(dashboard): Display subscription creation date in user dashboard 2025-03-01 19:55:31 +07:00
web@ppanel 8e16ef17ed feat(auth): Add privacy policy link to the footer 2025-03-01 19:40:45 +07:00
web@ppanel 2b80496637 🐛 fix(subscribe): Update payment return URL 2025-03-01 19:35:32 +07:00
semantic-release-bot 01a3aa02ec 🔖 chore(release): v1.0.0-beta.24 [skip ci]
# [1.0.0-beta.24](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.23...v1.0.0-beta.24) (2025-02-27)

### ♻ Code Refactoring

* **ui**: Optimize document display ([2ca2992](https://github.com/perfect-panel/ppanel-web/commit/2ca2992))
* Reduce code complexity and improve readability ([e11f18c](https://github.com/perfect-panel/ppanel-web/commit/e11f18c))

###  Features

* **loading**: Add loading components and integrate them in Providers ([d5847fa](https://github.com/perfect-panel/ppanel-web/commit/d5847fa))

### 🎫 Chores

* **merge**: Add advertising module and device settings ([0130e02](https://github.com/perfect-panel/ppanel-web/commit/0130e02))

### 🐛 Bug Fixes

* **locales**: Order recharge related fields ([35210fe](https://github.com/perfect-panel/ppanel-web/commit/35210fe))
2025-02-27 08:02:53 +00:00
web@ppanel 35210fe8cf 🐛 fix(locales): Order recharge related fields 2025-02-27 14:53:19 +07:00
web@ppanel d5847faeda feat(loading): Add loading components and integrate them in Providers 2025-02-27 14:44:30 +07:00
web@ppanel 0130e02ffd 🔧 chore(merge): Add advertising module and device settings 2025-02-27 13:46:51 +07:00
web@ppanel c2b858e3fe Merge pull request #11 from turbolnk-com/develop
♻️ refactor(ui): Optimize document display
2025-02-25 18:35:05 +07:00
turbolnk e11f18c034 ♻️ refactor: Reduce code complexity and improve readability 2025-02-25 19:31:32 +08:00
turbolnk 2ca299224d ♻️ refactor(ui): Optimize document display 2025-02-25 18:38:25 +08:00
semantic-release-bot cf1d66d07e 🔖 chore(release): v1.0.0-beta.23 [skip ci]
# [1.0.0-beta.23](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.22...v1.0.0-beta.23) (2025-02-24)

### 🐛 Bug Fixes

* **auth**: Update email verification logic to use domain suffix check ([62662bb](https://github.com/perfect-panel/ppanel-web/commit/62662bb))
2025-02-24 05:10:30 +00:00
web@ppanel 62662bb79c 🐛 fix(auth): Update email verification logic to use domain suffix check 2025-02-24 12:07:41 +07:00
semantic-release-bot c0fb34fa50 🔖 chore(release): v1.0.0-beta.22 [skip ci]
# [1.0.0-beta.22](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.21...v1.0.0-beta.22) (2025-02-23)

### 🐛 Bug Fixes

* **locales**: Removed language file import to clean up unnecessary language support ([68f6ab2](https://github.com/perfect-panel/ppanel-web/commit/68f6ab2))
2025-02-23 15:06:05 +00:00
web@ppanel 68f6ab2e6c 🐛 fix(locales): Removed language file import to clean up unnecessary language support 2025-02-23 22:03:15 +07:00
semantic-release-bot ca642c2001 🔖 chore(release): v1.0.0-beta.21 [skip ci]
# [1.0.0-beta.21](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.20...v1.0.0-beta.21) (2025-02-23)

###  Features

* **privacy-policy**: Add privacy policy related text and links ([baa68f0](https://github.com/perfect-panel/ppanel-web/commit/baa68f0))

### 🐛 Bug Fixes

* **locales**: Removed multilingual files to clean up unnecessary language support ([5b151cd](https://github.com/perfect-panel/ppanel-web/commit/5b151cd))
* **locales**: Update custom HTML description in language file, ([87381da](https://github.com/perfect-panel/ppanel-web/commit/87381da))
* **table**: Update privacy policy tab translation key and remove unnecessary requestType from OAuth callback ([14b3af5](https://github.com/perfect-panel/ppanel-web/commit/14b3af5))
2025-02-23 14:17:29 +00:00
web@ppanel 5b151cdbfc 🐛 fix(locales): Removed multilingual files to clean up unnecessary language support 2025-02-23 21:14:17 +07:00
web@ppanel 14b3af5f96 🐛 fix(table): Update privacy policy tab translation key and remove unnecessary requestType from OAuth callback 2025-02-23 20:59:48 +07:00
web@ppanel baa68f03e3 feat(privacy-policy): Add privacy policy related text and links 2025-02-23 20:54:56 +07:00
web@ppanel 87381da1e5 🐛 fix(locales): Update custom HTML description in language file, 2025-02-23 17:25:31 +07:00
semantic-release-bot bbd44f0107 🔖 chore(release): v1.0.0-beta.20 [skip ci]
# [1.0.0-beta.20](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.19...v1.0.0-beta.20) (2025-02-23)

### 🐛 Bug Fixes

* **auth**: Add error handling to form submission and reset Turnstile on failure ([715d011](https://github.com/perfect-panel/ppanel-web/commit/715d011))
* **subscribe**: Update subscription domain placeholder to include examples; improve site name retrieval in global store ([c65a44c](https://github.com/perfect-panel/ppanel-web/commit/c65a44c))
2025-02-23 06:31:01 +00:00
web@ppanel c65a44c2c7 🐛 fix(subscribe): Update subscription domain placeholder to include examples; improve site name retrieval in global store 2025-02-23 13:25:01 +07:00
web@ppanel 715d0113ca 🐛 fix(auth): Add error handling to form submission and reset Turnstile on failure 2025-02-23 13:12:25 +07:00
semantic-release-bot 0f15fb82cb 🔖 chore(release): v1.0.0-beta.19 [skip ci]
# [1.0.0-beta.19](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.18...v1.0.0-beta.19) (2025-02-23)

###  Features

* **form**: Make version field optional and set default value; update site domain placeholder for clarity ([42ba9e8](https://github.com/perfect-panel/ppanel-web/commit/42ba9e8))

### 🐛 Bug Fixes

* **auth**: Refactor forms to use Turnstile ref for reset functionality ([320a7dc](https://github.com/perfect-panel/ppanel-web/commit/320a7dc))
2025-02-23 05:58:05 +00:00
web@ppanel 320a7dc342 🐛 fix(auth): Refactor forms to use Turnstile ref for reset functionality 2025-02-23 12:55:21 +07:00
web@ppanel 42ba9e88ad feat(form): Make version field optional and set default value; update site domain placeholder for clarity 2025-02-23 12:46:29 +07:00
semantic-release-bot 4a002333d4 🔖 chore(release): v1.0.0-beta.18 [skip ci]
# [1.0.0-beta.18](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.17...v1.0.0-beta.18) (2025-02-22)

###  Features

* **platform**: Update platform naming and add keywords and custom HTML fields ([6384237](https://github.com/perfect-panel/ppanel-web/commit/6384237))
* **site**: Added localization support for custom HTML and keyword fields ([f9d7736](https://github.com/perfect-panel/ppanel-web/commit/f9d7736))

### 🐛 Bug Fixes

* **locales**: Update custom HTML description for clarity across multiple languages ([557c5cd](https://github.com/perfect-panel/ppanel-web/commit/557c5cd))
2025-02-22 16:38:46 +00:00
web@ppanel 6384237782 feat(platform): Update platform naming and add keywords and custom HTML fields 2025-02-22 23:35:30 +07:00
web@ppanel 557c5cdc29 🐛 fix(locales): Update custom HTML description for clarity across multiple languages 2025-02-22 12:51:57 +07:00
web@ppanel f9d7736e48 feat(site): Added localization support for custom HTML and keyword fields 2025-02-22 12:33:09 +07:00
semantic-release-bot 5b64389544 🔖 chore(release): v1.0.0-beta.17 [skip ci]
# [1.0.0-beta.17](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.16...v1.0.0-beta.17) (2025-02-21)

### 🐛 Bug Fixes

* **auth**: Refactor reset password form to simplify code input and update placeholder text ([23833b4](https://github.com/perfect-panel/ppanel-web/commit/23833b4))
2025-02-21 13:26:11 +00:00
web@ppanel 23833b47d1 🐛 fix(auth): Refactor reset password form to simplify code input and update placeholder text 2025-02-21 20:23:30 +07:00
semantic-release-bot c3eff0a0e7 🔖 chore(release): v1.0.0-beta.16 [skip ci]
# [1.0.0-beta.16](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.15...v1.0.0-beta.16) (2025-02-21)

### 🐛 Bug Fixes

* **auth**: Simplify email verification code input rendering ([6f7bc37](https://github.com/perfect-panel/ppanel-web/commit/6f7bc37))
2025-02-21 13:18:47 +00:00
web@ppanel 6f7bc37233 🐛 fix(auth): Simplify email verification code input rendering 2025-02-21 20:16:02 +07:00
semantic-release-bot b2e8fadaf3 🔖 chore(release): v1.0.0-beta.15 [skip ci]
# [1.0.0-beta.15](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.14...v1.0.0-beta.15) (2025-02-21)

### 🐛 Bug Fixes

* **profile**: Restore filter to ensure only valid OAuth accounts are shown ([315c8f9](https://github.com/perfect-panel/ppanel-web/commit/315c8f9))
2025-02-21 12:59:02 +00:00
web@ppanel 315c8f9afe 🐛 fix(profile): Restore filter to ensure only valid OAuth accounts are shown 2025-02-21 19:55:24 +07:00
semantic-release-bot 0fb0d8bc11 🔖 chore(release): v1.0.0-beta.14 [skip ci]
# [1.0.0-beta.14](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.13...v1.0.0-beta.14) (2025-02-21)

### 🐛 Bug Fixes

* **locales**: Fixed description in multilingual files, updated text related to email registration functionality ([c356bc2](https://github.com/perfect-panel/ppanel-web/commit/c356bc2))
2025-02-21 07:07:36 +00:00
web@ppanel c356bc26ff 🐛 fix(locales): Fixed description in multilingual files, updated text related to email registration functionality 2025-02-21 14:04:15 +07:00
semantic-release-bot 23c974a94d 🔖 chore(release): v1.0.0-beta.13 [skip ci]
# [1.0.0-beta.13](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2025-02-21)

### 🐛 Bug Fixes

* **api**: Fix type error in API request and add return URL parameter ([ee286dd](https://github.com/perfect-panel/ppanel-web/commit/ee286dd))
2025-02-21 06:03:00 +00:00
web@ppanel ee286dd629 🐛 fix(api): Fix type error in API request and add return URL parameter 2025-02-21 12:59:23 +07:00
semantic-release-bot 4418c47f4f 🔖 chore(release): v1.0.0-beta.12 [skip ci]
# [1.0.0-beta.12](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2025-02-18)

### 🐛 Bug Fixes

* More bugs ([2d88a3a](https://github.com/perfect-panel/ppanel-web/commit/2d88a3a))
2025-02-18 14:57:24 +00:00
web@ppanel 2d88a3a81d 🐛 fix: More bugs 2025-02-18 21:53:24 +07:00
semantic-release-bot 1f29506179 🔖 chore(release): v1.0.0-beta.11 [skip ci]
# [1.0.0-beta.11](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2025-02-16)

### 🐛 Bug Fixes

* **email**: Update platform configuration handling to use current ref for consistency ([c90175b](https://github.com/perfect-panel/ppanel-web/commit/c90175b))
2025-02-16 12:09:00 +00:00
web@ppanel c90175b6eb 🐛 fix(email): Update platform configuration handling to use current ref for consistency 2025-02-16 19:06:17 +07:00
semantic-release-bot 5cf573ab03 🔖 chore(release): v1.0.0-beta.10 [skip ci]
# [1.0.0-beta.10](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2025-02-16)

### 🐛 Bug Fixes

* **subscribe**: Update forms to include refetch functionality and improve toast messages ([fc55e95](https://github.com/perfect-panel/ppanel-web/commit/fc55e95))
2025-02-16 10:14:25 +00:00
web@ppanel fc55e9544d 🐛 fix(subscribe): Update forms to include refetch functionality and improve toast messages 2025-02-16 17:08:34 +07:00
semantic-release-bot 855d1b096f 🔖 chore(release): v1.0.0-beta.9 [skip ci]
# [1.0.0-beta.9](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2025-02-15)

### 🐛 Bug Fixes

* **register**: Adjust user email verification logic to handle domain suffix checks correctly ([686aa2d](https://github.com/perfect-panel/ppanel-web/commit/686aa2d))
2025-02-15 15:02:16 +00:00
web@ppanel 686aa2d0ba 🐛 fix(register): Adjust user email verification logic to handle domain suffix checks correctly 2025-02-15 21:58:25 +07:00
semantic-release-bot a593eac119 🔖 chore(release): v1.0.0-beta.8 [skip ci]
# [1.0.0-beta.8](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.7...v1.0.0-beta.8) (2025-02-15)

###  Features

* **accounts**: Update third-party account binding and unbinding ([1841552](https://github.com/perfect-panel/ppanel-web/commit/1841552))
* **auth-control**: Adding phone number labels to mobile verification configurations in multiple languages ([046740f](https://github.com/perfect-panel/ppanel-web/commit/046740f))
* **auth-control**: Update general ([3883646](https://github.com/perfect-panel/ppanel-web/commit/3883646))
* **auth**: Add type parameter to SendCode and update related API typings ([4198871](https://github.com/perfect-panel/ppanel-web/commit/4198871))
* **auth**: Refactor mobile authentication config to support whitelist functionality ([c761ec7](https://github.com/perfect-panel/ppanel-web/commit/c761ec7))
* **device**: Modify IMEI to device identifier support ([e3f9ef6](https://github.com/perfect-panel/ppanel-web/commit/e3f9ef6))
* **imei**: Add IMEI related internationalization support and menu items ([13c3337](https://github.com/perfect-panel/ppanel-web/commit/13c3337))
* **locales**: Add kick offline confirmation and success messages in multiple languages ([5db5343](https://github.com/perfect-panel/ppanel-web/commit/5db5343))
* **locales**: Update 'sms' to 'mobile' in authentication methods across multiple languages ([fea2171](https://github.com/perfect-panel/ppanel-web/commit/fea2171))
* **log**: Add message log retrieval functionality and update related typings ([1c0ecae](https://github.com/perfect-panel/ppanel-web/commit/1c0ecae))
* **profile**:  Update localization strings and enhance third-party account binding ([2d1effb](https://github.com/perfect-panel/ppanel-web/commit/2d1effb))
* **release**: Extend supported platforms for Docker images, closes [#9](https://github.com/perfect-panel/ppanel-web/issues/9) ([e3a31eb](https://github.com/perfect-panel/ppanel-web/commit/e3a31eb))
* **subscription**: Add delete user subscription functionality ([1fc3a10](https://github.com/perfect-panel/ppanel-web/commit/1fc3a10))
* **ui**: Update input components and enhance card minimum width for better layout ([8a02310](https://github.com/perfect-panel/ppanel-web/commit/8a02310))
* **user**: Add user Detail ([3a3d223](https://github.com/perfect-panel/ppanel-web/commit/3a3d223))
* **user**: Add User Detail ([fdaf11b](https://github.com/perfect-panel/ppanel-web/commit/fdaf11b))

### 🐛 Bug Fixes

* **auth-control**: Fix citation error for platform values ([c940f3c](https://github.com/perfect-panel/ppanel-web/commit/c940f3c))
* **auth-control**: Fix citation error for platform values ([28813d2](https://github.com/perfect-panel/ppanel-web/commit/28813d2))
* **auth-control**: Rename phone_variable to phone_number in mobile verification configuration ([e5455aa](https://github.com/perfect-panel/ppanel-web/commit/e5455aa))
* **auth**: Update authentication configuration and localization strings ([47f2c58](https://github.com/perfect-panel/ppanel-web/commit/47f2c58))
* **locales**: Update expiration time description from minutes to seconds in multiple languages ([5bac933](https://github.com/perfect-panel/ppanel-web/commit/5bac933))
* **notify**: Ensure user info is updated after notification settings submission ([9bc3a94](https://github.com/perfect-panel/ppanel-web/commit/9bc3a94))
* **notify**: Set default values for notification settings to false ([3652819](https://github.com/perfect-panel/ppanel-web/commit/3652819))
* **third-party-accounts**: Remove mobile display logic from third-party accounts component ([b4946f7](https://github.com/perfect-panel/ppanel-web/commit/b4946f7))
* **third-party-accounts**: Update redirect property name in binding response handling ([012e83a](https://github.com/perfect-panel/ppanel-web/commit/012e83a))
* **user**: Refactor user form validation and reset password fields ([6733fc2](https://github.com/perfect-panel/ppanel-web/commit/6733fc2))
* **user**: Update locales ([4e7d249](https://github.com/perfect-panel/ppanel-web/commit/4e7d249))
* **user**: Update notification and verify code settings ([574b043](https://github.com/perfect-panel/ppanel-web/commit/574b043))

### 💄 Styles

* **dashboard**: Enhance card components with full height and improved empty state handling ([7e1d551](https://github.com/perfect-panel/ppanel-web/commit/7e1d551))
2025-02-15 11:08:52 +00:00
web@ppanel 7e1d551d8f 💄 style(dashboard): Enhance card components with full height and improved empty state handling 2025-02-15 18:06:07 +07:00
web@ppanel 1fc3a10c67 feat(subscription): Add delete user subscription functionality 2025-02-14 23:58:10 +07:00
web@ppanel 9bc3a940ba 🐛 fix(notify): Ensure user info is updated after notification settings submission 2025-02-14 23:42:54 +07:00
web@ppanel 3652819ac9 🐛 fix(notify): Set default values for notification settings to false 2025-02-14 23:01:16 +07:00
web@ppanel c761ec7538 feat(auth): Refactor mobile authentication config to support whitelist functionality 2025-02-14 22:13:50 +07:00
web@ppanel 5bac933fbc 🐛 fix(locales): Update expiration time description from minutes to seconds in multiple languages 2025-02-14 15:53:26 +07:00
web@ppanel 8a02310752 feat(ui): Update input components and enhance card minimum width for better layout 2025-02-14 15:49:34 +07:00
web@ppanel 574b043be6 🐛 fix(user): Update notification and verify code settings 2025-02-14 15:39:25 +07:00
web@ppanel e3f9ef6ebe feat(device): Modify IMEI to device identifier support 2025-02-13 22:28:45 +07:00
web@ppanel 4e7d249902 🐛 fix(user): Update locales 2025-02-13 21:42:20 +07:00
web@ppanel 5db5343e4b feat(locales): Add kick offline confirmation and success messages in multiple languages 2025-02-13 21:37:05 +07:00
web@ppanel 1c0ecaecbd feat(log): Add message log retrieval functionality and update related typings 2025-02-13 21:25:21 +07:00
web@ppanel 13c33378aa feat(imei): Add IMEI related internationalization support and menu items 2025-02-12 16:34:23 +07:00
web@ppanel b4946f7a06 🐛 fix(third-party-accounts): Remove mobile display logic from third-party accounts component 2025-02-10 23:12:43 +07:00
web@ppanel 012e83a25c 🐛 fix(third-party-accounts): Update redirect property name in binding response handling 2025-02-10 22:51:31 +07:00
web@ppanel 1841552b1b feat(accounts): Update third-party account binding and unbinding 2025-02-10 21:39:28 +07:00
web@ppanel 4198871eef feat(auth): Add type parameter to SendCode and update related API typings 2025-02-10 14:39:31 +07:00
web@ppanel e5455aa1dc 🐛 fix(auth-control): Rename phone_variable to phone_number in mobile verification configuration 2025-02-10 13:05:10 +07:00
web@ppanel 046740f037 feat(auth-control): Adding phone number labels to mobile verification configurations in multiple languages 2025-02-10 13:03:48 +07:00
web@ppanel fea2171d54 feat(locales): Update 'sms' to 'mobile' in authentication methods across multiple languages 2025-02-09 22:24:00 +07:00
web@ppanel c940f3c209 🐛 fix(auth-control): Fix citation error for platform values 2025-02-09 22:06:29 +07:00
web@ppanel 28813d2d3a 🐛 fix(auth-control): Fix citation error for platform values 2025-02-09 21:12:26 +07:00
web@ppanel 2d1effb7ab feat(profile): Update localization strings and enhance third-party account binding 2025-02-09 21:05:56 +07:00
web@ppanel 3883646566 feat(auth-control): Update general 2025-02-09 20:14:27 +07:00
web@ppanel e3a31eb709 feat(release): Extend supported platforms for Docker images (#9) 2025-02-09 16:34:43 +07:00
web@ppanel 47f2c5853c 🐛 fix(auth): Update authentication configuration and localization strings 2025-02-08 23:58:03 +07:00
web@ppanel 3a3d2236e2 feat(user): Add user Detail 2025-02-08 14:47:23 +07:00
web@ppanel fdaf11b654 feat(user): Add User Detail 2025-02-02 14:40:29 +07:00
web@ppanel 6733fc211c 🐛 fix(user): Refactor user form validation and reset password fields 2025-01-29 17:16:25 +07:00
856 changed files with 30587 additions and 9012 deletions
+429 -61
View File
@@ -1,79 +1,447 @@
<a name="readme-top"></a> <a name="readme-top"></a>
# Changelog # Changelog
# [1.0.0-beta.7](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2025-01-28) # [1.0.0-beta.34](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.33...v1.0.0-beta.34) (2025-04-02)
### ♻ Code Refactoring
* **sbscribe**: Rename and reorganize components for better structure and clarity ([5e5e4ed](https://github.com/perfect-panel/ppanel-web/commit/5e5e4ed))
### ✨ Features ### ✨ Features
* **auth**: Add email and SMS code sending functionality with localization updates ([57eaa55](https://github.com/perfect-panel/ppanel-web/commit/57eaa55)) * **admin**: Add application and rule management entries to localization files ([8b43e69](https://github.com/perfect-panel/ppanel-web/commit/8b43e69))
* **auth**: Add Oauth configuration for Telegram, Facebook, Google, Github, and Apple ([18ee600](https://github.com/perfect-panel/ppanel-web/commit/18ee600)) * **api**: Add an interface to obtain user subscription details, update related type definitions and localized text ([cf5c39c](https://github.com/perfect-panel/ppanel-web/commit/cf5c39c))
* **auth**: Add SMS and email configuration options to global store and update localization ([4acf7b1](https://github.com/perfect-panel/ppanel-web/commit/4acf7b1)) * **user**: Integrate subscription list into user management, update request parameters and types ([8d49dac](https://github.com/perfect-panel/ppanel-web/commit/8d49dac))
* **auth**: Enhance user registration with invite handling and logo display ([207bc24](https://github.com/perfect-panel/ppanel-web/commit/207bc24))
* **auth**: Redirect user after OAuth login and add logos icon collection ([aa6dda8](https://github.com/perfect-panel/ppanel-web/commit/aa6dda8))
* **config**: Add application selection and encryption settings to configuration form ([88b3504](https://github.com/perfect-panel/ppanel-web/commit/88b3504))
* **config**: Update encryption fields in configuration form and refactor OAuth callback parameters ([652e032](https://github.com/perfect-panel/ppanel-web/commit/652e032))
* **global**: Add SMS configuration options to global store ([39a9ce6](https://github.com/perfect-panel/ppanel-web/commit/39a9ce6))
* **locales**: Add area code and telephone fields to user forms in multiple languages ([9b8258c](https://github.com/perfect-panel/ppanel-web/commit/9b8258c))
* **locales**: Add description information of communication keys and encryption methods to enhance client configuration capabilities ([d1f5a9b](https://github.com/perfect-panel/ppanel-web/commit/d1f5a9b))
* **node**: Add tags ([f408fdf](https://github.com/perfect-panel/ppanel-web/commit/f408fdf))
* **node**: Move the node configuration to the server module ([7f0f5ce](https://github.com/perfect-panel/ppanel-web/commit/7f0f5ce))
* **oauth**: Add certification component for handling OAuth login callbacks and improve user authentication flow ([5ed04c0](https://github.com/perfect-panel/ppanel-web/commit/5ed04c0))
* **oauth**: Implement OAuth token retrieval and refactor login callback handling ([40a6f7c](https://github.com/perfect-panel/ppanel-web/commit/40a6f7c))
* **oauth**: Refactor platform parameter handling and improve logout redirection logic ([8346c85](https://github.com/perfect-panel/ppanel-web/commit/8346c85))
* **oauth**: Update OAuth login handling to use callback parameter and improve URL parameter retrieval ([9227411](https://github.com/perfect-panel/ppanel-web/commit/9227411))
* **sms**: Update locales ([938363b](https://github.com/perfect-panel/ppanel-web/commit/938363b))
* **subscribe**: Add 'sold' column to SubscribeTable and update inventory terminology ([19619fd](https://github.com/perfect-panel/ppanel-web/commit/19619fd))
* **subscribe**: Move subscription configuration and application to subscription module ([f90d4d2](https://github.com/perfect-panel/ppanel-web/commit/f90d4d2))
* **subscribe**: Update SubscribeTable component to use API.SubscribeItem type and ensure proper type casting ([f26f1c2](https://github.com/perfect-panel/ppanel-web/commit/f26f1c2))
* **tutorial**: Fetch the latest tutorial version from GitHub API for dynamic URL generation ([28f8c78](https://github.com/perfect-panel/ppanel-web/commit/28f8c78))
* **user**: Add telephone input with area code selection and update localization ([585b99c](https://github.com/perfect-panel/ppanel-web/commit/585b99c))
* Update Auth Control ([c59742a](https://github.com/perfect-panel/ppanel-web/commit/c59742a))
### 🐛 Bug Fixes ### 🐛 Bug Fixes
* **api**: Remove redundant requestType parameter in appleLoginCallback ([0aa5d5b](https://github.com/perfect-panel/ppanel-web/commit/0aa5d5b)) * **admin**: Hidden versions and system upgrades ([64cd842](https://github.com/perfect-panel/ppanel-web/commit/64cd842))
* **api**: Rename app-related functions and types to application for consistency ([9d8b814](https://github.com/perfect-panel/ppanel-web/commit/9d8b814)) * **admin**: Modify the label type in the rule form to a string array ([a7aa5fe](https://github.com/perfect-panel/ppanel-web/commit/a7aa5fe))
* **api**: Update subscription_protocol to subscribe_type for consistency across services ([b6da51b](https://github.com/perfect-panel/ppanel-web/commit/b6da51b)) * **node**: Handle potential null value for online users count ([fa2fb28](https://github.com/perfect-panel/ppanel-web/commit/fa2fb28))
* **auth**: Change Textarea value to defaultValue for client_secret in Apple auth page ([69fc670](https://github.com/perfect-panel/ppanel-web/commit/69fc670)) * **subscribe**: Add value prop to field in subscription form for proper state management ([328838d](https://github.com/perfect-panel/ppanel-web/commit/328838d))
* **auth**: Remove unused telephone code login function and update typings for telephone login requests ([7239685](https://github.com/perfect-panel/ppanel-web/commit/7239685)) * **subscribe**: Refactor discount calculations and default selection logic in subscription forms ([423b240](https://github.com/perfect-panel/ppanel-web/commit/423b240))
* **auth**: Require minimum length for invite string when forced invite is enabled ([a604f28](https://github.com/perfect-panel/ppanel-web/commit/a604f28)) * **subscribe**: Update default selection logic in subscription form to ensure proper state management ([ef15374](https://github.com/perfect-panel/ppanel-web/commit/ef15374))
* **auth**: Update user authentication flow to include email and phone code verification ([5d078fd](https://github.com/perfect-panel/ppanel-web/commit/5d078fd))
* **dashboard**: Improve URL encoding for subscription links and enhance success message handling ([4983c33](https://github.com/perfect-panel/ppanel-web/commit/4983c33))
* **dashboard**: Update icon imports for platform consistency and adjust icon size ([3e8912e](https://github.com/perfect-panel/ppanel-web/commit/3e8912e))
* **dashboard**: Update platform detection logic and improve layout responsiveness ([b0aa364](https://github.com/perfect-panel/ppanel-web/commit/b0aa364))
* **deps**: Remove outdated @iconify/react dependency and add iconify-json packages ([d6fbc38](https://github.com/perfect-panel/ppanel-web/commit/d6fbc38))
* **locales**: Add error message for incorrect user information ([52c1d1f](https://github.com/perfect-panel/ppanel-web/commit/52c1d1f))
* **locales**: Add error message for incorrect user information ([3d92902](https://github.com/perfect-panel/ppanel-web/commit/3d92902))
* **locales**: Add logout message to authentication localization files ([1d0d911](https://github.com/perfect-panel/ppanel-web/commit/1d0d911))
* **nav**: Comment out unused social login options to simplify navigation configuration ([cefcb31](https://github.com/perfect-panel/ppanel-web/commit/cefcb31))
* **node-config**: Add null checks for time slots and ensure proper handling of undefined values ([1cdb7e7](https://github.com/perfect-panel/ppanel-web/commit/1cdb7e7))
* **node**: Add country and city fields to the form schema and localization files ([8775fb6](https://github.com/perfect-panel/ppanel-web/commit/8775fb6))
* **oauth**: Refactor OAuth configuration types and update related API methods ([6227ba9](https://github.com/perfect-panel/ppanel-web/commit/6227ba9))
* **oauth**: Remove redundant checks when updating configuration to simplify logic ([9140b8a](https://github.com/perfect-panel/ppanel-web/commit/9140b8a))
* **payment**: Replace window.open with window.location.href for checkout links ([1d8c765](https://github.com/perfect-panel/ppanel-web/commit/1d8c765))
* **phone**: Update SMS expiration time field to use 'sms_expire_time' with default value of 300 ([18b07c7](https://github.com/perfect-panel/ppanel-web/commit/18b07c7))
* **redirect**: Simplify redirect URL logic by removing unnecessary condition for sessionStorage ([c53ac61](https://github.com/perfect-panel/ppanel-web/commit/c53ac61))
* **redirect**: Update redirect URL logic to ensure proper handling of OAuth and auth paths ([7954762](https://github.com/perfect-panel/ppanel-web/commit/7954762))
* **site**: Add image upload functionality for site logo configuration ([4ea6e4a](https://github.com/perfect-panel/ppanel-web/commit/4ea6e4a))
* **sort**: Refactor sorting logic in NodeTable and SubscribeTable components for improved clarity and performance ([331bbea](https://github.com/perfect-panel/ppanel-web/commit/331bbea))
* **subscription**: Add reset functionality for user subscription token ([39e89bf](https://github.com/perfect-panel/ppanel-web/commit/39e89bf))
* **type**: Fix ts type check error ([3cb0629](https://github.com/perfect-panel/ppanel-web/commit/3cb0629))
* **user-nav**: Update user avatar and label to display telephone if email is not available ([7b6bb7b](https://github.com/perfect-panel/ppanel-web/commit/7b6bb7b))
* **user**: Update user identifier field and localizations ([1b6befa](https://github.com/perfect-panel/ppanel-web/commit/1b6befa))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.33](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.32...v1.0.0-beta.33) (2025-03-18)
### 🐛 Bug Fixes
- **subscribe**: Handle optional values in price and discount calculations ([5939763](https://github.com/perfect-panel/ppanel-web/commit/5939763))
# [1.0.0-beta.32](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.31...v1.0.0-beta.32) (2025-03-17)
### 🐛 Bug Fixes
- **forms**: Add step attribute to number inputs for better value control ([b8f4f1e](https://github.com/perfect-panel/ppanel-web/commit/b8f4f1e))
- **locales**: Update invite code text to indicate it's optional ([6a34bfb](https://github.com/perfect-panel/ppanel-web/commit/6a34bfb))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.31](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.30...v1.0.0-beta.31) (2025-03-15)
### 🐛 Bug Fixes
- **site**: Se ref to store site configuration for updates ([0c8f091](https://github.com/perfect-panel/ppanel-web/commit/0c8f091))
# [1.0.0-beta.30](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.29...v1.0.0-beta.30) (2025-03-15)
### ✨ Features
- **api**: Add CheckoutOrder request and response types, and update user purchase request parameters ([48a1b97](https://github.com/perfect-panel/ppanel-web/commit/48a1b97))
- **email**: Add traffic exhaustion template ([bb3bd7b](https://github.com/perfect-panel/ppanel-web/commit/bb3bd7b))
- **formatting**: Update differenceInDays function to return whole days or two decimal places ([bf58f25](https://github.com/perfect-panel/ppanel-web/commit/bf58f25))
- **global**: Add custom data ([6dbebd1](https://github.com/perfect-panel/ppanel-web/commit/6dbebd1))
- **input**: Add minimum value constraint and enhance number handling in EnhancedInput ([ce31972](https://github.com/perfect-panel/ppanel-web/commit/ce31972))
- **loading**: Replace loading animation with a simpler spinner and loading text ([f72df3a](https://github.com/perfect-panel/ppanel-web/commit/f72df3a))
- **node-form**: Update number input fields to enforce step, min, and max values ([3f7b6d1](https://github.com/perfect-panel/ppanel-web/commit/3f7b6d1))
- **payment**: Add isEdit prop to PaymentForm and disable fields when editing ([85f55de](https://github.com/perfect-panel/ppanel-web/commit/85f55de))
- **timeline**: Simplify timeline component layout and remove commented-out code ([fbad3b0](https://github.com/perfect-panel/ppanel-web/commit/fbad3b0))
### 🎫 Chores
- **release**: V1.0.0-beta.27 [skip ci] ([092477b](https://github.com/perfect-panel/ppanel-web/commit/092477b))
- **release**: V1.0.0-beta.28 [skip ci] ([786ba0e](https://github.com/perfect-panel/ppanel-web/commit/786ba0e))
- Merge branch 'beta' into develop ([f219c52](https://github.com/perfect-panel/ppanel-web/commit/f219c52))
### 🐛 Bug Fixes
- **dashboard**: Update date display to use start_time if available ([e551232](https://github.com/perfect-panel/ppanel-web/commit/e551232))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.29](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.28...v1.0.0-beta.29) (2025-03-14)
### ✨ Features
- **api**: Add CheckoutOrder request and response types, and update user purchase request parameters ([dddc21c](https://github.com/perfect-panel/ppanel-web/commit/dddc21c))
- **loading**: Replace loading animation with a simpler spinner and loading text ([b8316bb](https://github.com/perfect-panel/ppanel-web/commit/b8316bb))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.28](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.27...v1.0.0-beta.28) (2025-03-13)
### ✨ Features
- **input**: Add minimum value constraint and enhance number handling in EnhancedInput ([94822d9](https://github.com/perfect-panel/ppanel-web/commit/94822d9))
# [1.0.0-beta.27](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.26...v1.0.0-beta.27) (2025-03-13)
### ♻ Code Refactoring
- **payment**: Reconstruct the payment page ([7109472](https://github.com/perfect-panel/ppanel-web/commit/7109472))
- Enhance user navigation dropdown ui and styling ([d2732e6](https://github.com/perfect-panel/ppanel-web/commit/d2732e6))
### ✨ Features
- **cdn**: Add CDN URL configuration and update related references ([0c90733](https://github.com/perfect-panel/ppanel-web/commit/0c90733))
- **payment**: Add bank card payment ([7fa3a57](https://github.com/perfect-panel/ppanel-web/commit/7fa3a57))
- **subscription**: Improve layout and organization of subscription detail tabs ([e4630f8](https://github.com/perfect-panel/ppanel-web/commit/e4630f8))
- **subscription**: Refactor subscription handling and update imports for better organization ([2215c7f](https://github.com/perfect-panel/ppanel-web/commit/2215c7f))
### 🎫 Chores
- **merge**: Bump version to 1.0.0-beta.26 and update changelog ([3222016](https://github.com/perfect-panel/ppanel-web/commit/3222016))
### 🐛 Bug Fixes
- **affiliate**: Update user identifier ([35f92c9](https://github.com/perfect-panel/ppanel-web/commit/35f92c9))
- **changelog**: Update change log style ([cfa3fc0](https://github.com/perfect-panel/ppanel-web/commit/cfa3fc0))
- **payment**: Add notification URL field to payment management interface ([5c710e1](https://github.com/perfect-panel/ppanel-web/commit/5c710e1))
- **payment**: Fix payment related type definitions and update payment method references ([c3138a8](https://github.com/perfect-panel/ppanel-web/commit/c3138a8))
- **payment**: Refactor purchaseCheckout usage and remove redundant code ([a5e2079](https://github.com/perfect-panel/ppanel-web/commit/a5e2079))
- **payment**: Update checkout type from 'link' to 'url' for consistency ([136a1ab](https://github.com/perfect-panel/ppanel-web/commit/136a1ab))
- **payment**: Update payment information ([70d6a38](https://github.com/perfect-panel/ppanel-web/commit/70d6a38))
- **payment**: Update payment method update logic to include row data ([6752420](https://github.com/perfect-panel/ppanel-web/commit/6752420))
- **purchasing**: Update payment type to lowercase and add optional chaining for discounts ([c06ea49](https://github.com/perfect-panel/ppanel-web/commit/c06ea49))
- **ui**: Improve dashboard layout and enhance button functionality; open checkout URLs in a new tab ([fc0da76](https://github.com/perfect-panel/ppanel-web/commit/fc0da76))
- **ui**: Multiple display bugs ([f5d8fd3](https://github.com/perfect-panel/ppanel-web/commit/f5d8fd3))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.28](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.27...v1.0.0-beta.28) (2025-03-13)
### ✨ Features
- **input**: Add minimum value constraint and enhance number handling in EnhancedInput ([94822d9](https://github.com/perfect-panel/ppanel-web/commit/94822d9))
# [1.0.0-beta.27](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.26...v1.0.0-beta.27) (2025-03-13)
### ♻ Code Refactoring
- **payment**: Reconstruct the payment page ([7109472](https://github.com/perfect-panel/ppanel-web/commit/7109472))
- Enhance user navigation dropdown ui and styling ([d2732e6](https://github.com/perfect-panel/ppanel-web/commit/d2732e6))
### ✨ Features
- **cdn**: Add CDN URL configuration and update related references ([0c90733](https://github.com/perfect-panel/ppanel-web/commit/0c90733))
- **payment**: Add bank card payment ([7fa3a57](https://github.com/perfect-panel/ppanel-web/commit/7fa3a57))
- **subscription**: Improve layout and organization of subscription detail tabs ([e4630f8](https://github.com/perfect-panel/ppanel-web/commit/e4630f8))
- **subscription**: Refactor subscription handling and update imports for better organization ([2215c7f](https://github.com/perfect-panel/ppanel-web/commit/2215c7f))
### 🎫 Chores
- **merge**: Bump version to 1.0.0-beta.26 and update changelog ([3222016](https://github.com/perfect-panel/ppanel-web/commit/3222016))
### 🐛 Bug Fixes
- **affiliate**: Update user identifier ([35f92c9](https://github.com/perfect-panel/ppanel-web/commit/35f92c9))
- **changelog**: Update change log style ([cfa3fc0](https://github.com/perfect-panel/ppanel-web/commit/cfa3fc0))
- **payment**: Add notification URL field to payment management interface ([5c710e1](https://github.com/perfect-panel/ppanel-web/commit/5c710e1))
- **payment**: Fix payment related type definitions and update payment method references ([c3138a8](https://github.com/perfect-panel/ppanel-web/commit/c3138a8))
- **payment**: Refactor purchaseCheckout usage and remove redundant code ([a5e2079](https://github.com/perfect-panel/ppanel-web/commit/a5e2079))
- **payment**: Update checkout type from 'link' to 'url' for consistency ([136a1ab](https://github.com/perfect-panel/ppanel-web/commit/136a1ab))
- **payment**: Update payment information ([70d6a38](https://github.com/perfect-panel/ppanel-web/commit/70d6a38))
- **payment**: Update payment method update logic to include row data ([6752420](https://github.com/perfect-panel/ppanel-web/commit/6752420))
- **purchasing**: Update payment type to lowercase and add optional chaining for discounts ([c06ea49](https://github.com/perfect-panel/ppanel-web/commit/c06ea49))
- **ui**: Improve dashboard layout and enhance button functionality; open checkout URLs in a new tab ([fc0da76](https://github.com/perfect-panel/ppanel-web/commit/fc0da76))
- **ui**: Multiple display bugs ([f5d8fd3](https://github.com/perfect-panel/ppanel-web/commit/f5d8fd3))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.26](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.25...v1.0.0-beta.26) (2025-03-02)
### 🐛 Bug Fixes
- **icon**: Comment out unused icon collection imports ([f17bf8d](https://github.com/perfect-panel/ppanel-web/commit/f17bf8d))
# [1.0.0-beta.25](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.24...v1.0.0-beta.25) (2025-03-01)
### ✨ Features
- **auth**: Add privacy policy link to the footer ([8e16ef1](https://github.com/perfect-panel/ppanel-web/commit/8e16ef1))
### 🐛 Bug Fixes
- **dashboard**: Display subscription creation date in user dashboard ([d0e6df0](https://github.com/perfect-panel/ppanel-web/commit/d0e6df0))
- **request**: Add error code 40005 to trigger logout ([71bf002](https://github.com/perfect-panel/ppanel-web/commit/71bf002))
- **subscribe**: Update payment return URL ([2b80496](https://github.com/perfect-panel/ppanel-web/commit/2b80496))
# [1.0.0-beta.24](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.23...v1.0.0-beta.24) (2025-02-27)
### ♻ Code Refactoring
- **ui**: Optimize document display ([2ca2992](https://github.com/perfect-panel/ppanel-web/commit/2ca2992))
- Reduce code complexity and improve readability ([e11f18c](https://github.com/perfect-panel/ppanel-web/commit/e11f18c))
### ✨ Features
- **loading**: Add loading components and integrate them in Providers ([d5847fa](https://github.com/perfect-panel/ppanel-web/commit/d5847fa))
### 🎫 Chores
- **merge**: Add advertising module and device settings ([0130e02](https://github.com/perfect-panel/ppanel-web/commit/0130e02))
### 🐛 Bug Fixes
- **locales**: Order recharge related fields ([35210fe](https://github.com/perfect-panel/ppanel-web/commit/35210fe))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.23](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.22...v1.0.0-beta.23) (2025-02-24)
### 🐛 Bug Fixes
- **auth**: Update email verification logic to use domain suffix check ([62662bb](https://github.com/perfect-panel/ppanel-web/commit/62662bb))
# [1.0.0-beta.22](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.21...v1.0.0-beta.22) (2025-02-23)
### 🐛 Bug Fixes
- **locales**: Removed language file import to clean up unnecessary language support ([68f6ab2](https://github.com/perfect-panel/ppanel-web/commit/68f6ab2))
# [1.0.0-beta.21](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.20...v1.0.0-beta.21) (2025-02-23)
### ✨ Features
- **privacy-policy**: Add privacy policy related text and links ([baa68f0](https://github.com/perfect-panel/ppanel-web/commit/baa68f0))
### 🐛 Bug Fixes
- **locales**: Removed multilingual files to clean up unnecessary language support ([5b151cd](https://github.com/perfect-panel/ppanel-web/commit/5b151cd))
- **locales**: Update custom HTML description in language file, ([87381da](https://github.com/perfect-panel/ppanel-web/commit/87381da))
- **table**: Update privacy policy tab translation key and remove unnecessary requestType from OAuth callback ([14b3af5](https://github.com/perfect-panel/ppanel-web/commit/14b3af5))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.20](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.19...v1.0.0-beta.20) (2025-02-23)
### 🐛 Bug Fixes
- **auth**: Add error handling to form submission and reset Turnstile on failure ([715d011](https://github.com/perfect-panel/ppanel-web/commit/715d011))
- **subscribe**: Update subscription domain placeholder to include examples; improve site name retrieval in global store ([c65a44c](https://github.com/perfect-panel/ppanel-web/commit/c65a44c))
# [1.0.0-beta.19](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.18...v1.0.0-beta.19) (2025-02-23)
### ✨ Features
- **form**: Make version field optional and set default value; update site domain placeholder for clarity ([42ba9e8](https://github.com/perfect-panel/ppanel-web/commit/42ba9e8))
### 🐛 Bug Fixes
- **auth**: Refactor forms to use Turnstile ref for reset functionality ([320a7dc](https://github.com/perfect-panel/ppanel-web/commit/320a7dc))
# [1.0.0-beta.18](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.17...v1.0.0-beta.18) (2025-02-22)
### ✨ Features
- **platform**: Update platform naming and add keywords and custom HTML fields ([6384237](https://github.com/perfect-panel/ppanel-web/commit/6384237))
- **site**: Added localization support for custom HTML and keyword fields ([f9d7736](https://github.com/perfect-panel/ppanel-web/commit/f9d7736))
### 🐛 Bug Fixes
- **locales**: Update custom HTML description for clarity across multiple languages ([557c5cd](https://github.com/perfect-panel/ppanel-web/commit/557c5cd))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.17](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.16...v1.0.0-beta.17) (2025-02-21)
### 🐛 Bug Fixes
- **auth**: Refactor reset password form to simplify code input and update placeholder text ([23833b4](https://github.com/perfect-panel/ppanel-web/commit/23833b4))
# [1.0.0-beta.16](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.15...v1.0.0-beta.16) (2025-02-21)
### 🐛 Bug Fixes
- **auth**: Simplify email verification code input rendering ([6f7bc37](https://github.com/perfect-panel/ppanel-web/commit/6f7bc37))
# [1.0.0-beta.15](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.14...v1.0.0-beta.15) (2025-02-21)
### 🐛 Bug Fixes
- **profile**: Restore filter to ensure only valid OAuth accounts are shown ([315c8f9](https://github.com/perfect-panel/ppanel-web/commit/315c8f9))
# [1.0.0-beta.14](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.13...v1.0.0-beta.14) (2025-02-21)
### 🐛 Bug Fixes
- **locales**: Fixed description in multilingual files, updated text related to email registration functionality ([c356bc2](https://github.com/perfect-panel/ppanel-web/commit/c356bc2))
# [1.0.0-beta.13](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2025-02-21)
### 🐛 Bug Fixes
- **api**: Fix type error in API request and add return URL parameter ([ee286dd](https://github.com/perfect-panel/ppanel-web/commit/ee286dd))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.12](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2025-02-18)
### 🐛 Bug Fixes
- More bugs ([2d88a3a](https://github.com/perfect-panel/ppanel-web/commit/2d88a3a))
# [1.0.0-beta.11](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2025-02-16)
### 🐛 Bug Fixes
- **email**: Update platform configuration handling to use current ref for consistency ([c90175b](https://github.com/perfect-panel/ppanel-web/commit/c90175b))
# [1.0.0-beta.10](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2025-02-16)
### 🐛 Bug Fixes
- **subscribe**: Update forms to include refetch functionality and improve toast messages ([fc55e95](https://github.com/perfect-panel/ppanel-web/commit/fc55e95))
# [1.0.0-beta.9](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2025-02-15)
### 🐛 Bug Fixes
- **register**: Adjust user email verification logic to handle domain suffix checks correctly ([686aa2d](https://github.com/perfect-panel/ppanel-web/commit/686aa2d))
# [1.0.0-beta.8](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.7...v1.0.0-beta.8) (2025-02-15)
### ✨ Features
- **accounts**: Update third-party account binding and unbinding ([1841552](https://github.com/perfect-panel/ppanel-web/commit/1841552))
- **auth-control**: Adding phone number labels to mobile verification configurations in multiple languages ([046740f](https://github.com/perfect-panel/ppanel-web/commit/046740f))
- **auth-control**: Update general ([3883646](https://github.com/perfect-panel/ppanel-web/commit/3883646))
- **auth**: Add type parameter to SendCode and update related API typings ([4198871](https://github.com/perfect-panel/ppanel-web/commit/4198871))
- **auth**: Refactor mobile authentication config to support whitelist functionality ([c761ec7](https://github.com/perfect-panel/ppanel-web/commit/c761ec7))
- **device**: Modify IMEI to device identifier support ([e3f9ef6](https://github.com/perfect-panel/ppanel-web/commit/e3f9ef6))
- **imei**: Add IMEI related internationalization support and menu items ([13c3337](https://github.com/perfect-panel/ppanel-web/commit/13c3337))
- **locales**: Add kick offline confirmation and success messages in multiple languages ([5db5343](https://github.com/perfect-panel/ppanel-web/commit/5db5343))
- **locales**: Update 'sms' to 'mobile' in authentication methods across multiple languages ([fea2171](https://github.com/perfect-panel/ppanel-web/commit/fea2171))
- **log**: Add message log retrieval functionality and update related typings ([1c0ecae](https://github.com/perfect-panel/ppanel-web/commit/1c0ecae))
- **profile**: Update localization strings and enhance third-party account binding ([2d1effb](https://github.com/perfect-panel/ppanel-web/commit/2d1effb))
- **release**: Extend supported platforms for Docker images, closes [#9](https://github.com/perfect-panel/ppanel-web/issues/9) ([e3a31eb](https://github.com/perfect-panel/ppanel-web/commit/e3a31eb))
- **subscription**: Add delete user subscription functionality ([1fc3a10](https://github.com/perfect-panel/ppanel-web/commit/1fc3a10))
- **ui**: Update input components and enhance card minimum width for better layout ([8a02310](https://github.com/perfect-panel/ppanel-web/commit/8a02310))
- **user**: Add user Detail ([3a3d223](https://github.com/perfect-panel/ppanel-web/commit/3a3d223))
- **user**: Add User Detail ([fdaf11b](https://github.com/perfect-panel/ppanel-web/commit/fdaf11b))
### 🐛 Bug Fixes
- **auth-control**: Fix citation error for platform values ([c940f3c](https://github.com/perfect-panel/ppanel-web/commit/c940f3c))
- **auth-control**: Fix citation error for platform values ([28813d2](https://github.com/perfect-panel/ppanel-web/commit/28813d2))
- **auth-control**: Rename phone_variable to phone_number in mobile verification configuration ([e5455aa](https://github.com/perfect-panel/ppanel-web/commit/e5455aa))
- **auth**: Update authentication configuration and localization strings ([47f2c58](https://github.com/perfect-panel/ppanel-web/commit/47f2c58))
- **locales**: Update expiration time description from minutes to seconds in multiple languages ([5bac933](https://github.com/perfect-panel/ppanel-web/commit/5bac933))
- **notify**: Ensure user info is updated after notification settings submission ([9bc3a94](https://github.com/perfect-panel/ppanel-web/commit/9bc3a94))
- **notify**: Set default values for notification settings to false ([3652819](https://github.com/perfect-panel/ppanel-web/commit/3652819))
- **third-party-accounts**: Remove mobile display logic from third-party accounts component ([b4946f7](https://github.com/perfect-panel/ppanel-web/commit/b4946f7))
- **third-party-accounts**: Update redirect property name in binding response handling ([012e83a](https://github.com/perfect-panel/ppanel-web/commit/012e83a))
- **user**: Refactor user form validation and reset password fields ([6733fc2](https://github.com/perfect-panel/ppanel-web/commit/6733fc2))
- **user**: Update locales ([4e7d249](https://github.com/perfect-panel/ppanel-web/commit/4e7d249))
- **user**: Update notification and verify code settings ([574b043](https://github.com/perfect-panel/ppanel-web/commit/574b043))
### 💄 Styles ### 💄 Styles
* **dashboard**: Adjust grid layout and update image dimensions in application display ([f3204b7](https://github.com/perfect-panel/ppanel-web/commit/f3204b7)) - **dashboard**: Enhance card components with full height and improved empty state handling ([7e1d551](https://github.com/perfect-panel/ppanel-web/commit/7e1d551))
* **globals**: Refactor delete confirmation button and update badge styles in node and subscribe tables ([30ae781](https://github.com/perfect-panel/ppanel-web/commit/30ae781))
* Update node secret UI and add telephone code field to authentication form ([770932e](https://github.com/perfect-panel/ppanel-web/commit/770932e)) <a name="readme-top"></a>
# Changelog
# [1.0.0-beta.7](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2025-01-28)
### ♻ Code Refactoring
- **sbscribe**: Rename and reorganize components for better structure and clarity ([5e5e4ed](https://github.com/perfect-panel/ppanel-web/commit/5e5e4ed))
### ✨ Features
- **auth**: Add email and SMS code sending functionality with localization updates ([57eaa55](https://github.com/perfect-panel/ppanel-web/commit/57eaa55))
- **auth**: Add Oauth configuration for Telegram, Facebook, Google, Github, and Apple ([18ee600](https://github.com/perfect-panel/ppanel-web/commit/18ee600))
- **auth**: Add SMS and email configuration options to global store and update localization ([4acf7b1](https://github.com/perfect-panel/ppanel-web/commit/4acf7b1))
- **auth**: Enhance user registration with invite handling and logo display ([207bc24](https://github.com/perfect-panel/ppanel-web/commit/207bc24))
- **auth**: Redirect user after OAuth login and add logos icon collection ([aa6dda8](https://github.com/perfect-panel/ppanel-web/commit/aa6dda8))
- **config**: Add application selection and encryption settings to configuration form ([88b3504](https://github.com/perfect-panel/ppanel-web/commit/88b3504))
- **config**: Update encryption fields in configuration form and refactor OAuth callback parameters ([652e032](https://github.com/perfect-panel/ppanel-web/commit/652e032))
- **global**: Add SMS configuration options to global store ([39a9ce6](https://github.com/perfect-panel/ppanel-web/commit/39a9ce6))
- **locales**: Add area code and telephone fields to user forms in multiple languages ([9b8258c](https://github.com/perfect-panel/ppanel-web/commit/9b8258c))
- **locales**: Add description information of communication keys and encryption methods to enhance client configuration capabilities ([d1f5a9b](https://github.com/perfect-panel/ppanel-web/commit/d1f5a9b))
- **node**: Add tags ([f408fdf](https://github.com/perfect-panel/ppanel-web/commit/f408fdf))
- **node**: Move the node configuration to the server module ([7f0f5ce](https://github.com/perfect-panel/ppanel-web/commit/7f0f5ce))
- **oauth**: Add certification component for handling OAuth login callbacks and improve user authentication flow ([5ed04c0](https://github.com/perfect-panel/ppanel-web/commit/5ed04c0))
- **oauth**: Implement OAuth token retrieval and refactor login callback handling ([40a6f7c](https://github.com/perfect-panel/ppanel-web/commit/40a6f7c))
- **oauth**: Refactor platform parameter handling and improve logout redirection logic ([8346c85](https://github.com/perfect-panel/ppanel-web/commit/8346c85))
- **oauth**: Update OAuth login handling to use callback parameter and improve URL parameter retrieval ([9227411](https://github.com/perfect-panel/ppanel-web/commit/9227411))
- **sms**: Update locales ([938363b](https://github.com/perfect-panel/ppanel-web/commit/938363b))
- **subscribe**: Add 'sold' column to SubscribeTable and update inventory terminology ([19619fd](https://github.com/perfect-panel/ppanel-web/commit/19619fd))
- **subscribe**: Move subscription configuration and application to subscription module ([f90d4d2](https://github.com/perfect-panel/ppanel-web/commit/f90d4d2))
- **subscribe**: Update SubscribeTable component to use API.SubscribeItem type and ensure proper type casting ([f26f1c2](https://github.com/perfect-panel/ppanel-web/commit/f26f1c2))
- **tutorial**: Fetch the latest tutorial version from GitHub API for dynamic URL generation ([28f8c78](https://github.com/perfect-panel/ppanel-web/commit/28f8c78))
- **user**: Add telephone input with area code selection and update localization ([585b99c](https://github.com/perfect-panel/ppanel-web/commit/585b99c))
- Update Auth Control ([c59742a](https://github.com/perfect-panel/ppanel-web/commit/c59742a))
### 🐛 Bug Fixes
- **api**: Remove redundant requestType parameter in appleLoginCallback ([0aa5d5b](https://github.com/perfect-panel/ppanel-web/commit/0aa5d5b))
- **api**: Rename app-related functions and types to application for consistency ([9d8b814](https://github.com/perfect-panel/ppanel-web/commit/9d8b814))
- **api**: Update subscription_protocol to subscribe_type for consistency across services ([b6da51b](https://github.com/perfect-panel/ppanel-web/commit/b6da51b))
- **auth**: Change Textarea value to defaultValue for client_secret in Apple auth page ([69fc670](https://github.com/perfect-panel/ppanel-web/commit/69fc670))
- **auth**: Remove unused telephone code login function and update typings for telephone login requests ([7239685](https://github.com/perfect-panel/ppanel-web/commit/7239685))
- **auth**: Require minimum length for invite string when forced invite is enabled ([a604f28](https://github.com/perfect-panel/ppanel-web/commit/a604f28))
- **auth**: Update user authentication flow to include email and phone code verification ([5d078fd](https://github.com/perfect-panel/ppanel-web/commit/5d078fd))
- **dashboard**: Improve URL encoding for subscription links and enhance success message handling ([4983c33](https://github.com/perfect-panel/ppanel-web/commit/4983c33))
- **dashboard**: Update icon imports for platform consistency and adjust icon size ([3e8912e](https://github.com/perfect-panel/ppanel-web/commit/3e8912e))
- **dashboard**: Update platform detection logic and improve layout responsiveness ([b0aa364](https://github.com/perfect-panel/ppanel-web/commit/b0aa364))
- **deps**: Remove outdated @iconify/react dependency and add iconify-json packages ([d6fbc38](https://github.com/perfect-panel/ppanel-web/commit/d6fbc38))
- **locales**: Add error message for incorrect user information ([52c1d1f](https://github.com/perfect-panel/ppanel-web/commit/52c1d1f))
- **locales**: Add error message for incorrect user information ([3d92902](https://github.com/perfect-panel/ppanel-web/commit/3d92902))
- **locales**: Add logout message to authentication localization files ([1d0d911](https://github.com/perfect-panel/ppanel-web/commit/1d0d911))
- **nav**: Comment out unused social login options to simplify navigation configuration ([cefcb31](https://github.com/perfect-panel/ppanel-web/commit/cefcb31))
- **node-config**: Add null checks for time slots and ensure proper handling of undefined values ([1cdb7e7](https://github.com/perfect-panel/ppanel-web/commit/1cdb7e7))
- **node**: Add country and city fields to the form schema and localization files ([8775fb6](https://github.com/perfect-panel/ppanel-web/commit/8775fb6))
- **oauth**: Refactor OAuth configuration types and update related API methods ([6227ba9](https://github.com/perfect-panel/ppanel-web/commit/6227ba9))
- **oauth**: Remove redundant checks when updating configuration to simplify logic ([9140b8a](https://github.com/perfect-panel/ppanel-web/commit/9140b8a))
- **payment**: Replace window.open with window.location.href for checkout links ([1d8c765](https://github.com/perfect-panel/ppanel-web/commit/1d8c765))
- **phone**: Update SMS expiration time field to use 'sms_expire_time' with default value of 300 ([18b07c7](https://github.com/perfect-panel/ppanel-web/commit/18b07c7))
- **redirect**: Simplify redirect URL logic by removing unnecessary condition for sessionStorage ([c53ac61](https://github.com/perfect-panel/ppanel-web/commit/c53ac61))
- **redirect**: Update redirect URL logic to ensure proper handling of OAuth and auth paths ([7954762](https://github.com/perfect-panel/ppanel-web/commit/7954762))
- **site**: Add image upload functionality for site logo configuration ([4ea6e4a](https://github.com/perfect-panel/ppanel-web/commit/4ea6e4a))
- **sort**: Refactor sorting logic in NodeTable and SubscribeTable components for improved clarity and performance ([331bbea](https://github.com/perfect-panel/ppanel-web/commit/331bbea))
- **subscription**: Add reset functionality for user subscription token ([39e89bf](https://github.com/perfect-panel/ppanel-web/commit/39e89bf))
- **type**: Fix ts type check error ([3cb0629](https://github.com/perfect-panel/ppanel-web/commit/3cb0629))
- **user-nav**: Update user avatar and label to display telephone if email is not available ([7b6bb7b](https://github.com/perfect-panel/ppanel-web/commit/7b6bb7b))
- **user**: Update user identifier field and localizations ([1b6befa](https://github.com/perfect-panel/ppanel-web/commit/1b6befa))
### 💄 Styles
- **dashboard**: Adjust grid layout and update image dimensions in application display ([f3204b7](https://github.com/perfect-panel/ppanel-web/commit/f3204b7))
- **globals**: Refactor delete confirmation button and update badge styles in node and subscribe tables ([30ae781](https://github.com/perfect-panel/ppanel-web/commit/30ae781))
- Update node secret UI and add telephone code field to authentication form ([770932e](https://github.com/perfect-panel/ppanel-web/commit/770932e))
<a name="readme-top"></a> <a name="readme-top"></a>
+13 -4
View File
@@ -5,10 +5,10 @@ import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/
import { Input } from '@workspace/ui/components/input'; import { Input } from '@workspace/ui/components/input';
import { Icon } from '@workspace/ui/custom-components/icon'; import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import CloudFlareTurnstile from '../turnstile'; import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
export default function LoginForm({ export default function LoginForm({
loading, loading,
@@ -38,10 +38,19 @@ export default function LoginForm({
defaultValues: initialValues, defaultValues: initialValues,
}); });
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (error) {
turnstile.current?.reset();
}
});
return ( return (
<> <>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'> <form onSubmit={handleSubmit} className='grid gap-6'>
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
@@ -73,7 +82,7 @@ export default function LoginForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<CloudFlareTurnstile id='login' {...field} /> <CloudFlareTurnstile id='login' {...field} ref={turnstile} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
+26 -12
View File
@@ -6,11 +6,11 @@ import { Input } from '@workspace/ui/components/input';
import { Icon } from '@workspace/ui/custom-components/icon'; import { Icon } from '@workspace/ui/custom-components/icon';
import { Markdown } from '@workspace/ui/custom-components/markdown'; import { Markdown } from '@workspace/ui/custom-components/markdown';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import SendCode from '../send-code'; import SendCode from '../send-code';
import CloudFlareTurnstile from '../turnstile'; import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
export default function RegisterForm({ export default function RegisterForm({
loading, loading,
@@ -31,11 +31,10 @@ export default function RegisterForm({
const handleCheckUser = async (email: string) => { const handleCheckUser = async (email: string) => {
try { try {
if (!auth.email.enable_domain_suffix) return true;
const domain = email.split('@')[1]; const domain = email.split('@')[1];
const isValid = const isValid = auth.email?.domain_suffix_list.split('\n').includes(domain || '');
!auth.email.email_enable_verify || return isValid;
auth.email?.email_domain_suffix_list.split('\n').includes(domain || '');
return !isValid;
} catch (error) { } catch (error) {
console.log('Error checking user:', error); console.log('Error checking user:', error);
return false; return false;
@@ -52,8 +51,8 @@ export default function RegisterForm({
}), }),
password: z.string(), password: z.string(),
repeat_password: z.string(), repeat_password: z.string(),
code: auth.email.email_enable_verify ? z.string() : z.string().nullish(), code: auth.email.enable_verify ? z.string() : z.string().nullish(),
invite: invite.forced_invite ? z.string() : z.string().nullish(), invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key verify.enable_register_verify && verify.turnstile_site_key
? z.string() ? z.string()
@@ -77,13 +76,22 @@ export default function RegisterForm({
}, },
}); });
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (error) {
turnstile.current?.reset();
}
});
return ( return (
<> <>
{auth.register.stop_register ? ( {auth.register.stop_register ? (
<Markdown>{t('message')}</Markdown> <Markdown>{t('message')}</Markdown>
) : ( ) : (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'> <form onSubmit={handleSubmit} className='grid gap-6'>
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
@@ -114,13 +122,18 @@ export default function RegisterForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input placeholder='Enter password again...' type='password' {...field} /> <Input
disabled={loading}
placeholder='Enter password again...'
type='password'
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{auth.email.email_enable_verify && ( {auth.email.enable_verify && (
<FormField <FormField
control={form.control} control={form.control}
name='code' name='code'
@@ -129,6 +142,7 @@ export default function RegisterForm({
<FormControl> <FormControl>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Input <Input
disabled={loading}
placeholder='Enter code...' placeholder='Enter code...'
type='text' type='text'
{...field} {...field}
@@ -172,7 +186,7 @@ export default function RegisterForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<CloudFlareTurnstile id='register' {...field} /> <CloudFlareTurnstile id='register' {...field} ref={turnstile} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
+43 -36
View File
@@ -5,11 +5,11 @@ import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/
import { Input } from '@workspace/ui/components/input'; import { Input } from '@workspace/ui/components/input';
import { Icon } from '@workspace/ui/custom-components/icon'; import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import SendCode from '../send-code'; import SendCode from '../send-code';
import CloudFlareTurnstile from '../turnstile'; import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
export default function ResetForm({ export default function ResetForm({
loading, loading,
@@ -32,7 +32,7 @@ export default function ResetForm({
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email(t('email')), email: z.string().email(t('email')),
password: z.string(), password: z.string(),
code: auth?.email?.email_enable_verify ? z.string() : z.string().nullish(), code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key verify.enable_register_verify && verify.turnstile_site_key
? z.string() ? z.string()
@@ -43,10 +43,19 @@ export default function ResetForm({
defaultValues: initialValues, defaultValues: initialValues,
}); });
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (error) {
turnstile.current?.reset();
}
});
return ( return (
<> <>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'> <form onSubmit={handleSubmit} className='grid gap-6'>
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
@@ -61,45 +70,43 @@ export default function ResetForm({
/> />
<FormField <FormField
control={form.control} control={form.control}
name='password' name='code'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input placeholder='Enter your password...' type='password' {...field} /> <div className='flex items-center gap-2'>
<Input
disabled={loading}
placeholder='Enter code...'
type='text'
{...field}
value={field.value as string}
/>
<SendCode
type='email'
params={{
...form.getValues(),
type: 2,
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder='Enter your new password...' type='password' {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{auth?.email?.email_enable_verify && (
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem>
<FormControl>
<div className='flex items-center gap-2'>
<Input
placeholder='Enter code...'
type='text'
{...field}
value={field.value as string}
/>
<SendCode
type='email'
params={{
...form.getValues(),
type: 2,
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{verify.enable_reset_password_verify && ( {verify.enable_reset_password_verify && (
<FormField <FormField
control={form.control} control={form.control}
@@ -107,7 +114,7 @@ export default function ResetForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<CloudFlareTurnstile id='reset' {...field} /> <CloudFlareTurnstile id='reset' {...field} ref={turnstile} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
+2 -1
View File
@@ -37,10 +37,11 @@ export default function SendCode({ type, params }: SendCodeProps) {
}; };
const getPhoneCode = async () => { const getPhoneCode = async () => {
if (params.telephone && params.telephone_area_code) { if (params.telephone && params.telephone_area_code && params.type) {
await sendSmsCode({ await sendSmsCode({
telephone: params.telephone, telephone: params.telephone,
telephone_area_code: params.telephone_area_code, telephone_area_code: params.telephone_area_code,
type: params.type,
}); });
setTargetDate(Date.now() + 60000); setTargetDate(Date.now() + 60000);
} }
+26 -12
View File
@@ -1,26 +1,38 @@
'use client'; 'use client';
import useGlobalStore from '@/config/use-global';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useEffect } from 'react'; import { forwardRef, useEffect, useImperativeHandle } from 'react';
import Turnstile, { useTurnstile } from 'react-turnstile'; import Turnstile, { useTurnstile } from 'react-turnstile';
export default function CloudFlareTurnstile({ import useGlobalStore from '@/config/use-global';
id,
value, export type TurnstileRef = {
onChange, reset: () => void;
}: { };
id?: string;
value?: null | string; const CloudFlareTurnstile = forwardRef<
onChange: (value?: string) => void; TurnstileRef,
}) { {
id?: string;
value?: null | string;
onChange: (value?: string) => void;
}
>(function CloudFlareTurnstile({ id, value, onChange }, ref) {
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify } = common; const { verify } = common;
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const locale = useLocale(); const locale = useLocale();
const turnstile = useTurnstile(); const turnstile = useTurnstile();
useImperativeHandle(
ref,
() => ({
reset: () => turnstile.reset(),
}),
[turnstile],
);
useEffect(() => { useEffect(() => {
if (value === '') { if (value === '') {
turnstile.reset(); turnstile.reset();
@@ -51,4 +63,6 @@ export default function CloudFlareTurnstile({
/> />
) )
); );
} });
export default CloudFlareTurnstile;
+299
View File
@@ -0,0 +1,299 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { RadioGroup, RadioGroupItem } from '@workspace/ui/components/radio-group';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const formSchema = z.object({
title: z.string(),
type: z.enum(['image', 'video']),
content: z.string(),
description: z.string(),
target_url: z.string().url(),
start_time: z.number(),
end_time: z.number(),
});
interface AdsFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function AdsForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: AdsFormProps<T>) {
const t = useTranslations('ads');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
} as any,
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
const type = form.watch('type');
const startTime = form.watch('start_time');
const renderContentField = () => {
return (
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.content')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={
type === 'image'
? 'https://example.com/image.jpg'
: 'https://example.com/video.mp4'
}
value={field.value}
onValueChange={(value) => {
form.setValue('content', value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]'>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.title')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('form.enterTitle')}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.type')}</FormLabel>
<FormControl>
<RadioGroup
defaultValue={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
className='flex gap-4'
>
<FormItem className='flex items-center space-x-3 space-y-0'>
<FormControl>
<RadioGroupItem value='image' />
</FormControl>
<FormLabel className='font-normal'>{t('form.typeImage')}</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3 space-y-0'>
<FormControl>
<RadioGroupItem value='video' />
</FormControl>
<FormLabel className='font-normal'>{t('form.typeVideo')}</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{renderContentField()}
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.description')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('form.enterDescription')}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='target_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.targetUrl')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('form.enterTargetUrl')}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='start_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.startTime')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
placeholder={t('form.enterStartTime')}
value={field.value ? new Date(field.value).toISOString().slice(0, 16) : ''}
min={Number(new Date().toISOString().slice(0, 16))}
onValueChange={(value) => {
const timestamp = value ? new Date(value).getTime() : 0;
form.setValue(field.name, timestamp);
const endTime = form.getValues('end_time');
if (endTime && timestamp > endTime) {
form.setValue('end_time', '');
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='end_time'
render={({ field }) => {
return (
<FormItem>
<FormLabel>{t('form.endTime')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
placeholder={t('form.enterEndTime')}
value={
field.value ? new Date(field.value).toISOString().slice(0, 16) : ''
}
min={Number(
startTime
? new Date(startTime).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16),
)}
disabled={!startTime}
onValueChange={(value) => {
const timestamp = value ? new Date(value).getTime() : 0;
if (!startTime || timestamp < startTime) return;
form.setValue(field.name, timestamp);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button
variant='outline'
disabled={loading}
onClick={() => {
setOpen(false);
}}
>
{t('form.cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('form.confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+162
View File
@@ -0,0 +1,162 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import { createAds, deleteAds, getAdsList, updateAds } from '@/services/admin/ads';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Switch } from '@workspace/ui/components/switch';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import AdsForm from './ads-form';
export default function Page() {
const t = useTranslations('ads');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Ads, Record<string, unknown>>
action={ref}
header={{
toolbar: (
<AdsForm<API.CreateAdsRequest>
trigger={t('create')}
title={t('createAds')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createAds({
...values,
status: 0,
});
toast.success(t('createSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>
),
}}
params={[
{
key: 'search',
},
{
key: 'status',
placeholder: t('status'),
options: [
{ label: t('enabled'), value: '1' },
{ label: t('disabled'), value: '0' },
],
},
]}
request={async (pagination, filters) => {
const { data } = await getAdsList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
columns={[
{
accessorKey: 'status',
header: t('status'),
cell: ({ row }) => {
return (
<Switch
defaultChecked={row.getValue('status') === 1}
onCheckedChange={async (checked) => {
await updateAds({
...row.original,
status: checked ? 1 : 0,
});
ref.current?.refresh();
}}
/>
);
},
},
{
accessorKey: 'title',
header: t('title'),
},
{
accessorKey: 'type',
header: t('type'),
cell: ({ row }) => {
const type = row.original.type;
return <Badge>{type}</Badge>;
},
},
{
accessorKey: 'target_url',
header: t('targetUrl'),
},
{
accessorKey: 'description',
header: t('form.description'),
},
{
accessorKey: 'period',
header: t('validityPeriod'),
cell: ({ row }) => {
const { start_time, end_time } = row.original;
return (
<>
{formatDate(start_time)} - {formatDate(end_time)}
</>
);
},
},
]}
actions={{
render: (row) => [
<AdsForm<API.UpdateAdsRequest>
key='edit'
trigger={t('edit')}
title={t('editAds')}
loading={loading}
initialValues={row}
onSubmit={async (values) => {
setLoading(true);
try {
await updateAds({ ...row, ...values });
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteAds({ id: row.id });
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
],
}}
/>
);
}
@@ -50,7 +50,7 @@ const defaultValues = {
const versionSchema = z.object({ const versionSchema = z.object({
url: z.string(), url: z.string(),
version: z.string(), version: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
is_default: z.boolean().optional(), is_default: z.boolean().optional(),
}); });
@@ -221,6 +221,7 @@ export default function SubscribeAppForm<
name: 'version', name: 'version',
type: 'text', type: 'text',
placeholder: t('version'), placeholder: t('version'),
defaultValue: '1.0.0',
}, },
{ {
name: 'description', name: 'description',
@@ -231,16 +232,6 @@ export default function SubscribeAppForm<
name: 'is_default', name: 'is_default',
type: 'boolean', type: 'boolean',
placeholder: t('defaultVersion'), placeholder: t('defaultVersion'),
calculateValue: (value) => {
const newField = field.value?.map((item) => {
if (item.is_default) {
item.is_default = false;
}
return item;
});
form.setValue(field.name, newField);
return value;
},
}, },
{ {
name: 'url', name: 'url',
@@ -251,7 +242,32 @@ export default function SubscribeAppForm<
]} ]}
value={field.value} value={field.value}
onChange={(value) => { onChange={(value) => {
form.setValue(field.name, value); const filteredValue = value.filter((item) => item.url);
const newDefaultIndex = value.findIndex(
(item, idx) =>
item.is_default &&
(!field.value?.[idx] || !field.value[idx].is_default),
);
let finalValue = filteredValue;
if (newDefaultIndex >= 0 && filteredValue[newDefaultIndex]) {
finalValue = filteredValue.map((item, index) => ({
...item,
is_default: index === newDefaultIndex,
}));
} else if (
!filteredValue.some((item) => item.is_default) &&
filteredValue.length > 0
) {
finalValue = filteredValue.map((item, index) => ({
...item,
is_default: index === 0,
}));
}
form.setValue(field.name, finalValue as any);
}} }}
/> />
</FormControl> </FormControl>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { getOAuthByPlatform, updateOAuthConfig } from '@/services/admin/system'; import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label'; import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch'; import { Switch } from '@workspace/ui/components/switch';
@@ -14,21 +14,21 @@ export default function Page() {
const t = useTranslations('apple'); const t = useTranslations('apple');
const { data, refetch } = useQuery({ const { data, refetch } = useQuery({
queryKey: ['getOAuthByPlatform', 'apple'], queryKey: ['getAuthMethodConfig', 'apple'],
queryFn: async () => { queryFn: async () => {
const { data } = await getOAuthByPlatform({ const { data } = await getAuthMethodConfig({
platform: 'apple', method: 'apple',
}); });
return data.data; return data.data;
}, },
}); });
async function updateConfig(key: keyof API.UpdateOAuthConfig, value: unknown) { async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try { try {
await updateOAuthConfig({ await updateAuthMethodConfig({
...data, ...data,
[key]: value, [key]: value,
} as API.UpdateOAuthConfig); } as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess')); toast.success(t('saveSuccess'));
refetch(); refetch();
} catch (error) { } catch (error) {
@@ -131,9 +131,14 @@ export default function Page() {
</TableCell> </TableCell>
<TableCell className='text-right'> <TableCell className='text-right'>
<EnhancedInput <EnhancedInput
placeholder='https://your-domain.com/v1/auth/oauth/callback/apple' placeholder='https://your-domain.com'
value={data?.redirect} value={data?.config.redirect_url}
onValueBlur={(value) => updateConfig('redirect', value)} onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
redirect_url: value,
})
}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -0,0 +1,141 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { DicesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { uid } from 'radash';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('device');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'device'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'device',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('showAds')}</Label>
<p className='text-muted-foreground text-xs'>{t('showAdsDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.show_ads}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
show_ads: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('blockVirtualMachine')}</Label>
<p className='text-muted-foreground text-xs'>{t('blockVirtualMachineDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.only_real_device}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
only_real_device: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('enableSecurity')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableSecurityDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.enable_security}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
enable_security: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('communicationKey')}</Label>
<p className='text-muted-foreground text-xs'>{t('communicationKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.security_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
security_secret: value,
});
}}
suffix={
<div className='bg-muted flex h-9 items-center text-nowrap px-3'>
<DicesIcon
onClick={() => {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
updateConfig('config', {
...data?.config,
security_secret: formatted,
});
}}
className='cursor-pointer'
/>
</div>
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -1,6 +1,10 @@
'use client'; 'use client';
import { getEmailSmtpConfig, testEmailSmtp, updateEmailSmtpConfig } from '@/services/admin/system'; import {
getAuthMethodConfig,
testEmailSend,
updateAuthMethodConfig,
} from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { import {
@@ -20,17 +24,20 @@ import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { LogsTable } from '../log';
export default function Page() { export default function Page() {
const t = useTranslations('email'); const t = useTranslations('email');
const ref = useRef<Partial<API.EmailSmtpConfig>>({}); const ref = useRef<Partial<API.AuthMethodConfig>>({});
const [email, setEmail] = useState<string>(); const [email, setEmail] = useState<string>();
const { data, refetch, isFetching } = useQuery({ const { data, refetch, isFetching } = useQuery({
queryKey: ['getEmailSmtpConfig'], queryKey: ['getAuthMethodConfig', 'email'],
queryFn: async () => { queryFn: async () => {
const { data } = await getEmailSmtpConfig(); const { data } = await getAuthMethodConfig({
ref.current = data.data as API.EmailSmtpConfig; method: 'email',
});
ref.current = data.data as API.AuthMethodConfig;
return data.data; return data.data;
}, },
}); });
@@ -38,10 +45,10 @@ export default function Page() {
async function updateConfig(key: string, value: unknown) { async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return; if (data?.[key] === value) return;
try { try {
await updateEmailSmtpConfig({ await updateAuthMethodConfig({
...ref.current, ...ref.current,
[key]: value, [key]: value,
} as API.EmailSmtpConfig); } as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess')); toast.success(t('saveSuccess'));
refetch(); refetch();
} catch (error) { } catch (error) {
@@ -50,103 +57,202 @@ export default function Page() {
} }
return ( return (
<Tabs defaultValue='basic'> <Tabs defaultValue='settings'>
<TabsList> <TabsList className='h-full flex-wrap'>
<TabsTrigger value='basic'>{t('emailBasicConfig')}</TabsTrigger> <TabsTrigger value='settings'>{t('settings')}</TabsTrigger>
<TabsTrigger value='template'>{t('emailTemplate')}</TabsTrigger> <TabsTrigger value='template'>{t('template')}</TabsTrigger>
<TabsTrigger value='logs'>{t('emailLogs')}</TabsTrigger> <TabsTrigger value='logs'>{t('logs')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='basic'> <TabsContent value='settings'>
<Table> <Table>
<TableBody> <TableBody>
{[ <TableRow>
{ <TableCell>
key: 'email_enabled', <Label>{t('enable')}</Label>
label: t('enable'), <p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
description: t('enableDescription'), </TableCell>
component: 'switch', <TableCell className='text-right'>
}, <Switch
{ checked={data?.enabled}
key: 'enable_email_verify', onCheckedChange={(checked) => updateConfig('enabled', checked)}
label: t('emailVerification'), />
description: t('emailVerificationDescription'), </TableCell>
component: 'switch', </TableRow>
}, <TableRow>
{ <TableCell>
key: 'enable_email_domain_suffix', <Label>{t('emailVerification')}</Label>
label: t('emailSuffixWhitelist'), <p className='text-muted-foreground text-xs'>{t('emailVerificationDescription')}</p>
description: t('emailSuffixWhitelistDescription'), </TableCell>
component: 'switch', <TableCell className='text-right'>
}, <Switch
{ checked={data?.config?.enable_verify}
key: 'email_domain_suffix_list', onCheckedChange={(checked) =>
label: t('whitelistSuffixes'), updateConfig('config', { ...data?.config, enable_verify: checked })
description: t('whitelistSuffixesDescription'), }
component: 'textarea', />
}, </TableCell>
{ </TableRow>
key: 'email_smtp_host', <TableRow>
label: t('smtpServerAddress'), <TableCell>
description: t('smtpServerAddressDescription'), <Label>{t('emailSuffixWhitelist')}</Label>
}, <p className='text-muted-foreground text-xs'>
{ {t('emailSuffixWhitelistDescription')}
key: 'email_smtp_port', </p>
label: t('smtpServerPort'), </TableCell>
description: t('smtpServerPortDescription'), <TableCell className='text-right'>
type: 'number', <Switch
}, checked={data?.config?.enable_domain_suffix}
{ onCheckedChange={(checked) =>
key: 'email_smtp_ssl', updateConfig('config', { ...data?.config, enable_domain_suffix: checked })
label: t('smtpEncryptionMethod'), }
description: t('smtpEncryptionMethodDescription'), />
component: 'switch', </TableCell>
}, </TableRow>
{ <TableRow>
key: 'email_smtp_user', <TableCell className='align-top'>
label: t('smtpAccount'), <Label>{t('whitelistSuffixes')}</Label>
description: t('smtpAccountDescription'), <p className='text-muted-foreground text-xs'>{t('whitelistSuffixesDescription')}</p>
}, </TableCell>
{ <TableCell className='text-right'>
key: 'email_smtp_pass', <Textarea
label: t('smtpPassword'), className='h-32'
description: t('smtpPasswordDescription'), placeholder={t('whitelistSuffixesPlaceholder')}
type: 'password', defaultValue={data?.config?.domain_suffix_list}
}, onBlur={(e) =>
{ updateConfig('config', { ...data?.config, domain_suffix_list: e.target.value })
key: 'email_smtp_from', }
label: t('senderAddress'), />
description: t('senderAddressDescription'), </TableCell>
}, </TableRow>
].map(({ key, label, description, type = 'text', component = 'input' }) => ( <TableRow>
<TableRow key={key}> <TableCell>
<TableCell className={component === 'textarea' ? 'align-top' : undefined}> <Label>{t('smtpServerAddress')}</Label>
<Label>{label}</Label> <p className='text-muted-foreground text-xs'>{t('smtpServerAddressDescription')}</p>
<p className='text-muted-foreground text-xs'>{description}</p> </TableCell>
</TableCell> <TableCell className='text-right'>
<TableCell className='text-right'> <EnhancedInput
{component === 'input' ? ( placeholder={t('inputPlaceholder')}
<EnhancedInput value={data?.config?.platform_config?.host}
placeholder={t('inputPlaceholder')} onValueBlur={(value) =>
value={data?.[key]} updateConfig('config', {
type={type} ...data?.config,
onValueBlur={(value) => updateConfig(key, value)} platform_config: {
/> ...data?.platform_config,
) : component === 'switch' ? ( host: value,
<Switch },
checked={data?.[key]} })
onCheckedChange={(checked) => updateConfig(key, checked)} }
/> />
) : ( </TableCell>
<Textarea </TableRow>
className='h-32' <TableRow>
placeholder={t('whitelistSuffixesPlaceholder')} <TableCell>
defaultValue={data?.[key]} <Label>{t('smtpServerPort')}</Label>
onBlur={(e) => updateConfig(key, e.target.value)} <p className='text-muted-foreground text-xs'>{t('smtpServerPortDescription')}</p>
/> </TableCell>
)} <TableCell className='text-right'>
</TableCell> <EnhancedInput
</TableRow> placeholder={t('inputPlaceholder')}
))} value={data?.config?.platform_config?.port}
type='number'
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
port: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpEncryptionMethod')}</Label>
<p className='text-muted-foreground text-xs'>
{t('smtpEncryptionMethodDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.platform_config?.ssl}
onCheckedChange={(checked) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
ssl: checked,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpAccount')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpAccountDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.user}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
user: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpPassword')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpPasswordDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.pass}
type='password'
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
pass: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('senderAddress')}</Label>
<p className='text-muted-foreground text-xs'>{t('senderAddressDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.from}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
from: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
<Label>{t('sendTestEmail')}</Label> <Label>{t('sendTestEmail')}</Label>
@@ -164,7 +270,7 @@ export default function Page() {
onClick={async () => { onClick={async () => {
if (!email) return; if (!email) return;
try { try {
await testEmailSmtp({ email }); await testEmailSend({ email });
toast.success(t('sendSuccess')); toast.success(t('sendSuccess'));
} catch { } catch {
toast.error(t('sendFailure')); toast.error(t('sendFailure'));
@@ -180,30 +286,40 @@ export default function Page() {
</TabsContent> </TabsContent>
<TabsContent value='template'> <TabsContent value='template'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'> <div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
{['verify_email_template', 'expiration_email_template', 'maintenance_email_template'].map( {[
(templateKey) => ( 'verify_email_template',
<Card key={templateKey}> 'expiration_email_template',
<CardHeader> 'maintenance_email_template',
<CardTitle>{t(`${templateKey}`)}</CardTitle> 'traffic_exceed_email_template',
<CardDescription> ].map((templateKey) => (
{t(`${templateKey}Description`, { after: '{{', before: '}}' })} <Card key={templateKey}>
</CardDescription> <CardHeader>
</CardHeader> <CardTitle>{t(`${templateKey}`)}</CardTitle>
<CardContent> <CardDescription>
<HTMLEditor {t(`${templateKey}Description`, { after: '{{', before: '}}' })}
placeholder={t('inputPlaceholder')} </CardDescription>
value={data?.[templateKey as keyof API.EmailSmtpConfig] as string} </CardHeader>
onBlur={(value) => updateConfig(templateKey, value)} <CardContent>
/> <HTMLEditor
</CardContent> placeholder={t('inputPlaceholder')}
</Card> value={data?.config?.[templateKey] as string}
), onBlur={(value) =>
)} updateConfig('config', {
...data?.config,
[templateKey]: value,
})
}
/>
</CardContent>
</Card>
))}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value='logs'>Logs</TabsContent> <TabsContent value='logs'>
<LogsTable type='email' />
</TabsContent>
</Tabs> </Tabs>
); );
} }
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { getOAuthByPlatform, updateOAuthConfig } from '@/services/admin/system'; import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label'; import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch'; import { Switch } from '@workspace/ui/components/switch';
@@ -13,21 +13,21 @@ export default function Page() {
const t = useTranslations('facebook'); const t = useTranslations('facebook');
const { data, refetch } = useQuery({ const { data, refetch } = useQuery({
queryKey: ['getOAuthByPlatform', 'facebook'], queryKey: ['getAuthMethodConfig', 'facebook'],
queryFn: async () => { queryFn: async () => {
const { data } = await getOAuthByPlatform({ const { data } = await getAuthMethodConfig({
platform: 'facebook', method: 'facebook',
}); });
return data.data; return data.data;
}, },
}); });
async function updateConfig(key: keyof API.UpdateOAuthConfig, value: unknown) { async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try { try {
await updateOAuthConfig({ await updateAuthMethodConfig({
...data, ...data,
[key]: value, [key]: value,
} as API.UpdateOAuthConfig); } as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess')); toast.success(t('saveSuccess'));
refetch(); refetch();
} catch (error) { } catch (error) {
@@ -86,19 +86,6 @@ export default function Page() {
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>
<Label>{t('redirectUri')}</Label>
<p className='text-muted-foreground text-xs'>{t('redirectUriDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='https://your-domain.com/v1/auth/oauth/callback/facebook'
value={data?.redirect}
onValueBlur={(value) => updateConfig('redirect', value)}
/>
</TableCell>
</TableRow>
</TableBody> </TableBody>
</Table> </Table>
); );
@@ -0,0 +1,103 @@
'use client';
import { getInviteConfig, updateInviteConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Invite() {
const t = useTranslations('auth-control.invite');
const { data, refetch } = useQuery({
queryKey: ['getInviteConfig'],
queryFn: async () => {
const { data } = await getInviteConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateInviteConfig({
...data,
[key]: value,
} as API.InviteConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card>
<CardHeader>
<CardTitle>{t('inviteSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enableForcedInvite')}</Label>
<p className='text-muted-foreground text-xs'>
{t('enableForcedInviteDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.forced_invite}
onCheckedChange={(checked) => {
updateConfig('forced_invite', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('inviteCommissionPercentage')}</Label>
<p className='text-muted-foreground text-xs'>
{t('inviteCommissionPercentageDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.referral_percentage}
type='number'
min={0}
max={100}
suffix='%'
onValueBlur={(value) => updateConfig('referral_percentage', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('commissionFirstTimeOnly')}</Label>
<p className='text-muted-foreground text-xs'>
{t('commissionFirstTimeOnlyDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.only_first_purchase}
onCheckedChange={(checked) => {
updateConfig('only_first_purchase', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -1,118 +1,17 @@
'use client'; 'use client';
import { getRegisterConfig, updateRegisterConfig } from '@/services/admin/system'; import { Invite } from './invite';
import { useQuery } from '@tanstack/react-query'; import { Register } from './register';
import { Label } from '@workspace/ui/components/label'; import { Verify } from './verify';
import { Switch } from '@workspace/ui/components/switch'; import { VerifyCode } from './verify-code';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() { export default function Page() {
const t = useTranslations('auth-control');
const { data, refetch } = useQuery({
queryKey: ['getRegisterConfig'],
queryFn: async () => {
const { data } = await getRegisterConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateRegisterConfig({
...data,
[key]: value,
} as API.RegisterConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return ( return (
<Table> <div className='space-y-3'>
<TableBody> <Invite />
<TableRow> <Register />
<TableCell> <VerifyCode />
<Label>{t('stopNewUserRegistration')}</Label> <Verify />
<p className='text-muted-foreground text-xs'> </div>
{t('stopNewUserRegistrationDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.stop_register}
onCheckedChange={(checked) => {
updateConfig('stop_register', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('ipRegistrationLimit')}</Label>
<p className='text-muted-foreground text-xs'>{t('ipRegistrationLimitDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_ip_register_limit}
onCheckedChange={(checked) => {
updateConfig('enable_ip_register_limit', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationLimitCount')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationLimitCountDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit}
onValueBlur={(value) => updateConfig('ip_register_limit', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('penaltyTime')}</Label>
<p className='text-muted-foreground text-xs'>{t('penaltyTimeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit_duration}
onValueBlur={(value) => updateConfig('ip_register_limit_duration', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('trialRegistration')}</Label>
<p className='text-muted-foreground text-xs'>{t('trialRegistrationDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_trial}
onCheckedChange={(checked) => {
updateConfig('enable_trial', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
); );
} }
@@ -0,0 +1,201 @@
'use client';
import { getSubscribeList } from '@/services/admin/subscribe';
import { getRegisterConfig, updateRegisterConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Combobox } from '@workspace/ui/custom-components/combobox';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Register() {
const t = useTranslations('auth-control.register');
const { data, refetch } = useQuery({
queryKey: ['getRegisterConfig'],
queryFn: async () => {
const { data } = await getRegisterConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
await updateRegisterConfig({
...data,
[key]: value,
} as API.RegisterConfig);
toast.success(t('saveSuccess'));
refetch();
}
const { data: subscribe } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 9999,
});
return data.data?.list as API.Subscribe[];
},
});
return (
<Card>
<CardHeader>
<CardTitle>{t('registerSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('stopNewUserRegistration')}</Label>
<p className='text-muted-foreground text-xs'>
{t('stopNewUserRegistrationDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.stop_register}
onCheckedChange={(checked) => {
updateConfig('stop_register', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('ipRegistrationLimit')}</Label>
<p className='text-muted-foreground text-xs'>
{t('ipRegistrationLimitDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_ip_register_limit}
onCheckedChange={(checked) => {
updateConfig('enable_ip_register_limit', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationLimitCount')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationLimitCountDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit}
onValueBlur={(value) => updateConfig('ip_register_limit', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('penaltyTime')}</Label>
<p className='text-muted-foreground text-xs'>{t('penaltyTimeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit_duration}
onValueBlur={(value) => updateConfig('ip_register_limit_duration', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('trialRegistration')}</Label>
<p className='text-muted-foreground text-xs'>{t('trialRegistrationDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_trial}
onCheckedChange={(checked) => {
updateConfig('enable_trial', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('trialSubscribePlan')}</Label>
<p className='text-muted-foreground text-xs'>
{t('trialSubscribePlanDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('trialDuration')}
type='number'
min={0}
value={data?.trial_time}
onValueBlur={(value) => updateConfig('trial_time', value)}
prefix={
<Select
value={String(data?.trial_subscribe)}
onValueChange={(value) => updateConfig('trial_subscribe', Number(value))}
>
<SelectTrigger className='bg-secondary rounded-r-none'>
{data?.trial_subscribe ? (
<SelectValue placeholder='Select Subscribe' />
) : (
'Select Subscribe'
)}
</SelectTrigger>
<SelectContent>
{subscribe?.map((item) => (
<SelectItem key={item.id} value={String(item.id)}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
}
suffix={
<Combobox
className='bg-secondary rounded-l-none'
value={data?.trial_time_unit}
onChange={(value) => {
if (value) {
updateConfig('trial_time_unit', value);
}
}}
options={[
{ label: t('noLimit'), value: 'NoLimit' },
{ label: t('year'), value: 'Year' },
{ label: t('month'), value: 'Month' },
{ label: t('day'), value: 'Day' },
{ label: t('hour'), value: 'Hour' },
{ label: t('minute'), value: 'Minute' },
]}
/>
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,95 @@
'use client';
import { getVerifyCodeConfig, updateVerifyCodeConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function VerifyCode() {
const t = useTranslations('auth-control.verify-code');
const { data, refetch } = useQuery({
queryKey: ['getVerifyCodeConfig'],
queryFn: async () => {
const { data } = await getVerifyCodeConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateVerifyCodeConfig({
...data,
[key]: value,
} as API.VerifyCodeConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle>{t('verifyCodeSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('expireTime')}</Label>
<p className='text-muted-foreground text-xs'>{t('expireTimeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='300'
value={data?.verify_code_expire_time}
onValueBlur={(value) => updateConfig('verify_code_expire_time', Number(value))}
suffix={t('second')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('interval')}</Label>
<p className='text-muted-foreground text-xs'>{t('intervalDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='60'
value={data?.verify_code_interval}
onValueBlur={(value) => updateConfig('verify_code_interval', Number(value))}
suffix={t('second')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('dailyLimit')}</Label>
<p className='text-muted-foreground text-xs'>{t('dailyLimitDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='15'
value={data?.verify_code_limit}
onValueBlur={(value) => updateConfig('verify_code_limit', Number(value))}
suffix={t('times')}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,125 @@
'use client';
import { getVerifyConfig, updateVerifyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Verify() {
const t = useTranslations('auth-control.verify');
const { data, refetch } = useQuery({
queryKey: ['getVerifyConfig'],
queryFn: async () => {
const { data } = await getVerifyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateVerifyConfig({
...data,
[key]: value,
} as API.VerifyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle>{t('verifySettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>Turnstile Site Key</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSiteKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.turnstile_site_key}
onValueBlur={(value) => updateConfig('turnstile_site_key', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>Turnstile Site Secret</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.turnstile_secret}
onValueBlur={(value) => updateConfig('turnstile_secret', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_register_verify}
onCheckedChange={(checked) => {
updateConfig('enable_register_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('loginVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('loginVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_login_verify}
onCheckedChange={(checked) => {
updateConfig('enable_login_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('resetPasswordVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('resetPasswordVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_reset_password_verify}
onCheckedChange={(checked) => {
updateConfig('enable_reset_password_verify', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { getOAuthByPlatform, updateOAuthConfig } from '@/services/admin/system'; import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label'; import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch'; import { Switch } from '@workspace/ui/components/switch';
@@ -13,21 +13,21 @@ export default function Page() {
const t = useTranslations('github'); const t = useTranslations('github');
const { data, refetch } = useQuery({ const { data, refetch } = useQuery({
queryKey: ['getOAuthByPlatform', 'github'], queryKey: ['getAuthMethodConfig', 'github'],
queryFn: async () => { queryFn: async () => {
const { data } = await getOAuthByPlatform({ const { data } = await getAuthMethodConfig({
platform: 'github', method: 'github',
}); });
return data.data; return data.data;
}, },
}); });
async function updateConfig(key: keyof API.UpdateOAuthConfig, value: unknown) { async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try { try {
await updateOAuthConfig({ await updateAuthMethodConfig({
...data, ...data,
[key]: value, [key]: value,
} as API.UpdateOAuthConfig); } as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess')); toast.success(t('saveSuccess'));
refetch(); refetch();
} catch (error) { } catch (error) {
@@ -85,19 +85,6 @@ export default function Page() {
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>
<Label>{t('redirectUri')}</Label>
<p className='text-muted-foreground text-xs'>{t('redirectUriDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='https://your-domain.com/v1/auth/oauth/callback/github'
value={data?.redirect}
onValueBlur={(value) => updateConfig('redirect', value)}
/>
</TableCell>
</TableRow>
</TableBody> </TableBody>
</Table> </Table>
); );
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { getOAuthByPlatform, updateOAuthConfig } from '@/services/admin/system'; import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label'; import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch'; import { Switch } from '@workspace/ui/components/switch';
@@ -13,21 +13,21 @@ export default function Page() {
const t = useTranslations('google'); const t = useTranslations('google');
const { data, refetch } = useQuery({ const { data, refetch } = useQuery({
queryKey: ['getOAuthByPlatform', 'google'], queryKey: ['getAuthMethodConfig', 'google'],
queryFn: async () => { queryFn: async () => {
const { data } = await getOAuthByPlatform({ const { data } = await getAuthMethodConfig({
platform: 'google', method: 'google',
}); });
return data.data; return data.data;
}, },
}); });
async function updateConfig(key: keyof API.UpdateOAuthConfig, value: unknown) { async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try { try {
await updateOAuthConfig({ await updateAuthMethodConfig({
...data, ...data,
[key]: value, [key]: value,
} as API.UpdateOAuthConfig); } as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess')); toast.success(t('saveSuccess'));
refetch(); refetch();
} catch (error) { } catch (error) {
@@ -86,19 +86,6 @@ export default function Page() {
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>
<Label>{t('redirectUri')}</Label>
<p className='text-muted-foreground text-xs'>{t('redirectUriDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='https://your-domain.com/v1/auth/oauth/callback/google'
value={data?.redirect}
onValueBlur={(value) => updateConfig('redirect', value)}
/>
</TableCell>
</TableRow>
</TableBody> </TableBody>
</Table> </Table>
); );
@@ -0,0 +1,108 @@
import { ProTable, ProTableActions } from '@/components/pro-table';
import { getMessageLogList } from '@/services/admin/log';
import { Badge } from '@workspace/ui/components/badge';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useRef } from 'react';
export function LogsTable({ type }: { type: 'email' | 'mobile' }) {
const t = useTranslations('auth-control.log');
const ref = useRef<ProTableActions>(null);
return (
<ProTable<
API.MessageLog,
{
platform?: string;
to?: string;
subject?: string;
content?: string;
status?: number;
}
>
action={ref}
header={{
title: t(`${type}Log`),
}}
columns={[
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'platform',
header: t('platform'),
},
{
accessorKey: 'to',
header: t('to'),
},
{
accessorKey: 'subject',
header: t('subject'),
},
{
accessorKey: 'content',
header: t('content'),
},
{
accessorKey: 'status',
header: t('status'),
cell: ({ row }) => {
const status = row.getValue('status');
const text = status === 1 ? t('sendSuccess') : t('sendFailed');
return <Badge variant={status === 1 ? 'default' : 'destructive'}>{text}</Badge>;
},
},
{
accessorKey: 'created_at',
header: t('createdAt'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
{
accessorKey: 'updated_at',
header: t('updatedAt'),
cell: ({ row }) => formatDate(row.getValue('updated_at')),
},
]}
params={[
// {
// key: 'platform',
// placeholder: t('platform'),
// },
{
key: 'to',
placeholder: t('to'),
},
{
key: 'subject',
placeholder: t('subject'),
},
{
key: 'content',
placeholder: t('content'),
},
{
key: 'status',
placeholder: t('status'),
options: [
{ label: t('sendSuccess'), value: '1' },
{ label: t('sendFailed'), value: '0' },
],
},
]}
request={async (pagination, filter) => {
const { data } = await getMessageLogList({
...pagination,
...filter,
status: filter.status === undefined ? undefined : Number(filter.status),
type: type,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -1,15 +1,12 @@
'use client'; 'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import { getSmsList } from '@/services/admin/sms';
import { import {
getSmsConfig, getAuthMethodConfig,
getSmsPlatform, getSmsPlatform,
testSmsSend, testSmsSend,
updateSmsConfig, updateAuthMethodConfig,
} from '@/services/admin/system'; } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Label } from '@workspace/ui/components/label'; import { Label } from '@workspace/ui/components/label';
import { import {
@@ -25,18 +22,21 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/componen
import { Textarea } from '@workspace/ui/components/textarea'; import { Textarea } from '@workspace/ui/components/textarea';
import { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select'; import { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { formatDate } from '@workspace/ui/utils'; import TagInput from '@workspace/ui/custom-components/tag-input';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { useRef, useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { LogsTable } from '../log';
export default function Page() { export default function Page() {
const t = useTranslations('phone'); const t = useTranslations('phone');
const { data, refetch, isFetching } = useQuery({ const { data, refetch, isFetching } = useQuery({
queryKey: ['getSmsConfig'], queryKey: ['getAuthMethodConfig', 'mobile'],
queryFn: async () => { queryFn: async () => {
const { data } = await getSmsConfig(); const { data } = await getAuthMethodConfig({
method: 'mobile',
});
return data.data; return data.data;
}, },
}); });
@@ -49,25 +49,27 @@ export default function Page() {
}, },
}); });
const selectedPlatform = platforms?.find((platform) => platform.platform === data?.sms_platform); const selectedPlatform = platforms?.find(
(platform) => platform.platform === data?.config?.platform,
);
const { platform_url, platform_field_description: platformConfig } = selectedPlatform ?? {}; const { platform_url, platform_field_description: platformConfig } = selectedPlatform ?? {};
async function updateConfig(key: string, value: unknown) { async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return; if (data?.[key] === value) return;
try { try {
await updateSmsConfig({ await updateAuthMethodConfig({
...data, ...data,
[key]: value, [key]: value,
} as API.SmsConfig); } as API.UpdateAuthMethodConfigRequest);
toast.success(t('updateSuccess')); toast.success(t('updateSuccess'));
refetch(); refetch();
} catch (error) { } catch (error) {
/* empty */ /* empty */
} }
} }
const [params, setParams] = useState<API.SendSmsRequest>({ const [params, setParams] = useState<API.TestSmsSendRequest>({
telephone: '', telephone: '',
content: t('testSmsContent'),
area_code: '1', area_code: '1',
}); });
@@ -88,59 +90,38 @@ export default function Page() {
</TableCell> </TableCell>
<TableCell className='text-right'> <TableCell className='text-right'>
<Switch <Switch
checked={data?.sms_enabled} checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('sms_enabled', checked)} onCheckedChange={(checked) => updateConfig('enabled', checked)}
disabled={isFetching} disabled={isFetching}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
<Label>{t('expireTime')}</Label> <Label>{t('whitelistValidation')}</Label>
<p className='text-muted-foreground text-xs'>{t('expireTimeTip')}</p> <p className='text-muted-foreground text-xs'>{t('whitelistValidationTip')}</p>
</TableCell> </TableCell>
<TableCell className='text-right'> <TableCell className='text-right'>
<EnhancedInput <Switch
type='number' defaultValue={data?.config?.enable_whitelist}
min={0} onCheckedChange={(checked) =>
value={data?.sms_expire_time ?? 300} updateConfig('config', { ...data?.config, enable_whitelist: checked })
onValueBlur={(value) => updateConfig('sms_expire_time', value)} }
suffix='S'
disabled={isFetching}
placeholder={t('placeholders.expireTime')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('interval')}</Label>
<p className='text-muted-foreground text-xs'>{t('intervalTip')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
value={data?.sms_interval ?? 60}
min={0}
onValueBlur={(value) => updateConfig('sms_interval', value)}
suffix='S'
disabled={isFetching}
placeholder={t('placeholders.interval')}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
<Label>{t('limit')}</Label> <Label>{t('whitelistAreaCode')}</Label>
<p className='text-muted-foreground text-xs'>{t('limitTip')}</p> <p className='text-muted-foreground text-xs'>{t('whitelistAreaCodeTip')}</p>
</TableCell> </TableCell>
<TableCell className='text-right'> <TableCell className='w-1/2 text-right'>
<EnhancedInput <TagInput
type='number' placeholder='1, 852, 886, 888'
value={data?.sms_limit ?? 20} value={data?.config?.whitelist || []}
min={0} onChange={(value) =>
onValueBlur={(value) => updateConfig('sms_limit', value)} updateConfig('config', { ...data?.config, whitelist: value })
disabled={isFetching} }
placeholder={t('placeholders.limit')}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -151,8 +132,13 @@ export default function Page() {
</TableCell> </TableCell>
<TableCell className='flex items-center gap-1 text-right'> <TableCell className='flex items-center gap-1 text-right'>
<Select <Select
value={data?.sms_platform} value={data?.config?.platform}
onValueChange={(value) => updateConfig('sms_platform', value)} onValueChange={(value) =>
updateConfig('config', {
...data?.config,
platform: value,
})
}
disabled={isFetching} disabled={isFetching}
> >
<SelectTrigger> <SelectTrigger>
@@ -177,91 +163,189 @@ export default function Page() {
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
<Label>Key</Label> <Label>{t('accessLabel')}</Label>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.sms_key })} {t('platformConfigTip', { key: platformConfig?.access })}
</p> </p>
</TableCell> </TableCell>
<TableCell className='text-right'> <TableCell className='text-right'>
<EnhancedInput <EnhancedInput
value={data?.sms_key ?? ''} value={data?.config?.platform_config.access ?? ''}
onValueBlur={(value) => updateConfig('sms_key', value)} onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
access: value,
},
})
}
disabled={isFetching} disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.sms_key })} placeholder={t('platformConfigTip', { key: platformConfig?.access })}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> {platformConfig?.endpoint && (
<TableCell>
<Label>Secret</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.sms_secret })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.sms_secret ?? ''}
onValueBlur={(value) => updateConfig('sms_secret', value)}
disabled={isFetching}
type='password'
placeholder={t('platformConfigTip', { key: platformConfig?.sms_secret })}
/>
</TableCell>
</TableRow>
{platformConfig?.sms_template_code && (
<TableRow> <TableRow>
<TableCell> <TableCell>
<Label>{t('templateCode')}</Label> <Label>{t('endpointLabel')}</Label>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.sms_template_code })} {t('platformConfigTip', { key: platformConfig?.endpoint })}
</p> </p>
</TableCell> </TableCell>
<TableCell className='text-right'> <TableCell className='text-right'>
<EnhancedInput <EnhancedInput
value={data?.sms_template_code ?? ''} value={data?.config?.platform_config.endpoint ?? ''}
onValueBlur={(value) => updateConfig('sms_template_code', value)} onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
endpoint: value,
},
})
}
disabled={isFetching} disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.sms_template_code })} placeholder={t('platformConfigTip', { key: platformConfig?.endpoint })}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{platformConfig?.sms_template_param && ( <TableRow>
<TableCell>
<Label>{t('secretLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.secret })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.secret ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
secret: value,
},
})
}
disabled={isFetching}
type='password'
placeholder={t('platformConfigTip', { key: platformConfig?.secret })}
/>
</TableCell>
</TableRow>
{platformConfig?.template_code && (
<TableRow> <TableRow>
<TableCell> <TableCell>
<Label>{t('templateParam')}</Label> <Label>{t('templateCodeLabel')}</Label>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.sms_template_param })} {t('platformConfigTip', { key: platformConfig?.template_code })}
</p> </p>
</TableCell> </TableCell>
<TableCell className='text-right'> <TableCell className='text-right'>
<EnhancedInput <EnhancedInput
value={data?.sms_template_param ?? 'code'} value={data?.config?.platform_config?.template_code ?? 'code'}
onValueBlur={(value) => updateConfig('sms_template_param', value)} onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
template_code: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.template_code })}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.sign_name && (
<TableRow>
<TableCell>
<Label>{t('signNameLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.sign_name })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.sign_name ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
sign_name: value,
},
})
}
disabled={isFetching} disabled={isFetching}
placeholder={t('platformConfigTip', { placeholder={t('platformConfigTip', {
key: platformConfig?.sms_template_param, key: platformConfig?.sign_name,
})}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.phone_number && (
<TableRow>
<TableCell>
<Label>{t('phoneNumberLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.phone_number })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.phone_number ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
phone_number: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', {
key: platformConfig?.phone_number,
})}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.code_variable && (
<TableRow>
<TableCell>
<Label>{t('template')}</Label>
<p className='text-muted-foreground text-xs'>
{t('templateTip', { code: platformConfig?.code_variable })}
</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
defaultValue={data?.config?.platform_config?.template ?? ''}
onBlur={(e) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
template: e.target.value,
},
})
}
disabled={isFetching}
placeholder={t('placeholders.template', {
code: platformConfig?.code_variable,
})} })}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
<TableRow>
<TableCell>
<Label>{t('template')}</Label>
<p className='text-muted-foreground text-xs'>
{t('templateTip', { code: platformConfig?.sms_template })}
</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
defaultValue={data?.sms_template ?? ''}
onBlur={(e) => updateConfig('sms_template', e.target.value)}
disabled={isFetching}
placeholder={t('placeholders.template', { code: platformConfig?.sms_template })}
/>
</TableCell>
</TableRow>
<TableRow> <TableRow>
<TableCell> <TableCell>
<Label>{t('testSms')}</Label> <Label>{t('testSms')}</Label>
@@ -304,67 +388,8 @@ export default function Page() {
</TabsContent> </TabsContent>
<TabsContent value='logs'> <TabsContent value='logs'>
<LogsTable /> <LogsTable type='mobile' />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
); );
} }
function LogsTable() {
const t = useTranslations('phone');
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Sms, { telephone: string }>
action={ref}
header={{
title: t('SmsList'),
}}
columns={[
{
accessorKey: 'platform',
header: t('platform'),
},
{
accessorKey: 'areaCode',
header: t('areaCode'),
},
{
accessorKey: 'telephone',
header: t('telephone'),
},
{
accessorKey: 'content',
header: t('content'),
},
{
accessorKey: 'status',
header: t('status'),
cell: ({ row }) => {
const status = row.getValue('status');
const text = status === 1 ? t('sendSuccess') : t('sendFailed');
return <Badge variant={status === 1 ? 'default' : 'destructive'}>{text}</Badge>;
},
},
{
accessorKey: 'created_at',
header: t('createdAt'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
params={[
{
key: 'telephone',
placeholder: t('search'),
},
]}
request={async (pagination, filter) => {
const { data } = await getSmsList({ ...pagination, ...filter });
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { getOAuthByPlatform, updateOAuthConfig } from '@/services/admin/system'; import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label'; import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch'; import { Switch } from '@workspace/ui/components/switch';
@@ -13,21 +13,21 @@ export default function Page() {
const t = useTranslations('telegram'); const t = useTranslations('telegram');
const { data, refetch } = useQuery({ const { data, refetch } = useQuery({
queryKey: ['getOAuthByPlatform', 'telegram'], queryKey: ['getAuthMethodConfig', 'telegram'],
queryFn: async () => { queryFn: async () => {
const { data } = await getOAuthByPlatform({ const { data } = await getAuthMethodConfig({
platform: 'telegram', method: 'telegram',
}); });
return data.data; return data.data;
}, },
}); });
async function updateConfig(key: keyof API.UpdateOAuthConfig, value: unknown) { async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try { try {
await updateOAuthConfig({ await updateAuthMethodConfig({
...data, ...data,
[key]: value, [key]: value,
} as API.UpdateOAuthConfig); } as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess')); toast.success(t('saveSuccess'));
refetch(); refetch();
} catch (error) { } catch (error) {
@@ -86,19 +86,6 @@ export default function Page() {
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>
<Label>{t('redirectUri')}</Label>
<p className='text-muted-foreground text-xs'>{t('redirectUriDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='https://your-domain.com/v1/auth/oauth/callback/telegram'
value={data?.redirect}
onValueBlur={(value) => updateConfig('redirect', value)}
/>
</TableCell>
</TableRow>
</TableBody> </TableBody>
</Table> </Table>
); );
@@ -307,6 +307,7 @@ export default function CouponForm<T extends Record<string, any>>({
<EnhancedInput <EnhancedInput
placeholder={t('form.countPlaceholder')} placeholder={t('form.countPlaceholder')}
type='number' type='number'
step={1}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -327,6 +328,7 @@ export default function CouponForm<T extends Record<string, any>>({
<EnhancedInput <EnhancedInput
placeholder={t('form.userLimitPlaceholder')} placeholder={t('form.userLimitPlaceholder')}
type='number' type='number'
step={1}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
+15 -6
View File
@@ -17,7 +17,7 @@ import { cn } from '@workspace/ui/lib/utils';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { UserDetail } from '../user/user-detail'; import { UserDetail } from '../user/user-detail';
export default function Page() { export default function Page(props: any) {
const t = useTranslations('order'); const t = useTranslations('order');
const statusOptions = [ const statusOptions = [
@@ -115,8 +115,8 @@ export default function Page() {
<Separator className='my-4' /> <Separator className='my-4' />
<ul className='grid gap-3'> <ul className='grid gap-3'>
<li className='flex items-center justify-between'> <li className='flex items-center justify-between'>
<span className='text-muted-foreground'></span> <span className='text-muted-foreground'>{t('method')}</span>
<span>{t(`methods.${row.original.method}`)}</span> <span>{row.original?.payment?.name || row.original?.payment?.platform}</span>
</li> </li>
</ul> </ul>
</HoverCardContent> </HoverCardContent>
@@ -177,10 +177,19 @@ export default function Page() {
value: String(item.id), value: String(item.id),
})), })),
}, },
{ key: 'user_id', placeholder: `${t('user')}ID` }, ].concat(
]} props.userId
? []
: [
{
key: 'user_id',
placeholder: `${t('user')} ID`,
options: undefined,
},
],
)}
request={async (pagination, filter) => { request={async (pagination, filter) => {
const { data } = await getOrderList({ ...pagination, ...filter }); const { data } = await getOrderList({ ...pagination, ...filter, user_id: props.userId });
return { return {
list: data.data?.list || [], list: data.data?.list || [],
total: data.data?.total || 0, total: data.data?.total || 0,
@@ -1,256 +0,0 @@
'use client';
import { getAlipayF2FPaymentConfig, updateAlipayF2FPaymentConfig } from '@/services/admin/payment';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function AlipayF2F() {
const t = useTranslations('payment');
const { data, refetch } = useQuery({
queryKey: ['getAlipayF2FPaymentConfig'],
queryFn: async () => {
const { data } = await getAlipayF2FPaymentConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateAlipayF2FPaymentConfig({
...data,
[key]: value,
} as API.UpdateAlipayF2fRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable}
onCheckedChange={(checked) => {
updateConfig('enable', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('alipayf2f.sandbox')}</Label>
<p className='text-muted-foreground text-xs'>{t('alipayf2f.sandboxDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config.sandbox}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
sandbox: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('showName')}</Label>
<p className='text-muted-foreground text-xs'>{t('showNameDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.name}
onValueBlur={(value) => updateConfig('name', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('iconUrl')}</Label>
<p className='text-muted-foreground text-xs'>{t('iconUrlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.icon_url}
onValueBlur={(value) => updateConfig('icon', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('notifyUrl')}</Label>
<p className='text-muted-foreground text-xs'>{t('notifyUrlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.domain}
onValueBlur={(value) => updateConfig('domain', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('feeMode')}</Label>
<p className='text-muted-foreground text-xs'>{t('feeModeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Select
value={String(data?.fee_mode)}
onValueChange={(value) => {
updateConfig('fee_mode', Number(value));
}}
>
<SelectTrigger>
<SelectValue placeholder='请选择' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='0'>{t('feeModeItems.0')}</SelectItem>
<SelectItem value='1'>{t('feeModeItems.1')}</SelectItem>
<SelectItem value='2'>{t('feeModeItems.2')}</SelectItem>
<SelectItem value='3'>{t('feeModeItems.3')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('feePercent')}</Label>
<p className='text-muted-foreground text-xs'>{t('feePercentDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
type='number'
min={0}
max={100}
maxLength={3}
value={data?.fee_percent}
onValueBlur={(value) => updateConfig('fee_percent', value)}
suffix='%'
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('fixedFee')}</Label>
<p className='text-muted-foreground text-xs'>{t('fixedFeeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
min={0}
value={data?.fee_amount}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueBlur={(value) => updateConfig('fee_amount', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('alipayf2f.appId')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.app_id}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
app_id: value,
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('alipayf2f.privateKey')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<Textarea
placeholder={t('inputPlaceholder')}
defaultValue={data?.config.private_key}
onBlur={(e) => {
updateConfig('config', {
...data?.config,
private_key: e.target.value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('alipayf2f.publicKey')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<Textarea
placeholder={t('inputPlaceholder')}
defaultValue={data?.config.public_key}
onBlur={(e) => {
updateConfig('config', {
...data?.config,
public_key: e.target.value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('alipayf2f.invoiceName')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('alipayf2f.invoiceNameDescription')}
value={data?.config.invoice_name}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
invoice_name: value,
})
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
-221
View File
@@ -1,221 +0,0 @@
'use client';
import { getEpayPaymentConfig, updateEpayPaymentConfig } from '@/services/admin/payment';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Epay() {
const t = useTranslations('payment');
const { data, refetch } = useQuery({
queryKey: ['getEpayPaymentConfig'],
queryFn: async () => {
const { data } = await getEpayPaymentConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateEpayPaymentConfig({
...data,
[key]: value,
} as API.UpdateEpayRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable}
onCheckedChange={(checked) => {
updateConfig('enable', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('showName')}</Label>
<p className='text-muted-foreground text-xs'>{t('showNameDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.name}
onValueBlur={(value) => updateConfig('name', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('iconUrl')}</Label>
<p className='text-muted-foreground text-xs'>{t('iconUrlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.icon}
onValueBlur={(value) => updateConfig('icon', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('notifyUrl')}</Label>
<p className='text-muted-foreground text-xs'>{t('notifyUrlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.domain}
onValueBlur={(value) => updateConfig('domain', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('feeMode')}</Label>
<p className='text-muted-foreground text-xs'>{t('feeModeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Select
value={String(data?.fee_mode)}
onValueChange={(value) => {
updateConfig('fee_mode', Number(value));
}}
>
<SelectTrigger>
<SelectValue placeholder='请选择' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='0'>{t('feeModeItems.0')}</SelectItem>
<SelectItem value='1'>{t('feeModeItems.1')}</SelectItem>
<SelectItem value='2'>{t('feeModeItems.2')}</SelectItem>
<SelectItem value='3'>{t('feeModeItems.3')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('feePercent')}</Label>
<p className='text-muted-foreground text-xs'>{t('feePercentDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
type='number'
min={0}
max={100}
maxLength={3}
value={data?.fee_percent}
onValueBlur={(value) => updateConfig('fee_percent', value)}
suffix='%'
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('fixedFee')}</Label>
<p className='text-muted-foreground text-xs'>{t('fixedFeeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
type='number'
min={0}
value={data?.fee_amount}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueBlur={(value) => updateConfig('fee_amount', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('epay.url')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.url}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
url: value,
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('epay.pid')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.pid}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
pid: value,
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('epay.key')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.key}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
key: value,
})
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
+3 -26
View File
@@ -1,34 +1,11 @@
import Billing from '@/components/billing'; import Billing from '@/components/billing';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import PaymentTable from './payment-table';
import AlipayF2F from './alipayf2f';
import Epay from './epay';
import StripeAlipay from './stripe-alipay';
import StripeWeChatPay from './stripe-wechat-pay';
export default async function Page() { export default async function Page() {
return ( return (
<> <>
<Tabs defaultValue='Epay'> <PaymentTable />
<TabsList className='h-full flex-wrap'> <div className='mt-5 flex flex-col gap-3'>
<TabsTrigger value='Epay'>Epay</TabsTrigger>
<TabsTrigger value='Stripe-Alipay'>Stripe(AliPay)</TabsTrigger>
<TabsTrigger value='Strip-WeChatPay'>Stripe(WeChatPay)</TabsTrigger>
<TabsTrigger value='AlipayF2F'>AlipayF2F</TabsTrigger>
</TabsList>
<TabsContent value='Epay'>
<Epay />
</TabsContent>
<TabsContent value='Stripe-Alipay'>
<StripeAlipay />
</TabsContent>
<TabsContent value='Strip-WeChatPay'>
<StripeWeChatPay />
</TabsContent>
<TabsContent value='AlipayF2F'>
<AlipayF2F />
</TabsContent>
</Tabs>
<div className='flex flex-col gap-3'>
<Billing type='payment' /> <Billing type='payment' />
</div> </div>
</> </>
@@ -0,0 +1,419 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { getPaymentPlatform } from '@/services/admin/payment';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { RadioGroup, RadioGroupItem } from '@workspace/ui/components/radio-group';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
interface PaymentFormProps<T> {
trigger: React.ReactNode;
title: string;
loading?: boolean;
initialValues?: T;
onSubmit: (values: T) => Promise<boolean>;
isEdit?: boolean;
}
export default function PaymentForm<T>({
trigger,
title,
loading,
initialValues,
onSubmit,
isEdit,
}: PaymentFormProps<T>) {
const t = useTranslations('payment');
const { common } = useGlobalStore();
const { currency } = common;
const [open, setOpen] = useState(false);
const { data: platformData } = useQuery({
queryKey: ['getPaymentPlatform'],
queryFn: async () => {
const { data } = await getPaymentPlatform();
return data?.data?.list || [];
},
});
const formSchema = z.object({
name: z.string().min(1, { message: t('nameRequired') }),
platform: z.string().optional(),
icon: z.string().optional(),
domain: z.string().optional(),
config: z.any(),
fee_mode: z.coerce.number().min(0).max(2),
fee_percent: z.coerce.number().optional(),
fee_amount: z.coerce.number().optional(),
description: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
platform: '',
icon: '',
domain: '',
config: {},
fee_mode: 0,
fee_percent: 0,
fee_amount: 0,
...(initialValues as any),
},
});
const feeMode = form.watch('fee_mode');
const platformValue = form.watch('platform');
const configValues = form.watch('config');
const currentPlatform = platformData?.find((p) => p.platform === platformValue);
const currentFieldDescriptions = currentPlatform?.platform_field_description || {};
const configFields = Object.keys(currentFieldDescriptions) || [];
const platformUrl = currentPlatform?.platform_url || '';
useEffect(() => {
if (feeMode === 0) {
form.setValue('fee_amount', 0);
form.setValue('fee_percent', 0);
} else if (feeMode === 1) {
form.setValue('fee_amount', 0);
} else if (feeMode === 2) {
form.setValue('fee_percent', 0);
}
}, [feeMode, form]);
const handleClose = () => {
form.reset();
setOpen(false);
};
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const cleanedValues = { ...values };
if (values.fee_mode === 0) {
cleanedValues.fee_amount = undefined;
cleanedValues.fee_percent = undefined;
} else if (values.fee_mode === 1) {
cleanedValues.fee_amount = undefined;
} else if (values.fee_mode === 2) {
cleanedValues.fee_percent = undefined;
}
const success = await onSubmit(cleanedValues as unknown as T);
if (success) {
handleClose();
}
};
const openPlatformUrl = () => {
if (platformUrl) {
window.open(platformUrl, '_blank');
}
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent className='w-[550px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]'>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6 px-6 pt-4'>
{/* 基本信息分组 */}
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('namePlaceholder')}
value={field.value}
onValueChange={(value) => form.setValue('name', value as string)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('icon')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('iconPlaceholder')}
value={field.value}
onValueChange={(value) => form.setValue('icon', value as string)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='domain'
render={({ field }) => (
<FormItem>
<FormLabel>{t('domain')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('domainPlaceholder', { example: 'http(s)://example.com' })}
value={field.value}
onValueChange={(value) => form.setValue('domain', value as string)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='space-y-4'>
<FormField
control={form.control}
name='fee_mode'
render={({ field }) => (
<FormItem>
<FormLabel>{t('handlingFee')}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => field.onChange(parseInt(value))}
value={field.value.toString()}
className='flex flex-wrap gap-4'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='0' />
</FormControl>
<FormLabel className='!mt-0 cursor-pointer'>{t('noFee')}</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='1' />
</FormControl>
<FormLabel className='!mt-0 cursor-pointer'>
{t('percentFee')}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='2' />
</FormControl>
<FormLabel className='!mt-0 cursor-pointer'>{t('fixedFee')}</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{feeMode === 1 && (
<div className='grid grid-cols-1 sm:w-1/2'>
<FormField
control={form.control}
name='fee_percent'
render={({ field }) => (
<FormItem>
<FormLabel>{t('feePercent')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
step='0.01'
suffix='%'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{feeMode === 2 && (
<div className='grid grid-cols-1 sm:w-1/2'>
<FormField
control={form.control}
name='fee_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('feeAmount')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
step='0.01'
prefix={currency.currency_symbol}
suffix={currency.currency_unit}
value={unitConversion('centsToDollars', field.value)}
onValueChange={(value) =>
field.onChange(unitConversion('dollarsToCents', value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<div className='space-y-4'>
{(!platformValue || platformData?.find((p) => p.platform === platformValue)) && (
<FormField
control={form.control}
name='platform'
render={({ field }) => (
<FormItem>
<FormLabel>{t('platform')}</FormLabel>
<Select
onValueChange={(value) => {
form.setValue('platform', value as string);
form.setValue('config', {});
}}
defaultValue={field.value}
value={field.value}
// @ts-expect-error
disabled={isEdit && Boolean(initialValues?.platform)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectPlatform')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{platformData?.map((platform) => (
<SelectItem key={platform.platform} value={platform.platform}>
{platform.platform}
</SelectItem>
))}
</SelectContent>
</Select>
{platformUrl ? (
<div className='mt-1 flex justify-end'>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={openPlatformUrl}
>
<Icon icon='tabler:external-link' className='mr-1 h-3 w-3' />
{t('applyForPayment')}
</Button>
</div>
) : (
<div className='mt-1 h-6'></div>
)}
<FormMessage />
</FormItem>
)}
/>
)}
{configFields.length > 0 && (
<div className='mt-4 space-y-4'>
{configFields.map((fieldKey) => (
<FormItem key={fieldKey}>
<FormLabel>{currentFieldDescriptions[fieldKey]}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('configPlaceholder', {
field: currentFieldDescriptions[fieldKey],
})}
value={
configValues && configValues[fieldKey] !== undefined
? configValues[fieldKey]
: ''
}
onValueChange={(value) => {
const newConfig = { ...configValues };
newConfig[fieldKey] = value;
form.setValue('config', newConfig);
}}
/>
</FormControl>
</FormItem>
))}
</div>
)}
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('description')}</FormLabel>
<FormControl>
<MarkdownEditor
value={field.value}
onChange={(value) => form.setValue(field.name, value as string)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={handleClose}>
{t('cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('submit')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,223 @@
'use client';
import { Display } from '@/components/display';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
createPaymentMethod,
deletePaymentMethod,
getPaymentMethodList,
updatePaymentMethod,
} from '@/services/admin/payment';
import { Avatar, AvatarFallback, AvatarImage } from '@workspace/ui/components/avatar';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Switch } from '@workspace/ui/components/switch';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import PaymentForm from './payment-form';
export default function PaymentTable() {
const t = useTranslations('payment');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.PaymentConfig, { search: string }>
action={ref}
header={{
title: t('paymentManagement'),
toolbar: (
<PaymentForm<API.CreatePaymentMethodRequest>
trigger={<Button>{t('create')}</Button>}
title={t('createPayment')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createPaymentMethod({
...values,
enable: false,
});
toast.success(t('createSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>
),
}}
columns={[
{
accessorKey: 'enable',
header: t('enable'),
cell: ({ row }) => {
return (
<Switch
checked={Boolean(row.getValue('enable'))}
onCheckedChange={async (checked) => {
await updatePaymentMethod({
...row.original,
enable: checked,
});
ref.current?.refresh();
}}
/>
);
},
},
{
accessorKey: 'icon',
header: t('icon'),
cell: ({ row }) => {
const icon = row.getValue('icon') as string;
return (
<Avatar className='h-8 w-8'>
{icon ? <AvatarImage src={icon} alt={row.getValue('name')} /> : null}
<AvatarFallback>
{(row.getValue('name') as string)?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
);
},
},
{
accessorKey: 'name',
header: t('name'),
},
{
accessorKey: 'platform',
header: t('platform'),
cell: ({ row }) => <Badge>{t(row.getValue('platform'))}</Badge>,
},
{
accessorKey: 'notify_url',
header: t('notify_url'),
},
{
accessorKey: 'fee',
header: t('handlingFee'),
cell: ({ row }) => {
const feeMode = row.original.fee_mode;
if (feeMode === 1) {
return <Badge>{row.original.fee_percent}%</Badge>;
} else if (feeMode === 2) {
return (
<Badge>
<Display value={row.original.fee_amount} type='currency' />
</Badge>
);
}
return '--';
},
},
]}
params={[
{
key: 'search',
placeholder: t('searchPlaceholder'),
},
]}
request={async (pagination, filter) => {
const { data } = await getPaymentMethodList({
...pagination,
...filter,
});
return {
list: data?.data?.list || [],
total: data?.data?.total || 0,
};
}}
actions={{
render: (row) => [
<PaymentForm<API.UpdatePaymentMethodRequest>
isEdit
key='edit'
trigger={<Button>{t('edit')}</Button>}
title={t('editPayment')}
loading={loading}
initialValues={row}
onSubmit={async (values) => {
setLoading(true);
try {
await updatePaymentMethod({
...row,
...values,
});
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deletePaymentMethod({
id: row.id,
});
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
<Button
key='copy'
variant='outline'
onClick={async () => {
setLoading(true);
try {
const { id, ...params } = row;
await createPaymentMethod({
...params,
enable: false,
});
toast.success(t('copySuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
>
{t('copy')}
</Button>,
],
batchRender(rows) {
return [
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('batchDelete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
for (const row of rows) {
await deletePaymentMethod({ id: row.id });
}
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
];
},
}}
/>
);
}
@@ -1,232 +0,0 @@
'use client';
import {
getStripeAlipayPaymentConfig,
updateStripeAlipayPaymentConfig,
} from '@/services/admin/payment';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Stripe() {
const t = useTranslations('payment');
const { data, refetch } = useQuery({
queryKey: ['getStripeAlipayPaymentConfig'],
queryFn: async () => {
const { data } = await getStripeAlipayPaymentConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateStripeAlipayPaymentConfig({
...data,
mark: 'stripe_alipay',
[key]: value,
} as any);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('aliPay')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable}
onCheckedChange={async (checked) => {
updateConfig('enable', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('showName')}</Label>
<p className='text-muted-foreground text-xs'>{t('showNameDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.name}
onValueBlur={(value) => {
updateConfig('name', value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('iconUrl')}</Label>
<p className='text-muted-foreground text-xs'>{t('iconUrlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.icon}
onValueBlur={(value) => {
updateConfig('icon', value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('notifyUrl')}</Label>
<p className='text-muted-foreground text-xs'>{t('notifyUrlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.domain}
onValueBlur={(value) => {
updateConfig('domain', value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('feeMode')}</Label>
<p className='text-muted-foreground text-xs'>{t('feeModeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Select
value={String(data?.fee_mode)}
onValueChange={(value) => {
updateConfig('fee_mode', Number(value));
}}
>
<SelectTrigger>
<SelectValue placeholder='请选择' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='0'>{t('feeModeItems.0')}</SelectItem>
<SelectItem value='1'>{t('feeModeItems.1')}</SelectItem>
<SelectItem value='2'>{t('feeModeItems.2')}</SelectItem>
<SelectItem value='3'>{t('feeModeItems.3')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('feePercent')}</Label>
<p className='text-muted-foreground text-xs'>{t('feePercentDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
type='number'
min={0}
max={100}
value={data?.fee_percent}
onValueBlur={(value) => {
updateConfig('fee_percent', value);
}}
suffix='%'
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('fixedFee')}</Label>
<p className='text-muted-foreground text-xs'>{t('fixedFeeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
type='number'
min={0}
value={data?.fee_amount}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueBlur={(value) => updateConfig('fee_amount', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('stripe.publicKey')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.public_key}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
public_key: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('stripe.secretKey')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.secret_key}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
secret_key: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('stripe.webhookSecret')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.webhook_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
webhook_secret: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -1,232 +0,0 @@
'use client';
import {
getStripeWeChatPayPaymentConfig,
updateStripeWeChatPayPaymentConfig,
} from '@/services/admin/payment';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function StripeWeChatPay() {
const t = useTranslations('payment');
const { data, refetch } = useQuery({
queryKey: ['getStripeWeChatPayPaymentConfig'],
queryFn: async () => {
const { data } = await getStripeWeChatPayPaymentConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateStripeWeChatPayPaymentConfig({
...data,
mark: 'stripe_wechat_pay',
[key]: value,
} as any);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('wechatPay')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable}
onCheckedChange={async (checked) => {
updateConfig('enable', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('showName')}</Label>
<p className='text-muted-foreground text-xs'>{t('showNameDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.name}
onValueBlur={(value) => {
updateConfig('name', value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('iconUrl')}</Label>
<p className='text-muted-foreground text-xs'>{t('iconUrlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.icon}
onValueBlur={(value) => {
updateConfig('icon', value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('notifyUrl')}</Label>
<p className='text-muted-foreground text-xs'>{t('notifyUrlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.domain}
onValueBlur={(value) => {
updateConfig('domain', value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('feeMode')}</Label>
<p className='text-muted-foreground text-xs'>{t('feeModeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Select
value={String(data?.fee_mode)}
onValueChange={(value) => {
updateConfig('fee_mode', Number(value));
}}
>
<SelectTrigger>
<SelectValue placeholder='请选择' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='0'>{t('feeModeItems.0')}</SelectItem>
<SelectItem value='1'>{t('feeModeItems.1')}</SelectItem>
<SelectItem value='2'>{t('feeModeItems.2')}</SelectItem>
<SelectItem value='3'>{t('feeModeItems.3')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('feePercent')}</Label>
<p className='text-muted-foreground text-xs'>{t('feePercentDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
type='number'
min={0}
max={100}
value={data?.fee_percent}
onValueBlur={(value) => {
updateConfig('fee_percent', value);
}}
suffix='%'
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('fixedFee')}</Label>
<p className='text-muted-foreground text-xs'>{t('fixedFeeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
type='number'
min={0}
value={data?.fee_amount}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueBlur={(value) => updateConfig('fee_amount', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('stripe.publicKey')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.public_key}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
public_key: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('stripe.secretKey')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.secret_key}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
secret_key: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('stripe.webhookSecret')}</Label>
<p className='text-muted-foreground text-xs' />
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config.webhook_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
webhook_secret: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,215 @@
'use client';
import { createRuleGroup } from '@/services/admin/server';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@workspace/ui/components/dialog';
import { Label } from '@workspace/ui/components/label';
import { Progress } from '@workspace/ui/components/progress';
import { Textarea } from '@workspace/ui/components/textarea';
import { Icon } from '@workspace/ui/custom-components/icon';
import yaml from 'js-yaml';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
interface ImportYamlRulesProps {
onImportSuccess?: () => void;
}
interface RuleGroup {
name: string;
rules: string[];
}
export default function ImportYamlRules({ onImportSuccess }: ImportYamlRulesProps) {
const t = useTranslations('rules');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [yamlContent, setYamlContent] = useState('');
const [importProgress, setImportProgress] = useState(0);
const [importTotal, setImportTotal] = useState(0);
const [analyzing, setAnalyzing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setYamlContent(content);
setOpen(true);
};
reader.readAsText(file);
e.target.value = '';
};
const processRule = (rule: string): { policyGroup: string; cleanRule: string } | null => {
const parts = rule.split(',');
if (parts.length === 1) {
return null;
}
let policyGroup = 'default';
let cleanRule = rule;
if (parts.length >= 3) {
const thirdPart = parts[2]?.trim();
if (thirdPart) {
policyGroup = thirdPart;
}
}
cleanRule = parts.slice(0, 2).join(',');
return { policyGroup, cleanRule };
};
const parseRulesIntoGroups = (rules: string[]): Record<string, string[]> => {
const groups: Record<string, string[]> = {};
for (const rule of rules) {
if (!rule.trim()) continue;
const result = processRule(rule);
if (result === null) continue;
const { policyGroup, cleanRule } = result;
if (!groups[policyGroup]) {
groups[policyGroup] = [];
}
groups[policyGroup].push(cleanRule);
}
return groups;
};
const handleImport = async () => {
if (!yamlContent) {
toast.error(t('pleaseUploadFile'));
return;
}
setLoading(true);
setAnalyzing(true);
try {
const parsedYaml = yaml.load(yamlContent) as any;
if (!parsedYaml || !parsedYaml.rules) {
throw new Error(t('invalidYamlFormat'));
}
let allRules: string[] = [];
if (Array.isArray(parsedYaml.rules)) {
allRules = parsedYaml.rules.filter((rule: string) => rule.trim());
}
if (allRules.length === 0) {
throw new Error(t('noValidRules'));
}
const ruleGroups = parseRulesIntoGroups(allRules);
const groups = Object.entries(ruleGroups).map(([name, rules]) => ({
name,
rules,
}));
setImportTotal(groups.length);
setAnalyzing(false);
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (!group?.name || !group?.rules.length) continue;
await createRuleGroup({
name: group.name,
rules: group?.rules.join('\n'),
enable: false,
tags: [],
icon: '',
});
setImportProgress(i + 1);
}
toast.success(t('importSuccess'));
setOpen(false);
setYamlContent('');
setImportProgress(0);
setImportTotal(0);
onImportSuccess?.();
} catch (error) {
console.error('Import error:', error);
toast.error(error instanceof Error ? error.message : t('importFailed'));
} finally {
setLoading(false);
setAnalyzing(false);
}
};
return (
<>
<input
ref={fileInputRef}
type='file'
accept='.yml,.yaml'
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
<Button variant='default' onClick={() => fileInputRef.current?.click()}>
{t('import')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>{t('importYamlRules')}</DialogTitle>
<DialogDescription>{t('importYamlDescription')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
{yamlContent && (
<div className='grid gap-2'>
<Label htmlFor='preview'>{t('preview')}</Label>
<Textarea
id='preview'
value={yamlContent}
readOnly
rows={10}
className='font-mono text-xs'
/>
</div>
)}
{importTotal > 0 && (
<div className='grid gap-2'>
<div className='flex justify-between text-sm'>
<span>{analyzing ? t('analyzing') : t('importing')}</span>
<span>
{importProgress} / {importTotal}
</span>
</div>
<Progress value={(importProgress / importTotal) * 100} />
</div>
)}
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setOpen(false)} disabled={loading}>
{t('cancel')}
</Button>
<Button onClick={handleImport} disabled={loading || !yamlContent}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('import')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+212
View File
@@ -0,0 +1,212 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
createRuleGroup,
deleteRuleGroup,
getRuleGroupList,
updateRuleGroup,
} from '@/services/admin/server';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Switch } from '@workspace/ui/components/switch';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import Link from 'next/link';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import ImportYamlRules from './import-yaml-rules';
import RuleForm from './rule-form';
export default function Page() {
const t = useTranslations('rules');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.ServerRuleGroup, { query: string }>
action={ref}
header={{
toolbar: (
<div className='flex gap-2'>
<Button variant='default' asChild>
<Link href='/template/rules.yml' target='_blank' download>
{t('downloadTemplate')}
</Link>
</Button>
<ImportYamlRules onImportSuccess={() => ref.current?.refresh()} />
<RuleForm<API.CreateRuleGroupRequest>
trigger={t('create')}
title={t('createRule')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createRuleGroup({
name: values.name,
rules: values.rules || '',
enable: false,
tags: values.tags || [],
icon: values.icon || '',
});
toast.success(t('createSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>
</div>
),
}}
params={[
{
key: 'search',
placeholder: t('searchRule'),
},
]}
request={async (pagination, filters) => {
const { data } = await getRuleGroupList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
columns={[
{
accessorKey: 'enable',
header: t('enable'),
cell: ({ row }) => {
return (
<Switch
defaultChecked={row.getValue('enable')}
onCheckedChange={async (checked) => {
await updateRuleGroup({
...row.original,
enable: checked,
} as API.UpdateRuleGroupRequest);
ref.current?.refresh();
}}
/>
);
},
},
{
accessorKey: 'icon',
header: t('appIcon'),
cell: ({ row }) =>
row.getValue('icon') ? (
<Image
src={row.getValue('icon')}
alt={row.getValue('name')}
className='h-8 w-8 rounded-md'
width={32}
height={32}
/>
) : (
<div className='bg-muted flex h-8 w-8 items-center justify-center rounded-md'>
{row.original.name?.slice(0, 2)}
</div>
),
},
{
accessorKey: 'name',
header: t('name'),
},
{
accessorKey: 'tags',
header: t('tags'),
cell: ({ row }) => {
const tags = row.original.tags.filter((item) => item) || [];
if (!tags.length) return '--';
return (
<>
{tags.map((tag) => (
<Badge key={tag} variant='outline' className='mr-1'>
{tag}
</Badge>
))}
</>
);
},
},
{
accessorKey: 'created_at',
header: t('createdAt'),
cell: ({ row }) => formatDate(row.original.created_at),
},
]}
actions={{
render: (row) => [
<RuleForm<API.UpdateRuleGroupRequest>
key='edit'
trigger={t('edit')}
title={t('editRule')}
loading={loading}
initialValues={row}
onSubmit={async (values) => {
setLoading(true);
try {
await updateRuleGroup({
id: row.id,
name: values.name,
tags: values.tags,
rules: values.rules,
enable: row.enable,
icon: values.icon,
});
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteRuleGroup({ id: row.id });
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
],
batchRender: (rows) => [
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
for (const row of rows) {
await deleteRuleGroup({ id: row.id });
}
toast.success(t('deleteSuccess'));
ref.current?.reset();
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
],
}}
/>
);
}
@@ -0,0 +1,232 @@
'use client';
import { getNodeTagList } from '@/services/admin/server';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Textarea } from '@workspace/ui/components/textarea';
import { Combobox } from '@workspace/ui/custom-components/combobox';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { UploadImage } from '@workspace/ui/custom-components/upload-image';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const formSchema = z.object({
name: z.string().min(1, { message: '请输入规则名称' }),
tags: z.array(z.string()).default([]),
rules: z.string().default(''),
icon: z.string().default(''),
});
interface RuleFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function RuleForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: RuleFormProps<T>) {
const t = useTranslations('rules');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
} as any,
});
useEffect(() => {
if (initialValues) {
form.reset(initialValues);
}
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
const { data: tags } = useQuery({
queryKey: ['getNodeTagList'],
queryFn: async () => {
const { data } = await getNodeTagList();
return data.data?.tags || [];
},
});
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]'>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('appIcon')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('enterIconUrl')}
value={field.value}
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
onChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('enterRuleName')}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tags'
render={({ field }) => (
<FormItem>
<FormLabel>{t('tagsLabel')}</FormLabel>
<FormControl>
<Combobox<string, true>
multiple
placeholder={t('selectTags')}
value={field.value}
onChange={(value) => {
form.setValue(field.name, value);
}}
options={tags?.map((item: string) => ({
value: item,
label: item,
}))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='rules'
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesLabel')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterRules')}
value={field.value}
rows={10}
onChange={(e) => {
form.setValue(field.name, e.target.value);
}}
/>
</FormControl>
<div className='text-muted-foreground mt-1 text-xs'>
<pre>{t('rulesFormat')}</pre>
<div className='border-muted mt-2 space-y-1 border-l-2 pl-2'>
<p className='font-mono'>DOMAIN,example.com</p>
<p className='font-mono'>DOMAIN-SUFFIX,google.com,DIRECT</p>
<p className='font-mono'>DOMAIN-KEYWORD,amazon,REJECT</p>
<p className='font-mono'>IP-CIDR,192.168.0.0/16</p>
<p className='font-mono'>IP-CIDR6,2001:db8::/32,REJECT</p>
<p className='font-mono'>SRC-IP-CIDR,192.168.1.201/32</p>
<p className='font-mono'>GEOIP,CN,DIRECT</p>
<p className='font-mono'>GEOIP,US</p>
<p className='font-mono'>DST-PORT,80,DIRECT</p>
<p className='font-mono'>SRC-PORT,7777,REJECT</p>
<p className='font-mono'>PROCESS-NAME,telegram</p>
<p className='font-mono'>RULE-SET,netflix</p>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button
variant='outline'
disabled={loading}
onClick={() => {
setOpen(false);
}}
>
{t('cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -35,7 +35,7 @@ function getTimeRangeData(slots: API.TimePeriod[]) {
.filter((slot) => slot.start_time && slot.end_time) .filter((slot) => slot.start_time && slot.end_time)
.flatMap((slot) => { .flatMap((slot) => {
const [startH = 0, startM = 0] = slot.start_time.split(':').map(Number); const [startH = 0, startM = 0] = slot.start_time.split(':').map(Number);
const [endH = 0, endM = 0] = slot?.end_time?.split(':').map(Number); const [endH = 0, endM = 0] = slot?.end_time.split(':').map(Number);
const start = startH * 60 + startM; const start = startH * 60 + startM;
let end = endH * 60 + endM; let end = endH * 60 + endM;
if (end < start) end += MINUTES_IN_DAY; if (end < start) end += MINUTES_IN_DAY;
@@ -117,7 +117,7 @@ export default function NodeConfig() {
}, [timeSlots]); }, [timeSlots]);
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
return chartTimeSlots.reduce( return chartTimeSlots?.reduce(
(acc, item, index) => { (acc, item, index) => {
acc[item.name] = { acc[item.name] = {
label: item.name, label: item.name,
@@ -362,9 +362,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
<EnhancedInput <EnhancedInput
{...field} {...field}
type='number' type='number'
placeholder='1-65535' step={1}
min={1} min={1}
max={65535} max={65535}
placeholder='1-65535'
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
}} }}
@@ -418,6 +419,9 @@ export default function NodeForm<T extends { [x: string]: any }>({
<EnhancedInput <EnhancedInput
{...field} {...field}
type='number' type='number'
step={1}
min={1}
max={65535}
placeholder='1-65535' placeholder='1-65535'
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -899,6 +903,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
{ {
name: 'port', name: 'port',
type: 'number', type: 'number',
step: 1,
min: 1, min: 1,
max: 65535, max: 65535,
placeholder: t('form.relayPort'), placeholder: t('form.relayPort'),
+48 -35
View File
@@ -1,5 +1,11 @@
'use client'; 'use client';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@workspace/ui/components/accordion';
import { Badge } from '@workspace/ui/components/badge'; import { Badge } from '@workspace/ui/components/badge';
import { Progress } from '@workspace/ui/components/progress'; import { Progress } from '@workspace/ui/components/progress';
import { ScrollArea } from '@workspace/ui/components/scroll-area'; import { ScrollArea } from '@workspace/ui/components/scroll-area';
@@ -11,6 +17,8 @@ import {
} from '@workspace/ui/components/tooltip'; } from '@workspace/ui/components/tooltip';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { UserSubscribeDetail } from '../user/user-detail';
export function formatPercentage(value: number): string { export function formatPercentage(value: number): string {
return `${value.toFixed(1)}%`; return `${value.toFixed(1)}%`;
@@ -18,25 +26,19 @@ export function formatPercentage(value: number): string {
export function NodeStatusCell({ status }: { status: API.NodeStatus }) { export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
const t = useTranslations('server.node'); const t = useTranslations('server.node');
const [openItem, setOpenItem] = useState<string | null>(null);
const { const { online, cpu, mem, disk, updated_at } = status || {
last_at, online: {},
online_users, cpu: 0,
status: serverStatus, mem: 0,
} = status || { disk: 0,
online_users: [], updated_at: 0,
status: {
cpu: 0,
mem: 0,
disk: 0,
updated_at: 0,
},
last_at: 0,
}; };
const isOnline = last_at > 0; const isOnline = updated_at > 0;
const badgeVariant = isOnline ? 'default' : 'destructive'; const badgeVariant = isOnline ? 'default' : 'destructive';
const badgeText = isOnline ? t('normal') : t('abnormal'); const badgeText = isOnline ? t('normal') : t('abnormal');
const onlineCount = Array.isArray(online_users) ? online_users?.length : 0; const onlineCount = (online && Object.keys(online).length) || 0;
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -52,45 +54,56 @@ export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
<div className='flex flex-col space-y-1'> <div className='flex flex-col space-y-1'>
<div className='flex justify-between'> <div className='flex justify-between'>
<span>CPU</span> <span>CPU</span>
<span>{formatPercentage(serverStatus?.cpu ?? 0)}</span> <span>{formatPercentage(cpu ?? 0)}</span>
</div> </div>
<Progress value={serverStatus?.cpu ?? 0} className='h-2' max={100} /> <Progress value={cpu ?? 0} className='h-2' max={100} />
</div> </div>
<div className='flex flex-col space-y-1'> <div className='flex flex-col space-y-1'>
<div className='flex justify-between'> <div className='flex justify-between'>
<span>{t('memory')}</span> <span>{t('memory')}</span>
<span>{formatPercentage(serverStatus?.mem ?? 0)}</span> <span>{formatPercentage(mem ?? 0)}</span>
</div> </div>
<Progress value={serverStatus?.mem ?? 0} className='h-2' max={100} /> <Progress value={mem ?? 0} className='h-2' max={100} />
</div> </div>
<div className='flex flex-col space-y-1'> <div className='flex flex-col space-y-1'>
<div className='flex justify-between'> <div className='flex justify-between'>
<span>{t('disk')}</span> <span>{t('disk')}</span>
<span>{formatPercentage(serverStatus?.disk ?? 0)}</span> <span>{formatPercentage(disk ?? 0)}</span>
</div> </div>
<Progress value={serverStatus?.disk ?? 0} className='h-2' max={100} /> <Progress value={disk ?? 0} className='h-2' max={100} />
</div> </div>
{isOnline && ( {isOnline && (
<div> <div>
{t('lastUpdated')}: {formatDate(serverStatus?.updated_at ?? 0)} {t('lastUpdated')}: {formatDate(updated_at ?? 0)}
</div> </div>
)} )}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
{isOnline && onlineCount > 0 && ( {isOnline && onlineCount > 0 && (
<TooltipContent className='bg-muted text-foreground w-80'> <TooltipContent className='bg-card text-foreground w-96'>
<div className='space-y-4'> <ScrollArea className='h-[540px] rounded-md border px-4 py-2'>
<div className='space-y-2'> <h4 className='py-1 text-sm font-semibold'>{t('onlineUsers')}</h4>
<h4 className='text-sm font-semibold'>{t('onlineUsers')}</h4> <Accordion
<ScrollArea className='h-[400px] rounded-md border p-2'> type='single'
{online_users.map((user, index) => ( collapsible
<div key={user.uid} className='py-1 text-xs'> className='w-full'
{user.ip} (UID: {user.uid}) onValueChange={(value) => setOpenItem(value)}
</div> >
))} {Object.entries(online).map(([uid, ips]) => (
</ScrollArea> <AccordionItem key={uid} value={uid}>
</div> <AccordionTrigger>{`[UID: ${uid}] - ${ips[0]}`}</AccordionTrigger>
</div> <AccordionContent>
<ul>
{ips.map((ip: string) => (
<li key={ip}>{ip}</li>
))}
</ul>
<UserSubscribeDetail id={Number(uid)} enabled={openItem === uid} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</ScrollArea>
</TooltipContent> </TooltipContent>
)} )}
</Tooltip> </Tooltip>
@@ -2,7 +2,6 @@ import { getTranslations } from 'next-intl/server';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import SubscribeApp from './app/table';
import GroupTable from './group/table'; import GroupTable from './group/table';
import SubscribeConfig from './subscribe-config'; import SubscribeConfig from './subscribe-config';
import SubscribeTable from './subscribe-table'; import SubscribeTable from './subscribe-table';
@@ -16,7 +15,6 @@ export default async function Page() {
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger> <TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger> <TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
<TabsTrigger value='config'>{t('tabs.subscribeConfig')}</TabsTrigger> <TabsTrigger value='config'>{t('tabs.subscribeConfig')}</TabsTrigger>
<TabsTrigger value='app'>{t('tabs.subscribeApp')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='subscribe'> <TabsContent value='subscribe'>
<SubscribeTable /> <SubscribeTable />
@@ -27,9 +25,6 @@ export default async function Page() {
<TabsContent value='config'> <TabsContent value='config'>
<SubscribeConfig /> <SubscribeConfig />
</TabsContent> </TabsContent>
<TabsContent value='app'>
<SubscribeApp />
</TabsContent>
</Tabs> </Tabs>
); );
} }
@@ -30,7 +30,7 @@ export default function SubscribeConfig() {
...data, ...data,
[key]: value, [key]: value,
} as API.SubscribeConfig); } as API.SubscribeConfig);
toast.success(t('saveSuccess')); toast.success(t('updateSuccess'));
refetch(); refetch();
} catch (error) { } catch (error) {
/* empty */ /* empty */
@@ -92,7 +92,7 @@ export default function SubscribeConfig() {
<TableCell className='text-right'> <TableCell className='text-right'>
<Textarea <Textarea
className='h-52' className='h-52'
placeholder={t('subscriptionDomainPlaceholder')} placeholder={`${t('subscriptionDomainPlaceholder')}\nexample.com\nwww.example.com`}
defaultValue={data?.subscribe_domain} defaultValue={data?.subscribe_domain}
onBlur={(e) => { onBlur={(e) => {
updateConfig('subscribe_domain', e.target.value); updateConfig('subscribe_domain', e.target.value);
@@ -105,7 +105,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
server_group: z.array(z.number()).optional().default([]), server_group: z.array(z.number()).optional().default([]),
server: z.array(z.number()).optional().default([]), server: z.array(z.number()).optional().default([]),
deduction_ratio: z.number().optional().default(0), deduction_ratio: z.number().optional().default(0),
purchase_with_discount: z.boolean().optional().default(false), allow_deduction: z.boolean().optional().default(false),
reset_cycle: z.number().optional().default(0), reset_cycle: z.number().optional().default(0),
renewal_reset: z.boolean().optional().default(false), renewal_reset: z.boolean().optional().default(false),
}); });
@@ -157,6 +157,32 @@ export default function SubscribeForm<T extends Record<string, any>>({
}); });
const unit_time = form.watch('unit_time'); const unit_time = form.watch('unit_time');
const unit_price = form.watch('unit_price');
const discounts = form.watch('discount');
useEffect(() => {
if (!discounts?.length || !unit_price) return;
const calculatedValues = discounts.map((item: any) => {
const result = { ...item };
if (item.quantity && item.discount) {
result.price = evaluateWithPrecision(
`${unit_price || 0} * ${item.quantity} * ${item.discount} / 100`,
);
} else if (item.quantity && item.price && !item.discount) {
result.discount = evaluateWithPrecision(
`${item.price} / ${item.quantity} / ${unit_price} * 100`,
);
}
return result;
});
if (JSON.stringify(calculatedValues) !== JSON.stringify(discounts)) {
form.setValue('discount', calculatedValues);
}
}, [unit_price, discounts, form]);
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
@@ -298,6 +324,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
<EnhancedInput <EnhancedInput
placeholder={t('form.noLimit')} placeholder={t('form.noLimit')}
type='number' type='number'
step={1}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -321,7 +348,8 @@ export default function SubscribeForm<T extends Record<string, any>>({
<EnhancedInput <EnhancedInput
placeholder={t('form.noLimit')} placeholder={t('form.noLimit')}
type='number' type='number'
value={field.value === -1 ? 0 : field.value} step={1}
value={field.value}
min={0} min={0}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -343,6 +371,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
<EnhancedInput <EnhancedInput
placeholder={t('form.noLimit')} placeholder={t('form.noLimit')}
type='number' type='number'
step={1}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -540,11 +569,12 @@ export default function SubscribeForm<T extends Record<string, any>>({
<FormItem> <FormItem>
<FormLabel>{t('form.discount')}</FormLabel> <FormLabel>{t('form.discount')}</FormLabel>
<FormControl> <FormControl>
<ArrayInput<API.SubscribeDiscount> <ArrayInput<API.SubscribeDiscount & { price?: number }>
fields={[ fields={[
{ {
name: 'quantity', name: 'quantity',
type: 'number', type: 'number',
step: 1,
min: 1, min: 1,
suffix: unit_time && t(`form.${unit_time}`), suffix: unit_time && t(`form.${unit_time}`),
}, },
@@ -555,38 +585,56 @@ export default function SubscribeForm<T extends Record<string, any>>({
max: 100, max: 100,
placeholder: t('form.discountPercent'), placeholder: t('form.discountPercent'),
suffix: '%', suffix: '%',
calculateValue: function (data) {
const { unit_price } = form.getValues();
return {
...data,
price: evaluateWithPrecision(
`${unit_price} * ${data.quantity} * ${data.discount} / 100`,
),
};
},
}, },
{ {
name: 'price', name: 'price',
placeholder: t('form.discount_price'), placeholder: t('form.discount_price'),
type: 'number', type: 'number',
min: 0,
formatInput: (value) => unitConversion('centsToDollars', value), formatInput: (value) => unitConversion('centsToDollars', value),
formatOutput: (value) => unitConversion('dollarsToCents', value), formatOutput: (value) => unitConversion('dollarsToCents', value),
internal: true,
calculateValue: (data) => {
const { unit_price } = form.getValues();
return {
...data,
discount: evaluateWithPrecision(
`${data.price} / ${data.quantity} / ${unit_price} * 100`,
),
};
},
}, },
]} ]}
value={field.value} value={field.value}
onChange={(value) => { onChange={(newValues) => {
form.setValue(field.name, value); const oldValues = field.value || [];
const { unit_price } = form.getValues();
const calculatedValues = newValues.map((newItem, index) => {
const oldItem = oldValues[index] || {};
const result = { ...newItem };
const quantityChanged = newItem.quantity !== oldItem.quantity;
const discountChanged = newItem.discount !== oldItem.discount;
const priceChanged = newItem.price !== oldItem.price;
if (
(quantityChanged || discountChanged) &&
!priceChanged &&
newItem.quantity &&
newItem.discount
) {
result.price = evaluateWithPrecision(
`${unit_price || 0} * ${newItem.quantity} * ${newItem.discount} / 100`,
);
}
if (
priceChanged &&
!discountChanged &&
newItem.price &&
newItem.quantity &&
unit_price
) {
result.discount = evaluateWithPrecision(
`${newItem.price} / ${newItem.quantity} / ${unit_price} * 100`,
);
}
return result;
});
form.setValue(field.name, calculatedValues);
}} }}
/> />
</FormControl> </FormControl>
@@ -639,7 +687,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
/> />
<FormField <FormField
control={form.control} control={form.control}
name='purchase_with_discount' name='allow_deduction'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
@@ -1,93 +0,0 @@
'use client';
import { getInviteConfig, updateInviteConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Invite() {
const t = useTranslations('system.invite');
const { data, refetch } = useQuery({
queryKey: ['getInviteConfig'],
queryFn: async () => {
const { data } = await getInviteConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateInviteConfig({
...data,
[key]: value,
} as API.InviteConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enableForcedInvite')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableForcedInviteDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.forced_invite}
onCheckedChange={(checked) => {
updateConfig('forced_invite', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('inviteCommissionPercentage')}</Label>
<p className='text-muted-foreground text-xs'>
{t('inviteCommissionPercentageDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.referral_percentage}
type='number'
min={0}
max={100}
suffix='%'
onValueBlur={(value) => updateConfig('referral_percentage', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('commissionFirstTimeOnly')}</Label>
<p className='text-muted-foreground text-xs'>
{t('commissionFirstTimeOnlyDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.only_first_purchase}
onCheckedChange={(checked) => {
updateConfig('only_first_purchase', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
+5 -16
View File
@@ -1,12 +1,9 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import Currency from './currency'; import Currency from './currency';
import Invite from './invite'; import PrivacyPolicy from './privacy-policy';
import Site from './site'; import Site from './site';
import Telegram from './telegram';
import Tos from './tos'; import Tos from './tos';
import Verify from './verify';
export default async function Page() { export default async function Page() {
const t = await getTranslations('system'); const t = await getTranslations('system');
@@ -16,10 +13,8 @@ export default async function Page() {
<TabsList className='h-full flex-wrap'> <TabsList className='h-full flex-wrap'>
<TabsTrigger value='site'>{t('tabs.site')}</TabsTrigger> <TabsTrigger value='site'>{t('tabs.site')}</TabsTrigger>
<TabsTrigger value='currency'>{t('tabs.currency')}</TabsTrigger> <TabsTrigger value='currency'>{t('tabs.currency')}</TabsTrigger>
<TabsTrigger value='verify'>{t('tabs.verify')}</TabsTrigger>
<TabsTrigger value='invite'>{t('tabs.invite')}</TabsTrigger>
<TabsTrigger value='telegram'>{t('tabs.telegram')}</TabsTrigger>
<TabsTrigger value='tos'>{t('tabs.tos')}</TabsTrigger> <TabsTrigger value='tos'>{t('tabs.tos')}</TabsTrigger>
<TabsTrigger value='privacy-policy'>{t('privacy-policy.title')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='site'> <TabsContent value='site'>
<Site /> <Site />
@@ -27,18 +22,12 @@ export default async function Page() {
<TabsContent value='currency'> <TabsContent value='currency'>
<Currency /> <Currency />
</TabsContent> </TabsContent>
<TabsContent value='verify'>
<Verify />
</TabsContent>
<TabsContent value='invite'>
<Invite />
</TabsContent>
<TabsContent value='telegram'>
<Telegram />
</TabsContent>
<TabsContent value='tos'> <TabsContent value='tos'>
<Tos /> <Tos />
</TabsContent> </TabsContent>
<TabsContent value='privacy-policy'>
<PrivacyPolicy />
</TabsContent>
</Tabs> </Tabs>
); );
} }
@@ -0,0 +1,48 @@
'use client';
import { getPrivacyPolicyConfig, updatePrivacyPolicyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function PrivacyPolicy() {
const t = useTranslations('system.privacy-policy');
const { data, refetch, isFetched } = useQuery({
queryKey: ['getPrivacyPolicyConfig'],
queryFn: async () => {
const { data } = await getPrivacyPolicyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updatePrivacyPolicyConfig({
...data,
[key]: value,
} as API.PrivacyPolicyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
isFetched && (
<div className='h-[calc(100dvh-132px-env(safe-area-inset-top))] overflow-hidden'>
<MarkdownEditor
title={t('title')}
value={data?.privacy_policy}
onBlur={(value) => {
if (data?.privacy_policy !== value) {
updateConfig('privacy_policy', value);
}
}}
/>
</div>
)
);
}
+78 -3
View File
@@ -5,18 +5,21 @@ import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label'; import { Label } from '@workspace/ui/components/label';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table'; import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Textarea } from '@workspace/ui/components/textarea'; import { Textarea } from '@workspace/ui/components/textarea';
import { JSONEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { UploadImage } from '@workspace/ui/custom-components/upload-image'; import { UploadImage } from '@workspace/ui/custom-components/upload-image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function Site() { export default function Site() {
const t = useTranslations('system.site'); const t = useTranslations('system.site');
const ref = useRef<API.SiteConfig | undefined>(undefined);
const { data, refetch } = useQuery({ const { data, refetch } = useQuery({
queryKey: ['getSiteConfig'], queryKey: ['getSiteConfig'],
queryFn: async () => { queryFn: async () => {
const { data } = await getSiteConfig(); const { data } = await getSiteConfig();
ref.current = data.data;
return data.data; return data.data;
}, },
}); });
@@ -25,7 +28,7 @@ export default function Site() {
if (data?.[key] === value) return; if (data?.[key] === value) return;
try { try {
await updateSiteConfig({ await updateSiteConfig({
...data, ...ref.current,
[key]: value, [key]: value,
} as API.SiteConfig); } as API.SiteConfig);
toast.success(t('saveSuccess')); toast.success(t('saveSuccess'));
@@ -85,6 +88,35 @@ export default function Site() {
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>
<Label>{t('keywords')}</Label>
<p className='text-muted-foreground text-xs'>{t('keywordsDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('keywordsDescription')}
value={data?.keywords}
onValueBlur={(value) => updateConfig('keywords', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('customHtml')}</Label>
<p className='text-muted-foreground text-xs'>{t('customHtmlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-52'
placeholder={t('customHtmlDescription')}
defaultValue={data?.custom_html}
onBlur={(e) => {
updateConfig('custom_html', e.target.value);
}}
/>
</TableCell>
</TableRow>
<TableRow> <TableRow>
<TableCell className='align-top'> <TableCell className='align-top'>
<Label>{t('siteDomain')}</Label> <Label>{t('siteDomain')}</Label>
@@ -93,7 +125,7 @@ export default function Site() {
<TableCell className='text-right'> <TableCell className='text-right'>
<Textarea <Textarea
className='h-52' className='h-52'
placeholder={t('siteDomainPlaceholder')} placeholder={`${t('siteDomainPlaceholder')}\nexample.com\nwww.example.com`}
defaultValue={data?.host} defaultValue={data?.host}
onBlur={(e) => { onBlur={(e) => {
updateConfig('host', e.target.value); updateConfig('host', e.target.value);
@@ -101,6 +133,49 @@ export default function Site() {
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>
<Label>{t('customData')}</Label>
<p className='text-muted-foreground text-xs'>{t('customDataDescription')}</p>
</TableCell>
<TableCell className='w-1/2 text-right'>
<JSONEditor
schema={{
type: 'object',
additionalProperties: true,
properties: {
website: { type: 'string', title: 'Website' },
contacts: {
type: 'object',
title: 'Contacts',
additionalProperties: true,
properties: {
email: { type: 'string', title: 'Email' },
telephone: { type: 'string', title: 'Telephone' },
address: { type: 'string', title: 'Address' },
},
},
community: {
type: 'object',
title: 'Community',
additionalProperties: true,
properties: {
telegram: { type: 'string', title: 'Telegram' },
twitter: { type: 'string', title: 'Twitter' },
discord: { type: 'string', title: 'Discord' },
instagram: { type: 'string', title: 'Instagram' },
linkedin: { type: 'string', title: 'Linkedin' },
facebook: { type: 'string', title: 'Facebook' },
github: { type: 'string', title: 'Github' },
},
},
},
}}
value={data?.custom_data}
onBlur={(value) => updateConfig('custom_data', value)}
/>
</TableCell>
</TableRow>
</TableBody> </TableBody>
</Table> </Table>
); );
@@ -1,99 +0,0 @@
'use client';
import { getTelegramConfig, updateTelegramConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Telegram() {
const t = useTranslations('system.telegram');
const { data, refetch } = useQuery({
queryKey: ['getTelegramConfig'],
queryFn: async () => {
const { data } = await getTelegramConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateTelegramConfig({
...data,
[key]: value,
} as API.TelegramConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('webhookDomain')}</Label>
<p className='text-muted-foreground text-xs'>{t('webhookDomainDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholderWebhookDomain')}
value={data?.telegram_web_hook_domain}
onValueBlur={(value) => updateConfig('telegram_web_hook_domain', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('botToken')}</Label>
<p className='text-muted-foreground text-xs'>{t('botTokenDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholderBotToken')}
value={data?.telegram_bot_token}
onValueBlur={(value) => updateConfig('telegram_bot_token', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('enableBotNotifications')}</Label>
<p className='text-muted-foreground text-xs'>
{t('enableBotNotificationsDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.telegram_notify}
onCheckedChange={(checked) => {
updateConfig('telegram_notify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('groupURL')}</Label>
<p className='text-muted-foreground text-xs'>{t('groupURLDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholderGroupURL')}
value={data?.telegram_group_url}
onValueBlur={(value) => updateConfig('telegram_group_url', value)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
-115
View File
@@ -1,115 +0,0 @@
'use client';
import { getVerifyConfig, updateVerifyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Verify() {
const t = useTranslations('system.verify');
const { data, refetch } = useQuery({
queryKey: ['getVerifyConfig'],
queryFn: async () => {
const { data } = await getVerifyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateVerifyConfig({
...data,
[key]: value,
} as API.VerifyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('turnstileSiteKey')}</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSiteKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
defaultValue={data?.turnstile_site_key}
onValueBlur={(value) => updateConfig('turnstile_site_key', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('turnstileSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
defaultValue={data?.turnstile_secret}
onValueBlur={(value) => updateConfig('turnstile_secret', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_register_verify}
onCheckedChange={(checked) => {
updateConfig('enable_register_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('loginVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>{t('loginVerificationCodeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_login_verify}
onCheckedChange={(checked) => {
updateConfig('enable_login_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('resetPasswordVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('resetPasswordVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_reset_password_verify}
onCheckedChange={(checked) => {
updateConfig('enable_reset_password_verify', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
+4 -5
View File
@@ -10,7 +10,6 @@ import {
} from '@workspace/ui/components/accordion'; } from '@workspace/ui/components/accordion';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
@@ -67,7 +66,7 @@ export default function Page() {
<CardDescription>{t('viewLogsAndManage')}</CardDescription> <CardDescription>{t('viewLogsAndManage')}</CardDescription>
</div> </div>
<div className='mt-4 flex flex-col space-y-2 sm:mt-0 sm:flex-row sm:space-x-2 sm:space-y-0'> <div className='mt-4 flex flex-col space-y-2 sm:mt-0 sm:flex-row sm:space-x-2 sm:space-y-0'>
<AlertDialog> {/* <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button>{t('systemUpgrade')}</Button> <Button>{t('systemUpgrade')}</Button>
</AlertDialogTrigger> </AlertDialogTrigger>
@@ -81,7 +80,7 @@ export default function Page() {
<AlertDialogAction>{t('confirmUpgrade')}</AlertDialogAction> <AlertDialogAction>{t('confirmUpgrade')}</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog> */}
<AlertDialog open={openRestart} onOpenChange={setOpenRestart}> <AlertDialog open={openRestart} onOpenChange={setOpenRestart}>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant='destructive'>{t('systemReboot')}</Button> <Button variant='destructive'>{t('systemReboot')}</Button>
@@ -113,14 +112,14 @@ export default function Page() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='space-y-6'> <div className='space-y-6'>
<div className='flex items-center justify-between'> {/* <div className='flex items-center justify-between'>
<div className='text-lg font-semibold'> <div className='text-lg font-semibold'>
{t('currentVersion')} <span>V1.0.0</span> {t('currentVersion')} <span>V1.0.0</span>
</div> </div>
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
{t('lastUpdated')} <span>2024-12-16 12:00:00</span> {t('lastUpdated')} <span>2024-12-16 12:00:00</span>
</div> </div>
</div> </div> */}
<Card className='overflow-hidden'> <Card className='overflow-hidden'>
<CardHeader className='bg-secondary py-1'> <CardHeader className='bg-secondary py-1'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
@@ -0,0 +1,33 @@
import UserOrderList from '@/app/dashboard/order/page';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { getTranslations } from 'next-intl/server';
import UserLoginHistory from './user-login-history';
import { UserProfileForm } from './user-profile';
import UserSubscription from './user-subscription';
export default async function Page({ params }: { params: Promise<{ id: number }> }) {
const t = await getTranslations('user');
const { id } = await params;
return (
<Tabs defaultValue='profile'>
<TabsList>
<TabsTrigger value='profile'>{t('userProfile')}</TabsTrigger>
<TabsTrigger value='subscriptions'>{t('userSubscriptions')}</TabsTrigger>
<TabsTrigger value='orders'>{t('userOrders')}</TabsTrigger>
<TabsTrigger value='logs'>{t('userLogs')}</TabsTrigger>
</TabsList>
<TabsContent value='profile'>
<UserProfileForm />
</TabsContent>
<TabsContent value='subscriptions'>
<UserSubscription userId={id} />
</TabsContent>
<TabsContent value='orders'>
<UserOrderList userId={id} />
</TabsContent>
<TabsContent value='logs'>
<UserLoginHistory />
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,53 @@
'use client';
import { ProTable } from '@/components/pro-table';
import { getUserLoginLogs } from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
export default function UserLoginHistory() {
const t = useTranslations('user');
const { id } = useParams<{ id: string }>();
return (
<ProTable<API.UserLoginLog, Record<string, unknown>>
columns={[
{
accessorKey: 'success',
header: t('loginStatus'),
cell: ({ row }) => (
<Badge variant={row.getValue('success') ? 'default' : 'destructive'}>
{row.getValue('success') ? t('success') : t('failed')}
</Badge>
),
},
{
accessorKey: 'login_ip',
header: t('loginIp'),
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'created_at',
header: t('loginTime'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
request={async (pagination, filter) => {
const { data } = await getUserLoginLogs({
user_id: Number(id),
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,140 @@
'use client';
import {
createUserAuthMethod,
deleteUserAuthMethod,
updateUserAuthMethod,
} from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
export function AuthMethodsForm({ user, refetch }: { user: API.User; refetch: () => void }) {
const t = useTranslations('user');
const [emailChanges, setEmailChanges] = useState<Record<string, string>>({});
const handleRemoveAuth = async (authType: string) => {
await deleteUserAuthMethod({
user_id: user.id,
auth_type: authType,
});
toast.success(t('deleteSuccess'));
};
const handleUpdateEmail = async (email: string) => {
await updateUserAuthMethod({
user_id: user.id,
auth_type: 'email',
auth_identifier: email,
});
toast.success(t('updateSuccess'));
refetch();
};
const handleCreateEmail = async (email: string) => {
await createUserAuthMethod({
user_id: user.id,
auth_type: 'email',
auth_identifier: email,
});
toast.success(t('createSuccess'));
refetch();
};
const handleEmailChange = (authType: string, value: string) => {
setEmailChanges((prev) => ({
...prev,
[authType]: value,
}));
};
const emailMethod = user.auth_methods.find((method) => method.auth_type === 'email');
const otherMethods = user.auth_methods.filter((method) => method.auth_type !== 'email');
const defaultEmailMethod = {
auth_type: 'email',
auth_identifier: '',
verified: false,
...emailMethod,
};
const isEmailExists = !!emailMethod;
const handleEmailAction = () => {
const email = emailChanges['email'];
if (isEmailExists) {
handleUpdateEmail(email as string);
} else {
handleCreateEmail(email as string);
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t('authMethodsTitle')}</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-6'>
<Card className='border-none shadow-none'>
<CardContent className='space-y-3 p-0'>
<div className='flex items-center justify-between'>
<div className='font-medium uppercase'>email</div>
<Badge variant={defaultEmailMethod.verified ? 'default' : 'destructive'}>
{defaultEmailMethod.verified ? t('verified') : t('unverified')}
</Badge>
</div>
<div className='flex items-center gap-2'>
<div className='flex-1'>
<EnhancedInput
value={defaultEmailMethod.auth_identifier}
placeholder={t('pleaseEnterEmail')}
onValueChange={(value) => handleEmailChange('email', value as string)}
/>
</div>
<Button
onClick={handleEmailAction}
disabled={
!emailChanges['email'] ||
(isEmailExists && emailChanges['email'] === defaultEmailMethod.auth_identifier)
}
>
{isEmailExists ? t('update') : t('add')}
</Button>
</div>
</CardContent>
</Card>
{otherMethods.map((method) => (
<Card key={method.auth_type} className='border-none shadow-none'>
<CardContent className='space-y-3 p-0'>
<div className='flex items-center justify-between'>
<div className='font-medium uppercase'>{method.auth_type}</div>
<Badge variant={method.verified ? 'default' : 'destructive'}>
{method.verified ? t('verified') : t('unverified')}
</Badge>
</div>
<div className='flex items-center gap-4'>
<div className='flex-1'>
<div className='text-muted-foreground text-sm'>{method.auth_identifier}</div>
</div>
<Button
variant='destructive'
size='sm'
onClick={() => handleRemoveAuth(method.auth_type)}
>
{t('remove')}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,262 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { updateUserBasicInfo } from '@/services/admin/user';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { UploadImage } from '@workspace/ui/custom-components/upload-image';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import * as z from 'zod';
const basicInfoSchema = z.object({
avatar: z.string().optional(),
balance: z.number().optional(),
commission: z.number().optional(),
gift_amount: z.number().optional(),
refer_code: z.string().optional(),
referer_id: z.number().optional(),
is_admin: z.boolean().optional(),
password: z.string().optional(),
enable: z.boolean(),
});
type BasicInfoValues = z.infer<typeof basicInfoSchema>;
export function BasicInfoForm({ user, refetch }: { user: API.User; refetch: () => void }) {
const t = useTranslations('user');
const { common } = useGlobalStore();
const { currency } = common;
const form = useForm<BasicInfoValues>({
resolver: zodResolver(basicInfoSchema),
defaultValues: {
avatar: user.avatar,
balance: user.balance,
commission: user.commission,
gift_amount: user.gift_amount,
refer_code: user.refer_code,
referer_id: user.referer_id,
is_admin: user.is_admin,
enable: user.enable,
},
});
async function onSubmit(data: BasicInfoValues) {
await updateUserBasicInfo({
user_id: user.id,
telegram: user.telegram,
...data,
} as API.UpdateUserBasiceInfoRequest);
toast.success(t('updateSuccess'));
refetch();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle>{t('basicInfoTitle')}</CardTitle>
<Button type='submit' size='sm'>
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<FormField
control={form.control}
name='enable'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('accountEnable')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='is_admin'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('administrator')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<div className='grid grid-cols-3 gap-4'>
<FormField
control={form.control}
name='balance'
render={({ field }) => (
<FormItem>
<FormLabel>{t('balance')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
min={0}
value={field.value}
prefix={currency?.currency_symbol ?? '$'}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='commission'
render={({ field }) => (
<FormItem>
<FormLabel>{t('commission')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
min={0}
value={field.value}
prefix={currency?.currency_symbol ?? '$'}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='gift_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('giftAmount')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
min={0}
value={field.value}
prefix={currency?.currency_symbol ?? '$'}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='refer_code'
render={({ field }) => (
<FormItem>
<FormLabel>{t('referralCode')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='referer_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('referrerUserId')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='avatar'
render={({ field }) => (
<FormItem>
<FormLabel>{t('avatar')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
returnType='base64'
onChange={(value) => form.setValue('avatar', value as string)}
/>
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input type='password' placeholder={t('passwordPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</form>
</Form>
);
}
@@ -0,0 +1,38 @@
'use client';
import { getUserDetail } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { AuthMethodsForm } from './auth-methods-form';
import { BasicInfoForm } from './basic-info-form';
import { NotifySettingsForm } from './notify-settings-form';
export function UserProfileForm() {
const { id } = useParams<{ id: string }>();
const { data: user, refetch } = useQuery({
queryKey: ['user', id],
queryFn: async () => {
const { data } = await getUserDetail({
id: Number(id),
});
return data.data;
},
});
if (!user) return null;
return (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<div className='md:col-span-2 xl:col-span-1'>
<BasicInfoForm user={user} refetch={refetch} />
</div>
<div>
<NotifySettingsForm user={user} refetch={refetch} />
</div>
<div>
<AuthMethodsForm user={user} refetch={refetch} />
</div>
</div>
);
}
@@ -0,0 +1,114 @@
'use client';
import { updateUserNotifySetting } from '@/services/admin/user';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@workspace/ui/components/form';
import { Switch } from '@workspace/ui/components/switch';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import * as z from 'zod';
const notifySettingsSchema = z.object({
enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(),
});
type NotifySettingsValues = z.infer<typeof notifySettingsSchema>;
export function NotifySettingsForm({ user, refetch }: { user: API.User; refetch: () => void }) {
const t = useTranslations('user');
const form = useForm<NotifySettingsValues>({
resolver: zodResolver(notifySettingsSchema),
defaultValues: {
enable_balance_notify: user.enable_balance_notify,
enable_login_notify: user.enable_login_notify,
enable_subscribe_notify: user.enable_subscribe_notify,
enable_trade_notify: user.enable_trade_notify,
},
});
async function onSubmit(data: NotifySettingsValues) {
await updateUserNotifySetting({
...data,
user_id: user.id,
});
toast.success(t('updateSuccess'));
refetch();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle>{t('notifySettingsTitle')}</CardTitle>
<Button type='submit' size='sm'>
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid grid-cols-1 gap-4'>
<FormField
control={form.control}
name='enable_balance_notify'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('balanceNotifications')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_login_notify'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('loginNotifications')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_subscribe_notify'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('subscriptionNotifications')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_trade_notify'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('tradeNotifications')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</form>
</Form>
);
}
@@ -0,0 +1,166 @@
'use client';
import { Display } from '@/components/display';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
createUserSubscribe,
deleteUserSubscribe,
getUserSubscribe,
updateUserSubscribe,
} from '@/services/admin/user';
import { Button } from '@workspace/ui/components/button';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { SubscriptionDetail } from './subscription-detail';
import { SubscriptionForm } from './subscription-form';
export default function UserSubscription({ userId }: { userId: number }) {
const t = useTranslations('user');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.UserSubscribe, Record<string, unknown>>
action={ref}
header={{
title: t('subscriptionList'),
toolbar: (
<SubscriptionForm
key='create'
trigger={t('add')}
title={t('createSubscription')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await createUserSubscribe({
user_id: Number(userId),
...values,
});
toast.success(t('createSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
}}
/>
),
}}
columns={[
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'name',
header: t('subscriptionName'),
cell: ({ row }) => row.original.subscribe.name,
},
{
accessorKey: 'upload',
header: t('upload'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('upload')} />,
},
{
accessorKey: 'download',
header: t('download'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('download')} />,
},
{
accessorKey: 'traffic',
header: t('totalTraffic'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('traffic')} unlimited />,
},
{
accessorKey: 'speed_limit',
header: t('speedLimit'),
cell: ({ row }) => {
const speed = row.original?.subscribe?.speed_limit;
return <Display type='trafficSpeed' value={speed} />;
},
},
{
accessorKey: 'device_limit',
header: t('deviceLimit'),
cell: ({ row }) => {
const limit = row.original?.subscribe?.device_limit;
return <Display type='number' value={limit} unlimited />;
},
},
{
accessorKey: 'reset_time',
header: t('resetTime'),
cell: ({ row }) => {
return <Display type='number' value={row.getValue('reset_time')} unlimited />;
},
},
{
accessorKey: 'expire_time',
header: t('expireTime'),
cell: ({ row }) =>
row.getValue('expire_time') ? formatDate(row.getValue('expire_time')) : t('permanent'),
},
{
accessorKey: 'created_at',
header: t('createdAt'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribe({
user_id: userId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
actions={{
render: (row) => {
return [
<SubscriptionForm
key='edit'
trigger={t('edit')}
title={t('editSubscription')}
loading={loading}
initialData={row}
onSubmit={async (values) => {
setLoading(true);
await updateUserSubscribe({
user_id: Number(userId),
user_subscribe_id: row.id,
...values,
});
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
}}
/>,
<SubscriptionDetail
key='detail'
trigger={<Button variant='secondary'>{t('detail')}</Button>}
userId={userId}
subscriptionId={row.id}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteSubscriptionDescription')}
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
];
},
}}
/>
);
}
@@ -0,0 +1,213 @@
'use client';
import { Display } from '@/components/display';
import { ProTable } from '@/components/pro-table';
import {
getUserSubscribeDevices,
getUserSubscribeLogs,
getUserSubscribeTrafficLogs,
kickOfflineByUserDevice,
} from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@workspace/ui/components/dialog';
import { Switch } from '@workspace/ui/components/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { ReactNode, useState } from 'react';
import { toast } from 'sonner';
export function SubscriptionDetail({
trigger,
userId,
subscriptionId,
}: {
trigger: ReactNode;
userId: number;
subscriptionId: number;
}) {
const t = useTranslations('user');
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className='max-w-5xl'>
<DialogHeader>
<DialogTitle>{t('subscriptionDetails')}</DialogTitle>
</DialogHeader>
<div className='mt-4'>
<Tabs defaultValue='logs'>
<TabsList className='w-full'>
<TabsTrigger value='logs' className='flex-1'>
{t('subscriptionLogs')}
</TabsTrigger>
<TabsTrigger value='traffic' className='flex-1'>
{t('trafficLogs')}
</TabsTrigger>
<TabsTrigger value='devices' className='flex-1'>
{t('onlineDevices')}
</TabsTrigger>
</TabsList>
<div className='mt-4 max-h-[60dvh] overflow-y-auto'>
<TabsContent value='logs'>
<ProTable<API.UserSubscribeLog, Record<string, unknown>>
columns={[
{
accessorKey: 'ip',
header: 'IP',
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'token',
header: t('token'),
},
{
accessorKey: 'created_at',
header: t('time'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeLogs({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</TabsContent>
<TabsContent value='traffic'>
<ProTable<API.TrafficLog, Record<string, unknown>>
columns={[
{
accessorKey: 'download',
header: t('download'),
cell: ({ row }) => (
<Display type='traffic' value={row.getValue('download')} />
),
},
{
accessorKey: 'upload',
header: t('upload'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('upload')} />,
},
{
accessorKey: 'timestamp',
header: t('time'),
cell: ({ row }) => formatDate(row.getValue('timestamp')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeTrafficLogs({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
} as API.GetUserSubscribeTrafficLogsParams);
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</TabsContent>
<TabsContent value='devices'>
<ProTable<API.UserDevice, Record<string, unknown>>
columns={[
{
accessorKey: 'enabled',
header: t('enable'),
cell: ({ row }) => (
<Switch
checked={row.getValue('enabled')}
onChange={(checked) => {
console.log('Switch:', checked);
}}
/>
),
},
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'identifier',
header: 'IMEI',
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'ip',
header: 'IP',
},
{
accessorKey: 'online',
header: t('loginStatus'),
cell: ({ row }) => (
<Badge variant={row.getValue('online') ? 'default' : 'destructive'}>
{row.getValue('online') ? t('online') : t('offline')}
</Badge>
),
},
{
accessorKey: 'updated_at',
header: t('lastSeen'),
cell: ({ row }) => formatDate(row.getValue('updated_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeDevices({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
actions={{
render: (row) => {
if (!row.identifier) return [];
return [
<ConfirmButton
key='offline'
trigger={<Button variant='destructive'>{t('confirmOffline')}</Button>}
title={t('confirmOffline')}
description={t('kickOfflineConfirm', { ip: row.ip })}
onConfirm={async () => {
await kickOfflineByUserDevice({ id: row.id });
toast.success(t('kickOfflineSuccess'));
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
];
},
}}
/>
</TabsContent>
</div>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,240 @@
'use client';
import { getSubscribeList } from '@/services/admin/subscribe';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Combobox } from '@workspace/ui/custom-components/combobox';
import { DatePicker } from '@workspace/ui/custom-components/date-picker';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { ReactNode, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
interface Props {
trigger: ReactNode;
title: string;
loading?: boolean;
initialData?: API.UserSubscribe;
onSubmit: (values: any) => Promise<boolean>;
}
const formSchema = z.object({
subscribe_id: z.number().optional(),
traffic: z.number().optional(),
speed_limit: z.number().optional(),
device_limit: z.number().optional(),
expired_at: z.number().nullish().optional(),
});
export function SubscriptionForm({ trigger, title, loading, initialData, onSubmit }: Props) {
const t = useTranslations('user');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
subscribe_id: initialData?.subscribe_id || 0,
traffic: initialData?.traffic || 0,
upload: initialData?.upload || 0,
download: initialData?.download || 0,
expired_at: initialData?.expire_time || 0,
...(initialData && { id: initialData.id }),
},
});
const handleSubmit = async (values: any) => {
const success = await onSubmit(values);
if (success) {
setOpen(false);
form.reset();
}
};
const { data: subscribe } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 9999,
});
return data.data?.list as API.Subscribe[];
},
});
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent side='right'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='mt-4 space-y-4'>
<FormField
control={form.control}
name='subscribe_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('subscription')}</FormLabel>
<FormControl>
<Combobox<number, false>
placeholder='Select Subscription'
value={field.value}
onChange={(value) => {
form.setValue(field.name, value);
}}
options={subscribe?.map((item: API.Subscribe) => ({
value: item.id,
label: item.name,
}))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='traffic'
render={({ field }) => (
<FormItem>
<FormLabel>{t('trafficLimit')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('unlimited')}
type='number'
{...field}
formatInput={(value) => unitConversion('bytesToGb', value)}
formatOutput={(value) => unitConversion('gbToBytes', value)}
suffix='GB'
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='upload'
render={({ field }) => (
<FormItem>
<FormLabel>{t('uploadTraffic')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='0'
type='number'
{...field}
formatInput={(value) => unitConversion('bytesToGb', value)}
formatOutput={(value) => unitConversion('gbToBytes', value)}
suffix='GB'
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='download'
render={({ field }) => (
<FormItem>
<FormLabel>{t('downloadTraffic')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='0'
type='number'
{...field}
formatInput={(value) => unitConversion('bytesToGb', value)}
formatOutput={(value) => unitConversion('gbToBytes', value)}
suffix='GB'
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_at'
render={({ field }) => (
<FormItem>
<FormLabel>{t('expiredAt')}</FormLabel>
<FormControl>
<DatePicker
placeholder={t('permanent')}
value={field.value}
onChange={(value) => {
if (value === field.value) {
form.setValue(field.name, 0);
} else {
form.setValue(field.name, value!);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button
variant='outline'
disabled={loading}
onClick={() => {
setOpen(false);
}}
>
{t('cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+74 -33
View File
@@ -2,12 +2,16 @@
import { Display } from '@/components/display'; import { Display } from '@/components/display';
import { ProTable, ProTableActions } from '@/components/pro-table'; import { ProTable, ProTableActions } from '@/components/pro-table';
import { createUser, deleteUser, getUserList, updateUser } from '@/services/admin/user'; import { getSubscribeList } from '@/services/admin/subscribe';
import { createUser, deleteUser, getUserList, updateUserBasicInfo } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Switch } from '@workspace/ui/components/switch'; import { Switch } from '@workspace/ui/components/switch';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button'; import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { UserDetail } from './user-detail'; import { UserDetail } from './user-detail';
@@ -18,8 +22,19 @@ export default function Page() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null); const ref = useRef<ProTableActions>(null);
const { data: subscribeList } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 9999,
});
return data.data?.list as API.SubscribeGroup[];
},
});
return ( return (
<ProTable<API.User, Record<string, unknown>> <ProTable<API.User, API.GetUserListParams>
action={ref} action={ref}
header={{ header={{
title: t('userList'), title: t('userList'),
@@ -56,10 +71,23 @@ export default function Page() {
<Switch <Switch
defaultChecked={row.getValue('enable')} defaultChecked={row.getValue('enable')}
onCheckedChange={async (checked) => { onCheckedChange={async (checked) => {
await updateUser({ const {
...row.original, auth_methods,
user_devices,
enable_balance_notify,
enable_login_notify,
enable_subscribe_notify,
enable_trade_notify,
updated_at,
created_at,
id,
...rest
} = row.original;
await updateUserBasicInfo({
user_id: id,
...rest,
enable: checked, enable: checked,
} as unknown as API.UpdateUserRequest); } as unknown as API.UpdateUserBasiceInfoRequest);
toast.success(t('updateSuccess')); toast.success(t('updateSuccess'));
ref.current?.refresh(); ref.current?.refresh();
}} }}
@@ -74,7 +102,17 @@ export default function Page() {
{ {
accessorKey: 'auth_methods', accessorKey: 'auth_methods',
header: t('userName'), header: t('userName'),
cell: ({ row }) => row.original.auth_methods?.[0]?.auth_identifier || '--', cell: ({ row }) => {
const method = row.original.auth_methods?.[0];
return (
<div>
<Badge className='mr-1 uppercase' title={method?.verified ? t('verified') : ''}>
{method?.auth_type}
</Badge>
{method?.auth_identifier}
</div>
);
},
}, },
{ {
accessorKey: 'balance', accessorKey: 'balance',
@@ -107,41 +145,44 @@ export default function Page() {
cell: ({ row }) => formatDate(row.getValue('created_at')), cell: ({ row }) => formatDate(row.getValue('created_at')),
}, },
]} ]}
request={async (pagination) => { request={async (pagination, filter) => {
const { data } = await getUserList(pagination); const { data } = await getUserList({
...pagination,
...filter,
});
return { return {
list: data.data?.list || [], list: data.data?.list || [],
total: data.data?.total || 0, total: data.data?.total || 0,
}; };
}} }}
params={[
{
key: 'subscribe_id',
placeholder: t('subscription'),
options: subscribeList?.map((item) => ({
label: item.name,
value: String(item.id),
})),
},
{
key: 'search',
placeholder: 'Search',
},
{
key: 'user_id',
placeholder: t('userId'),
},
{
key: 'user_subscribe_id',
placeholder: t('subscriptionId'),
},
]}
actions={{ actions={{
render: (row) => { render: (row) => {
return [ return [
<UserForm<API.UpdateUserRequest> <Button key='detail' asChild>
key='edit' <Link href={`/dashboard/user/${row.id}`}>{t('edit')}</Link>
trigger={t('edit')} </Button>,
title={t('editUser')}
loading={loading}
initialValues={row as unknown as API.UpdateUserRequest}
onSubmit={async (values) => {
setLoading(true);
try {
await updateUser({
...row,
...values,
});
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton <ConfirmButton
key='edit' key='edit'
trigger={<Button variant='destructive'>{t('delete')}</Button>} trigger={<Button variant='destructive'>{t('delete')}</Button>}
+113 -8
View File
@@ -1,14 +1,121 @@
'use client'; 'use client';
import { Display } from '@/components/display'; import { Display } from '@/components/display';
import { getUserDetail } from '@/services/admin/user'; import { getUserDetail, getUserSubscribeById } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@workspace/ui/components/hover-card'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@workspace/ui/components/hover-card';
import { formatDate } from '@workspace/ui/utils'; import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
export function UserSubscribeDetail({ id, enabled }: { id: number; enabled: boolean }) {
const t = useTranslations('user');
const { data } = useQuery({
enabled: id !== 0 && enabled,
queryKey: ['getUserSubscribeById', id],
queryFn: async () => {
const { data } = await getUserSubscribeById({ id });
return data.data;
},
});
if (!id) return '--';
const usedTraffic = data ? data.upload + data.download : 0;
const totalTraffic = data?.traffic || 0;
return (
<div className='space-y-4'>
<div>
<h3 className='mb-2 text-sm font-medium'>{t('subscriptionInfo')}</h3>
<div className='bg-muted/30 rounded-lg p-3'>
<ul className='grid gap-3'>
<li className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('subscriptionId')}</span>
<span>{data?.id || '--'}</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('subscriptionName')}</span>
<span>{data?.subscribe?.name || '--'}</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('token')}</span>
<div className='font-mono text-xs' title={data?.token || ''}>
{data?.token || '--'}
</div>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('trafficUsage')}</span>
<span>
{data
? totalTraffic === 0
? `${formatBytes(usedTraffic)} / ${t('unlimited')}`
: `${formatBytes(usedTraffic)} / ${formatBytes(totalTraffic)}`
: '--'}
</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('startTime')}</span>
<span>{data?.start_time ? formatDate(data.start_time) : '--'}</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('expireTime')}</span>
<span>{data?.expire_time ? formatDate(data.expire_time) : '--'}</span>
</li>
</ul>
</div>
</div>
<div>
<h3 className='mb-2 text-sm font-medium'>
{t('userInfo')}
{data?.user_id && (
<Button
variant='link'
size='sm'
className='text-primary ml-2 h-auto p-0 text-xs'
asChild
>
<Link href={`/dashboard/user/${data.user_id}`}>{t('viewDetails')}</Link>
</Button>
)}
</h3>
<ul className='grid gap-3'>
<li className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('userId')}</span>
<span>{data?.user_id}</span>
</li>
<li className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('balance')}</span>
<span>
<Display type='currency' value={data?.user.balance} />
</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('giftAmount')}</span>
<span>
<Display type='currency' value={data?.user?.gift_amount} />
</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('commission')}</span>
<span>
<Display type='currency' value={data?.user?.commission} />
</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('createdAt')}</span>
<span>{data?.user?.created_at && formatDate(data?.user?.created_at)}</span>
</li>
</ul>
</div>
</div>
);
}
export function UserDetail({ id }: { id: number }) { export function UserDetail({ id }: { id: number }) {
const t = useTranslations('user'); const t = useTranslations('user');
const [shouldFetch, setShouldFetch] = useState(false); const [shouldFetch, setShouldFetch] = useState(false);
@@ -27,8 +134,10 @@ export function UserDetail({ id }: { id: number }) {
return ( return (
<HoverCard> <HoverCard>
<HoverCardTrigger asChild onMouseEnter={() => setShouldFetch(true)}> <HoverCardTrigger asChild onMouseEnter={() => setShouldFetch(true)}>
<Button variant='link' className='p-0'> <Button variant='link' className='p-0' asChild>
@{data?.email?.split('@')[0] || 'Loading...'} <Link href={`/dashboard/user/${id}`}>
{data?.auth_methods[0]?.auth_identifier || t('loading')}
</Link>
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent> <HoverCardContent>
@@ -38,10 +147,6 @@ export function UserDetail({ id }: { id: number }) {
<span className='text-muted-foreground'>ID</span> <span className='text-muted-foreground'>ID</span>
<span>{data?.id}</span> <span>{data?.id}</span>
</li> </li>
<li className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('email')}</span>
<span>{data?.email}</span>
</li>
<li className='flex items-center justify-between font-semibold'> <li className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('balance')}</span> <span className='text-muted-foreground'>{t('balance')}</span>
<span> <span>
+21 -22
View File
@@ -52,7 +52,7 @@ export default function UserForm<T extends Record<string, any>>({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email(t('form.invalidEmailFormat')), email: z.string().email(t('invalidEmailFormat')),
telephone_area_code: z.string().optional(), telephone_area_code: z.string().optional(),
telephone: z.string().optional(), telephone: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
@@ -104,10 +104,10 @@ export default function UserForm<T extends Record<string, any>>({
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.userEmail')}</FormLabel> <FormLabel>{t('userEmail')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={t('form.userEmailPlaceholder')} placeholder={t('userEmailPlaceholder')}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -124,7 +124,7 @@ export default function UserForm<T extends Record<string, any>>({
name='telephone' name='telephone'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.telephone')}</FormLabel> <FormLabel>{t('telephone')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
prefix={ prefix={
@@ -137,7 +137,7 @@ export default function UserForm<T extends Record<string, any>>({
<AreaCodeSelect <AreaCodeSelect
className='w-32 rounded-none border-y-0 border-l-0' className='w-32 rounded-none border-y-0 border-l-0'
simple simple
placeholder={t('form.areaCodePlaceholder')} placeholder={t('areaCodePlaceholder')}
value={field.value} value={field.value}
onChange={(value) => { onChange={(value) => {
form.setValue(field.name, value.phone); form.setValue(field.name, value.phone);
@@ -149,7 +149,7 @@ export default function UserForm<T extends Record<string, any>>({
)} )}
/> />
} }
placeholder={t('form.telephonePlaceholder')} placeholder={t('telephonePlaceholder')}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -165,10 +165,10 @@ export default function UserForm<T extends Record<string, any>>({
name='password' name='password'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.password')}</FormLabel> <FormLabel>{t('password')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={t('form.passwordPlaceholder')} placeholder={t('passwordPlaceholder')}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -184,10 +184,10 @@ export default function UserForm<T extends Record<string, any>>({
name='referer_id' name='referer_id'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.refererId')}</FormLabel> <FormLabel>{t('refererId')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={t('form.refererIdPlaceholder')} placeholder={t('refererIdPlaceholder')}
{...field} {...field}
type='number' type='number'
onValueChange={(value) => { onValueChange={(value) => {
@@ -204,10 +204,10 @@ export default function UserForm<T extends Record<string, any>>({
name='refer_code' name='refer_code'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.inviteCode')}</FormLabel> <FormLabel>{t('inviteCode')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={t('form.inviteCodePlaceholder')} placeholder={t('inviteCodePlaceholder')}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -223,11 +223,11 @@ export default function UserForm<T extends Record<string, any>>({
name='balance' name='balance'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.balance')}</FormLabel> <FormLabel>{t('balance')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
prefix={currency?.currency_symbol ?? '$'} prefix={currency?.currency_symbol ?? '$'}
placeholder={t('form.balancePlaceholder')} placeholder={t('balancePlaceholder')}
type='number' type='number'
{...field} {...field}
formatInput={(value) => unitConversion('centsToDollars', value)} formatInput={(value) => unitConversion('centsToDollars', value)}
@@ -247,11 +247,11 @@ export default function UserForm<T extends Record<string, any>>({
name='gift_amount' name='gift_amount'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.giftAmount')}</FormLabel> <FormLabel>{t('giftAmount')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
prefix={currency?.currency_symbol ?? '$'} prefix={currency?.currency_symbol ?? '$'}
placeholder={t('form.giftAmountPlaceholder')} placeholder={t('giftAmountPlaceholder')}
type='number' type='number'
{...field} {...field}
formatInput={(value) => unitConversion('centsToDollars', value)} formatInput={(value) => unitConversion('centsToDollars', value)}
@@ -271,11 +271,11 @@ export default function UserForm<T extends Record<string, any>>({
name='commission' name='commission'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.commission')}</FormLabel> <FormLabel>{t('commission')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
prefix={currency?.currency_symbol ?? '$'} prefix={currency?.currency_symbol ?? '$'}
placeholder={t('form.commissionPlaceholder')} placeholder={t('commissionPlaceholder')}
type='number' type='number'
{...field} {...field}
formatInput={(value) => unitConversion('centsToDollars', value)} formatInput={(value) => unitConversion('centsToDollars', value)}
@@ -295,7 +295,7 @@ export default function UserForm<T extends Record<string, any>>({
name='is_admin' name='is_admin'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.manager')}</FormLabel> <FormLabel>{t('manager')}</FormLabel>
<FormControl> <FormControl>
<div className='pt-2'> <div className='pt-2'>
<Switch checked={!!field.value} onCheckedChange={field.onChange} /> <Switch checked={!!field.value} onCheckedChange={field.onChange} />
@@ -316,11 +316,10 @@ export default function UserForm<T extends Record<string, any>>({
setOpen(false); setOpen(false);
}} }}
> >
{t('form.cancel')} {t('cancel')}
</Button> </Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}> <Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '} {loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />} {t('confirm')}
{t('form.confirm')}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -59,7 +59,7 @@ export function RevenueStatisticsCard() {
return ( return (
<Tabs defaultValue='today'> <Tabs defaultValue='today'>
<Card> <Card className='h-full'>
<CardHeader className='flex !flex-row items-center justify-between'> <CardHeader className='flex !flex-row items-center justify-between'>
<CardTitle>{t('revenueTitle')}</CardTitle> <CardTitle>{t('revenueTitle')}</CardTitle>
<TabsList> <TabsList>
@@ -68,8 +68,8 @@ export function RevenueStatisticsCard() {
<TabsTrigger value='total'>{t('total')}</TabsTrigger> <TabsTrigger value='total'>{t('total')}</TabsTrigger>
</TabsList> </TabsList>
</CardHeader> </CardHeader>
<TabsContent value='today'> <TabsContent value='today' className='h-full'>
<CardContent> <CardContent className='h-80'>
{RevenueStatistics?.today.new_order_amount || {RevenueStatistics?.today.new_order_amount ||
RevenueStatistics?.today.renewal_order_amount ? ( RevenueStatistics?.today.renewal_order_amount ? (
<ChartContainer config={IncomeStatisticsConfig} className='mx-auto max-h-80'> <ChartContainer config={IncomeStatisticsConfig} className='mx-auto max-h-80'>
@@ -129,7 +129,9 @@ export function RevenueStatisticsCard() {
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -162,8 +164,8 @@ export function RevenueStatisticsCard() {
</CardFooter> </CardFooter>
</TabsContent> </TabsContent>
<TabsContent value='month'> <TabsContent value='month' className='h-full'>
<CardContent> <CardContent className='h-80'>
{RevenueStatistics?.monthly.list && RevenueStatistics?.monthly.list.length > 0 ? ( {RevenueStatistics?.monthly.list && RevenueStatistics?.monthly.list.length > 0 ? (
<ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'> <ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'>
<BarChart <BarChart
@@ -196,7 +198,9 @@ export function RevenueStatisticsCard() {
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -232,8 +236,8 @@ export function RevenueStatisticsCard() {
</CardFooter> </CardFooter>
</TabsContent> </TabsContent>
<TabsContent value='total'> <TabsContent value='total' className='h-full'>
<CardContent> <CardContent className='h-80'>
{RevenueStatistics?.all.list && RevenueStatistics?.all.list.length > 0 ? ( {RevenueStatistics?.all.list && RevenueStatistics?.all.list.length > 0 ? (
<ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'> <ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'>
<AreaChart <AreaChart
@@ -283,7 +287,9 @@ export function RevenueStatisticsCard() {
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
+21 -14
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { UserSubscribeDetail } from '@/app/dashboard/user/user-detail';
import { queryServerTotalData, queryTicketWaitReply } from '@/services/admin/console'; import { queryServerTotalData, queryTicketWaitReply } from '@/services/admin/console';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'; import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
@@ -11,6 +12,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@workspace/ui/components/select'; } from '@workspace/ui/components/select';
import { Separator } from '@workspace/ui/components/separator';
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Icon } from '@workspace/ui/custom-components/icon'; import { Icon } from '@workspace/ui/custom-components/icon';
import { formatBytes } from '@workspace/ui/utils'; import { formatBytes } from '@workspace/ui/utils';
@@ -59,15 +61,13 @@ export default function Statistics() {
users: { users: {
today: today:
ServerTotal?.user_traffic_ranking_today?.map((item) => ({ ServerTotal?.user_traffic_ranking_today?.map((item) => ({
name: item.user_id, name: item.sid,
traffic: item.download + item.upload, traffic: item.download + item.upload,
email: item.email,
})) || [], })) || [],
yesterday: yesterday:
ServerTotal?.user_traffic_ranking_yesterday?.map((item) => ({ ServerTotal?.user_traffic_ranking_yesterday?.map((item) => ({
name: item.user_id, name: item.sid,
traffic: item.download + item.upload, traffic: item.download + item.upload,
email: item.email,
})) || [], })) || [],
}, },
}; };
@@ -149,7 +149,7 @@ export default function Statistics() {
</TabsList> </TabsList>
</Tabs> </Tabs>
</CardHeader> </CardHeader>
<CardContent> <CardContent className='h-80'>
<div className='mb-6 flex items-center justify-between'> <div className='mb-6 flex items-center justify-between'>
<h4 className='font-semibold'> <h4 className='font-semibold'>
{dataType === 'nodes' ? t('nodeTraffic') : t('userTraffic')} {dataType === 'nodes' ? t('nodeTraffic') : t('userTraffic')}
@@ -175,10 +175,6 @@ export default function Statistics() {
label: t('type'), label: t('type'),
color: 'hsl(var(--muted))', color: 'hsl(var(--muted))',
}, },
email: {
label: t('email'),
color: 'hsl(var(--muted))',
},
label: { label: {
color: 'hsl(var(--foreground))', color: 'hsl(var(--foreground))',
}, },
@@ -204,13 +200,22 @@ export default function Statistics() {
tickFormatter={(value, index) => String(index + 1)} tickFormatter={(value, index) => String(index + 1)}
/> />
<ChartTooltip <ChartTooltip
trigger='hover'
content={ content={
<ChartTooltipContent <ChartTooltipContent
label={true} label={true}
labelFormatter={(label) => labelFormatter={(label, [payload]) =>
dataType === 'nodes' dataType === 'nodes' ? (
? `${t('nodes')}: ${label}` `${t('nodes')}: ${label}`
: `${t('users')}: ${label}` ) : (
<>
<div className='w-80'>
<UserSubscribeDetail id={payload?.payload.name} enabled={true} />
</div>
<Separator className='my-2' />
<div>{`${t('users')}: ${label}`}</div>
</>
)
} }
formatter={(value) => { formatter={(value) => {
return formatBytes(Number(value) || 0); return formatBytes(Number(value) || 0);
@@ -230,7 +235,9 @@ export default function Statistics() {
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -61,7 +61,7 @@ export function UserStatisticsCard() {
return ( return (
<Tabs defaultValue='today'> <Tabs defaultValue='today'>
<Card> <Card className='h-full'>
<CardHeader className='flex !flex-row items-center justify-between'> <CardHeader className='flex !flex-row items-center justify-between'>
<CardTitle>{t('userTitle')}</CardTitle> <CardTitle>{t('userTitle')}</CardTitle>
<TabsList> <TabsList>
@@ -71,8 +71,8 @@ export function UserStatisticsCard() {
</TabsList> </TabsList>
</CardHeader> </CardHeader>
<TabsContent value='today'> <TabsContent value='today' className='h-full'>
<CardContent> <CardContent className='h-80'>
{UserStatistics?.today.register || {UserStatistics?.today.register ||
UserStatistics?.today.new_order_users || UserStatistics?.today.new_order_users ||
UserStatistics?.today.renewal_order_users ? ( UserStatistics?.today.renewal_order_users ? (
@@ -133,7 +133,9 @@ export function UserStatisticsCard() {
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -168,8 +170,8 @@ export function UserStatisticsCard() {
</CardFooter> </CardFooter>
</TabsContent> </TabsContent>
<TabsContent value='month'> <TabsContent value='month' className='h-full'>
<CardContent> <CardContent className='h-80'>
{UserStatistics?.monthly.list && UserStatistics?.monthly.list.length > 0 ? ( {UserStatistics?.monthly.list && UserStatistics?.monthly.list.length > 0 ? (
<ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'> <ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'>
<BarChart <BarChart
@@ -204,7 +206,9 @@ export function UserStatisticsCard() {
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -239,8 +243,8 @@ export function UserStatisticsCard() {
</CardFooter> </CardFooter>
</TabsContent> </TabsContent>
<TabsContent value='total'> <TabsContent value='total' className='h-full'>
<CardContent> <CardContent className='h-80'>
{UserStatistics?.all.list && UserStatistics?.all.list.length > 0 ? ( {UserStatistics?.all.list && UserStatistics?.all.list.length > 0 ? (
<ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'> <ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'>
<AreaChart <AreaChart
@@ -299,7 +303,9 @@ export function UserStatisticsCard() {
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
+29 -4
View File
@@ -11,13 +11,38 @@ import {
} from '@workspace/ui/components/select'; } from '@workspace/ui/components/select';
import { Icon } from '@workspace/ui/custom-components/icon'; import { Icon } from '@workspace/ui/custom-components/icon';
import { getCountry } from '@workspace/ui/utils'; import { getCountry } from '@workspace/ui/utils';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
const languages = {
'cs-CZ': 'Čeština',
'de-DE': 'Deutsch',
'en-US': 'English',
'es-ES': 'Español',
'es-MX': 'Español (México)',
'fa-IR': 'فارسی',
'fi-FI': 'Suomi',
'fr-FR': 'Français',
'hi-IN': 'हिन्दी',
'hu-HU': 'Magyar',
'ja-JP': '日本語',
'ko-KR': '한국어',
'no-NO': 'Norsk',
'pl-PL': 'Polski',
'pt-BR': 'Português (Brasil)',
'ro-RO': 'Română',
'ru-RU': 'Русский',
'th-TH': 'ไทย',
'tr-TR': 'Türkçe',
'uk-UA': 'Українська',
'vi-VN': 'Tiếng Việt',
'zh-CN': '简体中文',
'zh-HK': '繁體中文',
} as const;
export default function LanguageSwitch() { export default function LanguageSwitch() {
const locale = useLocale(); const locale = useLocale();
const country = getCountry(locale); const country = getCountry(locale);
const t = useTranslations('language');
const router = useRouter(); const router = useRouter();
const handleLanguageChange = (value: string) => { const handleLanguageChange = (value: string) => {
@@ -31,7 +56,7 @@ export default function LanguageSwitch() {
<SelectValue> <SelectValue>
<div className='flex items-center'> <div className='flex items-center'>
<Icon icon={`flagpack:${country?.alpha2.toLowerCase()}`} className='!size-5' /> <Icon icon={`flagpack:${country?.alpha2.toLowerCase()}`} className='!size-5' />
<span className='sr-only'>{t(`${locale}`)}</span> <span className='sr-only'>{languages[locale as keyof typeof languages]}</span>
</div> </div>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
@@ -40,7 +65,7 @@ export default function LanguageSwitch() {
<SelectItem key={`${item?.lang}-${item?.alpha2}`} value={`${item?.lang}-${item?.alpha2}`}> <SelectItem key={`${item?.lang}-${item?.alpha2}`} value={`${item?.lang}-${item?.alpha2}`}>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Icon icon={`flagpack:${item?.alpha2.toLowerCase()}`} className='!size-5' /> <Icon icon={`flagpack:${item?.alpha2.toLowerCase()}`} className='!size-5' />
{t(`${item?.lang}-${item?.alpha2}`)} {languages[`${item?.lang}-${item?.alpha2}` as keyof typeof languages]}
</div> </div>
</SelectItem> </SelectItem>
))} ))}
+2
View File
@@ -2,6 +2,7 @@
import { ProTable as _ProTable, ProTableProps } from '@workspace/ui/custom-components/pro-table'; import { ProTable as _ProTable, ProTableProps } from '@workspace/ui/custom-components/pro-table';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Empty } from './empty';
export { type ProTableActions } from '@workspace/ui/custom-components/pro-table'; export { type ProTableActions } from '@workspace/ui/custom-components/pro-table';
export function ProTable< export function ProTable<
@@ -25,6 +26,7 @@ export function ProTable<
}, },
textRowsPerPage: t('rowsPerPage'), textRowsPerPage: t('rowsPerPage'),
}} }}
empty={<Empty />}
/> />
); );
} }
+38 -7
View File
@@ -35,6 +35,10 @@ export const AuthControl = [
title: 'GitHub', title: 'GitHub',
url: '/dashboard/auth-control/github', url: '/dashboard/auth-control/github',
}, },
{
title: 'Device',
url: '/dashboard/auth-control/device',
},
]; ];
export const navs = [ export const navs = [
@@ -57,6 +61,11 @@ export const navs = [
icon: 'flat-color-icons:lock-portrait', icon: 'flat-color-icons:lock-portrait',
items: AuthControl, items: AuthControl,
}, },
{
title: 'ADS Config',
url: '/dashboard/ads',
icon: 'flat-color-icons:electrical-sensor',
},
{ {
title: 'Payment Config', title: 'Payment Config',
url: '/dashboard/payment', url: '/dashboard/payment',
@@ -72,16 +81,26 @@ export const navs = [
url: '/dashboard/server', url: '/dashboard/server',
icon: 'flat-color-icons:data-protection', icon: 'flat-color-icons:data-protection',
}, },
], {
}, title: 'Application Management',
{ url: '/dashboard/application',
title: 'Finance', icon: 'flat-color-icons:touchscreen-smartphone',
items: [ },
{
title: 'Rule Management',
url: '/dashboard/rules',
icon: 'flat-color-icons:ruler',
},
{ {
title: 'Subscribe Management', title: 'Subscribe Management',
url: '/dashboard/subscribe', url: '/dashboard/subscribe',
icon: 'flat-color-icons:shop', icon: 'flat-color-icons:shop',
}, },
],
},
{
title: 'Finance',
items: [
{ {
title: 'Order Management', title: 'Order Management',
url: '/dashboard/order', url: '/dashboard/order',
@@ -101,11 +120,17 @@ export const navs = [
title: 'User Management', title: 'User Management',
url: '/dashboard/user', url: '/dashboard/user',
icon: 'flat-color-icons:conference-call', icon: 'flat-color-icons:conference-call',
items: [
{
title: 'User Detail',
url: '/dashboard/user/:id',
},
],
}, },
{ {
title: 'Announcement Management', title: 'Announcement Management',
url: '/dashboard/announcement', url: '/dashboard/announcement',
icon: 'flat-color-icons:news', icon: 'flat-color-icons:advertising',
}, },
{ {
title: 'Ticket Management', title: 'Ticket Management',
@@ -127,9 +152,15 @@ export const navs = [
]; ];
export function findNavByUrl(url: string) { export function findNavByUrl(url: string) {
function matchDynamicRoute(pattern: string, path: string): boolean {
const regexPattern = pattern.replace(/:[^/]+/g, '[^/]+').replace(/\//g, '\\/');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(path);
}
function findNav(items: any[], url: string, path: any[] = []): any[] { function findNav(items: any[], url: string, path: any[] = []): any[] {
for (const item of items) { for (const item of items) {
if (item.url === url) { if (item.url === url || (item.url && matchDynamicRoute(item.url, url))) {
return [...path, item]; return [...path, item];
} }
if (item.items) { if (item.items) {
+18 -28
View File
@@ -14,50 +14,35 @@ export const useGlobalStore = create<GlobalStore>((set) => ({
site_name: '', site_name: '',
site_desc: '', site_desc: '',
site_logo: '', site_logo: '',
keywords: '',
custom_html: '',
custom_data: '',
}, },
verify: { verify: {
turnstile_site_key: '',
enable_login_verify: false, enable_login_verify: false,
enable_register_verify: false, enable_register_verify: false,
enable_reset_password_verify: false, enable_reset_password_verify: false,
turnstile_site_key: '',
}, },
auth: { auth: {
sms: { mobile: {
sms_enabled: false, enable: false,
sms_limit: 0, enable_whitelist: false,
sms_interval: 0, whitelist: [],
sms_expire_time: 0,
}, },
email: { email: {
email_enabled: false, enable: false,
email_enable_verify: false, enable_verify: false,
email_enable_domain_suffix: false, enable_domain_suffix: false,
email_domain_suffix_list: '', domain_suffix_list: '',
}, },
register: { register: {
stop_register: false, stop_register: false,
enable_trial: false,
enable_ip_register_limit: false, enable_ip_register_limit: false,
ip_register_limit: 0, ip_register_limit: 0,
ip_register_limit_duration: 0, ip_register_limit_duration: 0,
}, },
}, },
register: {
stop_register: false,
enable_email_verify: false,
enable_email_domain_suffix: false,
email_domain_suffix_list: '',
enable_trial: false,
enable_ip_register_limit: false,
ip_register_limit: 0,
ip_register_limit_duration: 0,
sms: {
sms_enabled: false,
sms_limit: 0,
sms_interval: 0,
sms_expire_time: 0,
},
},
invite: { invite: {
forced_invite: false, forced_invite: false,
referral_percentage: 0, referral_percentage: 0,
@@ -66,7 +51,6 @@ export const useGlobalStore = create<GlobalStore>((set) => ({
currency: { currency: {
currency_unit: 'USD', currency_unit: 'USD',
currency_symbol: '$', currency_symbol: '$',
access_key: '',
}, },
subscribe: { subscribe: {
single_model: false, single_model: false,
@@ -74,7 +58,13 @@ export const useGlobalStore = create<GlobalStore>((set) => ({
subscribe_domain: '', subscribe_domain: '',
pan_domain: false, pan_domain: false,
}, },
verify_code: {
verify_code_expire_time: 5,
verify_code_limit: 15,
verify_code_interval: 60,
},
oauth_methods: [], oauth_methods: [],
web_ad: false,
}, },
user: undefined, user: undefined,
setCommon: (common) => setCommon: (common) =>
+40
View File
@@ -0,0 +1,40 @@
{
"cancel": "Zrušit",
"confirm": "Potvrdit",
"confirmDelete": "Potvrdit smazání",
"create": "Vytvořit inzerát",
"createAds": "Vytvořit reklamu",
"createSuccess": "Úspěšně vytvořeno",
"delete": "Smazat",
"deleteSuccess": "Úspěšně smazáno",
"deleteWarning": "Opravdu chcete smazat tento inzerát?",
"disabled": "Deaktivováno",
"edit": "Upravit",
"editAds": "Upravit reklamu",
"enabled": "Aktivováno",
"form": {
"cancel": "Zrušit",
"confirm": "Potvrdit",
"content": "Obsah",
"description": "Popis",
"endTime": "Čas ukončení",
"enterContent": "Prosím zadejte obsah inzerátu",
"enterDescription": "Prosím zadejte popis inzerátu",
"enterEndTime": "Prosím vyberte čas ukončení",
"enterStartTime": "Prosím vyberte čas zahájení",
"enterTargetUrl": "Prosím zadejte cílovou URL",
"enterTitle": "Prosím zadejte název inzerátu",
"startTime": "Čas zahájení",
"targetUrl": "Cílová URL",
"title": "Název",
"type": "Typ inzerátu",
"typeImage": "Obrázek",
"typeVideo": "Video"
},
"status": "Stav",
"targetUrl": "Cílová URL",
"title": "Název",
"type": "Typ",
"updateSuccess": "Úspěšně aktualizováno",
"validityPeriod": "Doba platnosti"
}
+2 -2
View File
@@ -7,8 +7,8 @@
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého Apple ID", "enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého Apple ID",
"keyId": "ID klíče", "keyId": "ID klíče",
"keyIdDescription": "ID vašeho soukromého klíče z Apple Developer Portálu", "keyIdDescription": "ID vašeho soukromého klíče z Apple Developer Portálu",
"redirectUri": "Návratová URL", "redirectUri": "Přesměrovací URL",
"redirectUriDescription": "URL, na kterou Apple přesměruje po úspěšném ověření", "redirectUriDescription": "Prosím vyplňte adresu API přesměrované URL po úspěšném ověření pomocí Apple. Nepoužívejte / na konci.",
"saveFailed": "Uložení se nezdařilo", "saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné", "saveSuccess": "Uložení bylo úspěšné",
"teamId": "ID týmu", "teamId": "ID týmu",
+72 -11
View File
@@ -1,13 +1,74 @@
{ {
"ipRegistrationLimit": "Limit registrace IP", "invite": {
"ipRegistrationLimitDescription": "Když je povoleno, IP adresy, které splňují požadavky pravidla, budou omezeny v registraci; mějte na paměti, že určení IP může způsobit problémy kvůli CDN nebo frontendovým proxy serverům", "commissionFirstTimeOnly": "Provize pouze za první nákup",
"penaltyTime": "Čas trestu (minuty)", "commissionFirstTimeOnlyDescription": "Pokud je povoleno, provize se generuje pouze při první platbě pozývatele; jednotlivé uživatele můžete konfigurovat v uživatelské správě",
"penaltyTimeDescription": "Uživatelé musí počkat, až vyprší doba penalizace, než se mohou znovu zaregistrovat", "enableForcedInvite": "Povolit nucené pozvání",
"registrationLimitCount": "Počet registrací", "enableForcedInviteDescription": "Pokud je povoleno, mohou se registrovat pouze pozvaní uživatelé",
"registrationLimitCountDescription": "Povolit sankci po dosažení limitu registrací", "inputPlaceholder": "Zadejte",
"saveSuccess": "Uložení bylo úspěšné", "inviteCommissionPercentage": "Procento provize za pozvání",
"stopNewUserRegistration": "Zastavit registraci nových uživatelů", "inviteCommissionPercentageDescription": "Výchozí globální poměr rozdělení provize; jednotlivé poměry můžete konfigurovat v uživatelské správě",
"stopNewUserRegistrationDescription": "Když je povoleno, nikdo se nemůže registrovat", "inviteSettings": "Nastavení pozvání",
"trialRegistration": "Registrace do zkušební verze", "saveSuccess": "Uložení úspěšné"
"trialRegistrationDescription": "Povolit registraci na zkušební verzi; nejprve upravte zkušební balíček a dobu trvání" },
"log": {
"content": "Obsah",
"createdAt": "Vytvořeno",
"emailLog": "Emailový záznam",
"mobileLog": "Mobilní záznam",
"platform": "Platforma",
"sendFailed": "Selhalo",
"sendSuccess": "Úspěch",
"status": "Stav",
"subject": "Předmět",
"to": "Příjemce",
"updatedAt": "Aktualizováno"
},
"register": {
"day": "Den",
"hour": "Hodina",
"ipRegistrationLimit": "Omezení registrace podle IP",
"ipRegistrationLimitDescription": "Pokud je povoleno, IP adresy splňující pravidla budou omezeny v registraci; mějte na paměti, že určení IP může způsobit problémy kvůli CDN nebo proxy serverům",
"minute": "Minuta",
"month": "Měsíc",
"noLimit": "Bez omezení",
"penaltyTime": "Doba trestu (minuty)",
"penaltyTimeDescription": "Uživatelé musí počkat, až doba trestu vyprší, než se znovu zaregistrují",
"registerSettings": "Nastavení registrace",
"registrationLimitCount": "Počet omezení registrace",
"registrationLimitCountDescription": "Povolit trest po dosažení registračního limitu",
"saveSuccess": "Uložení úspěšné",
"stopNewUserRegistration": "Zastavit registraci nových uživatelů",
"stopNewUserRegistrationDescription": "Pokud je povoleno, nikdo se nemůže registrovat",
"trialDuration": "Doba zkušební verze",
"trialRegistration": "Zkušební registrace",
"trialRegistrationDescription": "Povolit zkušební registraci; nejprve upravte zkušební balíček a dobu trvání",
"trialSubscribePlan": "Plán zkušebního předplatného",
"trialSubscribePlanDescription": "Vyberte plán zkušebního předplatného",
"year": "Rok"
},
"verify": {
"inputPlaceholder": "Zadejte",
"loginVerificationCode": "Ověřovací kód pro přihlášení",
"loginVerificationCodeDescription": "Ověření člověka během přihlášení",
"registrationVerificationCode": "Ověřovací kód pro registraci",
"registrationVerificationCodeDescription": "Ověření člověka během registrace",
"resetPasswordVerificationCode": "Ověřovací kód pro resetování hesla",
"resetPasswordVerificationCodeDescription": "Ověření člověka během resetování hesla",
"saveSuccess": "Uložení úspěšné",
"turnstileSecretDescription": "Tajný klíč turniketu poskytovaný Cloudflare",
"turnstileSiteKeyDescription": "Klíč pro web turniketu poskytovaný Cloudflare",
"verifySettings": "Nastavení ověření"
},
"verify-code": {
"dailyLimit": "Denní limit",
"dailyLimitDescription": "Maximální počet ověřovacích kódů, které lze odeslat za den",
"expireTime": "Čas vypršení",
"expireTimeDescription": "Doba platnosti ověřovacího kódu (vteřiny)",
"interval": "Interval odesílání",
"intervalDescription": "Minimální interval mezi odesíláním ověřovacích kódů (sekundy)",
"saveSuccess": "Uložení úspěšné",
"second": "sekundy",
"times": "krát",
"verifyCodeSettings": "Nastavení ověřovacího kódu"
}
} }
+2 -2
View File
@@ -20,7 +20,7 @@
"logout": "Odhlásit se", "logout": "Odhlásit se",
"methods": { "methods": {
"email": "E-mail", "email": "E-mail",
"sms": "Telefon" "mobile": "Telefon"
}, },
"orWithEmail": "nebo pomocí e-mailu", "orWithEmail": "nebo pomocí e-mailu",
"register": { "register": {
@@ -28,7 +28,7 @@
"email": "Zadejte platnou e-mailovou adresu.", "email": "Zadejte platnou e-mailovou adresu.",
"existingAccount": "Již máte účet?", "existingAccount": "Již máte účet?",
"get": "Získat", "get": "Získat",
"invite": "Pozvánkový kód", "invite": "Pozvánkový kód (volitelné)",
"message": "#### Vážený uživateli,\n\nDěkujeme za váš zájem a podporu. V důsledku úpravy provozní strategie jsme uzavřeli registraci nových uživatelů. Během této doby nebude používání stávajících účtů nijak ovlivněno.\n\nSnažíme se vám poskytnout lepší služby a zážitky, a proto během uzavření registrace provedeme komplexní optimalizaci systému a aktualizaci funkcí. V budoucnu vás přivítáme s kvalitnějším obsahem a službami.\n\nSledujte naše webové stránky a sociální média pro nejnovější aktualizace a oznámení. Děkujeme za vaše pochopení a podporu.\n\nPokud máte jakékoli dotazy nebo potřebujete pomoc, neváhejte kontaktovat náš zákaznický servis.\n\n**Ještě jednou děkujeme za vaši podporu a pochopení.**", "message": "#### Vážený uživateli,\n\nDěkujeme za váš zájem a podporu. V důsledku úpravy provozní strategie jsme uzavřeli registraci nových uživatelů. Během této doby nebude používání stávajících účtů nijak ovlivněno.\n\nSnažíme se vám poskytnout lepší služby a zážitky, a proto během uzavření registrace provedeme komplexní optimalizaci systému a aktualizaci funkcí. V budoucnu vás přivítáme s kvalitnějším obsahem a službami.\n\nSledujte naše webové stránky a sociální média pro nejnovější aktualizace a oznámení. Děkujeme za vaše pochopení a podporu.\n\nPokud máte jakékoli dotazy nebo potřebujete pomoc, neváhejte kontaktovat náš zákaznický servis.\n\n**Ještě jednou děkujeme za vaši podporu a pochopení.**",
"passwordMismatch": "Hesla se neshodují", "passwordMismatch": "Hesla se neshodují",
"success": "Registrace úspěšná, automaticky přihlášeno!", "success": "Registrace úspěšná, automaticky přihlášeno!",
+2
View File
@@ -45,9 +45,11 @@
"60002": "Toto předplatné nelze momentálně použít, zkuste to prosím později.", "60002": "Toto předplatné nelze momentálně použít, zkuste to prosím později.",
"60003": "Bylo zjištěno existující předplatné. Zrušte ho, než budete pokračovat.", "60003": "Bylo zjištěno existující předplatné. Zrušte ho, než budete pokračovat.",
"60004": "Nelze momentálně odstranit, protože předplatné má aktivní uživatele.", "60004": "Nelze momentálně odstranit, protože předplatné má aktivní uživatele.",
"60005": "Režim jednotného předplatného překročil limit uživatelů",
"70001": "Ověřovací kód je nesprávný, zadejte ho prosím znovu.", "70001": "Ověřovací kód je nesprávný, zadejte ho prosím znovu.",
"80001": "Úkol nebyl úspěšně přidán do fronty, zkuste to prosím později.", "80001": "Úkol nebyl úspěšně přidán do fronty, zkuste to prosím později.",
"90001": "Vypněte prosím režim DEBUG a zkuste to znovu.", "90001": "Vypněte prosím režim DEBUG a zkuste to znovu.",
"90015": "Tento účet dnes dosáhl limitu pro odesílání, zkuste to prosím znovu zítra",
"undefined": "Došlo k chybě systému, zkuste to prosím později." "undefined": "Došlo k chybě systému, zkuste to prosím později."
}, },
"table": { "table": {
+14
View File
@@ -0,0 +1,14 @@
{
"blockVirtualMachine": "Blokovat virtuální stroj",
"blockVirtualMachineDescription": "Pokud je povoleno, zařízení nebudou moci běžet na virtuálních strojích nebo emulátorech",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Komunikační klíč se používá pro bezpečnou komunikaci mezi zařízeními a servery",
"enable": "Povolit",
"enableDescription": "Po povolení jsou podporovány různé identifikátory zařízení, jako jsou IMEI/IDFA/IDFV/AndroidID/Mac adresa pro přihlášení a registraci",
"enableSecurity": "Povolit šifrování komunikace",
"enableSecurityDescription": "Pokud je povoleno, komunikace mezi zařízeními a servery bude šifrována",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné",
"showAds": "Zobrazit reklamy",
"showAdsDescription": "Pokud je povoleno, na zařízeních se budou zobrazovat reklamy"
}
+6 -4
View File
@@ -1,19 +1,17 @@
{ {
"emailBasicConfig": "Základní konfigurace",
"emailBasicConfigDescription": "Nakonfigurujte nastavení SMTP serveru a možnosti ověření e-mailu", "emailBasicConfigDescription": "Nakonfigurujte nastavení SMTP serveru a možnosti ověření e-mailu",
"emailLogs": "Záznamy e-mailů",
"emailLogsDescription": "Zobrazit historii odeslaných e-mailů a jejich stav", "emailLogsDescription": "Zobrazit historii odeslaných e-mailů a jejich stav",
"emailSuffixWhitelist": "Seznam povolených přípon e-mailů", "emailSuffixWhitelist": "Seznam povolených přípon e-mailů",
"emailSuffixWhitelistDescription": "Když je povoleno, mohou se registrovat pouze e-maily s příponami v seznamu", "emailSuffixWhitelistDescription": "Když je povoleno, mohou se registrovat pouze e-maily s příponami v seznamu",
"emailTemplate": "Šablona e-mailu",
"emailVerification": "Ověření e-mailu", "emailVerification": "Ověření e-mailu",
"emailVerificationDescription": "Když je povoleno, uživatelé budou muset ověřit svůj e-mail", "emailVerificationDescription": "Když je povoleno, uživatelé budou muset ověřit svůj e-mail",
"enable": "Povolit", "enable": "Povolit",
"enableDescription": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu", "enableDescription": "Po aktivaci budou povoleny funkce registrace e-mailem, přihlášení, propojení a odpojení.",
"expiration_email_template": "Šablona oznámení o vypršení platnosti", "expiration_email_template": "Šablona oznámení o vypršení platnosti",
"expiration_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.", "expiration_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"failed": "Neúspěch", "failed": "Neúspěch",
"inputPlaceholder": "Zadejte hodnotu...", "inputPlaceholder": "Zadejte hodnotu...",
"logs": "Protokoly",
"maintenance_email_template": "Šablona oznámení o údržbě", "maintenance_email_template": "Šablona oznámení o údržbě",
"maintenance_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.", "maintenance_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"recipient": "Příjemce", "recipient": "Příjemce",
@@ -27,6 +25,7 @@
"senderAddressDescription": "Výchozí e-mailová adresa používaná pro odesílání e-mailů.", "senderAddressDescription": "Výchozí e-mailová adresa používaná pro odesílání e-mailů.",
"sent": "Odesláno", "sent": "Odesláno",
"sentAt": "Odesláno", "sentAt": "Odesláno",
"settings": "Nastavení",
"smtpAccount": "SMTP účet", "smtpAccount": "SMTP účet",
"smtpAccountDescription": "E-mailový účet používaný pro ověřování.", "smtpAccountDescription": "E-mailový účet používaný pro ověřování.",
"smtpEncryptionMethod": "Metoda šifrování SMTP", "smtpEncryptionMethod": "Metoda šifrování SMTP",
@@ -39,6 +38,9 @@
"smtpServerPortDescription": "Zadejte port používaný k připojení k SMTP serveru.", "smtpServerPortDescription": "Zadejte port používaný k připojení k SMTP serveru.",
"status": "Stav", "status": "Stav",
"subject": "Předmět", "subject": "Předmět",
"template": "Šablona",
"traffic_exceed_email_template": "Šablona oznámení o překročení provozu",
"traffic_exceed_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"verify_email_template": "Šablona ověřovacího e-mailu", "verify_email_template": "Šablona ověřovacího e-mailu",
"verify_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.", "verify_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"whitelistSuffixes": "Povolené přípony", "whitelistSuffixes": "Povolené přípony",
-2
View File
@@ -5,8 +5,6 @@
"clientSecretDescription": "Facebook App Secret z Facebook Developers Console", "clientSecretDescription": "Facebook App Secret z Facebook Developers Console",
"enable": "Povolit", "enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu na Facebooku", "enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu na Facebooku",
"redirectUri": "Autorizovaná přesměrovací URI",
"redirectUriDescription": "URL, na kterou bude uživatel přesměrován po ověření na Facebooku",
"saveFailed": "Uložení se nezdařilo", "saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné" "saveSuccess": "Uložení bylo úspěšné"
} }
-2
View File
@@ -5,8 +5,6 @@
"clientSecretDescription": "Tajný klíč klienta z nastavení vaší GitHub OAuth aplikace", "clientSecretDescription": "Tajný klíč klienta z nastavení vaší GitHub OAuth aplikace",
"enable": "Povolit ověřování GitHub", "enable": "Povolit ověřování GitHub",
"enableDescription": "Povolit uživatelům přihlásit se pomocí jejich účtů na GitHubu", "enableDescription": "Povolit uživatelům přihlásit se pomocí jejich účtů na GitHubu",
"redirectUri": "URL pro zpětné volání autorizace",
"redirectUriDescription": "URL ve vaší aplikaci, kam budou uživatelé přesměrováni po autentizaci na GitHubu",
"saveFailed": "Nepodařilo se uložit nastavení GitHubu", "saveFailed": "Nepodařilo se uložit nastavení GitHubu",
"saveSuccess": "Nastavení GitHubu bylo úspěšně uloženo" "saveSuccess": "Nastavení GitHubu bylo úspěšně uloženo"
} }
-2
View File
@@ -5,8 +5,6 @@
"clientSecretDescription": "Tajný klíč klienta Google OAuth 2.0 z Google Cloud Console", "clientSecretDescription": "Tajný klíč klienta Google OAuth 2.0 z Google Cloud Console",
"enable": "Povolit", "enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu Google", "enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu Google",
"redirectUri": "Autorizovaná přesměrovací URI",
"redirectUriDescription": "URL, na kterou bude uživatel přesměrován po ověření Googlem",
"saveFailed": "Uložení se nezdařilo", "saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné" "saveSuccess": "Uložení bylo úspěšné"
} }
-25
View File
@@ -1,25 +0,0 @@
{
"cs-CZ": "čeština",
"de-DE": "němčina",
"en-US": "angličtina",
"es-ES": "španělština",
"es-MX": "španělština (Mexiko)",
"fa-IR": "Perština",
"fi-FI": "finština",
"fr-FR": "Francouzština",
"hi-IN": "hindština",
"hu-HU": "maďarština",
"ja-JP": "Japonština",
"ko-KR": "Korejština",
"no-NO": "norština",
"pl-PL": "polština",
"pt-BR": "portugalština (Brazílie)",
"ro-RO": "rumunština",
"ru-RU": "ruština",
"th-TH": "thajština",
"tr-TR": "turečtina",
"uk-UA": "Ukrajinština",
"vi-VN": "Vietnamština",
"zh-CN": "čeština (CS)",
"zh-HK": "čínština (tradiční)"
}
+5
View File
@@ -1,9 +1,12 @@
{ {
"ADS Config": "Konfigurace ADS",
"Announcement Management": "Správa oznámení", "Announcement Management": "Správa oznámení",
"Apple": "Apple ID", "Apple": "Apple ID",
"Application Management": "Správa aplikací",
"Auth Control": "Řízení ověřování", "Auth Control": "Řízení ověřování",
"Coupon Management": "Správa kupónů", "Coupon Management": "Správa kupónů",
"Dashboard": "Přístrojová deska", "Dashboard": "Přístrojová deska",
"Device": "Zařízení",
"Document Management": "Správa dokumentů", "Document Management": "Správa dokumentů",
"Email": "E-mail", "Email": "E-mail",
"Facebook": "Facebook", "Facebook": "Facebook",
@@ -14,6 +17,7 @@
"Order Management": "Správa objednávek", "Order Management": "Správa objednávek",
"Payment Config": "Konfigurace platby", "Payment Config": "Konfigurace platby",
"Phone Number": "Telefonní číslo", "Phone Number": "Telefonní číslo",
"Rule Management": "Správa pravidel",
"Server": "Server", "Server": "Server",
"Server Management": "Správa serveru", "Server Management": "Správa serveru",
"Settings": "Nastavení", "Settings": "Nastavení",
@@ -24,5 +28,6 @@
"Ticket Management": "Správa lístků", "Ticket Management": "Správa lístků",
"Twitter": "Twitter", "Twitter": "Twitter",
"User": "Uživatel", "User": "Uživatel",
"User Detail": "Podrobnosti o uživateli",
"User Management": "Správa uživatelů" "User Management": "Správa uživatelů"
} }
+46 -38
View File
@@ -1,44 +1,52 @@
{ {
"aliPay": "Platba Alipay", "AlipayF2F": "Alipay Osobně",
"alipayf2f": { "EPay": "EPay",
"appId": "ID aplikace Alipay", "Stripe": "Stripe",
"invoiceName": "Název vlastního produktu", "applyForPayment": "Žádat o platbu",
"invoiceNameDescription": "Bude zobrazeno na faktuře Alipay", "balance": "Zůstatek",
"privateKey": "Soukromý klíč Alipay", "batchDelete": "Hromadné smazání",
"publicKey": "Veřejný klíč Alipay", "cancel": "Zrušit",
"sandbox": "Režim sandbox", "config": "Nastavení",
"sandboxDescription": "Povolte režim sandbox pro testování plateb" "configInfo": "Informace o nastavení",
}, "configPlaceholder": "Vyplňte prosím poskytnutou konfiguraci {field}",
"confirm": "Potvrdit",
"confirmDelete": "Potvrdit smazání",
"copy": "Kopírovat",
"copySuccess": "Úspěšně zkopírováno",
"create": "Přidat platební metodu",
"createPayment": "Přidat platební metodu",
"createSuccess": "Úspěšně vytvořeno",
"delete": "Smazat",
"deleteSuccess": "Úspěšně smazáno",
"deleteWarning": "Opravdu chcete smazat tuto platební metodu? Tuto akci nelze vrátit zpět.",
"description": "Popis",
"descriptionPlaceholder": "Zadejte popis platební metody",
"domain": "Doména",
"domainPlaceholder": "volitelné, např. {example}",
"edit": "Upravit",
"editPayment": "Upravit platební metodu",
"enable": "Povolit", "enable": "Povolit",
"enableDescription": "Povolit tento způsob platby", "feeAmount": "Pevná částka",
"epay": {
"key": "KLÍČ",
"pid": "PID",
"url": "URL"
},
"feeMode": "Způsob účtování", "feeMode": "Způsob účtování",
"feeModeDescription": "Způsob účtování dodatečných poplatků",
"feeModeItems": ["Bez poplatku", "Procento", "Pevná částka", "Obojí výše uvedené"],
"feePercent": "Procentuální poplatek (volitelně)", "feePercent": "Procentuální poplatek (volitelně)",
"feePercentDescription": "Přidání poplatku k částce objednávky",
"fixedFee": "Pevný poplatek (volitelný)", "fixedFee": "Pevný poplatek (volitelný)",
"fixedFeeDescription": "Přidání pevného poplatku k částce objednávky", "handlingFee": "Poplatek za zpracování",
"iconUrl": "URL ikony (volitelné)", "icon": "Ikona",
"iconUrlDescription": "Používá se pro zobrazení na frontendu", "iconPlaceholder": "Zadejte URL ikony",
"inputPlaceholder": "Prosím zadejte", "mark": "Identifikátor",
"key": "KLÍČ", "markPlaceholder": "Zadejte identifikátor platební metody",
"notifyUrl": "Vlastní oznámení URL (volitelné)", "name": "Název",
"notifyUrlDescription": "Oznámení brány budou odeslána na tuto doménu", "namePlaceholder": "Zadejte název platební metody",
"payForRecommendations": "Zaplatit za doporučení", "nameRequired": "Název je povinný",
"pid": "PID", "noFee": "Žádný poplatek",
"saveSuccess": "Uložení bylo úspěšné", "notify_url": "URL pro oznámení",
"showName": "Zobrazit název", "paymentManagement": "Správa plateb",
"showNameDescription": "Používá se pro zobrazení na frontendu", "percentFee": "Procento",
"stripe": { "platform": "Platforma",
"publicKey": "VEŘEJNÝ KLÍČ", "searchPlaceholder": "Zadejte hledané výrazy",
"secretKey": "TAJNÝ KLÍČ", "selectPlatform": "Vyberte platformu",
"webhookSecret": "PODPIS WEBHOOK KLÍČE" "selectType": "Vyberte typ platby",
}, "submit": "Odeslat",
"url": "URL", "type": "Typ",
"wechatPay": "WeChat Pay" "updateSuccess": "Úspěšně aktualizováno"
} }
+12 -21
View File
@@ -1,41 +1,32 @@
{ {
"SmsList": "Seznam SMS záznamů", "accessLabel": "Přístup",
"applyPlatform": "Použít platformu", "applyPlatform": "Použít platformu",
"areaCode": "Směrový kód",
"content": "Obsah",
"createdAt": "Čas odeslání",
"enable": "Povolit", "enable": "Povolit",
"enableTip": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu", "enableTip": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
"expireTime": "Čas vypršení", "endpointLabel": "Koncový bod",
"expireTimeTip": "Doba platnosti ověřovacího kódu SMS (v sekundách)",
"interval": "Interval",
"intervalTip": "Interval odesílání SMS ověřovacího kódu pro stejné telefonní číslo (v sekundách), 0 znamená bez omezení",
"limit": "Denní limit",
"limitTip": "Denní limit pro odesílání ověřovacích SMS kódů na stejné telefonní číslo, 0 znamená bez omezení",
"logs": "Protokoly", "logs": "Protokoly",
"phoneNumberLabel": "Telefonní číslo",
"placeholders": { "placeholders": {
"expireTime": "Zadejte dobu vypršení, výchozí je 300",
"interval": "Zadejte interval, výchozí je 60",
"limit": "Zadejte denní limit, výchozí je 20",
"template": "Váš ověřovací kód je {code}, platný po dobu 5 minut", "template": "Váš ověřovací kód je {code}, platný po dobu 5 minut",
"templateCode": "Zadejte kód šablony" "templateCode": "Zadejte kód šablony"
}, },
"platform": "SMS platforma", "platform": "SMS platforma",
"platformConfigTip": "Vyplňte prosím poskytnutou konfiguraci {key}", "platformConfigTip": "Vyplňte prosím poskytnutou konfiguraci {key}",
"platformTip": "Vyberte prosím platformu pro SMS", "platformTip": "Vyberte prosím platformu pro SMS",
"search": "Vyhledat telefonní číslo", "secretLabel": "Tajný klíč",
"sendFailed": "Odeslání se nezdařilo", "sendFailed": "Odeslání se nezdařilo",
"sendSuccess": "Úspěšně odesláno", "sendSuccess": "Úspěšně odesláno",
"settings": "Nastavení", "settings": "Nastavení",
"status": "Stav", "signNameLabel": "Název podpisu",
"telephone": "Telefonní číslo",
"template": "Šablona SMS", "template": "Šablona SMS",
"templateCode": "Kód šablony", "templateCodeLabel": "Kód šablony",
"templateParam": "Parametr šablony", "templateTip": "Prosím vyplňte SMS šablonu, ponechte {code} uprostřed, jinak SMS funkce nebude fungovat",
"templateTip": "Vyplňte prosím šablonu SMS, ponechte {code} uprostřed, jinak funkce SMS nebude fungovat",
"testSms": "Odeslat testovací SMS", "testSms": "Odeslat testovací SMS",
"testSmsContent": "Toto je testovací zpráva",
"testSmsPhone": "Zadejte telefonní číslo", "testSmsPhone": "Zadejte telefonní číslo",
"testSmsTip": "Odeslat testovací SMS pro ověření vaší konfigurace", "testSmsTip": "Odeslat testovací SMS pro ověření vaší konfigurace",
"updateSuccess": "Aktualizace úspěšná" "updateSuccess": "Aktualizace úspěšná",
"whitelistAreaCode": "Kód oblasti v bílém seznamu",
"whitelistAreaCodeTip": "Zadejte kódy oblastí v bílém seznamu, např. 1, 852, 886, 888",
"whitelistValidation": "Ověření bílého seznamu",
"whitelistValidationTip": "Pokud je povoleno, mohou SMS odesílat pouze kódy oblastí v bílém seznamu"
} }
+43
View File
@@ -0,0 +1,43 @@
{
"analyzing": "Analyzuji...",
"appIcon": "Ikona pravidla",
"cancel": "Zrušit",
"confirm": "Potvrdit",
"confirmDelete": "Potvrdit smazání",
"create": "Přidat pravidlo",
"createRule": "Přidat pravidlo",
"createSuccess": "Pravidlo bylo úspěšně vytvořeno",
"createdAt": "Vytvořeno dne",
"delete": "Smazat",
"deleteSuccess": "Pravidlo bylo úspěšně smazáno",
"deleteWarning": "Opravdu chcete smazat toto pravidlo? Tato akce není vratná.",
"description": "Popis pravidla",
"downloadTemplate": "Stáhnout šablonu",
"edit": "Upravit",
"editRule": "Upravit pravidlo",
"enable": "Povolit",
"enterIconUrl": "Zadejte nebo nahrajte ikonu",
"enterRuleName": "Zadejte název pravidla",
"enterRules": "Zadejte obsah pravidla, jedno na řádek",
"import": "Importovat",
"importFailed": "Import se nezdařil",
"importSuccess": "Import byl úspěšný",
"importYaml": "Importovat YAML",
"importYamlDescription": "Importujte pravidla ze souboru YAML, systém automaticky vytvoří skupiny pravidel na základě názvů skupin politiky",
"importYamlRules": "Importovat YAML pravidla",
"importing": "Importuji...",
"invalidYamlFormat": "Neplatný formát YAML, chybí pole pravidel",
"name": "Název pravidla",
"noValidRules": "Nebyly nalezeny žádné platné pravidla",
"pleaseUploadFile": "Prosím, nahrajte soubor YAML",
"preview": "Náhled",
"rules": "Obsah pravidla",
"rulesFormat": "Formát pravidla: typ pravidla, obsah shody, [politika], kde politika je volitelná.\nPokud politika není specifikována, bude automaticky použito aktuální jméno skupiny pravidel. Příklady:",
"rulesLabel": "Obsah pravidla",
"searchRule": "Hledat název pravidla",
"selectFile": "Vybrat soubor",
"selectTags": "Vybrat značky uzlu",
"tags": "Značky uzlu",
"tagsLabel": "Značky uzlu",
"updateSuccess": "Pravidlo bylo úspěšně aktualizováno"
}
+1
View File
@@ -51,6 +51,7 @@
"subscriptionPath": "Cesta předplatného", "subscriptionPath": "Cesta předplatného",
"subscriptionPathDescription": "Používá se pro předplatné; po úpravě nezapomeňte restartovat systém pro optimální výkon", "subscriptionPathDescription": "Používá se pro předplatné; po úpravě nezapomeňte restartovat systém pro optimální výkon",
"subscriptionPathPlaceholder": "Zadejte", "subscriptionPathPlaceholder": "Zadejte",
"updateSuccess": "Úspěšně aktualizováno",
"wildcardResolution": "Řešení zástupných znaků", "wildcardResolution": "Řešení zástupných znaků",
"wildcardResolutionDescription": "Používá se pro předplatné" "wildcardResolutionDescription": "Používá se pro předplatné"
}, },
+10 -41
View File
@@ -8,17 +8,17 @@
"currencyUnitDescription": "Pouze pro zobrazení, po změně se změní všechny měnové jednotky v systému", "currencyUnitDescription": "Pouze pro zobrazení, po změně se změní všechny měnové jednotky v systému",
"saveSuccess": "Úspěšně uloženo" "saveSuccess": "Úspěšně uloženo"
}, },
"invite": { "privacy-policy": {
"commissionFirstTimeOnly": "Provize pouze při prvním nákupu", "saveSuccess": "Uložení bylo úspěšné",
"commissionFirstTimeOnlyDescription": "Po aktivaci se provize generuje pouze při první platbě pozvaného uživatele. Můžete konfigurovat jednotlivé uživatele ve správě uživatelů.", "title": "Zásady ochrany osobních údajů"
"enableForcedInvite": "Povolit povinné pozvání",
"enableForcedInviteDescription": "Po aktivaci se mohou registrovat pouze pozvaní uživatelé.",
"inputPlaceholder": "Prosím zadejte",
"inviteCommissionPercentage": "Procento provize za pozvání",
"inviteCommissionPercentageDescription": "Výchozí globální procento rozdělení provize, můžete konfigurovat jednotlivé procento ve správě uživatelů.",
"saveSuccess": "Úspěšně uloženo"
}, },
"site": { "site": {
"customData": "Vlastní data",
"customDataDescription": "Vlastní data, používaná pro vlastní data webu",
"customHtml": "Vlastní HTML",
"customHtmlDescription": "Vlastní HTML kód, který bude vložen na konec tagu body na stránce.",
"keywords": "Klíčová slova",
"keywordsDescription": "Používá se pro SEO účely",
"logo": "LOGO", "logo": "LOGO",
"logoDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit LOGO", "logoDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit LOGO",
"logoPlaceholder": "Zadejte URL adresu LOGA, nekončete '/'", "logoPlaceholder": "Zadejte URL adresu LOGA, nekončete '/'",
@@ -35,42 +35,11 @@
}, },
"tabs": { "tabs": {
"currency": "Měna", "currency": "Měna",
"invite": "Pozvat",
"site": "Stránka", "site": "Stránka",
"telegram": "Telegram", "tos": "Podmínky služby"
"tos": "Podmínky služby",
"verify": "Ověřit"
},
"telegram": {
"botToken": "Token robota",
"botTokenDescription": "Zadejte token poskytnutý Botfatherem",
"enableBotNotifications": "Povolit oznámení robota",
"enableBotNotificationsDescription": "Po povolení bude robot posílat základní oznámení správcům a uživatelům propojeným s Telegramem",
"groupURL": "URL skupiny",
"groupURLDescription": "Po vyplnění se zobrazí na uživatelské straně nebo tam, kde je potřeba",
"inputPlaceholderBotToken": "0000000000:xxxxxxxxx_xxxxxxxxxxxxxxx",
"inputPlaceholderGroupURL": "https://t.me/xxxxxx",
"inputPlaceholderWebhookDomain": "https://example.com",
"saveSuccess": "Úspěšně uloženo",
"webhookDomain": "Doména webhooku",
"webhookDomainDescription": "Zadejte název domény serveru webhooku"
}, },
"tos": { "tos": {
"saveSuccess": "Uložení bylo úspěšné", "saveSuccess": "Uložení bylo úspěšné",
"title": "Podmínky služby" "title": "Podmínky služby"
},
"verify": {
"inputPlaceholder": "Prosím zadejte",
"loginVerificationCode": "Ověřovací kód pro přihlášení",
"loginVerificationCodeDescription": "Ověření člověka při přihlášení",
"registrationVerificationCode": "Ověřovací kód pro registraci",
"registrationVerificationCodeDescription": "Ověření člověka při registraci",
"resetPasswordVerificationCode": "Ověřovací kód pro resetování hesla",
"resetPasswordVerificationCodeDescription": "Ověření člověka při resetování hesla",
"saveSuccess": "Úspěšně uloženo",
"turnstileSecret": "Turnstile tajný klíč",
"turnstileSecretDescription": "Turnstile tajný klíč poskytovaný Cloudflare",
"turnstileSiteKey": "Turnstile klíč webu",
"turnstileSiteKeyDescription": "Turnstile klíč webu poskytovaný Cloudflare"
} }
} }
-2
View File
@@ -5,8 +5,6 @@
"clientSecretDescription": "Telegram Bot Token, který můžete získat od @BotFather", "clientSecretDescription": "Telegram Bot Token, který můžete získat od @BotFather",
"enable": "Povolit", "enable": "Povolit",
"enableDescription": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu", "enableDescription": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
"redirectUri": "Přesměrovací URL",
"redirectUriDescription": "URL, na kterou Telegram přesměruje po úspěšném ověření",
"saveFailed": "Uložení se nezdařilo", "saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné" "saveSuccess": "Uložení bylo úspěšné"
} }
+97 -25
View File
@@ -1,50 +1,122 @@
{ {
"accountEnable": "Povolení účtu",
"actions": "akce", "actions": "akce",
"active": "Aktivní",
"add": "Přidat",
"administrator": "Administrátor",
"areaCode": "Směrový kód",
"areaCodePlaceholder": "Kód oblasti",
"auth": "Autentizace",
"authIdentifier": "Identifikátor ověření",
"authIdentifierPlaceholder": "Zadejte identifikátor ověření",
"authMethodsTitle": "Nastavení ověřování",
"authType": "Typ ověření",
"authUser": "Autentizovaný uživatel",
"avatar": "Avatar",
"balance": "Zůstatek", "balance": "Zůstatek",
"balanceNotifications": "Oznámení o změně zůstatku",
"balancePlaceholder": "Zůstatek",
"basicInfoTitle": "Základní informace",
"cancel": "zrušit", "cancel": "zrušit",
"commission": "Provize", "commission": "Provize",
"commissionPlaceholder": "Zadejte provizi",
"confirm": "Potvrdit", "confirm": "Potvrdit",
"confirmDelete": "Opravdu chcete smazat?", "confirmDelete": "Opravdu chcete smazat?",
"confirmOffline": "Potvrdit offline",
"create": "Vytvořit", "create": "Vytvořit",
"createSubscription": "Vytvořit předplatné",
"createSuccess": "Vytvoření úspěšné", "createSuccess": "Vytvoření úspěšné",
"createUser": "Vytvořit uživatele", "createUser": "Vytvořit uživatele",
"createdAt": "Datum registrace", "createdAt": "Datum registrace",
"delete": "smazat", "delete": "smazat",
"deleteDescription": "Po odstranění nelze data obnovit, prosím, postupujte opatrně.", "deleteDescription": "Po odstranění nelze data obnovit, prosím, postupujte opatrně.",
"deleteSubscriptionDescription": "Opravdu chcete smazat toto předplatné?",
"deleteSuccess": "Úspěšně odstraněno", "deleteSuccess": "Úspěšně odstraněno",
"detail": "Detail",
"deviceLimit": "Limit zařízení",
"download": "Stáhnout",
"downloadTraffic": "Stáhnout provoz",
"edit": "Upravit", "edit": "Upravit",
"editSubscription": "Upravit předplatné",
"editUser": "Upravit uživatele", "editUser": "Upravit uživatele",
"email": "e-mail", "email": "e-mail",
"enable": "Povolit", "enable": "Povolit",
"form": { "expireTime": "Čas vypršení",
"areaCode": "Směrové číslo", "expired": "Vypršelo",
"areaCodePlaceholder": "Směrové číslo", "expiredAt": "Platnost vypršela",
"balance": "Zůstatek", "failed": "Neúspěch",
"balancePlaceholder": "Zůstatek",
"cancel": "Zrušit",
"commission": "Provize",
"commissionPlaceholder": "Zadejte provizi",
"confirm": "Potvrdit",
"giftAmount": "Částka daru",
"giftAmountPlaceholder": "Zadejte částku daru",
"invalidEmailFormat": "Neplatný formát e-mailu",
"inviteCode": "Pozvánkový kód",
"inviteCodePlaceholder": "Zadejte pozvánkový kód (ponechte prázdné pro automatické generování)",
"manager": "Správce",
"password": "Heslo",
"passwordPlaceholder": "Zadejte nové heslo (může zůstat prázdné)",
"refererId": "ID doporučitele",
"refererIdPlaceholder": "Zadejte ID doporučitele",
"telephone": "Telefonní číslo",
"telephonePlaceholder": "Zadejte telefonní číslo",
"userEmail": "E-mail uživatele",
"userEmailPlaceholder": "Zadejte e-mail uživatele"
},
"giftAmount": "Částka dárku", "giftAmount": "Částka dárku",
"giftAmountPlaceholder": "Zadejte částku dárku",
"inactive": "Neaktivní",
"invalidEmailFormat": "Neplatný formát e-mailu",
"inviteCode": "Pozvánkový kód", "inviteCode": "Pozvánkový kód",
"inviteCodePlaceholder": "Zadejte kód pozvánky (ponechte prázdné pro vygenerování)",
"kickOfflineConfirm": "Opravdu chcete odpojit IP {ip}?",
"kickOfflineSuccess": "Úspěšně oznámeno odpojení",
"lastSeen": "Naposledy viděno",
"loading": "Načítání...",
"loginIp": "Přihlašovací IP",
"loginNotifications": "Oznámení o přihlášení",
"loginStatus": "Stav přihlášení",
"loginTime": "Čas přihlášení",
"manager": "Manažer",
"notifySettingsTitle": "Nastavení oznámení",
"onlineDevices": "Online zařízení",
"password": "Heslo",
"passwordPlaceholder": "Zadejte nové heslo (volitelné)",
"permanent": "Trvalý",
"pleaseEnterEmail": "Prosím, zadejte e-mail",
"price": "Cena",
"referer": "Doporučitel", "referer": "Doporučitel",
"refererId": "ID doporučitele",
"refererIdPlaceholder": "Zadejte ID doporučitele",
"referralCode": "Doporučovací kód",
"referrerUserId": "Odesílatel (uživatelské ID)",
"remove": "Odstranit",
"resetTime": "Obnovit čas",
"save": "Uložit",
"searchIp": "Vyhledat IP adresu",
"selectAuthType": "Vyberte typ ověření",
"speedLimit": "Rychlostní limit",
"startTime": "Čas zahájení",
"status": "Stav",
"subscription": "Předplatné",
"subscriptionDetails": "Podrobnosti o předplatném",
"subscriptionId": "ID předplatného",
"subscriptionInfo": "Informace o předplatném",
"subscriptionList": "Seznam předplatných",
"subscriptionLogs": "Záznamy o předplatném",
"subscriptionName": "Název předplatného",
"subscriptionNotifications": "Oznámení o předplatném",
"success": "Úspěch",
"telephone": "Telefonní číslo", "telephone": "Telefonní číslo",
"telephonePlaceholder": "Zadejte telefonní číslo",
"time": "Čas",
"token": "Token",
"totalTraffic": "Celkový provoz",
"tradeNotifications": "Obchodní oznámení",
"trafficLimit": "Limit provozu",
"trafficLogs": "Protokoly provozu",
"trafficUsage": "Využití dat",
"unknown": "Neznámé",
"unlimited": "Neomezený",
"unverified": "Neověřeno",
"update": "Aktualizovat",
"updateSuccess": "Aktualizace byla úspěšná", "updateSuccess": "Aktualizace byla úspěšná",
"upload": "Nahrát",
"uploadTraffic": "Nahrát provoz",
"userAgent": "Uživatelský agent",
"userEmail": "Uživatelský e-mail",
"userEmailPlaceholder": "Zadejte e-mail uživatele",
"userId": "ID uživatele",
"userInfo": "Informace o uživateli",
"userList": "Seznam uživatelů", "userList": "Seznam uživatelů",
"userName": "Identifikátor uživatele" "userLogs": "Historie přihlášení",
"userName": "Identifikátor uživatele",
"userOrders": "Objednávky",
"userProfile": "Profil",
"userSubscriptions": "Předplatné",
"username": "Uživatelské jméno",
"verified": "Ověřeno",
"viewDetails": "Zobrazit podrobnosti"
} }
+40
View File
@@ -0,0 +1,40 @@
{
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"confirmDelete": "Löschen bestätigen",
"create": "Anzeige erstellen",
"createAds": "Anzeige erstellen",
"createSuccess": "Erfolgreich erstellt",
"delete": "Löschen",
"deleteSuccess": "Erfolgreich gelöscht",
"deleteWarning": "Sind Sie sicher, dass Sie diese Anzeige löschen möchten?",
"disabled": "Deaktiviert",
"edit": "Bearbeiten",
"editAds": "Anzeige bearbeiten",
"enabled": "Aktiviert",
"form": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"content": "Inhalt",
"description": "Beschreibung",
"endTime": "Endzeit",
"enterContent": "Bitte geben Sie den Anzeigeninhalt ein",
"enterDescription": "Bitte geben Sie die Anzeigenbeschreibung ein",
"enterEndTime": "Bitte wählen Sie die Endzeit aus",
"enterStartTime": "Bitte wählen Sie die Startzeit aus",
"enterTargetUrl": "Bitte geben Sie die Ziel-URL ein",
"enterTitle": "Bitte geben Sie den Anzeigentitel ein",
"startTime": "Startzeit",
"targetUrl": "Ziel-URL",
"title": "Titel",
"type": "Anzeigentyp",
"typeImage": "Bild",
"typeVideo": "Video"
},
"status": "Status",
"targetUrl": "Ziel-URL",
"title": "Titel",
"type": "Typ",
"updateSuccess": "Erfolgreich aktualisiert",
"validityPeriod": "Gültigkeitsdauer"
}
+2 -2
View File
@@ -7,8 +7,8 @@
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrer Apple-ID anmelden", "enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrer Apple-ID anmelden",
"keyId": "Schlüssel-ID", "keyId": "Schlüssel-ID",
"keyIdDescription": "Die ID Ihres privaten Schlüssels aus dem Apple Developer Portal", "keyIdDescription": "Die ID Ihres privaten Schlüssels aus dem Apple Developer Portal",
"redirectUri": "Rückkehr-URL", "redirectUri": "Weiterleitungs-URL",
"redirectUriDescription": "Die URL, zu der Apple nach erfolgreicher Authentifizierung weiterleitet", "redirectUriDescription": "Bitte geben Sie die API-Adresse der umgeleiteten URL ein, nachdem die Apple-Authentifizierung erfolgreich abgeschlossen wurde. Verwenden Sie kein / am Ende.",
"saveFailed": "Speichern fehlgeschlagen", "saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Erfolgreich gespeichert", "saveSuccess": "Erfolgreich gespeichert",
"teamId": "Team-ID", "teamId": "Team-ID",
+72 -11
View File
@@ -1,13 +1,74 @@
{ {
"ipRegistrationLimit": "IP-Registrierungsgrenze", "invite": {
"ipRegistrationLimitDescription": "Wenn aktiviert, werden IPs, die die Regelanforderungen erfüllen, von der Registrierung ausgeschlossen; beachten Sie, dass die Bestimmung der IPs aufgrund von CDNs oder Frontend-Proxys Probleme verursachen kann", "commissionFirstTimeOnly": "Provision nur beim ersten Kauf",
"penaltyTime": "Strafzeit (Minuten)", "commissionFirstTimeOnlyDescription": "Wenn aktiviert, wird die Provision nur bei der ersten Zahlung des Einladers generiert; individuelle Benutzer können in der Benutzerverwaltung konfiguriert werden",
"penaltyTimeDescription": "Benutzer müssen warten, bis die Strafzeit abgelaufen ist, bevor sie sich erneut registrieren können", "enableForcedInvite": "Erzwinge Einladung",
"registrationLimitCount": "Anzahl der Anmeldungen", "enableForcedInviteDescription": "Wenn aktiviert, können sich nur eingeladene Benutzer registrieren",
"registrationLimitCountDescription": "Strafe aktivieren, nachdem das Registrierungslimit erreicht wurde", "inputPlaceholder": "Eingeben",
"saveSuccess": "Erfolgreich gespeichert", "inviteCommissionPercentage": "Einladungsprovisionssatz",
"stopNewUserRegistration": "Neue Benutzerregistrierung stoppen", "inviteCommissionPercentageDescription": "Standardmäßiges globales Provisionsverteilungsschema; individuelle Sätze können in der Benutzerverwaltung konfiguriert werden",
"stopNewUserRegistrationDescription": "Wenn aktiviert, kann sich niemand registrieren", "inviteSettings": "Einladungseinstellungen",
"trialRegistration": "Studienregistrierung", "saveSuccess": "Erfolgreich gespeichert"
"trialRegistrationDescription": "Testregistrierung aktivieren; zuerst Testpaket und -dauer anpassen" },
"log": {
"content": "Inhalt",
"createdAt": "Erstellt am",
"emailLog": "E-Mail-Protokoll",
"mobileLog": "Mobilprotokoll",
"platform": "Plattform",
"sendFailed": "Fehlgeschlagen",
"sendSuccess": "Erfolgreich",
"status": "Status",
"subject": "Betreff",
"to": "Empfänger",
"updatedAt": "Aktualisiert am"
},
"register": {
"day": "Tag",
"hour": "Stunde",
"ipRegistrationLimit": "IP-Registrierungsgrenze",
"ipRegistrationLimitDescription": "Wenn aktiviert, werden IPs, die die Regelanforderungen erfüllen, von der Registrierung ausgeschlossen; beachten Sie, dass die IP-Bestimmung aufgrund von CDNs oder Frontend-Proxys Probleme verursachen kann",
"minute": "Minute",
"month": "Monat",
"noLimit": "Keine Begrenzung",
"penaltyTime": "Strafzeit (Minuten)",
"penaltyTimeDescription": "Benutzer müssen warten, bis die Strafzeit abgelaufen ist, bevor sie sich erneut registrieren",
"registerSettings": "Registrierungseinstellungen",
"registrationLimitCount": "Registrierungsgrenzanzahl",
"registrationLimitCountDescription": "Strafe aktivieren, nachdem die Registrierungsgrenze erreicht wurde",
"saveSuccess": "Erfolgreich gespeichert",
"stopNewUserRegistration": "Neue Benutzerregistrierung stoppen",
"stopNewUserRegistrationDescription": "Wenn aktiviert, kann sich niemand registrieren",
"trialDuration": "Testdauer",
"trialRegistration": "Testregistrierung",
"trialRegistrationDescription": "Testregistrierung aktivieren; Testpaket und -dauer zuerst ändern",
"trialSubscribePlan": "Testabonnement-Plan",
"trialSubscribePlanDescription": "Wählen Sie einen Testabonnement-Plan aus",
"year": "Jahr"
},
"verify": {
"inputPlaceholder": "Eingeben",
"loginVerificationCode": "Anmeldebestätigungscode",
"loginVerificationCodeDescription": "Menschliche Überprüfung während der Anmeldung",
"registrationVerificationCode": "Registrierungsbestätigungscode",
"registrationVerificationCodeDescription": "Menschliche Überprüfung während der Registrierung",
"resetPasswordVerificationCode": "Bestätigungscode zum Zurücksetzen des Passworts",
"resetPasswordVerificationCodeDescription": "Menschliche Überprüfung während der Passwortzurücksetzung",
"saveSuccess": "Erfolgreich gespeichert",
"turnstileSecretDescription": "Turnstile-Geheimschlüssel, bereitgestellt von Cloudflare",
"turnstileSiteKeyDescription": "Turnstile-Seitenschlüssel, bereitgestellt von Cloudflare",
"verifySettings": "Überprüfungseinstellungen"
},
"verify-code": {
"dailyLimit": "Tägliches Limit",
"dailyLimitDescription": "Maximale Anzahl von Bestätigungscodes, die pro Tag gesendet werden können",
"expireTime": "Ablaufzeit",
"expireTimeDescription": "Ablaufzeit des Bestätigungscodes (Sekunden)",
"interval": "Sendeintervall",
"intervalDescription": "Minimales Intervall zwischen dem Versenden von Bestätigungscodes (Sekunden)",
"saveSuccess": "Erfolgreich gespeichert",
"second": "Sekunden",
"times": "Mal",
"verifyCodeSettings": "Einstellungen für den Bestätigungscode"
}
} }
+2 -2
View File
@@ -20,7 +20,7 @@
"logout": "Abmelden", "logout": "Abmelden",
"methods": { "methods": {
"email": "E-Mail", "email": "E-Mail",
"sms": "Telefon" "mobile": "Telefon"
}, },
"orWithEmail": "oder mit E-Mail", "orWithEmail": "oder mit E-Mail",
"register": { "register": {
@@ -28,7 +28,7 @@
"email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.", "email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"existingAccount": "Bereits ein Konto?", "existingAccount": "Bereits ein Konto?",
"get": "Erhalten", "get": "Erhalten",
"invite": "Einladungscode", "invite": "Einladungscode (optional)",
"message": "#### Sehr geehrter Nutzer,\n\nVielen Dank für Ihr Interesse und Ihre Unterstützung. Aufgrund einer Anpassung unserer Betriebsstrategie haben wir die Registrierung für neue Nutzer geschlossen. Während dieser Zeit wird die Nutzung für bestehende Nutzer nicht beeinträchtigt.\n\nWir sind bestrebt, Ihnen einen besseren Service und ein besseres Erlebnis zu bieten, und werden daher während der Schließung der Registrierung umfassende Systemoptimierungen und Funktionsupgrades durchführen. In Zukunft werden wir Sie mit hochwertigeren Inhalten und Dienstleistungen willkommen heißen.\n\nBitte folgen Sie unserer Website und unseren Social-Media-Plattformen, um die neuesten Updates und Benachrichtigungen zu erhalten. Vielen Dank für Ihr Verständnis und Ihre Unterstützung.\n\nBei Fragen oder wenn Sie Hilfe benötigen, kontaktieren Sie bitte jederzeit unser Kundenserviceteam.\n\n**Nochmals vielen Dank für Ihre Unterstützung und Ihr Verständnis.**", "message": "#### Sehr geehrter Nutzer,\n\nVielen Dank für Ihr Interesse und Ihre Unterstützung. Aufgrund einer Anpassung unserer Betriebsstrategie haben wir die Registrierung für neue Nutzer geschlossen. Während dieser Zeit wird die Nutzung für bestehende Nutzer nicht beeinträchtigt.\n\nWir sind bestrebt, Ihnen einen besseren Service und ein besseres Erlebnis zu bieten, und werden daher während der Schließung der Registrierung umfassende Systemoptimierungen und Funktionsupgrades durchführen. In Zukunft werden wir Sie mit hochwertigeren Inhalten und Dienstleistungen willkommen heißen.\n\nBitte folgen Sie unserer Website und unseren Social-Media-Plattformen, um die neuesten Updates und Benachrichtigungen zu erhalten. Vielen Dank für Ihr Verständnis und Ihre Unterstützung.\n\nBei Fragen oder wenn Sie Hilfe benötigen, kontaktieren Sie bitte jederzeit unser Kundenserviceteam.\n\n**Nochmals vielen Dank für Ihre Unterstützung und Ihr Verständnis.**",
"passwordMismatch": "Die eingegebenen Passwörter stimmen nicht überein", "passwordMismatch": "Die eingegebenen Passwörter stimmen nicht überein",
"success": "Registrierung erfolgreich, Sie sind automatisch eingeloggt!", "success": "Registrierung erfolgreich, Sie sind automatisch eingeloggt!",
+2
View File
@@ -45,9 +45,11 @@
"60002": "Das Abonnement kann derzeit nicht verwendet werden, bitte versuchen Sie es später erneut.", "60002": "Das Abonnement kann derzeit nicht verwendet werden, bitte versuchen Sie es später erneut.",
"60003": "Ein bestehendes Abonnement wurde erkannt. Bitte kündigen Sie es, bevor Sie fortfahren.", "60003": "Ein bestehendes Abonnement wurde erkannt. Bitte kündigen Sie es, bevor Sie fortfahren.",
"60004": "Kann derzeit nicht gelöscht werden, da das Abonnement aktive Benutzer hat.", "60004": "Kann derzeit nicht gelöscht werden, da das Abonnement aktive Benutzer hat.",
"60005": "Im Single-Abo-Modus wurde das Benutzerlimit überschritten",
"70001": "Der Bestätigungscode ist falsch, bitte erneut eingeben.", "70001": "Der Bestätigungscode ist falsch, bitte erneut eingeben.",
"80001": "Die Aufgabe wurde nicht erfolgreich in die Warteschlange aufgenommen, bitte versuchen Sie es später erneut.", "80001": "Die Aufgabe wurde nicht erfolgreich in die Warteschlange aufgenommen, bitte versuchen Sie es später erneut.",
"90001": "Bitte deaktivieren Sie den DEBUG-Modus und versuchen Sie es erneut.", "90001": "Bitte deaktivieren Sie den DEBUG-Modus und versuchen Sie es erneut.",
"90015": "Dieses Konto hat das tägliche Sendelimit erreicht. Bitte versuchen Sie es morgen erneut",
"undefined": "Es ist ein Systemfehler aufgetreten, bitte versuchen Sie es später erneut." "undefined": "Es ist ein Systemfehler aufgetreten, bitte versuchen Sie es später erneut."
}, },
"table": { "table": {
+14
View File
@@ -0,0 +1,14 @@
{
"blockVirtualMachine": "Virtuelle Maschine blockieren",
"blockVirtualMachineDescription": "Wenn aktiviert, wird verhindert, dass Geräte auf virtuellen Maschinen oder Emulatoren ausgeführt werden.",
"communicationKey": "Kommunikationsschlüssel",
"communicationKeyDescription": "Der Kommunikationsschlüssel wird für die sichere Kommunikation zwischen Geräten und Servern verwendet.",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung werden mehrere Geräteidentifikatoren wie IMEI/IDFA/IDFV/AndroidID/Mac-Adresse für die Anmeldung und Registrierung unterstützt.",
"enableSecurity": "Kommunikationsverschlüsselung aktivieren",
"enableSecurityDescription": "Wenn aktiviert, wird die Kommunikation zwischen Geräten und Servern verschlüsselt.",
"saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Speichern erfolgreich",
"showAds": "Werbung anzeigen",
"showAdsDescription": "Wenn aktiviert, werden auf den Geräten Anzeigen angezeigt."
}
+6 -4
View File
@@ -1,19 +1,17 @@
{ {
"emailBasicConfig": "Grundkonfiguration",
"emailBasicConfigDescription": "Konfigurieren Sie die SMTP-Servereinstellungen und E-Mail-Verifizierungsoptionen", "emailBasicConfigDescription": "Konfigurieren Sie die SMTP-Servereinstellungen und E-Mail-Verifizierungsoptionen",
"emailLogs": "E-Mail-Protokolle",
"emailLogsDescription": "Sehen Sie sich den Verlauf gesendeter E-Mails und deren Status an", "emailLogsDescription": "Sehen Sie sich den Verlauf gesendeter E-Mails und deren Status an",
"emailSuffixWhitelist": "E-Mail-Suffix-Whitelist", "emailSuffixWhitelist": "E-Mail-Suffix-Whitelist",
"emailSuffixWhitelistDescription": "Wenn aktiviert, können sich nur E-Mails mit Suffixen aus der Liste registrieren", "emailSuffixWhitelistDescription": "Wenn aktiviert, können sich nur E-Mails mit Suffixen aus der Liste registrieren",
"emailTemplate": "E-Mail-Vorlage",
"emailVerification": "E-Mail-Verifizierung", "emailVerification": "E-Mail-Verifizierung",
"emailVerificationDescription": "Wenn aktiviert, müssen Benutzer ihre E-Mail-Adresse verifizieren", "emailVerificationDescription": "Wenn aktiviert, müssen Benutzer ihre E-Mail-Adresse verifizieren",
"enable": "Aktivieren", "enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung werden die Funktionen zur Registrierung, Anmeldung, Bindung und Entbindung von Mobiltelefonen aktiviert", "enableDescription": "Nach der Aktivierung werden die Funktionen zur E-Mail-Registrierung, Anmeldung, Bindung und Entbindung aktiviert.",
"expiration_email_template": "Vorlage für Ablaufbenachrichtigung", "expiration_email_template": "Vorlage für Ablaufbenachrichtigung",
"expiration_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.", "expiration_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.",
"failed": "Fehlgeschlagen", "failed": "Fehlgeschlagen",
"inputPlaceholder": "Wert eingeben...", "inputPlaceholder": "Wert eingeben...",
"logs": "Protokolle",
"maintenance_email_template": "Wartungshinweis Vorlage", "maintenance_email_template": "Wartungshinweis Vorlage",
"maintenance_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.", "maintenance_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.",
"recipient": "Empfänger", "recipient": "Empfänger",
@@ -27,6 +25,7 @@
"senderAddressDescription": "Die standardmäßige E-Mail-Adresse, die zum Versenden von E-Mails verwendet wird.", "senderAddressDescription": "Die standardmäßige E-Mail-Adresse, die zum Versenden von E-Mails verwendet wird.",
"sent": "Gesendet", "sent": "Gesendet",
"sentAt": "Gesendet am", "sentAt": "Gesendet am",
"settings": "Einstellungen",
"smtpAccount": "SMTP-Konto", "smtpAccount": "SMTP-Konto",
"smtpAccountDescription": "Das E-Mail-Konto, das für die Authentifizierung verwendet wird.", "smtpAccountDescription": "Das E-Mail-Konto, das für die Authentifizierung verwendet wird.",
"smtpEncryptionMethod": "SMTP-Verschlüsselungsmethode", "smtpEncryptionMethod": "SMTP-Verschlüsselungsmethode",
@@ -39,6 +38,9 @@
"smtpServerPortDescription": "Geben Sie den Port an, der für die Verbindung zum SMTP-Server verwendet wird.", "smtpServerPortDescription": "Geben Sie den Port an, der für die Verbindung zum SMTP-Server verwendet wird.",
"status": "Status", "status": "Status",
"subject": "Betreff", "subject": "Betreff",
"template": "Vorlage",
"traffic_exceed_email_template": "Benachrichtigung über überschreitenden Verkehr",
"traffic_exceed_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.",
"verify_email_template": "E-Mail-Bestätigungsvorlage", "verify_email_template": "E-Mail-Bestätigungsvorlage",
"verify_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass diese Variablen beibehalten werden.", "verify_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass diese Variablen beibehalten werden.",
"whitelistSuffixes": "Whitelist-Suffixe", "whitelistSuffixes": "Whitelist-Suffixe",
-2
View File
@@ -5,8 +5,6 @@
"clientSecretDescription": "Facebook-App-Geheimnis aus der Facebook-Entwicklerkonsole", "clientSecretDescription": "Facebook-App-Geheimnis aus der Facebook-Entwicklerkonsole",
"enable": "Aktivieren", "enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Facebook-Konto anmelden", "enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Facebook-Konto anmelden",
"redirectUri": "Autorisierte Umleitungs-URI",
"redirectUriDescription": "Die URL, zu der der Benutzer nach der Facebook-Authentifizierung weitergeleitet wird",
"saveFailed": "Speichern fehlgeschlagen", "saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Erfolgreich gespeichert" "saveSuccess": "Erfolgreich gespeichert"
} }
-2
View File
@@ -5,8 +5,6 @@
"clientSecretDescription": "Das Client-Geheimnis aus den Einstellungen Ihrer GitHub-OAuth-Anwendung", "clientSecretDescription": "Das Client-Geheimnis aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
"enable": "GitHub-Authentifizierung aktivieren", "enable": "GitHub-Authentifizierung aktivieren",
"enableDescription": "Ermöglichen Sie Benutzern, sich mit ihren GitHub-Konten anzumelden", "enableDescription": "Ermöglichen Sie Benutzern, sich mit ihren GitHub-Konten anzumelden",
"redirectUri": "Autorisierungs-Callback-URL",
"redirectUriDescription": "Die URL in Ihrer Anwendung, zu der Benutzer nach der GitHub-Authentifizierung weitergeleitet werden",
"saveFailed": "Speichern der GitHub-Einstellungen fehlgeschlagen", "saveFailed": "Speichern der GitHub-Einstellungen fehlgeschlagen",
"saveSuccess": "GitHub-Einstellungen erfolgreich gespeichert" "saveSuccess": "GitHub-Einstellungen erfolgreich gespeichert"
} }
-2
View File
@@ -5,8 +5,6 @@
"clientSecretDescription": "Google OAuth 2.0-Client-Geheimnis aus der Google Cloud Console", "clientSecretDescription": "Google OAuth 2.0-Client-Geheimnis aus der Google Cloud Console",
"enable": "Aktivieren", "enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Google-Konto anmelden", "enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Google-Konto anmelden",
"redirectUri": "Autorisierte Umleitungs-URI",
"redirectUriDescription": "Die URL, zu der der Benutzer nach der Google-Authentifizierung weitergeleitet wird",
"saveFailed": "Speichern fehlgeschlagen", "saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Erfolgreich gespeichert" "saveSuccess": "Erfolgreich gespeichert"
} }
-25
View File
@@ -1,25 +0,0 @@
{
"cs-CZ": "Tschechisch",
"de-DE": "Deutsch",
"en-US": "Englisch",
"es-ES": "Spanisch",
"es-MX": "Spanisch (Mexiko)",
"fa-IR": "Persisch",
"fi-FI": "Finnisch",
"fr-FR": "Französisch",
"hi-IN": "Hindi",
"hu-HU": "Ungarisch",
"ja-JP": "Japanisch",
"ko-KR": "Koreanisch",
"no-NO": "Norwegisch",
"pl-PL": "Polnisch",
"pt-BR": "Portugiesisch (Brasilien)",
"ro-RO": "Rumänisch",
"ru-RU": "Russisch",
"th-TH": "Thailändisch",
"tr-TR": "Türkisch",
"uk-UA": "Ukrainisch",
"vi-VN": "Vietnamesisch",
"zh-CN": "Chinesisch (vereinfacht)",
"zh-HK": "Chinesisch (Traditionell)"
}
+5
View File
@@ -1,9 +1,12 @@
{ {
"ADS Config": "ADS-Konfiguration",
"Announcement Management": "Ankündigungsverwaltung", "Announcement Management": "Ankündigungsverwaltung",
"Apple": "Apple-ID", "Apple": "Apple-ID",
"Application Management": "Anwendungsverwaltung",
"Auth Control": "Authentifizierungskontrolle", "Auth Control": "Authentifizierungskontrolle",
"Coupon Management": "Gutscheinverwaltung", "Coupon Management": "Gutscheinverwaltung",
"Dashboard": "Armaturenbrett", "Dashboard": "Armaturenbrett",
"Device": "Gerät",
"Document Management": "Dokumentenverwaltung", "Document Management": "Dokumentenverwaltung",
"Email": "E-Mail", "Email": "E-Mail",
"Facebook": "Facebook", "Facebook": "Facebook",
@@ -14,6 +17,7 @@
"Order Management": "Bestellverwaltung", "Order Management": "Bestellverwaltung",
"Payment Config": "Zahlungskonfiguration", "Payment Config": "Zahlungskonfiguration",
"Phone Number": "Telefonnummer", "Phone Number": "Telefonnummer",
"Rule Management": "Regelverwaltung",
"Server": "Dienst", "Server": "Dienst",
"Server Management": "Serververwaltung", "Server Management": "Serververwaltung",
"Settings": "Einstellungen", "Settings": "Einstellungen",
@@ -24,5 +28,6 @@
"Ticket Management": "Ticketverwaltung", "Ticket Management": "Ticketverwaltung",
"Twitter": "Twitter", "Twitter": "Twitter",
"User": "Benutzer", "User": "Benutzer",
"User Detail": "Benutzerdetails",
"User Management": "Benutzerverwaltung" "User Management": "Benutzerverwaltung"
} }

Some files were not shown because too many files have changed in this diff Show More