mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
Merge pull request #205 from BEDOLAGA-DEV/dev
feat: admin panel enhancements & release history
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -70,6 +70,7 @@ const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
|||||||
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
||||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
||||||
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
||||||
|
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
|
||||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||||
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
||||||
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
||||||
@@ -656,6 +657,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/updates"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminUpdates />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users/:id"
|
path="/admin/users/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -308,9 +308,23 @@ export interface RecentPaymentsResponse {
|
|||||||
total_week_kopeks: number;
|
total_week_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemInfo {
|
||||||
|
bot_version: string;
|
||||||
|
python_version: string;
|
||||||
|
uptime_seconds: number;
|
||||||
|
users_total: number;
|
||||||
|
subscriptions_active: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Dashboard Stats API ============
|
// ============ Dashboard Stats API ============
|
||||||
|
|
||||||
export const statsApi = {
|
export const statsApi = {
|
||||||
|
// Get system info
|
||||||
|
getSystemInfo: async (): Promise<SystemInfo> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/stats/system-info');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get complete dashboard stats
|
// Get complete dashboard stats
|
||||||
getDashboardStats: async (): Promise<DashboardStats> => {
|
getDashboardStats: async (): Promise<DashboardStats> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/stats/dashboard');
|
const response = await apiClient.get('/cabinet/admin/stats/dashboard');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface UserTrafficItem {
|
|||||||
user_id: number;
|
user_id: number;
|
||||||
telegram_id: number | null;
|
telegram_id: number | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
tariff_name: string | null;
|
tariff_name: string | null;
|
||||||
subscription_status: string | null;
|
subscription_status: string | null;
|
||||||
@@ -35,6 +36,18 @@ export interface ExportCsvResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrafficEnrichmentData {
|
||||||
|
devices_connected: number;
|
||||||
|
total_spent_kopeks: number;
|
||||||
|
subscription_start_date: string | null;
|
||||||
|
subscription_end_date: string | null;
|
||||||
|
last_node_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficEnrichmentResponse {
|
||||||
|
data: Record<number, TrafficEnrichmentData>;
|
||||||
|
}
|
||||||
|
|
||||||
export type TrafficParams = {
|
export type TrafficParams = {
|
||||||
period?: number;
|
period?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -69,6 +82,11 @@ function buildCacheKey(params: TrafficParams): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enrichmentCache: { data: TrafficEnrichmentResponse | null; timestamp: number } = {
|
||||||
|
data: null,
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export const adminTrafficApi = {
|
export const adminTrafficApi = {
|
||||||
getTrafficUsage: async (
|
getTrafficUsage: async (
|
||||||
params: TrafficParams,
|
params: TrafficParams,
|
||||||
@@ -104,6 +122,22 @@ export const adminTrafficApi = {
|
|||||||
trafficCache.clear();
|
trafficCache.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getEnrichment: async (options?: { skipCache?: boolean }): Promise<TrafficEnrichmentResponse> => {
|
||||||
|
if (
|
||||||
|
!options?.skipCache &&
|
||||||
|
enrichmentCache.data &&
|
||||||
|
Date.now() - enrichmentCache.timestamp < CACHE_TTL
|
||||||
|
) {
|
||||||
|
return enrichmentCache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.get('/cabinet/admin/traffic/enrichment');
|
||||||
|
const data: TrafficEnrichmentResponse = response.data;
|
||||||
|
enrichmentCache.data = data;
|
||||||
|
enrichmentCache.timestamp = Date.now();
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
exportCsv: async (data: {
|
exportCsv: async (data: {
|
||||||
period: number;
|
period: number;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
|
|||||||
32
src/api/adminUpdates.ts
Normal file
32
src/api/adminUpdates.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
// ============ Types ============
|
||||||
|
|
||||||
|
export interface ReleaseItem {
|
||||||
|
tag_name: string;
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
published_at: string;
|
||||||
|
prerelease: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectReleasesInfo {
|
||||||
|
current_version: string;
|
||||||
|
has_updates: boolean;
|
||||||
|
releases: ReleaseItem[];
|
||||||
|
repo_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleasesResponse {
|
||||||
|
bot: ProjectReleasesInfo;
|
||||||
|
cabinet: ProjectReleasesInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ API ============
|
||||||
|
|
||||||
|
export const adminUpdatesApi = {
|
||||||
|
getReleases: async (): Promise<ReleasesResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/updates/releases');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,6 +2,15 @@ import apiClient from './client';
|
|||||||
|
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
|
|
||||||
|
export interface TrafficPurchaseInfo {
|
||||||
|
id: number;
|
||||||
|
traffic_gb: number;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
days_remaining: number;
|
||||||
|
is_expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSubscriptionInfo {
|
export interface UserSubscriptionInfo {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -16,6 +25,8 @@ export interface UserSubscriptionInfo {
|
|||||||
autopay_enabled: boolean;
|
autopay_enabled: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
days_remaining: number;
|
days_remaining: number;
|
||||||
|
purchased_traffic_gb: number;
|
||||||
|
traffic_purchases: TrafficPurchaseInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserPromoGroupInfo {
|
export interface UserPromoGroupInfo {
|
||||||
@@ -187,6 +198,11 @@ export interface UserAvailableTariff {
|
|||||||
price_per_day_kopeks: number;
|
price_per_day_kopeks: number;
|
||||||
min_days: number;
|
min_days: number;
|
||||||
max_days: number;
|
max_days: number;
|
||||||
|
device_price_kopeks: number | null;
|
||||||
|
max_device_limit: number | null;
|
||||||
|
traffic_topup_enabled: boolean;
|
||||||
|
traffic_topup_packages: Record<string, number>;
|
||||||
|
max_topup_traffic_gb: number;
|
||||||
is_available: boolean;
|
is_available: boolean;
|
||||||
requires_promo_group: boolean;
|
requires_promo_group: boolean;
|
||||||
}
|
}
|
||||||
@@ -277,7 +293,10 @@ export interface UpdateSubscriptionRequest {
|
|||||||
| 'toggle_autopay'
|
| 'toggle_autopay'
|
||||||
| 'cancel'
|
| 'cancel'
|
||||||
| 'activate'
|
| 'activate'
|
||||||
| 'create';
|
| 'create'
|
||||||
|
| 'add_traffic'
|
||||||
|
| 'remove_traffic'
|
||||||
|
| 'set_device_limit';
|
||||||
days?: number;
|
days?: number;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
tariff_id?: number;
|
tariff_id?: number;
|
||||||
@@ -286,6 +305,8 @@ export interface UpdateSubscriptionRequest {
|
|||||||
autopay_enabled?: boolean;
|
autopay_enabled?: boolean;
|
||||||
is_trial?: boolean;
|
is_trial?: boolean;
|
||||||
device_limit?: number;
|
device_limit?: number;
|
||||||
|
traffic_gb?: number;
|
||||||
|
traffic_purchase_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSubscriptionResponse {
|
export interface UpdateSubscriptionResponse {
|
||||||
@@ -447,6 +468,17 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Update referral commission
|
||||||
|
updateReferralCommission: async (
|
||||||
|
userId: number,
|
||||||
|
commissionPercent: number | null,
|
||||||
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/referral-commission`, {
|
||||||
|
commission_percent: commissionPercent,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Delete user (soft delete, does NOT remove from Remnawave)
|
// Delete user (soft delete, does NOT remove from Remnawave)
|
||||||
deleteUser: async (
|
deleteUser: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -552,4 +584,33 @@ export const adminUsersApi = {
|
|||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get user devices
|
||||||
|
getUserDevices: async (
|
||||||
|
userId: number,
|
||||||
|
): Promise<{
|
||||||
|
devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[];
|
||||||
|
total: number;
|
||||||
|
device_limit: number;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/devices`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete single device
|
||||||
|
deleteUserDevice: async (
|
||||||
|
userId: number,
|
||||||
|
hwid: string,
|
||||||
|
): Promise<{ success: boolean; message: string; deleted_hwid: string | null }> => {
|
||||||
|
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices/${hwid}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset all devices
|
||||||
|
resetUserDevices: async (
|
||||||
|
userId: number,
|
||||||
|
): Promise<{ success: boolean; message: string; deleted_count: number }> => {
|
||||||
|
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -762,7 +762,8 @@
|
|||||||
"promoOffers": "Promo Offers",
|
"promoOffers": "Promo Offers",
|
||||||
"promocodes": "Promo Codes",
|
"promocodes": "Promo Codes",
|
||||||
"promoGroups": "Discount Groups",
|
"promoGroups": "Discount Groups",
|
||||||
"trafficUsage": "Traffic Usage"
|
"trafficUsage": "Traffic Usage",
|
||||||
|
"updates": "Updates"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -785,7 +786,8 @@
|
|||||||
"promoOffersDesc": "Personal discounts",
|
"promoOffersDesc": "Personal discounts",
|
||||||
"promocodesDesc": "Manage promo codes",
|
"promocodesDesc": "Manage promo codes",
|
||||||
"promoGroupsDesc": "Discount groups for users",
|
"promoGroupsDesc": "Discount groups for users",
|
||||||
"trafficUsageDesc": "Per-user traffic by nodes"
|
"trafficUsageDesc": "Per-user traffic by nodes",
|
||||||
|
"updatesDesc": "Bot & cabinet versions"
|
||||||
},
|
},
|
||||||
"trafficUsage": {
|
"trafficUsage": {
|
||||||
"title": "Traffic Usage",
|
"title": "Traffic Usage",
|
||||||
@@ -822,7 +824,12 @@
|
|||||||
"riskLow": "Low",
|
"riskLow": "Low",
|
||||||
"riskMedium": "Medium",
|
"riskMedium": "Medium",
|
||||||
"riskHigh": "High",
|
"riskHigh": "High",
|
||||||
"riskCritical": "Critical"
|
"riskCritical": "Critical",
|
||||||
|
"connected": "Conn.",
|
||||||
|
"totalSpent": "Spent",
|
||||||
|
"subStart": "Start",
|
||||||
|
"subEnd": "End",
|
||||||
|
"lastNode": "Last Node"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email Templates",
|
"title": "Email Templates",
|
||||||
@@ -1909,11 +1916,20 @@
|
|||||||
"nodeUsage": "Node usage",
|
"nodeUsage": "Node usage",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"panelNotFound": "User not found in panel",
|
"panelNotFound": "User not found in panel",
|
||||||
|
"devices": {
|
||||||
|
"title": "Devices",
|
||||||
|
"none": "No connected devices",
|
||||||
|
"resetAll": "Reset all",
|
||||||
|
"deleted": "Device removed",
|
||||||
|
"allDeleted": "All devices reset"
|
||||||
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "Referral program",
|
"title": "Referral program",
|
||||||
"referrals": "Referrals",
|
"referrals": "Referrals",
|
||||||
"earned": "Earned",
|
"earned": "Earned",
|
||||||
"commission": "Commission"
|
"commission": "Commission",
|
||||||
|
"default": "Default",
|
||||||
|
"invalidPercent": "Commission must be between 0 and 100"
|
||||||
},
|
},
|
||||||
"restrictions": {
|
"restrictions": {
|
||||||
"title": "Restrictions",
|
"title": "Restrictions",
|
||||||
@@ -1958,7 +1974,17 @@
|
|||||||
"unavailable": "(unavailable)",
|
"unavailable": "(unavailable)",
|
||||||
"noActive": "No active subscription",
|
"noActive": "No active subscription",
|
||||||
"create": "Create subscription",
|
"create": "Create subscription",
|
||||||
"creating": "Creating..."
|
"creating": "Creating...",
|
||||||
|
"trafficPackages": "Traffic packages",
|
||||||
|
"addTraffic": "Add traffic",
|
||||||
|
"selectPackage": "Select package",
|
||||||
|
"addButton": "Add",
|
||||||
|
"addTrafficNote": "Admin: free, valid 30 days",
|
||||||
|
"trafficRemoved": "Traffic package removed",
|
||||||
|
"trafficAdded": "Traffic package added",
|
||||||
|
"expired": "expired",
|
||||||
|
"daysLeft": "d left",
|
||||||
|
"deviceLimitUpdated": "Device limit updated"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "Current balance",
|
"current": "Current balance",
|
||||||
@@ -2089,6 +2115,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"adminUpdates": {
|
||||||
|
"title": "Updates",
|
||||||
|
"subtitle": "Bot and cabinet release history",
|
||||||
|
"bot": "Bot",
|
||||||
|
"cabinet": "Cabinet",
|
||||||
|
"currentVersion": "Current version",
|
||||||
|
"upToDate": "Up to date",
|
||||||
|
"updateAvailable": "Update available",
|
||||||
|
"published": "Published",
|
||||||
|
"prerelease": "Pre-release",
|
||||||
|
"refresh": "Check for updates",
|
||||||
|
"noReleases": "No release data"
|
||||||
|
},
|
||||||
"adminDashboard": {
|
"adminDashboard": {
|
||||||
"title": "Statistics Dashboard",
|
"title": "Statistics Dashboard",
|
||||||
"subtitle": "Real-time system overview",
|
"subtitle": "Real-time system overview",
|
||||||
@@ -2181,6 +2220,15 @@
|
|||||||
"purchasedWeek": "Week",
|
"purchasedWeek": "Week",
|
||||||
"purchasedMonth": "Month",
|
"purchasedMonth": "Month",
|
||||||
"noTariffs": "No tariffs"
|
"noTariffs": "No tariffs"
|
||||||
|
},
|
||||||
|
"systemInfo": {
|
||||||
|
"title": "System",
|
||||||
|
"cabinet": "Cabinet",
|
||||||
|
"bot": "Bot",
|
||||||
|
"python": "Python",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"users": "Users",
|
||||||
|
"activeSubs": "Active Subs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banSystem": {
|
"banSystem": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "در حال بارگذاری...",
|
"loading": "در حال بارگذاری...",
|
||||||
"all": "همه",
|
"all": "همه",
|
||||||
@@ -643,7 +643,8 @@
|
|||||||
"promoGroups": "گروههای تخفیف",
|
"promoGroups": "گروههای تخفیف",
|
||||||
"remnawave": "RemnaWave",
|
"remnawave": "RemnaWave",
|
||||||
"users": "کاربران",
|
"users": "کاربران",
|
||||||
"trafficUsage": "مصرف ترافیک"
|
"trafficUsage": "مصرف ترافیک",
|
||||||
|
"updates": "بهروزرسانیها"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
@@ -666,7 +667,8 @@
|
|||||||
"promoGroupsDesc": "گروههای تخفیف برای کاربران",
|
"promoGroupsDesc": "گروههای تخفیف برای کاربران",
|
||||||
"remnawaveDesc": "مدیریت پنل و آمار",
|
"remnawaveDesc": "مدیریت پنل و آمار",
|
||||||
"usersDesc": "مدیریت کاربران ربات",
|
"usersDesc": "مدیریت کاربران ربات",
|
||||||
"trafficUsageDesc": "ترافیک هر کاربر بر اساس نود"
|
"trafficUsageDesc": "ترافیک هر کاربر بر اساس نود",
|
||||||
|
"updatesDesc": "نسخههای ربات و کابینت"
|
||||||
},
|
},
|
||||||
"trafficUsage": {
|
"trafficUsage": {
|
||||||
"title": "مصرف ترافیک",
|
"title": "مصرف ترافیک",
|
||||||
@@ -703,7 +705,12 @@
|
|||||||
"riskLow": "کم",
|
"riskLow": "کم",
|
||||||
"riskMedium": "متوسط",
|
"riskMedium": "متوسط",
|
||||||
"riskHigh": "بالا",
|
"riskHigh": "بالا",
|
||||||
"riskCritical": "بحرانی"
|
"riskCritical": "بحرانی",
|
||||||
|
"connected": "متصل",
|
||||||
|
"totalSpent": "هزینه",
|
||||||
|
"subStart": "شروع",
|
||||||
|
"subEnd": "پایان",
|
||||||
|
"lastNode": "آخرین نود"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "قالبهای ایمیل",
|
"title": "قالبهای ایمیل",
|
||||||
@@ -1612,11 +1619,20 @@
|
|||||||
"nodeUsage": "مصرف نودها",
|
"nodeUsage": "مصرف نودها",
|
||||||
"copied": "کپی شد",
|
"copied": "کپی شد",
|
||||||
"panelNotFound": "کاربر در پنل یافت نشد",
|
"panelNotFound": "کاربر در پنل یافت نشد",
|
||||||
|
"devices": {
|
||||||
|
"title": "دستگاهها",
|
||||||
|
"none": "دستگاه متصلی وجود ندارد",
|
||||||
|
"resetAll": "بازنشانی همه",
|
||||||
|
"deleted": "دستگاه حذف شد",
|
||||||
|
"allDeleted": "همه دستگاهها بازنشانی شدند"
|
||||||
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "برنامه ارجاع",
|
"title": "برنامه ارجاع",
|
||||||
"referrals": "ارجاعات",
|
"referrals": "ارجاعات",
|
||||||
"earned": "درآمد",
|
"earned": "درآمد",
|
||||||
"commission": "کمیسیون"
|
"commission": "کمیسیون",
|
||||||
|
"default": "پیشفرض",
|
||||||
|
"invalidPercent": "کمیسیون باید بین ۰ تا ۱۰۰ باشد"
|
||||||
},
|
},
|
||||||
"restrictions": {
|
"restrictions": {
|
||||||
"title": "محدودیتها",
|
"title": "محدودیتها",
|
||||||
@@ -1657,7 +1673,17 @@
|
|||||||
"unavailable": "(غیرقابل دسترس)",
|
"unavailable": "(غیرقابل دسترس)",
|
||||||
"noActive": "اشتراک فعالی وجود ندارد",
|
"noActive": "اشتراک فعالی وجود ندارد",
|
||||||
"create": "ایجاد اشتراک",
|
"create": "ایجاد اشتراک",
|
||||||
"creating": "در حال ایجاد..."
|
"creating": "در حال ایجاد...",
|
||||||
|
"trafficPackages": "بستههای ترافیک",
|
||||||
|
"addTraffic": "افزودن ترافیک",
|
||||||
|
"selectPackage": "انتخاب بسته",
|
||||||
|
"addButton": "افزودن",
|
||||||
|
"addTrafficNote": "مدیر: رایگان، ۳۰ روز اعتبار",
|
||||||
|
"trafficRemoved": "بسته ترافیک حذف شد",
|
||||||
|
"trafficAdded": "بسته ترافیک اضافه شد",
|
||||||
|
"expired": "منقضی شده",
|
||||||
|
"daysLeft": "روز باقیمانده",
|
||||||
|
"deviceLimitUpdated": "محدودیت دستگاه بهروز شد"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "موجودی فعلی",
|
"current": "موجودی فعلی",
|
||||||
@@ -1904,6 +1930,19 @@
|
|||||||
"saturation": "اشباع"
|
"saturation": "اشباع"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"adminUpdates": {
|
||||||
|
"title": "بهروزرسانیها",
|
||||||
|
"subtitle": "تاریخچه انتشار ربات و کابینت",
|
||||||
|
"bot": "ربات",
|
||||||
|
"cabinet": "کابینت",
|
||||||
|
"currentVersion": "نسخه فعلی",
|
||||||
|
"upToDate": "بهروز است",
|
||||||
|
"updateAvailable": "بهروزرسانی موجود",
|
||||||
|
"published": "منتشر شده",
|
||||||
|
"prerelease": "پیشانتشار",
|
||||||
|
"refresh": "بررسی بهروزرسانی",
|
||||||
|
"noReleases": "دادهای موجود نیست"
|
||||||
|
},
|
||||||
"adminDashboard": {
|
"adminDashboard": {
|
||||||
"title": "پنل آمار",
|
"title": "پنل آمار",
|
||||||
"subtitle": "نمای کلی سیستم در لحظه",
|
"subtitle": "نمای کلی سیستم در لحظه",
|
||||||
@@ -1984,6 +2023,15 @@
|
|||||||
"amount": "مبلغ",
|
"amount": "مبلغ",
|
||||||
"method": "روش",
|
"method": "روش",
|
||||||
"date": "تاریخ"
|
"date": "تاریخ"
|
||||||
|
},
|
||||||
|
"systemInfo": {
|
||||||
|
"title": "سیستم",
|
||||||
|
"cabinet": "کابینت",
|
||||||
|
"bot": "ربات",
|
||||||
|
"python": "Python",
|
||||||
|
"uptime": "آپتایم",
|
||||||
|
"users": "کاربران",
|
||||||
|
"activeSubs": "اشتراکها"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"all": "Все",
|
"all": "Все",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -783,7 +783,8 @@
|
|||||||
"promoOffers": "Промопредложения",
|
"promoOffers": "Промопредложения",
|
||||||
"promocodes": "Промокоды",
|
"promocodes": "Промокоды",
|
||||||
"promoGroups": "Группы скидок",
|
"promoGroups": "Группы скидок",
|
||||||
"trafficUsage": "Расход трафика"
|
"trafficUsage": "Расход трафика",
|
||||||
|
"updates": "Обновления"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -806,7 +807,8 @@
|
|||||||
"promoOffersDesc": "Персональные скидки",
|
"promoOffersDesc": "Персональные скидки",
|
||||||
"promocodesDesc": "Управление промокодами",
|
"promocodesDesc": "Управление промокодами",
|
||||||
"promoGroupsDesc": "Группы скидок для пользователей",
|
"promoGroupsDesc": "Группы скидок для пользователей",
|
||||||
"trafficUsageDesc": "Трафик пользователей по нодам"
|
"trafficUsageDesc": "Трафик пользователей по нодам",
|
||||||
|
"updatesDesc": "Версии бота и кабинета"
|
||||||
},
|
},
|
||||||
"trafficUsage": {
|
"trafficUsage": {
|
||||||
"title": "Расход трафика",
|
"title": "Расход трафика",
|
||||||
@@ -843,7 +845,12 @@
|
|||||||
"riskLow": "Низкий",
|
"riskLow": "Низкий",
|
||||||
"riskMedium": "Средний",
|
"riskMedium": "Средний",
|
||||||
"riskHigh": "Высокий",
|
"riskHigh": "Высокий",
|
||||||
"riskCritical": "Критический"
|
"riskCritical": "Критический",
|
||||||
|
"connected": "Подкл.",
|
||||||
|
"totalSpent": "Траты",
|
||||||
|
"subStart": "Начало",
|
||||||
|
"subEnd": "Конец",
|
||||||
|
"lastNode": "Посл. нода"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email-шаблоны",
|
"title": "Email-шаблоны",
|
||||||
@@ -2435,11 +2442,20 @@
|
|||||||
"nodeUsage": "Расход по нодам",
|
"nodeUsage": "Расход по нодам",
|
||||||
"copied": "Скопировано",
|
"copied": "Скопировано",
|
||||||
"panelNotFound": "Пользователь не найден в панели",
|
"panelNotFound": "Пользователь не найден в панели",
|
||||||
|
"devices": {
|
||||||
|
"title": "Устройства",
|
||||||
|
"none": "Нет подключённых устройств",
|
||||||
|
"resetAll": "Сбросить все",
|
||||||
|
"deleted": "Устройство удалено",
|
||||||
|
"allDeleted": "Все устройства сброшены"
|
||||||
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "Реферальная программа",
|
"title": "Реферальная программа",
|
||||||
"referrals": "Рефералов",
|
"referrals": "Рефералов",
|
||||||
"earned": "Заработано",
|
"earned": "Заработано",
|
||||||
"commission": "Комиссия"
|
"commission": "Комиссия",
|
||||||
|
"default": "По умолч.",
|
||||||
|
"invalidPercent": "Процент должен быть от 0 до 100"
|
||||||
},
|
},
|
||||||
"restrictions": {
|
"restrictions": {
|
||||||
"title": "Ограничения",
|
"title": "Ограничения",
|
||||||
@@ -2484,7 +2500,17 @@
|
|||||||
"unavailable": "(недоступен)",
|
"unavailable": "(недоступен)",
|
||||||
"noActive": "Нет активной подписки",
|
"noActive": "Нет активной подписки",
|
||||||
"create": "Создать подписку",
|
"create": "Создать подписку",
|
||||||
"creating": "Создание..."
|
"creating": "Создание...",
|
||||||
|
"trafficPackages": "Пакеты трафика",
|
||||||
|
"addTraffic": "Добавить трафик",
|
||||||
|
"selectPackage": "Выберите пакет",
|
||||||
|
"addButton": "Добавить",
|
||||||
|
"addTrafficNote": "Админ: бесплатно, действует 30 дней",
|
||||||
|
"trafficRemoved": "Пакет трафика удалён",
|
||||||
|
"trafficAdded": "Пакет трафика добавлен",
|
||||||
|
"expired": "истёк",
|
||||||
|
"daysLeft": "дн. осталось",
|
||||||
|
"deviceLimitUpdated": "Лимит устройств обновлён"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "Текущий баланс",
|
"current": "Текущий баланс",
|
||||||
@@ -2635,6 +2661,19 @@
|
|||||||
"saturation": "Насыщенность"
|
"saturation": "Насыщенность"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"adminUpdates": {
|
||||||
|
"title": "Обновления",
|
||||||
|
"subtitle": "История релизов бота и кабинета",
|
||||||
|
"bot": "Бот",
|
||||||
|
"cabinet": "Кабинет",
|
||||||
|
"currentVersion": "Текущая версия",
|
||||||
|
"upToDate": "Актуальная версия",
|
||||||
|
"updateAvailable": "Доступно обновление",
|
||||||
|
"published": "Опубликовано",
|
||||||
|
"prerelease": "Предрелиз",
|
||||||
|
"refresh": "Проверить обновления",
|
||||||
|
"noReleases": "Нет данных о релизах"
|
||||||
|
},
|
||||||
"adminDashboard": {
|
"adminDashboard": {
|
||||||
"title": "Панель статистики",
|
"title": "Панель статистики",
|
||||||
"subtitle": "Обзор системы в реальном времени",
|
"subtitle": "Обзор системы в реальном времени",
|
||||||
@@ -2732,6 +2771,15 @@
|
|||||||
"amount": "Сумма",
|
"amount": "Сумма",
|
||||||
"method": "Метод",
|
"method": "Метод",
|
||||||
"date": "Дата"
|
"date": "Дата"
|
||||||
|
},
|
||||||
|
"systemInfo": {
|
||||||
|
"title": "Система",
|
||||||
|
"cabinet": "Кабинет",
|
||||||
|
"bot": "Бот",
|
||||||
|
"python": "Python",
|
||||||
|
"uptime": "Аптайм",
|
||||||
|
"users": "Юзеров",
|
||||||
|
"activeSubs": "Подписок"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banSystem": {
|
"banSystem": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
@@ -643,7 +643,8 @@
|
|||||||
"promoGroups": "折扣组",
|
"promoGroups": "折扣组",
|
||||||
"remnawave": "RemnaWave",
|
"remnawave": "RemnaWave",
|
||||||
"users": "用户",
|
"users": "用户",
|
||||||
"trafficUsage": "流量使用"
|
"trafficUsage": "流量使用",
|
||||||
|
"updates": "更新"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
@@ -666,7 +667,8 @@
|
|||||||
"promoGroupsDesc": "用户折扣组",
|
"promoGroupsDesc": "用户折扣组",
|
||||||
"remnawaveDesc": "面板管理和统计",
|
"remnawaveDesc": "面板管理和统计",
|
||||||
"usersDesc": "管理机器人用户",
|
"usersDesc": "管理机器人用户",
|
||||||
"trafficUsageDesc": "按节点统计用户流量"
|
"trafficUsageDesc": "按节点统计用户流量",
|
||||||
|
"updatesDesc": "机器人和面板版本"
|
||||||
},
|
},
|
||||||
"trafficUsage": {
|
"trafficUsage": {
|
||||||
"title": "流量使用",
|
"title": "流量使用",
|
||||||
@@ -703,7 +705,12 @@
|
|||||||
"riskLow": "低",
|
"riskLow": "低",
|
||||||
"riskMedium": "中",
|
"riskMedium": "中",
|
||||||
"riskHigh": "高",
|
"riskHigh": "高",
|
||||||
"riskCritical": "严重"
|
"riskCritical": "严重",
|
||||||
|
"connected": "连接",
|
||||||
|
"totalSpent": "消费",
|
||||||
|
"subStart": "开始",
|
||||||
|
"subEnd": "结束",
|
||||||
|
"lastNode": "最近节点"
|
||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "支付方法",
|
"title": "支付方法",
|
||||||
@@ -1611,11 +1618,20 @@
|
|||||||
"nodeUsage": "节点用量",
|
"nodeUsage": "节点用量",
|
||||||
"copied": "已复制",
|
"copied": "已复制",
|
||||||
"panelNotFound": "面板中未找到用户",
|
"panelNotFound": "面板中未找到用户",
|
||||||
|
"devices": {
|
||||||
|
"title": "设备",
|
||||||
|
"none": "没有已连接的设备",
|
||||||
|
"resetAll": "重置全部",
|
||||||
|
"deleted": "设备已删除",
|
||||||
|
"allDeleted": "所有设备已重置"
|
||||||
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "推荐计划",
|
"title": "推荐计划",
|
||||||
"referrals": "推荐人数",
|
"referrals": "推荐人数",
|
||||||
"earned": "已赚取",
|
"earned": "已赚取",
|
||||||
"commission": "佣金"
|
"commission": "佣金",
|
||||||
|
"default": "默认",
|
||||||
|
"invalidPercent": "佣金必须在0到100之间"
|
||||||
},
|
},
|
||||||
"restrictions": {
|
"restrictions": {
|
||||||
"title": "限制",
|
"title": "限制",
|
||||||
@@ -1656,7 +1672,17 @@
|
|||||||
"unavailable": "(不可用)",
|
"unavailable": "(不可用)",
|
||||||
"noActive": "无活跃订阅",
|
"noActive": "无活跃订阅",
|
||||||
"create": "创建订阅",
|
"create": "创建订阅",
|
||||||
"creating": "创建中..."
|
"creating": "创建中...",
|
||||||
|
"trafficPackages": "流量包",
|
||||||
|
"addTraffic": "添加流量",
|
||||||
|
"selectPackage": "选择套餐",
|
||||||
|
"addButton": "添加",
|
||||||
|
"addTrafficNote": "管理员:免费,有效期30天",
|
||||||
|
"trafficRemoved": "流量包已删除",
|
||||||
|
"trafficAdded": "流量包已添加",
|
||||||
|
"expired": "已过期",
|
||||||
|
"daysLeft": "天剩余",
|
||||||
|
"deviceLimitUpdated": "设备限制已更新"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "当前余额",
|
"current": "当前余额",
|
||||||
@@ -1903,6 +1929,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"adminUpdates": {
|
||||||
|
"title": "更新",
|
||||||
|
"subtitle": "机器人和面板发布历史",
|
||||||
|
"bot": "机器人",
|
||||||
|
"cabinet": "面板",
|
||||||
|
"currentVersion": "当前版本",
|
||||||
|
"upToDate": "已是最新",
|
||||||
|
"updateAvailable": "有可用更新",
|
||||||
|
"published": "发布于",
|
||||||
|
"prerelease": "预发布",
|
||||||
|
"refresh": "检查更新",
|
||||||
|
"noReleases": "没有发布数据"
|
||||||
|
},
|
||||||
"adminDashboard": {
|
"adminDashboard": {
|
||||||
"title": "统计面板",
|
"title": "统计面板",
|
||||||
"subtitle": "实时系统概览",
|
"subtitle": "实时系统概览",
|
||||||
@@ -1983,6 +2022,15 @@
|
|||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
"method": "方法",
|
"method": "方法",
|
||||||
"date": "日期"
|
"date": "日期"
|
||||||
|
},
|
||||||
|
"systemInfo": {
|
||||||
|
"title": "系统",
|
||||||
|
"cabinet": "面板",
|
||||||
|
"bot": "机器人",
|
||||||
|
"python": "Python",
|
||||||
|
"uptime": "运行时间",
|
||||||
|
"users": "用户",
|
||||||
|
"activeSubs": "订阅"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import {
|
|||||||
statsApi,
|
statsApi,
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
type NodeStatus,
|
type NodeStatus,
|
||||||
|
type SystemInfo,
|
||||||
type TopReferrersResponse,
|
type TopReferrersResponse,
|
||||||
type TopCampaignsResponse,
|
type TopCampaignsResponse,
|
||||||
type RecentPaymentsResponse,
|
type RecentPaymentsResponse,
|
||||||
} from '../api/admin';
|
} from '../api/admin';
|
||||||
|
|
||||||
|
const CABINET_VERSION = __APP_VERSION__;
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
@@ -375,6 +378,7 @@ export default function AdminDashboard() {
|
|||||||
const [campaigns, setCampaigns] = useState<TopCampaignsResponse | null>(null);
|
const [campaigns, setCampaigns] = useState<TopCampaignsResponse | null>(null);
|
||||||
const [payments, setPayments] = useState<RecentPaymentsResponse | null>(null);
|
const [payments, setPayments] = useState<RecentPaymentsResponse | null>(null);
|
||||||
const [referrersTab, setReferrersTab] = useState<'earnings' | 'invited'>('earnings');
|
const [referrersTab, setReferrersTab] = useState<'earnings' | 'invited'>('earnings');
|
||||||
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
const fetchStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -392,14 +396,16 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
const fetchExtendedStats = useCallback(async () => {
|
const fetchExtendedStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [referrersData, campaignsData, paymentsData] = await Promise.all([
|
const [referrersData, campaignsData, paymentsData, sysInfoData] = await Promise.all([
|
||||||
statsApi.getTopReferrers(10),
|
statsApi.getTopReferrers(10),
|
||||||
statsApi.getTopCampaigns(10),
|
statsApi.getTopCampaigns(10),
|
||||||
statsApi.getRecentPayments(20),
|
statsApi.getRecentPayments(20),
|
||||||
|
statsApi.getSystemInfo(),
|
||||||
]);
|
]);
|
||||||
setReferrers(referrersData);
|
setReferrers(referrersData);
|
||||||
setCampaigns(campaignsData);
|
setCampaigns(campaignsData);
|
||||||
setPayments(paymentsData);
|
setPayments(paymentsData);
|
||||||
|
setSystemInfo(sysInfoData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load extended stats:', err);
|
console.error('Failed to load extended stats:', err);
|
||||||
}
|
}
|
||||||
@@ -1137,6 +1143,49 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* System Info */}
|
||||||
|
{systemInfo && (
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-dark-300">
|
||||||
|
{t('adminDashboard.systemInfo.title')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-dark-500">{t('adminDashboard.systemInfo.cabinet')}: </span>
|
||||||
|
<span className="font-medium text-dark-200">v{CABINET_VERSION}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-dark-500">{t('adminDashboard.systemInfo.bot')}: </span>
|
||||||
|
<span className="font-medium text-dark-200">v{systemInfo.bot_version}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-dark-500">{t('adminDashboard.systemInfo.python')}: </span>
|
||||||
|
<span className="font-medium text-dark-200">{systemInfo.python_version}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-dark-500">{t('adminDashboard.systemInfo.uptime')}: </span>
|
||||||
|
<span className="font-medium text-dark-200">
|
||||||
|
{(() => {
|
||||||
|
const s = systemInfo.uptime_seconds;
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
return [d > 0 && `${d}d`, h > 0 && `${h}h`, `${m}m`].filter(Boolean).join(' ');
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-dark-500">{t('adminDashboard.systemInfo.users')}: </span>
|
||||||
|
<span className="font-medium text-dark-200">{systemInfo.users_total}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-dark-500">{t('adminDashboard.systemInfo.activeSubs')}: </span>
|
||||||
|
<span className="font-medium text-dark-200">{systemInfo.subscriptions_active}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RemnawaveIcon } from '../components/icons';
|
import { RemnawaveIcon, ArrowPathIcon } from '../components/icons';
|
||||||
|
|
||||||
// Group header icons
|
// Group header icons
|
||||||
const AnalyticsGroupIcon = () => (
|
const AnalyticsGroupIcon = () => (
|
||||||
@@ -473,6 +473,12 @@ export default function AdminPanel() {
|
|||||||
title: t('admin.nav.emailTemplates'),
|
title: t('admin.nav.emailTemplates'),
|
||||||
description: t('admin.panel.emailTemplatesDesc'),
|
description: t('admin.panel.emailTemplatesDesc'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/updates',
|
||||||
|
icon: <ArrowPathIcon className="h-5 w-5" />,
|
||||||
|
title: t('admin.nav.updates'),
|
||||||
|
description: t('admin.panel.updatesDesc'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type TrafficNodeInfo,
|
type TrafficNodeInfo,
|
||||||
type TrafficUsageResponse,
|
type TrafficUsageResponse,
|
||||||
type TrafficParams,
|
type TrafficParams,
|
||||||
|
type TrafficEnrichmentData,
|
||||||
} from '../api/adminTraffic';
|
} from '../api/adminTraffic';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
@@ -48,6 +49,21 @@ const getFlagEmoji = (countryCode: string): string => {
|
|||||||
return String.fromCodePoint(...codePoints);
|
return String.fromCodePoint(...codePoints);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (kopeks: number): string => {
|
||||||
|
const rubles = kopeks / 100;
|
||||||
|
if (rubles === 0) return '0';
|
||||||
|
if (rubles < 10) return rubles.toFixed(2);
|
||||||
|
if (rubles < 1000) return Math.round(rubles).toString();
|
||||||
|
return `${(rubles / 1000).toFixed(1)}k`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatShortDate = (iso: string | null): string => {
|
||||||
|
if (!iso) return '\u2014';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '\u2014';
|
||||||
|
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
const toBackendSortField = (columnId: string): string => {
|
const toBackendSortField = (columnId: string): string => {
|
||||||
if (columnId === 'user') return 'full_name';
|
if (columnId === 'user') return 'full_name';
|
||||||
return columnId;
|
return columnId;
|
||||||
@@ -1049,6 +1065,8 @@ export default function AdminTrafficUsage() {
|
|||||||
const [selectedCountries, setSelectedCountries] = useState<Set<string>>(new Set());
|
const [selectedCountries, setSelectedCountries] = useState<Set<string>>(new Set());
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
const [enrichment, setEnrichment] = useState<Record<number, TrafficEnrichmentData> | null>(null);
|
||||||
|
const [enrichmentLoading, setEnrichmentLoading] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
|
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
|
||||||
@@ -1158,6 +1176,27 @@ export default function AdminTrafficUsage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
// Load enrichment after main data arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialLoading || items.length === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setEnrichmentLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await adminTrafficApi.getEnrichment();
|
||||||
|
if (!cancelled) setEnrichment(res.data);
|
||||||
|
} catch {
|
||||||
|
// silently fail — enrichment is optional
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setEnrichmentLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [initialLoading, items.length]);
|
||||||
|
|
||||||
// Prefetch adjacent periods in background (only in period mode)
|
// Prefetch adjacent periods in background (only in period mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dateMode) return;
|
if (dateMode) return;
|
||||||
@@ -1286,6 +1325,13 @@ export default function AdminTrafficUsage() {
|
|||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
loadData(true);
|
loadData(true);
|
||||||
|
setEnrichment(null);
|
||||||
|
setEnrichmentLoading(true);
|
||||||
|
adminTrafficApi
|
||||||
|
.getEnrichment({ skipCache: true })
|
||||||
|
.then((res) => setEnrichment(res.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setEnrichmentLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableCountries = useMemo(() => {
|
const availableCountries = useMemo(() => {
|
||||||
@@ -1335,11 +1381,15 @@ export default function AdminTrafficUsage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-xs font-medium text-dark-100">{item.full_name}</div>
|
<div className="truncate text-xs font-medium text-dark-100">{item.full_name}</div>
|
||||||
{item.username && (
|
{item.username ? (
|
||||||
<div className="truncate text-[10px] leading-tight text-dark-500">
|
<div className="truncate text-[10px] leading-tight text-dark-500">
|
||||||
@{item.username}
|
@{item.username}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : item.email ? (
|
||||||
|
<div className="truncate text-[10px] leading-tight text-dark-500">
|
||||||
|
{item.email}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1381,6 +1431,90 @@ export default function AdminTrafficUsage() {
|
|||||||
return <span className="text-xs text-dark-300">{gb > 0 ? `${gb} GB` : '\u221E'}</span>;
|
return <span className="text-xs text-dark-300">{gb > 0 ? `${gb} GB` : '\u221E'}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ---- Enrichment columns ----
|
||||||
|
{
|
||||||
|
id: 'connected',
|
||||||
|
header: t('admin.trafficUsage.connected'),
|
||||||
|
size: 65,
|
||||||
|
minSize: 50,
|
||||||
|
enableSorting: true,
|
||||||
|
meta: { align: 'center' as const },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const e = enrichment?.[row.original.user_id];
|
||||||
|
if (enrichmentLoading && !enrichment)
|
||||||
|
return <div className="mx-auto h-4 w-8 animate-pulse rounded bg-dark-700" />;
|
||||||
|
return <span className="text-xs text-dark-300">{e?.devices_connected ?? '\u2014'}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'total_spent',
|
||||||
|
header: t('admin.trafficUsage.totalSpent'),
|
||||||
|
size: 75,
|
||||||
|
minSize: 55,
|
||||||
|
enableSorting: true,
|
||||||
|
meta: { align: 'center' as const },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const e = enrichment?.[row.original.user_id];
|
||||||
|
if (enrichmentLoading && !enrichment)
|
||||||
|
return <div className="mx-auto h-4 w-12 animate-pulse rounded bg-dark-700" />;
|
||||||
|
if (!e || e.total_spent_kopeks === 0)
|
||||||
|
return <span className="text-xs text-dark-300">{'\u2014'}</span>;
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-dark-300">{formatCurrency(e.total_spent_kopeks)}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sub_start',
|
||||||
|
header: t('admin.trafficUsage.subStart'),
|
||||||
|
size: 80,
|
||||||
|
minSize: 65,
|
||||||
|
enableSorting: true,
|
||||||
|
meta: { align: 'center' as const },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const e = enrichment?.[row.original.user_id];
|
||||||
|
if (enrichmentLoading && !enrichment)
|
||||||
|
return <div className="mx-auto h-4 w-14 animate-pulse rounded bg-dark-700" />;
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-dark-300">
|
||||||
|
{formatShortDate(e?.subscription_start_date ?? null)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sub_end',
|
||||||
|
header: t('admin.trafficUsage.subEnd'),
|
||||||
|
size: 80,
|
||||||
|
minSize: 65,
|
||||||
|
enableSorting: true,
|
||||||
|
meta: { align: 'center' as const },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const e = enrichment?.[row.original.user_id];
|
||||||
|
if (enrichmentLoading && !enrichment)
|
||||||
|
return <div className="mx-auto h-4 w-14 animate-pulse rounded bg-dark-700" />;
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-dark-300">
|
||||||
|
{formatShortDate(e?.subscription_end_date ?? null)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last_node',
|
||||||
|
header: t('admin.trafficUsage.lastNode'),
|
||||||
|
size: 100,
|
||||||
|
minSize: 70,
|
||||||
|
enableSorting: true,
|
||||||
|
meta: { align: 'center' as const },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const e = enrichment?.[row.original.user_id];
|
||||||
|
if (enrichmentLoading && !enrichment)
|
||||||
|
return <div className="mx-auto h-4 w-16 animate-pulse rounded bg-dark-700" />;
|
||||||
|
return <span className="text-xs text-dark-300">{e?.last_node_name ?? '\u2014'}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ---- Dynamic node columns ----
|
||||||
...displayNodes.map(
|
...displayNodes.map(
|
||||||
(node): ColumnDef<UserTrafficItem> => ({
|
(node): ColumnDef<UserTrafficItem> => ({
|
||||||
id: `node_${node.node_uuid}`,
|
id: `node_${node.node_uuid}`,
|
||||||
@@ -1486,6 +1620,8 @@ export default function AdminTrafficUsage() {
|
|||||||
totalThresholdNum,
|
totalThresholdNum,
|
||||||
nodeThresholdNum,
|
nodeThresholdNum,
|
||||||
periodDays,
|
periodDays,
|
||||||
|
enrichment,
|
||||||
|
enrichmentLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
377
src/pages/AdminUpdates.tsx
Normal file
377
src/pages/AdminUpdates.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import { adminUpdatesApi, ReleaseItem, ProjectReleasesInfo } from '../api/adminUpdates';
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
|
// ============ Icons ============
|
||||||
|
|
||||||
|
const BackIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RefreshIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.185M2.985 14.652"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BotIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h9a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0015.75 4.5h-7.5A2.25 2.25 0 006 6.75v10.5A2.25 2.25 0 008.25 19.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CabinetIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TagIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||||
|
/>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CalendarIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ExternalLinkIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============ Helpers ============
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripVPrefix(tag: string): string {
|
||||||
|
return tag.replace(/^v/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
const html = md
|
||||||
|
// Headers: ### Title -> <h3>Title</h3>
|
||||||
|
.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
|
||||||
|
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||||
|
// Bold: **text** -> <strong>text</strong>
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
// Inline code: `text` -> <code>text</code>
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
// Links: [text](url) -> <a>text</a>
|
||||||
|
.replace(
|
||||||
|
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||||
|
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process lines into blocks
|
||||||
|
const lines = html.split('\n');
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let listItems: string[] = [];
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (listItems.length > 0) {
|
||||||
|
blocks.push(`<ul>${listItems.join('')}</ul>`);
|
||||||
|
listItems = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
flushList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// List item: * text or - text
|
||||||
|
const listMatch = trimmed.match(/^[*-]\s+(.+)$/);
|
||||||
|
if (listMatch) {
|
||||||
|
listItems.push(`<li>${listMatch[1]}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Already an HTML block element from header replacement
|
||||||
|
if (/^<h[1-4]>/.test(trimmed)) {
|
||||||
|
flushList();
|
||||||
|
blocks.push(trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Regular line
|
||||||
|
flushList();
|
||||||
|
blocks.push(`<p>${trimmed}</p>`);
|
||||||
|
}
|
||||||
|
flushList();
|
||||||
|
|
||||||
|
return DOMPurify.sanitize(blocks.join(''), {
|
||||||
|
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'ul', 'li', 'strong', 'em', 'code', 'a', 'br'],
|
||||||
|
ALLOWED_ATTR: ['href', 'target', 'rel'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Components ============
|
||||||
|
|
||||||
|
function VersionBadge({ hasUpdate }: { hasUpdate: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (hasUpdate) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/20 px-2.5 py-0.5 text-xs font-medium text-amber-400">
|
||||||
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
|
||||||
|
{t('adminUpdates.updateAvailable')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2.5 py-0.5 text-xs font-medium text-emerald-400">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||||
|
{t('adminUpdates.upToDate')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleaseCard({ release }: { release: ReleaseItem }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Content is sanitized via DOMPurify in renderMarkdown — safe for innerHTML
|
||||||
|
const bodyHtml = useMemo(
|
||||||
|
() => (release.body ? renderMarkdown(release.body) : ''),
|
||||||
|
[release.body],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-dark-700/30 px-4 py-3 last:border-b-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-dark-200">
|
||||||
|
<TagIcon />
|
||||||
|
<span className="text-sm font-semibold">{release.tag_name}</span>
|
||||||
|
</div>
|
||||||
|
{release.name !== release.tag_name && (
|
||||||
|
<span className="text-sm text-dark-400">{release.name}</span>
|
||||||
|
)}
|
||||||
|
{release.prerelease && (
|
||||||
|
<span className="rounded-full bg-violet-500/20 px-2 py-0.5 text-[10px] font-medium text-violet-400">
|
||||||
|
{t('adminUpdates.prerelease')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 text-dark-500 sm:ml-auto">
|
||||||
|
<CalendarIcon />
|
||||||
|
<span className="text-xs">{formatDate(release.published_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bodyHtml ? (
|
||||||
|
<div
|
||||||
|
className="release-body max-h-48 overflow-auto rounded-lg bg-dark-900/50 p-3 text-xs leading-relaxed text-dark-300 [&_a]:text-accent-400 [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-accent-300 [&_code]:rounded [&_code]:bg-dark-700/50 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-dark-200 [&_h2]:mb-1.5 [&_h2]:mt-2 [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-dark-200 first:[&_h2]:mt-0 [&_h3]:mb-1 [&_h3]:mt-2 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-dark-200 first:[&_h3]:mt-0 [&_h4]:mb-0.5 [&_h4]:mt-1.5 [&_h4]:text-xs [&_h4]:font-medium [&_h4]:text-dark-300 [&_li]:ml-4 [&_li]:list-disc [&_li]:py-0.5 [&_p]:my-1"
|
||||||
|
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectSection({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
info,
|
||||||
|
currentVersion,
|
||||||
|
hasUpdate,
|
||||||
|
repoUrl,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
info: ProjectReleasesInfo;
|
||||||
|
currentVersion: string;
|
||||||
|
hasUpdate: boolean;
|
||||||
|
repoUrl: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/40">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b border-dark-700/50 px-4 py-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700/50 text-dark-300">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-base font-semibold text-dark-100">{title}</h2>
|
||||||
|
<VersionBadge hasUpdate={hasUpdate} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-dark-500">
|
||||||
|
{t('adminUpdates.currentVersion')}:{' '}
|
||||||
|
<span className="font-mono font-medium text-dark-300">{currentVersion || '—'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={repoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-dark-700/50 px-2.5 py-1.5 text-xs text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
<ExternalLinkIcon />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Releases list */}
|
||||||
|
{info.releases.length > 0 ? (
|
||||||
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
{info.releases.map((release) => (
|
||||||
|
<ReleaseCard key={release.tag_name} release={release} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-dark-500">
|
||||||
|
{t('adminUpdates.noReleases')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Page ============
|
||||||
|
|
||||||
|
export default function AdminUpdates() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||||
|
queryKey: ['admin', 'releases'],
|
||||||
|
queryFn: adminUpdatesApi.getReleases,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cabinet has_updates: compare __APP_VERSION__ with latest release
|
||||||
|
const cabinetHasUpdate = (() => {
|
||||||
|
if (!data?.cabinet.releases.length) return false;
|
||||||
|
try {
|
||||||
|
const latestTag = data.cabinet.releases.find((r) => !r.prerelease)?.tag_name;
|
||||||
|
if (!latestTag) return false;
|
||||||
|
return stripVPrefix(latestTag) !== stripVPrefix(__APP_VERSION__);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in space-y-4">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg border border-dark-700/50 bg-dark-800/40 transition-colors hover:border-dark-600 hover:bg-dark-800"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">{t('adminUpdates.title')}</h1>
|
||||||
|
<p className="text-xs text-dark-500">{t('adminUpdates.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-dark-700/50 bg-dark-800/40 px-3 py-2 text-xs text-dark-300 transition-colors hover:border-dark-600 hover:bg-dark-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className={isFetching ? 'animate-spin' : ''}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline">{t('adminUpdates.refresh')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[0, 1].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-64 animate-pulse rounded-xl border border-dark-700/50 bg-dark-800/40"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{data && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ProjectSection
|
||||||
|
icon={<BotIcon />}
|
||||||
|
title={t('adminUpdates.bot')}
|
||||||
|
info={data.bot}
|
||||||
|
currentVersion={data.bot.current_version}
|
||||||
|
hasUpdate={data.bot.has_updates}
|
||||||
|
repoUrl={data.bot.repo_url}
|
||||||
|
/>
|
||||||
|
<ProjectSection
|
||||||
|
icon={<CabinetIcon />}
|
||||||
|
title={t('adminUpdates.cabinet')}
|
||||||
|
info={data.cabinet}
|
||||||
|
currentVersion={__APP_VERSION__}
|
||||||
|
hasUpdate={cabinetHasUpdate}
|
||||||
|
repoUrl={data.cabinet.repo_url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -186,11 +186,26 @@ export default function AdminUserDetail() {
|
|||||||
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
|
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
|
||||||
const [editingPromoGroup, setEditingPromoGroup] = useState(false);
|
const [editingPromoGroup, setEditingPromoGroup] = useState(false);
|
||||||
|
|
||||||
|
// Referral commission
|
||||||
|
const [editingReferralCommission, setEditingReferralCommission] = useState(false);
|
||||||
|
const [referralCommissionValue, setReferralCommissionValue] = useState<number | ''>('');
|
||||||
|
|
||||||
// Send promo offer
|
// Send promo offer
|
||||||
const [offerDiscountPercent, setOfferDiscountPercent] = useState<number | ''>('');
|
const [offerDiscountPercent, setOfferDiscountPercent] = useState<number | ''>('');
|
||||||
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
|
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
|
||||||
const [offerSending, setOfferSending] = useState(false);
|
const [offerSending, setOfferSending] = useState(false);
|
||||||
|
|
||||||
|
// Traffic packages
|
||||||
|
const [selectedTrafficGb, setSelectedTrafficGb] = useState<string>('');
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
const [devices, setDevices] = useState<
|
||||||
|
{ hwid: string; platform: string; device_model: string; created_at: string | null }[]
|
||||||
|
>([]);
|
||||||
|
const [devicesTotal, setDevicesTotal] = useState(0);
|
||||||
|
const [deviceLimit, setDeviceLimit] = useState(0);
|
||||||
|
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||||
|
|
||||||
const userId = id ? parseInt(id, 10) : null;
|
const userId = id ? parseInt(id, 10) : null;
|
||||||
|
|
||||||
const loadUser = useCallback(async () => {
|
const loadUser = useCallback(async () => {
|
||||||
@@ -289,9 +304,24 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
|
const loadDevices = useCallback(async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
setDevicesLoading(true);
|
||||||
|
const data = await adminUsersApi.getUserDevices(userId);
|
||||||
|
setDevices(data.devices);
|
||||||
|
setDevicesTotal(data.total);
|
||||||
|
setDeviceLimit(data.device_limit);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setDevicesLoading(false);
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
const loadSubscriptionData = useCallback(async () => {
|
const loadSubscriptionData = useCallback(async () => {
|
||||||
await Promise.all([loadPanelInfo(), loadNodeUsage()]);
|
await Promise.all([loadPanelInfo(), loadNodeUsage(), loadDevices()]);
|
||||||
}, [loadPanelInfo, loadNodeUsage]);
|
}, [loadPanelInfo, loadNodeUsage, loadDevices]);
|
||||||
|
|
||||||
const loadPromoGroups = useCallback(async () => {
|
const loadPromoGroups = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -489,6 +519,85 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteDevice = async (hwid: string) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.deleteUserDevice(userId, hwid);
|
||||||
|
notify.success(t('admin.users.detail.devices.deleted'));
|
||||||
|
await loadDevices();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetDevices = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.resetUserDevices(userId);
|
||||||
|
notify.success(t('admin.users.detail.devices.allDeleted'));
|
||||||
|
await loadDevices();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTraffic = async (gb: number) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.updateSubscription(userId, { action: 'add_traffic', traffic_gb: gb });
|
||||||
|
notify.success(t('admin.users.detail.subscription.trafficAdded'));
|
||||||
|
setSelectedTrafficGb('');
|
||||||
|
await loadUser();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTraffic = async (purchaseId: number) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.updateSubscription(userId, {
|
||||||
|
action: 'remove_traffic',
|
||||||
|
traffic_purchase_id: purchaseId,
|
||||||
|
});
|
||||||
|
notify.success(t('admin.users.detail.subscription.trafficRemoved'));
|
||||||
|
await loadUser();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDeviceLimit = async (newLimit: number) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.updateSubscription(userId, {
|
||||||
|
action: 'set_device_limit',
|
||||||
|
device_limit: newLimit,
|
||||||
|
});
|
||||||
|
notify.success(t('admin.users.detail.subscription.deviceLimitUpdated'));
|
||||||
|
await loadUser();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTariff = tariffs.find((t) => t.id === user?.subscription?.tariff_id) || null;
|
||||||
|
|
||||||
const handleChangePromoGroup = async (groupId: number | null) => {
|
const handleChangePromoGroup = async (groupId: number | null) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -503,6 +612,25 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateReferralCommission = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const value = referralCommissionValue === '' ? null : toNumber(referralCommissionValue);
|
||||||
|
if (value !== null && (value < 0 || value > 100)) {
|
||||||
|
notify.error(t('admin.users.detail.referral.invalidPercent'), t('common.error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await adminUsersApi.updateReferralCommission(userId, value);
|
||||||
|
await loadUser();
|
||||||
|
setEditingReferralCommission(false);
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeactivateOffer = async () => {
|
const handleDeactivateOffer = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -850,8 +978,21 @@ export default function AdminUserDetail() {
|
|||||||
|
|
||||||
{/* Referral */}
|
{/* Referral */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
{t('admin.users.detail.referral.title')}
|
{t('admin.users.detail.referral.title')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!editingReferralCommission) {
|
||||||
|
setReferralCommissionValue(user.referral.commission_percent ?? '');
|
||||||
|
}
|
||||||
|
setEditingReferralCommission(!editingReferralCommission);
|
||||||
|
}}
|
||||||
|
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
|
||||||
|
>
|
||||||
|
{editingReferralCommission ? t('common.cancel') : t('common.edit')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -871,12 +1012,38 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
{editingReferralCommission ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={referralCommissionValue}
|
||||||
|
onChange={createNumberInputHandler(setReferralCommissionValue, 0)}
|
||||||
|
placeholder="0-100"
|
||||||
|
className="input w-full text-center text-sm"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
disabled={actionLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateReferralCommission}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="w-full rounded-lg bg-accent-500 px-2 py-1 text-xs text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading ? t('common.loading') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="text-lg font-bold text-dark-100">
|
<div className="text-lg font-bold text-dark-100">
|
||||||
{user.referral.commission_percent || 0}%
|
{user.referral.commission_percent != null
|
||||||
|
? `${user.referral.commission_percent}%`
|
||||||
|
: t('admin.users.detail.referral.default')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-dark-500">
|
<div className="text-xs text-dark-500">
|
||||||
{t('admin.users.detail.referral.commission')}
|
{t('admin.users.detail.referral.commission')}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1053,10 +1220,135 @@ export default function AdminUserDetail() {
|
|||||||
<div className="text-xs text-dark-500">
|
<div className="text-xs text-dark-500">
|
||||||
{t('admin.users.detail.subscription.devices')}
|
{t('admin.users.detail.subscription.devices')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-dark-100">{user.subscription.device_limit}</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetDeviceLimit(user.subscription!.device_limit - 1)}
|
||||||
|
disabled={actionLoading || user.subscription.device_limit <= 1}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md bg-dark-700 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<MinusIcon />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[2ch] text-center text-dark-100">
|
||||||
|
{user.subscription.device_limit}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetDeviceLimit(user.subscription!.device_limit + 1)}
|
||||||
|
disabled={
|
||||||
|
actionLoading ||
|
||||||
|
(currentTariff?.max_device_limit != null &&
|
||||||
|
user.subscription.device_limit >= currentTariff.max_device_limit)
|
||||||
|
}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md bg-dark-700 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Traffic Packages */}
|
||||||
|
{user.subscription.traffic_purchases &&
|
||||||
|
user.subscription.traffic_purchases.length > 0 && (
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.subscription.trafficPackages')}
|
||||||
|
{user.subscription.purchased_traffic_gb > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-dark-400">
|
||||||
|
({user.subscription.purchased_traffic_gb} {t('common.units.gb')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{user.subscription.traffic_purchases.map((tp) => (
|
||||||
|
<div
|
||||||
|
key={tp.id}
|
||||||
|
className={`flex items-center justify-between rounded-lg px-3 py-2 ${
|
||||||
|
tp.is_expired ? 'bg-dark-700/30 opacity-60' : 'bg-dark-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-dark-200">
|
||||||
|
<span className="font-medium">
|
||||||
|
{tp.traffic_gb} {t('common.units.gb')}
|
||||||
|
</span>
|
||||||
|
{tp.is_expired ? (
|
||||||
|
<span className="rounded-full bg-error-500/20 px-1.5 py-0.5 text-[10px] text-error-400">
|
||||||
|
{t('admin.users.detail.subscription.expired')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-dark-400">
|
||||||
|
{tp.days_remaining}{' '}
|
||||||
|
{t('admin.users.detail.subscription.daysLeft')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!tp.is_expired && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleInlineConfirm(`removeTraffic_${tp.id}`, () =>
|
||||||
|
handleRemoveTraffic(tp.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === `removeTraffic_${tp.id}`
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === `removeTraffic_${tp.id}` ? '?' : '\u00D7'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Traffic */}
|
||||||
|
{currentTariff &&
|
||||||
|
currentTariff.traffic_topup_enabled &&
|
||||||
|
Object.keys(currentTariff.traffic_topup_packages).length > 0 && (
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.subscription.addTraffic')}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedTrafficGb}
|
||||||
|
onChange={(e) => setSelectedTrafficGb(e.target.value)}
|
||||||
|
className="input flex-1"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t('admin.users.detail.subscription.selectPackage')}
|
||||||
|
</option>
|
||||||
|
{Object.entries(currentTariff.traffic_topup_packages)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([gb]) => (
|
||||||
|
<option key={gb} value={gb}>
|
||||||
|
{gb} {t('common.units.gb')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
selectedTrafficGb && handleAddTraffic(Number(selectedTrafficGb))
|
||||||
|
}
|
||||||
|
disabled={actionLoading || !selectedTrafficGb}
|
||||||
|
className="shrink-0 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('admin.users.detail.subscription.addButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.subscription.addTrafficNote')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
@@ -1391,6 +1683,87 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Devices */}
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit})
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => loadDevices()}
|
||||||
|
className="rounded-lg p-1 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
|
title={t('common.refresh')}
|
||||||
|
>
|
||||||
|
<RefreshIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleInlineConfirm('resetDevices', handleResetDevices)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-2 py-1 text-xs font-medium transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === 'resetDevices'
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === 'resetDevices'
|
||||||
|
? t('admin.users.detail.actions.areYouSure')
|
||||||
|
: t('admin.users.detail.devices.resetAll')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{devicesLoading ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : devices.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{devices.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.hwid}
|
||||||
|
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs font-medium text-dark-200">
|
||||||
|
{device.platform || device.device_model || device.hwid.slice(0, 12)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-dark-500">
|
||||||
|
{device.device_model && device.platform && (
|
||||||
|
<span>{device.device_model}</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
|
||||||
|
{device.created_at && (
|
||||||
|
<span>{new Date(device.created_at).toLocaleDateString(locale)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
|
||||||
|
handleDeleteDevice(device.hwid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === `deleteDevice_${device.hwid}`
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === `deleteDevice_${device.hwid}` ? '?' : '\u00D7'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-2 text-center text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.devices.none')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@@ -1,5 +1,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL: string;
|
readonly VITE_API_URL: string;
|
||||||
readonly VITE_TELEGRAM_BOT_USERNAME?: string;
|
readonly VITE_TELEGRAM_BOT_USERNAME?: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import packageJson from './package.json';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(packageJson.version),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user