| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612 |
- <script setup lang="ts">
- import { Page } from '@vben/common-ui';
- import { computed, h, onMounted, onUnmounted, ref } from 'vue';
- import { DesktopOutlined } from '@ant-design/icons-vue';
- import { Button, Form, InputNumber, message, Modal, Space, Switch, Table, Tag, Tooltip } from 'ant-design-vue';
- import dayjs from 'dayjs';
- import { requestClient } from '#/api/request';
- type SyncClient = {
- dataType?: string;
- device?: string;
- deviceId?: number;
- firstRequestTime?: number;
- groupSequence?: string;
- ip?: 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 MARKET_TYPE_ORDER = ['2', '1', '0'];
- const clients = ref<SyncClient[]>([]);
- const latestClientVersion = ref('');
- const loading = ref(false);
- const autoRefresh = ref(true);
- const editClient = ref<SyncClient>();
- const editDeviceId = ref<number>();
- const editVisible = ref(false);
- const refreshTimer = ref<ReturnType<typeof setTimeout>>();
- const deviceWindows = new Map<number, Window>();
- const columns = [
- {
- dataIndex: 'title',
- key: 'title',
- title: '分类',
- width: 180,
- },
- {
- align: 'center',
- dataIndex: 'groupSequence',
- key: 'groupSequence',
- title: '分组序列',
- width: 80,
- },
- {
- align: 'center',
- dataIndex: 'requestCount',
- key: 'requestCount',
- title: '请求次数',
- width: 100,
- },
- {
- align: 'center',
- dataIndex: 'lastRequestTime',
- key: 'lastRequestTime',
- title: '最后请求时间',
- width: 180,
- },
- {
- align: 'center',
- dataIndex: 'firstRequestTime',
- key: 'firstRequestTime',
- title: '初次请求时间',
- width: 180,
- },
- {
- align: 'center',
- dataIndex: 'route',
- key: 'route',
- title: '请求接口',
- width: 180,
- },
- {
- align: 'left',
- dataIndex: 'deviceId',
- key: 'deviceId',
- title: '设备',
- width: 160,
- },
- {
- key: 'action',
- title: '操作',
- width: 120,
- },
- ];
- 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 isClientVersionOutdated = (version?: string) => {
- return !!version && !!latestClientVersion.value && version !== latestClientVersion.value;
- };
- const getDeviceUrl = (deviceId?: number) => {
- if (!Number.isInteger(deviceId)) {
- return '';
- }
- const payload = btoa(`${deviceId}\x00c\x00mysql`);
- return `https://rdp.long.bid/#/client/${payload}`;
- };
- const openDevice = (deviceId?: number) => {
- if (!Number.isInteger(deviceId)) {
- return;
- }
- const existingWindow = deviceWindows.get(deviceId);
- if (existingWindow && !existingWindow.closed) {
- existingWindow.focus();
- return;
- }
- const deviceWindow = window.open(getDeviceUrl(deviceId), `rdp-device-${deviceId}`);
- if (deviceWindow) {
- deviceWindows.set(deviceId, deviceWindow);
- deviceWindow.focus();
- }
- };
- 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 sortMarketEntries = (entries: Array<[string, SyncClient[]]>) => {
- return entries.sort(([keyA], [keyB]) => {
- const indexA = MARKET_TYPE_ORDER.indexOf(keyA);
- const indexB = MARKET_TYPE_ORDER.indexOf(keyB);
- if (indexA >= 0 && indexB >= 0) {
- return indexA - indexB;
- }
- if (indexA >= 0) {
- return -1;
- }
- if (indexB >= 0) {
- return 1;
- }
- return keyA.localeCompare(keyB, 'zh-CN', { numeric: true });
- });
- };
- const sortClientsByGroupSequence = (items: SyncClient[]) => {
- return [...items].sort((a, b) => {
- const sequenceA = getGroupKey(a.groupSequence);
- const sequenceB = getGroupKey(b.groupSequence);
- if (sequenceA === '__empty__') {
- return 1;
- }
- if (sequenceB === '__empty__') {
- return -1;
- }
- return sequenceA.localeCompare(sequenceB, 'zh-CN', { numeric: true });
- });
- };
- const createPlatformRows = (items: SyncClient[], keyPrefix: string) => {
- const platformGroups = groupByField(items, 'platform');
- return sortGroupEntries(Object.entries(platformGroups)).map(([platform, platformItems]) => {
- return createGroupRow(
- 'platform',
- `${keyPrefix}:platform:${platform}`,
- getGroupTitle('platform', platform === '__empty__' ? undefined : platform),
- platformItems,
- sortClientsByGroupSequence(platformItems).map(createClientRow),
- );
- });
- };
- const groupedClients = computed<SyncClientRow[]>(() => {
- const listClients = clients.value.filter(item => String(item.dataType) === '1');
- const marketClients = clients.value.filter(item => String(item.dataType) !== '1');
- const rows: SyncClientRow[] = [];
- if (listClients.length) {
- rows.push(createGroupRow(
- 'dataType',
- 'data:1',
- getGroupTitle('dataType', '1'),
- listClients,
- createPlatformRows(listClients, 'data:1'),
- ));
- }
- const marketGroups = groupByField(marketClients, 'marketType');
- const marketRows = sortMarketEntries(Object.entries(marketGroups)).map(([marketType, marketItems]) => {
- const dataGroups = groupByField(marketItems, 'dataType');
- const dataChildren = sortGroupEntries(Object.entries(dataGroups)).map(([dataType, dataItems]) => {
- return createGroupRow(
- 'dataType',
- `market:${marketType}:data:${dataType}`,
- getGroupTitle('dataType', dataType === '__empty__' ? undefined : dataType),
- dataItems,
- createPlatformRows(dataItems, `market:${marketType}:data:${dataType}`),
- );
- });
- return createGroupRow(
- 'marketType',
- `market:${marketType}`,
- getGroupTitle('marketType', marketType === '__empty__' ? undefined : marketType),
- marketItems,
- dataChildren,
- );
- });
- return [...rows, ...marketRows];
- });
- 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, 30 * 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 fetchLatestClientVersion = async () => {
- try {
- const response = await fetch('https://bwh.841024.xyz/chrome_update/versions?names=events_sync');
- const data = await response.json();
- const version = data?.data?.[0];
- if (data?.state === 1 && typeof version === 'string') {
- latestClientVersion.value = version;
- }
- }
- catch (error) {
- console.error('Failed to fetch latest client version:', error);
- }
- };
- const openEditClient = (client: SyncClientRow) => {
- editClient.value = client;
- editDeviceId.value = client.deviceId;
- editVisible.value = true;
- };
- const saveClient = async () => {
- if (!editClient.value?.key) {
- return;
- }
- try {
- await requestClient.post('/pstery/update_client', {
- deviceId: editDeviceId.value,
- key: editClient.value.key,
- });
- message.success('保存成功');
- editVisible.value = false;
- await fetchClients();
- }
- catch (error) {
- console.error('Failed to save sync client:', error);
- message.error('保存客户端信息失败');
- }
- };
- const deleteClient = (client: SyncClientRow) => {
- Modal.confirm({
- cancelText: '取消',
- content: `确定删除客户端 ${client.title} 吗?`,
- okText: '删除',
- okType: 'danger',
- title: '删除客户端',
- onOk: async () => {
- try {
- await requestClient.post('/pstery/delete_client', { key: client.key });
- message.success('删除成功');
- await fetchClients();
- }
- catch (error) {
- console.error('Failed to delete sync client:', error);
- message.error('删除客户端失败');
- }
- },
- });
- };
- const handleAutoRefreshChange = () => {
- scheduleRefresh();
- };
- onMounted(() => {
- fetchLatestClientVersion();
- 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' : 'red'">
- {{ isOnline(record.lastRequestTime) ? '在线' : '离线' }}
- </Tag>
- <span>{{ record.title }}</span>
- <span
- v-if="record.rowType === 'client' && record.version"
- class="version-text"
- :class="{ outdated: isClientVersionOutdated(record.version) }"
- >
- {{ 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 === 'action'">
- <Space>
- <Button size="small" type="link" @click="openEditClient(record)">编辑</Button>
- <Button danger size="small" type="link" @click="deleteClient(record)">删除</Button>
- </Space>
- </template>
- <template v-else-if="column.key === 'requestCount'">
- {{ record.requestCount ?? 0 }}
- </template>
- <template v-else-if="column.key === 'deviceId'">
- <Space>
- <Tooltip v-if="Number.isInteger(record.deviceId)" title="打开设备">
- <Button
- class="device-link"
- size="small"
- type="link"
- @click="openDevice(record.deviceId)"
- >
- <DesktopOutlined />
- </Button>
- </Tooltip>
- <span v-else class="device-placeholder"></span>
- <span>{{ displayValue(record.ip) }}</span>
- </Space>
- </template>
- <template v-else>
- {{ displayValue(text) }}
- </template>
- </template>
- </Table>
- <Modal
- v-model:visible="editVisible"
- title="编辑客户端"
- ok-text="保存"
- cancel-text="取消"
- @ok="saveClient"
- >
- <Form layout="vertical">
- <Form.Item label="设备ID">
- <InputNumber
- v-model:value="editDeviceId"
- :min="0"
- :precision="0"
- style="width: 100%"
- />
- </Form.Item>
- </Form>
- </Modal>
- </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;
- }
- .version-text.outdated {
- color: #fa8c16;
- }
- .device-link {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- font-size: 16px;
- padding: 0;
- }
- .device-placeholder {
- display: inline-block;
- width: 24px;
- }
- :deep(.hidden-expand-icon) {
- display: none;
- }
- </style>
|