3x-ui/frontend/src/pages/inbounds/QrPanel.tsx
MHSanaei ef36757b88
refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).

New shared infra (lives alongside vue twins until inbounds migrates):

* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
  attach/detach + traffic reset, with WebSocket event handlers
  (traffic, client_stats, invalidate) and a small debounced refresh
  on the invalidate event. State managed via setState; the live
  client_stats event merges traffic snapshots row-by-row through a
  ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
  with subscribe/notify so multiple components can read the panel's
  Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
  vue3-persian-datetime-picker has no React port; the Jalali UI
  calendar is deferred (read-only Jalali display via IntlUtil
  formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
  shared between clients (qr modal) and inbounds (still on vue).
  Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
  the clients form needs. The full inbound model stays as
  inbound.js for now; inbounds will pull it in as inbound.ts.

The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.

The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.

ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
2026-05-21 22:03:31 +02:00

128 lines
3.8 KiB
TypeScript

import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, QRCode, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons';
import { ClipboardManager, FileManager } from '@/utils';
import './QrPanel.css';
interface QrPanelProps {
value: string;
remark?: string;
downloadName?: string;
size?: number;
showQr?: boolean;
}
async function svgToPngBlob(svgEl: SVGSVGElement | null, size: number): Promise<Blob | null> {
if (!svgEl) return null;
const svgData = new XMLSerializer().serializeToString(svgEl);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
return new Promise<Blob | null>((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
resolve(null);
return;
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => resolve(blob), 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
function downloadImageBlob(blob: Blob, remark: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${remark || 'qrcode'}.png`;
link.click();
URL.revokeObjectURL(url);
}
export default function QrPanel({
value,
remark = '',
downloadName = '',
size = 360,
showQr = true,
}: QrPanelProps) {
const { t } = useTranslation();
const qrRef = useRef<HTMLDivElement | null>(null);
async function copy() {
const ok = await ClipboardManager.copyText(value);
if (ok) message.success(t('copied'));
}
function download() {
if (!downloadName) return;
FileManager.downloadTextFile(value, downloadName);
}
async function copyImage() {
const svgEl = qrRef.current?.querySelector('svg') as SVGSVGElement | null;
const blob = await svgToPngBlob(svgEl, size);
if (!blob) return;
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
message.success(t('copied'));
} catch {
downloadImageBlob(blob, remark);
}
}
async function downloadImage() {
const svgEl = qrRef.current?.querySelector('svg') as SVGSVGElement | null;
const blob = await svgToPngBlob(svgEl, size);
if (blob) downloadImageBlob(blob, remark);
}
return (
<div className="qr-panel">
<div className="qr-panel-header">
<Tag color="green" className="qr-remark">{remark}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={copy} />
</Tooltip>
{showQr && (
<Tooltip title={t('downloadImage') !== 'downloadImage' ? t('downloadImage') : 'Download Image'}>
<Button size="small" icon={<PictureOutlined />} onClick={downloadImage} />
</Tooltip>
)}
{downloadName && (
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} onClick={download} />
</Tooltip>
)}
</div>
{showQr && (
<div ref={qrRef} className="qr-panel-canvas">
<Tooltip title={t('copy')}>
<QRCode
className="qr-code"
value={value}
size={size}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
onClick={copyImage}
/>
</Tooltip>
</div>
)}
</div>
);
}