Compare commits
54 Commits
v1.0.0-beta.30
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 42671a5974 | |||
| b2b4a95250 | |||
| b4d4f59c71 | |||
| 7f3cac3c1c | |||
| 32253e3717 | |||
| a76471a806 | |||
| d53a00611e | |||
| 2fb98be591 | |||
| 634be371b1 | |||
| ffa5c86d23 | |||
| f84f98c80f | |||
| e8ea597664 | |||
| 04af2f9433 | |||
| 96eba171d5 | |||
| 2675034b75 | |||
| 8f772c2364 | |||
| 166e48f442 | |||
| eec0b12154 | |||
| 3e290d7cb5 | |||
| 3304a55fd4 | |||
| 7cdc6bdd8f | |||
| 1fb305e7a1 | |||
| 7f81de31ab | |||
| bcfb10a6c7 | |||
| 9235fef3c6 | |||
| ee0c3371f7 | |||
| 7d18ff6825 | |||
| c86d28f798 | |||
| d323af8bb6 | |||
| 356ae5b777 | |||
| 50bdd2cd21 | |||
| 4a4d364f17 | |||
| 5ea64893e8 | |||
| 674a01c813 | |||
| 3c5542a3ea | |||
| 6e0c9b6698 | |||
| 3bb714d15c | |||
| 7023875548 | |||
| a7aa5fee3e | |||
| 64cd842926 | |||
| 8b43e69bfe | |||
| ef153747bd | |||
| 328838d754 | |||
| 423b24077f | |||
| fa2fb2864f | |||
| 8d49daca21 | |||
| cf5c39cfe5 | |||
| 383638f702 | |||
| 5939763b57 | |||
| fa56eb8293 | |||
| 6a34bfb78d | |||
| b8f4f1e694 | |||
| aa1d42651d | |||
| 0c8f0911c7 |
+514
-16
@@ -1,32 +1,530 @@
|
||||
<a name="readme-top"></a>
|
||||
# Changelog
|
||||
|
||||
# [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)
|
||||
# [1.2.0](https://github.com/perfect-panel/ppanel-web/compare/v1.1.5...v1.2.0) (2025-08-04)
|
||||
|
||||
|
||||
### ♻ Code Refactoring
|
||||
|
||||
* **view**: System and Auth Control ([b2b4a95](https://github.com/perfect-panel/ppanel-web/commit/b2b4a95))
|
||||
|
||||
|
||||
### ✨ 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))
|
||||
* **netlify**: Add Netlify configuration for admin and user apps with Next.js plugin ([b4d4f59](https://github.com/perfect-panel/ppanel-web/commit/b4d4f59))
|
||||
|
||||
<a name="readme-top"></a>
|
||||
|
||||
### 🎫 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))
|
||||
# Changelog
|
||||
|
||||
## [1.1.5](https://github.com/perfect-panel/ppanel-web/compare/v1.1.4...v1.1.5) (2025-07-26)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **dashboard**: Update date display to use start_time if available ([e551232](https://github.com/perfect-panel/ppanel-web/commit/e551232))
|
||||
- **subscribe**: Filter out items that are not marked as visible in subscription list ([32253e3](https://github.com/perfect-panel/ppanel-web/commit/32253e3))
|
||||
|
||||
## [1.1.4](https://github.com/perfect-panel/ppanel-web/compare/v1.1.3...v1.1.4) (2025-07-25)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **locales**: Simplify "show" label in subscription localization files ([d53a006](https://github.com/perfect-panel/ppanel-web/commit/d53a006))
|
||||
- **order**: Preserve last successful order on error during order creation ([2fb98be](https://github.com/perfect-panel/ppanel-web/commit/2fb98be))
|
||||
- **subscribe**: Filter out hidden items in subscription list display ([634be37](https://github.com/perfect-panel/ppanel-web/commit/634be37))
|
||||
|
||||
## [1.1.3](https://github.com/perfect-panel/ppanel-web/compare/v1.1.2...v1.1.3) (2025-07-24)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **auth**: Implement user redirection to dashboard upon authentication ([f84f98c](https://github.com/perfect-panel/ppanel-web/commit/f84f98c))
|
||||
|
||||
## [1.1.2](https://github.com/perfect-panel/ppanel-web/compare/v1.1.1...v1.1.2) (2025-07-24)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **billing**: Add display for gift amount in subscription billing ([04af2f9](https://github.com/perfect-panel/ppanel-web/commit/04af2f9))
|
||||
- **order**: Update subscription cell to display name and quantity ([96eba17](https://github.com/perfect-panel/ppanel-web/commit/96eba17))
|
||||
- **tool**: Added API for obtaining version, updated version information display logic ([2675034](https://github.com/perfect-panel/ppanel-web/commit/2675034))
|
||||
|
||||
<a name="readme-top"></a>
|
||||
|
||||
# Changelog
|
||||
|
||||
## [1.1.1](https://github.com/perfect-panel/ppanel-web/compare/v1.1.0...v1.1.1) (2025-07-20)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **node-table**: Update translations for headers and no data display ([eec0b12](https://github.com/perfect-panel/ppanel-web/commit/eec0b12))
|
||||
- **rules**: Change rule type from 'auto' to 'default' and update ([3e290d7](https://github.com/perfect-panel/ppanel-web/commit/3e290d7))
|
||||
- **rules**: Update rule settings ([3304a55](https://github.com/perfect-panel/ppanel-web/commit/3304a55))
|
||||
- **subscribe-form**: Optimize discount calculation logic and debounce updates ([166e48f](https://github.com/perfect-panel/ppanel-web/commit/166e48f))
|
||||
- **tutorial**: Comment out unused getVersion function and simplify getVersionPath ([7cdc6bd](https://github.com/perfect-panel/ppanel-web/commit/7cdc6bd))
|
||||
- **tutorial**: Return latest version in case of fetch error ([1fb305e](https://github.com/perfect-panel/ppanel-web/commit/1fb305e))
|
||||
|
||||
<a name="readme-top"></a>
|
||||
|
||||
# Changelog
|
||||
|
||||
# [1.1.0](https://github.com/perfect-panel/ppanel-web/compare/v1.0.2...v1.1.0) (2025-07-06)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **view**: Add AnyTLS protocol support and enhance node configuration options ([bcfb10a](https://github.com/perfect-panel/ppanel-web/commit/bcfb10a))
|
||||
|
||||
<a name="readme-top"></a>
|
||||
|
||||
# Changelog
|
||||
|
||||
## [1.0.2](https://github.com/perfect-panel/ppanel-web/compare/v1.0.1...v1.0.2) (2025-06-29)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **subscription**: User subscription information ([7d18ff6](https://github.com/perfect-panel/ppanel-web/commit/7d18ff6))
|
||||
|
||||
## [1.0.1](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0...v1.0.1) (2025-04-28)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **payment**: Disable webhook_secret field in PaymentForm component ([d323af8](https://github.com/perfect-panel/ppanel-web/commit/d323af8))
|
||||
- **recharge**: Set balance prop to false in PaymentMethods component ([356ae5b](https://github.com/perfect-panel/ppanel-web/commit/356ae5b))
|
||||
|
||||
# 1.0.0 (2025-04-24)
|
||||
|
||||
### ♻ Code Refactoring
|
||||
|
||||
- **api**: Sort and Announcement ([38d5616](https://github.com/perfect-panel/ppanel-web/commit/38d5616))
|
||||
- **auth**: Refactor user authorization handling and improve error logging ([68bc18f](https://github.com/perfect-panel/ppanel-web/commit/68bc18f))
|
||||
- **config**: GenerateMetadata ([a0bb101](https://github.com/perfect-panel/ppanel-web/commit/a0bb101))
|
||||
- **config**: Simplify environment variable handling and improve build script ([cf54d0f](https://github.com/perfect-panel/ppanel-web/commit/cf54d0f))
|
||||
- **config**: Viewport ([24b8601](https://github.com/perfect-panel/ppanel-web/commit/24b8601))
|
||||
- **core**: Restructure project for better module separation ([9d0cb8b](https://github.com/perfect-panel/ppanel-web/commit/9d0cb8b))
|
||||
- **deps**: Update ([19837a1](https://github.com/perfect-panel/ppanel-web/commit/19837a1))
|
||||
- **empty**: Content ([aa4c667](https://github.com/perfect-panel/ppanel-web/commit/aa4c667))
|
||||
- **payment**: Reconstruct the payment page ([7109472](https://github.com/perfect-panel/ppanel-web/commit/7109472))
|
||||
- **sbscribe**: Rename and reorganize components for better structure and clarity ([5e5e4ed](https://github.com/perfect-panel/ppanel-web/commit/5e5e4ed))
|
||||
- **ui**: Dependencies ([727d779](https://github.com/perfect-panel/ppanel-web/commit/727d779))
|
||||
- **ui**: Layout ([9262d7d](https://github.com/perfect-panel/ppanel-web/commit/9262d7d))
|
||||
- **ui**: Optimize document display ([2ca2992](https://github.com/perfect-panel/ppanel-web/commit/2ca2992))
|
||||
- Enhance user navigation dropdown ui and styling ([d2732e6](https://github.com/perfect-panel/ppanel-web/commit/d2732e6))
|
||||
- Reduce code complexity and improve readability ([e11f18c](https://github.com/perfect-panel/ppanel-web/commit/e11f18c))
|
||||
|
||||
### ⚡ Performance Improvements
|
||||
|
||||
- **subscribe**: Form discount price ([059a892](https://github.com/perfect-panel/ppanel-web/commit/059a892))
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **accounts**: Update third-party account binding and unbinding ([1841552](https://github.com/perfect-panel/ppanel-web/commit/1841552))
|
||||
- **ad**: Advertise ([b1105cd](https://github.com/perfect-panel/ppanel-web/commit/b1105cd))
|
||||
- **admin**: Add application and rule management entries to localization files ([8b43e69](https://github.com/perfect-panel/ppanel-web/commit/8b43e69))
|
||||
- **affiliate**: Add Affiliate component with commission display and invite link functionality ([4aea4e8](https://github.com/perfect-panel/ppanel-web/commit/4aea4e8))
|
||||
- **affiliate**: Affiliate Detail ([a782c17](https://github.com/perfect-panel/ppanel-web/commit/a782c17))
|
||||
- **affiliate**: Commission Rate ([5eec430](https://github.com/perfect-panel/ppanel-web/commit/5eec430))
|
||||
- **affiliate**: Update affiliate component to display total commission and improve data fetching ([cc834ca](https://github.com/perfect-panel/ppanel-web/commit/cc834ca))
|
||||
- **announcement**: Popup and pinned ([f3680a7](https://github.com/perfect-panel/ppanel-web/commit/f3680a7))
|
||||
- **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))
|
||||
- **api**: Add CheckoutOrder request and response types, and update user purchase request parameters ([48a1b97](https://github.com/perfect-panel/ppanel-web/commit/48a1b97))
|
||||
- **api**: Add CheckoutOrder request and response types, and update user purchase request parameters ([dddc21c](https://github.com/perfect-panel/ppanel-web/commit/dddc21c))
|
||||
- **api**: Add new subscription properties and locale support for deduction ratios and reset cycles ([fec80f5](https://github.com/perfect-panel/ppanel-web/commit/fec80f5))
|
||||
- **api**: Add Time Period Configuration ([837157c](https://github.com/perfect-panel/ppanel-web/commit/837157c))
|
||||
- **api**: Telegram ([17ce96a](https://github.com/perfect-panel/ppanel-web/commit/17ce96a))
|
||||
- **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 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 privacy policy link to the footer ([8e16ef1](https://github.com/perfect-panel/ppanel-web/commit/8e16ef1))
|
||||
- **auth**: Add SMS and email configuration options to global store and update localization ([4acf7b1](https://github.com/perfect-panel/ppanel-web/commit/4acf7b1))
|
||||
- **auth**: Add type parameter to SendCode and update related API typings ([4198871](https://github.com/perfect-panel/ppanel-web/commit/4198871))
|
||||
- **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))
|
||||
- **auth**: Refactor mobile authentication config to support whitelist functionality ([c761ec7](https://github.com/perfect-panel/ppanel-web/commit/c761ec7))
|
||||
- **billing**: Update Billing ([078fc9d](https://github.com/perfect-panel/ppanel-web/commit/078fc9d))
|
||||
- **cdn**: Add CDN URL configuration and update related references ([0c90733](https://github.com/perfect-panel/ppanel-web/commit/0c90733))
|
||||
- **config**: Add application selection and encryption settings to configuration form ([88b3504](https://github.com/perfect-panel/ppanel-web/commit/88b3504))
|
||||
- **config**: FormatBytes ([9251a09](https://github.com/perfect-panel/ppanel-web/commit/9251a09))
|
||||
- **config**: Protocol type ([a3b45b4](https://github.com/perfect-panel/ppanel-web/commit/a3b45b4))
|
||||
- **config**: Update encryption fields in configuration form and refactor OAuth callback parameters ([652e032](https://github.com/perfect-panel/ppanel-web/commit/652e032))
|
||||
- **config**: Webhook Domain ([01e06c6](https://github.com/perfect-panel/ppanel-web/commit/01e06c6))
|
||||
- **dashboard**: Optimization ([5b3f4b4](https://github.com/perfect-panel/ppanel-web/commit/5b3f4b4))
|
||||
- **dashboard**: Statistics ([2926abc](https://github.com/perfect-panel/ppanel-web/commit/2926abc))
|
||||
- **device**: Modify IMEI to device identifier support ([e3f9ef6](https://github.com/perfect-panel/ppanel-web/commit/e3f9ef6))
|
||||
- **email**: Add traffic exhaustion template ([bb3bd7b](https://github.com/perfect-panel/ppanel-web/commit/bb3bd7b))
|
||||
- **favicon**: Update SVG favicon design for admin and user interfaces ([1d91738](https://github.com/perfect-panel/ppanel-web/commit/1d91738))
|
||||
- **formatting**: Update differenceInDays function to return whole days or two decimal places ([bf58f25](https://github.com/perfect-panel/ppanel-web/commit/bf58f25))
|
||||
- **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))
|
||||
- **global**: Add custom data ([6dbebd1](https://github.com/perfect-panel/ppanel-web/commit/6dbebd1))
|
||||
- **global**: Add SMS configuration options to global store ([39a9ce6](https://github.com/perfect-panel/ppanel-web/commit/39a9ce6))
|
||||
- **header**: Update locales ([bfb6c27](https://github.com/perfect-panel/ppanel-web/commit/bfb6c27))
|
||||
- **imei**: Add IMEI related internationalization support and menu items ([13c3337](https://github.com/perfect-panel/ppanel-web/commit/13c3337))
|
||||
- **input**: Add minimum value constraint and enhance number handling in EnhancedInput ([ce31972](https://github.com/perfect-panel/ppanel-web/commit/ce31972))
|
||||
- **input**: Add minimum value constraint and enhance number handling in EnhancedInput ([94822d9](https://github.com/perfect-panel/ppanel-web/commit/94822d9))
|
||||
- **loading**: Add loading components and integrate them in Providers ([d5847fa](https://github.com/perfect-panel/ppanel-web/commit/d5847fa))
|
||||
- **loading**: Replace loading animation with a simpler spinner and loading text ([f72df3a](https://github.com/perfect-panel/ppanel-web/commit/f72df3a))
|
||||
- **loading**: Replace loading animation with a simpler spinner and loading text ([b8316bb](https://github.com/perfect-panel/ppanel-web/commit/b8316bb))
|
||||
- **locale**: Add Persian ([93a0a88](https://github.com/perfect-panel/ppanel-web/commit/93a0a88))
|
||||
- **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))
|
||||
- **locales**: Add kick offline confirmation and success messages in multiple languages ([5db5343](https://github.com/perfect-panel/ppanel-web/commit/5db5343))
|
||||
- **locales**: Add multiple languages ([b243ab9](https://github.com/perfect-panel/ppanel-web/commit/b243ab9))
|
||||
- **locales**: Replace 'nodeGroupId' with 'groupId' in multiple language files for consistency ([a4e9d5d](https://github.com/perfect-panel/ppanel-web/commit/a4e9d5d))
|
||||
- **locales**: Update 'deductBalance' to 'giftAmount' across multiple languages and fix newline in announcement.json ([70497af](https://github.com/perfect-panel/ppanel-web/commit/70497af))
|
||||
- **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))
|
||||
- **node-form**: Update number input fields to enforce step, min, and max values ([3f7b6d1](https://github.com/perfect-panel/ppanel-web/commit/3f7b6d1))
|
||||
- **node-subscription**: Add copy functionality for columns ([3a81e37](https://github.com/perfect-panel/ppanel-web/commit/3a81e37))
|
||||
- **node**: Add NodeStatus ([c712624](https://github.com/perfect-panel/ppanel-web/commit/c712624))
|
||||
- **node**: Add protocol ([301b635](https://github.com/perfect-panel/ppanel-web/commit/301b635))
|
||||
- **node**: Add serverKey ([25ce37e](https://github.com/perfect-panel/ppanel-web/commit/25ce37e))
|
||||
- **node**: Add status ([c06372b](https://github.com/perfect-panel/ppanel-web/commit/c06372b))
|
||||
- **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))
|
||||
- **payment**: Add bank card payment ([7fa3a57](https://github.com/perfect-panel/ppanel-web/commit/7fa3a57))
|
||||
- **payment**: Add isEdit prop to PaymentForm and disable fields when editing ([85f55de](https://github.com/perfect-panel/ppanel-web/commit/85f55de))
|
||||
- **platform**: Update platform naming and add keywords and custom HTML fields ([6384237](https://github.com/perfect-panel/ppanel-web/commit/6384237))
|
||||
- **privacy-policy**: Add privacy policy related text and links ([baa68f0](https://github.com/perfect-panel/ppanel-web/commit/baa68f0))
|
||||
- **profile**: Update localization strings and enhance third-party account binding ([2d1effb](https://github.com/perfect-panel/ppanel-web/commit/2d1effb))
|
||||
- **relay**: Add relay mode configuration and update related schemas ([3cc9477](https://github.com/perfect-panel/ppanel-web/commit/3cc9477))
|
||||
- **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))
|
||||
- **schema**: Add security field to hysteria2 and tuic schemas ([cd59d44](https://github.com/perfect-panel/ppanel-web/commit/cd59d44))
|
||||
- **site**: Added localization support for custom HTML and keyword fields ([f9d7736](https://github.com/perfect-panel/ppanel-web/commit/f9d7736))
|
||||
- **sms**: Update locales ([938363b](https://github.com/perfect-panel/ppanel-web/commit/938363b))
|
||||
- **stats**: Replace dynamic stat fetching with environment constants for user, server, and location counts ([46ae166](https://github.com/perfect-panel/ppanel-web/commit/46ae166))
|
||||
- **subscribe**: Add 'sold' column to SubscribeTable and update inventory terminology ([19619fd](https://github.com/perfect-panel/ppanel-web/commit/19619fd))
|
||||
- **subscribe**: Add reset_time to API typings and update unsubscribe logic ([eeea165](https://github.com/perfect-panel/ppanel-web/commit/eeea165))
|
||||
- **subscribe**: Add subscribe_discount type ([f99c604](https://github.com/perfect-panel/ppanel-web/commit/f99c604))
|
||||
- **subscribe**: Add subscription credits ([5bc7905](https://github.com/perfect-panel/ppanel-web/commit/5bc7905))
|
||||
- **subscribe**: Add unit time ([39d07ec](https://github.com/perfect-panel/ppanel-web/commit/39d07ec))
|
||||
- **subscribe**: Add unsubscribe functionality with confirmation messages and localized strings ([b2a2f42](https://github.com/perfect-panel/ppanel-web/commit/b2a2f42))
|
||||
- **subscribe**: Improve error handling in subscription forms and update component props ([d28a10b](https://github.com/perfect-panel/ppanel-web/commit/d28a10b))
|
||||
- **subscribe**: Improve layout and styling in subscription components ([5766376](https://github.com/perfect-panel/ppanel-web/commit/5766376))
|
||||
- **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))
|
||||
- **subscribe**: Update suffix from 'MB' to 'Mbps' and enhance speed limit display logic ([3547bb1](https://github.com/perfect-panel/ppanel-web/commit/3547bb1))
|
||||
- **subscription**: Improve layout and organization of subscription detail tabs ([e4630f8](https://github.com/perfect-panel/ppanel-web/commit/e4630f8))
|
||||
- **subscription**: Add delete user subscription functionality ([1fc3a10](https://github.com/perfect-panel/ppanel-web/commit/1fc3a10))
|
||||
- **subscription**: Add localized messages for existing subscriptions and deletion restrictions ([e8a72d5](https://github.com/perfect-panel/ppanel-web/commit/e8a72d5))
|
||||
- **subscription**: Refactor subscription handling and update imports for better organization ([2215c7f](https://github.com/perfect-panel/ppanel-web/commit/2215c7f))
|
||||
- **table**: Add sorting support for Node and subscription columns ([27924b0](https://github.com/perfect-panel/ppanel-web/commit/27924b0))
|
||||
- **table**: Supports drag and drop sorting ([2f56ef5](https://github.com/perfect-panel/ppanel-web/commit/2f56ef5))
|
||||
- **timeline**: Simplify timeline component layout and remove commented-out code ([fbad3b0](https://github.com/perfect-panel/ppanel-web/commit/fbad3b0))
|
||||
- **tos**: Display data ([6024454](https://github.com/perfect-panel/ppanel-web/commit/6024454))
|
||||
- **tutorial**: Add common tutorial list ([872252c](https://github.com/perfect-panel/ppanel-web/commit/872252c))
|
||||
- **tutorial**: Fetch the latest tutorial version from GitHub API for dynamic URL generation ([28f8c78](https://github.com/perfect-panel/ppanel-web/commit/28f8c78))
|
||||
- **ui**: System Tool ([1836980](https://github.com/perfect-panel/ppanel-web/commit/1836980))
|
||||
- **ui**: Update homepage data ([8425b13](https://github.com/perfect-panel/ppanel-web/commit/8425b13))
|
||||
- **ui**: Update input components and enhance card minimum width for better layout ([8a02310](https://github.com/perfect-panel/ppanel-web/commit/8a02310))
|
||||
- **user**: Add 'gift_amount' field and update related references in user services and components ([b13c77e](https://github.com/perfect-panel/ppanel-web/commit/b13c77e))
|
||||
- **user**: Add telephone input with area code selection and update localization ([585b99c](https://github.com/perfect-panel/ppanel-web/commit/585b99c))
|
||||
- **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))
|
||||
- **user**: Integrate subscription list into user management, update request parameters and types ([8d49dac](https://github.com/perfect-panel/ppanel-web/commit/8d49dac))
|
||||
- Update Auth Control ([c59742a](https://github.com/perfect-panel/ppanel-web/commit/c59742a))
|
||||
|
||||
### 🎫 Chores
|
||||
|
||||
- **config**: Entry locale ([5737331](https://github.com/perfect-panel/ppanel-web/commit/5737331))
|
||||
- **deps**: Update package dependencies across multiple projects for improved stability and performance ([b01a5bc](https://github.com/perfect-panel/ppanel-web/commit/b01a5bc))
|
||||
- **init**: Project initialization ([829edfa](https://github.com/perfect-panel/ppanel-web/commit/829edfa))
|
||||
- **merge**: Add advertising module and device settings ([0130e02](https://github.com/perfect-panel/ppanel-web/commit/0130e02))
|
||||
- **merge**: Bump version to 1.0.0-beta.26 and update changelog ([3222016](https://github.com/perfect-panel/ppanel-web/commit/3222016))
|
||||
- **release**: V1.0.0-beta.1 [skip ci] ([7284d1c](https://github.com/perfect-panel/ppanel-web/commit/7284d1c))
|
||||
- **release**: V1.0.0-beta.10 [skip ci] ([5cf573a](https://github.com/perfect-panel/ppanel-web/commit/5cf573a))
|
||||
- **release**: V1.0.0-beta.11 [skip ci] ([1f29506](https://github.com/perfect-panel/ppanel-web/commit/1f29506))
|
||||
- **release**: V1.0.0-beta.12 [skip ci] ([4418c47](https://github.com/perfect-panel/ppanel-web/commit/4418c47))
|
||||
- **release**: V1.0.0-beta.13 [skip ci] ([23c974a](https://github.com/perfect-panel/ppanel-web/commit/23c974a))
|
||||
- **release**: V1.0.0-beta.14 [skip ci] ([0fb0d8b](https://github.com/perfect-panel/ppanel-web/commit/0fb0d8b))
|
||||
- **release**: V1.0.0-beta.15 [skip ci] ([b2e8fad](https://github.com/perfect-panel/ppanel-web/commit/b2e8fad))
|
||||
- **release**: V1.0.0-beta.16 [skip ci] ([c3eff0a](https://github.com/perfect-panel/ppanel-web/commit/c3eff0a))
|
||||
- **release**: V1.0.0-beta.17 [skip ci] ([5b64389](https://github.com/perfect-panel/ppanel-web/commit/5b64389))
|
||||
- **release**: V1.0.0-beta.18 [skip ci] ([4a00233](https://github.com/perfect-panel/ppanel-web/commit/4a00233))
|
||||
- **release**: V1.0.0-beta.19 [skip ci] ([0f15fb8](https://github.com/perfect-panel/ppanel-web/commit/0f15fb8))
|
||||
- **release**: V1.0.0-beta.2 [skip ci] ([087c36c](https://github.com/perfect-panel/ppanel-web/commit/087c36c))
|
||||
- **release**: V1.0.0-beta.20 [skip ci] ([bbd44f0](https://github.com/perfect-panel/ppanel-web/commit/bbd44f0))
|
||||
- **release**: V1.0.0-beta.21 [skip ci] ([ca642c2](https://github.com/perfect-panel/ppanel-web/commit/ca642c2))
|
||||
- **release**: V1.0.0-beta.22 [skip ci] ([c0fb34f](https://github.com/perfect-panel/ppanel-web/commit/c0fb34f))
|
||||
- **release**: V1.0.0-beta.23 [skip ci] ([cf1d66d](https://github.com/perfect-panel/ppanel-web/commit/cf1d66d))
|
||||
- **release**: V1.0.0-beta.24 [skip ci] ([01a3aa0](https://github.com/perfect-panel/ppanel-web/commit/01a3aa0))
|
||||
- **release**: V1.0.0-beta.25 [skip ci] ([047a698](https://github.com/perfect-panel/ppanel-web/commit/047a698))
|
||||
- **release**: V1.0.0-beta.26 [skip ci] ([79edea7](https://github.com/perfect-panel/ppanel-web/commit/79edea7))
|
||||
- **release**: V1.0.0-beta.27 [skip ci] ([092477b](https://github.com/perfect-panel/ppanel-web/commit/092477b))
|
||||
- **release**: V1.0.0-beta.27 [skip ci] ([85fdc36](https://github.com/perfect-panel/ppanel-web/commit/85fdc36))
|
||||
- **release**: V1.0.0-beta.28 [skip ci] ([786ba0e](https://github.com/perfect-panel/ppanel-web/commit/786ba0e))
|
||||
- **release**: V1.0.0-beta.28 [skip ci] ([d10ecc9](https://github.com/perfect-panel/ppanel-web/commit/d10ecc9))
|
||||
- **release**: V1.0.0-beta.29 [skip ci] ([29bc3c7](https://github.com/perfect-panel/ppanel-web/commit/29bc3c7))
|
||||
- **release**: V1.0.0-beta.3 [skip ci] ([cd49427](https://github.com/perfect-panel/ppanel-web/commit/cd49427))
|
||||
- **release**: V1.0.0-beta.30 [skip ci] ([db0d9e0](https://github.com/perfect-panel/ppanel-web/commit/db0d9e0))
|
||||
- **release**: V1.0.0-beta.31 [skip ci] ([aa1d426](https://github.com/perfect-panel/ppanel-web/commit/aa1d426))
|
||||
- **release**: V1.0.0-beta.32 [skip ci] ([fa56eb8](https://github.com/perfect-panel/ppanel-web/commit/fa56eb8))
|
||||
- **release**: V1.0.0-beta.33 [skip ci] ([383638f](https://github.com/perfect-panel/ppanel-web/commit/383638f))
|
||||
- **release**: V1.0.0-beta.34 [skip ci] ([7023875](https://github.com/perfect-panel/ppanel-web/commit/7023875))
|
||||
- **release**: V1.0.0-beta.4 [skip ci] ([10d322f](https://github.com/perfect-panel/ppanel-web/commit/10d322f))
|
||||
- **release**: V1.0.0-beta.5 [skip ci] ([f275f01](https://github.com/perfect-panel/ppanel-web/commit/f275f01))
|
||||
- **release**: V1.0.0-beta.6 [skip ci] ([dd23279](https://github.com/perfect-panel/ppanel-web/commit/dd23279))
|
||||
- **release**: V1.0.0-beta.7 [skip ci] ([f60d40c](https://github.com/perfect-panel/ppanel-web/commit/f60d40c))
|
||||
- **release**: V1.0.0-beta.8 [skip ci], closes [#9](https://github.com/perfect-panel/ppanel-web/issues/9) ([a593eac](https://github.com/perfect-panel/ppanel-web/commit/a593eac))
|
||||
- **release**: V1.0.0-beta.9 [skip ci] ([855d1b0](https://github.com/perfect-panel/ppanel-web/commit/855d1b0))
|
||||
- **ui**: Update package dependencies for improved stability and performance ([25da429](https://github.com/perfect-panel/ppanel-web/commit/25da429))
|
||||
- Merge branch 'beta' into develop ([f219c52](https://github.com/perfect-panel/ppanel-web/commit/f219c52))
|
||||
- Update changelog, enhance prepare script, and add openapi command ([a93db4e](https://github.com/perfect-panel/ppanel-web/commit/a93db4e))
|
||||
|
||||
### 🐛 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))
|
||||
- **affiliate**: Update user identifier ([35f92c9](https://github.com/perfect-panel/ppanel-web/commit/35f92c9))
|
||||
- **api**: Fix type error in API request and add return URL parameter ([ee286dd](https://github.com/perfect-panel/ppanel-web/commit/ee286dd))
|
||||
- **api**: PreCreateOrder ([ca747f5](https://github.com/perfect-panel/ppanel-web/commit/ca747f5))
|
||||
- **api**: Purge ([98c1c30](https://github.com/perfect-panel/ppanel-web/commit/98c1c30))
|
||||
- **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**: Replace 'deduction' with 'gift_amount' and add 'commission' field in type definitions ([77edf1d](https://github.com/perfect-panel/ppanel-web/commit/77edf1d))
|
||||
- **api**: Server and order ([255bd82](https://github.com/perfect-panel/ppanel-web/commit/255bd82))
|
||||
- **api**: Statistics ([7962162](https://github.com/perfect-panel/ppanel-web/commit/7962162))
|
||||
- **api**: Subscribe token ([1932ba7](https://github.com/perfect-panel/ppanel-web/commit/1932ba7))
|
||||
- **api**: Update API type definitions to replace 'deduction' with 'gift_amount' and make 'commission' field optional ([c2af060](https://github.com/perfect-panel/ppanel-web/commit/c2af060))
|
||||
- **api**: Update Model ([39aaa73](https://github.com/perfect-panel/ppanel-web/commit/39aaa73))
|
||||
- **api**: Update subscription_protocol to subscribe_type for consistency across services ([b6da51b](https://github.com/perfect-panel/ppanel-web/commit/b6da51b))
|
||||
- **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**: Add error handling to form submission and reset Turnstile on failure ([715d011](https://github.com/perfect-panel/ppanel-web/commit/715d011))
|
||||
- **auth**: Change Textarea value to defaultValue for client_secret in Apple auth page ([69fc670](https://github.com/perfect-panel/ppanel-web/commit/69fc670))
|
||||
- **auth**: Refactor forms to use Turnstile ref for reset functionality ([320a7dc](https://github.com/perfect-panel/ppanel-web/commit/320a7dc))
|
||||
- **auth**: Refactor reset password form to simplify code input and update placeholder text ([23833b4](https://github.com/perfect-panel/ppanel-web/commit/23833b4))
|
||||
- **auth**: Refactor user authentication forms to remove global store dependency and improve type handling ([12026b0](https://github.com/perfect-panel/ppanel-web/commit/12026b0))
|
||||
- **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**: Simplify email verification code input rendering ([6f7bc37](https://github.com/perfect-panel/ppanel-web/commit/6f7bc37))
|
||||
- **auth**: Update authentication configuration and localization strings ([47f2c58](https://github.com/perfect-panel/ppanel-web/commit/47f2c58))
|
||||
- **auth**: Update email verification logic to use domain suffix check ([62662bb](https://github.com/perfect-panel/ppanel-web/commit/62662bb))
|
||||
- **auth**: Update user authentication flow to include email and phone code verification ([5d078fd](https://github.com/perfect-panel/ppanel-web/commit/5d078fd))
|
||||
- **auth**: Update UserCheckForm to use setInitialValues and modify onSubmit type ([c984c0d](https://github.com/perfect-panel/ppanel-web/commit/c984c0d))
|
||||
- **billing**: ExpiryDate ([e85e545](https://github.com/perfect-panel/ppanel-web/commit/e85e545))
|
||||
- **billing**: I18n and styles ([81e0f21](https://github.com/perfect-panel/ppanel-web/commit/81e0f21))
|
||||
- **changelog**: Update change log style ([cfa3fc0](https://github.com/perfect-panel/ppanel-web/commit/cfa3fc0))
|
||||
- **config**: AlipayF2F ([6c07107](https://github.com/perfect-panel/ppanel-web/commit/6c07107))
|
||||
- **config**: Bugs ([f57e40c](https://github.com/perfect-panel/ppanel-web/commit/f57e40c))
|
||||
- **config**: Checkout Order ([a31e763](https://github.com/perfect-panel/ppanel-web/commit/a31e763))
|
||||
- **config**: FormatBytes ([bbc2da0](https://github.com/perfect-panel/ppanel-web/commit/bbc2da0))
|
||||
- **config**: NoStore ([2cc18cf](https://github.com/perfect-panel/ppanel-web/commit/2cc18cf))
|
||||
- **config**: Runtime env ([a1e4999](https://github.com/perfect-panel/ppanel-web/commit/a1e4999))
|
||||
- **config**: Status Percentag ([8f322fb](https://github.com/perfect-panel/ppanel-web/commit/8f322fb))
|
||||
- **config**: SubLink ([1c61966](https://github.com/perfect-panel/ppanel-web/commit/1c61966))
|
||||
- **config**: Subscribe Link ([11ea821](https://github.com/perfect-panel/ppanel-web/commit/11ea821))
|
||||
- **content**: Parse subscription description and display features with icons ([3c5542a](https://github.com/perfect-panel/ppanel-web/commit/3c5542a))
|
||||
- **controller**: Order status ([8c6a097](https://github.com/perfect-panel/ppanel-web/commit/8c6a097))
|
||||
- **coupon**: Rename 'server' field to 'subscribe' in coupon form and update coupon update request type ([f8b6d82](https://github.com/perfect-panel/ppanel-web/commit/f8b6d82))
|
||||
- **dashboard**: Improve URL encoding for subscription links and enhance success message handling ([4983c33](https://github.com/perfect-panel/ppanel-web/commit/4983c33))
|
||||
- **dashboard**: Update date display to use start_time if available ([e551232](https://github.com/perfect-panel/ppanel-web/commit/e551232))
|
||||
- **dashboard**: Correct progress value calculations and update groupId accessor ([36c7667](https://github.com/perfect-panel/ppanel-web/commit/36c7667))
|
||||
- **dashboard**: Display subscription creation date in user dashboard ([d0e6df0](https://github.com/perfect-panel/ppanel-web/commit/d0e6df0))
|
||||
- **dashboard**: Format Bytes ([d8b0bd9](https://github.com/perfect-panel/ppanel-web/commit/d8b0bd9))
|
||||
- **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))
|
||||
- **deps**: Typescript config ([34e24b8](https://github.com/perfect-panel/ppanel-web/commit/34e24b8))
|
||||
- **deps**: Update clipboard ([5572710](https://github.com/perfect-panel/ppanel-web/commit/5572710))
|
||||
- **editor**: Change value ([4fdfeb2](https://github.com/perfect-panel/ppanel-web/commit/4fdfeb2))
|
||||
- **email**: Update platform configuration handling to use current ref for consistency ([c90175b](https://github.com/perfect-panel/ppanel-web/commit/c90175b))
|
||||
- **footer**: Email address ([a451f44](https://github.com/perfect-panel/ppanel-web/commit/a451f44))
|
||||
- **forms**: Add step attribute to number inputs for better value control ([b8f4f1e](https://github.com/perfect-panel/ppanel-web/commit/b8f4f1e))
|
||||
- **icon**: Comment out unused icon collection imports ([f17bf8d](https://github.com/perfect-panel/ppanel-web/commit/f17bf8d))
|
||||
- **layout**: Remove unnecessary cookie initialization in Logout function ([3065c3a](https://github.com/perfect-panel/ppanel-web/commit/3065c3a))
|
||||
- **locale**: Default value ([937408f](https://github.com/perfect-panel/ppanel-web/commit/937408f))
|
||||
- **locale**: Document ([6f0fa20](https://github.com/perfect-panel/ppanel-web/commit/6f0fa20))
|
||||
- **locale**: Empty ([3832d20](https://github.com/perfect-panel/ppanel-web/commit/3832d20))
|
||||
- **locale**: Input Placeholder Webhook Domain ([bca0935](https://github.com/perfect-panel/ppanel-web/commit/bca0935))
|
||||
- **locale**: Language Select ([0befdb0](https://github.com/perfect-panel/ppanel-web/commit/0befdb0))
|
||||
- **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))
|
||||
- **locales**: Fixed description in multilingual files, updated text related to email registration functionality ([c356bc2](https://github.com/perfect-panel/ppanel-web/commit/c356bc2))
|
||||
- **locales**: Order recharge related fields ([35210fe](https://github.com/perfect-panel/ppanel-web/commit/35210fe))
|
||||
- **locales**: Removed language file import to clean up unnecessary language support ([68f6ab2](https://github.com/perfect-panel/ppanel-web/commit/68f6ab2))
|
||||
- **locales**: Removed multilingual files to clean up unnecessary language support ([5b151cd](https://github.com/perfect-panel/ppanel-web/commit/5b151cd))
|
||||
- **locale**: Subscription Path Description ([4c67387](https://github.com/perfect-panel/ppanel-web/commit/4c67387))
|
||||
- **locales**: Update custom HTML description for clarity across multiple languages ([557c5cd](https://github.com/perfect-panel/ppanel-web/commit/557c5cd))
|
||||
- **locales**: Update custom HTML description in language file, ([87381da](https://github.com/perfect-panel/ppanel-web/commit/87381da))
|
||||
- **locales**: Update expiration time description from minutes to seconds in multiple languages ([5bac933](https://github.com/perfect-panel/ppanel-web/commit/5bac933))
|
||||
- **locales**: Update Hong Kong ([6d0d069](https://github.com/perfect-panel/ppanel-web/commit/6d0d069))
|
||||
- **locales**: Update invite code text to indicate it's optional ([6a34bfb](https://github.com/perfect-panel/ppanel-web/commit/6a34bfb))
|
||||
- **logs**: Update log display to render key-value pairs and remove badge ([5ea6489](https://github.com/perfect-panel/ppanel-web/commit/5ea6489))
|
||||
- **metadata**: Global metadata ([15d5ecf](https://github.com/perfect-panel/ppanel-web/commit/15d5ecf))
|
||||
- **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))
|
||||
- **node**: Handle potential null value for online users count ([fa2fb28](https://github.com/perfect-panel/ppanel-web/commit/fa2fb28))
|
||||
- **node**: Locale and form ([38be4d5](https://github.com/perfect-panel/ppanel-web/commit/38be4d5))
|
||||
- **node**: Port config ([a20834a](https://github.com/perfect-panel/ppanel-web/commit/a20834a))
|
||||
- **node**: Reality config ([fadd17f](https://github.com/perfect-panel/ppanel-web/commit/fadd17f))
|
||||
- **node**: Service Name config ([d0be685](https://github.com/perfect-panel/ppanel-web/commit/d0be685))
|
||||
- **node**: TLS config ([57fae12](https://github.com/perfect-panel/ppanel-web/commit/57fae12))
|
||||
- **node**: Trojan protocol config ([7e1eb90](https://github.com/perfect-panel/ppanel-web/commit/7e1eb90))
|
||||
- **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))
|
||||
- **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**: Add notification URL field to payment management interface ([5c710e1](https://github.com/perfect-panel/ppanel-web/commit/5c710e1))
|
||||
- **payment**: Config and types ([b0c87fb](https://github.com/perfect-panel/ppanel-web/commit/b0c87fb))
|
||||
- **payment**: Fix payment related type definitions and update payment method references ([c3138a8](https://github.com/perfect-panel/ppanel-web/commit/c3138a8))
|
||||
- **payment**: Qrcode ([a9a535b](https://github.com/perfect-panel/ppanel-web/commit/a9a535b))
|
||||
- **payment**: Refactor payment form placeholder and update localization files ([4a4d364](https://github.com/perfect-panel/ppanel-web/commit/4a4d364))
|
||||
- **payment**: Refactor purchaseCheckout usage and remove redundant code ([a5e2079](https://github.com/perfect-panel/ppanel-web/commit/a5e2079))
|
||||
- **payment**: Replace window.open with window.location.href for checkout links ([1d8c765](https://github.com/perfect-panel/ppanel-web/commit/1d8c765))
|
||||
- **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))
|
||||
- **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))
|
||||
- **profile**: Restore filter to ensure only valid OAuth accounts are shown ([315c8f9](https://github.com/perfect-panel/ppanel-web/commit/315c8f9))
|
||||
- **purchasing**: Update payment type to lowercase and add optional chaining for discounts ([c06ea49](https://github.com/perfect-panel/ppanel-web/commit/c06ea49))
|
||||
- **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))
|
||||
- **register**: Adjust user email verification logic to handle domain suffix checks correctly ([686aa2d](https://github.com/perfect-panel/ppanel-web/commit/686aa2d))
|
||||
- **request**: Add error code 40005 to trigger logout ([71bf002](https://github.com/perfect-panel/ppanel-web/commit/71bf002))
|
||||
- **request**: Locale ([37d408f](https://github.com/perfect-panel/ppanel-web/commit/37d408f))
|
||||
- **rule-form**: Remove redundant rule set display ([6e0c9b6](https://github.com/perfect-panel/ppanel-web/commit/6e0c9b6))
|
||||
- **rules**: Remove unused MATCH rule ([674a01c](https://github.com/perfect-panel/ppanel-web/commit/674a01c))
|
||||
- **site**: Add image upload functionality for site logo configuration ([4ea6e4a](https://github.com/perfect-panel/ppanel-web/commit/4ea6e4a))
|
||||
- **site**: Se ref to store site configuration for updates ([0c8f091](https://github.com/perfect-panel/ppanel-web/commit/0c8f091))
|
||||
- **sort**: Refactor sorting logic in NodeTable and SubscribeTable components for improved clarity and performance ([331bbea](https://github.com/perfect-panel/ppanel-web/commit/331bbea))
|
||||
- **subscribe**: Add value prop to field in subscription form for proper state management ([328838d](https://github.com/perfect-panel/ppanel-web/commit/328838d))
|
||||
- **subscribe**: Discount ([35a9f69](https://github.com/perfect-panel/ppanel-web/commit/35a9f69))
|
||||
- **subscribe**: Extract Domain ([40d61a9](https://github.com/perfect-panel/ppanel-web/commit/40d61a9))
|
||||
- **subscribe**: Handle optional values in price and discount calculations ([5939763](https://github.com/perfect-panel/ppanel-web/commit/5939763))
|
||||
- **subscribe**: Jumps and internationalization ([13fdec3](https://github.com/perfect-panel/ppanel-web/commit/13fdec3))
|
||||
- **subscribe**: Refactor discount calculations and default selection logic in subscription forms ([423b240](https://github.com/perfect-panel/ppanel-web/commit/423b240))
|
||||
- **subscribe**: Server group id ([90e6764](https://github.com/perfect-panel/ppanel-web/commit/90e6764))
|
||||
- **subscribe**: Update default selection logic in subscription form to ensure proper state management ([ef15374](https://github.com/perfect-panel/ppanel-web/commit/ef15374))
|
||||
- **subscribe**: Update forms to include refetch functionality and improve toast messages ([fc55e95](https://github.com/perfect-panel/ppanel-web/commit/fc55e95))
|
||||
- **subscribe**: Update payment return URL ([2b80496](https://github.com/perfect-panel/ppanel-web/commit/2b80496))
|
||||
- **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))
|
||||
- **subscribe**: Update value validation to check for number type in subscribe form ([6de29d5](https://github.com/perfect-panel/ppanel-web/commit/6de29d5))
|
||||
- **subscription**: Add reset functionality for user subscription token ([39e89bf](https://github.com/perfect-panel/ppanel-web/commit/39e89bf))
|
||||
- **table**: Update privacy policy tab translation key and remove unnecessary requestType from OAuth callback ([14b3af5](https://github.com/perfect-panel/ppanel-web/commit/14b3af5))
|
||||
- **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))
|
||||
- **turnstile**: Turnstile_site_key ([0327b73](https://github.com/perfect-panel/ppanel-web/commit/0327b73))
|
||||
- **type**: Fix ts type check error ([3cb0629](https://github.com/perfect-panel/ppanel-web/commit/3cb0629))
|
||||
- **types**: Add 'gift_amount' field to API type definitions ([8f8a12a](https://github.com/perfect-panel/ppanel-web/commit/8f8a12a))
|
||||
- **types**: Checking ([2992824](https://github.com/perfect-panel/ppanel-web/commit/2992824))
|
||||
- **types**: Order type ([c7e50a9](https://github.com/perfect-panel/ppanel-web/commit/c7e50a9))
|
||||
- **ui**: Bugs ([b023d0f](https://github.com/perfect-panel/ppanel-web/commit/b023d0f))
|
||||
- **ui**: Components ([a7927d7](https://github.com/perfect-panel/ppanel-web/commit/a7927d7))
|
||||
- **ui**: Fix json formatting ([e1ddd94](https://github.com/perfect-panel/ppanel-web/commit/e1ddd94))
|
||||
- **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))
|
||||
- **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**: Add the 'gift_amount' field to the user service's type definition ([6301409](https://github.com/perfect-panel/ppanel-web/commit/6301409))
|
||||
- **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))
|
||||
- **user**: Update user identifier field and localizations ([1b6befa](https://github.com/perfect-panel/ppanel-web/commit/1b6befa))
|
||||
- **user**: Update user subscribe display ([3bb714d](https://github.com/perfect-panel/ppanel-web/commit/3bb714d))
|
||||
- **utils**: Login redirect url ([cbe5f0d](https://github.com/perfect-panel/ppanel-web/commit/cbe5f0d))
|
||||
- More bugs ([2d88a3a](https://github.com/perfect-panel/ppanel-web/commit/2d88a3a))
|
||||
|
||||
### 👷 Build System
|
||||
|
||||
- **config**: Update pm2 config ([d95b425](https://github.com/perfect-panel/ppanel-web/commit/d95b425))
|
||||
|
||||
### 💄 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))
|
||||
- **document**: Update ([0a8109b](https://github.com/perfect-panel/ppanel-web/commit/0a8109b))
|
||||
- **globals**: Refactor delete confirmation button and update badge styles in node and subscribe tables ([30ae781](https://github.com/perfect-panel/ppanel-web/commit/30ae781))
|
||||
- **locales**: Remove unused subscription labels from multiple locale files ([fb0c510](https://github.com/perfect-panel/ppanel-web/commit/fb0c510))
|
||||
- **locales**: Update server.json to reorganize relay mode options and improve labels ([701cdee](https://github.com/perfect-panel/ppanel-web/commit/701cdee))
|
||||
- **node**: Form ([d5f5add](https://github.com/perfect-panel/ppanel-web/commit/d5f5add))
|
||||
- **node**: Improve layout and spacing in NodeStatusCell component ([136287d](https://github.com/perfect-panel/ppanel-web/commit/136287d))
|
||||
- **node**: Protocol Tab ([2bcb925](https://github.com/perfect-panel/ppanel-web/commit/2bcb925))
|
||||
- **time-slot**: Add chart display ([c44ad47](https://github.com/perfect-panel/ppanel-web/commit/c44ad47))
|
||||
- **ui**: Update mobile style ([eda18bc](https://github.com/perfect-panel/ppanel-web/commit/eda18bc))
|
||||
- Update node secret UI and add telephone code field to authentication form ([770932e](https://github.com/perfect-panel/ppanel-web/commit/770932e))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- **readme**: License name ([74cb16b](https://github.com/perfect-panel/ppanel-web/commit/74cb16b))
|
||||
|
||||
### 🔧 Continuous Integration
|
||||
|
||||
- **github**: Release docker ([5af60aa](https://github.com/perfect-panel/ppanel-web/commit/5af60aa))
|
||||
- **step**: Update step name ([9eca618](https://github.com/perfect-panel/ppanel-web/commit/9eca618))
|
||||
|
||||
<a name="readme-top"></a>
|
||||
|
||||
# Changelog
|
||||
|
||||
# [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))
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -8,13 +8,22 @@ import LoginLottie from '@workspace/ui/lotties/login.json';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import EmailAuthForm from './email/auth-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('auth');
|
||||
const { common } = useGlobalStore();
|
||||
const { common, user } = useGlobalStore();
|
||||
const { site } = common;
|
||||
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [router, user]);
|
||||
|
||||
return (
|
||||
<main className='bg-muted/50 flex h-full min-h-screen items-center'>
|
||||
<div className='flex size-full flex-auto flex-col justify-center lg:flex-row'>
|
||||
|
||||
@@ -46,9 +46,6 @@ export default function Page() {
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
placeholder: t('status'),
|
||||
@@ -57,6 +54,9 @@ export default function Page() {
|
||||
{ label: t('disabled'), value: '0' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getAdsList({
|
||||
|
||||
@@ -81,9 +81,13 @@ export default function AnnouncementForm<T extends Record<string, any>>({
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
|
||||
<form
|
||||
id='notice-form'
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='title'
|
||||
|
||||
+25
-13
@@ -232,16 +232,6 @@ export default function SubscribeAppForm<
|
||||
name: 'is_default',
|
||||
type: 'boolean',
|
||||
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',
|
||||
@@ -252,10 +242,32 @@ export default function SubscribeAppForm<
|
||||
]}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(
|
||||
field.name,
|
||||
value.filter((item) => item.url),
|
||||
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>
|
||||
@@ -1,148 +0,0 @@
|
||||
'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 { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('apple');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'apple'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'apple',
|
||||
});
|
||||
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('teamId')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('teamIdDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='ABCDE1FGHI'
|
||||
value={data?.config?.team_id}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
team_id: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('keyId')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('keyIdDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='ABC1234567'
|
||||
value={data?.config?.key_id}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
key_id: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('clientId')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='com.your.app.service'
|
||||
value={data?.config?.client_id}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
client_id: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='align-top'>
|
||||
<Label>{t('clientSecret')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Textarea
|
||||
className='h-20'
|
||||
placeholder={`-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----`}
|
||||
defaultValue={data?.config?.client_secret}
|
||||
onBlur={(e) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
client_secret: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</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'
|
||||
value={data?.config.redirect_url}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
redirect_url: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
'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,325 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
testEmailSend,
|
||||
updateAuthMethodConfig,
|
||||
} from '@/services/admin/authMethod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { HTMLEditor } from '@workspace/ui/custom-components/editor';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { LogsTable } from '../log';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('email');
|
||||
const ref = useRef<Partial<API.AuthMethodConfig>>({});
|
||||
const [email, setEmail] = useState<string>();
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'email'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'email',
|
||||
});
|
||||
ref.current = data.data as API.AuthMethodConfig;
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...ref.current,
|
||||
[key]: value,
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t('saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='settings'>
|
||||
<TabsList className='h-full flex-wrap'>
|
||||
<TabsTrigger value='settings'>{t('settings')}</TabsTrigger>
|
||||
<TabsTrigger value='template'>{t('template')}</TabsTrigger>
|
||||
<TabsTrigger value='logs'>{t('logs')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='settings'>
|
||||
<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('emailVerification')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('emailVerificationDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.config?.enable_verify}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig('config', { ...data?.config, enable_verify: checked })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('emailSuffixWhitelist')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('emailSuffixWhitelistDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.config?.enable_domain_suffix}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig('config', { ...data?.config, enable_domain_suffix: checked })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='align-top'>
|
||||
<Label>{t('whitelistSuffixes')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('whitelistSuffixesDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Textarea
|
||||
className='h-32'
|
||||
placeholder={t('whitelistSuffixesPlaceholder')}
|
||||
defaultValue={data?.config?.domain_suffix_list}
|
||||
onBlur={(e) =>
|
||||
updateConfig('config', { ...data?.config, domain_suffix_list: e.target.value })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('smtpServerAddress')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('smtpServerAddressDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config?.platform_config?.host}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
platform_config: {
|
||||
...data?.platform_config,
|
||||
host: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('smtpServerPort')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('smtpServerPortDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
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>
|
||||
<TableCell>
|
||||
<Label>{t('sendTestEmail')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('sendTestEmailDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='flex items-center gap-2 text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='test@example.com'
|
||||
value={email}
|
||||
type='email'
|
||||
onValueChange={(value) => setEmail(value as string)}
|
||||
/>
|
||||
<Button
|
||||
disabled={!email || isFetching}
|
||||
onClick={async () => {
|
||||
if (!email) return;
|
||||
try {
|
||||
await testEmailSend({ email });
|
||||
toast.success(t('sendSuccess'));
|
||||
} catch {
|
||||
toast.error(t('sendFailure'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('sendTestEmail')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='template'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
{[
|
||||
'verify_email_template',
|
||||
'expiration_email_template',
|
||||
'maintenance_email_template',
|
||||
'traffic_exceed_email_template',
|
||||
].map((templateKey) => (
|
||||
<Card key={templateKey}>
|
||||
<CardHeader>
|
||||
<CardTitle>{t(`${templateKey}`)}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(`${templateKey}Description`, { after: '{{', before: '}}' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<HTMLEditor
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config?.[templateKey] as string}
|
||||
onBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
[templateKey]: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='logs'>
|
||||
<LogsTable type='email' />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'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 { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('facebook');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'facebook'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'facebook',
|
||||
});
|
||||
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('clientId')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='1234567890123456'
|
||||
value={data?.config?.client_id}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
client_id: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='align-top'>
|
||||
<Label>{t('clientSecret')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='1234567890abcdef1234567890abcdef'
|
||||
value={data?.config?.client_secret}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
client_secret: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const appleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
team_id: z.string().optional(),
|
||||
key_id: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
redirect_url: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type AppleFormData = z.infer<typeof appleSchema>;
|
||||
|
||||
export default function AppleForm() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'apple'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'apple',
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<AppleFormData>({
|
||||
resolver: zodResolver(appleSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
config: {
|
||||
team_id: '',
|
||||
key_id: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
redirect_url: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled || false,
|
||||
config: {
|
||||
team_id: data.config?.team_id || '',
|
||||
key_id: data.config?.key_id || '',
|
||||
client_id: data.config?.client_id || '',
|
||||
client_secret: data.config?.client_secret || '',
|
||||
redirect_url: data.config?.redirect_url || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: AppleFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
...values.config,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:apple' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('apple.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('apple.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('apple.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form id='apple-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-2 pt-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('apple.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('apple.enableDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.team_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('apple.teamId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='ABCDE1FGHI'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('apple.teamIdDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.key_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('apple.keyId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='ABC1234567'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('apple.keyIdDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('apple.clientId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='com.your.app.service'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('apple.clientIdDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('apple.clientSecret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='h-20'
|
||||
placeholder={`-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----`}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('apple.clientSecretDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.redirect_url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('apple.redirectUri')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='https://your-domain.com'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('apple.redirectUriDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='apple-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { uid } from 'radash';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const deviceSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string().default('device'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: z
|
||||
.object({
|
||||
show_ads: z.boolean().optional(),
|
||||
only_real_device: z.boolean().optional(),
|
||||
enable_security: z.boolean().optional(),
|
||||
security_secret: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type DeviceFormData = z.infer<typeof deviceSchema>;
|
||||
|
||||
export default function DeviceForm() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'device'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'device',
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<DeviceFormData>({
|
||||
resolver: zodResolver(deviceSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: 'device',
|
||||
enabled: false,
|
||||
config: {
|
||||
show_ads: false,
|
||||
only_real_device: false,
|
||||
enable_security: false,
|
||||
security_secret: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: DeviceFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function generateSecurityKey() {
|
||||
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)}`;
|
||||
form.setValue('config.security_secret', formatted);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:devices' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('device.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('device.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('device.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='device-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('device.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('device.enableDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.show_ads'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('device.showAds')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('device.showAdsDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.only_real_device'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('device.blockVirtualMachine')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('device.blockVirtualMachineDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.enable_security'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('device.enableSecurity')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('device.enableSecurityDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.security_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('device.communicationKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='e.g., 12345678-1234-1234-1234-123456789abc'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
suffix={
|
||||
<div className='bg-muted flex h-9 items-center text-nowrap px-3'>
|
||||
<Icon
|
||||
icon='mdi:dice-multiple'
|
||||
onClick={generateSecurityKey}
|
||||
className='size-4 cursor-pointer'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('device.communicationKeyDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='device-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@workspace/ui/components/sheet';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { LogsTable } from '../log';
|
||||
|
||||
export default function EmailLogsTable() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:email-newsletter' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('email.logs')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('email.logsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[800px] max-w-full md:max-w-screen-lg'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('email.logs')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<div className='px-6 pt-4'>
|
||||
<LogsTable type='email' />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
testEmailSend,
|
||||
updateAuthMethodConfig,
|
||||
} from '@/services/admin/authMethod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { HTMLEditor } from '@workspace/ui/custom-components/editor';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const emailSettingsSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string().default('email'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: z
|
||||
.object({
|
||||
enable_verify: z.boolean().default(false),
|
||||
enable_domain_suffix: z.boolean().default(false),
|
||||
domain_suffix_list: z.string().optional(),
|
||||
verify_email_template: z.string().optional(),
|
||||
expiration_email_template: z.string().optional(),
|
||||
maintenance_email_template: z.string().optional(),
|
||||
traffic_exceed_email_template: z.string().optional(),
|
||||
platform: z.string().default('smtp'),
|
||||
platform_config: z
|
||||
.object({
|
||||
host: z.string().optional(),
|
||||
port: z.coerce.number().optional(),
|
||||
ssl: z.boolean().default(false),
|
||||
user: z.string().optional(),
|
||||
pass: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
|
||||
|
||||
export default function EmailSettingsForm() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState<string>();
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'email'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'email',
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<EmailSettingsFormData>({
|
||||
resolver: zodResolver(emailSettingsSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: 'email',
|
||||
enabled: false,
|
||||
config: {
|
||||
enable_verify: false,
|
||||
enable_domain_suffix: false,
|
||||
domain_suffix_list: '',
|
||||
verify_email_template: '',
|
||||
expiration_email_template: '',
|
||||
maintenance_email_template: '',
|
||||
traffic_exceed_email_template: '',
|
||||
platform: 'smtp',
|
||||
platform_config: {
|
||||
host: '',
|
||||
port: 587,
|
||||
ssl: false,
|
||||
user: '',
|
||||
pass: '',
|
||||
from: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: EmailSettingsFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...values,
|
||||
config: {
|
||||
...values.config,
|
||||
platform: 'smtp',
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:email-outline' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('email.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('email.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('email.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='email-settings-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<Tabs defaultValue='basic' className='space-y-2'>
|
||||
<TabsList className='flex h-full w-full flex-wrap *:flex-auto md:flex-nowrap'>
|
||||
<TabsTrigger value='basic'>{t('email.basicSettings')}</TabsTrigger>
|
||||
<TabsTrigger value='smtp'>{t('email.smtpSettings')}</TabsTrigger>
|
||||
<TabsTrigger value='verify'>{t('email.verifyTemplate')}</TabsTrigger>
|
||||
<TabsTrigger value='expiration'>{t('email.expirationTemplate')}</TabsTrigger>
|
||||
<TabsTrigger value='maintenance'>{t('email.maintenanceTemplate')}</TabsTrigger>
|
||||
<TabsTrigger value='traffic'>{t('email.trafficTemplate')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='basic' className='space-y-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('email.enableDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.enable_verify'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.emailVerification')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('email.emailVerificationDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.enable_domain_suffix'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.emailSuffixWhitelist')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('email.emailSuffixWhitelistDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.domain_suffix_list'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.whitelistSuffixes')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='h-32'
|
||||
placeholder={t('email.whitelistSuffixesPlaceholder')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('email.whitelistSuffixesDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='smtp' className='space-y-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.host'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.smtpServerAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('email.inputPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('email.smtpServerAddressDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.port'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.smtpServerPort')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
placeholder='587'
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('email.smtpServerPortDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.ssl'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.smtpEncryptionMethod')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('email.smtpEncryptionMethodDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.user'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.smtpAccount')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('email.inputPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('email.smtpAccountDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.pass'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.smtpPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='password'
|
||||
placeholder={t('email.inputPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('email.smtpPasswordDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.from'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.senderAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('email.inputPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('email.senderAddressDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-2 border-t pt-4'>
|
||||
<FormLabel>{t('email.sendTestEmail')}</FormLabel>
|
||||
<div className='flex items-center gap-2'>
|
||||
<EnhancedInput
|
||||
placeholder='test@example.com'
|
||||
type='email'
|
||||
value={testEmail}
|
||||
onValueChange={(value) => setTestEmail(value as string)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
disabled={!testEmail || isFetching}
|
||||
onClick={async () => {
|
||||
if (!testEmail) return;
|
||||
try {
|
||||
await testEmailSend({ email: testEmail });
|
||||
toast.success(t('email.sendSuccess'));
|
||||
} catch {
|
||||
toast.error(t('email.sendFailure'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('email.sendTestEmail')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('email.sendTestEmailDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='verify' className='space-y-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.verify_email_template'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.verifyEmailTemplate')}</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
placeholder={t('email.inputPlaceholder')}
|
||||
value={field.value}
|
||||
onBlur={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='mt-4 space-y-2 border-t pt-4'>
|
||||
<p className='text-muted-foreground text-sm font-medium'>
|
||||
{t('email.templateVariables.title')}
|
||||
</p>
|
||||
<div className='text-muted-foreground space-y-2 text-xs'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.Type}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.type.description')}</span>
|
||||
</div>
|
||||
<div className='pl-6 text-orange-600 dark:text-orange-400'>
|
||||
💡 {t('email.templateVariables.type.conditionalSyntax')}
|
||||
<br />
|
||||
<code className='rounded bg-orange-50 px-1 text-xs dark:bg-orange-900/20'>
|
||||
{'{{if eq .Type 1}}...{{else}}...{{end}}'}
|
||||
</code>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.SiteLogo}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.siteLogo.description')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.SiteName}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.siteName.description')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.Expire}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.expire.description')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.Code}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.code.description')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='expiration' className='space-y-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.expiration_email_template'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.expirationEmailTemplate')}</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
placeholder={t('email.inputPlaceholder')}
|
||||
value={field.value}
|
||||
onBlur={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='mt-4 space-y-2 border-t pt-4'>
|
||||
<p className='text-muted-foreground text-sm font-medium'>
|
||||
{t('email.templateVariables.title')}
|
||||
</p>
|
||||
<div className='text-muted-foreground space-y-2 text-xs'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.SiteLogo}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.siteLogo.description')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.SiteName}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.siteName.description')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.ExpireDate}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.expireDate.description')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='maintenance' className='space-y-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.maintenance_email_template'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.maintenanceEmailTemplate')}</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
placeholder={t('email.inputPlaceholder')}
|
||||
value={field.value}
|
||||
onBlur={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='mt-4 space-y-2 border-t pt-4'>
|
||||
<p className='text-muted-foreground text-sm font-medium'>
|
||||
{t('email.templateVariables.title')}
|
||||
</p>
|
||||
<div className='text-muted-foreground space-y-2 text-xs'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.SiteLogo}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.siteLogo.description')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.SiteName}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.siteName.description')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.MaintenanceDate}}'}
|
||||
</code>
|
||||
<span>
|
||||
{t('email.templateVariables.maintenanceDate.description')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.MaintenanceTime}}'}
|
||||
</code>
|
||||
<span>
|
||||
{t('email.templateVariables.maintenanceTime.description')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='traffic' className='space-y-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.traffic_exceed_email_template'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email.trafficExceedEmailTemplate')}</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
placeholder={t('email.inputPlaceholder')}
|
||||
value={field.value}
|
||||
onBlur={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='mt-4 space-y-2 border-t pt-4'>
|
||||
<p className='text-muted-foreground text-sm font-medium'>
|
||||
{t('email.templateVariables.title')}
|
||||
</p>
|
||||
<div className='text-muted-foreground space-y-2 text-xs'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.SiteLogo}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.siteLogo.description')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
|
||||
{'{{.SiteName}}'}
|
||||
</code>
|
||||
<span>{t('email.templateVariables.siteName.description')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='email-settings-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const facebookSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
type FacebookFormData = z.infer<typeof facebookSchema>;
|
||||
|
||||
export default function FacebookForm() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'facebook'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'facebook',
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
// 移除 enabled: open,现在默认加载数据
|
||||
});
|
||||
|
||||
const form = useForm<FacebookFormData>({
|
||||
resolver: zodResolver(facebookSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled || false,
|
||||
client_id: data.config?.client_id || '',
|
||||
client_secret: data.config?.client_secret || '',
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: FacebookFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
client_id: values.client_id,
|
||||
client_secret: values.client_secret,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:facebook' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('facebook.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('facebook.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('facebook.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='facebook-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('facebook.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('facebook.enableDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('facebook.clientId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='1234567890123456'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('facebook.clientIdDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('facebook.clientSecret')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='1234567890abcdef1234567890abcdef'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
type='password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('facebook.clientSecretDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='facebook-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const githubSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
type GithubFormData = z.infer<typeof githubSchema>;
|
||||
|
||||
export default function GithubForm() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'github'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'github',
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<GithubFormData>({
|
||||
resolver: zodResolver(githubSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled || false,
|
||||
client_id: data.config?.client_id || '',
|
||||
client_secret: data.config?.client_secret || '',
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: GithubFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
client_id: values.client_id,
|
||||
client_secret: values.client_secret,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:github' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('github.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('github.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('github.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='github-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('github.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('github.enableDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('github.clientId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='e.g., Iv1.1234567890abcdef'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('github.clientIdDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('github.clientSecret')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='e.g., 1234567890abcdef1234567890abcdef12345678'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
type='password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('github.clientSecretDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='github-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const googleSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string().default('google'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: z
|
||||
.object({
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type GoogleFormData = z.infer<typeof googleSchema>;
|
||||
|
||||
export default function GoogleForm() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'google'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'google',
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<GoogleFormData>({
|
||||
resolver: zodResolver(googleSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: 'google',
|
||||
enabled: false,
|
||||
config: {
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: GoogleFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:google' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('google.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('google.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('google.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='google-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('google.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('google.enableDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('google.clientId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='123456789-abc123def456.apps.googleusercontent.com'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('google.clientIdDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('google.clientSecret')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
type='password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('google.clientSecretDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='google-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@workspace/ui/components/sheet';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { LogsTable } from '../log';
|
||||
|
||||
export default function PhoneLogsTable() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:phone-log' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('phone.logs')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('phone.logsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[800px] max-w-full md:max-w-screen-lg'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('phone.logs')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<div className='px-6 pt-4'>
|
||||
<LogsTable type='mobile' />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
getSmsPlatform,
|
||||
testSmsSend,
|
||||
updateAuthMethodConfig,
|
||||
} from '@/services/admin/authMethod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@workspace/ui/components/form';
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import TagInput from '@workspace/ui/custom-components/tag-input';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const phoneSettingsSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string().default('mobile'),
|
||||
enabled: z.boolean().default(false),
|
||||
config: z
|
||||
.object({
|
||||
enable_whitelist: z.boolean().optional(),
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
platform: z.string().optional(),
|
||||
platform_config: z
|
||||
.object({
|
||||
access: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
template_code: z.string().optional(),
|
||||
sign_name: z.string().optional(),
|
||||
phone_number: z.string().optional(),
|
||||
template: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type PhoneSettingsFormData = z.infer<typeof phoneSettingsSchema>;
|
||||
|
||||
export default function PhoneSettingsForm() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testParams, setTestParams] = useState<API.TestSmsSendRequest>({
|
||||
telephone: '',
|
||||
area_code: '1',
|
||||
});
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'mobile'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'mobile',
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: platforms } = useQuery({
|
||||
queryKey: ['getSmsPlatform'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSmsPlatform();
|
||||
return data.data?.list;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<PhoneSettingsFormData>({
|
||||
resolver: zodResolver(phoneSettingsSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: 'mobile',
|
||||
enabled: false,
|
||||
config: {
|
||||
enable_whitelist: false,
|
||||
whitelist: [],
|
||||
platform: '',
|
||||
platform_config: {
|
||||
access: '',
|
||||
endpoint: '',
|
||||
secret: '',
|
||||
template_code: 'code',
|
||||
sign_name: '',
|
||||
phone_number: '',
|
||||
template: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPlatform = platforms?.find(
|
||||
(platform) => platform.platform === form.watch('config.platform'),
|
||||
);
|
||||
const { platform_url, platform_field_description: platformConfig } = selectedPlatform ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: PhoneSettingsFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:phone-settings' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('phone.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('phone.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('phone.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='phone-settings-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('phone.enableTip')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.enable_whitelist'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.whitelistValidation')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('phone.whitelistValidationTip')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.whitelist'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.whitelistAreaCode')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
placeholder='1, 852, 886, 888'
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('phone.whitelistAreaCodeTip')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.platform')}</FormLabel>
|
||||
<div className='flex items-center gap-1'>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{platforms?.map((item) => (
|
||||
<SelectItem key={item.platform} value={item.platform}>
|
||||
{item.platform}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{platform_url && (
|
||||
<Button size='sm' asChild>
|
||||
<Link href={platform_url} target='_blank'>
|
||||
{t('phone.applyPlatform')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormDescription>{t('phone.platformTip')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.access'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.accessLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
placeholder={t('phone.platformConfigTip', { key: platformConfig?.access })}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('phone.platformConfigTip', { key: platformConfig?.access })}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{platformConfig?.endpoint && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.endpointLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
placeholder={t('phone.platformConfigTip', {
|
||||
key: platformConfig?.endpoint,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('phone.platformConfigTip', { key: platformConfig?.endpoint })}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.secretLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='password'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
placeholder={t('phone.platformConfigTip', { key: platformConfig?.secret })}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('phone.platformConfigTip', { key: platformConfig?.secret })}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{platformConfig?.template_code && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.template_code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.templateCodeLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
placeholder={t('phone.platformConfigTip', {
|
||||
key: platformConfig?.template_code,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('phone.platformConfigTip', { key: platformConfig?.template_code })}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.sign_name && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.sign_name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.signNameLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
placeholder={t('phone.platformConfigTip', {
|
||||
key: platformConfig?.sign_name,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('phone.platformConfigTip', { key: platformConfig?.sign_name })}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.phone_number && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.phone_number'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.phoneNumberLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
placeholder={t('phone.platformConfigTip', {
|
||||
key: platformConfig?.phone_number,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('phone.platformConfigTip', { key: platformConfig?.phone_number })}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.code_variable && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.platform_config.template'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('phone.template')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={isFetching}
|
||||
placeholder={t('phone.placeholders.template', {
|
||||
code: platformConfig?.code_variable,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('phone.templateTip', { code: platformConfig?.code_variable })}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='space-y-4 border-t pt-4'>
|
||||
<div>
|
||||
<FormLabel>{t('phone.testSms')}</FormLabel>
|
||||
<p className='text-muted-foreground mb-3 text-sm'>{t('phone.testSmsTip')}</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<AreaCodeSelect
|
||||
value={testParams.area_code}
|
||||
onChange={(value) => {
|
||||
if (value.phone) {
|
||||
setTestParams((prev) => ({ ...prev, area_code: value.phone! }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EnhancedInput
|
||||
placeholder={t('phone.testSmsPhone')}
|
||||
value={testParams.telephone}
|
||||
onValueChange={(value) => {
|
||||
setTestParams((prev) => ({ ...prev, telephone: value as string }));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
disabled={!testParams.telephone || !testParams.area_code || isFetching}
|
||||
onClick={async () => {
|
||||
if (isFetching || !testParams.telephone || !testParams.area_code) return;
|
||||
try {
|
||||
await testSmsSend(testParams);
|
||||
toast.success(t('phone.sendSuccess'));
|
||||
} catch {
|
||||
toast.error(t('phone.sendFailed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('phone.testSms')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='phone-settings-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const telegramSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
bot: z.string().optional(),
|
||||
bot_token: z.string().optional(),
|
||||
});
|
||||
|
||||
type TelegramFormData = z.infer<typeof telegramSchema>;
|
||||
|
||||
export default function TelegramForm() {
|
||||
const t = useTranslations('auth-control');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'telegram'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'telegram',
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<TelegramFormData>({
|
||||
resolver: zodResolver(telegramSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
bot: '',
|
||||
bot_token: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled || false,
|
||||
bot: data.config?.bot || '',
|
||||
bot_token: data.config?.bot_token || '',
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: TelegramFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
bot: values.bot,
|
||||
bot_token: values.bot_token,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:telegram' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('telegram.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('telegram.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('telegram.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='telegram-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('telegram.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('telegram.enableDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='bot'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('telegram.clientId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='6123456789'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('telegram.clientIdDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='bot_token'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('telegram.clientSecret')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder='6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
type='password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('telegram.clientSecretDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='telegram-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
'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,17 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Invite } from './invite';
|
||||
import { Register } from './register';
|
||||
import { Verify } from './verify';
|
||||
import { VerifyCode } from './verify-code';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<Invite />
|
||||
<Register />
|
||||
<VerifyCode />
|
||||
<Verify />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
'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,91 +0,0 @@
|
||||
'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 { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('github');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'github'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'github',
|
||||
});
|
||||
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('clientId')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='e.g., Iv1.1234567890abcdef'
|
||||
value={data?.config?.client_id}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
client_id: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='align-top'>
|
||||
<Label>{t('clientSecret')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='e.g., 1234567890abcdef1234567890abcdef12345678'
|
||||
value={data?.config?.client_secret}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
client_secret: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'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 { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('google');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'google'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'google',
|
||||
});
|
||||
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('clientId')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='123456789-abc123def456.apps.googleusercontent.com'
|
||||
value={data?.config?.client_id}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
client_id: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='align-top'>
|
||||
<Label>{t('clientSecret')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
value={data?.config?.client_secret}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
client_secret: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AuthControl } from '@/config/navs';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
interface AuthControlLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AuthControlLayout({ children }: Readonly<AuthControlLayoutProps>) {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations('menu');
|
||||
if (!pathname) return null;
|
||||
return (
|
||||
<Tabs value={pathname}>
|
||||
<TabsList className='h-full flex-wrap'>
|
||||
{AuthControl.map((item) => (
|
||||
<TabsTrigger key={item.url} value={item.url} asChild>
|
||||
<Link href={item.url}>{t(item.title)}</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value={pathname}>{children}</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -66,10 +66,6 @@ export function LogsTable({ type }: { type: 'email' | 'mobile' }) {
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
// {
|
||||
// key: 'platform',
|
||||
// placeholder: t('platform'),
|
||||
// },
|
||||
{
|
||||
key: 'to',
|
||||
placeholder: t('to'),
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import AppleForm from './forms/apple-form';
|
||||
import DeviceForm from './forms/device-form';
|
||||
import EmailLogsTable from './forms/email-logs-table';
|
||||
import EmailSettingsForm from './forms/email-settings-form';
|
||||
import FacebookForm from './forms/facebook-form';
|
||||
import GithubForm from './forms/github-form';
|
||||
import GoogleForm from './forms/google-form';
|
||||
import PhoneLogsTable from './forms/phone-logs-table';
|
||||
import PhoneSettingsForm from './forms/phone-settings-form';
|
||||
import TelegramForm from './forms/telegram-form';
|
||||
|
||||
export default function Page() {
|
||||
return redirect('/dashboard/auth-control/general');
|
||||
const t = useTranslations('auth-control');
|
||||
|
||||
// 定义表单配置
|
||||
const formSections = [
|
||||
{
|
||||
title: t('communicationMethods'),
|
||||
forms: [
|
||||
{ component: EmailSettingsForm },
|
||||
{ component: EmailLogsTable },
|
||||
{ component: PhoneSettingsForm },
|
||||
{ component: PhoneLogsTable },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('socialAuthMethods'),
|
||||
forms: [
|
||||
{ component: AppleForm },
|
||||
{ component: GoogleForm },
|
||||
{ component: FacebookForm },
|
||||
{ component: GithubForm },
|
||||
{ component: TelegramForm },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('deviceAuthMethods'),
|
||||
forms: [{ component: DeviceForm }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='space-y-8'>
|
||||
{formSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<h2 className='mb-4 text-lg font-semibold'>{section.title}</h2>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{section.forms.map((form, formIndex) => {
|
||||
const FormComponent = form.component;
|
||||
return (
|
||||
<TableRow key={formIndex}>
|
||||
<TableCell>
|
||||
<FormComponent />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
getSmsPlatform,
|
||||
testSmsSend,
|
||||
updateAuthMethodConfig,
|
||||
} from '@/services/admin/authMethod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import TagInput from '@workspace/ui/custom-components/tag-input';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { LogsTable } from '../log';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('phone');
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'mobile'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'mobile',
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: platforms } = useQuery({
|
||||
queryKey: ['getSmsPlatform'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSmsPlatform();
|
||||
return data.data?.list;
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPlatform = platforms?.find(
|
||||
(platform) => platform.platform === data?.config?.platform,
|
||||
);
|
||||
const { platform_url, platform_field_description: platformConfig } = selectedPlatform ?? {};
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t('updateSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
const [params, setParams] = useState<API.TestSmsSendRequest>({
|
||||
telephone: '',
|
||||
area_code: '1',
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='settings' className='w-full'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='settings'>{t('settings')}</TabsTrigger>
|
||||
<TabsTrigger value='logs'>{t('logs')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='settings'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('enable')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('enableTip')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enabled}
|
||||
onCheckedChange={(checked) => updateConfig('enabled', checked)}
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('whitelistValidation')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('whitelistValidationTip')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
defaultValue={data?.config?.enable_whitelist}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig('config', { ...data?.config, enable_whitelist: checked })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('whitelistAreaCode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('whitelistAreaCodeTip')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='w-1/2 text-right'>
|
||||
<TagInput
|
||||
placeholder='1, 852, 886, 888'
|
||||
value={data?.config?.whitelist || []}
|
||||
onChange={(value) =>
|
||||
updateConfig('config', { ...data?.config, whitelist: value })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('platform')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('platformTip')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='flex items-center gap-1 text-right'>
|
||||
<Select
|
||||
value={data?.config?.platform}
|
||||
onValueChange={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
platform: value,
|
||||
})
|
||||
}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{platforms?.map((item) => (
|
||||
<SelectItem key={item.platform} value={item.platform}>
|
||||
{item.platform}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{platform_url && (
|
||||
<Button size='sm' asChild>
|
||||
<Link href={platform_url} target='_blank'>
|
||||
{t('applyPlatform')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('accessLabel')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('platformConfigTip', { key: platformConfig?.access })}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
value={data?.config?.platform_config.access ?? ''}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
platform_config: {
|
||||
...data?.config?.platform_config,
|
||||
access: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={isFetching}
|
||||
placeholder={t('platformConfigTip', { key: platformConfig?.access })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{platformConfig?.endpoint && (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('endpointLabel')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('platformConfigTip', { key: platformConfig?.endpoint })}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
value={data?.config?.platform_config.endpoint ?? ''}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
platform_config: {
|
||||
...data?.config?.platform_config,
|
||||
endpoint: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={isFetching}
|
||||
placeholder={t('platformConfigTip', { key: platformConfig?.endpoint })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<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>
|
||||
<TableCell>
|
||||
<Label>{t('templateCodeLabel')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('platformConfigTip', { key: platformConfig?.template_code })}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
value={data?.config?.platform_config?.template_code ?? 'code'}
|
||||
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}
|
||||
placeholder={t('platformConfigTip', {
|
||||
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>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('testSms')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('testSmsTip')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='flex items-center gap-2 text-right'>
|
||||
<AreaCodeSelect
|
||||
value={params.area_code}
|
||||
onChange={(value) => {
|
||||
if (value.phone) {
|
||||
setParams((prev) => ({ ...prev, area_code: value.phone! }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EnhancedInput
|
||||
placeholder={t('testSmsPhone')}
|
||||
value={params.telephone}
|
||||
onValueChange={(value) => {
|
||||
setParams((prev) => ({ ...prev, telephone: value as string }));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
disabled={!params.telephone || !params.area_code}
|
||||
onClick={async () => {
|
||||
if (isFetching || !params.telephone || !params.area_code) return;
|
||||
try {
|
||||
await testSmsSend(params);
|
||||
toast.success(t('sendSuccess'));
|
||||
} catch {
|
||||
toast.error(t('sendFailed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('testSms')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='logs'>
|
||||
<LogsTable type='mobile' />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'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 { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('telegram');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAuthMethodConfig', 'telegram'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: 'telegram',
|
||||
});
|
||||
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('clientId')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='6123456789'
|
||||
value={data?.config?.bot}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
bot: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('clientSecret')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
value={data?.config?.bot_token}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
bot_token: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -307,6 +307,7 @@ export default function CouponForm<T extends Record<string, any>>({
|
||||
<EnhancedInput
|
||||
placeholder={t('form.countPlaceholder')}
|
||||
type='number'
|
||||
step={1}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
@@ -327,6 +328,7 @@ export default function CouponForm<T extends Record<string, any>>({
|
||||
<EnhancedInput
|
||||
placeholder={t('form.userLimitPlaceholder')}
|
||||
type='number'
|
||||
step={1}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
|
||||
@@ -64,9 +64,6 @@ export default function Page() {
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'subscribe',
|
||||
placeholder: t('subscribe'),
|
||||
@@ -75,6 +72,9 @@ export default function Page() {
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getCouponList({
|
||||
|
||||
@@ -57,8 +57,13 @@ export default function Page(props: any) {
|
||||
{
|
||||
accessorKey: 'subscribe_id',
|
||||
header: t('subscribe'),
|
||||
cell: ({ row }) =>
|
||||
subscribeList?.find((item) => item.id === row.getValue('subscribe_id'))?.name,
|
||||
cell: ({ row }) => {
|
||||
const name = subscribeList?.find(
|
||||
(item) => item.id === row.getValue('subscribe_id'),
|
||||
)?.name;
|
||||
const quantity = row.original.quantity;
|
||||
return name ? `${name} × ${quantity}` : '';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
@@ -160,7 +165,6 @@ export default function Page(props: any) {
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{ key: 'search' },
|
||||
{
|
||||
key: 'status',
|
||||
placeholder: t('status.0'),
|
||||
@@ -177,6 +181,7 @@ export default function Page(props: any) {
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{ key: 'search' },
|
||||
].concat(
|
||||
props.userId
|
||||
? []
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function PaymentForm<T>({
|
||||
<FormLabel>{t('domain')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('domainPlaceholder', { example: 'http(s)://example.com' })}
|
||||
placeholder='http(s)://example.com'
|
||||
value={field.value}
|
||||
onValueChange={(value) => form.setValue('domain', value as string)}
|
||||
/>
|
||||
@@ -319,7 +319,8 @@ export default function PaymentForm<T>({
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
disabled={isEdit}
|
||||
// @ts-expect-error
|
||||
disabled={isEdit && Boolean(initialValues?.platform)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -370,6 +371,7 @@ export default function PaymentForm<T>({
|
||||
? configValues[fieldKey]
|
||||
: ''
|
||||
}
|
||||
disabled={fieldKey === 'webhook_secret'}
|
||||
onValueChange={(value) => {
|
||||
const newConfig = { ...configValues };
|
||||
newConfig[fieldKey] = value;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'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] = [];
|
||||
}
|
||||
|
||||
// 不插入 MATCH 规则,只用于标识默认规则组
|
||||
if (!rule.trim().startsWith('MATCH,')) {
|
||||
groups[policyGroup].push(cleanRule);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const checkIfDefaultRule = (originalRules: string[], groupName: string): boolean => {
|
||||
return originalRules.some((rule) => {
|
||||
const trimmedRule = rule.trim();
|
||||
if (!trimmedRule.startsWith('MATCH,')) return false;
|
||||
|
||||
// 检查 MATCH 规则是否属于当前组
|
||||
const parts = trimmedRule.split(',');
|
||||
if (parts.length >= 3) {
|
||||
const ruleGroup = parts[2]?.trim();
|
||||
return ruleGroup === groupName;
|
||||
}
|
||||
|
||||
return groupName === 'default';
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const isDefault = checkIfDefaultRule(allRules, group.name);
|
||||
|
||||
await createRuleGroup({
|
||||
name: group.name,
|
||||
rules: group?.rules.join('\n'),
|
||||
enable: false,
|
||||
tags: [],
|
||||
icon: '',
|
||||
type: 'default',
|
||||
default: isDefault,
|
||||
});
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'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 || '',
|
||||
type: values.type || 'default',
|
||||
default: false,
|
||||
});
|
||||
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: 'default',
|
||||
header: t('defaultRule'),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.default}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateRuleGroup({
|
||||
...row.original,
|
||||
default: checked,
|
||||
} as API.UpdateRuleGroupRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: t('type'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type || 'default';
|
||||
if (type === 'default') {
|
||||
return <Badge variant='default'>{t('default')}</Badge>;
|
||||
}
|
||||
if (type === 'reject') {
|
||||
return <Badge variant='destructive'>{t('reject')}</Badge>;
|
||||
}
|
||||
if (type === 'direct') {
|
||||
return <Badge variant='secondary'>{t('direct')}</Badge>;
|
||||
}
|
||||
return <Badge variant='default'>{t('default')}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('name'),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex items-center gap-2'>
|
||||
{row.original.icon && (
|
||||
<Image
|
||||
src={row.original.icon}
|
||||
alt={row.original.name}
|
||||
className='h-6 w-6 rounded-md'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
)}
|
||||
<span>{row.original.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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,
|
||||
type: values.type,
|
||||
default: row.default,
|
||||
});
|
||||
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,266 @@
|
||||
'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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@workspace/ui/components/select';
|
||||
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(''),
|
||||
type: z.string().default('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='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('type')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectType')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='default'>{t('default')}</SelectItem>
|
||||
<SelectItem value='reject'>{t('reject')}</SelectItem>
|
||||
<SelectItem value='direct'>{t('direct')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const protocols = ['shadowsocks', 'vmess', 'vless', 'trojan', 'hysteria2', 'tuic'];
|
||||
export const protocols = ['shadowsocks', 'vmess', 'vless', 'trojan', 'hysteria2', 'tuic', 'anytls'];
|
||||
|
||||
const nullableString = z.string().nullish();
|
||||
const portSchema = z.number().max(65535).nullish();
|
||||
@@ -58,7 +58,15 @@ const hysteria2Schema = z.object({
|
||||
|
||||
const tuicSchema = z.object({
|
||||
port: portSchema,
|
||||
security: z.string(),
|
||||
disable_sni: z.boolean().default(false),
|
||||
reduce_rtt: z.boolean().default(false),
|
||||
udp_relay_mode: z.string().default('native'),
|
||||
congestion_controller: z.string().default('bbr'),
|
||||
security_config: securityConfigSchema,
|
||||
});
|
||||
|
||||
const anytlsSchema = z.object({
|
||||
port: portSchema,
|
||||
security_config: securityConfigSchema,
|
||||
});
|
||||
|
||||
@@ -87,6 +95,10 @@ const protocolConfigSchema = z.discriminatedUnion('protocol', [
|
||||
protocol: z.literal('tuic'),
|
||||
config: tuicSchema,
|
||||
}),
|
||||
z.object({
|
||||
protocol: z.literal('anytls'),
|
||||
config: anytlsSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
const baseFormSchema = z.object({
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function GroupForm<T extends Record<string, any>>({
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.name')}</FormLabel>
|
||||
<FormLabel>{t('groupForm.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -108,7 +108,7 @@ export default function GroupForm<T extends Record<string, any>>({
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.description')}</FormLabel>
|
||||
<FormLabel>{t('groupForm.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -132,11 +132,11 @@ export default function GroupForm<T extends Record<string, any>>({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('group.form.cancel')}
|
||||
{t('groupForm.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('group.form.confirm')}
|
||||
{t('groupForm.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { UserDetail } from '@/app/dashboard/user/user-detail';
|
||||
import { ProTable } from '@/components/pro-table';
|
||||
import { getUserSubscribeById } 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 { Progress } from '@workspace/ui/components/progress';
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@workspace/ui/components/sheet';
|
||||
import { formatBytes, formatDate } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface NodeDetailDialogProps {
|
||||
node: API.Server;
|
||||
children?: React.ReactNode;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
// 统一的用户订阅信息组件
|
||||
function UserSubscribeInfo({
|
||||
userId,
|
||||
type,
|
||||
}: {
|
||||
userId: number;
|
||||
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
|
||||
}) {
|
||||
const { data } = useQuery({
|
||||
enabled: userId !== 0,
|
||||
queryKey: ['getUserSubscribeById', userId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserSubscribeById({ id: userId });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return <span className='text-muted-foreground'>--</span>;
|
||||
|
||||
switch (type) {
|
||||
case 'account':
|
||||
if (!data.user_id) return <span className='text-muted-foreground'>--</span>;
|
||||
return <UserDetail id={data.user_id} />;
|
||||
|
||||
case 'subscribeName':
|
||||
if (!data.subscribe?.name) return <span className='text-muted-foreground'>--</span>;
|
||||
return <span className='text-sm'>{data.subscribe.name}</span>;
|
||||
|
||||
case 'subscribeId':
|
||||
if (!data.id) return <span className='text-muted-foreground'>--</span>;
|
||||
return <span className='font-mono text-sm'>{data.id}</span>;
|
||||
|
||||
case 'trafficUsage': {
|
||||
const usedTraffic = data.upload + data.download;
|
||||
const totalTraffic = data.traffic || 0;
|
||||
return (
|
||||
<div className='min-w-0 text-sm'>
|
||||
<div className='break-words'>
|
||||
{formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'expireTime': {
|
||||
if (!data.expire_time) return <span className='text-muted-foreground'>--</span>;
|
||||
const isExpired = data.expire_time < Date.now() / 1000;
|
||||
return (
|
||||
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
|
||||
<span className='text-sm'>{formatDate(data.expire_time)}</span>
|
||||
{isExpired && (
|
||||
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
|
||||
过期
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span className='text-muted-foreground'>--</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export function NodeDetailDialog({ node, children, trigger }: NodeDetailDialogProps) {
|
||||
const t = useTranslations('server.node');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { status } = node;
|
||||
const { online, cpu, mem, disk, updated_at } = status || {
|
||||
online: {},
|
||||
cpu: 0,
|
||||
mem: 0,
|
||||
disk: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
const isOnline = updated_at > 0;
|
||||
const onlineCount = (online && Object.keys(online).length) || 0;
|
||||
|
||||
// 转换在线用户数据为ProTable需要的格式
|
||||
const onlineUsersData = Object.entries(online || {}).map(([uid, ips]) => ({
|
||||
uid,
|
||||
ips: ips as string[],
|
||||
primaryIp: ips[0] || '',
|
||||
allIps: (ips as string[]).join(', '),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant='outline' size='sm'>
|
||||
{t('detail')}
|
||||
</Button>
|
||||
)}
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-full max-w-full sm:w-[600px] sm:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t('nodeDetail')} - {node.name}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className='-mx-6 h-[calc(100dvh-48px-16px-env(safe-area-inset-top))] space-y-2 overflow-y-auto px-6 py-4'>
|
||||
<h3 className='text-base font-medium'>{t('nodeStatus')}</h3>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex w-full flex-col gap-2 text-sm sm:flex-row sm:items-center sm:gap-3'>
|
||||
<Badge variant={isOnline ? 'default' : 'destructive'} className='w-fit text-xs'>
|
||||
{isOnline ? t('normal') : t('abnormal')}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('onlineCount')}: {onlineCount}
|
||||
</span>
|
||||
{isOnline && (
|
||||
<span className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{t('lastUpdated')}: {formatDate(updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOnline && (
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>CPU</span>
|
||||
<span>{cpu?.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={cpu ?? 0} className='h-1.5' max={100} />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>{t('memory')}</span>
|
||||
<span>{mem?.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={mem ?? 0} className='h-1.5' max={100} />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>{t('disk')}</span>
|
||||
<span>{disk?.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={disk ?? 0} className='h-1.5' max={100} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOnline && onlineCount > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 text-lg font-medium'>{t('onlineUsers')}</h3>
|
||||
<div className='overflow-x-auto'>
|
||||
<ProTable
|
||||
header={{
|
||||
hidden: true,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'allIps',
|
||||
header: t('ipAddresses'),
|
||||
cell: ({ row }) => {
|
||||
const ips = row.original.ips;
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-1'>
|
||||
{ips.map((ip: string, index: number) => (
|
||||
<div key={ip} className='whitespace-nowrap text-sm'>
|
||||
{index === 0 ? (
|
||||
<span className='font-medium'>{ip}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>{ip}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: t('user'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='account' />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscribeName',
|
||||
header: t('subscribeName'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeName' />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscribeId',
|
||||
header: t('subscribeId'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeId' />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'trafficUsage',
|
||||
header: t('trafficUsage'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='trafficUsage' />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'expireTime',
|
||||
header: t('expireTime'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='expireTime' />
|
||||
),
|
||||
},
|
||||
]}
|
||||
request={async () => ({
|
||||
list: onlineUsersData,
|
||||
total: onlineUsersData.length,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
trigger,
|
||||
title,
|
||||
}: Readonly<NodeFormProps<T>>) {
|
||||
const t = useTranslations('server.node');
|
||||
const t = useTranslations('server');
|
||||
const tf = useTranslations('server.nodeForm');
|
||||
const trs = useTranslations('server.relayModeOptions');
|
||||
const tsc = useTranslations('server.securityConfig');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
@@ -110,7 +113,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[520px] max-w-full md:max-w-screen-md'>
|
||||
<SheetContent className='w-[580px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
@@ -123,7 +126,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.name')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -141,10 +144,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='group_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.groupId')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.groupId')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, false>
|
||||
placeholder={t('form.selectNodeGroup')}
|
||||
placeholder={t('nodeForm.selectNodeGroup')}
|
||||
{...field}
|
||||
options={groups?.map((item) => ({
|
||||
value: item.id,
|
||||
@@ -166,10 +169,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='tags'
|
||||
render={({ field }) => (
|
||||
<FormItem className='col-span-3'>
|
||||
<FormLabel>{t('form.tags')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.tags')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
placeholder={t('form.tagsPlaceholder')}
|
||||
placeholder={t('nodeForm.tagsPlaceholder')}
|
||||
value={field.value || []}
|
||||
onChange={(value) => form.setValue(field.name, value)}
|
||||
/>
|
||||
@@ -183,7 +186,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='country'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.country')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.country')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -201,7 +204,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='city'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.city')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.city')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -221,7 +224,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='server_addr'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.serverAddr')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.serverAddr')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -239,12 +242,12 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='speed_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.speedLimit')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.speedLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
{...field}
|
||||
placeholder={t('form.speedLimitPlaceholder')}
|
||||
placeholder={t('nodeForm.speedLimitPlaceholder')}
|
||||
formatInput={(value) => unitConversion('bitsToMb', value)}
|
||||
formatOutput={(value) => unitConversion('mbToBits', value)}
|
||||
onValueChange={(value) => {
|
||||
@@ -262,7 +265,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='traffic_ratio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.trafficRatio')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.trafficRatio')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -284,13 +287,13 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='protocol'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.protocol')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.protocol')}</FormLabel>
|
||||
<FormControl>
|
||||
<Tabs
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
if (['trojan', 'hysteria2', 'tuic'].includes(value)) {
|
||||
if (['trojan', 'hysteria2', 'tuic', 'anytls'].includes(value)) {
|
||||
form.setValue('config.security', 'tls');
|
||||
}
|
||||
}}
|
||||
@@ -316,7 +319,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.method'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.encryptionMethod')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.encryptionMethod')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
@@ -326,7 +329,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.selectEncryptionMethod')} />
|
||||
<SelectValue placeholder={t('nodeForm.selectEncryptionMethod')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -357,7 +360,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.port'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.port')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.port')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -385,7 +388,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.server_key'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.serverKey')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.serverKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -402,11 +405,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{['vmess', 'vless', 'trojan', 'hysteria2', 'tuic'].includes(protocol) && (
|
||||
{['vmess', 'vless', 'trojan', 'hysteria2', 'tuic', 'anytls'].includes(protocol) && (
|
||||
<div className='grid gap-4'>
|
||||
<div
|
||||
className={cn('flex gap-4 *:flex-1', {
|
||||
'grid grid-cols-2': ['hysteria2'].includes(protocol),
|
||||
'grid grid-cols-2': ['hysteria2', 'tuic'].includes(protocol),
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
@@ -414,7 +417,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.port'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.port')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.port')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -438,7 +441,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.flow'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.flow')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.flow')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
@@ -448,7 +451,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.pleaseSelect')} />
|
||||
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -469,11 +472,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.obfs_password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.obfsPassword')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.obfsPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
placeholder={t('form.obfsPasswordPlaceholder')}
|
||||
placeholder={t('nodeForm.obfsPasswordPlaceholder')}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
@@ -488,10 +491,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.hop_ports'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.hopPorts')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.hopPorts')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.hopPortsPlaceholder')}
|
||||
placeholder={t('nodeForm.hopPortsPlaceholder')}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
@@ -507,7 +510,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.hop_interval'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.hopInterval')}</FormLabel>
|
||||
<FormLabel>{t('nodeForm.hopInterval')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -524,11 +527,114 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{protocol === 'tuic' && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.udp_relay_mode'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('nodeForm.udpRelayMode')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='native'>Native</SelectItem>
|
||||
<SelectItem value='quic'>QUIC</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.congestion_controller'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('nodeForm.congestionController')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='bbr'>BBR</SelectItem>
|
||||
<SelectItem value='cubic'>Cubic</SelectItem>
|
||||
<SelectItem value='reno'>Reno</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.disable_sni'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('nodeForm.disableSni')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='pt-2'>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(field.name, checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.reduce_rtt'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('nodeForm.reduceRtt')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='pt-2'>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(field.name, checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{['vmess', 'vless', 'trojan'].includes(protocol) && (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between p-3'>
|
||||
<CardTitle>{t('form.transportConfig')}</CardTitle>
|
||||
<CardTitle>{t('nodeForm.transportConfig')}</CardTitle>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.transport'
|
||||
@@ -543,7 +649,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.pleaseSelect')} />
|
||||
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -630,9 +736,12 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{(['vmess', 'vless', 'trojan'].includes(protocol) ||
|
||||
['anytls', 'tuic', 'hysteria2'].includes(protocol)) && (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between p-3'>
|
||||
<CardTitle>{t('form.securityConfig')}</CardTitle>
|
||||
<CardTitle>{t('nodeForm.securityConfig')}</CardTitle>
|
||||
{['vmess', 'vless', 'trojan'].includes(protocol) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.security'
|
||||
@@ -646,7 +755,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.pleaseSelect')} />
|
||||
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -663,8 +772,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CardHeader>
|
||||
{security !== 'none' && (
|
||||
{(['anytls', 'tuic', 'hysteria2'].includes(protocol) ||
|
||||
(['vmess', 'vless', 'trojan'].includes(protocol) &&
|
||||
security !== 'none')) && (
|
||||
<CardContent className='grid grid-cols-2 gap-4 p-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -684,6 +796,8 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Reality 特殊配置只在 vless + reality 时显示 */}
|
||||
{protocol === 'vless' && security === 'reality' && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -691,7 +805,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.security_config.reality_server_addr'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.security_config.serverAddress')}</FormLabel>
|
||||
<FormLabel>{t('securityConfig.serverAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
@@ -712,14 +826,16 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.security_config.reality_server_port'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.security_config.serverPort')}</FormLabel>
|
||||
<FormLabel>{t('securityConfig.serverPort')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
type='number'
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder={t('form.security_config.serverPortPlaceholder')}
|
||||
placeholder={t(
|
||||
'form.security_config.serverPortPlaceholder',
|
||||
)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
@@ -734,11 +850,13 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.security_config.reality_private_key'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.security_config.privateKey')}</FormLabel>
|
||||
<FormLabel>{t('securityConfig.privateKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
placeholder={t('form.security_config.privateKeyPlaceholder')}
|
||||
placeholder={t(
|
||||
'form.security_config.privateKeyPlaceholder',
|
||||
)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
@@ -753,11 +871,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.security_config.reality_public_key'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.security_config.publicKey')}</FormLabel>
|
||||
<FormLabel>{t('securityConfig.publicKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
placeholder={t('form.security_config.publicKeyPlaceholder')}
|
||||
placeholder={t('securityConfig.publicKeyPlaceholder')}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
@@ -772,11 +890,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
name='config.security_config.reality_short_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.security_config.shortId')}</FormLabel>
|
||||
<FormLabel>{t('securityConfig.shortId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
placeholder={t('form.security_config.shortIdPlaceholder')}
|
||||
placeholder={t('securityConfig.shortIdPlaceholder')}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
@@ -788,13 +906,14 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{protocol === 'vless' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.security_config.fingerprint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.security_config.fingerprint')}</FormLabel>
|
||||
<FormLabel>{t('securityConfig.fingerprint')}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
@@ -803,7 +922,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.pleaseSelect')} />
|
||||
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -818,11 +937,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='config.security_config.allow_insecure'
|
||||
@@ -833,8 +952,8 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
<div className='pt-2'>
|
||||
<Switch
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(field.name, checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -846,12 +965,13 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between p-3'>
|
||||
<CardTitle>{t('form.relayMode')}</CardTitle>
|
||||
<CardTitle>{t('nodeForm.relayMode')}</CardTitle>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='relay_mode'
|
||||
@@ -866,17 +986,13 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.selectRelayMode')} />
|
||||
<SelectValue placeholder={t('nodeForm.selectRelayMode')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='none'>
|
||||
{t('form.relayModeOptions.none')}
|
||||
</SelectItem>
|
||||
<SelectItem value='all'>{t('form.relayModeOptions.all')}</SelectItem>
|
||||
<SelectItem value='random'>
|
||||
{t('form.relayModeOptions.random')}
|
||||
</SelectItem>
|
||||
<SelectItem value='none'>{t('relayModeOptions.none')}</SelectItem>
|
||||
<SelectItem value='all'>{t('relayModeOptions.all')}</SelectItem>
|
||||
<SelectItem value='random'>{t('relayModeOptions.random')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -898,7 +1014,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
{
|
||||
name: 'host',
|
||||
type: 'text',
|
||||
placeholder: t('form.relayHost'),
|
||||
placeholder: t('nodeForm.relayHost'),
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
@@ -906,12 +1022,12 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
step: 1,
|
||||
min: 1,
|
||||
max: 65535,
|
||||
placeholder: t('form.relayPort'),
|
||||
placeholder: t('nodeForm.relayPort'),
|
||||
},
|
||||
{
|
||||
name: 'prefix',
|
||||
type: 'text',
|
||||
placeholder: t('form.relayPrefix'),
|
||||
placeholder: t('nodeForm.relayPrefix'),
|
||||
},
|
||||
]}
|
||||
value={field.value}
|
||||
@@ -938,7 +1054,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
{t('node.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
@@ -952,7 +1068,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
|
||||
})}
|
||||
>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('form.confirm')}
|
||||
{t('node.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -1,99 +1,266 @@
|
||||
'use client';
|
||||
|
||||
import { UserDetail } from '@/app/dashboard/user/user-detail';
|
||||
import { IpLink } from '@/components/ip-link';
|
||||
import { ProTable } from '@/components/pro-table';
|
||||
import { getUserSubscribeById } from '@/services/admin/user';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import { Progress } from '@workspace/ui/components/progress';
|
||||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@workspace/ui/components/tooltip';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@workspace/ui/components/sheet';
|
||||
import { formatBytes, formatDate } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function formatPercentage(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
|
||||
const t = useTranslations('server.node');
|
||||
// 统一的用户订阅信息组件
|
||||
function UserSubscribeInfo({
|
||||
userId,
|
||||
type,
|
||||
}: {
|
||||
userId: number;
|
||||
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
|
||||
}) {
|
||||
const { data } = useQuery({
|
||||
enabled: userId !== 0,
|
||||
queryKey: ['getUserSubscribeById', userId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserSubscribeById({ id: userId });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
last_at,
|
||||
online_users,
|
||||
status: serverStatus,
|
||||
} = status || {
|
||||
online_users: [],
|
||||
status: {
|
||||
if (!data) return <span className='text-muted-foreground'>--</span>;
|
||||
|
||||
switch (type) {
|
||||
case 'account':
|
||||
if (!data.user_id) return <span className='text-muted-foreground'>--</span>;
|
||||
return <UserDetail id={data.user_id} />;
|
||||
|
||||
case 'subscribeName':
|
||||
if (!data.subscribe?.name) return <span className='text-muted-foreground'>--</span>;
|
||||
return <span className='text-sm'>{data.subscribe.name}</span>;
|
||||
|
||||
case 'subscribeId':
|
||||
if (!data.id) return <span className='text-muted-foreground'>--</span>;
|
||||
return <span className='font-mono text-sm'>{data.id}</span>;
|
||||
|
||||
case 'trafficUsage': {
|
||||
const usedTraffic = data.upload + data.download;
|
||||
const totalTraffic = data.traffic || 0;
|
||||
return (
|
||||
<div className='min-w-0 text-sm'>
|
||||
<div className='break-words'>
|
||||
{formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'expireTime': {
|
||||
if (!data.expire_time) return <span className='text-muted-foreground'>--</span>;
|
||||
const isExpired = data.expire_time < Date.now() / 1000;
|
||||
return (
|
||||
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
|
||||
<span className='text-sm'>{formatDate(data.expire_time)}</span>
|
||||
{isExpired && (
|
||||
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
|
||||
过期
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span className='text-muted-foreground'>--</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export function NodeStatusCell({ status, node }: { status: API.NodeStatus; node?: API.Server }) {
|
||||
const t = useTranslations('server.node');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { online, cpu, mem, disk, updated_at } = status || {
|
||||
online: {},
|
||||
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 badgeText = isOnline ? t('normal') : t('abnormal');
|
||||
const onlineCount = Array.isArray(online_users) ? online_users?.length : 0;
|
||||
const onlineCount = (online && Object.keys(online).length) || 0;
|
||||
|
||||
// 转换在线用户数据为ProTable需要的格式
|
||||
const onlineUsersData = Object.entries(online || {}).map(([uid, ips]) => ({
|
||||
uid,
|
||||
ips: ips as string[],
|
||||
primaryIp: ips[0] || '',
|
||||
allIps: (ips as string[]).join(', '),
|
||||
}));
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='flex items-center gap-2 text-xs *:flex-1'>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button className='hover:text-foreground flex cursor-pointer items-center gap-2 border-none bg-transparent p-0 text-left text-sm transition-colors'>
|
||||
<Badge variant={badgeVariant}>{badgeText}</Badge>
|
||||
<span className='font-medium'>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('onlineCount')}: {onlineCount}
|
||||
</span>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
{node && (
|
||||
<SheetContent className='h-screen w-screen max-w-none sm:h-auto sm:w-[800px] sm:max-w-[90vw]'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t('nodeDetail')} - {node.name}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className='-mx-6 h-[calc(100vh-48px-16px)] space-y-2 overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]'>
|
||||
<h3 className='text-base font-medium'>{t('nodeStatus')}</h3>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex w-full flex-col gap-2 text-sm sm:flex-row sm:items-center sm:gap-3'>
|
||||
<Badge variant={isOnline ? 'default' : 'destructive'} className='w-fit text-xs'>
|
||||
{isOnline ? t('normal') : t('abnormal')}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('onlineCount')}: {onlineCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<div className='flex justify-between'>
|
||||
<span>CPU</span>
|
||||
<span>{formatPercentage(serverStatus?.cpu ?? 0)}</span>
|
||||
</div>
|
||||
<Progress value={serverStatus?.cpu ?? 0} className='h-2' max={100} />
|
||||
</div>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<div className='flex justify-between'>
|
||||
<span>{t('memory')}</span>
|
||||
<span>{formatPercentage(serverStatus?.mem ?? 0)}</span>
|
||||
</div>
|
||||
<Progress value={serverStatus?.mem ?? 0} className='h-2' max={100} />
|
||||
</div>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<div className='flex justify-between'>
|
||||
<span>{t('disk')}</span>
|
||||
<span>{formatPercentage(serverStatus?.disk ?? 0)}</span>
|
||||
</div>
|
||||
<Progress value={serverStatus?.disk ?? 0} className='h-2' max={100} />
|
||||
</div>
|
||||
{isOnline && (
|
||||
<div>
|
||||
{t('lastUpdated')}: {formatDate(serverStatus?.updated_at ?? 0)}
|
||||
<span className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{t('lastUpdated')}: {formatDate(updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOnline && (
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>CPU</span>
|
||||
<span>{cpu?.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={cpu ?? 0} className='h-1.5' max={100} />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>{t('memory')}</span>
|
||||
<span>{mem?.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={mem ?? 0} className='h-1.5' max={100} />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>{t('disk')}</span>
|
||||
<span>{disk?.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={disk ?? 0} className='h-1.5' max={100} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{isOnline && onlineCount > 0 && (
|
||||
<TooltipContent className='bg-muted text-foreground w-80'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<h4 className='text-sm font-semibold'>{t('onlineUsers')}</h4>
|
||||
<ScrollArea className='h-[400px] rounded-md border p-2'>
|
||||
{online_users.map((user, index) => (
|
||||
<div key={user.uid} className='py-1 text-xs'>
|
||||
{user.ip} (UID: {user.uid})
|
||||
<div>
|
||||
<h3 className='mb-3 text-lg font-medium'>{t('onlineUsers')}</h3>
|
||||
<div className='overflow-x-auto'>
|
||||
<ProTable
|
||||
header={{
|
||||
hidden: true,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'allIps',
|
||||
header: t('ipAddresses'),
|
||||
cell: ({ row }) => {
|
||||
const ips = row.original.ips;
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-1'>
|
||||
{ips.map((ip: string, index: number) => (
|
||||
<div key={ip} className='whitespace-nowrap text-sm'>
|
||||
{index === 0 ? (
|
||||
<IpLink ip={ip} className='font-medium' />
|
||||
) : (
|
||||
<IpLink ip={ip} className='text-muted-foreground' />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: t('user'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo userId={Number(row.original.uid)} type='account' />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscribeName',
|
||||
header: t('subscribeName'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
userId={Number(row.original.uid)}
|
||||
type='subscribeName'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscribeId',
|
||||
header: t('subscribeId'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
userId={Number(row.original.uid)}
|
||||
type='subscribeId'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'trafficUsage',
|
||||
header: t('trafficUsage'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
userId={Number(row.original.uid)}
|
||||
type='trafficUsage'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'expireTime',
|
||||
header: t('expireTime'),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
userId={Number(row.original.uid)}
|
||||
type='expireTime'
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
request={async () => ({
|
||||
list: onlineUsersData,
|
||||
total: onlineUsersData.length,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</SheetContent>
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function NodeTable() {
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
header: t('id'),
|
||||
cell: ({ row }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -87,6 +87,7 @@ export default function NodeTable() {
|
||||
'bg-yellow-500': row.original.protocol === 'trojan',
|
||||
'bg-purple-500': row.original.protocol === 'hysteria2',
|
||||
'bg-cyan-500': row.original.protocol === 'tuic',
|
||||
'bg-gray-500': row.original.protocol === 'anytls',
|
||||
})}
|
||||
>
|
||||
{row.getValue('id')}
|
||||
@@ -138,7 +139,7 @@ export default function NodeTable() {
|
||||
accessorKey: 'status',
|
||||
header: t('status'),
|
||||
cell: ({ row }) => {
|
||||
return <NodeStatusCell status={row.original?.status} />;
|
||||
return <NodeStatusCell status={row.original?.status} node={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -159,7 +160,7 @@ export default function NodeTable() {
|
||||
header: t('nodeGroup'),
|
||||
cell: ({ row }) => {
|
||||
const name = groups?.find((group) => group.id === row.getValue('group_id'))?.name;
|
||||
return name ? <Badge variant='outline'>{name}</Badge> : '--';
|
||||
return name ? <Badge variant='outline'>{name}</Badge> : t('noData');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -176,15 +177,12 @@ export default function NodeTable() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
'--'
|
||||
t('noData')
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'group_id',
|
||||
placeholder: t('nodeGroup'),
|
||||
@@ -193,6 +191,9 @@ export default function NodeTable() {
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getNodeList({
|
||||
@@ -272,17 +273,17 @@ export default function NodeTable() {
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteNode({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
|
||||
import SubscribeApp from './app/table';
|
||||
import GroupTable from './group/table';
|
||||
import SubscribeConfig from './subscribe-config';
|
||||
import SubscribeTable from './subscribe-table';
|
||||
@@ -16,7 +15,6 @@ export default async function Page() {
|
||||
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
|
||||
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
|
||||
<TabsTrigger value='config'>{t('tabs.subscribeConfig')}</TabsTrigger>
|
||||
<TabsTrigger value='app'>{t('tabs.subscribeApp')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='subscribe'>
|
||||
<SubscribeTable />
|
||||
@@ -27,9 +25,6 @@ export default async function Page() {
|
||||
<TabsContent value='config'>
|
||||
<SubscribeConfig />
|
||||
</TabsContent>
|
||||
<TabsContent value='app'>
|
||||
<SubscribeApp />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import { evaluateWithPrecision, unitConversion } from '@workspace/ui/utils';
|
||||
import { CreditCard, Server, Settings } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { assign, shake } from 'radash';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
@@ -81,6 +81,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
}: Readonly<SubscribeFormProps<T>>) {
|
||||
const t = useTranslations('subscribe');
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -118,12 +119,111 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
),
|
||||
});
|
||||
|
||||
const debouncedCalculateDiscount = useCallback(
|
||||
(values: any[], fieldName: string, lastChangedField?: string, changedIndex?: number) => {
|
||||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
|
||||
updateTimeoutRef.current = setTimeout(() => {
|
||||
const { unit_price } = form.getValues();
|
||||
if (!unit_price || !values?.length) return;
|
||||
|
||||
let hasChanges = false;
|
||||
const calculatedValues = values.map((item: any, index: number) => {
|
||||
const result = { ...item };
|
||||
|
||||
if (changedIndex !== undefined && index !== changedIndex) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const quantity = Number(item.quantity) || 0;
|
||||
const discount = Number(item.discount) || 0;
|
||||
const price = Number(item.price) || 0;
|
||||
|
||||
switch (lastChangedField) {
|
||||
case 'quantity':
|
||||
case 'discount':
|
||||
if (quantity > 0 && discount > 0) {
|
||||
const newPrice = evaluateWithPrecision(
|
||||
`${unit_price} * ${quantity} * ${discount} / 100`,
|
||||
);
|
||||
if (Math.abs(newPrice - price) > 0.01) {
|
||||
result.price = newPrice;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'price':
|
||||
if (quantity > 0 && price > 0) {
|
||||
const newDiscount = evaluateWithPrecision(
|
||||
`${price} / ${quantity} / ${unit_price} * 100`,
|
||||
);
|
||||
if (Math.abs(newDiscount - discount) > 0.01) {
|
||||
result.discount = Math.min(100, Math.max(0, newDiscount));
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (discount > 0 && price > 0) {
|
||||
const newQuantity = evaluateWithPrecision(
|
||||
`${price} / ${unit_price} / ${discount} * 100`,
|
||||
);
|
||||
if (Math.abs(newQuantity - quantity) > 0.01 && newQuantity > 0) {
|
||||
result.quantity = Math.max(1, Math.round(newQuantity));
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (quantity > 0 && discount > 0 && price === 0) {
|
||||
result.price = evaluateWithPrecision(
|
||||
`${unit_price} * ${quantity} * ${discount} / 100`,
|
||||
);
|
||||
hasChanges = true;
|
||||
} else if (quantity > 0 && price > 0 && discount === 0) {
|
||||
const newDiscount = evaluateWithPrecision(
|
||||
`${price} / ${quantity} / ${unit_price} * 100`,
|
||||
);
|
||||
result.discount = Math.min(100, Math.max(0, newDiscount));
|
||||
hasChanges = true;
|
||||
} else if (discount > 0 && price > 0 && quantity === 0) {
|
||||
const newQuantity = evaluateWithPrecision(
|
||||
`${price} / ${unit_price} / ${discount} * 100`,
|
||||
);
|
||||
if (newQuantity > 0) {
|
||||
result.quantity = Math.max(1, Math.round(newQuantity));
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
form.setValue(fieldName, calculatedValues, { shouldDirty: true });
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(
|
||||
assign(defaultValues, shake(initialValues, (value) => value === null) as Record<string, any>),
|
||||
);
|
||||
}, [form, initialValues]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
@@ -157,6 +257,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
});
|
||||
|
||||
const unit_time = form.watch('unit_time');
|
||||
const unit_price = form.watch('unit_price');
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
@@ -298,6 +399,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
step={1}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
@@ -321,7 +423,8 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
value={field.value === -1 ? 0 : field.value}
|
||||
step={1}
|
||||
value={field.value}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
@@ -343,6 +446,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
step={1}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
@@ -540,53 +644,75 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.discount')}</FormLabel>
|
||||
<FormControl>
|
||||
<ArrayInput<API.SubscribeDiscount>
|
||||
<ArrayInput<API.SubscribeDiscount & { price?: number }>
|
||||
fields={[
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'number',
|
||||
step: 1,
|
||||
min: 1,
|
||||
suffix: unit_time && t(`form.${unit_time}`),
|
||||
},
|
||||
{
|
||||
name: 'discount',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
min: 0.01,
|
||||
max: 100,
|
||||
step: 0.01,
|
||||
placeholder: t('form.discountPercent'),
|
||||
suffix: '%',
|
||||
calculateValue: function (data) {
|
||||
const { unit_price } = form.getValues();
|
||||
return {
|
||||
...data,
|
||||
price: evaluateWithPrecision(
|
||||
`${unit_price} * ${data.quantity} * ${data.discount} / 100`,
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
placeholder: t('form.discount_price'),
|
||||
type: 'number',
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
formatInput: (value) => unitConversion('centsToDollars', 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}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
onChange={(
|
||||
newValues: (API.SubscribeDiscount & { price?: number })[],
|
||||
) => {
|
||||
const oldValues = field.value || [];
|
||||
let lastChangedField: string | undefined;
|
||||
let changedIndex: number | undefined;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.max(newValues.length, oldValues.length);
|
||||
i++
|
||||
) {
|
||||
const newItem = newValues[i] || {};
|
||||
const oldItem = oldValues[i] || {};
|
||||
|
||||
if ((newItem as any).quantity !== (oldItem as any).quantity) {
|
||||
lastChangedField = 'quantity';
|
||||
changedIndex = i;
|
||||
break;
|
||||
}
|
||||
if ((newItem as any).discount !== (oldItem as any).discount) {
|
||||
lastChangedField = 'discount';
|
||||
changedIndex = i;
|
||||
break;
|
||||
}
|
||||
if ((newItem as any).price !== (oldItem as any).price) {
|
||||
lastChangedField = 'price';
|
||||
changedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
form.setValue(field.name, newValues, { shouldDirty: true });
|
||||
if (newValues?.length > 0) {
|
||||
debouncedCalculateDiscount(
|
||||
newValues,
|
||||
field.name,
|
||||
lastChangedField,
|
||||
changedIndex,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -67,9 +67,6 @@ export default function SubscribeTable() {
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'group_id',
|
||||
placeholder: t('subscribeGroup'),
|
||||
@@ -78,6 +75,9 @@ export default function SubscribeTable() {
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getSubscribeList({
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { getCurrencyConfig, updateCurrencyConfig } from '@/services/admin/system';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { 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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Constants
|
||||
const EXCHANGE_RATE_HOST_URL = 'https://exchangerate.host';
|
||||
|
||||
const currencySchema = z.object({
|
||||
access_key: z.string().optional(),
|
||||
currency_unit: z.string().min(1),
|
||||
currency_symbol: z.string().min(1),
|
||||
});
|
||||
|
||||
type CurrencyFormData = z.infer<typeof currencySchema>;
|
||||
|
||||
export default function CurrencyConfig() {
|
||||
const t = useTranslations('system');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getCurrencyConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getCurrencyConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open, // Only request data when the modal is open
|
||||
});
|
||||
|
||||
const form = useForm<CurrencyFormData>({
|
||||
resolver: zodResolver(currencySchema),
|
||||
defaultValues: {
|
||||
access_key: '',
|
||||
currency_unit: 'USD',
|
||||
currency_symbol: '$',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: CurrencyFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateCurrencyConfig(values as API.CurrencyConfig);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:currency-usd' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('currency.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('currency.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('currency.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='currency-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_key'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('currency.accessKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('currency.accessKeyPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('currency.accessKeyDescription', { url: EXCHANGE_RATE_HOST_URL })}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='currency_unit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('currency.currencyUnit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('currency.currencyUnitPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('currency.currencyUnitDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='currency_symbol'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('currency.currencySymbol')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('currency.currencySymbolPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('currency.currencySymbolDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='currency-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { getPrivacyPolicyConfig, updatePrivacyPolicyConfig } from '@/services/admin/system';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { MarkdownEditor } from '@workspace/ui/custom-components/editor';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const privacyPolicySchema = z.object({
|
||||
content: z.string().optional(),
|
||||
});
|
||||
|
||||
type PrivacyPolicyFormData = z.infer<typeof privacyPolicySchema>;
|
||||
|
||||
export default function PrivacyPolicyConfig() {
|
||||
const t = useTranslations('system');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getPrivacyPolicyConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getPrivacyPolicyConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<PrivacyPolicyFormData>({
|
||||
resolver: zodResolver(privacyPolicySchema),
|
||||
defaultValues: {
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
content: data.content || '',
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: PrivacyPolicyFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updatePrivacyPolicyConfig(values as API.PrivacyPolicyConfig);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:shield-account-outline' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('privacyPolicy.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('privacyPolicy.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('privacyPolicy.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='privacy-policy-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='content'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('privacyPolicy.title')}</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor value={field.value || ''} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>{t('privacyPolicy.description')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='privacy-policy-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { getSiteConfig, updateSiteConfig } from '@/services/admin/system';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { JSONEditor } from '@workspace/ui/custom-components/editor';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const siteSchema = z.object({
|
||||
site_logo: z.string().optional(),
|
||||
site_name: z.string().min(1),
|
||||
site_desc: z.string().optional(),
|
||||
keywords: z.string().optional(),
|
||||
custom_html: z.string().optional(),
|
||||
host: z.string().optional(),
|
||||
custom_data: z.any().optional(),
|
||||
});
|
||||
|
||||
type SiteFormData = z.infer<typeof siteSchema>;
|
||||
|
||||
export default function SiteConfig() {
|
||||
const t = useTranslations('system');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getSiteConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSiteConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<SiteFormData>({
|
||||
resolver: zodResolver(siteSchema),
|
||||
defaultValues: {
|
||||
site_logo: '',
|
||||
site_name: '',
|
||||
site_desc: '',
|
||||
keywords: '',
|
||||
custom_html: '',
|
||||
host: '',
|
||||
custom_data: {},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: SiteFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSiteConfig(values as API.SiteConfig);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:web' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('site.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('site.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('site.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form id='site-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-2 pt-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site_logo'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('site.logo')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('site.logoPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
suffix={
|
||||
<UploadImage
|
||||
className='bg-muted h-9 rounded-none border-none px-2'
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('site.logoDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site_name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('site.siteName')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('site.siteNamePlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('site.siteNameDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site_desc'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('site.siteDesc')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('site.siteDescPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('site.siteDescDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='keywords'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('site.keywords')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('site.keywordsPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('site.keywordsDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='custom_html'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('site.customHtml')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='h-32'
|
||||
placeholder={t('site.customHtmlDescription')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('site.customHtmlDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='host'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('site.siteDomain')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='h-32'
|
||||
placeholder={`${t('site.siteDomainPlaceholder')}\nexample.com\nwww.example.com`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('site.siteDomainDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='custom_data'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('site.customData')}</FormLabel>
|
||||
<FormControl>
|
||||
<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={field.value}
|
||||
onBlur={(value) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('site.customDataDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='site-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { getTosConfig, updateTosConfig } from '@/services/admin/system';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { MarkdownEditor } from '@workspace/ui/custom-components/editor';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const tosSchema = z.object({
|
||||
content: z.string().optional(),
|
||||
});
|
||||
|
||||
type TosFormData = z.infer<typeof tosSchema>;
|
||||
|
||||
export default function TosConfig() {
|
||||
const t = useTranslations('system');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getTosConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getTosConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<TosFormData>({
|
||||
resolver: zodResolver(tosSchema),
|
||||
defaultValues: {
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
content: data.content || '',
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: TosFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateTosConfig(values as API.TosConfig);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:file-document-outline' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('tos.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('tos.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('tos.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form id='tos-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-2 pt-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='content'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('tos.title')}</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor value={field.value || ''} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>{t('tos.description')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='tos-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getCurrencyConfig, updateCurrencyConfig } from '@/services/admin/system';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 default function Site() {
|
||||
const t = useTranslations('system.currency');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getCurrencyConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getCurrencyConfig();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateCurrencyConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.CurrencyConfig);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('accessKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('accessKeyDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
value={data?.access_key}
|
||||
onValueBlur={(value) => updateConfig('access_key', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('currencyUnit')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('currencyUnitDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='USD'
|
||||
value={data?.currency_unit}
|
||||
onValueBlur={(value) => updateConfig('currency_unit', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('currencySymbol')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('currencySymbolDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder='$'
|
||||
value={data?.currency_symbol}
|
||||
onValueBlur={(value) => updateConfig('currency_symbol', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,62 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Currency from './currency';
|
||||
import PrivacyPolicy from './privacy-policy';
|
||||
import Site from './site';
|
||||
import Tos from './tos';
|
||||
'use client';
|
||||
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('system');
|
||||
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import CurrencyForm from './basic-settings/currency-form';
|
||||
import PrivacyPolicyForm from './basic-settings/privacy-policy-form';
|
||||
import SiteForm from './basic-settings/site-form';
|
||||
import TosForm from './basic-settings/tos-form';
|
||||
import InviteForm from './user-security/invite-form';
|
||||
import RegisterForm from './user-security/register-form';
|
||||
import VerifyCodeForm from './user-security/verify-code-form';
|
||||
import VerifyForm from './user-security/verify-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('system');
|
||||
|
||||
// 定义表单配置
|
||||
const formSections = [
|
||||
{
|
||||
title: t('basicSettings'),
|
||||
forms: [
|
||||
{ component: SiteForm },
|
||||
{ component: CurrencyForm },
|
||||
{ component: TosForm },
|
||||
{ component: PrivacyPolicyForm },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('userSecuritySettings'),
|
||||
forms: [
|
||||
{ component: RegisterForm },
|
||||
{ component: InviteForm },
|
||||
{ component: VerifyForm },
|
||||
{ component: VerifyCodeForm },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='site'>
|
||||
<TabsList className='h-full flex-wrap'>
|
||||
<TabsTrigger value='site'>{t('tabs.site')}</TabsTrigger>
|
||||
<TabsTrigger value='currency'>{t('tabs.currency')}</TabsTrigger>
|
||||
<TabsTrigger value='tos'>{t('tabs.tos')}</TabsTrigger>
|
||||
<TabsTrigger value='privacy-policy'>{t('privacy-policy.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='site'>
|
||||
<Site />
|
||||
</TabsContent>
|
||||
<TabsContent value='currency'>
|
||||
<Currency />
|
||||
</TabsContent>
|
||||
<TabsContent value='tos'>
|
||||
<Tos />
|
||||
</TabsContent>
|
||||
<TabsContent value='privacy-policy'>
|
||||
<PrivacyPolicy />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className='space-y-8'>
|
||||
{formSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<h2 className='mb-4 text-lg font-semibold'>{section.title}</h2>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{section.forms.map((form, formIndex) => {
|
||||
const FormComponent = form.component;
|
||||
return (
|
||||
<TableRow key={formIndex}>
|
||||
<TableCell>
|
||||
<FormComponent />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getSiteConfig, updateSiteConfig } from '@/services/admin/system';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Label } from '@workspace/ui/components/label';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
|
||||
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 { UploadImage } from '@workspace/ui/custom-components/upload-image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Site() {
|
||||
const t = useTranslations('system.site');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getSiteConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSiteConfig();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateSiteConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.SiteConfig);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('logo')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('logoDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('logoPlaceholder')}
|
||||
value={data?.site_logo}
|
||||
onValueBlur={(value) => updateConfig('site_logo', value)}
|
||||
suffix={
|
||||
<UploadImage
|
||||
className='bg-muted h-9 rounded-none border-none px-2'
|
||||
onChange={(value) => {
|
||||
updateConfig('site_logo', value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('siteName')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('siteNameDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('siteNamePlaceholder')}
|
||||
value={data?.site_name}
|
||||
onValueBlur={(value) => updateConfig('site_name', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('siteDesc')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('siteDescDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('siteDescPlaceholder')}
|
||||
value={data?.site_desc}
|
||||
onValueBlur={(value) => updateConfig('site_desc', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</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>
|
||||
<TableCell className='align-top'>
|
||||
<Label>{t('siteDomain')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('siteDomainDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Textarea
|
||||
className='h-52'
|
||||
placeholder={`${t('siteDomainPlaceholder')}\nexample.com\nwww.example.com`}
|
||||
defaultValue={data?.host}
|
||||
onBlur={(e) => {
|
||||
updateConfig('host', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</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>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getTosConfig, updateTosConfig } 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 Tos() {
|
||||
const t = useTranslations('system.tos');
|
||||
const { data, refetch, isFetched } = useQuery({
|
||||
queryKey: ['getTosConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getTosConfig();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateTosConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.TosConfig);
|
||||
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?.tos_content}
|
||||
onBlur={(value) => {
|
||||
if (data?.tos_content !== value) {
|
||||
updateConfig('tos_content', value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { getInviteConfig, updateInviteConfig } from '@/services/admin/system';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const inviteSchema = z.object({
|
||||
forced_invite: z.boolean().optional(),
|
||||
referral_percentage: z.number().optional(),
|
||||
only_first_purchase: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type InviteFormData = z.infer<typeof inviteSchema>;
|
||||
|
||||
export default function InviteConfig() {
|
||||
const t = useTranslations('system.invite');
|
||||
const systemT = useTranslations('system');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getInviteConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getInviteConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<InviteFormData>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
defaultValues: {
|
||||
forced_invite: false,
|
||||
referral_percentage: 0,
|
||||
only_first_purchase: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: InviteFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateInviteConfig(values as API.InviteConfig);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:account-multiple-plus-outline' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='invite-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='forced_invite'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('forcedInvite')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('forcedInviteDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='referral_percentage'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('referralPercentage')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={field.value}
|
||||
type='number'
|
||||
min={0}
|
||||
max={100}
|
||||
suffix='%'
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('referralPercentageDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='only_first_purchase'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('onlyFirstPurchase')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('onlyFirstPurchaseDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{systemT('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='invite-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{systemT('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { getSubscribeList } from '@/services/admin/subscribe';
|
||||
import { getRegisterConfig, updateRegisterConfig } from '@/services/admin/system';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
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 { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerSchema = z.object({
|
||||
stop_register: z.boolean().optional(),
|
||||
enable_ip_register_limit: z.boolean().optional(),
|
||||
ip_register_limit_count: z.number().optional(),
|
||||
ip_register_limit_expire_day: z.number().optional(),
|
||||
trial_flow: z.number().optional(),
|
||||
trial_day: z.number().optional(),
|
||||
default_subscribe_id: z.number().optional(),
|
||||
});
|
||||
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function RegisterConfig() {
|
||||
const t = useTranslations('system.register');
|
||||
const systemT = useTranslations('system');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getRegisterConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getRegisterConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: subscribe } = useQuery({
|
||||
queryKey: ['getSubscribeList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list as API.Subscribe[];
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
stop_register: false,
|
||||
enable_ip_register_limit: false,
|
||||
ip_register_limit_count: 1,
|
||||
ip_register_limit_expire_day: 1,
|
||||
trial_flow: 0,
|
||||
trial_day: 0,
|
||||
default_subscribe_id: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: RegisterFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateRegisterConfig(values as API.RegisterConfig);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:account-plus-outline' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='register-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='stop_register'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('stopNewUserRegistration')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('stopNewUserRegistrationDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_ip_register_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('ipRegistrationLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('ipRegistrationLimitDescription')}</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ip_register_limit_count'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('registrationLimitCount')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={field.value}
|
||||
type='number'
|
||||
min={1}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('registrationLimitCountDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ip_register_limit_expire_day'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('registrationLimitExpire')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={field.value}
|
||||
type='number'
|
||||
min={1}
|
||||
suffix={t('day')}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('registrationLimitExpireDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='trial_flow'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('trialFlow')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={field.value}
|
||||
type='number'
|
||||
min={0}
|
||||
suffix='GB'
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('trialFlowDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='trial_day'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('trialDay')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={field.value}
|
||||
type='number'
|
||||
min={0}
|
||||
suffix={t('day')}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('trialDayDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='default_subscribe_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('defaultSubscribe')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
placeholder={t('selectPlaceholder')}
|
||||
value={field.value}
|
||||
onChange={(value: number) => {
|
||||
if (value) {
|
||||
field.onChange(value);
|
||||
}
|
||||
}}
|
||||
options={
|
||||
subscribe?.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('defaultSubscribeDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{systemT('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='register-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{systemT('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { getVerifyCodeConfig, updateVerifyCodeConfig } from '@/services/admin/system';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { 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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const verifyCodeSchema = z.object({
|
||||
verify_code_expire_time: z.number().optional(),
|
||||
verify_code_interval: z.number().optional(),
|
||||
verify_code_limit: z.number().optional(),
|
||||
});
|
||||
|
||||
type VerifyCodeFormData = z.infer<typeof verifyCodeSchema>;
|
||||
|
||||
export default function VerifyCodeConfig() {
|
||||
const t = useTranslations('system.verifyCode');
|
||||
const systemT = useTranslations('system');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getVerifyCodeConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getVerifyCodeConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<VerifyCodeFormData>({
|
||||
resolver: zodResolver(verifyCodeSchema),
|
||||
defaultValues: {
|
||||
verify_code_expire_time: 300,
|
||||
verify_code_interval: 60,
|
||||
verify_code_limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: VerifyCodeFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateVerifyCodeConfig(values as API.VerifyCodeConfig);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:message-text-clock-outline' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='verify-code-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='verify_code_expire_time'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('expireTime')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={field.value}
|
||||
type='number'
|
||||
min={60}
|
||||
suffix={t('seconds')}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('expireTimeDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='verify_code_interval'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('interval')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={field.value}
|
||||
type='number'
|
||||
min={30}
|
||||
suffix={t('seconds')}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('intervalDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='verify_code_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('dailyLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={field.value}
|
||||
type='number'
|
||||
min={1}
|
||||
suffix={t('times')}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('dailyLimitDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{systemT('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='verify-code-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{systemT('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { getVerifyConfig, updateVerifyConfig } from '@/services/admin/system';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
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 { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const verifySchema = z.object({
|
||||
turnstile_site_key: z.string().optional(),
|
||||
turnstile_secret: z.string().optional(),
|
||||
enable_register_verify: z.boolean().optional(),
|
||||
enable_login_verify: z.boolean().optional(),
|
||||
enable_password_verify: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type VerifyFormData = z.infer<typeof verifySchema>;
|
||||
|
||||
export default function VerifyConfig() {
|
||||
const t = useTranslations('system.verify');
|
||||
const systemT = useTranslations('system');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getVerifyConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getVerifyConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<VerifyFormData>({
|
||||
resolver: zodResolver(verifySchema),
|
||||
defaultValues: {
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret: '',
|
||||
enable_register_verify: false,
|
||||
enable_login_verify: false,
|
||||
enable_password_verify: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: VerifyFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateVerifyConfig(values as API.VerifyConfig);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('saveFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center justify-between transition-colors'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
<Icon icon='mdi:shield-check-outline' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon='mdi:chevron-right' className='size-6' />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='verify-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='turnstile_site_key'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('turnstileSiteKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('turnstileSiteKeyPlaceholder')}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('turnstileSiteKeyDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='turnstile_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('turnstileSecret')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('turnstileSecretPlaceholder')}
|
||||
value={field.value}
|
||||
type='password'
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('turnstileSecretDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_register_verify'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('enableRegisterVerify')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('enableRegisterVerifyDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_login_verify'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('enableLoginVerify')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('enableLoginVerifyDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_password_verify'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('enablePasswordVerify')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('enablePasswordVerifyDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{systemT('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='verify-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{systemT('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { getSystemLog, restartSystem } from '@/services/admin/tool';
|
||||
import { getSystemLog, getVersion, restartSystem } from '@/services/admin/tool';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Accordion,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '@workspace/ui/components/accordion';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
@@ -30,8 +29,10 @@ import {
|
||||
} from '@workspace/ui/components/card';
|
||||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import packageJson from '../../../../../package.json';
|
||||
|
||||
const getLogLevelColor = (level: string) => {
|
||||
const colorMap: { [key: string]: string } = {
|
||||
@@ -59,6 +60,91 @@ export default function Page() {
|
||||
const [openRestart, setOpenRestart] = useState(false);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
const { data: latestReleases } = useQuery({
|
||||
queryKey: ['getLatestReleases'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const [webResponse, serverResponse] = await Promise.all([
|
||||
fetch('https://api.github.com/repos/perfect-panel/ppanel-web/releases/latest'),
|
||||
fetch('https://api.github.com/repos/perfect-panel/server/releases/latest'),
|
||||
]);
|
||||
|
||||
const webData = webResponse.ok ? await webResponse.json() : null;
|
||||
const serverData = serverResponse.ok ? await serverResponse.json() : null;
|
||||
|
||||
return {
|
||||
web: webData
|
||||
? {
|
||||
version: webData.tag_name,
|
||||
url: webData.html_url,
|
||||
publishedAt: webData.published_at,
|
||||
}
|
||||
: null,
|
||||
server: serverData
|
||||
? {
|
||||
version: serverData.tag_name,
|
||||
url: serverData.html_url,
|
||||
publishedAt: serverData.published_at,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest releases:', error);
|
||||
return { web: null, server: null };
|
||||
}
|
||||
},
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: 1,
|
||||
retryDelay: 10000,
|
||||
});
|
||||
|
||||
// 检查是否有新版本
|
||||
const hasNewVersion =
|
||||
latestReleases?.web && packageJson.version !== latestReleases.web.version.replace(/^v/, '');
|
||||
|
||||
const { data: systemInfo } = useQuery({
|
||||
queryKey: ['getVersion'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getVersion();
|
||||
|
||||
const versionString = data.data?.version || '';
|
||||
const releaseVersionRegex = /^[Vv]?\d+\.\d+\.\d+$/;
|
||||
const timeMatch = versionString.match(/\(([^)]+)\)/);
|
||||
const timeInBrackets = timeMatch ? timeMatch[1] : '';
|
||||
|
||||
const versionWithoutTime = versionString.replace(/\([^)]*\)/, '').trim();
|
||||
const isDevelopment = !releaseVersionRegex.test(versionWithoutTime);
|
||||
|
||||
let baseVersion = versionWithoutTime;
|
||||
let versionSuffix = '';
|
||||
let lastUpdated = '';
|
||||
|
||||
if (isDevelopment && versionWithoutTime.includes('-')) {
|
||||
const parts = versionWithoutTime.split('-');
|
||||
baseVersion = parts[0] || versionWithoutTime;
|
||||
versionSuffix = parts.slice(1).join('-');
|
||||
}
|
||||
|
||||
lastUpdated = formatDate(new Date(timeInBrackets || Date.now())) || '';
|
||||
|
||||
const displayVersion =
|
||||
baseVersion.startsWith('V') || baseVersion.startsWith('v')
|
||||
? baseVersion
|
||||
: `V${baseVersion}`;
|
||||
|
||||
return {
|
||||
isRelease: !isDevelopment,
|
||||
version: displayVersion,
|
||||
lastUpdated,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const hasServerNewVersion =
|
||||
latestReleases?.server &&
|
||||
systemInfo &&
|
||||
systemInfo.version.replace(/^V/, '') !== latestReleases.server.version.replace(/^v/, '');
|
||||
|
||||
return (
|
||||
<Card className='border-none'>
|
||||
<CardHeader className='flex flex-col items-start justify-between sm:flex-row sm:items-center'>
|
||||
@@ -67,7 +153,7 @@ export default function Page() {
|
||||
<CardDescription>{t('viewLogsAndManage')}</CardDescription>
|
||||
</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'>
|
||||
<AlertDialog>
|
||||
{/* <AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button>{t('systemUpgrade')}</Button>
|
||||
</AlertDialogTrigger>
|
||||
@@ -81,7 +167,7 @@ export default function Page() {
|
||||
<AlertDialogAction>{t('confirmUpgrade')}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AlertDialog> */}
|
||||
<AlertDialog open={openRestart} onOpenChange={setOpenRestart}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant='destructive'>{t('systemReboot')}</Button>
|
||||
@@ -112,13 +198,88 @@ export default function Page() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-lg font-semibold'>
|
||||
{t('currentVersion')} <span>V1.0.0</span>
|
||||
<div className='space-y-4'>
|
||||
{/* 版本信息紧凑显示 */}
|
||||
<div className='flex flex-col space-y-2 sm:flex-row sm:space-x-3 sm:space-y-0'>
|
||||
{/* 用户端/管理端版本 */}
|
||||
<div className='bg-muted/30 flex flex-1 items-center justify-between rounded-md p-2'>
|
||||
<div className='flex items-center'>
|
||||
<Icon icon='mdi:web' className='mr-2 h-4 w-4 text-green-600' />
|
||||
<div className='flex items-center space-x-2'>
|
||||
<span className='text-xs font-medium'>{t('webVersion')}</span>
|
||||
<Badge variant='default' className='px-1.5 py-0.5 text-xs'>
|
||||
V{packageJson.version}
|
||||
</Badge>
|
||||
{hasNewVersion && (
|
||||
<Badge variant='destructive' className='animate-pulse px-1.5 py-0.5 text-xs'>
|
||||
{t('newVersionAvailable')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasNewVersion && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() =>
|
||||
window.open(
|
||||
latestReleases?.web?.url ||
|
||||
'https://github.com/perfect-panel/ppanel-web/releases',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon icon='mdi:open-in-new' className='mr-1 h-3 w-3' />
|
||||
{t('viewNewVersion')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 服务端版本 */}
|
||||
<div className='bg-muted/30 flex flex-1 items-center justify-between rounded-md p-2'>
|
||||
<div className='flex items-center'>
|
||||
<Icon icon='mdi:server' className='mr-2 h-4 w-4 text-blue-600' />
|
||||
<div className='flex items-center space-x-2'>
|
||||
<span className='text-xs font-medium'>{t('serverVersion')}</span>
|
||||
<Badge
|
||||
variant={!systemInfo?.isRelease ? 'destructive' : 'default'}
|
||||
className='px-1.5 py-0.5 text-xs'
|
||||
>
|
||||
{systemInfo?.version || 'V1.0.0'}
|
||||
{!systemInfo?.isRelease && (
|
||||
<span className='ml-1'>{t('developmentVersion')}</span>
|
||||
)}
|
||||
</Badge>
|
||||
{hasServerNewVersion && (
|
||||
<Badge variant='destructive' className='animate-pulse px-1.5 py-0.5 text-xs'>
|
||||
{t('newVersionAvailable')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
{hasServerNewVersion && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() =>
|
||||
window.open(
|
||||
latestReleases?.server?.url ||
|
||||
'https://github.com/perfect-panel/server/releases',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon icon='mdi:open-in-new' className='mr-1 h-3 w-3' />
|
||||
{t('viewNewVersion')}
|
||||
</Button>
|
||||
)}
|
||||
<div className='text-muted-foreground hidden text-right text-xs sm:block'>
|
||||
<div className='font-mono'>{systemInfo?.lastUpdated || '--'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('lastUpdated')} <span>2024-12-16 12:00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
<Card className='overflow-hidden'>
|
||||
@@ -146,20 +307,20 @@ export default function Page() {
|
||||
<AccordionItem key={index} value={`item-${index}`} className='px-4'>
|
||||
<AccordionTrigger className='hover:no-underline'>
|
||||
<div className='flex w-full flex-col items-start space-y-2 sm:flex-row sm:items-center sm:space-x-4 sm:space-y-0'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={`${getLogLevelColor(log.level)} mb-2 sm:mb-0`}
|
||||
>
|
||||
{log.level}
|
||||
</Badge>
|
||||
<span className='text-xs font-medium sm:text-sm'>{log.time}</span>
|
||||
<span className='text-muted-foreground flex-1 truncate text-xs sm:text-sm'>
|
||||
{log.message}
|
||||
</span>
|
||||
<span className='text-xs font-medium sm:text-sm'>{log.timestamp}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className='px-2'>
|
||||
<div className='grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm'>
|
||||
{Object.entries(log).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className='grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm'
|
||||
>
|
||||
<span className='font-medium'>{key}:</span>
|
||||
<span className='break-all'>{value as string}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* <div className='grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm'>
|
||||
<div className='font-medium'>{t('ip')}:</div>
|
||||
<div>{log.ip}</div>
|
||||
<div className='font-medium'>{t('request')}:</div>
|
||||
@@ -174,7 +335,7 @@ export default function Page() {
|
||||
<div className='break-all'>{log.query || t('none')}</div>
|
||||
<div className='font-medium'>{t('userAgent')}:</div>
|
||||
<div className='break-all'>{log['user-agent']}</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { IpLink } from '@/components/ip-link';
|
||||
import { ProTable } from '@/components/pro-table';
|
||||
import { getUserLoginLogs } from '@/services/admin/user';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
@@ -26,6 +27,7 @@ export default function UserLoginHistory() {
|
||||
{
|
||||
accessorKey: 'login_ip',
|
||||
header: t('loginIp'),
|
||||
cell: ({ row }) => <IpLink ip={row.getValue('login_ip')} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user_agent',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { IpLink } from '@/components/ip-link';
|
||||
import { ProTable } from '@/components/pro-table';
|
||||
import {
|
||||
getUserSubscribeDevices,
|
||||
@@ -64,6 +65,7 @@ export function SubscriptionDetail({
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
header: 'IP',
|
||||
cell: ({ row }) => <IpLink ip={row.getValue('ip')} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user_agent',
|
||||
@@ -156,6 +158,7 @@ export function SubscriptionDetail({
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
header: 'IP',
|
||||
cell: ({ row }) => <IpLink ip={row.getValue('ip')} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'online',
|
||||
|
||||
@@ -45,6 +45,9 @@ const formSchema = z.object({
|
||||
speed_limit: z.number().optional(),
|
||||
device_limit: z.number().optional(),
|
||||
expired_at: z.number().nullish().optional(),
|
||||
upload: z.number().optional(),
|
||||
download: z.number().optional(),
|
||||
id: z.number().optional(),
|
||||
});
|
||||
|
||||
export function SubscriptionForm({ trigger, title, loading, initialData, onSubmit }: Props) {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
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 { Switch } from '@workspace/ui/components/switch';
|
||||
@@ -20,8 +22,19 @@ export default function Page() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 (
|
||||
<ProTable<API.User, Record<string, unknown>>
|
||||
<ProTable<API.User, API.GetUserListParams>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('userList'),
|
||||
@@ -132,13 +145,38 @@ export default function Page() {
|
||||
cell: ({ row }) => formatDate(row.getValue('created_at')),
|
||||
},
|
||||
]}
|
||||
request={async (pagination) => {
|
||||
const { data } = await getUserList(pagination);
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getUserList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
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={{
|
||||
render: (row) => {
|
||||
return [
|
||||
|
||||
@@ -1,21 +1,125 @@
|
||||
'use client';
|
||||
|
||||
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 { Button } from '@workspace/ui/components/button';
|
||||
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 Link from 'next/link';
|
||||
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 }) {
|
||||
const t = useTranslations('user');
|
||||
const [shouldFetch, setShouldFetch] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: id !== 0 && shouldFetch,
|
||||
enabled: id !== 0,
|
||||
queryKey: ['getUserDetail', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserDetail({ id });
|
||||
@@ -27,7 +131,7 @@ export function UserDetail({ id }: { id: number }) {
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => setShouldFetch(true)}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button variant='link' className='p-0' asChild>
|
||||
<Link href={`/dashboard/user/${id}`}>
|
||||
{data?.auth_methods[0]?.auth_identifier || t('loading')}
|
||||
|
||||
+11
-10
@@ -9,20 +9,20 @@ import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
import { unstable_noStore as noStore } from 'next/cache';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
// import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { cookies } from 'next/headers';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import React from 'react';
|
||||
|
||||
const fontSans = Geist({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
});
|
||||
// const fontSans = Geist({
|
||||
// subsets: ['latin'],
|
||||
// variable: '--font-sans',
|
||||
// });
|
||||
|
||||
const fontMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
});
|
||||
// const fontMono = Geist_Mono({
|
||||
// subsets: ['latin'],
|
||||
// variable: '--font-mono',
|
||||
// });
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
noStore();
|
||||
@@ -111,7 +111,8 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body
|
||||
suppressHydrationWarning
|
||||
className={`${fontSans.variable} ${fontMono.variable} size-full min-h-[calc(100dvh-env(safe-area-inset-top))] font-sans antialiased`}
|
||||
// ${fontSans.variable} ${fontMono.variable}
|
||||
className={`size-full min-h-[calc(100dvh-env(safe-area-inset-top))] font-sans antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NextTopLoader showSpinner={false} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { UserSubscribeDetail } from '@/app/dashboard/user/user-detail';
|
||||
import { queryServerTotalData, queryTicketWaitReply } from '@/services/admin/console';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@workspace/ui/components/select';
|
||||
import { Separator } from '@workspace/ui/components/separator';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { formatBytes } from '@workspace/ui/utils';
|
||||
@@ -59,15 +61,13 @@ export default function Statistics() {
|
||||
users: {
|
||||
today:
|
||||
ServerTotal?.user_traffic_ranking_today?.map((item) => ({
|
||||
name: item.user_id,
|
||||
name: item.sid,
|
||||
traffic: item.download + item.upload,
|
||||
email: item.email,
|
||||
})) || [],
|
||||
yesterday:
|
||||
ServerTotal?.user_traffic_ranking_yesterday?.map((item) => ({
|
||||
name: item.user_id,
|
||||
name: item.sid,
|
||||
traffic: item.download + item.upload,
|
||||
email: item.email,
|
||||
})) || [],
|
||||
},
|
||||
};
|
||||
@@ -175,10 +175,6 @@ export default function Statistics() {
|
||||
label: t('type'),
|
||||
color: 'hsl(var(--muted))',
|
||||
},
|
||||
email: {
|
||||
label: t('email'),
|
||||
color: 'hsl(var(--muted))',
|
||||
},
|
||||
label: {
|
||||
color: 'hsl(var(--foreground))',
|
||||
},
|
||||
@@ -204,13 +200,22 @@ export default function Statistics() {
|
||||
tickFormatter={(value, index) => String(index + 1)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
trigger='hover'
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
label={true}
|
||||
labelFormatter={(label) =>
|
||||
dataType === 'nodes'
|
||||
? `${t('nodes')}: ${label}`
|
||||
: `${t('users')}: ${label}`
|
||||
labelFormatter={(label, [payload]) =>
|
||||
dataType === 'nodes' ? (
|
||||
`${t('nodes')}: ${label}`
|
||||
) : (
|
||||
<>
|
||||
<div className='w-80'>
|
||||
<UserSubscribeDetail id={payload?.payload.name} enabled={true} />
|
||||
</div>
|
||||
<Separator className='my-2' />
|
||||
<div>{`${t('users')}: ${label}`}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
formatter={(value) => {
|
||||
return formatBytes(Number(value) || 0);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface IpLinkProps {
|
||||
ip: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
target?: '_blank' | '_self';
|
||||
}
|
||||
|
||||
export function IpLink({ ip, children, className = '', target = '_blank' }: IpLinkProps) {
|
||||
const url = `https://ip.sb/ip/${ip}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target={target}
|
||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
className={`text-primary hover:text-primary/80 inline-flex items-center gap-1 font-mono transition-colors hover:underline ${className}`}
|
||||
>
|
||||
{children || ip}
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
export const AuthControl = [
|
||||
{
|
||||
title: 'General',
|
||||
url: '/dashboard/auth-control/general',
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
url: '/dashboard/auth-control/email',
|
||||
@@ -81,16 +77,26 @@ export const navs = [
|
||||
url: '/dashboard/server',
|
||||
icon: 'flat-color-icons:data-protection',
|
||||
},
|
||||
],
|
||||
{
|
||||
title: 'Application Management',
|
||||
url: '/dashboard/application',
|
||||
icon: 'flat-color-icons:touchscreen-smartphone',
|
||||
},
|
||||
{
|
||||
title: 'Finance',
|
||||
items: [
|
||||
title: 'Rule Management',
|
||||
url: '/dashboard/rules',
|
||||
icon: 'flat-color-icons:ruler',
|
||||
},
|
||||
{
|
||||
title: 'Subscribe Management',
|
||||
url: '/dashboard/subscribe',
|
||||
icon: 'flat-color-icons:shop',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Finance',
|
||||
items: [
|
||||
{
|
||||
title: 'Order Management',
|
||||
url: '/dashboard/order',
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"clientId": "ID služby",
|
||||
"clientIdDescription": "Apple Service ID, který můžete získat z Apple Developer Portálu",
|
||||
"clientSecret": "Soukromý klíč",
|
||||
"clientSecretDescription": "Obsah soukromého klíče (.p8 soubor) používaný pro autentizaci s Apple",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého Apple ID",
|
||||
"keyId": "ID klíče",
|
||||
"keyIdDescription": "ID vašeho soukromého klíče z Apple Developer Portálu",
|
||||
"redirectUri": "Přesměrovací URL",
|
||||
"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",
|
||||
"saveSuccess": "Uložení bylo úspěšné",
|
||||
"teamId": "ID týmu",
|
||||
"teamIdDescription": "ID týmu Apple Developer"
|
||||
}
|
||||
@@ -1,14 +1,149 @@
|
||||
{
|
||||
"invite": {
|
||||
"commissionFirstTimeOnly": "Provize pouze za první nákup",
|
||||
"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ě",
|
||||
"enableForcedInvite": "Povolit nucené pozvání",
|
||||
"enableForcedInviteDescription": "Pokud je povoleno, mohou se registrovat pouze pozvaní uživatelé",
|
||||
"inputPlaceholder": "Zadejte",
|
||||
"inviteCommissionPercentage": "Procento provize za pozvání",
|
||||
"inviteCommissionPercentageDescription": "Výchozí globální poměr rozdělení provize; jednotlivé poměry můžete konfigurovat v uživatelské správě",
|
||||
"inviteSettings": "Nastavení pozvání",
|
||||
"saveSuccess": "Uložení úspěšné"
|
||||
"apple": {
|
||||
"clientId": "ID služby",
|
||||
"clientIdDescription": "Apple Service ID, který můžete získat z Apple Developer Portálu",
|
||||
"clientSecret": "Soukromý klíč",
|
||||
"clientSecretDescription": "Obsah soukromého klíče (.p8 soubor) používaný pro autentizaci s Apple",
|
||||
"description": "Ověřte uživatele pomocí účtů Apple",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého Apple ID",
|
||||
"keyId": "ID klíče",
|
||||
"keyIdDescription": "ID vašeho soukromého klíče z Apple Developer Portálu",
|
||||
"redirectUri": "Přesměrovací URL",
|
||||
"redirectUriDescription": "Prosím vyplňte adresu API přesměrované URL po úspěšném ověření pomocí Apple. Nepoužívejte / na konci.",
|
||||
"teamId": "ID týmu",
|
||||
"teamIdDescription": "ID týmu Apple Developer",
|
||||
"title": "Přihlášení pomocí Apple"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Zrušit",
|
||||
"save": "Uložit",
|
||||
"saveFailed": "Uložení se nezdařilo",
|
||||
"saveSuccess": "Úspěšně uloženo"
|
||||
},
|
||||
"communicationMethods": "Communication Methods",
|
||||
"device": {
|
||||
"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",
|
||||
"description": "Ověřte uživatele pomocí identifikátorů zařízení",
|
||||
"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",
|
||||
"showAds": "Zobrazit reklamy",
|
||||
"showAdsDescription": "Pokud je povoleno, na zařízeních se budou zobrazovat reklamy",
|
||||
"title": "Ověření zařízení"
|
||||
},
|
||||
"deviceAuthMethods": "Device Authentication Methods",
|
||||
"email": {
|
||||
"basicSettings": "Základní nastavení",
|
||||
"description": "Ověřte uživatele pomocí e-mailových adres",
|
||||
"emailSuffixWhitelist": "Seznam povolených přípon e-mailů",
|
||||
"emailSuffixWhitelistDescription": "Když je povoleno, mohou se registrovat pouze e-maily s příponami v seznamu",
|
||||
"emailVerification": "Ověření e-mailu",
|
||||
"emailVerificationDescription": "Když je povoleno, uživatelé budou muset ověřit svůj e-mail",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po aktivaci budou povoleny funkce registrace e-mailem, přihlášení, propojení a odpojení.",
|
||||
"expirationEmailTemplate": "Šablona oznámení o vypršení platnosti",
|
||||
"expirationTemplate": "Oznámení o vypršení platnosti",
|
||||
"inputPlaceholder": "Zadejte hodnotu...",
|
||||
"logs": "Protokoly",
|
||||
"logsDescription": "Zobrazit historii odeslaných e-mailů a jejich stav",
|
||||
"maintenanceEmailTemplate": "Šablona oznámení o údržbě",
|
||||
"maintenanceTemplate": "Oznámení o údržbě",
|
||||
"sendFailure": "Nepodařilo se odeslat testovací e-mail, zkontrolujte prosím konfiguraci.",
|
||||
"sendSuccess": "Testovací e-mail byl úspěšně odeslán.",
|
||||
"sendTestEmail": "Odeslat testovací e-mail",
|
||||
"sendTestEmailDescription": "Odeslat testovací e-mail pro ověření konfigurace.",
|
||||
"senderAddress": "Adresa odesílatele",
|
||||
"senderAddressDescription": "Výchozí e-mailová adresa používaná pro odesílání e-mailů.",
|
||||
"smtpAccount": "SMTP účet",
|
||||
"smtpAccountDescription": "E-mailový účet používaný pro ověřování.",
|
||||
"smtpEncryptionMethod": "Metoda šifrování SMTP",
|
||||
"smtpEncryptionMethodDescription": "Vyberte, zda povolit šifrování SSL/TLS.",
|
||||
"smtpPassword": "SMTP heslo",
|
||||
"smtpPasswordDescription": "Heslo pro účet SMTP.",
|
||||
"smtpServerAddress": "Adresa SMTP serveru",
|
||||
"smtpServerAddressDescription": "Zadejte adresu serveru používanou pro odesílání e-mailů.",
|
||||
"smtpServerPort": "Port SMTP serveru",
|
||||
"smtpServerPortDescription": "Zadejte port používaný k připojení k SMTP serveru.",
|
||||
"smtpSettings": "Nastavení SMTP",
|
||||
"templateVariables": {
|
||||
"code": {
|
||||
"description": "6místný ověřovací kód",
|
||||
"title": "Ověřovací kód"
|
||||
},
|
||||
"expire": {
|
||||
"description": "Doba platnosti ověřovacího kódu (minuty)",
|
||||
"title": "Doba platnosti"
|
||||
},
|
||||
"expireDate": {
|
||||
"description": "Datum vypršení platnosti služby, připomíná uživatelům, kdy služba vyprší",
|
||||
"title": "Datum vypršení platnosti"
|
||||
},
|
||||
"maintenanceDate": {
|
||||
"description": "Datum údržby systému, zobrazuje datum zahájení údržby",
|
||||
"title": "Datum údržby"
|
||||
},
|
||||
"maintenanceTime": {
|
||||
"description": "Odhadovaná doba údržby, zobrazuje časové období nebo trvání údržby",
|
||||
"title": "Doba údržby"
|
||||
},
|
||||
"siteLogo": {
|
||||
"description": "URL obrázku loga webu",
|
||||
"title": "Logo webu"
|
||||
},
|
||||
"siteName": {
|
||||
"description": "Aktuální název webu",
|
||||
"title": "Název webu"
|
||||
},
|
||||
"title": "Proměnné šablony e-mailu",
|
||||
"type": {
|
||||
"conditionalSyntax": "Podporuje podmínkovou syntaxi pro přepínání obsahu na základě typu",
|
||||
"description": "Identifikátor typu e-mailu, 1 pro ověřovací kód registrace, ostatní pro ověřovací kód pro resetování hesla",
|
||||
"title": "Typ e-mailu"
|
||||
}
|
||||
},
|
||||
"title": "Ověření e-mailem",
|
||||
"trafficExceedEmailTemplate": "Šablona oznámení o překročení limitu provozu",
|
||||
"trafficTemplate": "Limit provozu",
|
||||
"verifyEmailTemplate": "Šablona ověřovacího e-mailu",
|
||||
"verifyTemplate": "Ověřovací e-mail",
|
||||
"whitelistSuffixes": "Povolené přípony",
|
||||
"whitelistSuffixesDescription": "Používá se pro ověření e-mailu během registrace; jeden na řádek",
|
||||
"whitelistSuffixesPlaceholder": "Zadejte přípony e-mailů, každou na samostatný řádek"
|
||||
},
|
||||
"facebook": {
|
||||
"clientId": "ID klienta",
|
||||
"clientIdDescription": "ID aplikace Facebook z konzole pro vývojáře Facebooku",
|
||||
"clientSecret": "Klientský tajný klíč",
|
||||
"clientSecretDescription": "Facebook App Secret z Facebook Developers Console",
|
||||
"description": "Ověřte uživatele pomocí účtů Facebook",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu na Facebooku",
|
||||
"title": "Přihlášení pomocí Facebooku"
|
||||
},
|
||||
"github": {
|
||||
"clientId": "ID klienta GitHub",
|
||||
"clientIdDescription": "ID klienta z nastavení vaší GitHub OAuth aplikace",
|
||||
"clientSecret": "GitHub klientský tajný klíč",
|
||||
"clientSecretDescription": "Tajný klíč klienta z nastavení vaší GitHub OAuth aplikace",
|
||||
"description": "Ověřte uživatele pomocí účtů GitHub",
|
||||
"enable": "Povolit ověřování GitHub",
|
||||
"enableDescription": "Povolit uživatelům přihlásit se pomocí jejich účtů na GitHubu",
|
||||
"title": "Přihlášení pomocí GitHubu"
|
||||
},
|
||||
"google": {
|
||||
"clientId": "ID klienta",
|
||||
"clientIdDescription": "ID klienta Google OAuth 2.0 z Google Cloud Console",
|
||||
"clientSecret": "Klientský tajný klíč",
|
||||
"clientSecretDescription": "Tajný klíč klienta Google OAuth 2.0 z Google Cloud Console",
|
||||
"description": "Ověřte uživatele pomocí účtů Google",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu Google",
|
||||
"title": "Přihlášení pomocí Google"
|
||||
},
|
||||
"log": {
|
||||
"content": "Obsah",
|
||||
@@ -23,52 +158,49 @@
|
||||
"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"
|
||||
"phone": {
|
||||
"accessLabel": "Přístup",
|
||||
"applyPlatform": "Použít platformu",
|
||||
"description": "Ověřte uživatele pomocí telefonních čísel",
|
||||
"enable": "Povolit",
|
||||
"enableTip": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
|
||||
"endpointLabel": "Koncový bod",
|
||||
"logs": "Protokoly",
|
||||
"logsDescription": "Zobrazit historii odeslaných SMS zpráv a jejich stav",
|
||||
"phoneNumberLabel": "Telefonní číslo",
|
||||
"placeholders": {
|
||||
"template": "Váš ověřovací kód je {code}, platný po dobu 5 minut"
|
||||
},
|
||||
"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í"
|
||||
"platform": "SMS platforma",
|
||||
"platformConfigTip": "Vyplňte prosím poskytnutou konfiguraci {key}",
|
||||
"platformTip": "Vyberte prosím platformu pro SMS",
|
||||
"secretLabel": "Tajný klíč",
|
||||
"sendFailed": "Odeslání se nezdařilo",
|
||||
"sendSuccess": "Úspěšně odesláno",
|
||||
"settings": "Nastavení",
|
||||
"signNameLabel": "Název podpisu",
|
||||
"template": "Šablona SMS",
|
||||
"templateCodeLabel": "Kód šablony",
|
||||
"templateTip": "Prosím vyplňte SMS šablonu, ponechte {code} uprostřed, jinak SMS funkce nebude fungovat",
|
||||
"testSms": "Odeslat testovací SMS",
|
||||
"testSmsPhone": "Zadejte telefonní číslo",
|
||||
"testSmsTip": "Odeslat testovací SMS pro ověření vaší konfigurace",
|
||||
"title": "Ověření telefonem",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
"socialAuthMethods": "Social Authentication Methods",
|
||||
"telegram": {
|
||||
"clientId": "ID robota",
|
||||
"clientIdDescription": "ID Telegram bota, které můžete získat od @BotFather",
|
||||
"clientSecret": "Token robota",
|
||||
"clientSecretDescription": "Telegram Bot Token, který můžete získat od @BotFather",
|
||||
"description": "Ověřte uživatele pomocí účtů Telegram",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
|
||||
"title": "Přihlášení pomocí Telegramu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"email": "Zadejte platnou e-mailovou adresu.",
|
||||
"existingAccount": "Již máte účet?",
|
||||
"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í.**",
|
||||
"passwordMismatch": "Hesla se neshodují",
|
||||
"success": "Registrace úspěšná, automaticky přihlášeno!",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"emailBasicConfigDescription": "Nakonfigurujte nastavení SMTP serveru a možnosti ověření e-mailu",
|
||||
"emailLogsDescription": "Zobrazit historii odeslaných e-mailů a jejich stav",
|
||||
"emailSuffixWhitelist": "Seznam povolených přípon e-mailů",
|
||||
"emailSuffixWhitelistDescription": "Když je povoleno, mohou se registrovat pouze e-maily s příponami v seznamu",
|
||||
"emailVerification": "Ověření e-mailu",
|
||||
"emailVerificationDescription": "Když je povoleno, uživatelé budou muset ověřit svůj e-mail",
|
||||
"enable": "Povolit",
|
||||
"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_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",
|
||||
"inputPlaceholder": "Zadejte hodnotu...",
|
||||
"logs": "Protokoly",
|
||||
"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.",
|
||||
"recipient": "Příjemce",
|
||||
"saveFailed": "Nepodařilo se uložit konfiguraci",
|
||||
"saveSuccess": "Konfigurace byla úspěšně uložena.",
|
||||
"sendFailure": "Nepodařilo se odeslat testovací e-mail, zkontrolujte prosím konfiguraci.",
|
||||
"sendSuccess": "Testovací e-mail byl úspěšně odeslán.",
|
||||
"sendTestEmail": "Odeslat testovací e-mail",
|
||||
"sendTestEmailDescription": "Odeslat testovací e-mail pro ověření konfigurace.",
|
||||
"senderAddress": "Adresa odesílatele",
|
||||
"senderAddressDescription": "Výchozí e-mailová adresa používaná pro odesílání e-mailů.",
|
||||
"sent": "Odesláno",
|
||||
"sentAt": "Odesláno",
|
||||
"settings": "Nastavení",
|
||||
"smtpAccount": "SMTP účet",
|
||||
"smtpAccountDescription": "E-mailový účet používaný pro ověřování.",
|
||||
"smtpEncryptionMethod": "Metoda šifrování SMTP",
|
||||
"smtpEncryptionMethodDescription": "Vyberte, zda povolit šifrování SSL/TLS.",
|
||||
"smtpPassword": "SMTP heslo",
|
||||
"smtpPasswordDescription": "Heslo pro účet SMTP.",
|
||||
"smtpServerAddress": "Adresa SMTP serveru",
|
||||
"smtpServerAddressDescription": "Zadejte adresu serveru používanou pro odesílání e-mailů.",
|
||||
"smtpServerPort": "Port SMTP serveru",
|
||||
"smtpServerPortDescription": "Zadejte port používaný k připojení k SMTP serveru.",
|
||||
"status": "Stav",
|
||||
"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_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",
|
||||
"whitelistSuffixesDescription": "Používá se pro ověření e-mailu během registrace; jeden na řádek",
|
||||
"whitelistSuffixesPlaceholder": "Zadejte přípony e-mailů, každou na samostatný řádek"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"clientId": "ID klienta",
|
||||
"clientIdDescription": "ID aplikace Facebook z konzole pro vývojáře Facebooku",
|
||||
"clientSecret": "Klientský tajný klíč",
|
||||
"clientSecretDescription": "Facebook App Secret z Facebook Developers Console",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu na Facebooku",
|
||||
"saveFailed": "Uložení se nezdařilo",
|
||||
"saveSuccess": "Uložení bylo úspěšné"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"clientId": "ID klienta GitHub",
|
||||
"clientIdDescription": "ID klienta z nastavení vaší GitHub OAuth aplikace",
|
||||
"clientSecret": "GitHub klientský tajný klíč",
|
||||
"clientSecretDescription": "Tajný klíč klienta z nastavení vaší GitHub OAuth aplikace",
|
||||
"enable": "Povolit ověřování GitHub",
|
||||
"enableDescription": "Povolit uživatelům přihlásit se pomocí jejich účtů na GitHubu",
|
||||
"saveFailed": "Nepodařilo se uložit nastavení GitHubu",
|
||||
"saveSuccess": "Nastavení GitHubu bylo úspěšně uloženo"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"clientId": "ID klienta",
|
||||
"clientIdDescription": "ID klienta Google OAuth 2.0 z Google Cloud Console",
|
||||
"clientSecret": "Klientský tajný klíč",
|
||||
"clientSecretDescription": "Tajný klíč klienta Google OAuth 2.0 z Google Cloud Console",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu Google",
|
||||
"saveFailed": "Uložení se nezdařilo",
|
||||
"saveSuccess": "Uložení bylo úspěšné"
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"ADS Config": "Konfigurace ADS",
|
||||
"Announcement Management": "Správa oznámení",
|
||||
"Apple": "Apple ID",
|
||||
"Application Management": "Správa aplikací",
|
||||
"Auth Control": "Řízení ověřování",
|
||||
"Coupon Management": "Správa kupónů",
|
||||
"Dashboard": "Přístrojová deska",
|
||||
@@ -16,6 +17,7 @@
|
||||
"Order Management": "Správa objednávek",
|
||||
"Payment Config": "Konfigurace platby",
|
||||
"Phone Number": "Telefonní číslo",
|
||||
"Rule Management": "Správa pravidel",
|
||||
"Server": "Server",
|
||||
"Server Management": "Správa serveru",
|
||||
"Settings": "Nastavení",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"description": "Popis",
|
||||
"descriptionPlaceholder": "Zadejte popis platební metody",
|
||||
"domain": "Doména",
|
||||
"domainPlaceholder": "volitelné, např. {example}",
|
||||
"edit": "Upravit",
|
||||
"editPayment": "Upravit platební metodu",
|
||||
"enable": "Povolit",
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"accessLabel": "Přístup",
|
||||
"applyPlatform": "Použít platformu",
|
||||
"enable": "Povolit",
|
||||
"enableTip": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
|
||||
"endpointLabel": "Koncový bod",
|
||||
"logs": "Protokoly",
|
||||
"phoneNumberLabel": "Telefonní číslo",
|
||||
"placeholders": {
|
||||
"template": "Váš ověřovací kód je {code}, platný po dobu 5 minut",
|
||||
"templateCode": "Zadejte kód šablony"
|
||||
},
|
||||
"platform": "SMS platforma",
|
||||
"platformConfigTip": "Vyplňte prosím poskytnutou konfiguraci {key}",
|
||||
"platformTip": "Vyberte prosím platformu pro SMS",
|
||||
"secretLabel": "Tajný klíč",
|
||||
"sendFailed": "Odeslání se nezdařilo",
|
||||
"sendSuccess": "Úspěšně odesláno",
|
||||
"settings": "Nastavení",
|
||||
"signNameLabel": "Název podpisu",
|
||||
"template": "Šablona SMS",
|
||||
"templateCodeLabel": "Kód šablony",
|
||||
"templateTip": "Prosím vyplňte SMS šablonu, ponechte {code} uprostřed, jinak SMS funkce nebude fungovat",
|
||||
"testSms": "Odeslat testovací SMS",
|
||||
"testSmsPhone": "Zadejte telefonní číslo",
|
||||
"testSmsTip": "Odeslat testovací SMS pro ověření vaší konfigurace",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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",
|
||||
"default": "Výchozí",
|
||||
"defaultRule": "Výchozí pravidlo",
|
||||
"delete": "Smazat",
|
||||
"deleteSuccess": "Pravidlo bylo úspěšně smazáno",
|
||||
"deleteWarning": "Opravdu chcete smazat toto pravidlo? Tato akce není vratná.",
|
||||
"direct": "Přímý",
|
||||
"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ý",
|
||||
"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",
|
||||
"reject": "Odmítnout",
|
||||
"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",
|
||||
"selectTags": "Vybrat značky uzlu",
|
||||
"selectType": "Vyberte typ pravidla",
|
||||
"tags": "Značky uzlu",
|
||||
"tagsLabel": "Značky uzlu",
|
||||
"type": "Typ pravidla",
|
||||
"updateSuccess": "Pravidlo bylo úspěšně aktualizováno"
|
||||
}
|
||||
@@ -33,21 +33,22 @@
|
||||
"description": "Popis",
|
||||
"edit": "Upravit",
|
||||
"editNodeGroup": "Upravit skupinu uzlů",
|
||||
"form": {
|
||||
"name": "Název",
|
||||
"title": "Seznam skupin uzlů",
|
||||
"updatedAt": "Aktualizováno"
|
||||
},
|
||||
"groupForm": {
|
||||
"cancel": "Zrušit",
|
||||
"confirm": "Potvrdit",
|
||||
"description": "Popis",
|
||||
"name": "Název"
|
||||
},
|
||||
"name": "Název",
|
||||
"title": "Seznam skupin uzlů",
|
||||
"updatedAt": "Aktualizováno"
|
||||
},
|
||||
"node": {
|
||||
"abnormal": "Abnormální",
|
||||
"actions": "Akce",
|
||||
"address": "Adresa",
|
||||
"all": "Vše",
|
||||
"basicInfo": "Základní informace",
|
||||
"cancel": "Zrušit",
|
||||
"confirm": "Potvrdit",
|
||||
"confirmDelete": "Opravdu chcete smazat?",
|
||||
@@ -59,60 +60,81 @@
|
||||
"delete": "Smazat",
|
||||
"deleteSuccess": "Úspěšně smazáno",
|
||||
"deleteWarning": "Po smazání nebude možné data obnovit. Prosím, buďte opatrní.",
|
||||
"detail": "Detail",
|
||||
"disabled": "Zakázáno",
|
||||
"disk": "Disk",
|
||||
"edit": "Upravit",
|
||||
"editNode": "Upravit uzel",
|
||||
"enable": "Povolit",
|
||||
"form": {
|
||||
"enabled": "Povoleno",
|
||||
"expireTime": "Čas vypršení",
|
||||
"hide": "Skrýt",
|
||||
"id": "ID",
|
||||
"ipAddresses": "IP adresy",
|
||||
"lastUpdated": "Poslední aktualizace",
|
||||
"location": "Umístění",
|
||||
"memory": "Paměť",
|
||||
"name": "Název",
|
||||
"noData": "--",
|
||||
"node": "Uzel",
|
||||
"nodeDetail": "Detail uzlu",
|
||||
"nodeGroup": "Skupina uzlů",
|
||||
"nodeStatus": "Stav uzlu",
|
||||
"normal": "Normální",
|
||||
"onlineCount": "Počet online",
|
||||
"onlineUsers": "Online uživatelé",
|
||||
"protocol": "Protokol",
|
||||
"rate": "Rychlost",
|
||||
"relay": "Přenos",
|
||||
"serverAddr": "Adresa serveru",
|
||||
"speedLimit": "Omezení rychlosti",
|
||||
"status": "Stav",
|
||||
"subscribeId": "ID předplatného",
|
||||
"subscribeName": "Název předplatného",
|
||||
"subscription": "Předplatné",
|
||||
"tags": "Štítky",
|
||||
"trafficRatio": "Poměr provozu",
|
||||
"trafficUsage": "Využití provozu",
|
||||
"type": "Typ",
|
||||
"updateSuccess": "Úspěšně aktualizováno",
|
||||
"updatedAt": "Čas aktualizace",
|
||||
"userAccount": "Uživatelský účet",
|
||||
"userDetail": "Detail uživatele",
|
||||
"userId": "ID uživatele"
|
||||
},
|
||||
"nodeForm": {
|
||||
"allowInsecure": "Povolit nezabezpečené",
|
||||
"cancel": "Zrušit",
|
||||
"city": "Město",
|
||||
"confirm": "Potvrdit",
|
||||
"congestionController": "Ovladač přetížení",
|
||||
"country": "Země",
|
||||
"disableSni": "Zakázat SNI",
|
||||
"edit": "Upravit",
|
||||
"editSecurity": "Upravit nastavení zabezpečení",
|
||||
"editSecurity": "Upravit bezpečnostní konfiguraci",
|
||||
"enableTLS": "Povolit TLS",
|
||||
"encryptionMethod": "Metoda šifrování",
|
||||
"fingerprint": "Otisk",
|
||||
"flow": "Algoritmus řízení toku",
|
||||
"groupId": "Skupina uzlů",
|
||||
"hopInterval": "Interval skoků",
|
||||
"hopPorts": "Porty skoků",
|
||||
"hopPortsPlaceholder": "Více portů oddělených čárkou",
|
||||
"hopPortsPlaceholder": "Oddělte více portů čárkami",
|
||||
"name": "Název",
|
||||
"obfsPassword": "Heslo pro zmatení",
|
||||
"obfsPasswordPlaceholder": "Nechte prázdné, pokud nechcete zmást",
|
||||
"obfsPassword": "Heslo pro obfuskování",
|
||||
"obfsPasswordPlaceholder": "Nechte prázdné pro žádné obfuskování",
|
||||
"path": "Cesta",
|
||||
"pleaseSelect": "Prosím vyberte",
|
||||
"port": "Port služby",
|
||||
"port": "Port serveru",
|
||||
"protocol": "Protokol",
|
||||
"relayHost": "Adresa přenosu",
|
||||
"reduceRtt": "Snížit RTT",
|
||||
"relayHost": "Hostitel relé",
|
||||
"relayMode": "Režim relé",
|
||||
"relayModeOptions": {
|
||||
"all": "Vše",
|
||||
"none": "Žádný",
|
||||
"random": "Náhodný"
|
||||
},
|
||||
"relayPort": "Port přenosu",
|
||||
"relayPort": "Port relé",
|
||||
"relayPrefix": "Předpona relé",
|
||||
"remarks": "Poznámky",
|
||||
"security": "Zabezpečení",
|
||||
"securityConfig": "Nastavení zabezpečení",
|
||||
"security_config": {
|
||||
"fingerprint": "FingerPrint",
|
||||
"privateKey": "Soukromý klíč",
|
||||
"privateKeyPlaceholder": "Nechte prázdné pro automatické generování",
|
||||
"publicKey": "Veřejný klíč",
|
||||
"publicKeyPlaceholder": "Nechte prázdné pro automatické generování",
|
||||
"serverAddress": "Adresa serveru",
|
||||
"serverAddressPlaceholder": "Cílová adresa REALITY, výchozí je SNI",
|
||||
"serverName": "Název serveru (SNI)",
|
||||
"serverNamePlaceholder": "REALITY je povinné, musí být shodné s backendem",
|
||||
"serverPort": "Port serveru",
|
||||
"serverPortPlaceholder": "Cílový port REALITY, výchozí 443",
|
||||
"shortId": "ShortId",
|
||||
"shortIdPlaceholder": "Nechte prázdné pro automatické generování",
|
||||
"sni": "Indikace názvu serveru (SNI)"
|
||||
},
|
||||
"security": "Bezpečnost",
|
||||
"securityConfig": "Bezpečnostní konfigurace",
|
||||
"selectEncryptionMethod": "Vyberte metodu šifrování",
|
||||
"selectNodeGroup": "Vyberte skupinu uzlů",
|
||||
"selectProtocol": "Vyberte protokol",
|
||||
@@ -120,35 +142,38 @@
|
||||
"serverAddr": "Adresa serveru",
|
||||
"serverKey": "Klíč serveru",
|
||||
"serverName": "Název služby",
|
||||
"speedLimit": "Omezení rychlosti",
|
||||
"speedLimitPlaceholder": "Bez omezení",
|
||||
"speedLimit": "Rychlostní limit",
|
||||
"speedLimitPlaceholder": "Neomezeno",
|
||||
"tags": "Štítky",
|
||||
"tagsPlaceholder": "Použijte Enter nebo čárku (,) pro zadání více štítků",
|
||||
"trafficRatio": "Poměr provozu",
|
||||
"transport": "Přenosový protokol",
|
||||
"transportConfig": "Nastavení přenosového protokolu",
|
||||
"transportHost": "Adresa přenosové služby",
|
||||
"transportPath": "Cesta přenosu",
|
||||
"transportServerName": "Název přenosové služby"
|
||||
"transport": "Konfigurace transportního protokolu",
|
||||
"transportConfig": "Konfigurace transportního protokolu",
|
||||
"transportHost": "Adresa transportního serveru",
|
||||
"transportPath": "Transportní cesta",
|
||||
"transportServerName": "Název transportního serveru",
|
||||
"udpRelayMode": "Režim UDP relé"
|
||||
},
|
||||
"lastUpdated": "Poslední aktualizace",
|
||||
"memory": "Paměť",
|
||||
"name": "Název",
|
||||
"node": "Uzel",
|
||||
"nodeGroup": "Skupina uzlů",
|
||||
"normal": "Normální",
|
||||
"onlineCount": "Počet online",
|
||||
"onlineUsers": "Online uživatelé",
|
||||
"rate": "Rychlost",
|
||||
"relay": "Přenos",
|
||||
"serverAddr": "Adresa serveru",
|
||||
"speedLimit": "Omezení rychlosti",
|
||||
"status": "Stav",
|
||||
"tags": "Štítky",
|
||||
"trafficRatio": "Poměr provozu",
|
||||
"type": "Typ",
|
||||
"updateSuccess": "Úspěšně aktualizováno",
|
||||
"updatedAt": "Čas aktualizace"
|
||||
"relayModeOptions": {
|
||||
"all": "Vše",
|
||||
"none": "Žádné",
|
||||
"random": "Náhodně"
|
||||
},
|
||||
"securityConfig": {
|
||||
"fingerprint": "Otisk",
|
||||
"privateKey": "Soukromý klíč",
|
||||
"privateKeyPlaceholder": "Nechte prázdné pro automatické vygenerování",
|
||||
"publicKey": "Veřejný klíč",
|
||||
"publicKeyPlaceholder": "Nechte prázdné pro automatické vygenerování",
|
||||
"serverAddress": "Adresa serveru",
|
||||
"serverAddressPlaceholder": "Cílová adresa REALITY, výchozí použití SNI",
|
||||
"serverName": "Název serveru (SNI)",
|
||||
"serverNamePlaceholder": "REALITY vyžaduje, konzistentní s backendem",
|
||||
"serverPort": "Port serveru",
|
||||
"serverPortPlaceholder": "Cílový port REALITY, výchozí 443",
|
||||
"shortId": "Krátké ID",
|
||||
"shortIdPlaceholder": "Nechte prázdné pro automatické vygenerování",
|
||||
"sni": "Indikace názvu serveru (SNI)"
|
||||
},
|
||||
"tabs": {
|
||||
"node": "Uzlu",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"quota": "Limit na nákup/čas",
|
||||
"replacement": "Obnovení ceny/krát",
|
||||
"sell": "Prodej",
|
||||
"show": "Zobrazit na hlavní stránce",
|
||||
"show": "Zobrazit",
|
||||
"sold": "Počet předplatných",
|
||||
"subscribe": "Předplatit",
|
||||
"subscribeGroup": "Přihlásit se ke skupině",
|
||||
|
||||
@@ -1,28 +1,77 @@
|
||||
{
|
||||
"authSettings": "Nastavení ověřování",
|
||||
"basicSettings": "Základní nastavení",
|
||||
"common": {
|
||||
"cancel": "Zrušit",
|
||||
"save": "Uložit nastavení",
|
||||
"saveFailed": "Uložení selhalo",
|
||||
"saveSuccess": "Uložení úspěšné"
|
||||
},
|
||||
"currency": {
|
||||
"accessKey": "Klíč",
|
||||
"accessKeyDescription": "https://exchangerate.host poskytuje zdarma klíč k API směnných kurzů",
|
||||
"accessKeyDescription": "{url} poskytuje zdarma klíč k API směnných kurzů",
|
||||
"accessKeyPlaceholder": "Zadejte API klíč",
|
||||
"currencySymbol": "Symbol měny",
|
||||
"currencySymbolDescription": "Pouze pro zobrazení, po změně se změní všechny měnové jednotky v systému",
|
||||
"currencySymbolPlaceholder": "Kč",
|
||||
"currencyUnit": "Měnová jednotka",
|
||||
"currencyUnitDescription": "Pouze pro zobrazení, po změně se změní všechny měnové jednotky v systému",
|
||||
"saveSuccess": "Úspěšně uloženo"
|
||||
"currencyUnitPlaceholder": "CZK",
|
||||
"description": "Nastavte jednotky měny, symboly a nastavení API pro směnné kurzy",
|
||||
"title": "Nastavení měny"
|
||||
},
|
||||
"privacy-policy": {
|
||||
"saveSuccess": "Uložení bylo úspěšné",
|
||||
"invite": {
|
||||
"description": "Nastavte pozvání uživatelů a odměny za doporučení",
|
||||
"forcedInvite": "Vyžadovat pozvánku k registraci",
|
||||
"forcedInviteDescription": "Pokud je povoleno, uživatelé se musí registrovat prostřednictvím pozvánkového odkazu",
|
||||
"inputPlaceholder": "Prosím zadejte",
|
||||
"onlyFirstPurchase": "Odměna pouze za první nákup",
|
||||
"onlyFirstPurchaseDescription": "Pokud je povoleno, doporučující obdrží odměny pouze za první nákup doporučených uživatelů",
|
||||
"referralPercentage": "Procento odměny za doporučení",
|
||||
"referralPercentageDescription": "Procento odměny udělené doporučujícím",
|
||||
"saveFailed": "Uložení selhalo",
|
||||
"saveSuccess": "Uložení úspěšné",
|
||||
"title": "Nastavení pozvánek"
|
||||
},
|
||||
"privacyPolicy": {
|
||||
"description": "Upravte a spravujte obsah zásad ochrany osobních údajů",
|
||||
"title": "Zásady ochrany osobních údajů"
|
||||
},
|
||||
"register": {
|
||||
"day": "den(dny)",
|
||||
"defaultSubscribe": "Výchozí předplatné",
|
||||
"defaultSubscribeDescription": "Výchozí plán předplatného pro nové uživatele",
|
||||
"description": "Nastavte související nastavení registrace uživatelů",
|
||||
"inputPlaceholder": "Prosím zadejte",
|
||||
"ipRegistrationLimit": "Limit registrace podle IP",
|
||||
"ipRegistrationLimitDescription": "Omezení počtu registrací z jedné IP adresy",
|
||||
"registrationLimitCount": "Počet registrací v limitu",
|
||||
"registrationLimitCountDescription": "Počet registrací povolených na IP během limitního období",
|
||||
"registrationLimitExpire": "Limitní období",
|
||||
"registrationLimitExpireDescription": "Doba pro limit registrace podle IP",
|
||||
"saveFailed": "Uložení selhalo",
|
||||
"saveSuccess": "Uložení úspěšné",
|
||||
"selectPlaceholder": "Prosím vyberte",
|
||||
"stopNewUserRegistration": "Zastavit registraci nových uživatelů",
|
||||
"stopNewUserRegistrationDescription": "Pokud je povoleno, registrace nových uživatelů bude zakázána",
|
||||
"title": "Nastavení registrace",
|
||||
"trialDay": "Zkušební dny",
|
||||
"trialDayDescription": "Zkušební dny poskytnuté novým uživatelům při registraci",
|
||||
"trialFlow": "Zkušební provoz",
|
||||
"trialFlowDescription": "Zkušební provoz poskytnutý novým uživatelům při registraci"
|
||||
},
|
||||
"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.",
|
||||
"description": "Nastavte základní informace o webu, logo, doménu a další nastavení",
|
||||
"keywords": "Klíčová slova",
|
||||
"keywordsDescription": "Používá se pro SEO účely",
|
||||
"keywordsPlaceholder": "klíčové slovo1, klíčové slovo2, klíčové slovo3",
|
||||
"logo": "LOGO",
|
||||
"logoDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit LOGO",
|
||||
"logoPlaceholder": "Zadejte URL adresu LOGA, nekončete '/'",
|
||||
"saveSuccess": "Uložení bylo úspěšné",
|
||||
"siteDesc": "Popis webu",
|
||||
"siteDescDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit popis webu",
|
||||
"siteDescPlaceholder": "Zadejte popis webu",
|
||||
@@ -31,15 +80,47 @@
|
||||
"siteDomainPlaceholder": "Zadejte adresu domény, pro více domén použijte jeden řádek pro každou",
|
||||
"siteName": "Název webu",
|
||||
"siteNameDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit název webu",
|
||||
"siteNamePlaceholder": "Zadejte název webu"
|
||||
},
|
||||
"tabs": {
|
||||
"currency": "Měna",
|
||||
"site": "Stránka",
|
||||
"tos": "Podmínky služby"
|
||||
"siteNamePlaceholder": "Zadejte název webu",
|
||||
"title": "Nastavení webu"
|
||||
},
|
||||
"siteSettings": "Nastavení webu",
|
||||
"tos": {
|
||||
"saveSuccess": "Uložení bylo úspěšné",
|
||||
"description": "Upravte a spravujte obsah podmínek služby",
|
||||
"title": "Podmínky služby"
|
||||
},
|
||||
"userSecuritySettings": "Uživatel a zabezpečení",
|
||||
"verify": {
|
||||
"description": "Nastavte Turnstile CAPTCHA a ověřovací nastavení",
|
||||
"enableLoginVerify": "Povolit ověření při přihlášení",
|
||||
"enableLoginVerifyDescription": "Pokud je povoleno, uživatelé musí projít ověřením člověka při přihlášení",
|
||||
"enablePasswordVerify": "Povolit ověření při resetování hesla",
|
||||
"enablePasswordVerifyDescription": "Pokud je povoleno, uživatelé musí projít ověřením člověka při resetování hesla",
|
||||
"enableRegisterVerify": "Povolit ověření při registraci",
|
||||
"enableRegisterVerifyDescription": "Pokud je povoleno, uživatelé musí projít ověřením člověka při registraci",
|
||||
"inputPlaceholder": "Prosím zadejte",
|
||||
"saveFailed": "Uložení selhalo",
|
||||
"saveSuccess": "Uložení úspěšné",
|
||||
"title": "Ověření zabezpečení",
|
||||
"turnstileSecret": "Tajný klíč Turnstile",
|
||||
"turnstileSecretDescription": "Tajný klíč Cloudflare Turnstile pro ověření na backendu",
|
||||
"turnstileSecretPlaceholder": "Zadejte tajný klíč Turnstile",
|
||||
"turnstileSiteKey": "Klíč webu Turnstile",
|
||||
"turnstileSiteKeyDescription": "Klíč webu Cloudflare Turnstile pro ověření na frontendu",
|
||||
"turnstileSiteKeyPlaceholder": "Zadejte klíč webu Turnstile"
|
||||
},
|
||||
"verifyCode": {
|
||||
"dailyLimit": "Denní limit odesílání",
|
||||
"dailyLimitDescription": "Maximální počet ověřovacích kódů, které může každý uživatel odeslat za den",
|
||||
"description": "Nastavte pravidla a limity pro odesílání ověřovacích kódů e-mailem",
|
||||
"expireTime": "Platnost ověřovacího kódu",
|
||||
"expireTimeDescription": "Doba platnosti ověřovacích kódů (vteřiny)",
|
||||
"inputPlaceholder": "Prosím zadejte",
|
||||
"interval": "Interval odesílání",
|
||||
"intervalDescription": "Minimální interval mezi dvěma odesláními ověřovacího kódu (vteřiny)",
|
||||
"saveFailed": "Uložení selhalo",
|
||||
"saveSuccess": "Uložení úspěšné",
|
||||
"seconds": "vteřiny",
|
||||
"times": "krát",
|
||||
"title": "Nastavení ověřovacího kódu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"clientId": "ID robota",
|
||||
"clientIdDescription": "ID Telegram bota, které můžete získat od @BotFather",
|
||||
"clientSecret": "Token robota",
|
||||
"clientSecretDescription": "Telegram Bot Token, který můžete získat od @BotFather",
|
||||
"enable": "Povolit",
|
||||
"enableDescription": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
|
||||
"saveFailed": "Uložení se nezdařilo",
|
||||
"saveSuccess": "Uložení bylo úspěšné"
|
||||
}
|
||||
@@ -6,15 +6,18 @@
|
||||
"confirmSystemUpgrade": "Potvrdit aktualizaci systému",
|
||||
"confirmUpgrade": "Potvrdit aktualizaci",
|
||||
"currentVersion": "Aktuální verze systému:",
|
||||
"developmentVersion": "Vývojová verze",
|
||||
"errors": "Chyby",
|
||||
"ip": "IP",
|
||||
"ip": "IP adresa",
|
||||
"lastUpdated": "Poslední aktualizace:",
|
||||
"newVersionAvailable": "Nová verze",
|
||||
"none": "Žádný",
|
||||
"query": "Dotaz",
|
||||
"rebootDescription": "Opravdu chcete restartovat systém? Tato akce způsobí krátké přerušení služby.",
|
||||
"rebooting": "Systém se restartuje...",
|
||||
"refreshLogs": "Obnovit protokoly",
|
||||
"request": "Žádost",
|
||||
"serverVersion": "Server",
|
||||
"status": "Stav",
|
||||
"systemLogs": "Systémové protokoly",
|
||||
"systemReboot": "Restart systému",
|
||||
@@ -22,5 +25,8 @@
|
||||
"systemUpgrade": "Aktualizace systému",
|
||||
"upgradeDescription": "Jste si jisti, že chcete provést aktualizaci systému? Tato operace může trvat několik minut a během ní může být systém neodpovídající.",
|
||||
"userAgent": "Uživatelský agent",
|
||||
"viewLogsAndManage": "Zobrazit systémové protokoly, provádět aktualizace systému a restartovat operace"
|
||||
"versionInfo": "Informace o verzi",
|
||||
"viewLogsAndManage": "Zobrazit systémové protokoly, provádět aktualizace systému a restartovat operace",
|
||||
"viewNewVersion": "Zobrazit novou verzi",
|
||||
"webVersion": "Web/Admin"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"accountEnable": "Povolení účtu",
|
||||
"actions": "akce",
|
||||
"active": "Aktivní",
|
||||
"add": "Přidat",
|
||||
"administrator": "Administrátor",
|
||||
"areaCode": "Směrový kód",
|
||||
@@ -41,10 +42,12 @@
|
||||
"email": "e-mail",
|
||||
"enable": "Povolit",
|
||||
"expireTime": "Čas vypršení",
|
||||
"expired": "Vypršelo",
|
||||
"expiredAt": "Platnost vypršela",
|
||||
"failed": "Neúspěch",
|
||||
"giftAmount": "Částka dárku",
|
||||
"giftAmountPlaceholder": "Zadejte částku dárku",
|
||||
"inactive": "Neaktivní",
|
||||
"invalidEmailFormat": "Neplatný formát e-mailu",
|
||||
"inviteCode": "Pozvánkový kód",
|
||||
"inviteCodePlaceholder": "Zadejte kód pozvánky (ponechte prázdné pro vygenerování)",
|
||||
@@ -63,6 +66,7 @@
|
||||
"passwordPlaceholder": "Zadejte nové heslo (volitelné)",
|
||||
"permanent": "Trvalý",
|
||||
"pleaseEnterEmail": "Prosím, zadejte e-mail",
|
||||
"price": "Cena",
|
||||
"referer": "Doporučitel",
|
||||
"refererId": "ID doporučitele",
|
||||
"refererIdPlaceholder": "Zadejte ID doporučitele",
|
||||
@@ -74,8 +78,12 @@
|
||||
"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",
|
||||
@@ -89,6 +97,8 @@
|
||||
"tradeNotifications": "Obchodní oznámení",
|
||||
"trafficLimit": "Limit provozu",
|
||||
"trafficLogs": "Protokoly provozu",
|
||||
"trafficUsage": "Využití dat",
|
||||
"unknown": "Neznámé",
|
||||
"unlimited": "Neomezený",
|
||||
"unverified": "Neověřeno",
|
||||
"update": "Aktualizovat",
|
||||
@@ -98,11 +108,15 @@
|
||||
"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ů",
|
||||
"userLogs": "Historie přihlášení",
|
||||
"userName": "Identifikátor uživatele",
|
||||
"userOrders": "Objednávky",
|
||||
"userProfile": "Profil",
|
||||
"userSubscriptions": "Předplatné",
|
||||
"verified": "Ověřeno"
|
||||
"username": "Uživatelské jméno",
|
||||
"verified": "Ověřeno",
|
||||
"viewDetails": "Zobrazit podrobnosti"
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"clientId": "Dienst-ID",
|
||||
"clientIdDescription": "Apple-Dienst-ID, die Sie im Apple Developer Portal erhalten können",
|
||||
"clientSecret": "Privater Schlüssel",
|
||||
"clientSecretDescription": "Der private Schlüsselinhalt (.p8-Datei), der für die Authentifizierung bei Apple verwendet wird",
|
||||
"enable": "Aktivieren",
|
||||
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrer Apple-ID anmelden",
|
||||
"keyId": "Schlüssel-ID",
|
||||
"keyIdDescription": "Die ID Ihres privaten Schlüssels aus dem Apple Developer Portal",
|
||||
"redirectUri": "Weiterleitungs-URL",
|
||||
"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",
|
||||
"saveSuccess": "Erfolgreich gespeichert",
|
||||
"teamId": "Team-ID",
|
||||
"teamIdDescription": "Apple Developer-Team-ID"
|
||||
}
|
||||
@@ -1,15 +1,150 @@
|
||||
{
|
||||
"invite": {
|
||||
"commissionFirstTimeOnly": "Provision nur beim ersten Kauf",
|
||||
"commissionFirstTimeOnlyDescription": "Wenn aktiviert, wird die Provision nur bei der ersten Zahlung des Einladers generiert; individuelle Benutzer können in der Benutzerverwaltung konfiguriert werden",
|
||||
"enableForcedInvite": "Erzwinge Einladung",
|
||||
"enableForcedInviteDescription": "Wenn aktiviert, können sich nur eingeladene Benutzer registrieren",
|
||||
"inputPlaceholder": "Eingeben",
|
||||
"inviteCommissionPercentage": "Einladungsprovisionssatz",
|
||||
"inviteCommissionPercentageDescription": "Standardmäßiges globales Provisionsverteilungsschema; individuelle Sätze können in der Benutzerverwaltung konfiguriert werden",
|
||||
"inviteSettings": "Einladungseinstellungen",
|
||||
"apple": {
|
||||
"clientId": "Dienst-ID",
|
||||
"clientIdDescription": "Apple-Dienst-ID, die Sie im Apple Developer Portal erhalten können",
|
||||
"clientSecret": "Privater Schlüssel",
|
||||
"clientSecretDescription": "Der private Schlüsselinhalt (.p8-Datei), der für die Authentifizierung bei Apple verwendet wird",
|
||||
"description": "Benutzer mit Apple-Konten authentifizieren",
|
||||
"enable": "Aktivieren",
|
||||
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrer Apple-ID anmelden",
|
||||
"keyId": "Schlüssel-ID",
|
||||
"keyIdDescription": "Die ID Ihres privaten Schlüssels aus dem Apple Developer Portal",
|
||||
"redirectUri": "Weiterleitungs-URL",
|
||||
"redirectUriDescription": "Bitte geben Sie die API-Adresse der umgeleiteten URL ein, nachdem die Apple-Authentifizierung erfolgreich abgeschlossen wurde. Verwenden Sie kein / am Ende.",
|
||||
"teamId": "Team-ID",
|
||||
"teamIdDescription": "Apple Developer-Team-ID",
|
||||
"title": "Apple-Anmeldung"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"saveSuccess": "Erfolgreich gespeichert"
|
||||
},
|
||||
"communicationMethods": "Communication Methods",
|
||||
"device": {
|
||||
"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.",
|
||||
"description": "Benutzer mit Gerätekennungen authentifizieren",
|
||||
"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.",
|
||||
"showAds": "Werbung anzeigen",
|
||||
"showAdsDescription": "Wenn aktiviert, werden auf den Geräten Anzeigen angezeigt.",
|
||||
"title": "Geräteauthentifizierung"
|
||||
},
|
||||
"deviceAuthMethods": "Device Authentication Methods",
|
||||
"email": {
|
||||
"basicSettings": "Grundeinstellungen",
|
||||
"description": "Benutzer mit E-Mail-Adressen authentifizieren",
|
||||
"emailSuffixWhitelist": "E-Mail-Suffix-Whitelist",
|
||||
"emailSuffixWhitelistDescription": "Wenn aktiviert, können sich nur E-Mails mit Suffixen aus der Liste registrieren",
|
||||
"emailVerification": "E-Mail-Verifizierung",
|
||||
"emailVerificationDescription": "Wenn aktiviert, müssen Benutzer ihre E-Mail-Adresse verifizieren",
|
||||
"enable": "Aktivieren",
|
||||
"enableDescription": "Nach der Aktivierung werden die Funktionen zur E-Mail-Registrierung, Anmeldung, Bindung und Entbindung aktiviert.",
|
||||
"expirationEmailTemplate": "Vorlage für Ablaufbenachrichtigung",
|
||||
"expirationTemplate": "Ablaufbenachrichtigung",
|
||||
"inputPlaceholder": "Wert eingeben...",
|
||||
"logs": "Protokolle",
|
||||
"logsDescription": "Verlauf der gesendeten E-Mails und deren Status anzeigen",
|
||||
"maintenanceEmailTemplate": "Vorlage für Wartungsbenachrichtigung",
|
||||
"maintenanceTemplate": "Wartungsbenachrichtigung",
|
||||
"sendFailure": "Senden der Test-E-Mail fehlgeschlagen, bitte überprüfen Sie die Konfiguration.",
|
||||
"sendSuccess": "Test-E-Mail erfolgreich gesendet.",
|
||||
"sendTestEmail": "Test-E-Mail senden",
|
||||
"sendTestEmailDescription": "Senden Sie eine Test-E-Mail, um die Konfiguration zu überprüfen.",
|
||||
"senderAddress": "Absenderadresse",
|
||||
"senderAddressDescription": "Die standardmäßige E-Mail-Adresse, die zum Versenden von E-Mails verwendet wird.",
|
||||
"smtpAccount": "SMTP-Konto",
|
||||
"smtpAccountDescription": "Das E-Mail-Konto, das für die Authentifizierung verwendet wird.",
|
||||
"smtpEncryptionMethod": "SMTP-Verschlüsselungsmethode",
|
||||
"smtpEncryptionMethodDescription": "Wählen Sie, ob SSL/TLS-Verschlüsselung aktiviert werden soll.",
|
||||
"smtpPassword": "SMTP-Passwort",
|
||||
"smtpPasswordDescription": "Das Passwort für das SMTP-Konto.",
|
||||
"smtpServerAddress": "SMTP-Server-Adresse",
|
||||
"smtpServerAddressDescription": "Geben Sie die Serveradresse an, die zum Senden von E-Mails verwendet wird.",
|
||||
"smtpServerPort": "SMTP-Server-Port",
|
||||
"smtpServerPortDescription": "Geben Sie den Port an, der für die Verbindung zum SMTP-Server verwendet wird.",
|
||||
"smtpSettings": "SMTP-Einstellungen",
|
||||
"templateVariables": {
|
||||
"code": {
|
||||
"description": "6-stelliger Bestätigungscode",
|
||||
"title": "Bestätigungscode"
|
||||
},
|
||||
"expire": {
|
||||
"description": "Gültigkeitszeit des Bestätigungscodes (Minuten)",
|
||||
"title": "Gültigkeitsdauer"
|
||||
},
|
||||
"expireDate": {
|
||||
"description": "Ablaufdatum des Dienstes, erinnert Benutzer daran, wann der Dienst abläuft",
|
||||
"title": "Ablaufdatum"
|
||||
},
|
||||
"maintenanceDate": {
|
||||
"description": "Datum der Systemwartung, zeigt das Startdatum der Wartung an",
|
||||
"title": "Wartungsdatum"
|
||||
},
|
||||
"maintenanceTime": {
|
||||
"description": "Geschätzte Wartungszeit, zeigt den Wartungszeitraum oder die Dauer an",
|
||||
"title": "Wartungsdauer"
|
||||
},
|
||||
"siteLogo": {
|
||||
"description": "URL des Website-Logo-Bildes",
|
||||
"title": "Website-Logo"
|
||||
},
|
||||
"siteName": {
|
||||
"description": "Aktueller Website-Name",
|
||||
"title": "Website-Name"
|
||||
},
|
||||
"title": "E-Mail-Vorlagenvariablen",
|
||||
"type": {
|
||||
"conditionalSyntax": "Unterstützt bedingte Syntax zum Wechseln des Inhalts basierend auf dem Typ",
|
||||
"description": "E-Mail-Typ-Identifikator, 1 für Registrierungsbestätigungscode, andere für Passwortzurücksetzungsbestätigungscode",
|
||||
"title": "E-Mail-Typ"
|
||||
}
|
||||
},
|
||||
"title": "E-Mail-Authentifizierung",
|
||||
"trafficExceedEmailTemplate": "Vorlage für Verkehrsüberschreitungsbenachrichtigung",
|
||||
"trafficTemplate": "Verkehrsgrenze",
|
||||
"verifyEmailTemplate": "Vorlage für Bestätigungs-E-Mail",
|
||||
"verifyTemplate": "Bestätigungs-E-Mail",
|
||||
"whitelistSuffixes": "Whitelist-Suffixe",
|
||||
"whitelistSuffixesDescription": "Wird zur E-Mail-Verifizierung während der Registrierung verwendet; eine pro Zeile",
|
||||
"whitelistSuffixesPlaceholder": "Geben Sie E-Mail-Suffixe ein, eines pro Zeile"
|
||||
},
|
||||
"facebook": {
|
||||
"clientId": "Kunden-ID",
|
||||
"clientIdDescription": "Facebook-App-ID aus der Facebook-Entwicklerkonsole",
|
||||
"clientSecret": "Client-Geheimnis",
|
||||
"clientSecretDescription": "Facebook-App-Geheimnis aus der Facebook-Entwicklerkonsole",
|
||||
"description": "Benutzer mit Facebook-Konten authentifizieren",
|
||||
"enable": "Aktivieren",
|
||||
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Facebook-Konto anmelden",
|
||||
"title": "Facebook-Anmeldung"
|
||||
},
|
||||
"github": {
|
||||
"clientId": "GitHub-Client-ID",
|
||||
"clientIdDescription": "Die Client-ID aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
|
||||
"clientSecret": "GitHub-Clientgeheimnis",
|
||||
"clientSecretDescription": "Das Client-Geheimnis aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
|
||||
"description": "Benutzer mit GitHub-Konten authentifizieren",
|
||||
"enable": "GitHub-Authentifizierung aktivieren",
|
||||
"enableDescription": "Ermöglichen Sie Benutzern, sich mit ihren GitHub-Konten anzumelden",
|
||||
"title": "GitHub-Anmeldung"
|
||||
},
|
||||
"google": {
|
||||
"clientId": "Kunden-ID",
|
||||
"clientIdDescription": "Google OAuth 2.0-Client-ID aus der Google Cloud Console",
|
||||
"clientSecret": "Client-Geheimnis",
|
||||
"clientSecretDescription": "Google OAuth 2.0-Client-Geheimnis aus der Google Cloud Console",
|
||||
"description": "Benutzer mit Google-Konten authentifizieren",
|
||||
"enable": "Aktivieren",
|
||||
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Google-Konto anmelden",
|
||||
"title": "Google-Anmeldung"
|
||||
},
|
||||
"log": {
|
||||
"content": "Inhalt",
|
||||
"createdAt": "Erstellt am",
|
||||
@@ -23,52 +158,49 @@
|
||||
"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"
|
||||
"phone": {
|
||||
"accessLabel": "Zugriff",
|
||||
"applyPlatform": "Plattform anwenden",
|
||||
"description": "Benutzer mit Telefonnummern authentifizieren",
|
||||
"enable": "Aktivieren",
|
||||
"enableTip": "Nach der Aktivierung werden die Funktionen zur Registrierung, Anmeldung, Bindung und Entbindung von Mobiltelefonen aktiviert",
|
||||
"endpointLabel": "Endpunkt",
|
||||
"logs": "Protokolle",
|
||||
"logsDescription": "Verlauf der gesendeten SMS-Nachrichten und deren Status anzeigen",
|
||||
"phoneNumberLabel": "Telefonnummer",
|
||||
"placeholders": {
|
||||
"template": "Ihr Bestätigungscode ist {code}, gültig für 5 Minuten"
|
||||
},
|
||||
"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"
|
||||
"platform": "SMS-Plattform",
|
||||
"platformConfigTip": "Bitte füllen Sie die bereitgestellte {key}-Konfiguration aus",
|
||||
"platformTip": "Bitte wählen Sie die SMS-Plattform aus",
|
||||
"secretLabel": "Geheimnis",
|
||||
"sendFailed": "Senden fehlgeschlagen",
|
||||
"sendSuccess": "Erfolgreich gesendet",
|
||||
"settings": "Einstellungen",
|
||||
"signNameLabel": "Signaturname",
|
||||
"template": "SMS-Vorlage",
|
||||
"templateCodeLabel": "Vorlagen-Code",
|
||||
"templateTip": "Bitte füllen Sie die SMS-Vorlage aus, lassen Sie {code} in der Mitte, andernfalls funktioniert die SMS-Funktion nicht.",
|
||||
"testSms": "Test-SMS senden",
|
||||
"testSmsPhone": "Telefonnummer eingeben",
|
||||
"testSmsTip": "Senden Sie eine Test-SMS, um Ihre Konfiguration zu überprüfen",
|
||||
"title": "Telefon-Authentifizierung",
|
||||
"updateSuccess": "Aktualisierung erfolgreich",
|
||||
"whitelistAreaCode": "Whitelist Bereichscode",
|
||||
"whitelistAreaCodeTip": "Bitte geben Sie die Whitelist Bereichscodes ein, z.B. 1, 852, 886, 888",
|
||||
"whitelistValidation": "Whitelist Überprüfung",
|
||||
"whitelistValidationTip": "Wenn aktiviert, können nur Bereichscodes in der Whitelist SMS senden"
|
||||
},
|
||||
"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"
|
||||
"socialAuthMethods": "Social Authentication Methods",
|
||||
"telegram": {
|
||||
"clientId": "Bot-ID",
|
||||
"clientIdDescription": "Telegram-Bot-ID, die Sie von @BotFather erhalten können",
|
||||
"clientSecret": "Bot-Token",
|
||||
"clientSecretDescription": "Telegram-Bot-Token, den Sie von @BotFather erhalten können",
|
||||
"description": "Benutzer mit Telegram-Konten authentifizieren",
|
||||
"enable": "Aktivieren",
|
||||
"enableDescription": "Nach der Aktivierung werden die Funktionen zur Registrierung, Anmeldung, Bindung und Entbindung von Mobiltelefonen aktiviert",
|
||||
"title": "Telegram-Anmeldung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"existingAccount": "Bereits ein Konto?",
|
||||
"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.**",
|
||||
"passwordMismatch": "Die eingegebenen Passwörter stimmen nicht überein",
|
||||
"success": "Registrierung erfolgreich, Sie sind automatisch eingeloggt!",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"emailBasicConfigDescription": "Konfigurieren Sie die SMTP-Servereinstellungen und E-Mail-Verifizierungsoptionen",
|
||||
"emailLogsDescription": "Sehen Sie sich den Verlauf gesendeter E-Mails und deren Status an",
|
||||
"emailSuffixWhitelist": "E-Mail-Suffix-Whitelist",
|
||||
"emailSuffixWhitelistDescription": "Wenn aktiviert, können sich nur E-Mails mit Suffixen aus der Liste registrieren",
|
||||
"emailVerification": "E-Mail-Verifizierung",
|
||||
"emailVerificationDescription": "Wenn aktiviert, müssen Benutzer ihre E-Mail-Adresse verifizieren",
|
||||
"enable": "Aktivieren",
|
||||
"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_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"inputPlaceholder": "Wert eingeben...",
|
||||
"logs": "Protokolle",
|
||||
"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.",
|
||||
"recipient": "Empfänger",
|
||||
"saveFailed": "Konfiguration konnte nicht gespeichert werden",
|
||||
"saveSuccess": "Konfiguration erfolgreich gespeichert.",
|
||||
"sendFailure": "Senden der Test-E-Mail fehlgeschlagen, bitte überprüfen Sie die Konfiguration.",
|
||||
"sendSuccess": "Test-E-Mail erfolgreich gesendet.",
|
||||
"sendTestEmail": "Test-E-Mail senden",
|
||||
"sendTestEmailDescription": "Senden Sie eine Test-E-Mail, um die Konfiguration zu überprüfen.",
|
||||
"senderAddress": "Absenderadresse",
|
||||
"senderAddressDescription": "Die standardmäßige E-Mail-Adresse, die zum Versenden von E-Mails verwendet wird.",
|
||||
"sent": "Gesendet",
|
||||
"sentAt": "Gesendet am",
|
||||
"settings": "Einstellungen",
|
||||
"smtpAccount": "SMTP-Konto",
|
||||
"smtpAccountDescription": "Das E-Mail-Konto, das für die Authentifizierung verwendet wird.",
|
||||
"smtpEncryptionMethod": "SMTP-Verschlüsselungsmethode",
|
||||
"smtpEncryptionMethodDescription": "Wählen Sie, ob SSL/TLS-Verschlüsselung aktiviert werden soll.",
|
||||
"smtpPassword": "SMTP-Passwort",
|
||||
"smtpPasswordDescription": "Das Passwort für das SMTP-Konto.",
|
||||
"smtpServerAddress": "SMTP-Server-Adresse",
|
||||
"smtpServerAddressDescription": "Geben Sie die Serveradresse an, die zum Senden von E-Mails verwendet wird.",
|
||||
"smtpServerPort": "SMTP-Server-Port",
|
||||
"smtpServerPortDescription": "Geben Sie den Port an, der für die Verbindung zum SMTP-Server verwendet wird.",
|
||||
"status": "Status",
|
||||
"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_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass diese Variablen beibehalten werden.",
|
||||
"whitelistSuffixes": "Whitelist-Suffixe",
|
||||
"whitelistSuffixesDescription": "Wird zur E-Mail-Verifizierung während der Registrierung verwendet; eine pro Zeile",
|
||||
"whitelistSuffixesPlaceholder": "Geben Sie E-Mail-Suffixe ein, eines pro Zeile"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"clientId": "Kunden-ID",
|
||||
"clientIdDescription": "Facebook-App-ID aus der Facebook-Entwicklerkonsole",
|
||||
"clientSecret": "Client-Geheimnis",
|
||||
"clientSecretDescription": "Facebook-App-Geheimnis aus der Facebook-Entwicklerkonsole",
|
||||
"enable": "Aktivieren",
|
||||
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Facebook-Konto anmelden",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"saveSuccess": "Erfolgreich gespeichert"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"clientId": "GitHub-Client-ID",
|
||||
"clientIdDescription": "Die Client-ID aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
|
||||
"clientSecret": "GitHub-Clientgeheimnis",
|
||||
"clientSecretDescription": "Das Client-Geheimnis aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
|
||||
"enable": "GitHub-Authentifizierung aktivieren",
|
||||
"enableDescription": "Ermöglichen Sie Benutzern, sich mit ihren GitHub-Konten anzumelden",
|
||||
"saveFailed": "Speichern der GitHub-Einstellungen fehlgeschlagen",
|
||||
"saveSuccess": "GitHub-Einstellungen erfolgreich gespeichert"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"clientId": "Kunden-ID",
|
||||
"clientIdDescription": "Google OAuth 2.0-Client-ID aus der Google Cloud Console",
|
||||
"clientSecret": "Client-Geheimnis",
|
||||
"clientSecretDescription": "Google OAuth 2.0-Client-Geheimnis aus der Google Cloud Console",
|
||||
"enable": "Aktivieren",
|
||||
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Google-Konto anmelden",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"saveSuccess": "Erfolgreich gespeichert"
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"ADS Config": "ADS-Konfiguration",
|
||||
"Announcement Management": "Ankündigungsverwaltung",
|
||||
"Apple": "Apple-ID",
|
||||
"Application Management": "Anwendungsverwaltung",
|
||||
"Auth Control": "Authentifizierungskontrolle",
|
||||
"Coupon Management": "Gutscheinverwaltung",
|
||||
"Dashboard": "Armaturenbrett",
|
||||
@@ -16,6 +17,7 @@
|
||||
"Order Management": "Bestellverwaltung",
|
||||
"Payment Config": "Zahlungskonfiguration",
|
||||
"Phone Number": "Telefonnummer",
|
||||
"Rule Management": "Regelverwaltung",
|
||||
"Server": "Dienst",
|
||||
"Server Management": "Serververwaltung",
|
||||
"Settings": "Einstellungen",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Geben Sie die Beschreibung der Zahlungsmethode ein",
|
||||
"domain": "Domain",
|
||||
"domainPlaceholder": "optional, z.B. {example}",
|
||||
"edit": "Bearbeiten",
|
||||
"editPayment": "Zahlungsmethode bearbeiten",
|
||||
"enable": "Aktivieren",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user