ClientRowTable.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780
  1. <script setup>
  2. import { computed, ref, watch } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import {
  5. EditOutlined,
  6. InfoCircleOutlined,
  7. QrcodeOutlined,
  8. RetweetOutlined,
  9. DeleteOutlined,
  10. EllipsisOutlined,
  11. } from '@ant-design/icons-vue';
  12. import { Modal } from 'ant-design-vue';
  13. import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
  14. import InfinityIcon from '@/components/InfinityIcon.vue';
  15. import { useDatepicker } from '@/composables/useDatepicker.js';
  16. const { datepicker } = useDatepicker();
  17. const { t } = useI18n();
  18. // Per-inbound expand-row content. CSS-grid layout (not a nested
  19. // <a-table>) so it sits flush inside the parent's expanded cell.
  20. // No API calls here — events bubble to the parent's modals.
  21. const props = defineProps({
  22. dbInbound: { type: Object, required: true },
  23. isMobile: { type: Boolean, default: false },
  24. trafficDiff: { type: Number, default: 0 },
  25. expireDiff: { type: Number, default: 0 },
  26. onlineClients: { type: Array, default: () => [] },
  27. lastOnlineMap: { type: Object, default: () => ({}) },
  28. isDarkTheme: { type: Boolean, default: false },
  29. });
  30. const emit = defineEmits([
  31. 'edit-client',
  32. 'qrcode-client',
  33. 'info-client',
  34. 'reset-traffic-client',
  35. 'delete-client',
  36. 'delete-clients',
  37. 'toggle-enable-client',
  38. ]);
  39. const inbound = computed(() => props.dbInbound.toInbound());
  40. const clients = computed(() => inbound.value?.clients || []);
  41. // === Per-client stats lookup =======================================
  42. const statsMap = computed(() => {
  43. const m = new Map();
  44. for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
  45. return m;
  46. });
  47. function statsFor(email) {
  48. return email ? statsMap.value.get(email) : null;
  49. }
  50. function getUp(email) { return statsFor(email)?.up || 0; }
  51. function getDown(email) { return statsFor(email)?.down || 0; }
  52. function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
  53. function getRem(email) {
  54. const s = statsFor(email);
  55. if (!s) return 0;
  56. const r = s.total - s.up - s.down;
  57. return r > 0 ? r : 0;
  58. }
  59. function getAllTime(email) {
  60. const s = statsFor(email);
  61. if (!s) return 0;
  62. // allTime is the cumulative-historical counter; never let it dip
  63. // below up+down (manual edits / partial migrations can push it under).
  64. const current = (s.up || 0) + (s.down || 0);
  65. return s.allTime > current ? s.allTime : current;
  66. }
  67. function isClientDepleted(email) {
  68. const s = statsFor(email);
  69. if (!s) return false;
  70. const total = s.total ?? 0;
  71. const used = (s.up ?? 0) + (s.down ?? 0);
  72. if (total > 0 && used >= total) return true;
  73. const exp = s.expiryTime ?? 0;
  74. if (exp > 0 && Date.now() >= exp) return true;
  75. return false;
  76. }
  77. function isClientOnline(email) {
  78. return !!email && props.onlineClients.includes(email);
  79. }
  80. function lastOnlineLabel(email) {
  81. const ts = props.lastOnlineMap[email];
  82. if (!ts) return '-';
  83. return IntlUtil.formatDate(ts, datepicker.value);
  84. }
  85. function statsProgress(email) {
  86. const s = statsFor(email);
  87. if (!s) return 0;
  88. if (s.total === 0) return 100;
  89. return (100 * (s.down + s.up)) / s.total;
  90. }
  91. function expireProgress(expTime, reset) {
  92. const now = Date.now();
  93. const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
  94. const resetSec = reset * 86400;
  95. if (remainedSec >= resetSec) return 0;
  96. return 100 * (1 - remainedSec / resetSec);
  97. }
  98. function clientStatsColor(email) {
  99. return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
  100. }
  101. function statsExpColor(email) {
  102. // AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
  103. // matches the row's traffic/expiry tags.
  104. const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
  105. if (!email) return PURPLE;
  106. const s = statsFor(email);
  107. if (!s) return PURPLE;
  108. const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
  109. const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
  110. if (a === 'red' || b === 'red') return DANGER;
  111. if (a === 'orange' || b === 'orange') return WARN;
  112. if (a === 'green' || b === 'green') return SUCCESS;
  113. return PURPLE;
  114. }
  115. const isRemovable = computed(() => clients.value.length > 1);
  116. function totalGbDisplay(client) {
  117. if (!client.totalGB || client.totalGB <= 0) return '';
  118. return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
  119. }
  120. const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
  121. function statusBadgeColor(client) {
  122. if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
  123. return statsExpColor(client.email);
  124. }
  125. // === Action confirms ==============================================
  126. function confirmReset(client) {
  127. Modal.confirm({
  128. title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
  129. content: t('pages.inbounds.resetTrafficContent'),
  130. okText: t('reset'),
  131. cancelText: t('cancel'),
  132. onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
  133. });
  134. }
  135. function confirmDelete(client) {
  136. Modal.confirm({
  137. title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
  138. content: t('pages.inbounds.deleteClientContent'),
  139. okText: t('delete'),
  140. okType: 'danger',
  141. cancelText: t('cancel'),
  142. onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
  143. });
  144. }
  145. // Stable row key for v-for — falls back through email/id/password
  146. // because not every protocol fills the same field.
  147. function rowKey(client) {
  148. return client.email || client.id || client.password || JSON.stringify(client);
  149. }
  150. const selected = ref(new Set());
  151. const allSelected = computed(() =>
  152. clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
  153. );
  154. const someSelected = computed(() =>
  155. clients.value.some((c) => selected.value.has(rowKey(c))),
  156. );
  157. const selectedCount = computed(() => selected.value.size);
  158. function isSelected(key) {
  159. return selected.value.has(key);
  160. }
  161. function toggleSelect(key, next) {
  162. const s = new Set(selected.value);
  163. if (next) s.add(key); else s.delete(key);
  164. selected.value = s;
  165. }
  166. function selectAll(next) {
  167. if (next) {
  168. selected.value = new Set(clients.value.map(rowKey));
  169. } else {
  170. selected.value = new Set();
  171. }
  172. }
  173. function clearSelection() {
  174. selected.value = new Set();
  175. }
  176. watch(clients, (list) => {
  177. if (selected.value.size === 0) return;
  178. const valid = new Set(list.map(rowKey));
  179. const next = new Set();
  180. for (const k of selected.value) if (valid.has(k)) next.add(k);
  181. if (next.size !== selected.value.size) selected.value = next;
  182. });
  183. function confirmBulkDelete() {
  184. const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
  185. if (picked.length === 0) return;
  186. Modal.confirm({
  187. title: t('pages.inbounds.deleteClient') + ` — ${picked.length}`,
  188. content: t('pages.inbounds.deleteClientContent'),
  189. okText: t('delete'),
  190. okType: 'danger',
  191. cancelText: t('cancel'),
  192. onOk: () => {
  193. emit('delete-clients', { dbInbound: props.dbInbound, clients: picked });
  194. clearSelection();
  195. },
  196. });
  197. }
  198. </script>
  199. <template>
  200. <div class="client-list"
  201. :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
  202. <div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
  203. <span class="bulk-count">{{ selectedCount }} selected</span>
  204. <a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
  205. <a-button size="small" danger @click="confirmBulkDelete">
  206. <DeleteOutlined /> {{ t('delete') }}
  207. </a-button>
  208. </div>
  209. <!-- ====================== Desktop: grid table ===================== -->
  210. <template v-if="!isMobile">
  211. <div class="client-row client-list-header">
  212. <div v-if="isRemovable" class="cell cell-select">
  213. <a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
  214. @change="(e) => selectAll(e.target.checked)" />
  215. </div>
  216. <div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
  217. <div class="cell cell-enable">{{ t('enable') }}</div>
  218. <div class="cell cell-online">{{ t('online') }}</div>
  219. <div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
  220. <div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
  221. <div class="cell cell-remained">{{ t('remained') }}</div>
  222. <div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
  223. <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
  224. </div>
  225. <div v-for="client in clients" :key="rowKey(client)" class="client-row"
  226. :class="{ 'is-selected': isSelected(rowKey(client)) }">
  227. <div v-if="isRemovable" class="cell cell-select">
  228. <a-checkbox :checked="isSelected(rowKey(client))"
  229. @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
  230. </div>
  231. <div class="cell cell-actions">
  232. <a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
  233. <QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
  234. </a-tooltip>
  235. <a-tooltip :title="t('edit')">
  236. <EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
  237. </a-tooltip>
  238. <a-tooltip :title="t('info')">
  239. <InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
  240. </a-tooltip>
  241. <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
  242. <RetweetOutlined class="row-icon" @click="confirmReset(client)" />
  243. </a-tooltip>
  244. <a-tooltip v-if="isRemovable" :title="t('delete')">
  245. <DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
  246. </a-tooltip>
  247. </div>
  248. <div class="cell cell-enable">
  249. <a-switch :checked="client.enable" size="small"
  250. @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
  251. </div>
  252. <div class="cell cell-online">
  253. <a-popover>
  254. <template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
  255. <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
  256. <a-tag v-else>{{ t('offline') }}</a-tag>
  257. </a-popover>
  258. </div>
  259. <div class="cell cell-client">
  260. <a-tooltip>
  261. <template #title>
  262. <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
  263. <template v-else-if="!client.enable">{{ t('disabled') }}</template>
  264. <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
  265. <template v-else>{{ t('offline') }}</template>
  266. </template>
  267. <a-badge :color="statusBadgeColor(client)" />
  268. </a-tooltip>
  269. <div class="client-id-stack">
  270. <a-tooltip :title="client.email">
  271. <span class="client-email">{{ client.email }}</span>
  272. </a-tooltip>
  273. <span v-if="client.comment && client.comment.trim()" class="client-comment">
  274. {{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
  275. </span>
  276. </div>
  277. </div>
  278. <div class="cell cell-traffic">
  279. <a-popover>
  280. <template v-if="client.email" #content>
  281. <table cellpadding="2">
  282. <tbody>
  283. <tr>
  284. <td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
  285. <td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
  286. </tr>
  287. <tr v-if="client.totalGB > 0">
  288. <td>{{ t('remained') }}</td>
  289. <td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
  290. </tr>
  291. </tbody>
  292. </table>
  293. </template>
  294. <div class="usage-bar">
  295. <span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
  296. <a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
  297. :show-info="false" :percent="statsProgress(client.email)" size="small" />
  298. <a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
  299. :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
  300. :percent="statsProgress(client.email)" size="small" />
  301. <a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
  302. <span class="usage-text">
  303. <InfinityIcon v-if="isUnlimitedTotal(client)" />
  304. <template v-else>{{ totalGbDisplay(client) }}</template>
  305. </span>
  306. </div>
  307. </a-popover>
  308. </div>
  309. <div class="cell cell-remained">
  310. <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
  311. <InfinityIcon />
  312. </a-tag>
  313. <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
  314. {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
  315. </a-tag>
  316. </div>
  317. <div class="cell cell-alltime">
  318. <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
  319. </div>
  320. <div class="cell cell-expiry">
  321. <template v-if="client.expiryTime !== 0 && client.reset > 0">
  322. <a-popover>
  323. <template #content>
  324. <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
  325. <span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
  326. </template>
  327. <div class="usage-bar">
  328. <span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
  329. <a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
  330. :percent="expireProgress(client.expiryTime, client.reset)" size="small" />
  331. <span class="usage-text">{{ client.reset }}d</span>
  332. </div>
  333. </a-popover>
  334. </template>
  335. <a-popover v-else-if="client.expiryTime !== 0">
  336. <template #content>
  337. <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
  338. <span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
  339. </template>
  340. <a-tag :style="{ minWidth: '50px', border: 'none' }"
  341. :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
  342. {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
  343. </a-tag>
  344. </a-popover>
  345. <a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
  346. class="infinite-tag">
  347. <InfinityIcon />
  348. </a-tag>
  349. </div>
  350. </div>
  351. </template>
  352. <!-- ====================== Mobile: card list ======================= -->
  353. <template v-else>
  354. <div v-for="client in clients" :key="rowKey(client)" class="client-card"
  355. :class="{ 'is-selected': isSelected(rowKey(client)) }">
  356. <div class="client-card-head">
  357. <a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
  358. @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
  359. <a-tooltip>
  360. <template #title>
  361. <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
  362. <template v-else-if="!client.enable">{{ t('disabled') }}</template>
  363. <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
  364. <template v-else>{{ t('offline') }}</template>
  365. </template>
  366. <a-badge :color="statusBadgeColor(client)" />
  367. </a-tooltip>
  368. <a-tooltip :title="client.email">
  369. <span class="client-email">{{ client.email }}</span>
  370. </a-tooltip>
  371. <div class="client-card-actions">
  372. <a-switch :checked="client.enable" size="small"
  373. @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
  374. <a-dropdown :trigger="['click']" placement="bottomRight">
  375. <EllipsisOutlined class="row-icon" @click.prevent />
  376. <template #overlay>
  377. <a-menu>
  378. <a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
  379. <QrcodeOutlined /> {{ t('qrCode') }}
  380. </a-menu-item>
  381. <a-menu-item @click="emit('edit-client', { dbInbound, client })">
  382. <EditOutlined /> {{ t('edit') }}
  383. </a-menu-item>
  384. <a-menu-item @click="emit('info-client', { dbInbound, client })">
  385. <InfoCircleOutlined /> {{ t('info') }}
  386. </a-menu-item>
  387. <a-menu-item v-if="client.email" @click="confirmReset(client)">
  388. <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
  389. </a-menu-item>
  390. <a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
  391. <DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
  392. </a-menu-item>
  393. </a-menu>
  394. </template>
  395. </a-dropdown>
  396. </div>
  397. </div>
  398. <div v-if="client.comment && client.comment.trim()" class="client-comment-line">
  399. {{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
  400. </div>
  401. <div class="client-card-foot">
  402. <div class="stat-row">
  403. <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
  404. <a-tag :color="clientStatsColor(client.email)">
  405. {{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
  406. <InfinityIcon v-if="isUnlimitedTotal(client)" />
  407. <template v-else>{{ totalGbDisplay(client) }}</template>
  408. </a-tag>
  409. </div>
  410. <div class="stat-row">
  411. <span class="stat-label">{{ t('remained') }}</span>
  412. <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
  413. <InfinityIcon />
  414. </a-tag>
  415. <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
  416. {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
  417. </a-tag>
  418. </div>
  419. <div class="stat-row">
  420. <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
  421. <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
  422. </div>
  423. <div class="stat-row">
  424. <span class="stat-label">{{ t('online') }}</span>
  425. <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
  426. <a-tag v-else>{{ t('offline') }}</a-tag>
  427. </div>
  428. <div class="stat-row">
  429. <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
  430. <a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
  431. {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
  432. </a-tag>
  433. <a-tag v-else-if="client.expiryTime < 0" color="green">
  434. {{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
  435. </a-tag>
  436. <a-tag v-else color="purple">
  437. <InfinityIcon />
  438. </a-tag>
  439. </div>
  440. </div>
  441. </div>
  442. </template>
  443. </div>
  444. </template>
  445. <style scoped>
  446. .client-list {
  447. margin: -8px 0;
  448. font-size: 13px;
  449. }
  450. .bulk-bar {
  451. display: flex;
  452. align-items: center;
  453. gap: 12px;
  454. padding: 6px 16px;
  455. background: rgba(22, 119, 255, 0.08);
  456. border-bottom: 1px solid rgba(22, 119, 255, 0.18);
  457. }
  458. .bulk-count {
  459. font-weight: 500;
  460. font-size: 13px;
  461. }
  462. .is-selected {
  463. background: rgba(22, 119, 255, 0.06);
  464. }
  465. .client-row {
  466. display: grid;
  467. /* Default — no select column (single-client inbounds). The .has-select
  468. * modifier below prepends the 40px checkbox column. */
  469. grid-template-columns:
  470. 140px
  471. /* actions */
  472. 60px
  473. /* enable */
  474. 80px
  475. /* online */
  476. minmax(160px, 2fr)
  477. /* client identity */
  478. minmax(160px, 2fr)
  479. /* traffic */
  480. 130px
  481. /* all-time */
  482. 130px
  483. /* remained */
  484. 140px;
  485. /* expiry */
  486. gap: 12px;
  487. align-items: center;
  488. padding: 8px 16px;
  489. border-top: 1px solid rgba(128, 128, 128, 0.12);
  490. }
  491. .client-list.has-select .client-row {
  492. grid-template-columns:
  493. 40px
  494. /* select */
  495. 140px
  496. /* actions */
  497. 60px
  498. /* enable */
  499. 80px
  500. /* online */
  501. minmax(160px, 2fr)
  502. /* client identity */
  503. minmax(160px, 2fr)
  504. /* traffic */
  505. 130px
  506. /* all-time */
  507. 130px
  508. /* remained */
  509. 140px;
  510. /* expiry */
  511. }
  512. .client-row:last-child {
  513. border-bottom: 1px solid rgba(128, 128, 128, 0.12);
  514. }
  515. .client-list-header {
  516. font-weight: 500;
  517. font-size: 12px;
  518. opacity: 0.65;
  519. padding-top: 6px;
  520. padding-bottom: 6px;
  521. border-top: none;
  522. text-transform: uppercase;
  523. letter-spacing: 0.02em;
  524. }
  525. .cell {
  526. min-width: 0;
  527. /* allow grid children to shrink instead of overflowing */
  528. }
  529. .cell-select,
  530. .cell-actions,
  531. .cell-enable,
  532. .cell-online,
  533. .cell-alltime,
  534. .cell-remained {
  535. text-align: center;
  536. display: inline-flex;
  537. align-items: center;
  538. justify-content: center;
  539. gap: 6px;
  540. flex-wrap: wrap;
  541. }
  542. .cell-actions {
  543. justify-content: flex-start;
  544. }
  545. .cell-client {
  546. display: inline-flex;
  547. align-items: center;
  548. gap: 6px;
  549. min-width: 0;
  550. }
  551. .cell-traffic,
  552. .cell-expiry {
  553. text-align: center;
  554. }
  555. .client-list-header .cell {
  556. text-align: center;
  557. }
  558. .client-list-header .cell-actions,
  559. .client-list-header .cell-client {
  560. text-align: left;
  561. }
  562. /* Action icons */
  563. .row-icon {
  564. font-size: 16px;
  565. cursor: pointer;
  566. padding: 0 2px;
  567. color: inherit;
  568. transition: color 120ms ease;
  569. }
  570. .row-icon:hover {
  571. color: var(--ant-color-primary, #1677ff);
  572. }
  573. .row-icon.danger {
  574. color: #ff4d4f;
  575. }
  576. .danger {
  577. color: #ff4d4f;
  578. }
  579. /* Client identity stack (badge + email + comment) */
  580. .client-id-stack {
  581. display: flex;
  582. flex-direction: column;
  583. gap: 2px;
  584. min-width: 0;
  585. overflow: hidden;
  586. }
  587. .client-email {
  588. font-weight: 500;
  589. white-space: nowrap;
  590. overflow: hidden;
  591. text-overflow: ellipsis;
  592. display: inline-block;
  593. }
  594. .client-comment {
  595. font-size: 11px;
  596. opacity: 0.7;
  597. white-space: nowrap;
  598. overflow: hidden;
  599. text-overflow: ellipsis;
  600. display: inline-block;
  601. }
  602. /* Traffic / expiry inline bar: text | progress | text */
  603. .usage-bar {
  604. display: grid;
  605. grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
  606. align-items: center;
  607. gap: 6px;
  608. }
  609. .usage-text {
  610. font-size: 12px;
  611. white-space: nowrap;
  612. }
  613. .usage-bar :deep(.ant-progress) {
  614. margin: 0;
  615. line-height: 1;
  616. }
  617. .infinite-tag {
  618. min-width: 50px;
  619. display: inline-flex;
  620. align-items: center;
  621. justify-content: center;
  622. }
  623. /* Strip AD-Vue's default expanded-cell padding so the desktop grid
  624. * sits flush against the inbound row's left/right edges. */
  625. :deep(.ant-table-expanded-row > .ant-table-cell) {
  626. padding: 0 !important;
  627. }
  628. /* ===== Mobile card list =========================================== */
  629. .client-list.is-mobile {
  630. display: flex;
  631. flex-direction: column;
  632. gap: 8px;
  633. margin: 0;
  634. }
  635. .client-card {
  636. border: 1px solid rgba(128, 128, 128, 0.18);
  637. border-radius: 8px;
  638. padding: 10px 12px;
  639. display: flex;
  640. flex-direction: column;
  641. gap: 6px;
  642. }
  643. :global(body.dark) .client-card {
  644. border-color: rgba(255, 255, 255, 0.1);
  645. }
  646. .client-card-head {
  647. display: flex;
  648. align-items: center;
  649. gap: 8px;
  650. min-width: 0;
  651. }
  652. .client-card-head .client-email {
  653. flex: 1;
  654. min-width: 0;
  655. font-size: 14px;
  656. font-weight: 500;
  657. white-space: nowrap;
  658. overflow: hidden;
  659. text-overflow: ellipsis;
  660. }
  661. .client-card-actions {
  662. margin-left: auto;
  663. display: flex;
  664. align-items: center;
  665. gap: 8px;
  666. flex-shrink: 0;
  667. }
  668. .client-card-actions .row-icon {
  669. font-size: 20px;
  670. padding: 4px;
  671. }
  672. .client-comment-line {
  673. font-size: 11px;
  674. opacity: 0.7;
  675. white-space: nowrap;
  676. overflow: hidden;
  677. text-overflow: ellipsis;
  678. }
  679. .client-card-foot {
  680. display: flex;
  681. flex-direction: column;
  682. gap: 4px;
  683. }
  684. .client-card-foot .stat-row {
  685. display: flex;
  686. align-items: center;
  687. flex-wrap: wrap;
  688. gap: 6px;
  689. }
  690. .client-card-foot .stat-label {
  691. font-size: 10px;
  692. text-transform: uppercase;
  693. letter-spacing: 0.04em;
  694. opacity: 0.6;
  695. min-width: 96px;
  696. flex-shrink: 0;
  697. }
  698. .client-card-foot :deep(.ant-tag) {
  699. margin: 0;
  700. }
  701. /* Bigger status badge for thumb-readable state at a glance. */
  702. .client-card-head :deep(.ant-badge-status-dot) {
  703. width: 9px;
  704. height: 9px;
  705. }
  706. </style>