🎉 chore(init): project initialization
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Default Language
|
||||
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
|
||||
|
||||
# Site URL and API URL
|
||||
NEXT_PUBLIC_SITE_URL=https://admin.ppanel.dev
|
||||
NEXT_PUBLIC_API_URL=https://api.ppanel.dev
|
||||
|
||||
# Default Login User
|
||||
NEXT_PUBLIC_DEFAULT_USER_EMAIL=support@ppanel.dev
|
||||
NEXT_PUBLIC_DEFAULT_USER_PASSWORD=support@ppanel.dev
|
||||
|
||||
# Please put in the .env file, otherwise the i18n command will not work
|
||||
# OpenAI API key and proxy URL required for i18n command (optional)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_PROXY_URL=
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@repo/eslint-config/next.js'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entry": "./locales/zh-CN",
|
||||
"entryLocale": "zh-CN",
|
||||
"experimental": {
|
||||
"jsonMode": true
|
||||
},
|
||||
"markdown": {
|
||||
"entry": ["./README.md"],
|
||||
"entryLocale": "en-US",
|
||||
"outputLocales": ["zh-CN"]
|
||||
},
|
||||
"modelName": "gpt-3.5-turbo",
|
||||
"output": "./locales",
|
||||
"outputLocales": ["en-US", "zh-CN"]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<a name="readme-top"></a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img width="160" src="https://raw.githubusercontent.com/perfect-panel/ppanel-assets/refs/heads/main/logo.svg">
|
||||
|
||||
<h1>PPanel admin web</h1>
|
||||
|
||||
This is a PPanel admin web powered by PPanel
|
||||
|
||||
English
|
||||
·
|
||||
[Chinese](./README.zh-CN.md)
|
||||
·
|
||||
[Changelog](../../CHANGELOG.md)
|
||||
·
|
||||
[Report Bug][issues-link]
|
||||
·
|
||||
[Request Feature][issues-link]
|
||||
|
||||
<!-- SHIELD GROUP -->
|
||||
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-releasedate-shield]][github-releasedate-link]
|
||||
[![][github-action-test-shield]][github-action-test-link]
|
||||
[![][github-action-release-shield]][github-action-release-link]<br/>
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-issues-shield]][github-issues-link]
|
||||
[![][github-license-shield]][github-license-link]
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Table of contents</kbd></summary>
|
||||
|
||||
#### TOC
|
||||
|
||||
- [⌨️ Local Development](#️-local-development)
|
||||
- [🚀 Deploy on Vercel](#-deploy-on-vercel)
|
||||
- [🤝 Contributing](#-contributing)
|
||||
- [📝 License](#-license)
|
||||
|
||||
####
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ Local Development
|
||||
|
||||
You can use Github Codespaces for online development:
|
||||
|
||||
[![][codespaces-shield]][codespaces-link]
|
||||
|
||||
You can use Gitpod for online development:
|
||||
|
||||
[][gitpod-link]
|
||||
|
||||
or clone it for local development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/perfect-panel/ppanel-web.git
|
||||
cd ppanel-web
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run the development server
|
||||
cd apps/admin
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open <http://localhost:3000> with your browser to see the result.
|
||||
|
||||
## 🚀 Deploy on Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?demo-description=PPanel%20is%20a%20pure%2C%20professional%2C%20and%20perfect%20open-source%20proxy%20panel%20tool%2C%20designed%20to%20be%20your%20ideal%20choice%20for%20learning%20and%20practical%20use&demo-image=https%3A%2F%2Furlscan.io%2Fliveshot%2F%3Fwidth%3D1920%26height%3D1080%26url%3Dhttps%3A%2F%2Fadmin.ppanel.dev&demo-title=PPanel%20Admin%20Web&demo-url=https%3A%2F%2Fadmin.ppanel.dev%2F&from=.&project-name=ppanel-admin-web&repository-name=ppanel-web&repository-url=https%3A%2F%2Fgithub.com%2Fperfect-panel%2Fppanel-web&root-directory=apps%2Fadmin&skippable-integrations=1)
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the
|
||||
[Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme)
|
||||
from the creators of Next.js.
|
||||
|
||||
Check out our
|
||||
[Next.js deployment documentation](https://nextjs.org/docs/deployment)
|
||||
for more details.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions of all types are more than welcome,
|
||||
if you're interested in contributing code, feel free to check out our GitHub
|
||||
[Issues][github-issues-link] to get stuck in to show us what you’re made of.
|
||||
|
||||
[![][pr-welcome-shield]][pr-welcome-link]
|
||||
|
||||
[![][contributors-contrib]][contributors-url]
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
Copyright © 2024 [PPanel][profile-link]. <br />
|
||||
This project is [GUN](../../LICENSE) licensed.
|
||||
|
||||
<!-- LINK GROUP -->
|
||||
|
||||
[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square
|
||||
[codespaces-link]: https://codespaces.new/perfect-panel/ppanel-web
|
||||
[codespaces-shield]: https://github.com/codespaces/badge.svg
|
||||
[contributors-contrib]: https://contrib.rocks/image?repo=perfect-panel/ppanel-web
|
||||
[contributors-url]: https://github.com/perfect-panel/ppanel-web/graphs/contributors
|
||||
[github-action-release-link]: https://github.com/perfect-panel/ppanel-web/actions/workflows/release.yml
|
||||
[github-action-release-shield]: https://img.shields.io/github/actions/workflow/status/perfect-panel/ppanel-web/release.yml?label=release&labelColor=black&logo=githubactions&logoColor=white&style=flat-square
|
||||
[github-action-test-link]: https://github.com/perfect-panel/ppanel-web/actions/workflows/test.yml
|
||||
[github-action-test-shield]: https://img.shields.io/github/actions/workflow/status/perfect-panel/ppanel-web/test.yml?label=test&labelColor=black&logo=githubactions&logoColor=white&style=flat-square
|
||||
[github-contributors-link]: https://github.com/perfect-panel/ppanel-web/graphs/contributors
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/perfect-panel/ppanel-web?color=c4f042&labelColor=black&style=flat-square
|
||||
[github-forks-link]: https://github.com/perfect-panel/ppanel-web/network/members
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/perfect-panel/ppanel-web?color=8ae8ff&labelColor=black&style=flat-square
|
||||
[github-issues-link]: https://github.com/perfect-panel/ppanel-web/issues
|
||||
[github-issues-shield]: https://img.shields.io/github/issues/perfect-panel/ppanel-web?color=ff80eb&labelColor=black&style=flat-square
|
||||
[github-license-link]: https://github.com/perfect-panel/ppanel-web/blob/master/LICENSE
|
||||
[github-license-shield]: https://img.shields.io/github/license/perfect-panel/ppanel-web?color=white&labelColor=black&style=flat-square
|
||||
[github-release-link]: https://github.com/perfect-panel/ppanel-web/releases
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/perfect-panel/ppanel-web?style=flat-square&sort=semver&logo=github
|
||||
[github-releasedate-link]: https://github.com/perfect-panel/ppanel-web/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/perfect-panel/ppanel-web?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/perfect-panel/ppanel-web/network/stargazers
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/perfect-panel/ppanel-web?color=ffcb47&labelColor=black&style=flat-square
|
||||
[gitpod-link]: https://gitpod.io/#https://github.com/perfect-panel/ppanel-web
|
||||
[issues-link]: https://github.com/perfect-panel/ppanel-web/issues/new/choose
|
||||
[pr-welcome-link]: https://github.com/perfect-panel/ppanel-web/pulls
|
||||
[pr-welcome-shield]: https://img.shields.io/badge/🤯_pr_welcome-%E2%86%92-ffcb47?labelColor=black&style=for-the-badge
|
||||
[profile-link]: https://github.com/perfect-panel
|
||||
@@ -0,0 +1,141 @@
|
||||
<a name="readme-top"></a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img width="160" src="https://raw.githubusercontent.com/perfect-panel/ppanel-assets/refs/heads/main/logo.svg">
|
||||
|
||||
<h1>PPanel 管理后台</h1>
|
||||
|
||||
这是由 PPanel 提供支持的 PPanel 管理后台
|
||||
|
||||
[英文](./README.md)
|
||||
·
|
||||
中文
|
||||
·
|
||||
[更新日志](../../CHANGELOG.md)
|
||||
·
|
||||
[报告问题][issues-link]
|
||||
·
|
||||
[请求功能][issues-link]
|
||||
|
||||
<!-- SHIELD GROUP -->
|
||||
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-releasedate-shield]][github-releasedate-link]
|
||||
[![][github-action-test-shield]][github-action-test-link]
|
||||
[![][github-action-release-shield]][github-action-release-link]<br/>
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-issues-shield]][github-issues-link]
|
||||
[![][github-license-shield]][github-license-link]
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><kbd>目录</kbd></summary>
|
||||
|
||||
#### 目录
|
||||
|
||||
- [⌨️ 本地开发](#️-本地开发)
|
||||
- [🚀 在 Vercel 上部署](#-在-vercel-上部署)
|
||||
- [🤝 贡献](#-贡献)
|
||||
- [📝 许可证](#-许可证)
|
||||
|
||||
####
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ 本地开发
|
||||
|
||||
您可以使用 Github Codespaces 进行在线开发:
|
||||
|
||||
[![][codespaces-shield]][codespaces-link]
|
||||
|
||||
您可以使用 Gitpod 进行在线开发:
|
||||
|
||||
[][gitpod-link]
|
||||
|
||||
或者克隆项目进行本地开发:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/perfect-panel/ppanel-web.git
|
||||
cd ppanel-web
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 运行开发服务器
|
||||
cd apps/admin
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
在浏览器中打开 <http://localhost:3000> 查看结果。
|
||||
|
||||
## 🚀 在 Vercel 上部署
|
||||
|
||||
[](https://vercel.com/new/clone?demo-description=PPanel%20is%20a%20pure%2C%20professional%2C%20and%20perfect%20open-source%20proxy%20panel%20tool%2C%20designed%20to%20be%20your%20ideal%20choice%20for%20learning%20and%20practical%20use&demo-image=https%3A%2F%2Furlscan.io%2Fliveshot%2F%3Fwidth%3D1920%26height%3D1080%26url%3Dhttps%3A%2F%2Fadmin.ppanel.dev&demo-title=PPanel%20Admin%20Web&demo-url=https%3A%2F%2Fadmin.ppanel.dev%2F&from=.&project-name=ppanel-admin-web&repository-name=ppanel-web&repository-url=https%3A%2F%2Fgithub.com%2Fperfect-panel%2Fppanel-web&root-directory=apps%2Fadmin&skippable-integrations=1)
|
||||
|
||||
部署 Next.js 应用的最简单方式是使用
|
||||
[ Vercel 平台](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme)
|
||||
由 Next.js 的创建者提供支持。
|
||||
|
||||
查看我们的
|
||||
[Next.js 部署文档](https://nextjs.org/docs/deployment)
|
||||
获取更多详情。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎各种类型的贡献,
|
||||
如果您有兴趣贡献代码,请随时查看我们的 GitHub
|
||||
[问题][github-issues-link] 来展示您的能力。
|
||||
|
||||
[![][pr-welcome-shield]][pr-welcome-link]
|
||||
|
||||
[![][contributors-contrib]][contributors-url]
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 许可证
|
||||
|
||||
版权所有 © 2024 [PPanel][profile-link]。<br />
|
||||
本项目使用 [GUN](./LICENSE) 许可证。
|
||||
|
||||
<!-- LINK GROUP -->
|
||||
|
||||
[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square
|
||||
[codespaces-link]: https://codespaces.new/perfect-panel/ppanel-web
|
||||
[codespaces-shield]: https://github.com/codespaces/badge.svg
|
||||
[contributors-contrib]: https://contrib.rocks/image?repo=perfect-panel/ppanel-web
|
||||
[contributors-url]: https://github.com/perfect-panel/ppanel-web/graphs/contributors
|
||||
[github-action-release-link]: https://github.com/perfect-panel/ppanel-web/actions/workflows/release.yml
|
||||
[github-action-release-shield]: https://img.shields.io/github/actions/workflow/status/perfect-panel/ppanel-web/release.yml?label=release&labelColor=black&logo=githubactions&logoColor=white&style=flat-square
|
||||
[github-action-test-link]: https://github.com/perfect-panel/ppanel-web/actions/workflows/test.yml
|
||||
[github-action-test-shield]: https://img.shields.io/github/actions/workflow/status/perfect-panel/ppanel-web/test.yml?label=test&labelColor=black&logo=githubactions&logoColor=white&style=flat-square
|
||||
[github-contributors-link]: https://github.com/perfect-panel/ppanel-web/graphs/contributors
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/perfect-panel/ppanel-web?color=c4f042&labelColor=black&style=flat-square
|
||||
[github-forks-link]: https://github.com/perfect-panel/ppanel-web/network/members
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/perfect-panel/ppanel-web?color=8ae8ff&labelColor=black&style=flat-square
|
||||
[github-issues-link]: https://github.com/perfect-panel/ppanel-web/issues
|
||||
[github-issues-shield]: https://img.shields.io/github/issues/perfect-panel/ppanel-web?color=ff80eb&labelColor=black&style=flat-square
|
||||
[github-license-link]: https://github.com/perfect-panel/ppanel-web/blob/master/LICENSE
|
||||
[github-license-shield]: https://img.shields.io/github/license/perfect-panel/ppanel-web?color=white&labelColor=black&style=flat-square
|
||||
[github-release-link]: https://github.com/perfect-panel/ppanel-web/releases
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/perfect-panel/ppanel-web?style=flat-square&sort=semver&logo=github
|
||||
[github-releasedate-link]: https://github.com/perfect-panel/ppanel-web/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/perfect-panel/ppanel-web?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/perfect-panel/ppanel-web/network/stargazers
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/perfect-panel/ppanel-web?color=ffcb47&labelColor=black&style=flat-square
|
||||
[gitpod-link]: https://gitpod.io/#https://github.com/perfect-panel/ppanel-web
|
||||
[issues-link]: https://github.com/perfect-panel/ppanel-web/issues/new/choose
|
||||
[pr-welcome-link]: https://github.com/perfect-panel/ppanel-web/pulls
|
||||
[pr-welcome-shield]: https://img.shields.io/badge/🤯_pr_welcome-%E2%86%92-ffcb47?labelColor=black&style=for-the-badge
|
||||
[profile-link]: https://github.com/perfect-panel
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import LanguageSwitch from '@/components/language-switch';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { LoginIcon } from '@repo/ui/lotties';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import ThemeSwitch from '@/components/theme-switch';
|
||||
import UserAuthForm from './user-auth-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('auth');
|
||||
const { common } = useGlobalStore();
|
||||
const { site } = common;
|
||||
|
||||
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'>
|
||||
<div className='flex lg:w-1/2 lg:flex-auto'>
|
||||
<div className='flex w-full flex-col items-center justify-center px-5 py-4 md:px-14 lg:py-14'>
|
||||
<Link className='mb-0 flex flex-col items-center lg:mb-12' href='/'>
|
||||
<Image src={site.site_logo || '/favicon.svg'} height={48} width={48} alt='logo' />
|
||||
<span className='text-2xl font-semibold'>{site.site_name}</span>
|
||||
</Link>
|
||||
<LoginIcon className='mx-auto hidden w-[275px] md:w-1/2 lg:block xl:w-[500px]' />
|
||||
<p className='hidden w-[275px] text-center md:w-1/2 lg:block xl:w-[500px]'>
|
||||
{site.site_desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-initial justify-center p-8 lg:flex-auto lg:justify-end'>
|
||||
<div className='lg:bg-background flex flex-col items-center rounded-2xl md:w-[600px] lg:flex-auto lg:p-10 lg:shadow'>
|
||||
<div className='flex flex-col items-stretch justify-center md:w-[400px] lg:h-full'>
|
||||
<div className='flex flex-col justify-center pb-14 lg:flex-auto lg:pb-20'>
|
||||
<UserAuthForm />
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-primary flex gap-5 text-sm font-semibold'>
|
||||
<Link href='/tos'>{t('tos')}</Link>
|
||||
</div>
|
||||
<div className='flex items-center gap-5'>
|
||||
<LanguageSwitch />
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect } from 'react';
|
||||
import Turnstile, { useTurnstile } from 'react-turnstile';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
|
||||
export default function CloudFlareTurnstile({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id?: string;
|
||||
value?: null | string;
|
||||
onChange: (value?: string) => void;
|
||||
}) {
|
||||
const { common } = useGlobalStore();
|
||||
const { verify } = common;
|
||||
const { resolvedTheme } = useTheme();
|
||||
const locale = useLocale();
|
||||
const turnstile = useTurnstile();
|
||||
|
||||
useEffect(() => {
|
||||
if (value === '') {
|
||||
turnstile.reset();
|
||||
}
|
||||
}, [turnstile, value]);
|
||||
|
||||
return (
|
||||
verify.turnstile_site_key && (
|
||||
<Turnstile
|
||||
id={id}
|
||||
sitekey={verify.turnstile_site_key}
|
||||
theme={resolvedTheme as 'light' | 'dark'}
|
||||
language={locale.toLowerCase()}
|
||||
fixedSize
|
||||
onVerify={(token) => onChange(token)}
|
||||
// onError={() => {
|
||||
// onChange();
|
||||
// turnstile.reset();
|
||||
// }}
|
||||
onExpire={() => {
|
||||
onChange();
|
||||
turnstile.reset();
|
||||
}}
|
||||
onTimeout={() => {
|
||||
onChange();
|
||||
turnstile.reset();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
NEXT_PUBLIC_DEFAULT_USER_EMAIL,
|
||||
NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
|
||||
} from '@/config/constants';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { checkUser, resetPassword, userLogin, userRegister } from '@/services/common/auth';
|
||||
import { getRedirectUrl, setAuthorization } from '@/utils/common';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { cn } from '@shadcn/ui/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ReactNode, useState, useTransition } from 'react';
|
||||
import UserCheckForm from './user-check-form';
|
||||
import UserLoginForm from './user-login-form';
|
||||
import UserRegisterForm from './user-register-form';
|
||||
import UserResetForm from './user-reset-form';
|
||||
|
||||
export default function UserAuthForm() {
|
||||
const t = useTranslations('auth');
|
||||
const { common } = useGlobalStore();
|
||||
const { register } = common;
|
||||
const router = useRouter();
|
||||
const [type, setType] = useState<'login' | 'register' | 'reset'>();
|
||||
const [loading, startTransition] = useTransition();
|
||||
const [initialValues, setInitialValues] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
}>({
|
||||
email: NEXT_PUBLIC_DEFAULT_USER_EMAIL,
|
||||
password: NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (params: any) => {
|
||||
const onLogin = async (token?: string) => {
|
||||
if (!token) return;
|
||||
setAuthorization(token);
|
||||
router.replace(getRedirectUrl());
|
||||
router.refresh();
|
||||
};
|
||||
startTransition(async () => {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'login':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const login = await userLogin(params);
|
||||
toast.success(t('login.success'));
|
||||
onLogin(login.data.data?.token);
|
||||
break;
|
||||
case 'register':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const create = await userRegister(params);
|
||||
toast.success(t('register.success'));
|
||||
onLogin(create.data.data?.token);
|
||||
break;
|
||||
case 'reset':
|
||||
await resetPassword(params);
|
||||
toast.success(t('reset.success'));
|
||||
setType('login');
|
||||
break;
|
||||
default:
|
||||
if (type === 'reset') break;
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const response = await checkUser(params);
|
||||
setInitialValues({
|
||||
...initialValues,
|
||||
...params,
|
||||
});
|
||||
setType(response.data.data?.exist ? 'login' : 'register');
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
};
|
||||
let UserForm: ReactNode = null;
|
||||
switch (type) {
|
||||
case 'login':
|
||||
UserForm = (
|
||||
<UserLoginForm
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
setInitialValues={setInitialValues}
|
||||
onSwitchForm={setType}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'register':
|
||||
UserForm = (
|
||||
<UserRegisterForm
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
setInitialValues={setInitialValues}
|
||||
onSwitchForm={setType}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'reset':
|
||||
UserForm = (
|
||||
<UserResetForm
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
setInitialValues={setInitialValues}
|
||||
onSwitchForm={setType}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
UserForm = (
|
||||
<UserCheckForm
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mb-11 text-center'>
|
||||
<h1 className='mb-3 text-2xl font-bold'>{t(`${type || 'check'}.title`)}</h1>
|
||||
<div className='text-muted-foreground font-medium'>
|
||||
{t(`${type || 'check'}.description`)}
|
||||
</div>
|
||||
</div>
|
||||
{!((type === 'register' && register.stop_register) || type === 'reset') && (
|
||||
<>
|
||||
<div className='mb-3 flex flex-wrap items-center justify-center gap-3 font-bold'>
|
||||
<Button type='button' variant='outline'>
|
||||
<Icon icon='uil:telegram' className='mr-2 size-5' />
|
||||
Telegram
|
||||
</Button>
|
||||
<Button type='button' variant='outline'>
|
||||
<Icon icon='uil:google' className='mr-2 size-5' />
|
||||
Google
|
||||
</Button>
|
||||
<Button type='button' variant='outline'>
|
||||
<Icon icon='uil:apple' className='mr-2 size-5' />
|
||||
Apple
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'my-14 flex h-0 items-center text-center',
|
||||
'before:mr-4 before:block before:w-1/2 before:border-b-[1px]',
|
||||
'after:ml-4 after:w-1/2 after:border-b-[1px]',
|
||||
)}
|
||||
>
|
||||
<span className='text-muted-foreground w-[125px] text-sm'>{t('orWithEmail')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{UserForm}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function UserCheckForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
}) {
|
||||
const t = useTranslations('auth.check');
|
||||
const { common } = useGlobalStore();
|
||||
const { register } = common;
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email(t('email'))
|
||||
.refine(
|
||||
(email) => {
|
||||
if (!register.enable_email_domain_suffix) return true;
|
||||
const domain = email.split('@')[1] as string;
|
||||
return register.email_domain_suffix_list.split('\n').includes(domain);
|
||||
},
|
||||
{
|
||||
message: t('whitelist'),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleEmailChange = (value: string) => {
|
||||
form.setValue('email', value);
|
||||
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(async () => {
|
||||
const isValid = await form.trigger('email');
|
||||
if (isValid) {
|
||||
setIsSubmitting(true);
|
||||
form.handleSubmit(onSubmit)();
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues && initialValues.email) {
|
||||
handleEmailChange(initialValues.email);
|
||||
}
|
||||
return () => {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter your email...'
|
||||
type='email'
|
||||
{...field}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type='submit' disabled={!isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Icon icon='mdi:loading' className='mr-2 size-5 animate-spin' />
|
||||
{t('checking')}
|
||||
</>
|
||||
) : (
|
||||
t('continue')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import CloudFlareTurnstile from './turnstile';
|
||||
|
||||
export default function UserLoginForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: (type?: 'register' | 'reset') => void;
|
||||
}) {
|
||||
const t = useTranslations('auth.login');
|
||||
const { common } = useGlobalStore();
|
||||
const { verify } = common;
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
cf_token: verify.enable_login_verify ? z.string() : z.string().optional(),
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input disabled placeholder='Enter your email...' type='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter your password...'
|
||||
type='password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{verify.enable_login_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cf_token'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile id='login' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||
{t('title')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='mt-4 flex w-full justify-between text-sm'>
|
||||
<Button variant='link' type='button' className='p-0' onClick={() => onSwitchForm('reset')}>
|
||||
{t('forgotPassword')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0'
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm(undefined);
|
||||
}}
|
||||
>
|
||||
{t('switchAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { sendEmailCode } from '@/services/common/common';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Markdown } from '@repo/ui/markdown';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useCountDown } from 'ahooks';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
|
||||
import CloudFlareTurnstile from './turnstile';
|
||||
|
||||
export default function UserRegisterForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: (type?: 'register' | 'reset') => void;
|
||||
}) {
|
||||
const t = useTranslations('auth.register');
|
||||
const { common } = useGlobalStore();
|
||||
const { verify, register, invite } = common;
|
||||
|
||||
const [targetDate, setTargetDate] = useState<number>();
|
||||
const [, { seconds }] = useCountDown({
|
||||
targetDate,
|
||||
onEnd: () => {
|
||||
setTargetDate(undefined);
|
||||
},
|
||||
});
|
||||
const handleSendCode = async () => {
|
||||
await sendEmailCode({
|
||||
email: initialValues.email,
|
||||
type: 1,
|
||||
});
|
||||
setTargetDate(Date.now() + 60000);
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
repeat_password: z.string(),
|
||||
code: register.enable_email_verify ? z.string() : z.string().nullish(),
|
||||
invite: invite.forced_invite ? z.string() : z.string().nullish(),
|
||||
cf_token: verify.enable_register_verify ? z.string() : z.string().nullish(),
|
||||
})
|
||||
.superRefine(({ password, repeat_password }, ctx) => {
|
||||
if (password !== repeat_password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('passwordMismatch'),
|
||||
path: ['repeat_password'],
|
||||
});
|
||||
}
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
invite: sessionStorage.getItem('invite'),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{register.stop_register ? (
|
||||
<Markdown>{t('message')}</Markdown>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input disabled placeholder='Enter your email...' type='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter your password...'
|
||||
type='password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='repeat_password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter password again...'
|
||||
type='password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{register.enable_email_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter code...'
|
||||
type='text'
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
|
||||
{seconds > 0 ? `${seconds}s` : t('get')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='invite'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t('invite')}
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{verify.enable_register_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cf_token'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile id='register' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||
{t('title')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
<div className='mt-4 text-right text-sm'>
|
||||
{t('existingAccount')}
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0'
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm(undefined);
|
||||
}}
|
||||
>
|
||||
{t('switchToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { sendEmailCode } from '@/services/common/common';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { useCountDown } from 'ahooks';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
|
||||
import CloudFlareTurnstile from './turnstile';
|
||||
|
||||
export default function UserResetForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: (type?: 'register' | 'reset') => void;
|
||||
}) {
|
||||
const t = useTranslations('auth.reset');
|
||||
|
||||
const { common } = useGlobalStore();
|
||||
const { verify, register } = common;
|
||||
|
||||
const [targetDate, setTargetDate] = useState<number>();
|
||||
const [, { seconds }] = useCountDown({
|
||||
targetDate,
|
||||
onEnd: () => {
|
||||
setTargetDate(undefined);
|
||||
},
|
||||
});
|
||||
const handleSendCode = async () => {
|
||||
await sendEmailCode({
|
||||
email: initialValues.email,
|
||||
type: 2,
|
||||
});
|
||||
setTargetDate(Date.now() + 60000); // 60秒倒计时
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
code: register.enable_email_verify ? z.string() : z.string().nullish(),
|
||||
cf_token: verify.enable_register_verify ? z.string() : z.string().nullish(),
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input disabled placeholder='Enter your email...' type='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter your password...'
|
||||
type='password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{register.enable_email_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder='Enter code...'
|
||||
type='text'
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
|
||||
{seconds > 0 ? `${seconds}s` : t('get')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{verify.enable_reset_password_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cf_token'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile id='reset' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||
{t('title')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='mt-4 text-right text-sm'>
|
||||
{t('existingAccount')}
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0'
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm(undefined);
|
||||
}}
|
||||
>
|
||||
{t('switchToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Icon } from '@iconify/react';
|
||||
import { MarkdownEditor } from '@repo/ui/editor';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string(),
|
||||
content: z.string().optional(),
|
||||
});
|
||||
|
||||
interface AnnouncementFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function AnnouncementForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: AnnouncementFormProps<T>) {
|
||||
const t = useTranslations('announcement');
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
content: '',
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[800px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='title'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.title')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('form.titlePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='content'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.content')}</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value || '');
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createAnnouncement,
|
||||
deleteAnnouncement,
|
||||
getAnnouncementList,
|
||||
updateAnnouncement,
|
||||
updateAnnouncementEnable,
|
||||
} from '@/services/admin/announcement';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { format } from '@shadcn/ui/lib/date-fns';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import NoticeForm from './notice-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('announcement');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>();
|
||||
|
||||
return (
|
||||
<ProTable<API.Announcement, { enable: boolean; search: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('announcementList'),
|
||||
toolbar: (
|
||||
<NoticeForm<API.CreateAnnouncementRequest>
|
||||
trigger={t('create')}
|
||||
title={t('createAnnouncement')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createAnnouncement(values);
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'enable',
|
||||
header: t('enable'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('enable')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateAnnouncementEnable({
|
||||
id: row.original.id,
|
||||
enable: checked,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: t('title'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'content',
|
||||
header: t('content'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: t('updatedAt'),
|
||||
cell: ({ row }) => format(row.getValue('updated_at'), 'yyyy-MM-dd HH:mm:ss'),
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{
|
||||
key: 'enable',
|
||||
placeholder: t('enable'),
|
||||
options: [
|
||||
{ label: t('show'), value: 'false' },
|
||||
{ label: t('hide'), value: 'true' },
|
||||
],
|
||||
},
|
||||
{ key: 'search' },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getAnnouncementList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render(row) {
|
||||
return [
|
||||
<NoticeForm<API.Announcement>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editAnnouncement')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAnnouncement({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t('updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteDescription')}
|
||||
onConfirm={async () => {
|
||||
await deleteAnnouncement({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteDescription')}
|
||||
onConfirm={async () => {
|
||||
for (const element of rows) {
|
||||
await deleteAnnouncement({
|
||||
id: element.id!,
|
||||
});
|
||||
}
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import { getSubscribeList } from '@/services/admin/subscribe';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Combobox } from '@repo/ui/combobox';
|
||||
import { DatePicker } from '@repo/ui/date-picker';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
code: z.string().optional(),
|
||||
count: z.number().optional(),
|
||||
type: z.number().optional(),
|
||||
discount: z.number().optional(),
|
||||
start_time: z.number().optional(),
|
||||
expire_time: z.number().optional(),
|
||||
subscribe: z.array(z.number()).nullish(),
|
||||
user_limit: z.number().optional(),
|
||||
});
|
||||
|
||||
interface CouponFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function CouponForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: CouponFormProps<T>) {
|
||||
const t = useTranslations('coupon');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
type: 1,
|
||||
...initialValues,
|
||||
} as any,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
const type = form.watch('type');
|
||||
|
||||
const { data: subscribe } = useQuery({
|
||||
queryKey: ['getSubscribeList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list as API.Subscribe[];
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent 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='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.enterCouponName')}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.customCouponCode')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.customCouponCodePlaceholder')}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.type')}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
defaultValue={String(field.value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, Number(value));
|
||||
form.setValue('discount', '');
|
||||
}}
|
||||
className='flex gap-2'
|
||||
>
|
||||
<FormItem className='flex items-center space-x-3 space-y-0'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value='1' />
|
||||
</FormControl>
|
||||
<FormLabel className='font-normal'>
|
||||
{t('form.percentageDiscount')}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className='flex items-center space-x-3 space-y-0'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value='2' />
|
||||
</FormControl>
|
||||
<FormLabel className='font-normal'>{t('form.amountDiscount')}</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.percentageDiscount')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.enterValue')}
|
||||
type='number'
|
||||
suffix='%'
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{type === 2 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.amountDiscount')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.enterValue')}
|
||||
type='number'
|
||||
value={field.value}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='server'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.specifiedServer')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, true>
|
||||
multiple
|
||||
placeholder={t('form.selectServer')}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
options={subscribe?.map((item: API.Subscribe) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='start_time'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.startTime')}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder={t('form.enterValue')}
|
||||
value={field.value}
|
||||
disabled={(date) => date < new Date(Date.now() - 24 * 60 * 60 * 1000)}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='expire_time'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.expireTime')}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder={t('form.enterValue')}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='count'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.count')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.countPlaceholder')}
|
||||
type='number'
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.userLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.userLimitPlaceholder')}
|
||||
type='number'
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteCoupon,
|
||||
createCoupon,
|
||||
deleteCoupon,
|
||||
getCouponList,
|
||||
updateCoupon,
|
||||
} from '@/services/admin/coupon';
|
||||
import { getSubscribeList } from '@/services/admin/subscribe';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import CouponForm from './coupon-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('coupon');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { data } = useQuery({
|
||||
queryKey: ['getSubscribeList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list as API.SubscribeGroup[];
|
||||
},
|
||||
});
|
||||
const ref = useRef<ProTableActions>();
|
||||
return (
|
||||
<ProTable<API.Coupon, { group_id: number; query: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
toolbar: (
|
||||
<CouponForm<API.CreateCouponRequest>
|
||||
trigger={t('create')}
|
||||
title={t('createCoupon')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createCoupon({
|
||||
...values,
|
||||
enable: false,
|
||||
});
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'subscribe',
|
||||
placeholder: t('subscribe'),
|
||||
options: data?.map((item) => ({
|
||||
label: item.name,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getCouponList({
|
||||
...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 updateCoupon({
|
||||
...row,
|
||||
enable: checked,
|
||||
} as any);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: t('code'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: t('type'),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.getValue('type') === 1 ? 'default' : 'secondary'}>
|
||||
{row.getValue('type') === 1 ? t('percentage') : t('amount')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'discount',
|
||||
header: t('discount'),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.getValue('type') === 1 ? 'default' : 'secondary'}>
|
||||
{row.getValue('type') === 1 ? (
|
||||
`${row.original.discount} %`
|
||||
) : (
|
||||
<Display type='currency' value={row.original.discount} />
|
||||
)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'count',
|
||||
header: t('count'),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<span>
|
||||
{t('count')}: {row.original.count === 0 ? t('unlimited') : row.original.count}
|
||||
</span>
|
||||
<span>
|
||||
{t('remainingTimes')}:{' '}
|
||||
{row.original.count === 0
|
||||
? t('unlimited')
|
||||
: row.original.count - row.original.used_count}
|
||||
</span>
|
||||
<span>
|
||||
{t('usedTimes')}: {row.original.used_count}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'expire',
|
||||
header: t('validityPeriod'),
|
||||
cell: ({ row }) => {
|
||||
const { start_time, expire_time } = row.original;
|
||||
if (start_time) {
|
||||
return expire_time ? (
|
||||
<>
|
||||
{formatDate(start_time)} - {formatDate(expire_time)}
|
||||
</>
|
||||
) : start_time ? (
|
||||
formatDate(start_time)
|
||||
) : (
|
||||
'--'
|
||||
);
|
||||
}
|
||||
return '--';
|
||||
},
|
||||
},
|
||||
]}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<CouponForm<API.UpdateCouponRequest>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editCoupon')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateCoupon({ ...row, ...values });
|
||||
toast.success(t('updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await deleteCoupon({ 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 () => {
|
||||
await batchDeleteCoupon({ ids: rows.map((item) => item.id) });
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.reset();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Icon } from '@iconify/react';
|
||||
import { MarkdownEditor } from '@repo/ui/editor';
|
||||
import { TagInput } from '@repo/ui/tag-input';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
content: z.string().nullish(),
|
||||
});
|
||||
|
||||
interface DocumentFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function DocumentForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: DocumentFormProps<T>) {
|
||||
const t = useTranslations('document');
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
tags: [],
|
||||
...initialValues,
|
||||
} as any,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset({
|
||||
tags: [],
|
||||
...initialValues,
|
||||
});
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='title'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.title')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('form.titlePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='tags'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.tags')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
placeholder={t('form.tagsPlaceholder')}
|
||||
value={field.value}
|
||||
onChange={(value) => form.setValue(field.name, value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='content'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.content')}</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteDocument,
|
||||
createDocument,
|
||||
deleteDocument,
|
||||
getDocumentList,
|
||||
updateDocument,
|
||||
} from '@/services/admin/document';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import DocumentForm from './document-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('document');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const ref = useRef<ProTableActions>();
|
||||
return (
|
||||
<ProTable<API.Document, { tag: string; search: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('DocumentList'),
|
||||
toolbar: (
|
||||
<DocumentForm<API.CreateDocumentRequest>
|
||||
key='create'
|
||||
trigger={t('create')}
|
||||
title={t('createDocument')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createDocument({
|
||||
...values,
|
||||
show: false,
|
||||
});
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'show',
|
||||
header: t('show'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('show')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateDocument({
|
||||
...row.original,
|
||||
show: checked,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: t('title'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'tags',
|
||||
header: t('tags'),
|
||||
cell: ({ row }) => row.original.tags.join(', '),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: t('updatedAt'),
|
||||
cell: ({ row }) => formatDate(row.getValue('updated_at')),
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'tag',
|
||||
placeholder: t('tags'),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getDocumentList({ ...pagination, ...filter });
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render(row) {
|
||||
return [
|
||||
<DocumentForm<API.UpdateDocumentRequest>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editDocument')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateDocument({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t('updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteDescription')}
|
||||
onConfirm={async () => {
|
||||
await deleteDocument({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteDescription')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteDocument({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Header } from '@/components/header';
|
||||
import { SidebarLeft } from '@/components/sidebar-left';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import { SidebarInset, SidebarProvider } from '@shadcn/ui/sidebar';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get('sidebar:state')?.value === 'true';
|
||||
return (
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<SidebarLeft />
|
||||
<SidebarInset className='flex-grow overflow-hidden'>
|
||||
<Header />
|
||||
<div className='flex h-[calc(100vh-56px)] flex-1 flex-col gap-4 p-4'>
|
||||
<ScrollArea className='h-full flex-grow overflow-hidden'>{children}</ScrollArea>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import { getOrderList, updateOrderStatus } from '@/services/admin/order';
|
||||
import { getSubscribeList } from '@/services/admin/subscribe';
|
||||
import { Combobox } from '@repo/ui/combobox';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@shadcn/ui/hover-card';
|
||||
import { cn } from '@shadcn/ui/lib/utils';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { UserDetail } from '../user/user-detail';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('order');
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 1, label: t('status.1'), className: 'bg-orange-500' },
|
||||
{ value: 2, label: t('status.2'), className: 'bg-green-500' },
|
||||
{ value: 3, label: t('status.3'), className: 'bg-gray-500' },
|
||||
{ value: 4, label: t('status.4'), className: 'bg-red-500' },
|
||||
{ value: 5, label: t('status.5'), className: 'bg-green-500' },
|
||||
];
|
||||
|
||||
const ref = useRef<ProTableActions>();
|
||||
|
||||
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.Order, any>
|
||||
action={ref}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'order_no',
|
||||
header: t('orderNumber'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: t('type.0'),
|
||||
cell: ({ row }) => t(`type.${row.getValue('type')}`),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subscribe_id',
|
||||
header: t('subscribe'),
|
||||
cell: ({ row }) =>
|
||||
subscribeList?.find((item) => item.id === row.getValue('subscribe_id'))?.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: t('amount'),
|
||||
cell: ({ row }) => (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button variant='link' className='p-0'>
|
||||
<Display type='currency' value={row.original.amount} />
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className='grid gap-3'>
|
||||
{row.original.trade_no && (
|
||||
<>
|
||||
<div className='font-semibold'>{t('tradeNo')}</div>
|
||||
<span className='text-muted-foreground'>{row.original.trade_no}</span>
|
||||
<Separator className='my-2' />
|
||||
</>
|
||||
)}
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('subscribePrice')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={row.original.price} />
|
||||
</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('reductionPrice')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={row.original.reduction} />
|
||||
</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('feAmount')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={row.original.fee_amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('total')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={row.original.amount} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Separator className='my-4' />
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>支付方式</span>
|
||||
<span>{t(`methods.${row.original.method}`)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'user_id',
|
||||
header: t('user'),
|
||||
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: t('updateTime'),
|
||||
cell: ({ row }) => formatDate(row.original.updated_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('status.0'),
|
||||
cell: ({ row }) => {
|
||||
const option = statusOptions.find((opt) => opt.value === row.original.status);
|
||||
if ([1, 3, 4].includes(row.getValue('status'))) {
|
||||
return (
|
||||
<Combobox<number, false>
|
||||
placeholder='状态'
|
||||
value={row.original.status}
|
||||
onChange={async (value) => {
|
||||
await updateOrderStatus({
|
||||
id: row.original.id,
|
||||
status: value,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
options={statusOptions}
|
||||
className={cn(option?.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Badge>{t(`status.${row.getValue('status')}`)}</Badge>;
|
||||
},
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{ key: 'search' },
|
||||
{
|
||||
key: 'status',
|
||||
placeholder: t('status.0'),
|
||||
options: statusOptions.map((item) => ({
|
||||
label: item.label,
|
||||
value: String(item.value),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'subscribe_id',
|
||||
placeholder: `${t('subscribe')}`,
|
||||
options: subscribeList?.map((item) => ({
|
||||
label: item.name,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{ key: 'user_id', placeholder: `${t('user')}ID` },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getOrderList({ ...pagination, ...filter });
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
'use client';
|
||||
|
||||
import { formatBytes } from '@repo/ui/utils';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@shadcn/ui/chart';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
} from '@shadcn/ui/lib/recharts';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@shadcn/ui/table';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
const UserStatisticsConfig = {
|
||||
register: {
|
||||
label: '注册',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
new_purchase: {
|
||||
label: '新购',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
repurchase: {
|
||||
label: '复购',
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const IncomeStatisticsConfig = {
|
||||
new_purchase: {
|
||||
label: '新购',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
repurchase: {
|
||||
label: '复购',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<div className='flex flex-1 flex-col gap-4'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid grid-cols-2 gap-4 lg:grid-cols-4'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-sm'>在线IP数</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>666</div>
|
||||
</div>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-sm'>在线节点数</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>99</div>
|
||||
</div>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-sm'>离线节点数</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>1</div>
|
||||
</div>
|
||||
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-sm'>待处理工单</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>1</div>
|
||||
</div>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-sm'>今日上传流量</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{formatBytes(99999999999999)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-sm'>今日下载流量</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{formatBytes(99999999999999)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-sm'>总上传流量</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{formatBytes(99999999999999)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-sm'>总下载流量</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{formatBytes(99999999999999)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className='grid gap-4 lg:grid-cols-3'>
|
||||
<Card>
|
||||
<CardHeader className='items-center'>
|
||||
<CardTitle>今日收入统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={IncomeStatisticsConfig}
|
||||
className='mx-auto aspect-square max-h-[213px]'
|
||||
>
|
||||
<PieChart>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
type: 'new_purchase',
|
||||
value: 200,
|
||||
fill: 'var(--color-new_purchase)',
|
||||
},
|
||||
{
|
||||
type: 'repurchase',
|
||||
value: 187,
|
||||
fill: 'var(--color-repurchase)',
|
||||
},
|
||||
]}
|
||||
dataKey='value'
|
||||
nameKey='type'
|
||||
innerRadius={50}
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor='middle'
|
||||
dominantBaseline='middle'
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className='fill-foreground text-2xl font-bold'
|
||||
>
|
||||
6,666
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>总收入</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>6,666</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>123</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>456</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>本月收入统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={IncomeStatisticsConfig}>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={[
|
||||
{ date: '2024-10-01', new_purchase: 98, repurchase: 125 },
|
||||
{ date: '2024-10-02', new_purchase: 110, repurchase: 140 },
|
||||
{ date: '2024-10-03', new_purchase: 100, repurchase: 130 },
|
||||
{ date: '2024-10-04', new_purchase: 115, repurchase: 145 },
|
||||
{ date: '2024-10-05', new_purchase: 108, repurchase: 138 },
|
||||
{ date: '2024-10-06', new_purchase: 120, repurchase: 150 },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey='new_purchase' fill='var(--color-new_purchase)' radius={4} />
|
||||
<Bar dataKey='repurchase' fill='var(--color-repurchase)' radius={4} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>总收入</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>654,321</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>123</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>456</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>收入统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={IncomeStatisticsConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={[
|
||||
{ date: '2024-07', new_purchase: 98, repurchase: 125 },
|
||||
{ date: '2024-08', new_purchase: 110, repurchase: 140 },
|
||||
{ date: '2024-09', new_purchase: 100, repurchase: 130 },
|
||||
{ date: '2024-10', new_purchase: 115, repurchase: 145 },
|
||||
{ date: '2024-11', new_purchase: 108, repurchase: 138 },
|
||||
{ date: '2024-12', new_purchase: 120, repurchase: 150 },
|
||||
]}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator='dot' />} />
|
||||
<Area
|
||||
dataKey='new_purchase'
|
||||
type='natural'
|
||||
fill='var(--color-new_purchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-new_purchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='repurchase'
|
||||
type='natural'
|
||||
fill='var(--color-repurchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-repurchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>总收入</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>987,654,321</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='items-center'>
|
||||
<CardTitle>今日用户统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={UserStatisticsConfig}
|
||||
className='mx-auto aspect-square max-h-[213px]'
|
||||
>
|
||||
<PieChart>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
type: 'register',
|
||||
value: 275,
|
||||
fill: 'var(--color-register)',
|
||||
},
|
||||
{
|
||||
type: 'new_purchase',
|
||||
value: 200,
|
||||
fill: 'var(--color-new_purchase)',
|
||||
},
|
||||
{
|
||||
type: 'repurchase',
|
||||
value: 187,
|
||||
fill: 'var(--color-repurchase)',
|
||||
},
|
||||
]}
|
||||
dataKey='value'
|
||||
nameKey='type'
|
||||
innerRadius={50}
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor='middle'
|
||||
dominantBaseline='middle'
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className='fill-foreground text-3xl font-bold'
|
||||
>
|
||||
275
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>789</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>123</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>456</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>本月用户统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={UserStatisticsConfig}>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={[
|
||||
{ date: '2024-10-01', register: 215, new_purchase: 98, repurchase: 125 },
|
||||
{ date: '2024-10-02', register: 240, new_purchase: 110, repurchase: 140 },
|
||||
{ date: '2024-10-03', register: 225, new_purchase: 100, repurchase: 130 },
|
||||
{ date: '2024-10-04', register: 250, new_purchase: 115, repurchase: 145 },
|
||||
{ date: '2024-10-05', register: 235, new_purchase: 108, repurchase: 138 },
|
||||
{ date: '2024-10-06', register: 260, new_purchase: 120, repurchase: 150 },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey='register' fill='var(--color-register)' radius={4} />
|
||||
<Bar dataKey='new_purchase' fill='var(--color-new_purchase)' radius={4} />
|
||||
<Bar dataKey='repurchase' fill='var(--color-repurchase)' radius={4} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>789</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>123</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>456</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={UserStatisticsConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={[
|
||||
{ date: '2024-07', register: 215, new_purchase: 98, repurchase: 125 },
|
||||
{ date: '2024-08', register: 240, new_purchase: 110, repurchase: 140 },
|
||||
{ date: '2024-09', register: 225, new_purchase: 100, repurchase: 130 },
|
||||
{ date: '2024-10', register: 250, new_purchase: 115, repurchase: 145 },
|
||||
{ date: '2024-11', register: 235, new_purchase: 108, repurchase: 138 },
|
||||
{ date: '2024-12', register: 260, new_purchase: 120, repurchase: 150 },
|
||||
]}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator='dot' />} />
|
||||
<Area
|
||||
dataKey='register'
|
||||
type='natural'
|
||||
fill='var(--color-register)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-register)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='new_purchase'
|
||||
type='natural'
|
||||
fill='var(--color-new_purchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-new_purchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='repurchase'
|
||||
type='natural'
|
||||
fill='var(--color-repurchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-repurchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>987,654,321</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<div className='grid gap-4 lg:grid-cols-2 xl:grid-cols-3'>
|
||||
<Card className='xl:col-span-2'>
|
||||
<CardHeader>
|
||||
<CardTitle>今日节点流量排行</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>节点</TableHead>
|
||||
<TableHead className='text-right'>流量</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{new Array(10)
|
||||
.toString()
|
||||
.split(',')
|
||||
.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Badge>Trojan</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='font-medium'>节点名称</div>
|
||||
<div className='text-muted-foreground hidden text-sm md:inline'>
|
||||
127.0.0.1:443
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>1,000 GB</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>今日用户流量排行</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4'>
|
||||
{new Array(15)
|
||||
.toString()
|
||||
.split(',')
|
||||
.map((item, index) => (
|
||||
<div className='flex items-center gap-4' key={index}>
|
||||
<div className='text-sm font-medium leading-none'>olivia.martin@email.com</div>
|
||||
<div className='ml-auto font-medium'>100 GB</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { getAlipayF2FPaymentConfig, updateAlipayF2FPaymentConfig } from '@/services/admin/payment';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@shadcn/ui/select';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { Textarea } from '@shadcn/ui/textarea';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function AlipayF2F() {
|
||||
const t = useTranslations('payment');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getAlipayF2FPaymentConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAlipayF2FPaymentConfig();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateAlipayF2FPaymentConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.PaymentConfig);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('enable')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('showName')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('showNameDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.name}
|
||||
onValueBlur={(value) => updateConfig('name', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('iconUrl')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('iconUrlDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.icon_url}
|
||||
onValueBlur={(value) => updateConfig('icon', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('notifyUrl')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('notifyUrlDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.domain}
|
||||
onValueBlur={(value) => updateConfig('domain', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('feeMode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('feeModeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Select
|
||||
value={String(data?.fee_mode)}
|
||||
onValueChange={(value) => {
|
||||
updateConfig('fee_mode', Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='请选择' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='0'>{t('feeModeItems.0')}</SelectItem>
|
||||
<SelectItem value='1'>{t('feeModeItems.1')}</SelectItem>
|
||||
<SelectItem value='2'>{t('feeModeItems.2')}</SelectItem>
|
||||
<SelectItem value='3'>{t('feeModeItems.3')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('feePercent')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('feePercentDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
type='number'
|
||||
min={0}
|
||||
max={100}
|
||||
maxLength={3}
|
||||
value={data?.fee_percent}
|
||||
onValueBlur={(value) => updateConfig('fee_percent', value)}
|
||||
suffix='%'
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('fixedFee')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('fixedFeeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
min={0}
|
||||
value={data?.fee_amount}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueBlur={(value) => updateConfig('fee_amount', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('alipayf2f.appId')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.app_id}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
app_id: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('alipayf2f.privateKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Textarea
|
||||
placeholder={t('inputPlaceholder')}
|
||||
defaultValue={data?.config.private_key}
|
||||
onBlur={(e) => {
|
||||
updateConfig('config', {
|
||||
config: {
|
||||
...data?.config,
|
||||
private_key: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('alipayf2f.publicKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Textarea
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.public_key}
|
||||
onBlur={(e) => {
|
||||
updateConfig('config', {
|
||||
config: {
|
||||
...data?.config,
|
||||
public_key: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('alipayf2f.invoiceName')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('alipayf2f.invoiceNameDescription')}
|
||||
value={data?.config.invoice_name}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
invoice_name: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { getEpayPaymentConfig, updateEpayPaymentConfig } from '@/services/admin/payment';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@shadcn/ui/select';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Epay() {
|
||||
const t = useTranslations('payment');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getEpayPaymentConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getEpayPaymentConfig();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateEpayPaymentConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.PaymentConfig);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('enable')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('showName')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('showNameDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.name}
|
||||
onValueBlur={(value) => updateConfig('name', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('iconUrl')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('iconUrlDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.icon}
|
||||
onValueBlur={(value) => updateConfig('icon', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('notifyUrl')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('notifyUrlDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.domain}
|
||||
onValueBlur={(value) => updateConfig('domain', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('feeMode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('feeModeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Select
|
||||
value={String(data?.fee_mode)}
|
||||
onValueChange={(value) => {
|
||||
updateConfig('fee_mode', Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='请选择' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='0'>{t('feeModeItems.0')}</SelectItem>
|
||||
<SelectItem value='1'>{t('feeModeItems.1')}</SelectItem>
|
||||
<SelectItem value='2'>{t('feeModeItems.2')}</SelectItem>
|
||||
<SelectItem value='3'>{t('feeModeItems.3')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('feePercent')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('feePercentDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
type='number'
|
||||
min={0}
|
||||
max={100}
|
||||
maxLength={3}
|
||||
value={data?.fee_percent}
|
||||
onValueBlur={(value) => updateConfig('fee_percent', value)}
|
||||
suffix='%'
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('fixedFee')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('fixedFeeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
type='number'
|
||||
min={0}
|
||||
value={data?.fee_amount}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueBlur={(value) => updateConfig('fee_amount', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('epay.url')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.url}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
url: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('epay.pid')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.pid}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
pid: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('epay.key')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.key}
|
||||
onValueBlur={(value) =>
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
key: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import AlipayF2F from './alipayf2f';
|
||||
import Epay from './epay';
|
||||
import StripeAlipay from './stripe-alipay';
|
||||
import StripeWeChatPay from './stripe-wechat-pay';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Tabs defaultValue='Epay'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='Epay'>Epay</TabsTrigger>
|
||||
<TabsTrigger value='Stripe-Alipay'>Stripe(AliPay)</TabsTrigger>
|
||||
<TabsTrigger value='Strip-WeChatPay'>Stripe(WeChatPay)</TabsTrigger>
|
||||
<TabsTrigger value='AlipayF2F'>AlipayF2F</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='Epay'>
|
||||
<Epay />
|
||||
</TabsContent>
|
||||
<TabsContent value='Stripe-Alipay'>
|
||||
<StripeAlipay />
|
||||
</TabsContent>
|
||||
<TabsContent value='Strip-WeChatPay'>
|
||||
<StripeWeChatPay />
|
||||
</TabsContent>
|
||||
<TabsContent value='AlipayF2F'>
|
||||
<AlipayF2F />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getStripeAlipayPaymentConfig,
|
||||
updateStripeAlipayPaymentConfig,
|
||||
} from '@/services/admin/payment';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@shadcn/ui/select';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Stripe() {
|
||||
const t = useTranslations('payment');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getStripeAlipayPaymentConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getStripeAlipayPaymentConfig();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateStripeAlipayPaymentConfig({
|
||||
...data,
|
||||
mark: 'stripe_alipay',
|
||||
[key]: value,
|
||||
} as any);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('aliPay')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable}
|
||||
onCheckedChange={async (checked) => {
|
||||
updateConfig('enable', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('showName')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('showNameDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.name}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('name', value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('iconUrl')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('iconUrlDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.icon}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('icon', value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('notifyUrl')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('notifyUrlDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.domain}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('domain', value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('feeMode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('feeModeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Select
|
||||
value={String(data?.fee_mode)}
|
||||
onValueChange={(value) => {
|
||||
updateConfig('fee_mode', Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='请选择' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='0'>{t('feeModeItems.0')}</SelectItem>
|
||||
<SelectItem value='1'>{t('feeModeItems.1')}</SelectItem>
|
||||
<SelectItem value='2'>{t('feeModeItems.2')}</SelectItem>
|
||||
<SelectItem value='3'>{t('feeModeItems.3')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('feePercent')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('feePercentDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
type='number'
|
||||
min={0}
|
||||
max={100}
|
||||
value={data?.fee_percent}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('fee_percent', value);
|
||||
}}
|
||||
suffix='%'
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('fixedFee')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('fixedFeeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
type='number'
|
||||
min={0}
|
||||
value={data?.fee_amount}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueBlur={(value) => updateConfig('fee_amount', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('stripe.publicKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.public_key}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
public_key: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('stripe.secretKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.secret_key}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
secret_key: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('stripe.webhookSecret')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.webhook_secret}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
webhook_secret: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getStripeWeChatPayPaymentConfig,
|
||||
updateStripeWeChatPayPaymentConfig,
|
||||
} from '@/services/admin/payment';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@shadcn/ui/select';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function StripeWeChatPay() {
|
||||
const t = useTranslations('payment');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getStripeWeChatPayPaymentConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getStripeWeChatPayPaymentConfig();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateStripeWeChatPayPaymentConfig({
|
||||
...data,
|
||||
mark: 'stripe_wechat_pay',
|
||||
[key]: value,
|
||||
} as any);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('wechatPay')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable}
|
||||
onCheckedChange={async (checked) => {
|
||||
updateConfig('enable', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('showName')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('showNameDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.name}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('name', value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('iconUrl')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('iconUrlDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.icon}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('icon', value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('notifyUrl')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('notifyUrlDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.domain}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('domain', value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('feeMode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('feeModeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Select
|
||||
value={String(data?.fee_mode)}
|
||||
onValueChange={(value) => {
|
||||
updateConfig('fee_mode', Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='请选择' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='0'>{t('feeModeItems.0')}</SelectItem>
|
||||
<SelectItem value='1'>{t('feeModeItems.1')}</SelectItem>
|
||||
<SelectItem value='2'>{t('feeModeItems.2')}</SelectItem>
|
||||
<SelectItem value='3'>{t('feeModeItems.3')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('feePercent')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('feePercentDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
type='number'
|
||||
min={0}
|
||||
max={100}
|
||||
value={data?.fee_percent}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('fee_percent', value);
|
||||
}}
|
||||
suffix='%'
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('fixedFee')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('fixedFeeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
type='number'
|
||||
min={0}
|
||||
value={data?.fee_amount}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueBlur={(value) => updateConfig('fee_amount', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('stripe.publicKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.public_key}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
public_key: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('stripe.secretKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.secret_key}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
secret_key: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('stripe.webhookSecret')}</Label>
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.config.webhook_secret}
|
||||
onValueBlur={(value) => {
|
||||
updateConfig('config', {
|
||||
...data?.config,
|
||||
webhook_secret: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
interface GroupFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function GroupForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: GroupFormProps<T>) {
|
||||
const t = useTranslations('server');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-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='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('group.form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('group.form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteNodeGroup,
|
||||
createNodeGroup,
|
||||
deleteNodeGroup,
|
||||
getNodeGroupList,
|
||||
updateNodeGroup,
|
||||
} from '@/services/admin/server';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import GroupForm from './group-form';
|
||||
|
||||
export default function GroupTable() {
|
||||
const t = useTranslations('server');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>();
|
||||
|
||||
return (
|
||||
<ProTable<API.ServerGroup, any>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('group.title'),
|
||||
toolbar: (
|
||||
<GroupForm<API.CreateNodeGroupRequest>
|
||||
trigger={t('group.create')}
|
||||
title={t('group.createNodeGroup')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createNodeGroup(values);
|
||||
toast.success(t('group.createdSuccessfully'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('group.name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: t('group.description'),
|
||||
cell: ({ row }) => <p className='line-clamp-3'>{row.getValue('description')}</p>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: t('group.updatedAt'),
|
||||
cell: ({ row }) => formatDate(row.getValue('updated_at')),
|
||||
},
|
||||
]}
|
||||
request={async () => {
|
||||
const { data } = await getNodeGroupList();
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<GroupForm<API.ServerGroup>
|
||||
key='edit'
|
||||
trigger={t('group.edit')}
|
||||
title={t('group.editNodeGroup')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateNodeGroup({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t('group.createdSuccessfully'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await deleteNodeGroup({
|
||||
id: row.id!,
|
||||
});
|
||||
toast.success(t('group.deletedSuccessfully'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
],
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteNodeGroup({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteNode,
|
||||
createNode,
|
||||
deleteNode,
|
||||
getNodeGroupList,
|
||||
getNodeList,
|
||||
updateNode,
|
||||
} from '@/services/admin/server';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { cn } from '@shadcn/ui/lib/utils';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@shadcn/ui/tooltip';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import NodeForm from './node-form';
|
||||
|
||||
export default function NodeTable() {
|
||||
const t = useTranslations('server.node');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data: groups } = useQuery({
|
||||
queryKey: ['getNodeGroupList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeGroupList();
|
||||
return (data.data?.list || []) as API.ServerGroup[];
|
||||
},
|
||||
});
|
||||
|
||||
const ref = useRef<ProTableActions>();
|
||||
|
||||
return (
|
||||
<ProTable<API.Server, { groupId: number; search: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
toolbar: (
|
||||
<NodeForm<API.CreateNodeRequest>
|
||||
trigger={t('create')}
|
||||
title={t('createNode')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createNode({ ...values, enable: false });
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
cell: ({ row }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn('text-primary-foreground', {
|
||||
'bg-green-500': row.original.protocol === 'shadowsocks',
|
||||
'bg-rose-500': row.original.protocol === 'vmess',
|
||||
'bg-blue-500': row.original.protocol === 'vless',
|
||||
'bg-yellow-500': row.original.protocol === 'trojan',
|
||||
})}
|
||||
>
|
||||
{row.getValue('id')}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{row.original.protocol}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'enable',
|
||||
header: t('enable'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
checked={row.getValue('enable')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateNode({
|
||||
...row.original,
|
||||
id: row.original.id!,
|
||||
enable: checked,
|
||||
} as API.UpdateNodeRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'server_addr',
|
||||
header: t('serverAddr'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'speed_limit',
|
||||
header: t('speedLimit'),
|
||||
cell: ({ row }) => (
|
||||
<Display type='traffic' value={row.getValue('speed_limit')} unlimited />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'traffic_ratio',
|
||||
header: t('trafficRatio'),
|
||||
cell: ({ row }) => <Badge variant='outline'>{row.getValue('traffic_ratio')} X</Badge>,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'groupId',
|
||||
header: t('nodeGroup'),
|
||||
cell: ({ row }) => {
|
||||
const name = groups?.find((group) => group.id === row.getValue('groupId'))?.name;
|
||||
return name ? <Badge variant='outline'>{name}</Badge> : '--';
|
||||
},
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'group_id',
|
||||
placeholder: t('nodeGroup'),
|
||||
options: groups?.map((item) => ({
|
||||
label: item.name,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getNodeList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<NodeForm<API.Server>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editNode')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateNode({ ...row, ...values } as API.UpdateNodeRequest);
|
||||
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 deleteNode({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
],
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteNode({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import GroupTable from './group-table';
|
||||
import NodeTable from './node-table';
|
||||
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('server');
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='node'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='node'>{t('tabs.node')}</TabsTrigger>
|
||||
<TabsTrigger value='group'>{t('tabs.nodeGroup')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='node'>
|
||||
<NodeTable />
|
||||
</TabsContent>
|
||||
<TabsContent value='group'>
|
||||
<GroupTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Icon } from '@iconify/react';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
interface GroupFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function GroupForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: GroupFormProps<T>) {
|
||||
const t = useTranslations('subscribe');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-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='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('group.form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('group.form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteSubscribeGroup,
|
||||
createSubscribeGroup,
|
||||
deleteSubscribeGroup,
|
||||
getSubscribeGroupList,
|
||||
updateSubscribeGroup,
|
||||
} from '@/services/admin/subscribe';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import GroupForm from './group-form';
|
||||
|
||||
const GroupTable = () => {
|
||||
const t = useTranslations('subscribe');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>();
|
||||
|
||||
return (
|
||||
<ProTable<API.SubscribeGroup, any>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('group.title'),
|
||||
toolbar: (
|
||||
<GroupForm<API.CreateSubscribeGroupRequest>
|
||||
trigger={t('group.create')}
|
||||
title={t('group.createSubscribeGroup')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createSubscribeGroup(values);
|
||||
toast.success(t('group.createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('group.name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: t('group.description'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: t('group.updatedAt'),
|
||||
cell: ({ row }) => formatDate(row.getValue('updated_at')),
|
||||
},
|
||||
]}
|
||||
request={async () => {
|
||||
const { data } = await getSubscribeGroupList();
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<GroupForm<API.SubscribeGroup>
|
||||
key='edit'
|
||||
trigger={t('group.edit')}
|
||||
title={t('group.editSubscribeGroup')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribeGroup({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t('group.updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await deleteSubscribeGroup({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
],
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteSubscribeGroup({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTable;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
|
||||
import GroupTable from './group-table';
|
||||
import SubscribeTable from './subscribe-table';
|
||||
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('subscribe');
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='subscribe'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
|
||||
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='subscribe'>
|
||||
<SubscribeTable />
|
||||
</TabsContent>
|
||||
<TabsContent value='group'>
|
||||
<GroupTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { getNodeGroupList, getNodeList } from '@/services/admin/server';
|
||||
import { getSubscribeGroupList } from '@/services/admin/subscribe';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Combobox } from '@repo/ui/combobox';
|
||||
import { ArrayInput } from '@repo/ui/dynamic-Inputs';
|
||||
import { JSONEditor } from '@repo/ui/editor';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@shadcn/ui/accordion';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Checkbox } from '@shadcn/ui/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@shadcn/ui/form';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { assign, shake } from 'radash';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface SubscribeFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
inventory: -1,
|
||||
speed_limit: 0,
|
||||
device_limit: 0,
|
||||
traffic: 0,
|
||||
quota: 0,
|
||||
discount: [],
|
||||
server_group: [],
|
||||
server: [],
|
||||
};
|
||||
|
||||
export default function SubscribeForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: SubscribeFormProps<T>) {
|
||||
const t = useTranslations('subscribe');
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
unit_price: z.number(),
|
||||
replacement: z.number().optional(),
|
||||
discount: z
|
||||
.array(
|
||||
z.object({
|
||||
months: z.number(),
|
||||
discount: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
inventory: z.number().optional(),
|
||||
speed_limit: z.number().optional(),
|
||||
device_limit: z.number().optional(),
|
||||
traffic: z.number().optional(),
|
||||
quota: z.number().optional(),
|
||||
group_id: z.number().optional().nullish(),
|
||||
server_group: z.array(z.number()).optional(),
|
||||
server: z.array(z.number()).optional(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: assign(
|
||||
defaultValues,
|
||||
shake(initialValues, (value) => value === null) as Record<string, any>,
|
||||
),
|
||||
});
|
||||
useEffect(() => {
|
||||
form?.reset(
|
||||
assign(defaultValues, shake(initialValues, (value) => value === null) as Record<string, any>),
|
||||
);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
const { data: group } = useQuery({
|
||||
queryKey: ['getSubscribeGroupList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeGroupList();
|
||||
return data.data?.list as API.SubscribeGroup[];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: server } = useQuery({
|
||||
queryKey: ['getNodeList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: server_groups } = useQuery({
|
||||
queryKey: ['getNodeGroupList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeGroupList();
|
||||
return (data.data?.list || []) as API.ServerGroup[];
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-screen max-w-full md:max-w-full'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='grid gap-4 px-6 pt-4 lg:grid-cols-3'
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<JSONEditor
|
||||
title={t('form.description')}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={{
|
||||
description: 'description',
|
||||
features: [
|
||||
{
|
||||
type: 'default',
|
||||
icon: '',
|
||||
label: 'label',
|
||||
},
|
||||
],
|
||||
}}
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'A brief description of the item.',
|
||||
},
|
||||
features: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
icon: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Enter an Iconify icon identifier (e.g., 'mdi:account').",
|
||||
pattern: '^[a-z0-9]+:[a-z0-9-]+$',
|
||||
examples: [
|
||||
'uil:shield-check',
|
||||
'uil:shield-exclamation',
|
||||
'uil:database',
|
||||
'uil:server',
|
||||
'Visit https://icon-sets.iconify.design to browse available icons.',
|
||||
],
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'The label describing the feature.',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['default', 'success', 'destructive'],
|
||||
description:
|
||||
'The type of feature, limited to specific values.',
|
||||
},
|
||||
},
|
||||
},
|
||||
description: 'A list of feature objects.',
|
||||
},
|
||||
},
|
||||
required: ['description', 'features'],
|
||||
additionalProperties: false,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='unit_price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.unit_price')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='replacement'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.replacement')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='traffic'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.traffic')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
formatInput={(value) => unitConversion('bytesToGb', value)}
|
||||
formatOutput={(value) => unitConversion('gbToBytes', value)}
|
||||
suffix='GB'
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='speed_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.speedLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
formatInput={(value) => unitConversion('bitsToMb', value)}
|
||||
formatOutput={(value) => unitConversion('mbToBits', value)}
|
||||
suffix='MB'
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='device_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.deviceLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='inventory'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.inventory')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={`-1 ${t('form.noLimit')}`}
|
||||
type='number'
|
||||
{...field}
|
||||
min={-1}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='group_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.subscribeGroup')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, false>
|
||||
placeholder={t('form.selectSubscribeGroup')}
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
options={group?.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='col-span-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.discount')}</FormLabel>
|
||||
<FormControl>
|
||||
<ArrayInput<API.SubscribeDiscount>
|
||||
fields={[
|
||||
{
|
||||
name: 'months',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
suffix: t('form.discountMonths'),
|
||||
},
|
||||
{
|
||||
name: 'discount',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 100,
|
||||
placeholder: t('form.discountPercent'),
|
||||
suffix: '%',
|
||||
},
|
||||
]}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('form.discountDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='server_group'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.serverGroup')}</FormLabel>
|
||||
<FormControl>
|
||||
<Accordion type='single' collapsible className='w-full'>
|
||||
{server_groups?.map((group: API.ServerGroup) => {
|
||||
const value = field.value || [];
|
||||
|
||||
return (
|
||||
<AccordionItem key={group.id} value={String(group.id)}>
|
||||
<AccordionTrigger>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={value.includes(group.id!)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? form.setValue(field.name, [...value, group.id])
|
||||
: form.setValue(
|
||||
field.name,
|
||||
value.filter((value: number) => value !== group.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label>{group.name}</Label>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className='list-disc [&>li]:mt-2'>
|
||||
{server
|
||||
?.filter((server: API.Server) => server.groupId === group.id)
|
||||
?.map((node: API.Server) => {
|
||||
return (
|
||||
<li
|
||||
key={node.id}
|
||||
className='flex items-center justify-between *:flex-1'
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<span>{node.server_addr}</span>
|
||||
<span className='text-right'>{node.protocol}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='server'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.server')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{server
|
||||
?.filter((item: API.Server) => !item.groupId)
|
||||
?.map((item: API.Server) => {
|
||||
const value = field.value || [];
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2' key={item.id}>
|
||||
<Checkbox
|
||||
checked={value.includes(item.id!)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? form.setValue(field.name, [...value, item.id])
|
||||
: form.setValue(
|
||||
field.name,
|
||||
value.filter((value: number) => value !== item.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label className='flex w-full items-center justify-between *:flex-1'>
|
||||
<span>{item.name}</span>
|
||||
<span>{item.server_addr}</span>
|
||||
<span className='text-right'>{item.protocol}</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteSubscribe,
|
||||
createSubscribe,
|
||||
deleteSubscribe,
|
||||
getSubscribeGroupList,
|
||||
getSubscribeList,
|
||||
updateSubscribe,
|
||||
} from '@/services/admin/subscribe';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import SubscribeForm from './subscribe-form';
|
||||
|
||||
export default function SubscribeTable() {
|
||||
const t = useTranslations('subscribe');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { data: groups } = useQuery({
|
||||
queryKey: ['getSubscribeGroupList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeGroupList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list as API.SubscribeGroup[];
|
||||
},
|
||||
});
|
||||
const ref = useRef<ProTableActions>();
|
||||
return (
|
||||
<ProTable<API.Subscribe, { group_id: number; query: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
toolbar: (
|
||||
<SubscribeForm<API.CreateSubscribeRequest>
|
||||
trigger={t('create')}
|
||||
title={t('createSubscribe')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createSubscribe({
|
||||
...values,
|
||||
show: false,
|
||||
sell: false,
|
||||
});
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'group_id',
|
||||
placeholder: t('subscribeGroup'),
|
||||
options: groups?.map((item) => ({
|
||||
label: item.name,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getSubscribeList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'show',
|
||||
header: t('show'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('show')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribe({
|
||||
...row.original,
|
||||
show: checked,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'sell',
|
||||
header: t('sell'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('sell')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribe({
|
||||
...row.original,
|
||||
sell: checked,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'unit_price',
|
||||
header: t('unitPrice'),
|
||||
cell: ({ row }) => <Display type='currency' value={row.getValue('unit_price')} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'replacement',
|
||||
header: t('replacement'),
|
||||
cell: ({ row }) => <Display type='currency' value={row.getValue('replacement')} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'traffic',
|
||||
header: t('traffic'),
|
||||
cell: ({ row }) => <Display type='traffic' value={row.getValue('traffic')} unlimited />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'device_limit',
|
||||
header: t('deviceLimit'),
|
||||
cell: ({ row }) => (
|
||||
<Display type='number' value={row.getValue('device_limit')} unlimited />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'inventory',
|
||||
header: t('inventory'),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type='number'
|
||||
value={row.getValue('inventory') === -1 ? 0 : row.getValue('inventory')}
|
||||
unlimited
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'quota',
|
||||
header: t('quota'),
|
||||
cell: ({ row }) => <Display type='number' value={row.getValue('quota')} unlimited />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'group_id',
|
||||
header: t('subscribeGroup'),
|
||||
cell: ({ row }) => {
|
||||
const name = groups?.find((group) => group.id === row.getValue('group_id'))?.name;
|
||||
return name ? <Badge variant='outline'>{name}</Badge> : '--';
|
||||
},
|
||||
},
|
||||
]}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<SubscribeForm<API.Subscribe>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editSubscribe')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribe({
|
||||
...row,
|
||||
...values,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
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 deleteSubscribe({
|
||||
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 () => {
|
||||
await batchDeleteSubscribe({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.reset();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { getCurrencyConfig, updateCurrencyConfig } from '@/services/admin/system';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
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.UpdateCurrencyConfigRequest);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { getEmailSmtpConfig, testEmailSmtp, updateEmailSmtpConfig } from '@/services/admin/system';
|
||||
import { HTMLEditor } from '@repo/ui/editor';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Email() {
|
||||
const t = useTranslations('system.email');
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ['getEmailSmtpConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getEmailSmtpConfig();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const [email, setEmail] = useState<string>();
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateEmailSmtpConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.UpdateEmailSmtpConfigRequest);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{[
|
||||
{
|
||||
key: 'email_smtp_host',
|
||||
label: t('smtpServerAddress'),
|
||||
description: t('smtpServerAddressDescription'),
|
||||
},
|
||||
{
|
||||
key: 'email_smtp_port',
|
||||
label: t('smtpServerPort'),
|
||||
description: t('smtpServerPortDescription'),
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
key: 'email_smtp_ssl',
|
||||
label: t('smtpEncryptionMethod'),
|
||||
description: t('smtpEncryptionMethodDescription'),
|
||||
component: 'switch',
|
||||
},
|
||||
{
|
||||
key: 'email_smtp_user',
|
||||
label: t('smtpAccount'),
|
||||
description: t('smtpAccountDescription'),
|
||||
},
|
||||
{
|
||||
key: 'email_smtp_pass',
|
||||
label: t('smtpPassword'),
|
||||
description: t('smtpPasswordDescription'),
|
||||
type: 'password',
|
||||
},
|
||||
{
|
||||
key: 'email_smtp_from',
|
||||
label: t('senderAddress'),
|
||||
description: t('senderAddressDescription'),
|
||||
},
|
||||
].map(({ key, label, description, type = 'text', component = 'input' }) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
<Label>{label}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{description}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
{component === 'input' ? (
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.[key]}
|
||||
type={type}
|
||||
onValueBlur={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
checked={data?.[key]}
|
||||
onCheckedChange={(checked) => updateConfig(key, checked)}
|
||||
/>
|
||||
)}
|
||||
</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={t('inputPlaceholder')}
|
||||
value={email}
|
||||
onValueChange={(value) => setEmail(value as string)}
|
||||
/>
|
||||
<Button
|
||||
disabled={!email}
|
||||
onClick={async () => {
|
||||
if (isFetching || !email) return;
|
||||
try {
|
||||
await testEmailSmtp({ email });
|
||||
toast.success(t('sendSuccess'));
|
||||
} catch {
|
||||
toast.error(t('sendFailure'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('sendTestEmail')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className='grid grid-cols-1 gap-4 py-4 md:grid-cols-3'>
|
||||
{['verify_email_template', 'expiration_email_template', 'maintenance_email_template'].map(
|
||||
(templateKey) => (
|
||||
<HTMLEditor
|
||||
key={templateKey}
|
||||
title={t(`${templateKey}`)}
|
||||
description={t(`${templateKey}Description`, { after: '{{', before: '}}' })}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.[templateKey]}
|
||||
onBlur={(value) => {
|
||||
updateConfig(templateKey, value);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { getInviteConfig, updateInviteConfig } from '@/services/admin/system';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Invite() {
|
||||
const t = useTranslations('system.invite');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getInviteConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getInviteConfig();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateInviteConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.UpdateInviteConfigRequest);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('enableForcedInvite')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('enableForcedInviteDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.forced_invite}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('forced_invite', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('inviteCommissionPercentage')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('inviteCommissionPercentageDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.referral_percentage}
|
||||
type='number'
|
||||
min={0}
|
||||
max={100}
|
||||
suffix='%'
|
||||
onValueBlur={(value) => updateConfig('referral_percentage', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('commissionFirstTimeOnly')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('commissionFirstTimeOnlyDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.only_first_purchase}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('only_first_purchase', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { getNodeConfig, updateNodeConfig } from '@/services/admin/system';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Node() {
|
||||
const t = useTranslations('system.node');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getNodeConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeConfig();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateNodeConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.GetNodeConfigResponse);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('communicationKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('communicationKeyDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={data?.node_secret}
|
||||
onValueBlur={(value) => updateConfig('node_secret', value)}
|
||||
suffix={
|
||||
<Icon
|
||||
icon='uil:arrow-random'
|
||||
onClick={() => {
|
||||
updateConfig('node_secret', nanoid());
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('nodePullInterval')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('nodePullIntervalDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
min={0}
|
||||
onValueBlur={(value) => updateConfig('node_pull_interval', value)}
|
||||
suffix='S'
|
||||
value={data?.node_pull_interval}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('nodePushInterval')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('nodePushIntervalDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
min={0}
|
||||
value={data?.node_push_interval}
|
||||
onValueBlur={(value) => updateConfig('node_push_interval', value)}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import Currency from './currency';
|
||||
import Email from './email';
|
||||
import Invite from './invite';
|
||||
import Node from './node';
|
||||
import Register from './register';
|
||||
import Site from './site';
|
||||
import Subscription from './subscription';
|
||||
import Telegram from './telegram';
|
||||
import Tos from './tos';
|
||||
import Verify from './verify';
|
||||
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('system');
|
||||
|
||||
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='subscription'>{t('tabs.subscription')}</TabsTrigger>
|
||||
<TabsTrigger value='register'>{t('tabs.register')}</TabsTrigger>
|
||||
<TabsTrigger value='verify'>{t('tabs.verify')}</TabsTrigger>
|
||||
<TabsTrigger value='email'>{t('tabs.email')}</TabsTrigger>
|
||||
<TabsTrigger value='node'>{t('tabs.node')}</TabsTrigger>
|
||||
<TabsTrigger value='invite'>{t('tabs.invite')}</TabsTrigger>
|
||||
<TabsTrigger value='telegram'>{t('tabs.telegram')}</TabsTrigger>
|
||||
<TabsTrigger value='tos'>{t('tabs.tos')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='site'>
|
||||
<Site />
|
||||
</TabsContent>
|
||||
<TabsContent value='currency'>
|
||||
<Currency />
|
||||
</TabsContent>
|
||||
<TabsContent value='subscription'>
|
||||
<Subscription />
|
||||
</TabsContent>
|
||||
<TabsContent value='register'>
|
||||
<Register />
|
||||
</TabsContent>
|
||||
<TabsContent value='verify'>
|
||||
<Verify />
|
||||
</TabsContent>
|
||||
<TabsContent value='email'>
|
||||
<Email />
|
||||
</TabsContent>
|
||||
<TabsContent value='node'>
|
||||
<Node />
|
||||
</TabsContent>
|
||||
<TabsContent value='invite'>
|
||||
<Invite />
|
||||
</TabsContent>
|
||||
<TabsContent value='telegram'>
|
||||
<Telegram />
|
||||
</TabsContent>
|
||||
<TabsContent value='tos'>
|
||||
<Tos />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { getRegisterConfig, updateRegisterConfig } from '@/services/admin/system';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { Textarea } from '@shadcn/ui/textarea';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Register() {
|
||||
const t = useTranslations('system.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;
|
||||
try {
|
||||
await updateRegisterConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.GetRegisterConfigResponse);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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('emailVerification')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('emailVerificationDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable_email_verify}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable_email_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?.enable_email_domain_suffix}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable_email_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-52'
|
||||
placeholder={t('whitelistSuffixesPlaceholder')}
|
||||
defaultValue={data?.email_domain_suffix_list}
|
||||
onBlur={(e) => {
|
||||
updateConfig('email_domain_suffix_list', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('ipRegistrationLimit')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('ipRegistrationLimitDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable_ip_register_limit}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable_ip_register_limit', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('registrationLimitCount')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('registrationLimitCountDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
min={0}
|
||||
value={data?.ip_register_limit}
|
||||
onValueBlur={(value) => updateConfig('ip_register_limit', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('penaltyTime')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('penaltyTimeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
min={0}
|
||||
value={data?.ip_register_limit_duration}
|
||||
onValueBlur={(value) => updateConfig('ip_register_limit_duration', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('trialRegistration')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('trialRegistrationDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable_trial}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable_trial', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { getSiteConfig, updateSiteConfig } from '@/services/admin/system';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { Textarea } from '@shadcn/ui/textarea';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
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)}
|
||||
/>
|
||||
</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 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')}
|
||||
defaultValue={data?.host}
|
||||
onBlur={(e) => {
|
||||
updateConfig('host', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
'use client';
|
||||
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
createApplication,
|
||||
deleteApplication,
|
||||
getApplication,
|
||||
getSubscribeConfig,
|
||||
getSubscribeType,
|
||||
updateApplication,
|
||||
updateSubscribeConfig,
|
||||
} from '@/services/admin/system';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { Textarea } from '@shadcn/ui/textarea';
|
||||
|
||||
function compareData(
|
||||
originalData: API.GetApplicationResponse,
|
||||
modifiedData: API.GetApplicationResponse,
|
||||
): {
|
||||
added: API.Application[];
|
||||
deleted: API.Application[];
|
||||
updated: API.Application[];
|
||||
} {
|
||||
const added: API.Application[] = [];
|
||||
const deleted: API.Application[] = [];
|
||||
const updated: API.Application[] = [];
|
||||
|
||||
const findById = (array: API.Application[], id: number): API.Application | undefined => {
|
||||
return array.find((item) => item.id === id);
|
||||
};
|
||||
|
||||
const isUpdated = (original: API.Application, modified: API.Application): boolean => {
|
||||
return (
|
||||
original.name !== modified.name ||
|
||||
original.platform !== modified.platform ||
|
||||
original.subscribe_type !== modified.subscribe_type ||
|
||||
original.url !== modified.url ||
|
||||
original.icon !== modified.icon
|
||||
);
|
||||
};
|
||||
|
||||
Object.values(originalData).forEach((platformData) => {
|
||||
platformData.forEach((originalApp) => {
|
||||
const modifiedApp = findById(
|
||||
modifiedData[originalApp.platform as API.CreateApplicationRequest['platform']],
|
||||
originalApp.id,
|
||||
);
|
||||
|
||||
if (!modifiedApp) {
|
||||
deleted.push(originalApp);
|
||||
} else if (isUpdated(originalApp, modifiedApp)) {
|
||||
updated.push(modifiedApp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Object.values(modifiedData).forEach((platformData) => {
|
||||
platformData.forEach((modifiedApp) => {
|
||||
if (
|
||||
!findById(
|
||||
originalData[modifiedApp.platform as API.CreateApplicationRequest['platform']],
|
||||
modifiedApp.id,
|
||||
)
|
||||
) {
|
||||
added.push(modifiedApp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { added, deleted, updated };
|
||||
}
|
||||
|
||||
export default function Subscription() {
|
||||
const t = useTranslations('system.subscription');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getSubscribeConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeConfig();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: apps, refetch: appsRefetch } = useQuery({
|
||||
queryKey: ['getApplication'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getApplication();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateSubscribeConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.GetSubscribeConfigResponse);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
const { data: subscribe_types } = useQuery<string[]>({
|
||||
queryKey: ['getSubscribeType'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeType();
|
||||
|
||||
return data.data?.subscribe_types || [];
|
||||
},
|
||||
});
|
||||
const [app, setApp] = useState<API.GetApplicationResponse>();
|
||||
const appTypes = Object.keys(apps || {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!app) setApp(apps);
|
||||
}, [app, apps]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('singleSubscriptionMode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('singleSubscriptionModeDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.single_model}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('single_model', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('wildcardResolution')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('wildcardResolutionDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.pan_domain}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('pan_domain', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('subscriptionPath')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('subscriptionPathDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='flex items-center gap-2 text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('subscriptionPathPlaceholder')}
|
||||
value={data?.subscribe_path}
|
||||
onValueBlur={(value) => updateConfig('subscribe_path', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='align-top'>
|
||||
<Label>{t('subscriptionDomain')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('subscriptionDomainDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Textarea
|
||||
className='h-52'
|
||||
placeholder={t('subscriptionDomainPlaceholder')}
|
||||
defaultValue={data?.subscribe_domain}
|
||||
onBlur={(e) => {
|
||||
updateConfig('subscribe_domain', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('app')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('appDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='flex justify-end gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setApp(apps);
|
||||
}}
|
||||
>
|
||||
{t('reset')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const { added, deleted, updated } = compareData(apps!, app!);
|
||||
|
||||
added.forEach(async (item) => {
|
||||
await createApplication(item as API.CreateApplicationRequest);
|
||||
});
|
||||
deleted.forEach(async (item) => {
|
||||
await deleteApplication({
|
||||
id: item.id,
|
||||
});
|
||||
});
|
||||
updated.forEach(async (item) => {
|
||||
await updateApplication(item);
|
||||
});
|
||||
toast.success(t('saveSuccess'));
|
||||
appsRefetch();
|
||||
}}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Tabs defaultValue='windows'>
|
||||
<TabsList className='h-full flex-wrap'>
|
||||
{appTypes.map((type) => {
|
||||
return (
|
||||
<TabsTrigger value={type} key={type} className='uppercase'>
|
||||
{type}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
{appTypes.map((type) => {
|
||||
const list = (app?.[type] as API.Application[]) || [];
|
||||
const updatedList = (key: string, value: string, index: number) => {
|
||||
const newList = list.map((item, i) => (i === index ? { ...item, [key]: value } : item));
|
||||
|
||||
setApp({
|
||||
...app,
|
||||
[type]: newList,
|
||||
} as API.GetApplicationResponse);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value={type} key={type} className='mt-4 space-y-4'>
|
||||
{list.map((item, index) => {
|
||||
return (
|
||||
<div className='flex flex-col items-center gap-2 lg:flex-row' key={index}>
|
||||
<Select
|
||||
value={item.subscribe_type}
|
||||
onValueChange={(value) => {
|
||||
updatedList('subscribe_type', value, index);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('subscriptionProtocol')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subscribe_types?.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<EnhancedInput
|
||||
placeholder={t('appName')}
|
||||
value={item.name}
|
||||
onValueChange={(value) => updatedList('name', value as string, index)}
|
||||
/>
|
||||
<EnhancedInput
|
||||
placeholder={t('appIcon')}
|
||||
value={item.icon}
|
||||
onValueChange={(value) => updatedList('icon', value as string, index)}
|
||||
/>
|
||||
<EnhancedInput
|
||||
placeholder={t('appDownloadURL')}
|
||||
value={item.url}
|
||||
onValueChange={(value) => updatedList('url', value as string, index)}
|
||||
/>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setApp({
|
||||
...app,
|
||||
[type]: list.filter((l, i) => i !== index),
|
||||
} as API.GetApplicationResponse);
|
||||
}}
|
||||
>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
className='w-full'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setApp({
|
||||
...app,
|
||||
[type]: [
|
||||
...list,
|
||||
{
|
||||
platform: type,
|
||||
},
|
||||
],
|
||||
} as API.GetApplicationResponse);
|
||||
}}
|
||||
>
|
||||
{t('add')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { getTelegramConfig, updateTelegramConfig } from '@/services/admin/system';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Telegram() {
|
||||
const t = useTranslations('system.telegram');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getTelegramConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getTelegramConfig();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateTelegramConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.GetTelegramConfigResponse);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('botToken')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('botTokenDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholderBotToken')}
|
||||
value={data?.telegram_bot_token}
|
||||
onValueBlur={(value) => updateConfig('telegram_bot_token', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('enableBotNotifications')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('enableBotNotificationsDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.telegram_notify}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('telegram_notify', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('groupURL')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('groupURLDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholderGroupURL')}
|
||||
value={data?.telegram_group_url}
|
||||
onValueBlur={(value) => updateConfig('telegram_group_url', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { getTosConfig, updateTosConfig } from '@/services/admin/system';
|
||||
import { MarkdownEditor } from '@repo/ui/editor';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
export default function Tos() {
|
||||
const t = useTranslations('system.tos');
|
||||
const { resolvedTheme } = useTheme();
|
||||
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.GetTosConfigResponse);
|
||||
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,115 @@
|
||||
'use client';
|
||||
|
||||
import { getVerifyConfig, updateVerifyConfig } from '@/services/admin/system';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@shadcn/ui/table';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Verify() {
|
||||
const t = useTranslations('system.verify');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getVerifyConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getVerifyConfig();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateVerifyConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.GetVerifyConfigResponse);
|
||||
toast.success(t('saveSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('turnstileSiteKey')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('turnstileSiteKeyDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
defaultValue={data?.turnstile_site_key}
|
||||
onValueBlur={(value) => updateConfig('turnstile_site_key', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('turnstileSecret')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('turnstileSecretDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('inputPlaceholder')}
|
||||
defaultValue={data?.turnstile_secret}
|
||||
onValueBlur={(value) => updateConfig('turnstile_secret', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('registrationVerificationCode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('registrationVerificationCodeDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable_register_verify}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable_register_verify', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('loginVerificationCode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('loginVerificationCodeDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable_login_verify}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable_login_verify', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('resetPasswordVerificationCode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('resetPasswordVerificationCodeDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.enable_reset_password_verify}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('enable_reset_password_verify', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
createTicketFollow,
|
||||
getTicket,
|
||||
getTicketList,
|
||||
updateTicketStatus,
|
||||
} from '@/services/admin/ticket';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@shadcn/ui/drawer';
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { cn } from '@shadcn/ui/lib/utils';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import NextImage from 'next/legacy/image';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { UserDetail } from '../user/user-detail';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('ticket');
|
||||
|
||||
const [ticketId, setTicketId] = useState<any>(null);
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const { data: ticket, refetch: refetchTicket } = useQuery({
|
||||
queryKey: ['getTicket', ticketId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getTicket({
|
||||
id: ticketId,
|
||||
});
|
||||
return data.data as API.Ticket;
|
||||
},
|
||||
enabled: !!ticketId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.children[1]?.scrollTo({
|
||||
top: scrollRef.current.children[1].scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 66);
|
||||
}, [ticket?.follow?.length]);
|
||||
|
||||
const ref = useRef<ProTableActions>();
|
||||
return (
|
||||
<>
|
||||
<ProTable<API.Ticket, { status: number }>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('ticketList'),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: t('title'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'user_id',
|
||||
header: t('user'),
|
||||
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('status.0'),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-2 before:block before:size-1.5 before:animate-pulse before:rounded-full before:ring-2 before:ring-opacity-50',
|
||||
{
|
||||
'before:bg-rose-500 before:ring-rose-500': row.original.status === 1,
|
||||
'before:bg-yellow-500 before:ring-yellow-500': row.original.status === 2,
|
||||
'before:bg-green-500 before:ring-green-500': row.original.status === 3,
|
||||
'before:bg-zinc-500 before:ring-zinc-500': row.original.status === 4,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{t(`status.${row.original.status}`)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: t('updatedAt'),
|
||||
cell: ({ row }) => formatDate(row.getValue('updated_at')),
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{
|
||||
key: 'status',
|
||||
placeholder: t('status.0'),
|
||||
options: [
|
||||
{
|
||||
label: t('close'),
|
||||
value: '4',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getTicketList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render(row) {
|
||||
if (row.status !== 4) {
|
||||
return [
|
||||
<Button key='reply' size='sm' onClick={() => setTicketId(row.id)}>
|
||||
{t('reply')}
|
||||
</Button>,
|
||||
<ConfirmButton
|
||||
key='colse'
|
||||
trigger={<Button variant='destructive'>{t('close')}</Button>}
|
||||
title={t('confirmClose')}
|
||||
description={t('closeWarning')}
|
||||
onConfirm={async () => {
|
||||
await updateTicketStatus({
|
||||
id: row.id,
|
||||
status: 4,
|
||||
});
|
||||
toast.success(t('closeSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
return [
|
||||
<Button key='check' size='sm' onClick={() => setTicketId(row.id)}>
|
||||
{t('check')}
|
||||
</Button>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
open={!!ticketId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setTicketId(null);
|
||||
}}
|
||||
>
|
||||
<DrawerContent className='container mx-auto h-screen'>
|
||||
<DrawerHeader className='border-b text-left'>
|
||||
<DrawerTitle>{ticket?.title}</DrawerTitle>
|
||||
<DrawerDescription>{ticket?.description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<ScrollArea className='h-full overflow-hidden' ref={scrollRef}>
|
||||
<div className='flex h-screen flex-col gap-4 p-4'>
|
||||
{ticket?.follow?.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn('flex items-center gap-4', {
|
||||
'flex-row-reverse': item.from === 'System',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('flex flex-col gap-1', {
|
||||
'items-end': item.from === 'System',
|
||||
})}
|
||||
>
|
||||
<p className='text-muted-foreground text-sm'>{formatDate(item.created_at)}</p>
|
||||
<p
|
||||
className={cn('bg-accent w-fit rounded-lg p-2 font-medium', {
|
||||
'bg-primary text-primary-foreground': item.from === 'System',
|
||||
})}
|
||||
>
|
||||
{item.type === 1 && item.content}
|
||||
{item.type === 2 && (
|
||||
<NextImage
|
||||
src={item.content!}
|
||||
width={300}
|
||||
height={300}
|
||||
className='!size-auto object-cover'
|
||||
alt='image'
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{ticket?.status !== 4 && (
|
||||
<DrawerFooter>
|
||||
<form
|
||||
className='flex w-full flex-row items-center gap-2'
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (message) {
|
||||
await createTicketFollow({
|
||||
ticket_id: ticketId,
|
||||
from: 'System',
|
||||
type: 1,
|
||||
content: message,
|
||||
});
|
||||
refetchTicket();
|
||||
setMessage('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type='button' variant='outline' className='p-0'>
|
||||
<Label htmlFor='picture' className='p-2'>
|
||||
<Icon icon='uil:image-upload' className='text-2xl' />
|
||||
</Label>
|
||||
<Input
|
||||
id='picture'
|
||||
type='file'
|
||||
className='hidden'
|
||||
accept='image/*'
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.src = e.target?.result as string;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const maxWidth = 300;
|
||||
const maxHeight = 300;
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
height = Math.round((maxWidth / width) * height);
|
||||
width = maxWidth;
|
||||
}
|
||||
} else {
|
||||
if (height > maxHeight) {
|
||||
width = Math.round((maxHeight / height) * width);
|
||||
height = maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx?.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob!);
|
||||
reader.onloadend = async () => {
|
||||
await createTicketFollow({
|
||||
ticket_id: ticketId,
|
||||
from: 'System',
|
||||
type: 2,
|
||||
content: reader.result as string,
|
||||
});
|
||||
refetchTicket();
|
||||
};
|
||||
},
|
||||
'image/webp',
|
||||
0.8,
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Input
|
||||
placeholder={t('inputPlaceholder')}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
<Button type='submit' disabled={!message}>
|
||||
<Icon icon='uil:navigator' />
|
||||
</Button>
|
||||
</form>
|
||||
</DrawerFooter>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import { createUser, deleteUser, getUserList, updateUser } from '@/services/admin/user';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { formatDate, unitConversion } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import { UserDetail } from './user-detail';
|
||||
import UserForm from './user-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('user');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>();
|
||||
|
||||
return (
|
||||
<ProTable<API.User, any>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('userList'),
|
||||
toolbar: (
|
||||
<UserForm<API.CreateUserRequest>
|
||||
key='create'
|
||||
trigger={t('create')}
|
||||
title={t('createUser')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createUser(values);
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'enable',
|
||||
header: t('enable'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('enable')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateUser({
|
||||
...row.original,
|
||||
enable: checked,
|
||||
} as unknown as API.UpdateUserRequest);
|
||||
toast.success(t('updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: t('userName'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'balance',
|
||||
header: t('balance'),
|
||||
cell: ({ row }) => unitConversion('centsToDollars', row.getValue('balance')),
|
||||
},
|
||||
{
|
||||
accessorKey: 'referer_id',
|
||||
header: t('referer'),
|
||||
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: t('createdAt'),
|
||||
cell: ({ row }) => formatDate(row.getValue('created_at')),
|
||||
},
|
||||
]}
|
||||
request={async (pagination) => {
|
||||
const { data } = await getUserList(pagination);
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render: (row) => {
|
||||
return [
|
||||
<UserForm<API.UpdateUserRequest>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editUser')}
|
||||
loading={loading}
|
||||
initialValues={row as unknown as API.UpdateUserRequest}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUser({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t('updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='edit'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteDescription')}
|
||||
onConfirm={async () => {
|
||||
await deleteUser({ id: row.id });
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { getUserDetail } from '@/services/admin/user';
|
||||
import { formatDate, unitConversion } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@shadcn/ui/hover-card';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function UserDetail({ id }: { id: number }) {
|
||||
const t = useTranslations('user');
|
||||
const [shouldFetch, setShouldFetch] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: id !== 0 && shouldFetch,
|
||||
queryKey: ['getUserDetail', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserDetail({ id });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!id) return '--';
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => setShouldFetch(true)}>
|
||||
<Button variant='link' className='p-0'>
|
||||
@{data?.email?.split('@')[0] || 'Loading...'}
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className='grid gap-3'>
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>ID</span>
|
||||
<span>{data?.id}</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('email')}</span>
|
||||
<span>{data?.email}</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('balance')}</span>
|
||||
<span>{unitConversion('centsToDollars', data?.balance)}</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('createdAt')}</span>
|
||||
<span>{data?.created_at && formatDate(data?.created_at)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface UserFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
update?: boolean;
|
||||
}
|
||||
|
||||
export default function UserForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: UserFormProps<T>) {
|
||||
const t = useTranslations('user');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(t('form.invalidEmailFormat')),
|
||||
password: z.string().optional(),
|
||||
referer_id: z.number().optional(),
|
||||
refer_code: z.string().optional(),
|
||||
is_admin: z.boolean().optional(),
|
||||
balance: z.number().optional(),
|
||||
});
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
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(100dvh-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='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.userEmail')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.userEmailPlaceholder')}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.passwordPlaceholder')}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='referer_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.refererId')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.refererIdPlaceholder')}
|
||||
{...field}
|
||||
type='number'
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='refer_code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.inviteCode')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.inviteCodePlaceholder')}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='balance'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.balance')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
prefix='¥'
|
||||
placeholder={t('form.balancePlaceholder')}
|
||||
type='number'
|
||||
{...field}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
min={0}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='is_admin'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.manager')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='pt-2'>
|
||||
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Providers from '@/components/providers';
|
||||
import { geistMono, geistSans } from '@/config/fonts';
|
||||
import { currentUser } from '@/services/admin/user';
|
||||
import { getGlobalConfig } from '@/services/common/common';
|
||||
import '@shadcn/ui/globals.css';
|
||||
import { Toaster } from '@shadcn/ui/sonner';
|
||||
import { Metadata } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
import { cookies } from 'next/headers';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import React from 'react';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getGlobalConfig({ skipErrorHandler: true }).then((res) => res.data.data!);
|
||||
const site = config.site || {};
|
||||
return {
|
||||
title: {
|
||||
default: `${site.site_name}`,
|
||||
template: `%s | ${site.site_name}`,
|
||||
},
|
||||
description: site.site_desc,
|
||||
icons: {
|
||||
icon: site.site_logo
|
||||
? [
|
||||
{
|
||||
url: site.site_logo,
|
||||
sizes: 'any',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
let config, user;
|
||||
|
||||
try {
|
||||
config = await getGlobalConfig({ skipErrorHandler: true }).then((res) => res.data.data);
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
try {
|
||||
user = await currentUser({
|
||||
skipErrorHandler: true,
|
||||
Authorization: (await cookies()).get('Authorization')?.value,
|
||||
}).then((res) => res.data.data);
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<head>
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
<body
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} size-full min-h-[calc(100dvh-env(safe-area-inset-top))] antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NextTopLoader showSpinner={false} />
|
||||
<Providers common={{ ...config }} user={user}>
|
||||
<Toaster richColors closeButton />
|
||||
{children}
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { formatBytes, unitConversion } from '@repo/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
type DisplayType = 'currency' | 'traffic' | 'number';
|
||||
|
||||
interface DisplayProps<T> {
|
||||
value?: T;
|
||||
unlimited?: boolean;
|
||||
type?: DisplayType;
|
||||
}
|
||||
|
||||
export function Display<T extends number | undefined | null>({
|
||||
value = 0,
|
||||
unlimited = false,
|
||||
type = 'number',
|
||||
}: DisplayProps<T>): string {
|
||||
const t = useTranslations('common');
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
|
||||
if (type === 'currency') {
|
||||
const formattedValue = `${currency?.currency_symbol ?? ''}${unitConversion('centsToDollars', value as number)?.toFixed(2) ?? '0.00'}`;
|
||||
return formattedValue;
|
||||
}
|
||||
|
||||
if (['traffic', 'number'].includes(type) && unlimited && !value) {
|
||||
return t('unlimited');
|
||||
}
|
||||
|
||||
if (type === 'traffic') {
|
||||
return value ? formatBytes(value as number) : '0';
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
return value ? value.toString() : '0';
|
||||
}
|
||||
|
||||
return '0';
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
import { findNavByUrl } from '@/config/navs';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@shadcn/ui/breadcrumb';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { SidebarTrigger } from '@shadcn/ui/sidebar';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import LanguageSwitch from './language-switch';
|
||||
import ThemeSwitch from './theme-switch';
|
||||
import { UserNav } from './user-nav';
|
||||
|
||||
export function Header() {
|
||||
const t = useTranslations('menu');
|
||||
const pathname = usePathname();
|
||||
const items = useMemo(() => findNavByUrl(pathname), [pathname]);
|
||||
return (
|
||||
<header className='bg-background sticky top-0 z-50 flex h-14 shrink-0 items-center gap-2'>
|
||||
<div className='flex flex-1 items-center gap-2 px-3'>
|
||||
<SidebarTrigger />
|
||||
<Separator orientation='vertical' className='mr-2 h-4' />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<Fragment key={item?.title}>
|
||||
{index !== items.length - 1 && (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={item?.url || '/dashboard'}>
|
||||
{t(item?.title)}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
{index < items.length - 1 && <BreadcrumbSeparator />}
|
||||
{index === items.length - 1 && <BreadcrumbPage>{t(item?.title)}</BreadcrumbPage>}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 px-3'>
|
||||
<LanguageSwitch />
|
||||
<ThemeSwitch />
|
||||
<UserNav />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { locales } from '@/config/constants';
|
||||
import { setLocale } from '@/utils/common';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { getCountry } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@shadcn/ui/dropdown-menu';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LanguageSwitch() {
|
||||
const locale = useLocale();
|
||||
const country = getCountry(locale);
|
||||
const t = useTranslations('language');
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon'>
|
||||
<Icon icon={`flagpack:${country?.alpha2.toLowerCase()}`} className='!size-5' />
|
||||
<span className='sr-only'>Switch Language</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{locales.map(getCountry).map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={`${item?.lang}-${item?.alpha2}`}
|
||||
onClick={() => {
|
||||
setLocale(`${item?.lang}-${item?.alpha2}`);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon icon={`flagpack:${item?.alpha2.toLowerCase()}`} />
|
||||
{t(`${item?.lang}-${item?.alpha2}`)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
import { ProTable as _ProTable, ProTableProps } from '@repo/ui/pro-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export { type ProTableActions } from '@repo/ui/pro-table';
|
||||
export function ProTable<TData, TValue extends Record<string, unknown>>(
|
||||
props: ProTableProps<TData, TValue>,
|
||||
) {
|
||||
const t = useTranslations('common.table');
|
||||
return (
|
||||
<_ProTable
|
||||
{...props}
|
||||
texts={{
|
||||
actions: t('actions'),
|
||||
asc: t('asc'),
|
||||
desc: t('desc'),
|
||||
hide: t('hide'),
|
||||
selectedRowsText(total) {
|
||||
return t('selectedItems', { total });
|
||||
},
|
||||
textPageOf(current, total) {
|
||||
return t('pageInfo', { current, total });
|
||||
},
|
||||
textRowsPerPage: t('rowsPerPage'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore, { GlobalStore } from '@/config/use-global';
|
||||
import { Logout } from '@/utils/common';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function Providers({
|
||||
children,
|
||||
common,
|
||||
user,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
common: Partial<GlobalStore['common']>;
|
||||
user: GlobalStore['user'];
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { setCommon, setUser } = useGlobalStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setUser(user);
|
||||
} else {
|
||||
Logout();
|
||||
}
|
||||
}, [setUser, user]);
|
||||
|
||||
useEffect(() => {
|
||||
setCommon(common);
|
||||
}, [setCommon, common]);
|
||||
|
||||
return (
|
||||
<NextThemesProvider attribute='class' defaultTheme='system' enableSystem>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
import { navs } from '@/config/navs';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Icon } from '@iconify/react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@shadcn/ui/sidebar';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { common } = useGlobalStore();
|
||||
const { site } = common;
|
||||
const t = useTranslations('menu');
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Sidebar className='border-r-0' collapsible='icon' {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size='lg' asChild>
|
||||
<Link href='/'>
|
||||
<div className='flex aspect-square size-8 items-center justify-center rounded-lg'>
|
||||
<Image
|
||||
src={site.site_logo || '/favicon.svg'}
|
||||
alt='logo'
|
||||
width={48}
|
||||
height={48}
|
||||
className='size-full'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{site.site_name}</span>
|
||||
<span className='truncate text-xs'>{site.site_desc}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarMenu>
|
||||
{navs.map((nav) => (
|
||||
<SidebarGroup key={nav.title}>
|
||||
{nav.items && <SidebarGroupLabel>{t(nav.title)}</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{(nav.items || [nav]).map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={t(item.title)}
|
||||
isActive={item.url === pathname}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <Icon icon={item.icon} />}
|
||||
<span>{t(item.title)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { MoonIcon, SunIcon } from '@repo/ui/lotties';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@shadcn/ui/dropdown-menu';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
export default function ThemeSwitch() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon'>
|
||||
<MoonIcon className='size-8 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
|
||||
<SunIcon className='roate-90 absolute size-8 scale-0 transition-all dark:rotate-0 dark:scale-100' />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Logout } from '@/utils/common';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@shadcn/ui/dropdown-menu';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function UserNav() {
|
||||
const t = useTranslations('auth');
|
||||
const { user } = useGlobalStore();
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size='icon' variant='default'>
|
||||
<Avatar className='size-8'>
|
||||
<AvatarImage alt={user?.avatar ?? ''} src={user?.avatar ?? ''} />
|
||||
<AvatarFallback className='rounded-none bg-transparent'>
|
||||
{user?.email?.[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent forceMount align='end' className='w-56'>
|
||||
<DropdownMenuLabel className='font-normal'>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<p className='text-sm font-medium leading-none'>{user?.email}</p>
|
||||
{/* <p className='text-xs leading-none text-muted-foreground'>ID: {user?.id}</p> */}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
{/* <DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
Profile
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Billing
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Settings
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>New Team</DropdownMenuItem>
|
||||
</DropdownMenuGroup> */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={Logout}>
|
||||
{t('logout')}
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
export const locales = ['en-US', 'zh-CN'];
|
||||
|
||||
export const NEXT_PUBLIC_DEFAULT_LANGUAGE = env('NEXT_PUBLIC_DEFAULT_LANGUAGE');
|
||||
|
||||
export const NEXT_PUBLIC_SITE_URL = env('NEXT_PUBLIC_SITE_URL');
|
||||
export const NEXT_PUBLIC_API_URL = env('NEXT_PUBLIC_API_URL');
|
||||
|
||||
export const NEXT_PUBLIC_DEFAULT_USER_EMAIL = env('NEXT_PUBLIC_DEFAULT_USER_EMAIL');
|
||||
export const NEXT_PUBLIC_DEFAULT_USER_PASSWORD = env('NEXT_PUBLIC_DEFAULT_USER_PASSWORD');
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
import localFont from 'next/font/local';
|
||||
|
||||
export const geistSans = localFont({
|
||||
src: './GeistVF.woff',
|
||||
variable: '--font-geist-sans',
|
||||
weight: '100 900',
|
||||
});
|
||||
export const geistMono = localFont({
|
||||
src: './GeistMonoVF.woff',
|
||||
variable: '--font-geist-mono',
|
||||
weight: '100 900',
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
export const navs = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
url: '/dashboard',
|
||||
icon: 'flat-color-icons:globe',
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
items: [
|
||||
{
|
||||
title: 'System Config',
|
||||
url: '/dashboard/system',
|
||||
icon: 'flat-color-icons:services',
|
||||
},
|
||||
{
|
||||
title: 'Payment Config',
|
||||
url: '/dashboard/payment',
|
||||
icon: 'flat-color-icons:currency-exchange',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Server',
|
||||
items: [
|
||||
{
|
||||
title: 'Server Management',
|
||||
url: '/dashboard/server',
|
||||
icon: 'flat-color-icons:data-protection',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Finance',
|
||||
items: [
|
||||
{
|
||||
title: 'Subscribe Management',
|
||||
url: '/dashboard/subscribe',
|
||||
icon: 'flat-color-icons:shop',
|
||||
},
|
||||
{
|
||||
title: 'Order Management',
|
||||
url: '/dashboard/order',
|
||||
icon: 'flat-color-icons:todo-list',
|
||||
},
|
||||
{
|
||||
title: 'Coupon Management',
|
||||
url: '/dashboard/coupon',
|
||||
icon: 'flat-color-icons:bookmark',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'User',
|
||||
items: [
|
||||
{
|
||||
title: 'User Management',
|
||||
url: '/dashboard/user',
|
||||
icon: 'flat-color-icons:conference-call',
|
||||
},
|
||||
{
|
||||
title: 'Announcement Management',
|
||||
url: '/dashboard/announcement',
|
||||
icon: 'flat-color-icons:news',
|
||||
},
|
||||
{
|
||||
title: 'Ticket Management',
|
||||
url: '/dashboard/ticket',
|
||||
icon: 'flat-color-icons:collaboration',
|
||||
},
|
||||
{
|
||||
title: 'Document Management',
|
||||
url: '/dashboard/document',
|
||||
icon: 'flat-color-icons:document',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function findNavByUrl(url: string) {
|
||||
for (const nav of navs) {
|
||||
if (nav.url && nav.url === url) {
|
||||
return [nav];
|
||||
}
|
||||
if (nav.items) {
|
||||
const current = nav.items.find((item) => item.url === url);
|
||||
if (current) {
|
||||
return [nav, current];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface GlobalStore {
|
||||
common: API.GetGlobalConfigResponse;
|
||||
user?: API.User;
|
||||
setCommon: (common: Partial<API.GetGlobalConfigResponse>) => void;
|
||||
setUser: (user?: API.User) => void;
|
||||
}
|
||||
|
||||
export const useGlobalStore = create<GlobalStore>((set) => ({
|
||||
common: {
|
||||
site: {
|
||||
host: '',
|
||||
site_name: '',
|
||||
site_desc: '',
|
||||
site_logo: '',
|
||||
},
|
||||
verify: {
|
||||
enable_login_verify: false,
|
||||
enable_register_verify: false,
|
||||
enable_reset_password_verify: false,
|
||||
turnstile_site_key: '',
|
||||
},
|
||||
register: {
|
||||
stop_register: false,
|
||||
enable_email_verify: false,
|
||||
enable_email_domain_suffix: false,
|
||||
email_domain_suffix_list: '',
|
||||
},
|
||||
invite: {
|
||||
forced_invite: false,
|
||||
},
|
||||
currency: {
|
||||
currency_unit: 'USD',
|
||||
currency_symbol: '$',
|
||||
},
|
||||
subscribe: {
|
||||
single_model: false,
|
||||
subscribe_path: '',
|
||||
subscribe_domain: '',
|
||||
pan_domain: false,
|
||||
},
|
||||
},
|
||||
user: undefined,
|
||||
setCommon: (common) =>
|
||||
set((state) => ({
|
||||
common: {
|
||||
...state.common,
|
||||
...common,
|
||||
},
|
||||
})),
|
||||
setUser: (user) => set({ user }),
|
||||
}));
|
||||
|
||||
export default useGlobalStore;
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"actions": "Actions",
|
||||
"announcementList": "Announcement List",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"content": "Content",
|
||||
"create": "Create",
|
||||
"createAnnouncement": "Create Announcement",
|
||||
"createSuccess": "Create Success",
|
||||
"delete": "Delete",
|
||||
"deleteDescription": "Once deleted, the data cannot be recovered. Please proceed with caution.",
|
||||
"deleteSuccess": "Delete Success",
|
||||
"edit": "Edit",
|
||||
"editAnnouncement": "Edit Announcement",
|
||||
"enable": "Enable",
|
||||
"form": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"content": "Content",
|
||||
"contentPlaceholder": "Please enter announcement content (supports Markdown)",
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "Please enter"
|
||||
},
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"title": "Title",
|
||||
"updateSuccess": "Update Success",
|
||||
"updatedAt": "Updated At"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"check": {
|
||||
"checking": "Checking...",
|
||||
"continue": "Continue",
|
||||
"description": "Please enter your email to continue logging in or registering.",
|
||||
"email": "Please enter a valid email address.",
|
||||
"title": "Login/Register",
|
||||
"whitelist": "The email domain is not in the allowed whitelist."
|
||||
},
|
||||
"login": {
|
||||
"description": "Please enter your account information to log in.",
|
||||
"forgotPassword": "Forgot Password?",
|
||||
"success": "Login successful!",
|
||||
"switchAccount": "Register/Switch Account",
|
||||
"title": "Login"
|
||||
},
|
||||
"logout": "Logout",
|
||||
"orWithEmail": "Or with Email",
|
||||
"register": {
|
||||
"description": "Create a new account, fill in your information to register.",
|
||||
"existingAccount": "Already have an account?",
|
||||
"get": "Get",
|
||||
"invite": "Invitation code",
|
||||
"message": "#### Dear User, Hello!\n\nThank you for your attention and support. Due to adjustments in site operation strategy, we have closed the new user registration function. During this period, existing users will not be affected.\n\nWe are committed to providing you with better service and experience, so we will conduct comprehensive system optimization and feature upgrades during the registration closure. In the future, we will welcome you with better content and services.\n\nPlease follow our website and social media platforms to get the latest updates and notifications. Thank you for your understanding and support.\n\nIf you have any questions or need assistance, please feel free to contact our customer service team.\n\n**Thank you again for your support and understanding.**",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"success": "Registration successful, automatically logged in!",
|
||||
"switchToLogin": "Login/Reset Email",
|
||||
"title": "Register"
|
||||
},
|
||||
"reset": {
|
||||
"description": "Please enter your email address to reset your password.",
|
||||
"existingAccount": "Already have an account?",
|
||||
"get": "Get",
|
||||
"success": "Password reset successful, automatically switched to login!",
|
||||
"switchToLogin": "Login/Register",
|
||||
"title": "Reset Password"
|
||||
},
|
||||
"tos": "Terms of Service"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"empty": [
|
||||
"Imagine this place filled with exciting content! For now, you'll have to use your imagination...",
|
||||
"This area mysteriously disappeared, but we're summoning it back!",
|
||||
"Oh no, nothing happened... Feel free to fill in the blank!",
|
||||
"It's like being at a concert and realizing there's no one on stage... Want to go up and perform?",
|
||||
"You've found a blank canvas! How about building a house?",
|
||||
"This area is currently empty, but creativity starts here!",
|
||||
"Nothing here... but don't worry, it's just the beginning!",
|
||||
"There were supposed to be big surprises here, but they slipped away!",
|
||||
"There's nothing here for now, like an empty snack cabinet.",
|
||||
"This empty space is waiting for its star to take the stage!"
|
||||
],
|
||||
"request": {
|
||||
"10001": "Database query failed, please try again later",
|
||||
"10002": "Data update failed, please try again later",
|
||||
"10003": "Data save failed, please try again later",
|
||||
"10004": "Data deletion failed, please try again later",
|
||||
"20001": "The username is already registered",
|
||||
"20002": "User not found",
|
||||
"20003": "Incorrect password, please try again",
|
||||
"20004": "Your account has been disabled",
|
||||
"30001": "Node already exists",
|
||||
"30002": "Node not found",
|
||||
"30003": "Node group already exists",
|
||||
"30004": "Node group not found",
|
||||
"30005": "There is data in the node group, cannot be deleted",
|
||||
"400": "Invalid request parameters, please check and resubmit",
|
||||
"40002": "Please retry after logging in",
|
||||
"40003": "Invalid login information, please log in again",
|
||||
"40004": "Login session expired, please log in again",
|
||||
"401": "Request is too frequent, please try again later",
|
||||
"500": "Internal server error, we are working on fixing it urgently",
|
||||
"undefined": "System error occurred, please try again later"
|
||||
},
|
||||
"table": {
|
||||
"actions": "Actions",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending",
|
||||
"hide": "Hide",
|
||||
"pageInfo": "Page {current} of {total}",
|
||||
"rowsPerPage": "Rows per page",
|
||||
"selectedItems": "Selected {total} rows"
|
||||
},
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"actions": "Actions",
|
||||
"amount": "Amount",
|
||||
"cancel": "Cancel",
|
||||
"code": "Code",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"count": "Count",
|
||||
"couponList": "Coupon List",
|
||||
"create": "Create",
|
||||
"createCoupon": "Create Coupon",
|
||||
"createSuccess": "Create Success",
|
||||
"delete": "Delete",
|
||||
"deleteSuccess": "Delete Success",
|
||||
"deleteWarning": "Once deleted, data cannot be recovered. Please proceed with caution.",
|
||||
"discount": "Discount",
|
||||
"edit": "Edit",
|
||||
"editCoupon": "Edit Coupon",
|
||||
"enable": "Enable",
|
||||
"form": {
|
||||
"amountDiscount": "Amount Discount",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"count": "Max Usage Count",
|
||||
"countPlaceholder": "Max Usage Count (leave blank for no limit)",
|
||||
"customCouponCode": "Custom Coupon Code",
|
||||
"customCouponCodePlaceholder": "Custom Coupon Code (leave blank for auto-generation)",
|
||||
"enterCouponName": "Enter Coupon Name",
|
||||
"enterValue": "Enter Value",
|
||||
"expireTime": "Expire Time",
|
||||
"name": "Name",
|
||||
"percentageDiscount": "Percentage Discount",
|
||||
"selectServer": "Select Subscription",
|
||||
"selectTime": "Select Time",
|
||||
"specifiedServer": "Specified Subscription",
|
||||
"startTime": "Start Time",
|
||||
"type": "Coupon Type",
|
||||
"userLimit": "Max Usage Count per User",
|
||||
"userLimitPlaceholder": "Max Usage Count per User (leave blank for no limit)"
|
||||
},
|
||||
"name": "Name",
|
||||
"percentage": "Percentage",
|
||||
"remainingTimes": "Remaining",
|
||||
"subscribe": "Subscribe",
|
||||
"type": "Type",
|
||||
"unlimited": "Unlimited",
|
||||
"updateSuccess": "Update Success",
|
||||
"usedTimes": "Usage Times",
|
||||
"validityPeriod": "Validity Period"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"DocumentList": "Document List",
|
||||
"actions": "Actions",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"create": "Create",
|
||||
"createDocument": "Create Document",
|
||||
"createSuccess": "Create Success",
|
||||
"delete": "Delete",
|
||||
"deleteDescription": "Once deleted, the data cannot be recovered. Please proceed with caution.",
|
||||
"deleteSuccess": "Delete Success",
|
||||
"edit": "Edit",
|
||||
"editDocument": "Edit Document",
|
||||
"form": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"content": "Content",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "Use Enter or comma (,) to enter multiple tags",
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "Please enter"
|
||||
},
|
||||
"show": "Show",
|
||||
"tags": "Tags",
|
||||
"title": "Title",
|
||||
"updateSuccess": "Update Success",
|
||||
"updatedAt": "Updated At"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"cs-CZ": "Czech",
|
||||
"de-DE": "German",
|
||||
"en-US": "English",
|
||||
"es-ES": "Spanish",
|
||||
"es-MX": "Spanish (Mexico)",
|
||||
"fi-FI": "Finnish",
|
||||
"fr-FR": "French",
|
||||
"hi-IN": "Hindi",
|
||||
"hu-HU": "Hungarian",
|
||||
"ja-JP": "Japanese",
|
||||
"ko-KR": "Korean",
|
||||
"no-NO": "Norwegian",
|
||||
"pl-PL": "Polish",
|
||||
"pt-BR": "Portuguese (Brazil)",
|
||||
"ro-RO": "Romanian",
|
||||
"ru-RU": "Russian",
|
||||
"th-TH": "Thai",
|
||||
"tr-TR": "Turkish",
|
||||
"uk-UA": "Ukrainian",
|
||||
"vi-VN": "Vietnamese",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"zh-TW": "Chinese (Traditional)"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Announcement Management": "Announcement Management",
|
||||
"Coupon Management": "Coupon Management",
|
||||
"Dashboard": "Dashboard",
|
||||
"Document Management": "Document Management",
|
||||
"Finance": "Finance",
|
||||
"Order Management": "Order Management",
|
||||
"Payment Config": "Payment Config",
|
||||
"Server": "Server",
|
||||
"Server Management": "Server Management",
|
||||
"Settings": "Settings",
|
||||
"Subscribe Management": "Subscribe Management",
|
||||
"System Config": "System Config",
|
||||
"Ticket Management": "Ticket Management",
|
||||
"User": "User",
|
||||
"User Management": "User Management"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"amount": "Amount",
|
||||
"failed": "Failed",
|
||||
"feAmount": "Fee Amount",
|
||||
"method": "Payment Method",
|
||||
"methods": {
|
||||
"alipay_f2f": "Alipay (Face to Face)",
|
||||
"balance": "Balance",
|
||||
"epay": "Epay",
|
||||
"stripe_alipay": "Stripe (Alipay)",
|
||||
"stripe_wechat_pay": "Stripe (WeChat)"
|
||||
},
|
||||
"orderNumber": "Order Number",
|
||||
"reductionPrice": "Discount Amount",
|
||||
"status": "Order Status",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribePrice": "Subscription Price",
|
||||
"total": "Total",
|
||||
"tradeNo": "Transaction Number",
|
||||
"type": {
|
||||
"0": "Type",
|
||||
"1": "New Purchase",
|
||||
"2": "Renewal",
|
||||
"3": "Reset Traffic",
|
||||
"4": "Recharge"
|
||||
},
|
||||
"updateTime": "Update Time",
|
||||
"user": "User"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"aliPay": "Alipay payment",
|
||||
"alipayf2f": {
|
||||
"appId": "Alipay App ID",
|
||||
"invoiceName": "Custom Product Name",
|
||||
"invoiceNameDescription": "Will be displayed on Alipay bill",
|
||||
"privateKey": "Alipay Private Key",
|
||||
"publicKey": "Alipay Public Key"
|
||||
},
|
||||
"enable": "Enable",
|
||||
"enableDescription": "Enable this payment method",
|
||||
"epay": {
|
||||
"key": "KEY",
|
||||
"pid": "PID",
|
||||
"url": "URL"
|
||||
},
|
||||
"feeMode": "Fee Mode",
|
||||
"feeModeDescription": "Fee charging method for additional fees",
|
||||
"feeModeItems": ["No Fee", "Percentage", "Fixed Amount", "Both"],
|
||||
"feePercent": "Percentage Fee (optional)",
|
||||
"feePercentDescription": "Add fee on top of order amount",
|
||||
"fixedFee": "Fixed Fee (optional)",
|
||||
"fixedFeeDescription": "Add fixed fee on top of order amount",
|
||||
"iconUrl": "Icon URL (optional)",
|
||||
"iconUrlDescription": "Used for frontend display",
|
||||
"inputPlaceholder": "Please enter",
|
||||
"key": "KEY",
|
||||
"notifyUrl": "Custom Notification Domain (optional)",
|
||||
"notifyUrlDescription": "Gateway notifications will be sent to this domain",
|
||||
"pid": "PID",
|
||||
"saveSuccess": "Save Successful",
|
||||
"showName": "Display Name",
|
||||
"showNameDescription": "Used for frontend display",
|
||||
"stripe": {
|
||||
"publicKey": "PUBLIC KEY",
|
||||
"secretKey": "SECRET KEY",
|
||||
"webhookSecret": "WEBHOOK Secret Key"
|
||||
},
|
||||
"url": "URL",
|
||||
"wechatPay": "WeChat Pay"
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"group": {
|
||||
"actions": "Actions",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"create": "Create",
|
||||
"createNodeGroup": "Create Node Group",
|
||||
"createdSuccessfully": "Created successfully",
|
||||
"delete": "Delete",
|
||||
"deleteWarning": "Data will not be recoverable after deletion. Please proceed with caution.",
|
||||
"deletedSuccessfully": "Deleted successfully",
|
||||
"description": "Description",
|
||||
"edit": "Edit",
|
||||
"editNodeGroup": "Edit Node Group",
|
||||
"form": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"description": "Description",
|
||||
"name": "Name"
|
||||
},
|
||||
"name": "Name",
|
||||
"title": "Node Group List",
|
||||
"updatedAt": "Updated At"
|
||||
},
|
||||
"node": {
|
||||
"actions": "Actions",
|
||||
"address": "Address",
|
||||
"all": "All",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"create": "Create",
|
||||
"createNode": "Create Node",
|
||||
"createSuccess": "Created successfully",
|
||||
"delete": "Delete",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteWarning": "Data will not be recoverable after deletion. Please proceed with caution.",
|
||||
"edit": "Edit",
|
||||
"editNode": "Edit Node",
|
||||
"enable": "Enable",
|
||||
"form": {
|
||||
"Address or IP": "Address or IP",
|
||||
"Host Name": "Host Name",
|
||||
"allowInsecure": "Allow Insecure",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"edit": "Edit",
|
||||
"editSecurity": "Edit Security Configuration",
|
||||
"enableRelay": "Enable Relay",
|
||||
"enableTLS": "Enable TLS",
|
||||
"enableTls": "TLS",
|
||||
"encryptionMethod": "Encryption Method",
|
||||
"name": "Name",
|
||||
"networkType": "Transport Protocol",
|
||||
"nodeGroupId": "Node Group ID",
|
||||
"password": "Password",
|
||||
"path": "Path",
|
||||
"port": "Server Port",
|
||||
"protocol": "Protocol",
|
||||
"relayHost": "Relay Host",
|
||||
"relayPort": "Relay Port",
|
||||
"remarks": "Remarks",
|
||||
"security": "Security",
|
||||
"security_config": {
|
||||
"fingerprint": "Fingerprint",
|
||||
"privateKey": "Private Key",
|
||||
"privateKeyPlaceholder": "Leave blank for auto-generation",
|
||||
"proxyProtocol": "Proxy Protocol",
|
||||
"publicKey": "Public Key",
|
||||
"publicKeyPlaceholder": "Leave blank for auto-generation",
|
||||
"serverAddress": "Server Address",
|
||||
"serverAddressPlaceholder": "REALITY target address, default using SNI",
|
||||
"serverName": "Server Name (SNI)",
|
||||
"serverNamePlaceholder": "REALITY required, consistent with backend",
|
||||
"serverPort": "Server Port",
|
||||
"serverPortPlaceholder": "REALITY target port, default 443",
|
||||
"shortId": "Short ID",
|
||||
"shortIdPlaceholder": "Leave blank for auto-generation"
|
||||
},
|
||||
"selectEncryptionMethod": "Select Encryption Method",
|
||||
"selectNodeGroup": "Select Node Group",
|
||||
"selectProtocol": "Select Protocol",
|
||||
"serverAddr": "Server IP",
|
||||
"serverName": "Service Name",
|
||||
"serviceName": "Service Name",
|
||||
"sni": "Server Name Indication (SNI)",
|
||||
"speedLimit": "Speed Limit",
|
||||
"speedLimitPlaceholder": "Unlimited",
|
||||
"trafficRatio": "Traffic Rate",
|
||||
"transport": "Transport Protocol Configuration",
|
||||
"xtls": "XTLS Flow Control Algorithm"
|
||||
},
|
||||
"name": "Name",
|
||||
"node": "Node",
|
||||
"nodeGroup": "Node Group",
|
||||
"rate": "Rate",
|
||||
"relay": "Relay",
|
||||
"serverAddr": "Server IP",
|
||||
"speedLimit": "Speed Limit",
|
||||
"status": "Status",
|
||||
"trafficRatio": "Traffic Rate",
|
||||
"type": "Type",
|
||||
"updateSuccess": "Update successful",
|
||||
"updatedAt": "Updated At"
|
||||
},
|
||||
"tabs": {
|
||||
"node": "Node",
|
||||
"nodeGroup": "Node Group"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"actions": "Actions",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"create": "Create",
|
||||
"createSubscribe": "Create Subscription",
|
||||
"createSuccess": "Create Successful",
|
||||
"delete": "Delete",
|
||||
"deleteSuccess": "Delete Successful",
|
||||
"deleteWarning": "Data cannot be recovered after deletion. Please proceed with caution.",
|
||||
"deviceLimit": "Device Limit/Unit",
|
||||
"edit": "Edit",
|
||||
"editSubscribe": "Edit Subscription",
|
||||
"form": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"description": "Description",
|
||||
"deviceLimit": "Device Limit",
|
||||
"discount": "Discount",
|
||||
"discountDescription": "Set discount based on unit price",
|
||||
"discountMonths": "Months",
|
||||
"discountPercent": "Discount Percentage",
|
||||
"duration": "Duration (months)",
|
||||
"inventory": "Inventory",
|
||||
"name": "Name",
|
||||
"noLimit": "No Limit",
|
||||
"quota": "Purchase Limit",
|
||||
"replacement": "Reset Price (per time)",
|
||||
"selectSubscribeGroup": "Select Subscription Group",
|
||||
"server": "Server",
|
||||
"serverGroup": "Server Group",
|
||||
"speedLimit": "Speed Limit (Mbps)",
|
||||
"subscribeGroup": "Subscription Group",
|
||||
"traffic": "Traffic",
|
||||
"unit_price": "Monthly Price"
|
||||
},
|
||||
"group": {
|
||||
"actions": "Actions",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"create": "Create",
|
||||
"createSubscribeGroup": "Create Subscription Group",
|
||||
"createSuccess": "Create Successful",
|
||||
"delete": "Delete",
|
||||
"deleteSuccess": "Delete Successful",
|
||||
"deleteWarning": "Data cannot be recovered after deletion. Please proceed with caution.",
|
||||
"description": "Description",
|
||||
"edit": "Edit",
|
||||
"editSubscribeGroup": "Edit Subscription Group",
|
||||
"form": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"description": "Description",
|
||||
"name": "Name"
|
||||
},
|
||||
"name": "Name",
|
||||
"title": "Subscription Group List",
|
||||
"updateSuccess": "Update Successful",
|
||||
"updatedAt": "Updated At"
|
||||
},
|
||||
"inventory": "Inventory",
|
||||
"name": "Name",
|
||||
"quota": "Purchase Limit/Time",
|
||||
"replacement": "Reset Price/Time",
|
||||
"sell": "Sell",
|
||||
"show": "Show",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribeGroup": "Subscription Group",
|
||||
"tabs": {
|
||||
"subscribe": "Subscribe",
|
||||
"subscribeGroup": "Subscription Group"
|
||||
},
|
||||
"traffic": "Traffic",
|
||||
"unitPrice": "Price/Month",
|
||||
"updateSuccess": "Update Successful"
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"currency": {
|
||||
"accessKey": "Access Key",
|
||||
"accessKeyDescription": "API key provided for free by https://exchangerate.host",
|
||||
"currencySymbol": "Currency Symbol",
|
||||
"currencySymbolDescription": "Used for display purposes only; changing this will affect all currency units in the system",
|
||||
"currencyUnit": "Currency Unit",
|
||||
"currencyUnitDescription": "Used for display purposes only; changing this will affect all currency units in the system",
|
||||
"saveSuccess": "Save Successful"
|
||||
},
|
||||
"email": {
|
||||
"expiration_email_template": "Expiration Notice Template",
|
||||
"expiration_email_templateDescription": "The {after}.variable{before} placeholders will be replaced with actual data. Ensure to keep these variables.",
|
||||
"inputPlaceholder": "Enter value...",
|
||||
"maintenance_email_template": "Maintenance Notice Template",
|
||||
"maintenance_email_templateDescription": "The {after}.variable{before} placeholders will be replaced with actual data. Ensure to keep these variables.",
|
||||
"saveSuccess": "Configuration saved successfully.",
|
||||
"sendFailure": "Failed to send test email, please check the configuration.",
|
||||
"sendSuccess": "Test email sent successfully.",
|
||||
"sendTestEmail": "Send Test Email",
|
||||
"sendTestEmailDescription": "Send a test email to verify the configuration.",
|
||||
"senderAddress": "Sender Address",
|
||||
"senderAddressDescription": "The default email address used for sending emails.",
|
||||
"smtpAccount": "SMTP Account",
|
||||
"smtpAccountDescription": "The email account used for authentication.",
|
||||
"smtpEncryptionMethod": "SMTP Encryption Method",
|
||||
"smtpEncryptionMethodDescription": "Choose whether to enable SSL encryption.",
|
||||
"smtpPassword": "SMTP Password",
|
||||
"smtpPasswordDescription": "The password for the SMTP account.",
|
||||
"smtpServerAddress": "SMTP Server Address",
|
||||
"smtpServerAddressDescription": "Specify the server address used for sending emails.",
|
||||
"smtpServerPort": "SMTP Server Port",
|
||||
"smtpServerPortDescription": "Specify the port used to connect to the SMTP server.",
|
||||
"verify_email_template": "Verification Email Template",
|
||||
"verify_email_templateDescription": "The {after}.variable{before} placeholders will be replaced with actual data. Ensure to keep these variables."
|
||||
},
|
||||
"invite": {
|
||||
"commissionFirstTimeOnly": "Commission for First Purchase Only",
|
||||
"commissionFirstTimeOnlyDescription": "When enabled, commission is generated only on the inviter's first payment; you can configure individual users in user management",
|
||||
"enableForcedInvite": "Enable Forced Invite",
|
||||
"enableForcedInviteDescription": "When enabled, only invited users can register",
|
||||
"inputPlaceholder": "Enter",
|
||||
"inviteCommissionPercentage": "Invite Commission Percentage",
|
||||
"inviteCommissionPercentageDescription": "Default global commission distribution ratio; you can configure individual ratios in user management",
|
||||
"saveSuccess": "Save Successful"
|
||||
},
|
||||
"node": {
|
||||
"communicationKey": "Communication Key",
|
||||
"communicationKeyDescription": "Key for node communication to ensure data security",
|
||||
"inputPlaceholder": "Enter",
|
||||
"nodePullInterval": "Node Pull Interval",
|
||||
"nodePullIntervalDescription": "Frequency (in seconds) for nodes to fetch data from the panel",
|
||||
"nodePushInterval": "Node Push Interval",
|
||||
"nodePushIntervalDescription": "Frequency for nodes to push data to the panel",
|
||||
"saveSuccess": "Save Successful"
|
||||
},
|
||||
"register": {
|
||||
"emailSuffixWhitelist": "Email Suffix Whitelist",
|
||||
"emailSuffixWhitelistDescription": "When enabled, only emails with suffixes in the list can register",
|
||||
"emailVerification": "Email Verification",
|
||||
"emailVerificationDescription": "When enabled, users will need to verify their email",
|
||||
"ipRegistrationLimit": "IP Registration Limit",
|
||||
"ipRegistrationLimitDescription": "When enabled, IPs that meet the rule requirements will be restricted from registering; note that IP determination may cause issues due to CDNs or frontend proxies",
|
||||
"penaltyTime": "Penalty Time (minutes)",
|
||||
"penaltyTimeDescription": "Users must wait for the penalty time to expire before registering again",
|
||||
"registrationLimitCount": "Registration Limit Count",
|
||||
"registrationLimitCountDescription": "Enable penalty after reaching registration limit",
|
||||
"saveSuccess": "Save Successful",
|
||||
"stopNewUserRegistration": "Stop New User Registration",
|
||||
"stopNewUserRegistrationDescription": "When enabled, no one can register",
|
||||
"trialRegistration": "Trial Registration",
|
||||
"trialRegistrationDescription": "Enable trial registration; modify trial package and duration first",
|
||||
"whitelistSuffixes": "Whitelist Suffixes",
|
||||
"whitelistSuffixesDescription": "Used for email verification during registration; one per line",
|
||||
"whitelistSuffixesPlaceholder": "Enter email suffixes, one per line"
|
||||
},
|
||||
"site": {
|
||||
"logo": "Logo",
|
||||
"logoDescription": "Used for displaying the logo in designated locations",
|
||||
"logoPlaceholder": "Enter the URL of the logo, without ending with '/'",
|
||||
"saveSuccess": "Save Successful",
|
||||
"siteDesc": "Site Description",
|
||||
"siteDescDescription": "Used for displaying the site description in designated locations",
|
||||
"siteDescPlaceholder": "Enter site description",
|
||||
"siteDomain": "Site Domain",
|
||||
"siteDomainDescription": "Domain address of the current website, e.g., used in emails",
|
||||
"siteDomainPlaceholder": "Please enter the domain address. For multiple domains, please enter one per line.",
|
||||
"siteName": "Site Name",
|
||||
"siteNameDescription": "Used for displaying the site name in designated locations",
|
||||
"siteNamePlaceholder": "Enter site name"
|
||||
},
|
||||
"subscription": {
|
||||
"add": "Add",
|
||||
"app": "App",
|
||||
"appDescription": "Configure app download URL and import rules; remember to save after modification",
|
||||
"appDownloadURL": "App Download URL",
|
||||
"appIcon": "App Icon",
|
||||
"appName": "App Name",
|
||||
"delete": "Delete",
|
||||
"reset": "Reset",
|
||||
"save": "Save",
|
||||
"saveSuccess": "Save Successful",
|
||||
"singleSubscriptionMode": "Single Subscription Mode",
|
||||
"singleSubscriptionModeDescription": "When enabled, all user subscriptions will be converted to balance",
|
||||
"subscriptionDomain": "Subscription Domain",
|
||||
"subscriptionDomainDescription": "Used for subscription; leave blank to use site domain",
|
||||
"subscriptionDomainPlaceholder": "Enter subscription domain, one per line",
|
||||
"subscriptionPath": "Subscription Path",
|
||||
"subscriptionPathDescription": "Used for subscription",
|
||||
"subscriptionPathPlaceholder": "Enter",
|
||||
"subscriptionProtocol": "Subscription Protocol",
|
||||
"wildcardResolution": "Wildcard Resolution",
|
||||
"wildcardResolutionDescription": "Used for subscription"
|
||||
},
|
||||
"tabs": {
|
||||
"currency": "Currency",
|
||||
"email": "Email",
|
||||
"invite": "Invite",
|
||||
"node": "Node",
|
||||
"register": "Register",
|
||||
"site": "Site",
|
||||
"subscription": "Subscription",
|
||||
"telegram": "Telegram",
|
||||
"tos": "Terms of Service",
|
||||
"verify": "Verify"
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "Bot Token",
|
||||
"botTokenDescription": "Enter the token provided by Botfather",
|
||||
"enableBotNotifications": "Enable Bot Notifications",
|
||||
"enableBotNotificationsDescription": "When enabled, the bot will send basic notifications to linked Telegram admins and users",
|
||||
"groupURL": "Group URL",
|
||||
"groupURLDescription": "If filled, will be displayed to users or used in required locations",
|
||||
"inputPlaceholderBotToken": "0000000000:xxxxxxxxx_xxxxxxxxxxxxxxx",
|
||||
"inputPlaceholderGroupURL": "https://t.me/xxxxxx",
|
||||
"saveSuccess": "Save Successful"
|
||||
},
|
||||
"tos": {
|
||||
"saveSuccess": "Save Successful",
|
||||
"title": "Terms of Service"
|
||||
},
|
||||
"verify": {
|
||||
"inputPlaceholder": "Enter",
|
||||
"loginVerificationCode": "Login Verification Code",
|
||||
"loginVerificationCodeDescription": "Human verification during login",
|
||||
"registrationVerificationCode": "Registration Verification Code",
|
||||
"registrationVerificationCodeDescription": "Human verification during registration",
|
||||
"resetPasswordVerificationCode": "Reset Password Verification Code",
|
||||
"resetPasswordVerificationCodeDescription": "Human verification during password reset",
|
||||
"saveSuccess": "Save Successful",
|
||||
"turnstileSecret": "Turnstile Secret Key",
|
||||
"turnstileSecretDescription": "Turnstile secret key provided by Cloudflare",
|
||||
"turnstileSiteKey": "Turnstile Site Key",
|
||||
"turnstileSiteKeyDescription": "Turnstile site key provided by Cloudflare"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"actions": "Actions",
|
||||
"cancel": "Cancel",
|
||||
"check": "Check",
|
||||
"close": "Close",
|
||||
"closeSuccess": "Closed successfully",
|
||||
"closeWarning": "Once closed, the ticket cannot be operated on. Please proceed with caution.",
|
||||
"confirm": "Confirm",
|
||||
"confirmClose": "Are you sure you want to close?",
|
||||
"createdAt": "Created At",
|
||||
"inputPlaceholder": "Please enter your question, we will reply as soon as possible.",
|
||||
"open": "Open",
|
||||
"reply": "Reply",
|
||||
"status": ["Status", "Pending Follow-up", "Pending Reply", "Resolved", "Closed"],
|
||||
"ticketList": "Ticket List",
|
||||
"title": "Title",
|
||||
"updatedAt": "Updated At",
|
||||
"user": "User"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"actions": "Actions",
|
||||
"balance": "Balance",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"create": "Create",
|
||||
"createSuccess": "Create successful",
|
||||
"createUser": "Create User",
|
||||
"createdAt": "Created At",
|
||||
"delete": "Delete",
|
||||
"deleteDescription": "Data cannot be recovered after deletion, please proceed with caution.",
|
||||
"deleteSuccess": "Delete successful",
|
||||
"edit": "Edit",
|
||||
"editUser": "Edit User",
|
||||
"email": "Email",
|
||||
"enable": "Enable",
|
||||
"form": {
|
||||
"balance": "Balance",
|
||||
"balancePlaceholder": "Balance",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"invalidEmailFormat": "Invalid email format",
|
||||
"inviteCode": "Invite Code",
|
||||
"inviteCodePlaceholder": "Enter invite code (leave blank to generate)",
|
||||
"manager": "Manager",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Enter new password (optional)",
|
||||
"refererId": "Referer ID",
|
||||
"refererIdPlaceholder": "Enter referer ID",
|
||||
"userEmail": "User Email",
|
||||
"userEmailPlaceholder": "Enter user email"
|
||||
},
|
||||
"referer": "Referer",
|
||||
"updateSuccess": "Update successful",
|
||||
"userList": "User List",
|
||||
"userName": "User Email"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
|
||||
const locales = ['zh-CN', 'en-US'];
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const browserLocale = (await headers()).get('Accept-Language')?.split(',')?.[0] || '';
|
||||
const defaultLocale = locales.includes(browserLocale) ? browserLocale : '';
|
||||
const cookieLocale = (await cookies()).get('locale')?.value || '';
|
||||
|
||||
const locale =
|
||||
cookieLocale || defaultLocale || process.env.NEXT_PUBLIC_DEFAULT_LANGUAGE || locales[0];
|
||||
|
||||
const messages = {
|
||||
language: (await import(`./${locale}/language.json`)).default,
|
||||
menu: (await import(`./${locale}/menu.json`)).default,
|
||||
auth: (await import(`./${locale}/auth.json`)).default,
|
||||
common: (await import(`./${locale}/common.json`)).default,
|
||||
system: (await import(`./${locale}/system.json`)).default,
|
||||
payment: (await import(`./${locale}/payment.json`)).default,
|
||||
server: (await import(`./${locale}/server.json`)).default,
|
||||
subscribe: (await import(`./${locale}/subscribe.json`)).default,
|
||||
order: (await import(`./${locale}/order.json`)).default,
|
||||
coupon: (await import(`./${locale}/coupon.json`)).default,
|
||||
user: (await import(`./${locale}/user.json`)).default,
|
||||
announcement: (await import(`./${locale}/announcement.json`)).default,
|
||||
ticket: (await import(`./${locale}/ticket.json`)).default,
|
||||
document: (await import(`./${locale}/document.json`)).default,
|
||||
index: (await import(`./${locale}/index.json`)).default,
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getLocale } from '@/utils/common';
|
||||
import { createTranslator } from 'next-intl';
|
||||
|
||||
export async function getTranslations(namespace: string) {
|
||||
const locale = getLocale();
|
||||
const messages = (await import(`./${locale}/${namespace}.json`)).default;
|
||||
return createTranslator({ locale, messages });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"actions": "操作",
|
||||
"announcementList": "公告列表",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "您确定要删除吗?",
|
||||
"content": "内容",
|
||||
"create": "创建",
|
||||
"createAnnouncement": "创建公告",
|
||||
"createSuccess": "创建成功",
|
||||
"delete": "删除",
|
||||
"deleteDescription": "一旦删除,数据将无法恢复。请谨慎操作。",
|
||||
"deleteSuccess": "删除成功",
|
||||
"edit": "编辑",
|
||||
"editAnnouncement": "编辑公告",
|
||||
"enable": "启用",
|
||||
"form": {
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"content": "内容",
|
||||
"contentPlaceholder": "请输入公告内容(支持Markdown)",
|
||||
"title": "标题",
|
||||
"titlePlaceholder": "请输入"
|
||||
},
|
||||
"hide": "隐藏",
|
||||
"show": "显示",
|
||||
"title": "标题",
|
||||
"updateSuccess": "更新成功",
|
||||
"updatedAt": "更新时间"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"check": {
|
||||
"checking": "正在验证...",
|
||||
"continue": "继续",
|
||||
"description": "请输入您的电子邮件以继续登录或注册。",
|
||||
"email": "请输入有效的邮箱地址。",
|
||||
"title": "登录/注册",
|
||||
"whitelist": "邮箱域名不在允许的白名单中。"
|
||||
},
|
||||
"login": {
|
||||
"description": "请输入您的账户信息以登录。",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"success": "登录成功!",
|
||||
"switchAccount": "注册/切换账号",
|
||||
"title": "登录"
|
||||
},
|
||||
"logout": "退出登录",
|
||||
"orWithEmail": "或使用邮箱",
|
||||
"register": {
|
||||
"description": "创建新账户,填写您的信息以注册。",
|
||||
"existingAccount": "已有账户?",
|
||||
"get": "获取",
|
||||
"invite": "邀请码",
|
||||
"message": "#### 尊敬的用户,您好!\n\n感谢您对我们的关注和支持。由于站点运营策略的调整,我们已关闭新用户注册功能。在此期间,现有用户的使用不会受到任何影响。\n\n我们致力于为您提供更好的服务和体验,因此将在关闭注册期间进行全面的系统优化和功能升级。未来,我们将以更优质的内容和服务迎接您的到来。\n\n请关注我们的网站和社交媒体平台,获取最新的动态和通知。感谢您的理解与支持。\n\n如有任何疑问或需要帮助,请随时联系我们的客服团队。\n\n**再次感谢您的支持与理解。**",
|
||||
"passwordMismatch": "两次密码输入不一致",
|
||||
"success": "注册成功,已自动登录!",
|
||||
"switchToLogin": "登录/重置邮箱",
|
||||
"title": "注册"
|
||||
},
|
||||
"reset": {
|
||||
"description": "请输入您的电子邮件地址以重置密码。",
|
||||
"existingAccount": "已有账户?",
|
||||
"get": "获取",
|
||||
"success": "重置密码成功,已自动切换到登录!",
|
||||
"switchToLogin": "登录/注册",
|
||||
"title": "重置密码"
|
||||
},
|
||||
"tos": "服务条款"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"empty": [
|
||||
"想象一下这里充满了精彩的内容!暂时只能靠想象了…",
|
||||
"这片区域神秘消失了,我们正在召唤它回来!",
|
||||
"哎呀,什么都没发生…你可以自己填点什么!",
|
||||
"这就像在演唱会上发现舞台上没人…要不你上去表演?",
|
||||
"你找到了一片空白乐土!要不要建个房子?",
|
||||
"这里暂时空无一物,但创意从这里开始!",
|
||||
"什么也没有…但别担心,这只是个开始!",
|
||||
"这里原本准备了大惊喜,结果惊喜溜走了!",
|
||||
"暂时什么都没有,就像零食柜被吃空了一样。",
|
||||
"这片空地在等它的主角登场!"
|
||||
],
|
||||
"request": {
|
||||
"10001": "数据库查询失败,请稍后重试",
|
||||
"10002": "数据更新失败,请稍后重试",
|
||||
"10003": "数据保存失败,请稍后重试",
|
||||
"10004": "数据删除失败,请稍后重试",
|
||||
"20001": "该用户名已被注册",
|
||||
"20002": "找不到该用户",
|
||||
"20003": "密码错误,请重新输入",
|
||||
"20004": "您的账号已被禁用",
|
||||
"30001": "该节点已存在",
|
||||
"30002": "找不到该节点",
|
||||
"30003": "该节点组已存在",
|
||||
"30004": "找不到该节点组",
|
||||
"30005": "节点组中还有数据,无法删除",
|
||||
"400": "请求参数有误,请检查后重新提交",
|
||||
"40002": "请登录后重试",
|
||||
"40003": "登录信息无效,请重新登录",
|
||||
"40004": "登录已过期,请重新登录",
|
||||
"401": "请求过于频繁,请稍后重试",
|
||||
"500": "系统内部错误,我们正在紧急修复",
|
||||
"undefined": "系统发生错误,请稍后重试"
|
||||
},
|
||||
"table": {
|
||||
"actions": "操作",
|
||||
"asc": "升序",
|
||||
"desc": "降序",
|
||||
"hide": "隐藏",
|
||||
"pageInfo": "第 {current} 页,共 {total} 页",
|
||||
"rowsPerPage": "每页行数",
|
||||
"selectedItems": "已选择 {total} 行"
|
||||
},
|
||||
"unlimited": "无限制"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"actions": "操作",
|
||||
"amount": "金额",
|
||||
"cancel": "取消",
|
||||
"code": "代码",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "您确定要删除吗?",
|
||||
"count": "数量",
|
||||
"couponList": "优惠券列表",
|
||||
"create": "创建",
|
||||
"createCoupon": "创建优惠券",
|
||||
"createSuccess": "创建成功",
|
||||
"delete": "删除",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteWarning": "一旦删除,数据将无法恢复。请谨慎操作。",
|
||||
"discount": "折扣",
|
||||
"edit": "编辑",
|
||||
"editCoupon": "编辑优惠券",
|
||||
"enable": "启用",
|
||||
"form": {
|
||||
"amountDiscount": "金额折扣",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"count": "最大使用次数",
|
||||
"countPlaceholder": "最大使用次数(留空表示无限制)",
|
||||
"customCouponCode": "自定义优惠码",
|
||||
"customCouponCodePlaceholder": "自定义优惠码(留空自动生成)",
|
||||
"enterCouponName": "请输入优惠券名称",
|
||||
"enterValue": "请输入数值",
|
||||
"expireTime": "过期时间",
|
||||
"name": "名称",
|
||||
"percentageDiscount": "百分比折扣",
|
||||
"selectServer": "请选择订阅",
|
||||
"selectTime": "选择时间",
|
||||
"specifiedServer": "指定订阅",
|
||||
"startTime": "生效时间",
|
||||
"type": "优惠券类型",
|
||||
"userLimit": "每用户最大使用次数",
|
||||
"userLimitPlaceholder": "每用户最大使用次数(留空表示无限制)"
|
||||
},
|
||||
"name": "名称",
|
||||
"percentage": "百分比",
|
||||
"remainingTimes": "剩余",
|
||||
"subscribe": "订阅",
|
||||
"type": "类型",
|
||||
"unlimited": "无限制",
|
||||
"updateSuccess": "更新成功",
|
||||
"usedTimes": "使用次数",
|
||||
"validityPeriod": "有效期"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"DocumentList": "文档列表",
|
||||
"actions": "操作",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "您确定要删除吗?",
|
||||
"create": "创建",
|
||||
"createDocument": "创建文档",
|
||||
"createSuccess": "创建成功",
|
||||
"delete": "删除",
|
||||
"deleteDescription": "一旦删除,数据将无法恢复。请谨慎操作。",
|
||||
"deleteSuccess": "删除成功",
|
||||
"edit": "编辑",
|
||||
"editDocument": "编辑文档",
|
||||
"form": {
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"content": "内容",
|
||||
"tags": "类别",
|
||||
"tagsPlaceholder": "请用回车键或逗号(,)分隔以输入多个类别",
|
||||
"title": "标题",
|
||||
"titlePlaceholder": "请输入"
|
||||
},
|
||||
"show": "显示",
|
||||
"tags": "类别",
|
||||
"title": "标题",
|
||||
"updateSuccess": "更新成功",
|
||||
"updatedAt": "更新时间"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"cs-CZ": "捷克语",
|
||||
"de-DE": "德语",
|
||||
"en-US": "英语",
|
||||
"es-ES": "西班牙语",
|
||||
"es-MX": "西班牙语(墨西哥)",
|
||||
"fi-FI": "芬兰语",
|
||||
"fr-FR": "法语",
|
||||
"hi-IN": "印地语",
|
||||
"hu-HU": "匈牙利语",
|
||||
"ja-JP": "日语",
|
||||
"ko-KR": "韩语",
|
||||
"no-NO": "挪威语",
|
||||
"pl-PL": "波兰语",
|
||||
"pt-BR": "葡萄牙语(巴西)",
|
||||
"ro-RO": "罗马尼亚语",
|
||||
"ru-RU": "俄语",
|
||||
"th-TH": "泰语",
|
||||
"tr-TR": "土耳其语",
|
||||
"uk-UA": "乌克兰语",
|
||||
"vi-VN": "越南语",
|
||||
"zh-CN": "中文(简体)",
|
||||
"zh-TW": "中文(繁体)"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Announcement Management": "公告管理",
|
||||
"Coupon Management": "优惠券管理",
|
||||
"Dashboard": "仪表盘",
|
||||
"Document Management": "文档管理",
|
||||
"Finance": "财务",
|
||||
"Order Management": "订单管理",
|
||||
"Payment Config": "支付配置",
|
||||
"Server": "服务",
|
||||
"Server Management": "服务管理",
|
||||
"Settings": "设置",
|
||||
"Subscribe Management": "订阅管理",
|
||||
"System Config": "系统配置",
|
||||
"Ticket Management": "工单管理",
|
||||
"User": "用户",
|
||||
"User Management": "用户管理"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"amount": "金额",
|
||||
"failed": "失败",
|
||||
"feAmount": "手续费",
|
||||
"method": "支付方式",
|
||||
"methods": {
|
||||
"alipay_f2f": "支付宝(面对面)",
|
||||
"balance": "余额",
|
||||
"epay": "易支付",
|
||||
"stripe_alipay": "Stripe(支付宝)",
|
||||
"stripe_wechat_pay": "Stripe(微信)"
|
||||
},
|
||||
"orderNumber": "订单编号",
|
||||
"reductionPrice": "优惠金额",
|
||||
"status": {
|
||||
"0": "状态",
|
||||
"1": "待支付",
|
||||
"2": "已支付",
|
||||
"3": "已取消",
|
||||
"4": "已关闭",
|
||||
"5": "已完成"
|
||||
},
|
||||
"subscribe": "订阅",
|
||||
"subscribePrice": "订阅价格",
|
||||
"total": "总计",
|
||||
"tradeNo": "交易号",
|
||||
"type": {
|
||||
"0": "类型",
|
||||
"1": "新购",
|
||||
"2": "续费",
|
||||
"3": "重置流量",
|
||||
"4": "充值"
|
||||
},
|
||||
"updateTime": "更新时间",
|
||||
"user": "用户"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"aliPay": "支付宝支付",
|
||||
"alipayf2f": {
|
||||
"appId": "支付宝应用ID",
|
||||
"invoiceName": "自定义产品名称",
|
||||
"invoiceNameDescription": "将显示在支付宝账单中",
|
||||
"privateKey": "支付宝私钥",
|
||||
"publicKey": "支付宝公钥"
|
||||
},
|
||||
"enable": "启用",
|
||||
"enableDescription": "启用此支付方式",
|
||||
"epay": {
|
||||
"key": "KEY",
|
||||
"pid": "PID",
|
||||
"url": "URL"
|
||||
},
|
||||
"feeMode": "收费方式",
|
||||
"feeModeDescription": "额外手续费的收费方式",
|
||||
"feeModeItems": ["无手续费", "百分比", "固定金额", "以上两种"],
|
||||
"feePercent": "百分比费用(可选)",
|
||||
"feePercentDescription": "在订单金额之上添加费用",
|
||||
"fixedFee": "固定费用(可选)",
|
||||
"fixedFeeDescription": "在订单金额之上添加固定费用",
|
||||
"iconUrl": "图标 URL(可选)",
|
||||
"iconUrlDescription": "用于前端显示",
|
||||
"inputPlaceholder": "请输入",
|
||||
"key": "KEY",
|
||||
"notifyUrl": "自定义通知域(可选)",
|
||||
"notifyUrlDescription": "网关通知将发送到此域",
|
||||
"pid": "PID",
|
||||
"saveSuccess": "保存成功",
|
||||
"showName": "显示名称",
|
||||
"showNameDescription": "用于前端显示",
|
||||
"stripe": {
|
||||
"publicKey": "PUBLIC KEY",
|
||||
"secretKey": "SECRET KEY",
|
||||
"webhookSecret": "WEBHOOK 密钥签名"
|
||||
},
|
||||
"url": "URL",
|
||||
"wechatPay": "微信支付"
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"group": {
|
||||
"actions": "操作",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "确定要删除吗?",
|
||||
"create": "创建",
|
||||
"createNodeGroup": "创建节点组",
|
||||
"createdSuccessfully": "创建成功",
|
||||
"delete": "删除",
|
||||
"deleteWarning": "删除后数据将无法恢复,请谨慎操作。",
|
||||
"deletedSuccessfully": "删除成功",
|
||||
"description": "描述",
|
||||
"edit": "编辑",
|
||||
"editNodeGroup": "编辑节点组",
|
||||
"form": {
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"description": "描述",
|
||||
"name": "名称"
|
||||
},
|
||||
"name": "名称",
|
||||
"title": "节点组列表",
|
||||
"updatedAt": "更新时间"
|
||||
},
|
||||
"node": {
|
||||
"actions": "操作",
|
||||
"address": "地址",
|
||||
"all": "全部",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "确定要删除吗?",
|
||||
"create": "创建",
|
||||
"createNode": "创建节点",
|
||||
"createSuccess": "创建成功",
|
||||
"delete": "删除",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteWarning": "删除后,数据将无法恢复。请谨慎操作。",
|
||||
"edit": "编辑",
|
||||
"editNode": "编辑节点",
|
||||
"enable": "启用",
|
||||
"form": {
|
||||
"Address or IP": "地址或IP",
|
||||
"Host Name": "服务地址",
|
||||
"allowInsecure": "允许不安全",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"edit": "编辑",
|
||||
"editSecurity": "编辑安全性配置",
|
||||
"enableRelay": "启用中转",
|
||||
"enableTLS": "启用TLS",
|
||||
"enableTls": "TLS",
|
||||
"encryptionMethod": "加密方法",
|
||||
"name": "名称",
|
||||
"networkType": "传输协议",
|
||||
"nodeGroupId": "节点组ID",
|
||||
"password": "密码",
|
||||
"path": "路径",
|
||||
"port": "服务端口",
|
||||
"protocol": "协议",
|
||||
"relayHost": "中转地址",
|
||||
"relayPort": "中转端口",
|
||||
"remarks": "备注",
|
||||
"security": "安全性",
|
||||
"security_config": {
|
||||
"fingerprint": "FingerPrint",
|
||||
"privateKey": "Private Key",
|
||||
"privateKeyPlaceholder": "留空自动生成",
|
||||
"proxyProtocol": "Proxy Protocol",
|
||||
"publicKey": "Public Key",
|
||||
"publicKeyPlaceholder": "留空自动生成",
|
||||
"serverAddress": "Server Address",
|
||||
"serverAddressPlaceholder": "REALITY目标地址,默认使用SNI",
|
||||
"serverName": "Server Name(SNI)",
|
||||
"serverNamePlaceholder": "REALITY必填,与后端保持一致",
|
||||
"serverPort": "Server Port",
|
||||
"serverPortPlaceholder": "REALITY目标端口,默认443",
|
||||
"shortId": "ShortId",
|
||||
"shortIdPlaceholder": "留空自动生成"
|
||||
},
|
||||
"selectEncryptionMethod": "选择加密方法",
|
||||
"selectNodeGroup": "选择节点组",
|
||||
"selectProtocol": "选择协议",
|
||||
"serverAddr": "服务器IP",
|
||||
"serverName": "服务名称",
|
||||
"serviceName": "服务名称",
|
||||
"sni": "服务器名称指示(sni)",
|
||||
"speedLimit": "速度限制",
|
||||
"speedLimitPlaceholder": "无限制",
|
||||
"trafficRatio": "流量速率",
|
||||
"transport": "传输协议配置",
|
||||
"xtls": "XTLS流控算法"
|
||||
},
|
||||
"name": "名称",
|
||||
"node": "节点",
|
||||
"nodeGroup": "节点组",
|
||||
"rate": "速率",
|
||||
"relay": "中继",
|
||||
"serverAddr": "服务器IP",
|
||||
"speedLimit": "速度限制",
|
||||
"status": "状态",
|
||||
"trafficRatio": "流量速率",
|
||||
"type": "类型",
|
||||
"updateSuccess": "更新成功",
|
||||
"updatedAt": "更新时间"
|
||||
},
|
||||
"tabs": {
|
||||
"node": "节点",
|
||||
"nodeGroup": "节点组"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"actions": "操作",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "确定删除吗?",
|
||||
"create": "创建",
|
||||
"createSubscribe": "新建订阅",
|
||||
"createSuccess": "创建成功",
|
||||
"delete": "删除",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
|
||||
"deviceLimit": "设备数/台",
|
||||
"edit": "编辑",
|
||||
"editSubscribe": "编辑订阅",
|
||||
"form": {
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"description": "描述",
|
||||
"deviceLimit": "设备限制",
|
||||
"discount": "折扣",
|
||||
"discountDescription": "基于单价设置折扣",
|
||||
"discountMonths": "月",
|
||||
"discountPercent": "折扣比",
|
||||
"duration": "时长(月)",
|
||||
"inventory": "库存",
|
||||
"name": "名称",
|
||||
"noLimit": "无限制",
|
||||
"quota": "限购数量",
|
||||
"replacement": "重置价格(每次)",
|
||||
"selectSubscribeGroup": "请选择订阅组",
|
||||
"server": "服务",
|
||||
"serverGroup": "服务组",
|
||||
"speedLimit": "速度限制(Mbps)",
|
||||
"subscribeGroup": "订阅组",
|
||||
"traffic": "流量",
|
||||
"unit_price": "单月价格"
|
||||
},
|
||||
"group": {
|
||||
"actions": "操作",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "确定删除吗?",
|
||||
"create": "创建",
|
||||
"createSubscribeGroup": "新建订阅组",
|
||||
"createSuccess": "创建成功",
|
||||
"delete": "删除",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
|
||||
"description": "描述",
|
||||
"edit": "编辑",
|
||||
"editSubscribeGroup": "编辑订阅组",
|
||||
"form": {
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"description": "描述",
|
||||
"name": "名称"
|
||||
},
|
||||
"name": "名称",
|
||||
"title": "订阅组列表",
|
||||
"updateSuccess": "更新成功",
|
||||
"updatedAt": "更新时间"
|
||||
},
|
||||
"inventory": "库存",
|
||||
"name": "名称",
|
||||
"quota": "限购/次",
|
||||
"replacement": "重置价格/次",
|
||||
"sell": "销售",
|
||||
"show": "显示",
|
||||
"subscribe": "订阅",
|
||||
"subscribeGroup": "订阅组",
|
||||
"tabs": {
|
||||
"subscribe": "订阅",
|
||||
"subscribeGroup": "订阅组"
|
||||
},
|
||||
"traffic": "流量",
|
||||
"unitPrice": "价格/月",
|
||||
"updateSuccess": "更新成功"
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"currency": {
|
||||
"accessKey": "密钥",
|
||||
"accessKeyDescription": "https://exchangerate.host 提供免费提供的汇率API密钥",
|
||||
"currencySymbol": "货币符号",
|
||||
"currencySymbolDescription": "仅用于展示使用,更改后系统中所有的货币单位都将发生变更",
|
||||
"currencyUnit": "货币单位",
|
||||
"currencyUnitDescription": "仅用于展示使用,更改后系统中所有的货币单位都将发生变更",
|
||||
"saveSuccess": "保存成功"
|
||||
},
|
||||
"email": {
|
||||
"expiration_email_template": "过期通知模板",
|
||||
"expiration_email_templateDescription": "模板中的 {after}.变量名{before} 会被替换为实际数据,请确保保留这些变量。",
|
||||
"inputPlaceholder": "请输入内容...",
|
||||
"maintenance_email_template": "维护通知模板",
|
||||
"maintenance_email_templateDescription": "模板中的 {after}.变量名{before} 会被替换为实际数据,请确保保留这些变量。",
|
||||
"saveSuccess": "配置已成功保存。",
|
||||
"sendFailure": "测试邮件发送失败,请检查配置。",
|
||||
"sendSuccess": "测试邮件已成功发送。",
|
||||
"sendTestEmail": "发送测试邮件",
|
||||
"sendTestEmailDescription": "发送一封测试邮件以验证配置是否正确。",
|
||||
"senderAddress": "发件人地址",
|
||||
"senderAddressDescription": "默认用于发送邮件的邮箱地址。",
|
||||
"smtpAccount": "SMTP 账号",
|
||||
"smtpAccountDescription": "用于身份验证的邮件账号。",
|
||||
"smtpEncryptionMethod": "SMTP 加密方式",
|
||||
"smtpEncryptionMethodDescription": "选择是否启用 SSL 加密。",
|
||||
"smtpPassword": "SMTP 密码",
|
||||
"smtpPasswordDescription": "该 SMTP 账号的密码。",
|
||||
"smtpServerAddress": "SMTP 服务器地址",
|
||||
"smtpServerAddressDescription": "指定用于发送邮件的服务器地址。",
|
||||
"smtpServerPort": "SMTP 服务器端口",
|
||||
"smtpServerPortDescription": "指定用于连接 SMTP 服务器的端口号。",
|
||||
"verify_email_template": "验证邮件模板",
|
||||
"verify_email_templateDescription": "模板中的 {after}.变量名{before} 会被替换为实际数据,请确保保留这些变量。"
|
||||
},
|
||||
"invite": {
|
||||
"commissionFirstTimeOnly": "仅首次购买佣金",
|
||||
"commissionFirstTimeOnlyDescription": "启用后,佣金仅在邀请人首次付款时生成您可以在用户管理中配置单个用户",
|
||||
"enableForcedInvite": "启用强制邀请",
|
||||
"enableForcedInviteDescription": "启用后,只有受邀用户才能注册",
|
||||
"inputPlaceholder": "请输入",
|
||||
"inviteCommissionPercentage": "邀请佣金百分比",
|
||||
"inviteCommissionPercentageDescription": "默认全局佣金分配比例,您可以在用户管理中配置单个比例",
|
||||
"saveSuccess": "保存成功"
|
||||
},
|
||||
"node": {
|
||||
"communicationKey": "通信密钥",
|
||||
"communicationKeyDescription": "节点通信的密钥,以确保数据不被他人拦截",
|
||||
"inputPlaceholder": "请输入",
|
||||
"nodePullInterval": "节点拉取间隔",
|
||||
"nodePullIntervalDescription": "节点从面板获取数据的频率(秒)",
|
||||
"nodePushInterval": "节点推送间隔",
|
||||
"nodePushIntervalDescription": "节点将数据推送到面板的频率",
|
||||
"saveSuccess": "保存成功"
|
||||
},
|
||||
"register": {
|
||||
"emailSuffixWhitelist": "电子邮件后缀白名单",
|
||||
"emailSuffixWhitelistDescription": "启用后,只有列表中的后缀的电子邮件才能注册",
|
||||
"emailVerification": "电子邮件验证",
|
||||
"emailVerificationDescription": "启用后,用户将需要验证他们的电子邮件",
|
||||
"ipRegistrationLimit": "IP 注册限制",
|
||||
"ipRegistrationLimitDescription": "启用后,符合规则要求的 IP 将被限制注册请注意,由于 CDN 或前端代理,IP 确定可能会导致问题",
|
||||
"penaltyTime": "处罚时间(分钟)",
|
||||
"penaltyTimeDescription": "用户必须等待处罚时间过去才能再次注册",
|
||||
"registrationLimitCount": "注册限制计数",
|
||||
"registrationLimitCountDescription": "达到注册限制后启用处罚",
|
||||
"saveSuccess": "保存成功",
|
||||
"stopNewUserRegistration": "停止新用户注册",
|
||||
"stopNewUserRegistrationDescription": "启用后,没有人可以注册",
|
||||
"trialRegistration": "试用注册",
|
||||
"trialRegistrationDescription": "启用试用注册,请先修改试用套餐和时长",
|
||||
"whitelistSuffixes": "白名单后缀",
|
||||
"whitelistSuffixesDescription": "用于注册电子邮件验证,每行一个",
|
||||
"whitelistSuffixesPlaceholder": "请输入电子邮件后缀,每行一个"
|
||||
},
|
||||
"site": {
|
||||
"logo": "LOGO",
|
||||
"logoDescription": "用于显示需要展示 LOGO 的位置",
|
||||
"logoPlaceholder": "请输入 LOGO 的 URL 地址,不要以 '/' 结尾",
|
||||
"saveSuccess": "保存成功",
|
||||
"siteDesc": "站点描述",
|
||||
"siteDescDescription": "用于显示需要展示站点描述的位置",
|
||||
"siteDescPlaceholder": "请输入站点描述",
|
||||
"siteDomain": "站点域名",
|
||||
"siteDomainDescription": "当前网站的域名地址,例如用于电子邮件中",
|
||||
"siteDomainPlaceholder": "请输入域名地址,多个域名请每行一个",
|
||||
"siteName": "站点名称",
|
||||
"siteNameDescription": "用于显示需要展示站点名称的位置",
|
||||
"siteNamePlaceholder": "请输入站点名称"
|
||||
},
|
||||
"subscription": {
|
||||
"add": "添加",
|
||||
"app": "应用",
|
||||
"appDescription": "配置 APP 的下载地址和导入规则,记得修改后保存",
|
||||
"appDownloadURL": "应用下载地址",
|
||||
"appIcon": "应用图标",
|
||||
"appName": "应用名称",
|
||||
"delete": "删除",
|
||||
"reset": "重置",
|
||||
"save": "保存",
|
||||
"saveSuccess": "保存成功",
|
||||
"singleSubscriptionMode": "单一订阅模式",
|
||||
"singleSubscriptionModeDescription": "开启后,所有用户的套餐将转换为余额",
|
||||
"subscriptionDomain": "订阅域名",
|
||||
"subscriptionDomainDescription": "用于订阅,留空则使用站点域名",
|
||||
"subscriptionDomainPlaceholder": "请输入订阅域名,多个域名请每行一个",
|
||||
"subscriptionPath": "订阅路径",
|
||||
"subscriptionPathDescription": "用于订阅",
|
||||
"subscriptionPathPlaceholder": "请输入",
|
||||
"subscriptionProtocol": "订阅协议",
|
||||
"wildcardResolution": "通配符解析",
|
||||
"wildcardResolutionDescription": "用于订阅"
|
||||
},
|
||||
"tabs": {
|
||||
"currency": "货币",
|
||||
"email": "电子邮件",
|
||||
"invite": "邀请",
|
||||
"node": "节点",
|
||||
"register": "注册",
|
||||
"site": "站点",
|
||||
"subscription": "订阅",
|
||||
"telegram": "Telegram",
|
||||
"tos": "服务条款",
|
||||
"verify": "验证"
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "机器人 Token",
|
||||
"botTokenDescription": "请输入 Botfather 提供的 token",
|
||||
"enableBotNotifications": "启用机器人通知",
|
||||
"enableBotNotificationsDescription": "启用后,机器人将向已链接 Telegram 的管理员和用户发送基本通知",
|
||||
"groupURL": "群组 URL",
|
||||
"groupURLDescription": "填写后,将在用户端显示或用于需要的位置",
|
||||
"inputPlaceholderBotToken": "0000000000:xxxxxxxxx_xxxxxxxxxxxxxxx",
|
||||
"inputPlaceholderGroupURL": "https://t.me/xxxxxx",
|
||||
"saveSuccess": "保存成功"
|
||||
},
|
||||
"tos": {
|
||||
"saveSuccess": "保存成功",
|
||||
"title": "服务条款"
|
||||
},
|
||||
"verify": {
|
||||
"inputPlaceholder": "请输入",
|
||||
"loginVerificationCode": "登录验证码",
|
||||
"loginVerificationCodeDescription": "登录时的人机验证",
|
||||
"registrationVerificationCode": "注册验证码",
|
||||
"registrationVerificationCodeDescription": "注册时的人机验证",
|
||||
"resetPasswordVerificationCode": "重置密码验证码",
|
||||
"resetPasswordVerificationCodeDescription": "重置密码时的人机验证",
|
||||
"saveSuccess": "保存成功",
|
||||
"turnstileSecret": "Turnstile 密钥",
|
||||
"turnstileSecretDescription": "Cloudflare 提供的 Turnstile 密钥",
|
||||
"turnstileSiteKey": "Turnstile 站点密钥",
|
||||
"turnstileSiteKeyDescription": "Cloudflare 提供的 Turnstile 站点密钥"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"actions": "操作",
|
||||
"cancel": "取消",
|
||||
"check": "查看",
|
||||
"close": "关闭",
|
||||
"closeSuccess": "关闭成功",
|
||||
"closeWarning": "一旦关闭,将无法操作该工单。请谨慎操作。",
|
||||
"confirm": "确认",
|
||||
"confirmClose": "确定要关闭吗?",
|
||||
"createdAt": "创建时间",
|
||||
"inputPlaceholder": "请输入您的问题,我们会尽快回复。",
|
||||
"open": "打开",
|
||||
"reply": "回复",
|
||||
"status": ["状态", "待跟进", "待回复", " 已处理", "关闭"],
|
||||
"ticketList": "工单列表",
|
||||
"title": "标题",
|
||||
"updatedAt": "更新时间",
|
||||
"user": "用户"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"actions": "操作",
|
||||
"balance": "余额",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "您确定要删除吗?",
|
||||
"create": "创建",
|
||||
"createSuccess": "创建成功",
|
||||
"createUser": "创建用户",
|
||||
"createdAt": "注册时间",
|
||||
"delete": "删除",
|
||||
"deleteDescription": "删除后无法恢复数据,请谨慎操作。",
|
||||
"deleteSuccess": "删除成功",
|
||||
"edit": "编辑",
|
||||
"editUser": "编辑用户",
|
||||
"email": "邮箱",
|
||||
"enable": "启用",
|
||||
"form": {
|
||||
"balance": "余额",
|
||||
"balancePlaceholder": "余额",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"invalidEmailFormat": "无效的电子邮件格式",
|
||||
"inviteCode": "邀请码",
|
||||
"inviteCodePlaceholder": "请输入邀请码(留空则自动生成)",
|
||||
"manager": "管理员",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "请输入新密码(可留空)",
|
||||
"refererId": "推荐人 ID",
|
||||
"refererIdPlaceholder": "请输入推荐人 ID",
|
||||
"userEmail": "用户邮箱",
|
||||
"userEmailPlaceholder": "请输入用户邮箱"
|
||||
},
|
||||
"referer": "推荐人",
|
||||
"updateSuccess": "更新成功",
|
||||
"userList": "用户列表",
|
||||
"userName": "用户邮箱"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./locales/request.ts');
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@shadcn/ui', '@repo/ui'],
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.**.**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.**',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user