Files
3x-ui/frontend/src/pages/sub/subPageModel.ts
T
Sanaei e26cf1d3ed feat(sub): redesign the subscription page around usage, tabs and app imports
The info page was a long key/value table followed by every link and two
app dropdowns, and it rendered left-to-right even for Persian and Arabic.
It now leads with a usage ring, the remaining quota and a stats grid, and
splits the rest into Subscription / Apps / Configs tabs.

- Status tells expired, data-used-up and disabled apart instead of one
  "Inactive", replacing the hard-coded English expiry chip.
- The Apps tab keeps every Android and iOS app with its existing deep
  link, preselects the visitor's platform and adds Windows: Hiddify and
  Clash Verge Rev import directly, v2rayN copies the link.
- fa-IR and ar-EG render right-to-left; URLs, IDs and sizes stay LTR.
- The footer shows the support link and the client refresh interval, so
  subPageContext now carries subUpdates (also in ?format=info).
- Status, days-left and app deep-link logic lives in subPageModel.ts,
  with unit tests pinning the deep links the page already shipped.
2026-09-16 01:55:56 +02:00

94 lines
2.6 KiB
TypeScript

const DAY_MS = 86_400_000;
export type SubStatus = 'active' | 'unlimited' | 'expired' | 'depleted' | 'disabled';
export interface SubUsage {
enabled: boolean;
usedByte: number;
totalByte: number;
expireMs: number;
}
export function resolveSubStatus(sub: SubUsage, now: number): SubStatus {
if (!sub.enabled) return 'disabled';
if (sub.expireMs > 0 && now >= sub.expireMs) return 'expired';
if (sub.totalByte > 0 && sub.usedByte >= sub.totalByte) return 'depleted';
if (sub.totalByte <= 0 && sub.expireMs === 0) return 'unlimited';
return 'active';
}
export function daysUntil(expireMs: number, now: number): number | null {
if (expireMs <= 0) return null;
return Math.max(0, Math.ceil((expireMs - now) / DAY_MS));
}
export function usagePercent(usedByte: number, totalByte: number): number {
if (totalByte <= 0) return 0;
const pct = (usedByte / totalByte) * 100;
return Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
}
export type AppPlatform = 'android' | 'ios';
export function detectPlatform(userAgent: string): AppPlatform {
// iPadOS sends a Macintosh UA, and App Store clients also run on Apple-silicon Macs.
if (/iphone|ipad|ipod|macintosh/i.test(userAgent)) return 'ios';
return 'android';
}
export interface SubApp {
name: string;
url: string;
}
export interface SubAppSource {
subUrl: string;
sId: string;
subTitle: string;
}
export function buildSubApps({
subUrl,
sId,
subTitle,
}: SubAppSource): Record<AppPlatform, SubApp[]> {
const encSub = encodeURIComponent(subUrl);
const profileName = encodeURIComponent(subTitle || sId);
const v2box = {
name: 'V2Box',
url: `v2box://install-sub?url=${encSub}&name=${encodeURIComponent(sId)}`,
};
const singBox = {
name: 'Sing-box',
url: `sing-box://import-remote-profile?url=${encSub}#${profileName}`,
};
const v2raytun = { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` };
const happ = { name: 'Happ', url: `happ://add/${subUrl}` };
const incy = { name: 'Incy', url: `incy://add/${subUrl}` };
const rocketSource = `${subUrl}${subUrl.includes('?') ? '&' : '?'}flag=shadowrocket`;
const rocketRemark = encodeURIComponent(subTitle || sId || 'Subscription');
return {
android: [
v2box,
{ name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
singBox,
v2raytun,
happ,
incy,
],
ios: [
{
name: 'Shadowrocket',
url: `shadowrocket://add/sub://${btoa(rocketSource)}?remark=${rocketRemark}`,
},
v2box,
{ name: 'Streisand', url: `streisand://import/${encSub}` },
v2raytun,
happ,
incy,
],
};
}