feat(announcement): Popup and pinned

This commit is contained in:
web@ppanel 2024-12-21 00:10:09 +07:00
parent 79621623d5
commit f3680a7020
35 changed files with 465 additions and 351 deletions

View File

@ -5,7 +5,6 @@ import {
deleteAnnouncement, deleteAnnouncement,
getAnnouncementList, getAnnouncementList,
updateAnnouncement, updateAnnouncement,
updateAnnouncementEnable,
} from '@/services/admin/announcement'; } from '@/services/admin/announcement';
import { ConfirmButton } from '@repo/ui/confirm-button'; import { ConfirmButton } from '@repo/ui/confirm-button';
import { format } from '@shadcn/ui/lib/date-fns'; import { format } from '@shadcn/ui/lib/date-fns';
@ -51,16 +50,52 @@ export default function Page() {
}} }}
columns={[ columns={[
{ {
accessorKey: 'enable', accessorKey: 'show',
header: t('enable'), header: t('show'),
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<Switch <Switch
defaultChecked={row.getValue('enable')} defaultChecked={row.getValue('show')}
onCheckedChange={async (checked) => { onCheckedChange={async (checked) => {
await updateAnnouncementEnable({ await updateAnnouncement({
id: row.original.id, ...row.original,
enable: checked, show: checked,
});
ref.current?.refresh();
}}
/>
);
},
},
{
accessorKey: 'pinned',
header: t('pinned'),
cell: ({ row }) => {
return (
<Switch
defaultChecked={row.getValue('pinned')}
onCheckedChange={async (checked) => {
await updateAnnouncement({
...row.original,
pinned: checked,
});
ref.current?.refresh();
}}
/>
);
},
},
{
accessorKey: 'popup',
header: t('popup'),
cell: ({ row }) => {
return (
<Switch
defaultChecked={row.getValue('popup')}
onCheckedChange={async (checked) => {
await updateAnnouncement({
...row.original,
popup: checked,
}); });
ref.current?.refresh(); ref.current?.refresh();
}} }}

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Zadejte" "titlePlaceholder": "Zadejte"
}, },
"hide": "skrýt", "hide": "skrýt",
"pinned": "Připnuto",
"popup": "vyskakovací okno",
"show": "Zobrazit", "show": "Zobrazit",
"title": "Titul", "title": "Titul",
"updateSuccess": "Aktualizace byla úspěšná", "updateSuccess": "Aktualizace byla úspěšná",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Bitte eingeben" "titlePlaceholder": "Bitte eingeben"
}, },
"hide": "Verbergen", "hide": "Verbergen",
"pinned": "Angeheftet",
"popup": "Popup",
"show": "Anzeigen", "show": "Anzeigen",
"title": "Titel", "title": "Titel",
"updateSuccess": "Aktualisierung erfolgreich", "updateSuccess": "Aktualisierung erfolgreich",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Please enter" "titlePlaceholder": "Please enter"
}, },
"hide": "Hide", "hide": "Hide",
"pinned": "Pinned",
"popup": "Popup",
"show": "Show", "show": "Show",
"title": "Title", "title": "Title",
"updateSuccess": "Update Success", "updateSuccess": "Update Success",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Por favor, introduce" "titlePlaceholder": "Por favor, introduce"
}, },
"hide": "Ocultar", "hide": "Ocultar",
"pinned": "Fijado",
"popup": "Ventana emergente",
"show": "mostrar", "show": "mostrar",
"title": "Título", "title": "Título",
"updateSuccess": "Actualización exitosa", "updateSuccess": "Actualización exitosa",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Por favor, ingresa" "titlePlaceholder": "Por favor, ingresa"
}, },
"hide": "Ocultar", "hide": "Ocultar",
"pinned": "Fijado",
"popup": "ventana emergente",
"show": "Mostrar", "show": "Mostrar",
"title": "Título", "title": "Título",
"updateSuccess": "Actualización exitosa", "updateSuccess": "Actualización exitosa",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Syötä" "titlePlaceholder": "Syötä"
}, },
"hide": "piilota", "hide": "piilota",
"pinned": "Kiinnitetty",
"popup": "ponnahdusikkuna",
"show": "Näytä", "show": "Näytä",
"title": "Otsikko", "title": "Otsikko",
"updateSuccess": "Päivitys onnistui", "updateSuccess": "Päivitys onnistui",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Veuillez entrer" "titlePlaceholder": "Veuillez entrer"
}, },
"hide": "Masquer", "hide": "Masquer",
"pinned": "Épinglé",
"popup": "Fenêtre contextuelle",
"show": "afficher", "show": "afficher",
"title": "Titre", "title": "Titre",
"updateSuccess": "Mise à jour réussie", "updateSuccess": "Mise à jour réussie",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "कृपया दर्ज करें" "titlePlaceholder": "कृपया दर्ज करें"
}, },
"hide": "छिपाएं", "hide": "छिपाएं",
"pinned": "पिन किया हुआ",
"popup": "पॉपअप",
"show": "प्रदर्शित करें", "show": "प्रदर्शित करें",
"title": "शीर्षक", "title": "शीर्षक",
"updateSuccess": "अपडेट सफल", "updateSuccess": "अपडेट सफल",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Kérjük, adja meg" "titlePlaceholder": "Kérjük, adja meg"
}, },
"hide": "elrejt", "hide": "elrejt",
"pinned": "Kiemelt",
"popup": "Felugró ablak",
"show": "Megjelenítés", "show": "Megjelenítés",
"title": "Cím", "title": "Cím",
"updateSuccess": "Sikeres frissítés", "updateSuccess": "Sikeres frissítés",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "入力してください" "titlePlaceholder": "入力してください"
}, },
"hide": "隠す", "hide": "隠す",
"pinned": "固定",
"popup": "ポップアップ",
"show": "表示", "show": "表示",
"title": "タイトル", "title": "タイトル",
"updateSuccess": "更新が成功しました", "updateSuccess": "更新が成功しました",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "입력하세요" "titlePlaceholder": "입력하세요"
}, },
"hide": "숨기기", "hide": "숨기기",
"pinned": "고정",
"popup": "팝업",
"show": "표시", "show": "표시",
"title": "제목", "title": "제목",
"updateSuccess": "업데이트 성공", "updateSuccess": "업데이트 성공",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Vennligst skriv inn" "titlePlaceholder": "Vennligst skriv inn"
}, },
"hide": "skjul", "hide": "skjul",
"pinned": "Festet",
"popup": "Popup",
"show": "Vis", "show": "Vis",
"title": "Tittel", "title": "Tittel",
"updateSuccess": "Oppdatering vellykket", "updateSuccess": "Oppdatering vellykket",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Wprowadź" "titlePlaceholder": "Wprowadź"
}, },
"hide": "ukryj", "hide": "ukryj",
"pinned": "Przypięte",
"popup": "wyskakujące okienko",
"show": "Pokaż", "show": "Pokaż",
"title": "Tytuł", "title": "Tytuł",
"updateSuccess": "Aktualizacja zakończona pomyślnie", "updateSuccess": "Aktualizacja zakończona pomyślnie",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Por favor, insira" "titlePlaceholder": "Por favor, insira"
}, },
"hide": "ocultar", "hide": "ocultar",
"pinned": "Fixado",
"popup": "Janela pop-up",
"show": "mostrar", "show": "mostrar",
"title": "Título", "title": "Título",
"updateSuccess": "Atualização bem-sucedida", "updateSuccess": "Atualização bem-sucedida",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Introduceți" "titlePlaceholder": "Introduceți"
}, },
"hide": "ascunde", "hide": "ascunde",
"pinned": "Fixat",
"popup": "fereastră pop-up",
"show": "afișare", "show": "afișare",
"title": "Titlu", "title": "Titlu",
"updateSuccess": "Actualizare reușită", "updateSuccess": "Actualizare reușită",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Пожалуйста, введите" "titlePlaceholder": "Пожалуйста, введите"
}, },
"hide": "скрыть", "hide": "скрыть",
"pinned": "Закреплено",
"popup": "всплывающее окно",
"show": "Показать", "show": "Показать",
"title": "Заголовок", "title": "Заголовок",
"updateSuccess": "Обновление успешно", "updateSuccess": "Обновление успешно",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "กรุณาใส่" "titlePlaceholder": "กรุณาใส่"
}, },
"hide": "ซ่อน", "hide": "ซ่อน",
"pinned": "ปักหมุด",
"popup": "ป๊อปอัพ",
"show": "แสดง", "show": "แสดง",
"title": "หัวข้อ", "title": "หัวข้อ",
"updateSuccess": "อัปเดตสำเร็จ", "updateSuccess": "อัปเดตสำเร็จ",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Lütfen giriniz" "titlePlaceholder": "Lütfen giriniz"
}, },
"hide": "gizle", "hide": "gizle",
"pinned": "Sabitlenmiş",
"popup": "açılır pencere",
"show": "göster", "show": "göster",
"title": "Başlık", "title": "Başlık",
"updateSuccess": "Güncelleme başarılı", "updateSuccess": "Güncelleme başarılı",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Будь ласка, введіть" "titlePlaceholder": "Будь ласка, введіть"
}, },
"hide": "приховати", "hide": "приховати",
"pinned": "Закріплено",
"popup": "спливаюче вікно",
"show": "Показати", "show": "Показати",
"title": "Заголовок", "title": "Заголовок",
"updateSuccess": "Оновлення успішне", "updateSuccess": "Оновлення успішне",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "Vui lòng nhập" "titlePlaceholder": "Vui lòng nhập"
}, },
"hide": "Ẩn", "hide": "Ẩn",
"pinned": "Ghim",
"popup": "Cửa sổ bật lên",
"show": "Hiển thị", "show": "Hiển thị",
"title": "Tiêu đề", "title": "Tiêu đề",
"updateSuccess": "Cập nhật thành công", "updateSuccess": "Cập nhật thành công",

