|
|
@@ -0,0 +1,383 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { Page } from '@vben/common-ui';
|
|
|
+
|
|
|
+import { computed, h, onMounted, onUnmounted, ref } from 'vue';
|
|
|
+
|
|
|
+import { Button, message, Space, Switch, Table, Tag } from 'ant-design-vue';
|
|
|
+import dayjs from 'dayjs';
|
|
|
+
|
|
|
+import { requestClient } from '#/api/request';
|
|
|
+
|
|
|
+type SyncClient = {
|
|
|
+ dataType?: string;
|
|
|
+ device?: string;
|
|
|
+ firstRequestTime?: number;
|
|
|
+ groupSequence?: string;
|
|
|
+ key: string;
|
|
|
+ lastRequestTime?: number;
|
|
|
+ marketType?: string;
|
|
|
+ platform?: string;
|
|
|
+ requestCount?: number;
|
|
|
+ route?: string;
|
|
|
+ version?: string;
|
|
|
+};
|
|
|
+
|
|
|
+type SyncClientRow = SyncClient & {
|
|
|
+ children?: SyncClientRow[];
|
|
|
+ count?: number;
|
|
|
+ onlineCount?: number;
|
|
|
+ rowKey: string;
|
|
|
+ rowType: 'client' | 'dataType' | 'marketType' | 'platform';
|
|
|
+ title: string;
|
|
|
+};
|
|
|
+
|
|
|
+const CHAR_MAP: Record<string, Record<string, string>> = {
|
|
|
+ dataType: {
|
|
|
+ 1: '列表',
|
|
|
+ 2: '让球/大小',
|
|
|
+ 3: '特别投注',
|
|
|
+ },
|
|
|
+ marketType: {
|
|
|
+ 0: '早盘',
|
|
|
+ 1: '今日',
|
|
|
+ 2: '滚球',
|
|
|
+ },
|
|
|
+ platform: {
|
|
|
+ ay: 'IM',
|
|
|
+ hg: '皇冠',
|
|
|
+ im: 'IM',
|
|
|
+ ob: 'OB',
|
|
|
+ ps: '平博',
|
|
|
+ tq: 'OB',
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+const clients = ref<SyncClient[]>([]);
|
|
|
+const loading = ref(false);
|
|
|
+const autoRefresh = ref(true);
|
|
|
+const refreshTimer = ref<ReturnType<typeof setTimeout>>();
|
|
|
+
|
|
|
+const columns = [
|
|
|
+ {
|
|
|
+ dataIndex: 'title',
|
|
|
+ key: 'title',
|
|
|
+ title: '分类',
|
|
|
+ width: 180,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ dataIndex: 'groupSequence',
|
|
|
+ key: 'groupSequence',
|
|
|
+ title: '分组序列',
|
|
|
+ width: 80,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ dataIndex: 'requestCount',
|
|
|
+ key: 'requestCount',
|
|
|
+ title: '请求次数',
|
|
|
+ width: 100,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ dataIndex: 'lastRequestTime',
|
|
|
+ key: 'lastRequestTime',
|
|
|
+ title: '最后请求时间',
|
|
|
+ width: 180,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ dataIndex: 'firstRequestTime',
|
|
|
+ key: 'firstRequestTime',
|
|
|
+ title: '初次请求时间',
|
|
|
+ width: 180,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ dataIndex: 'route',
|
|
|
+ key: 'route',
|
|
|
+ title: '请求接口',
|
|
|
+ width: 180,
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const onlineThreshold = 5 * 60 * 1000;
|
|
|
+
|
|
|
+const onlineCount = computed(() => {
|
|
|
+ const now = Date.now();
|
|
|
+ return clients.value.filter(item => now - (item.lastRequestTime ?? 0) <= onlineThreshold).length;
|
|
|
+});
|
|
|
+
|
|
|
+const formatTime = (time?: number) => {
|
|
|
+ return time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-';
|
|
|
+};
|
|
|
+
|
|
|
+const displayValue = (value?: number | string) => {
|
|
|
+ return value === undefined || value === '' ? '-' : value;
|
|
|
+};
|
|
|
+
|
|
|
+const displayMappedValue = (
|
|
|
+ type: keyof typeof CHAR_MAP,
|
|
|
+ value?: number | string,
|
|
|
+) => {
|
|
|
+ if (value === undefined || value === '') {
|
|
|
+ return '-';
|
|
|
+ }
|
|
|
+ return CHAR_MAP[type]?.[String(value)] ?? value;
|
|
|
+};
|
|
|
+
|
|
|
+const isOnline = (time?: number) => {
|
|
|
+ return !!time && Date.now() - time <= onlineThreshold;
|
|
|
+};
|
|
|
+
|
|
|
+const getGroupKey = (value?: string) => {
|
|
|
+ return value === undefined || value === '' ? '__empty__' : String(value);
|
|
|
+};
|
|
|
+
|
|
|
+const getGroupTitle = (type: keyof typeof CHAR_MAP, value?: string) => {
|
|
|
+ return displayMappedValue(type, value);
|
|
|
+};
|
|
|
+
|
|
|
+const getLatestTime = (items: SyncClient[]) => {
|
|
|
+ return Math.max(...items.map(item => item.lastRequestTime ?? 0));
|
|
|
+};
|
|
|
+
|
|
|
+const getRequestCount = (items: SyncClient[]) => {
|
|
|
+ return items.reduce((total, item) => total + (item.requestCount ?? 0), 0);
|
|
|
+};
|
|
|
+
|
|
|
+const getOnlineCount = (items: SyncClient[]) => {
|
|
|
+ return items.filter(item => isOnline(item.lastRequestTime)).length;
|
|
|
+};
|
|
|
+
|
|
|
+const createClientRow = (client: SyncClient): SyncClientRow => ({
|
|
|
+ ...client,
|
|
|
+ rowKey: `client:${client.key}`,
|
|
|
+ rowType: 'client',
|
|
|
+ title: client.device || '客户端',
|
|
|
+});
|
|
|
+
|
|
|
+const createGroupRow = (
|
|
|
+ rowType: SyncClientRow['rowType'],
|
|
|
+ rowKey: string,
|
|
|
+ title: string,
|
|
|
+ items: SyncClient[],
|
|
|
+ children: SyncClientRow[],
|
|
|
+): SyncClientRow => ({
|
|
|
+ rowKey,
|
|
|
+ key: rowKey,
|
|
|
+ rowType,
|
|
|
+ title,
|
|
|
+ children,
|
|
|
+ count: items.length,
|
|
|
+ lastRequestTime: getLatestTime(items),
|
|
|
+ onlineCount: getOnlineCount(items),
|
|
|
+ requestCount: getRequestCount(items),
|
|
|
+});
|
|
|
+
|
|
|
+const groupByField = (items: SyncClient[], field: keyof SyncClient) => {
|
|
|
+ return items.reduce<Record<string, SyncClient[]>>((groups, item) => {
|
|
|
+ const key = getGroupKey(item[field] as string | undefined);
|
|
|
+ if (!groups[key]) {
|
|
|
+ groups[key] = [];
|
|
|
+ }
|
|
|
+ groups[key].push(item);
|
|
|
+ return groups;
|
|
|
+ }, {});
|
|
|
+};
|
|
|
+
|
|
|
+const sortGroupEntries = (entries: Array<[string, SyncClient[]]>) => {
|
|
|
+ return entries.sort(([keyA], [keyB]) => {
|
|
|
+ if (keyA === '__empty__') {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ if (keyB === '__empty__') {
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+ return keyA.localeCompare(keyB, 'zh-CN', { numeric: true });
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const groupedClients = computed<SyncClientRow[]>(() => {
|
|
|
+ const marketGroups = groupByField(clients.value, 'marketType');
|
|
|
+
|
|
|
+ return sortGroupEntries(Object.entries(marketGroups)).map(([marketType, marketItems]) => {
|
|
|
+ const dataGroups = groupByField(marketItems, 'dataType');
|
|
|
+ const dataChildren = sortGroupEntries(Object.entries(dataGroups)).map(([dataType, dataItems]) => {
|
|
|
+ const platformGroups = groupByField(dataItems, 'platform');
|
|
|
+ const platformChildren = sortGroupEntries(Object.entries(platformGroups)).map(([platform, platformItems]) => {
|
|
|
+ return createGroupRow(
|
|
|
+ 'platform',
|
|
|
+ `market:${marketType}:data:${dataType}:platform:${platform}`,
|
|
|
+ getGroupTitle('platform', platform === '__empty__' ? undefined : platform),
|
|
|
+ platformItems,
|
|
|
+ platformItems.map(createClientRow),
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ return createGroupRow(
|
|
|
+ 'dataType',
|
|
|
+ `market:${marketType}:data:${dataType}`,
|
|
|
+ getGroupTitle('dataType', dataType === '__empty__' ? undefined : dataType),
|
|
|
+ dataItems,
|
|
|
+ platformChildren,
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ return createGroupRow(
|
|
|
+ 'marketType',
|
|
|
+ `market:${marketType}`,
|
|
|
+ getGroupTitle('marketType', marketType === '__empty__' ? undefined : marketType),
|
|
|
+ marketItems,
|
|
|
+ dataChildren,
|
|
|
+ );
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+const expandedRowKeys = computed(() => {
|
|
|
+ const keys: string[] = [];
|
|
|
+ const collectKeys = (rows: SyncClientRow[]) => {
|
|
|
+ rows.forEach((row) => {
|
|
|
+ if (row.children?.length) {
|
|
|
+ keys.push(row.rowKey);
|
|
|
+ collectKeys(row.children);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ };
|
|
|
+ collectKeys(groupedClients.value);
|
|
|
+ return keys;
|
|
|
+});
|
|
|
+
|
|
|
+const renderExpandIcon = () => h('span', { class: 'hidden-expand-icon' });
|
|
|
+
|
|
|
+const clearRefreshTimer = () => {
|
|
|
+ if (refreshTimer.value) {
|
|
|
+ clearTimeout(refreshTimer.value);
|
|
|
+ refreshTimer.value = undefined;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const scheduleRefresh = () => {
|
|
|
+ clearRefreshTimer();
|
|
|
+ if (!autoRefresh.value) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ refreshTimer.value = setTimeout(fetchClients, 10 * 1000);
|
|
|
+};
|
|
|
+
|
|
|
+const fetchClients = async () => {
|
|
|
+ loading.value = true;
|
|
|
+ try {
|
|
|
+ const data = await requestClient.get<SyncClient[]>('/pstery/get_clients');
|
|
|
+ clients.value = data ?? [];
|
|
|
+ }
|
|
|
+ catch (error) {
|
|
|
+ console.error('Failed to fetch sync clients:', error);
|
|
|
+ message.error('获取数据同步客户端失败');
|
|
|
+ }
|
|
|
+ finally {
|
|
|
+ loading.value = false;
|
|
|
+ scheduleRefresh();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleAutoRefreshChange = () => {
|
|
|
+ scheduleRefresh();
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ fetchClients();
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ clearRefreshTimer();
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <Page title="数据同步">
|
|
|
+ <div class="data-sync-toolbar">
|
|
|
+ <Space>
|
|
|
+ <Button type="primary" :loading="loading" @click="fetchClients">刷新</Button>
|
|
|
+ <span class="summary">
|
|
|
+ 在线 {{ onlineCount }} / 总计 {{ clients.length }}
|
|
|
+ </span>
|
|
|
+ </Space>
|
|
|
+ <Space>
|
|
|
+ <span class="auto-label">自动刷新</span>
|
|
|
+ <Switch v-model:checked="autoRefresh" @change="handleAutoRefreshChange" />
|
|
|
+ </Space>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Table
|
|
|
+ :columns="columns"
|
|
|
+ :data-source="groupedClients"
|
|
|
+ :expanded-row-keys="expandedRowKeys"
|
|
|
+ :expand-icon="renderExpandIcon"
|
|
|
+ :loading="loading"
|
|
|
+ :pagination="false"
|
|
|
+ :row-key="record => record.rowKey"
|
|
|
+ bordered
|
|
|
+ size="small"
|
|
|
+ :scroll="{ x: 900 }"
|
|
|
+ >
|
|
|
+ <template #bodyCell="{ column, record, text }">
|
|
|
+ <template v-if="column.key === 'title'">
|
|
|
+ <Space>
|
|
|
+ <Tag v-if="record.rowType === 'client'" :color="isOnline(record.lastRequestTime) ? 'green' : 'default'">
|
|
|
+ {{ isOnline(record.lastRequestTime) ? '在线' : '离线' }}
|
|
|
+ </Tag>
|
|
|
+ <span>{{ record.title }}</span>
|
|
|
+ <span v-if="record.rowType === 'client' && record.version" class="version-text">
|
|
|
+ {{ record.version }}
|
|
|
+ </span>
|
|
|
+ <span v-if="record.rowType !== 'client'" class="group-count">
|
|
|
+ {{ record.onlineCount ?? 0 }} / {{ record.count ?? 0 }}
|
|
|
+ </span>
|
|
|
+ </Space>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="column.key === 'lastRequestTime'">
|
|
|
+ <span v-if="record.rowType === 'client'">{{ formatTime(record.lastRequestTime) }}</span>
|
|
|
+ <span v-else>-</span>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="column.key === 'firstRequestTime'">
|
|
|
+ <span v-if="record.rowType === 'client'">{{ formatTime(record.firstRequestTime) }}</span>
|
|
|
+ <span v-else>-</span>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="record.rowType !== 'client'">
|
|
|
+ -
|
|
|
+ </template>
|
|
|
+ <template v-else-if="column.key === 'requestCount'">
|
|
|
+ {{ record.requestCount ?? 0 }}
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ {{ displayValue(text) }}
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </Table>
|
|
|
+ </Page>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.data-sync-toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.summary,
|
|
|
+.auto-label {
|
|
|
+ color: hsl(var(--muted-foreground));
|
|
|
+}
|
|
|
+
|
|
|
+.group-count {
|
|
|
+ color: hsl(var(--muted-foreground));
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.version-text {
|
|
|
+ color: hsl(var(--muted-foreground));
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.hidden-expand-icon) {
|
|
|
+ display: none;
|
|
|
+}
|
|
|
+</style>
|