index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. <script setup lang="ts">
  2. import { Page } from '@vben/common-ui';
  3. import { computed, h, onMounted, onUnmounted, ref } from 'vue';
  4. import { DesktopOutlined } from '@ant-design/icons-vue';
  5. import { Button, Form, InputNumber, message, Modal, Space, Switch, Table, Tag, Tooltip } from 'ant-design-vue';
  6. import dayjs from 'dayjs';
  7. import { requestClient } from '#/api/request';
  8. type SyncClient = {
  9. dataType?: string;
  10. device?: string;
  11. deviceId?: number;
  12. firstRequestTime?: number;
  13. groupSequence?: string;
  14. ip?: string;
  15. key: string;
  16. lastRequestTime?: number;
  17. marketType?: string;
  18. platform?: string;
  19. requestCount?: number;
  20. route?: string;
  21. version?: string;
  22. };
  23. type SyncClientRow = SyncClient & {
  24. children?: SyncClientRow[];
  25. count?: number;
  26. onlineCount?: number;
  27. rowKey: string;
  28. rowType: 'client' | 'dataType' | 'marketType' | 'platform';
  29. title: string;
  30. };
  31. const CHAR_MAP: Record<string, Record<string, string>> = {
  32. dataType: {
  33. 1: '列表',
  34. 2: '让球/大小',
  35. 3: '特别投注',
  36. },
  37. marketType: {
  38. 0: '早盘',
  39. 1: '今日',
  40. 2: '滚球',
  41. },
  42. platform: {
  43. ay: 'IM',
  44. hg: '皇冠',
  45. im: 'IM',
  46. ob: 'OB',
  47. ps: '平博',
  48. tq: 'OB',
  49. },
  50. };
  51. const MARKET_TYPE_ORDER = ['2', '1', '0'];
  52. const clients = ref<SyncClient[]>([]);
  53. const latestClientVersion = ref('');
  54. const loading = ref(false);
  55. const autoRefresh = ref(true);
  56. const editClient = ref<SyncClient>();
  57. const editDeviceId = ref<number>();
  58. const editVisible = ref(false);
  59. const refreshTimer = ref<ReturnType<typeof setTimeout>>();
  60. const deviceWindows = new Map<number, Window>();
  61. const columns = [
  62. {
  63. dataIndex: 'title',
  64. key: 'title',
  65. title: '分类',
  66. width: 180,
  67. },
  68. {
  69. align: 'center',
  70. dataIndex: 'groupSequence',
  71. key: 'groupSequence',
  72. title: '分组序列',
  73. width: 80,
  74. },
  75. {
  76. align: 'center',
  77. dataIndex: 'requestCount',
  78. key: 'requestCount',
  79. title: '请求次数',
  80. width: 100,
  81. },
  82. {
  83. align: 'center',
  84. dataIndex: 'lastRequestTime',
  85. key: 'lastRequestTime',
  86. title: '最后请求时间',
  87. width: 180,
  88. },
  89. {
  90. align: 'center',
  91. dataIndex: 'firstRequestTime',
  92. key: 'firstRequestTime',
  93. title: '初次请求时间',
  94. width: 180,
  95. },
  96. {
  97. align: 'center',
  98. dataIndex: 'route',
  99. key: 'route',
  100. title: '请求接口',
  101. width: 180,
  102. },
  103. {
  104. align: 'left',
  105. dataIndex: 'deviceId',
  106. key: 'deviceId',
  107. title: '设备',
  108. width: 160,
  109. },
  110. {
  111. key: 'action',
  112. title: '操作',
  113. width: 120,
  114. },
  115. ];
  116. const onlineThreshold = 5 * 60 * 1000;
  117. const onlineCount = computed(() => {
  118. const now = Date.now();
  119. return clients.value.filter(item => now - (item.lastRequestTime ?? 0) <= onlineThreshold).length;
  120. });
  121. const formatTime = (time?: number) => {
  122. return time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-';
  123. };
  124. const displayValue = (value?: number | string) => {
  125. return value === undefined || value === '' ? '-' : value;
  126. };
  127. const displayMappedValue = (
  128. type: keyof typeof CHAR_MAP,
  129. value?: number | string,
  130. ) => {
  131. if (value === undefined || value === '') {
  132. return '-';
  133. }
  134. return CHAR_MAP[type]?.[String(value)] ?? value;
  135. };
  136. const isClientVersionOutdated = (version?: string) => {
  137. return !!version && !!latestClientVersion.value && version !== latestClientVersion.value;
  138. };
  139. const getDeviceUrl = (deviceId?: number) => {
  140. if (!Number.isInteger(deviceId)) {
  141. return '';
  142. }
  143. const payload = btoa(`${deviceId}\x00c\x00mysql`);
  144. return `https://rdp.long.bid/#/client/${payload}`;
  145. };
  146. const openDevice = (deviceId?: number) => {
  147. if (!Number.isInteger(deviceId)) {
  148. return;
  149. }
  150. const existingWindow = deviceWindows.get(deviceId);
  151. if (existingWindow && !existingWindow.closed) {
  152. existingWindow.focus();
  153. return;
  154. }
  155. const deviceWindow = window.open(getDeviceUrl(deviceId), `rdp-device-${deviceId}`);
  156. if (deviceWindow) {
  157. deviceWindows.set(deviceId, deviceWindow);
  158. deviceWindow.focus();
  159. }
  160. };
  161. const isOnline = (time?: number) => {
  162. return !!time && Date.now() - time <= onlineThreshold;
  163. };
  164. const getGroupKey = (value?: string) => {
  165. return value === undefined || value === '' ? '__empty__' : String(value);
  166. };
  167. const getGroupTitle = (type: keyof typeof CHAR_MAP, value?: string) => {
  168. return displayMappedValue(type, value);
  169. };
  170. const getLatestTime = (items: SyncClient[]) => {
  171. return Math.max(...items.map(item => item.lastRequestTime ?? 0));
  172. };
  173. const getRequestCount = (items: SyncClient[]) => {
  174. return items.reduce((total, item) => total + (item.requestCount ?? 0), 0);
  175. };
  176. const getOnlineCount = (items: SyncClient[]) => {
  177. return items.filter(item => isOnline(item.lastRequestTime)).length;
  178. };
  179. const createClientRow = (client: SyncClient): SyncClientRow => ({
  180. ...client,
  181. rowKey: `client:${client.key}`,
  182. rowType: 'client',
  183. title: client.device || '客户端',
  184. });
  185. const createGroupRow = (
  186. rowType: SyncClientRow['rowType'],
  187. rowKey: string,
  188. title: string,
  189. items: SyncClient[],
  190. children: SyncClientRow[],
  191. ): SyncClientRow => ({
  192. rowKey,
  193. key: rowKey,
  194. rowType,
  195. title,
  196. children,
  197. count: items.length,
  198. lastRequestTime: getLatestTime(items),
  199. onlineCount: getOnlineCount(items),
  200. requestCount: getRequestCount(items),
  201. });
  202. const groupByField = (items: SyncClient[], field: keyof SyncClient) => {
  203. return items.reduce<Record<string, SyncClient[]>>((groups, item) => {
  204. const key = getGroupKey(item[field] as string | undefined);
  205. if (!groups[key]) {
  206. groups[key] = [];
  207. }
  208. groups[key].push(item);
  209. return groups;
  210. }, {});
  211. };
  212. const sortGroupEntries = (entries: Array<[string, SyncClient[]]>) => {
  213. return entries.sort(([keyA], [keyB]) => {
  214. if (keyA === '__empty__') {
  215. return 1;
  216. }
  217. if (keyB === '__empty__') {
  218. return -1;
  219. }
  220. return keyA.localeCompare(keyB, 'zh-CN', { numeric: true });
  221. });
  222. };
  223. const sortMarketEntries = (entries: Array<[string, SyncClient[]]>) => {
  224. return entries.sort(([keyA], [keyB]) => {
  225. const indexA = MARKET_TYPE_ORDER.indexOf(keyA);
  226. const indexB = MARKET_TYPE_ORDER.indexOf(keyB);
  227. if (indexA >= 0 && indexB >= 0) {
  228. return indexA - indexB;
  229. }
  230. if (indexA >= 0) {
  231. return -1;
  232. }
  233. if (indexB >= 0) {
  234. return 1;
  235. }
  236. return keyA.localeCompare(keyB, 'zh-CN', { numeric: true });
  237. });
  238. };
  239. const sortClientsByGroupSequence = (items: SyncClient[]) => {
  240. return [...items].sort((a, b) => {
  241. const sequenceA = getGroupKey(a.groupSequence);
  242. const sequenceB = getGroupKey(b.groupSequence);
  243. if (sequenceA === '__empty__') {
  244. return 1;
  245. }
  246. if (sequenceB === '__empty__') {
  247. return -1;
  248. }
  249. return sequenceA.localeCompare(sequenceB, 'zh-CN', { numeric: true });
  250. });
  251. };
  252. const createPlatformRows = (items: SyncClient[], keyPrefix: string) => {
  253. const platformGroups = groupByField(items, 'platform');
  254. return sortGroupEntries(Object.entries(platformGroups)).map(([platform, platformItems]) => {
  255. return createGroupRow(
  256. 'platform',
  257. `${keyPrefix}:platform:${platform}`,
  258. getGroupTitle('platform', platform === '__empty__' ? undefined : platform),
  259. platformItems,
  260. sortClientsByGroupSequence(platformItems).map(createClientRow),
  261. );
  262. });
  263. };
  264. const groupedClients = computed<SyncClientRow[]>(() => {
  265. const listClients = clients.value.filter(item => String(item.dataType) === '1');
  266. const marketClients = clients.value.filter(item => String(item.dataType) !== '1');
  267. const rows: SyncClientRow[] = [];
  268. if (listClients.length) {
  269. rows.push(createGroupRow(
  270. 'dataType',
  271. 'data:1',
  272. getGroupTitle('dataType', '1'),
  273. listClients,
  274. createPlatformRows(listClients, 'data:1'),
  275. ));
  276. }
  277. const marketGroups = groupByField(marketClients, 'marketType');
  278. const marketRows = sortMarketEntries(Object.entries(marketGroups)).map(([marketType, marketItems]) => {
  279. const dataGroups = groupByField(marketItems, 'dataType');
  280. const dataChildren = sortGroupEntries(Object.entries(dataGroups)).map(([dataType, dataItems]) => {
  281. return createGroupRow(
  282. 'dataType',
  283. `market:${marketType}:data:${dataType}`,
  284. getGroupTitle('dataType', dataType === '__empty__' ? undefined : dataType),
  285. dataItems,
  286. createPlatformRows(dataItems, `market:${marketType}:data:${dataType}`),
  287. );
  288. });
  289. return createGroupRow(
  290. 'marketType',
  291. `market:${marketType}`,
  292. getGroupTitle('marketType', marketType === '__empty__' ? undefined : marketType),
  293. marketItems,
  294. dataChildren,
  295. );
  296. });
  297. return [...rows, ...marketRows];
  298. });
  299. const expandedRowKeys = computed(() => {
  300. const keys: string[] = [];
  301. const collectKeys = (rows: SyncClientRow[]) => {
  302. rows.forEach((row) => {
  303. if (row.children?.length) {
  304. keys.push(row.rowKey);
  305. collectKeys(row.children);
  306. }
  307. });
  308. };
  309. collectKeys(groupedClients.value);
  310. return keys;
  311. });
  312. const renderExpandIcon = () => h('span', { class: 'hidden-expand-icon' });
  313. const clearRefreshTimer = () => {
  314. if (refreshTimer.value) {
  315. clearTimeout(refreshTimer.value);
  316. refreshTimer.value = undefined;
  317. }
  318. };
  319. const scheduleRefresh = () => {
  320. clearRefreshTimer();
  321. if (!autoRefresh.value) {
  322. return;
  323. }
  324. refreshTimer.value = setTimeout(fetchClients, 30 * 1000);
  325. };
  326. const fetchClients = async () => {
  327. loading.value = true;
  328. try {
  329. const data = await requestClient.get<SyncClient[]>('/pstery/get_clients');
  330. clients.value = data ?? [];
  331. }
  332. catch (error) {
  333. console.error('Failed to fetch sync clients:', error);
  334. message.error('获取数据同步客户端失败');
  335. }
  336. finally {
  337. loading.value = false;
  338. scheduleRefresh();
  339. }
  340. };
  341. const fetchLatestClientVersion = async () => {
  342. try {
  343. const response = await fetch('https://bwh.841024.xyz/chrome_update/versions?names=events_sync');
  344. const data = await response.json();
  345. const version = data?.data?.[0];
  346. if (data?.state === 1 && typeof version === 'string') {
  347. latestClientVersion.value = version;
  348. }
  349. }
  350. catch (error) {
  351. console.error('Failed to fetch latest client version:', error);
  352. }
  353. };
  354. const openEditClient = (client: SyncClientRow) => {
  355. editClient.value = client;
  356. editDeviceId.value = client.deviceId;
  357. editVisible.value = true;
  358. };
  359. const saveClient = async () => {
  360. if (!editClient.value?.key) {
  361. return;
  362. }
  363. try {
  364. await requestClient.post('/pstery/update_client', {
  365. deviceId: editDeviceId.value,
  366. key: editClient.value.key,
  367. });
  368. message.success('保存成功');
  369. editVisible.value = false;
  370. await fetchClients();
  371. }
  372. catch (error) {
  373. console.error('Failed to save sync client:', error);
  374. message.error('保存客户端信息失败');
  375. }
  376. };
  377. const deleteClient = (client: SyncClientRow) => {
  378. Modal.confirm({
  379. cancelText: '取消',
  380. content: `确定删除客户端 ${client.title} 吗?`,
  381. okText: '删除',
  382. okType: 'danger',
  383. title: '删除客户端',
  384. onOk: async () => {
  385. try {
  386. await requestClient.post('/pstery/delete_client', { key: client.key });
  387. message.success('删除成功');
  388. await fetchClients();
  389. }
  390. catch (error) {
  391. console.error('Failed to delete sync client:', error);
  392. message.error('删除客户端失败');
  393. }
  394. },
  395. });
  396. };
  397. const handleAutoRefreshChange = () => {
  398. scheduleRefresh();
  399. };
  400. onMounted(() => {
  401. fetchLatestClientVersion();
  402. fetchClients();
  403. });
  404. onUnmounted(() => {
  405. clearRefreshTimer();
  406. });
  407. </script>
  408. <template>
  409. <Page title="数据同步">
  410. <div class="data-sync-toolbar">
  411. <Space>
  412. <Button type="primary" :loading="loading" @click="fetchClients">刷新</Button>
  413. <span class="summary">
  414. 在线 {{ onlineCount }} / 总计 {{ clients.length }}
  415. </span>
  416. </Space>
  417. <Space>
  418. <span class="auto-label">自动刷新</span>
  419. <Switch v-model:checked="autoRefresh" @change="handleAutoRefreshChange" />
  420. </Space>
  421. </div>
  422. <Table
  423. :columns="columns"
  424. :data-source="groupedClients"
  425. :expanded-row-keys="expandedRowKeys"
  426. :expand-icon="renderExpandIcon"
  427. :loading="loading"
  428. :pagination="false"
  429. :row-key="record => record.rowKey"
  430. bordered
  431. size="small"
  432. :scroll="{ x: 900 }"
  433. >
  434. <template #bodyCell="{ column, record, text }">
  435. <template v-if="column.key === 'title'">
  436. <Space>
  437. <Tag v-if="record.rowType === 'client'" :color="isOnline(record.lastRequestTime) ? 'green' : 'red'">
  438. {{ isOnline(record.lastRequestTime) ? '在线' : '离线' }}
  439. </Tag>
  440. <span>{{ record.title }}</span>
  441. <span
  442. v-if="record.rowType === 'client' && record.version"
  443. class="version-text"
  444. :class="{ outdated: isClientVersionOutdated(record.version) }"
  445. >
  446. {{ record.version }}
  447. </span>
  448. <span v-if="record.rowType !== 'client'" class="group-count">
  449. {{ record.onlineCount ?? 0 }} / {{ record.count ?? 0 }}
  450. </span>
  451. </Space>
  452. </template>
  453. <template v-else-if="column.key === 'lastRequestTime'">
  454. <span v-if="record.rowType === 'client'">{{ formatTime(record.lastRequestTime) }}</span>
  455. <span v-else>-</span>
  456. </template>
  457. <template v-else-if="column.key === 'firstRequestTime'">
  458. <span v-if="record.rowType === 'client'">{{ formatTime(record.firstRequestTime) }}</span>
  459. <span v-else>-</span>
  460. </template>
  461. <template v-else-if="record.rowType !== 'client'">
  462. -
  463. </template>
  464. <template v-else-if="column.key === 'action'">
  465. <Space>
  466. <Button size="small" type="link" @click="openEditClient(record)">编辑</Button>
  467. <Button danger size="small" type="link" @click="deleteClient(record)">删除</Button>
  468. </Space>
  469. </template>
  470. <template v-else-if="column.key === 'requestCount'">
  471. {{ record.requestCount ?? 0 }}
  472. </template>
  473. <template v-else-if="column.key === 'deviceId'">
  474. <Space>
  475. <Tooltip v-if="Number.isInteger(record.deviceId)" title="打开设备">
  476. <Button
  477. class="device-link"
  478. size="small"
  479. type="link"
  480. @click="openDevice(record.deviceId)"
  481. >
  482. <DesktopOutlined />
  483. </Button>
  484. </Tooltip>
  485. <span v-else class="device-placeholder"></span>
  486. <span>{{ displayValue(record.ip) }}</span>
  487. </Space>
  488. </template>
  489. <template v-else>
  490. {{ displayValue(text) }}
  491. </template>
  492. </template>
  493. </Table>
  494. <Modal
  495. v-model:visible="editVisible"
  496. title="编辑客户端"
  497. ok-text="保存"
  498. cancel-text="取消"
  499. @ok="saveClient"
  500. >
  501. <Form layout="vertical">
  502. <Form.Item label="设备ID">
  503. <InputNumber
  504. v-model:value="editDeviceId"
  505. :min="0"
  506. :precision="0"
  507. style="width: 100%"
  508. />
  509. </Form.Item>
  510. </Form>
  511. </Modal>
  512. </Page>
  513. </template>
  514. <style scoped>
  515. .data-sync-toolbar {
  516. display: flex;
  517. align-items: center;
  518. justify-content: space-between;
  519. gap: 12px;
  520. margin-bottom: 12px;
  521. }
  522. .summary,
  523. .auto-label {
  524. color: hsl(var(--muted-foreground));
  525. }
  526. .group-count {
  527. color: hsl(var(--muted-foreground));
  528. font-size: 12px;
  529. }
  530. .version-text {
  531. color: hsl(var(--muted-foreground));
  532. font-size: 12px;
  533. }
  534. .version-text.outdated {
  535. color: #fa8c16;
  536. }
  537. .device-link {
  538. display: inline-flex;
  539. align-items: center;
  540. justify-content: center;
  541. width: 24px;
  542. height: 24px;
  543. font-size: 16px;
  544. padding: 0;
  545. }
  546. .device-placeholder {
  547. display: inline-block;
  548. width: 24px;
  549. }
  550. :deep(.hidden-expand-icon) {
  551. display: none;
  552. }
  553. </style>