index.vue 14 KB

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