View File

@ -13,7 +13,7 @@
"deleteSuccess": "删除成功", "deleteSuccess": "删除成功",
"edit": "编辑", "edit": "编辑",
"editAnnouncement": "编辑公告", "editAnnouncement": "编辑公告",
"enable": "启用", "enable": "是否显示",
"form": { "form": {
"cancel": "取消", "cancel": "取消",
"confirm": "确认", "confirm": "确认",
@ -23,6 +23,8 @@
"titlePlaceholder": "请输入" "titlePlaceholder": "请输入"
}, },
"hide": "隐藏", "hide": "隐藏",
"pinned": "置顶",
"popup": "弹窗",
"show": "显示", "show": "显示",
"title": "标题", "title": "标题",
"updateSuccess": "更新成功", "updateSuccess": "更新成功",

View File

@ -23,6 +23,8 @@
"titlePlaceholder": "請輸入" "titlePlaceholder": "請輸入"
}, },
"hide": "隱藏", "hide": "隱藏",
"pinned": "置頂",
"popup": "彈窗",
"show": "顯示", "show": "顯示",
"title": "標題", "title": "標題",
"updateSuccess": "更新成功", "updateSuccess": "更新成功",

View File

@ -1,13 +1,15 @@
const config = [ const config = [
{ {
requestLibPath: "import request from '@/utils/request';", requestLibPath: "import request from '@/utils/request';",
schemaPath: 'https://docs.ppanel.dev/swagger/common.json', schemaPath:
'https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/common.json',
serversPath: './services', serversPath: './services',
projectName: 'common', projectName: 'common',
}, },
{ {
requestLibPath: "import request from '@/utils/request';", requestLibPath: "import request from '@/utils/request';",
schemaPath: 'https://docs.ppanel.dev/swagger/admin.json', schemaPath:
'https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/admin.json',
serversPath: './services', serversPath: './services',
projectName: 'admin', projectName: 'admin',
}, },

View File

@ -62,21 +62,6 @@ export async function getAnnouncement(
}); });
} }
/** Update announcement enable PUT /v1/admin/announcement/enable */
export async function updateAnnouncementEnable(
body: API.UpdateAnnouncementEnableRequest,
options?: { [key: string]: any },
) {
return request<API.Response & { data?: any }>('/v1/admin/announcement/enable', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Get announcement list GET /v1/admin/announcement/list */ /** Get announcement list GET /v1/admin/announcement/list */
export async function getAnnouncementList( export async function getAnnouncementList(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象) // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)

View File

@ -11,8 +11,9 @@ declare namespace API {
id: number; id: number;
title: string; title: string;
content: string; content: string;
enable: boolean; show: boolean;
type: number; pinned: boolean;
popup: boolean;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -82,7 +83,6 @@ declare namespace API {
type CreateAnnouncementRequest = { type CreateAnnouncementRequest = {
title: string; title: string;
content: string; content: string;
type: number;
}; };
type CreateApplicationRequest = { type CreateApplicationRequest = {
@ -823,8 +823,9 @@ declare namespace API {
id: number; id: number;
title: string; title: string;
content: string; content: string;
enable: boolean; show: boolean;
type: number; pinned: boolean;
popup: boolean;
}; };
type UpdateApplicationRequest = { type UpdateApplicationRequest = {

View File

@ -3,8 +3,9 @@ declare namespace API {
id: number; id: number;
title: string; title: string;
content: string; content: string;
enable: boolean; show: boolean;
type: number; pinned: boolean;
popup: boolean;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -321,6 +322,7 @@ declare namespace API {
server: number[]; server: number[];
show: boolean; show: boolean;
sell: boolean; sell: boolean;
sort: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };

View File

@ -1,36 +0,0 @@
'use client';
import { Empty } from '@/components/empty';
import { queryAnnouncement } from '@/services/user/announcement';
import { Icon } from '@iconify/react';
import { Markdown } from '@repo/ui/markdown';
import { Card } from '@shadcn/ui/card';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
export default function Announcement() {
const t = useTranslations('dashboard');
const { data } = useQuery({
queryKey: ['queryAnnouncement', 1],
queryFn: async () => {
const { data } = await queryAnnouncement({
page: 1,
size: 1,
});
return (data.data?.announcements?.[0] as API.Announcement) || {};
},
});
return (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:bell' className='size-5' />
{t('latestAnnouncement')}
</h2>
<Card className='p-6'>
{data?.content ? <Markdown>{data?.content}</Markdown> : <Empty />}
</Card>
</>
);
}

View File

@ -0,0 +1,281 @@
'use client';
import { Display } from '@/components/display';
import { queryApplicationConfig } from '@/services/user/subscribe';
import { queryUserSubscribe } from '@/services/user/user';
import { Icon } from '@iconify/react';
import { getNextResetDate, isBrowser } from '@repo/ui/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@shadcn/ui/accordion';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@shadcn/ui/alert-dialog';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { differenceInDays } from '@shadcn/ui/lib/date-fns';
import { toast } from '@shadcn/ui/lib/sonner';
import { Separator } from '@shadcn/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import Link from 'next/link';
import { QRCodeCanvas } from 'qrcode.react';
import { useState } from 'react';
import useGlobalStore from '@/config/use-global';
import { getStat } from '@/services/common/common';
import { getPlatform } from '@/utils/common';
import CopyToClipboard from 'react-copy-to-clipboard';
import Renewal from '../order/renewal';
import ResetTraffic from '../order/reset-traffic';
import Subscribe from '../subscribe/page';
export default function Content() {
const t = useTranslations('dashboard');
const { getUserSubscribe, getAppSubLink } = useGlobalStore();
const [protocol, setProtocol] = useState('');
const { data: userSubscribe = [] } = useQuery({
queryKey: ['queryUserSubscribe'],
queryFn: async () => {
const { data } = await queryUserSubscribe();
return data.data?.list || [];
},
});
const { data: application } = useQuery({
queryKey: ['queryApplicationConfig'],
queryFn: async () => {
const { data } = await queryApplicationConfig();
return data.data as API.ApplicationResponse;
},
});
const [platform, setPlatform] = useState<keyof API.ApplicationResponse>(getPlatform());
const { data } = useQuery({
queryKey: ['getStat'],
queryFn: async () => {
const { data } = await getStat({
skipErrorHandler: true,
});
return data.data;
},
refetchOnWindowFocus: false,
});
return (
<>
{userSubscribe.length ? (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:servers' className='size-5' />
{t('mySubscriptions')}
</h2>
<div className='flex flex-wrap justify-between gap-4'>
<Tabs
value={platform}
onValueChange={(value) => setPlatform(value as keyof API.ApplicationResponse)}
className='w-full max-w-full md:w-auto'
>
<TabsList className='flex *:flex-auto'>
{application &&
Object.keys(application)?.map((item) => (
<TabsTrigger value={item} key={item} className='px-1 uppercase lg:px-3'>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{data?.protocol && data?.protocol.length > 1 && (
<Tabs
value={protocol}
onValueChange={setProtocol}
className='w-full max-w-full md:w-auto'
>
<TabsList className='flex *:flex-auto'>
{['all', ...(data?.protocol || [])].map((item) => (
<TabsTrigger
value={item === 'all' ? '' : item}
key={item}
className='px-1 uppercase lg:px-3'
>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
{userSubscribe.map((item) => (
<Card key={item.id}>
<CardHeader className='flex flex-row flex-wrap items-center justify-between gap-2 space-y-0'>
<CardTitle className='font-medium'>{item.subscribe.name}</CardTitle>
<div className='flex gap-2'>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size='sm' variant='destructive'>
{t('resetSubscription')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('prompt')}</AlertDialogTitle>
<AlertDialogDescription>
{t('confirmResetSubscription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => toast.success(t('resetSuccess'))}>
{t('confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResetTraffic
id={item.subscribe_id}
token={item.token}
replacement={item.subscribe.replacement}
/>
<Renewal token={item.token} subscribe={item.subscribe} />
</div>
</CardHeader>
<CardContent>
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4'>
<li>
<span className='text-muted-foreground'>{t('used')}</span>
<span className='text-2xl font-bold'>
<Display
type='traffic'
value={item.upload + item.download}
unlimited={!item.traffic}
/>
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('totalTraffic')}</span>
<span className='text-2xl font-bold'>
<Display type='traffic' value={item.traffic} unlimited={!item.traffic} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('nextResetDays')}</span>
<span className='text-2xl font-semibold'>
{differenceInDays(getNextResetDate(item.start_time), new Date()) ||
t('unknown')}
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('expirationDays')}</span>
<span className='text-2xl font-semibold'>
{differenceInDays(new Date(item.expire_time), new Date()) || t('unknown')}
</span>
</li>
</ul>
<Separator className='mt-4' />
<Accordion type='single' collapsible defaultValue='0' className='w-full'>
{getUserSubscribe(item.token, protocol)?.map((url, index) => (
<AccordionItem key={url} value={String(index)}>
<AccordionTrigger className='hover:no-underline'>
<div className='flex w-full flex-row items-center justify-between'>
<CardTitle className='text-sm font-medium'>
{t('subscriptionUrl')} {index + 1}
</CardTitle>
<CopyToClipboard
text={url}
onCopy={(text, result) => {
if (result) {
toast.success(t('copySuccess'));
}
}}
>
<span
className='text-primary hover:bg-accent mr-4 flex cursor-pointer rounded p-2 text-sm'
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon='uil:copy' className='mr-2 size-5' />
{t('copy')}
</span>
</CopyToClipboard>
</div>
</AccordionTrigger>
<AccordionContent>
<div className='grid grid-cols-3 gap-4 lg:grid-cols-4 xl:grid-cols-7'>
{application &&
application[platform]?.map((app) => (
<div
key={app.name}
className='text-muted-foreground flex size-full flex-col items-center justify-between gap-2 text-xs'
>
<span>{app.name}</span>
{app.icon && (
<Image src={app.icon} alt={app.name} width={50} height={50} />
)}
<div className='flex'>
<Button size='sm' variant='secondary' className='px-1.5' asChild>
<Link href={app.url!}>{t('download')}</Link>
</Button>
<CopyToClipboard
text={url}
onCopy={(text, result) => {
const href = getAppSubLink(app.subscribe_type, url);
if (isBrowser() && href) {
window.location.href = href;
} else if (result) {
toast.success(
<>
<p>{t('copySuccess')}</p>
<p>{t('manualImportMessage')}</p>
</>,
);
}
}}
>
<Button size='sm' className='p-2'>
{t('import')}
</Button>
</CopyToClipboard>
</div>
</div>
))}
<div className='text-muted-foreground hidden size-full flex-col items-center justify-between gap-2 text-sm lg:flex'>
<span>{t('qrCode')}</span>
<QRCodeCanvas
value={url}
size={80}
bgColor='transparent'
fgColor='rgb(59, 130, 246)'
/>
<span className='text-center'>{t('scanToSubscribe')}</span>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
))}
</>
) : (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:shop' className='size-5' />
{t('purchaseSubscription')}
</h2>
<Subscribe />
</>
)}
</>
);
}

View File

@ -1,283 +1,12 @@
'use client'; import Announcement from '@/components/announcement';
import { cookies } from 'next/headers';
import { Display } from '@/components/display'; import Content from './content';
import { queryApplicationConfig } from '@/services/user/subscribe';
import { queryUserSubscribe } from '@/services/user/user';
import { Icon } from '@iconify/react';
import { getNextResetDate, isBrowser } from '@repo/ui/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@shadcn/ui/accordion';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@shadcn/ui/alert-dialog';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { differenceInDays } from '@shadcn/ui/lib/date-fns';
import { toast } from '@shadcn/ui/lib/sonner';
import { Separator } from '@shadcn/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import Link from 'next/link';
import { QRCodeCanvas } from 'qrcode.react';
import { useState } from 'react';
import useGlobalStore from '@/config/use-global';
import { getStat } from '@/services/common/common';
import { getPlatform } from '@/utils/common';
import CopyToClipboard from 'react-copy-to-clipboard';
import Renewal from '../order/renewal';
import ResetTraffic from '../order/reset-traffic';
import Subscribe from '../subscribe/page';
import Announcement from './announcemnet';
export default function Page() {
const t = useTranslations('dashboard');
const { getUserSubscribe, getAppSubLink } = useGlobalStore();
const [protocol, setProtocol] = useState('');
const { data: userSubscribe = [] } = useQuery({
queryKey: ['queryUserSubscribe'],
queryFn: async () => {
const { data } = await queryUserSubscribe();
return data.data?.list || [];
},
});
const { data: application } = useQuery({
queryKey: ['queryApplicationConfig'],
queryFn: async () => {
const { data } = await queryApplicationConfig();
return data.data as API.ApplicationResponse;
},
});
const [platform, setPlatform] = useState<keyof API.ApplicationResponse>(getPlatform());
const { data } = useQuery({
queryKey: ['getStat'],
queryFn: async () => {
const { data } = await getStat({
skipErrorHandler: true,
});
return data.data;
},
refetchOnWindowFocus: false,
});
export default async function Page() {
return ( return (
<div className='flex min-h-[calc(100vh-64px-58px-32px-114px)] w-full flex-col gap-4 overflow-hidden'> <div className='flex min-h-[calc(100vh-64px-58px-32px-114px)] w-full flex-col gap-4 overflow-hidden'>
<Announcement /> <Announcement type='pinned' Authorization={(await cookies()).get('Authorization')?.value} />
{userSubscribe.length ? ( <Content />
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:servers' className='size-5' />
{t('mySubscriptions')}
</h2>
<div className='flex flex-wrap justify-between gap-4'>
<Tabs
value={platform}
onValueChange={(value) => setPlatform(value as keyof API.ApplicationResponse)}
className='w-full max-w-full md:w-auto'
>
<TabsList className='flex *:flex-auto'>
{application &&
Object.keys(application)?.map((item) => (
<TabsTrigger value={item} key={item} className='px-1 uppercase lg:px-3'>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{data?.protocol && data?.protocol.length > 1 && (
<Tabs
value={protocol}
onValueChange={setProtocol}
className='w-full max-w-full md:w-auto'
>
<TabsList className='flex *:flex-auto'>
{['all', ...(data?.protocol || [])].map((item) => (
<TabsTrigger
value={item === 'all' ? '' : item}
key={item}
className='px-1 uppercase lg:px-3'
>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
{userSubscribe.map((item) => (
<Card key={item.id}>
<CardHeader className='flex flex-row flex-wrap items-center justify-between gap-2 space-y-0'>
<CardTitle className='font-medium'>{item.subscribe.name}</CardTitle>
<div className='flex gap-2'>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size='sm' variant='destructive'>
{t('resetSubscription')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('prompt')}</AlertDialogTitle>
<AlertDialogDescription>
{t('confirmResetSubscription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => toast.success(t('resetSuccess'))}>
{t('confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResetTraffic
id={item.subscribe_id}
token={item.token}
replacement={item.subscribe.replacement}
/>
<Renewal token={item.token} subscribe={item.subscribe} />
</div>
</CardHeader>
<CardContent>
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4'>
<li>
<span className='text-muted-foreground'>{t('used')}</span>
<span className='text-2xl font-bold'>
<Display
type='traffic'
value={item.upload + item.download}
unlimited={!item.traffic}
/>
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('totalTraffic')}</span>
<span className='text-2xl font-bold'>
<Display type='traffic' value={item.traffic} unlimited={!item.traffic} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('nextResetDays')}</span>
<span className='text-2xl font-semibold'>
{differenceInDays(getNextResetDate(item.start_time), new Date()) ||
t('unknown')}
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('expirationDays')}</span>
<span className='text-2xl font-semibold'>
{differenceInDays(new Date(item.expire_time), new Date()) || t('unknown')}
</span>
</li>
</ul>
<Separator className='mt-4' />
<Accordion type='single' collapsible defaultValue='0' className='w-full'>
{getUserSubscribe(item.token, protocol)?.map((url, index) => (
<AccordionItem key={url} value={String(index)}>
<AccordionTrigger className='hover:no-underline'>
<div className='flex w-full flex-row items-center justify-between'>
<CardTitle className='text-sm font-medium'>
{t('subscriptionUrl')} {index + 1}
</CardTitle>
<CopyToClipboard
text={url}
onCopy={(text, result) => {
if (result) {
toast.success(t('copySuccess'));
}
}}
>
<span
className='text-primary hover:bg-accent mr-4 flex cursor-pointer rounded p-2 text-sm'
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon='uil:copy' className='mr-2 size-5' />
{t('copy')}
</span>
</CopyToClipboard>
</div>
</AccordionTrigger>
<AccordionContent>
<div className='grid grid-cols-3 gap-4 lg:grid-cols-4 xl:grid-cols-7'>
{application &&
application[platform]?.map((app) => (
<div
key={app.name}
className='text-muted-foreground flex size-full flex-col items-center justify-between gap-2 text-xs'
>
<span>{app.name}</span>
{app.icon && (
<Image src={app.icon} alt={app.name} width={50} height={50} />
)}
<div className='flex'>
<Button size='sm' variant='secondary' className='px-1.5' asChild>
<Link href={app.url!}>{t('download')}</Link>
</Button>
<CopyToClipboard
text={url}
onCopy={(text, result) => {
const href = getAppSubLink(app.subscribe_type, url);
if (isBrowser() && href) {
window.location.href = href;
} else if (result) {
toast.success(
<>
<p>{t('copySuccess')}</p>
<p>{t('manualImportMessage')}</p>
</>,
);
}
}}
>
<Button size='sm' className='p-2'>
{t('import')}
</Button>
</CopyToClipboard>
</div>
</div>
))}
<div className='text-muted-foreground hidden size-full flex-col items-center justify-between gap-2 text-sm lg:flex'>
<span>{t('qrCode')}</span>
<QRCodeCanvas
value={url}
size={80}
bgColor='transparent'
fgColor='rgb(59, 130, 246)'
/>
<span className='text-center'>{t('scanToSubscribe')}</span>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
))}
</>
) : (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:shop' className='size-5' />
{t('purchaseSubscription')}
</h2>
<Subscribe />
</>
)}
</div> </div>
); );
} }

View File

@ -1,4 +1,6 @@
import Announcement from '@/components/announcement';
import { SidebarInset, SidebarProvider } from '@shadcn/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@shadcn/ui/sidebar';
import { cookies } from 'next/headers';
import { SidebarLeft } from './sidebar-left'; import { SidebarLeft } from './sidebar-left';
import { SidebarRight } from './sidebar-right'; import { SidebarRight } from './sidebar-right';
@ -8,6 +10,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<SidebarLeft className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' /> <SidebarLeft className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
<SidebarInset className='relative p-4'>{children}</SidebarInset> <SidebarInset className='relative p-4'>{children}</SidebarInset>
<SidebarRight className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent 2xl:flex' /> <SidebarRight className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent 2xl:flex' />
<Announcement type='popup' Authorization={(await cookies()).get('Authorization')?.value} />
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@ -0,0 +1,62 @@
import { queryAnnouncement } from '@/services/user/announcement';
import { Icon } from '@iconify/react';
import { Markdown } from '@repo/ui/markdown';
import { Card } from '@shadcn/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@shadcn/ui/dialog';
import { getTranslations } from 'next-intl/server';
import { Empty } from '../empty';
export default async function Announcement({
type,
Authorization,
}: {
type: 'popup' | 'pinned';
Authorization?: string;
}) {
let data;
try {
data = await queryAnnouncement(
{
page: 1,
size: 50,
},
{
skipErrorHandler: true,
Authorization,
},
).then((res) => {
return res.data.data?.announcements.find((item) => item[type]);
});
} catch (error) {
/* empty */
}
if (!data) return null;
const t = await getTranslations('dashboard');
if (type === 'popup') {
return (
<Dialog defaultOpen={!!data}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>{data?.title}</DialogTitle>
</DialogHeader>
<Markdown>{data?.content}</Markdown>
</DialogContent>
</Dialog>
);
}
if (type === 'pinned') {
return (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:bell' className='size-5' />
{t('latestAnnouncement')}
</h2>
<Card className='p-6'>
{data?.content ? <Markdown>{data?.content}</Markdown> : <Empty />}
</Card>
</>
);
}
}

View File

@ -1,13 +1,15 @@
const config = [ const config = [
{ {
requestLibPath: "import request from '@/utils/request';", requestLibPath: "import request from '@/utils/request';",
schemaPath: 'https://docs.ppanel.dev/swagger/common.json', schemaPath:
'https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/common.json',
serversPath: './services', serversPath: './services',
projectName: 'common', projectName: 'common',
}, },
{ {
requestLibPath: "import request from '@/utils/request';", requestLibPath: "import request from '@/utils/request';",
schemaPath: 'https://docs.ppanel.dev/swagger/user.json', schemaPath:
'https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/user.json',
serversPath: './services', serversPath: './services',
projectName: 'user', projectName: 'user',
}, },

View File

@ -3,8 +3,9 @@ declare namespace API {
id: number; id: number;
title: string; title: string;
content: string; content: string;
enable: boolean; show: boolean;
type: number; pinned: boolean;
popup: boolean;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -321,6 +322,7 @@ declare namespace API {
server: number[]; server: number[];
show: boolean; show: boolean;
sell: boolean; sell: boolean;
sort: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };

View File

@ -3,8 +3,9 @@ declare namespace API {
id: number; id: number;
title: string; title: string;
content: string; content: string;
enable: boolean; show: boolean;
type: number; pinned: boolean;
popup: boolean;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -460,6 +461,7 @@ declare namespace API {
server: number[]; server: number[];
show: boolean; show: boolean;
sell: boolean; sell: boolean;
sort: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };