From 40621376e56d1bdbb74baec66ee029f02aa60a31 Mon Sep 17 00:00:00 2001 From: Songsl <2431955898@qq.com> Date: Wed, 13 May 2026 09:16:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=81=E4=BB=93=E8=AE=A2=E5=8D=95=E6=8E=A5MQ?= =?UTF-8?q?TT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 5 +- .env.production | 5 +- package.json | 1 + src/api/modules/user.ts | 7 + src/stores/modules/orderPosition.ts | 77 +++++++ src/stores/modules/transaction.ts | 131 ++++++++++++ src/stores/types/user.d.ts | 37 ++-- src/utils/ListFrameThrottler.ts | 74 +++++++ src/utils/mqttClient.ts | 314 ++++++++++++++++++++++++++++ src/utils/timeUtils.ts | 300 ++++++++++++++++++++++++++ src/views/order/position/index.vue | 100 ++++----- src/views/order/position/options.ts | 26 +-- 12 files changed, 982 insertions(+), 95 deletions(-) create mode 100644 src/stores/modules/orderPosition.ts create mode 100644 src/stores/modules/transaction.ts create mode 100644 src/utils/ListFrameThrottler.ts create mode 100644 src/utils/mqttClient.ts create mode 100644 src/utils/timeUtils.ts diff --git a/.env.development b/.env.development index cb9763e..4029ed4 100644 --- a/.env.development +++ b/.env.development @@ -8,4 +8,7 @@ VITE_API_BASE_URL = http://18.167.120.9:8085/api VITE_REQUEST_TIMEOUT = 10000 # 可选:开发环境其他配置(如是否开启调试) -VITE_DEBUG = true \ No newline at end of file +VITE_DEBUG = true + +# 交易mqtt +VITE_MQTT_TRANSACTION_HOST=ws://43.198.76.61:8083/mqtt \ No newline at end of file diff --git a/.env.production b/.env.production index 3e78f21..7184e14 100644 --- a/.env.production +++ b/.env.production @@ -8,4 +8,7 @@ VITE_API_BASE_URL = http://18.167.120.9:8085/api VITE_REQUEST_TIMEOUT = 10000 # 可选:生产环境其他配置(如关闭调试) -VITE_DEBUG = false \ No newline at end of file +VITE_DEBUG = false + +# 交易mqtt +VITE_MQTT_TRANSACTION_HOST=ws://43.198.76.61:8083/mqtt \ No newline at end of file diff --git a/package.json b/package.json index 6606752..df1e679 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dayjs": "^1.11.20", "element-plus": "^2.13.2", "file-saver": "^2.0.5", + "mqtt": "^5.15.1", "pinia": "^3.0.4", "qrcode": "^1.5.4", "vue": "^3.5.29", diff --git a/src/api/modules/user.ts b/src/api/modules/user.ts index bef5b2f..97e299e 100644 --- a/src/api/modules/user.ts +++ b/src/api/modules/user.ts @@ -86,3 +86,10 @@ export async function editUserApi(params: { }) { return request.post('/user/editUser', params); } + +/** + * 检查账号是否为子账号的交易账户 + */ +export async function checkAccountIdIsSonForUser(params: { account_id: string }) { + return request.get('/user/checkAccountIdIsSonForUser', params) +} diff --git a/src/stores/modules/orderPosition.ts b/src/stores/modules/orderPosition.ts new file mode 100644 index 0000000..cb3e9ca --- /dev/null +++ b/src/stores/modules/orderPosition.ts @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia' +import { ListFrameThrottler } from '@/utils/ListFrameThrottler.ts' +import { ref } from 'vue' +import { formatDate } from '@/utils/timeUtils.ts' + +interface PositionObj { + id?: string + account?: string + typeCode?: string + orderNo?: string + direction?: string + lots?: string + takeProfit?: string + stopLoss?: string + entryPrice?: string + currentPrice?: string + floatingPL?: string + margin?: string + commission?: string + overnightFee?: string + timestamp?: string + singleId?: string | number + sort?: number +} + +export const useOrderPositionStore = defineStore('orderPosition', () => { + const positionOrderList = ref([]) + + const handleInfoListData = (positionObj: PositionObj) => { + if (positionObj?.orderNo) { + const existingIndex = positionOrderList.value.findIndex( + (p: PositionObj) => p.orderNo === positionObj.orderNo, + ) + if (existingIndex !== -1) { + positionOrderList.value[existingIndex] = positionObj + } else { + positionOrderList.value.unshift(positionObj) + } + } + + positionOrderList.value.sort((a: PositionObj, b: PositionObj) => b.sort - a.sort) + } + + const listFrameThrottler = new ListFrameThrottler(handleInfoListData) + + const handlePositionOrderList = async (item:string) => { + const fields = item.split(':') + const positionObj: PositionObj = { + id: fields[2], // 订单号 + account: fields[0], // 账户id + typeCode: fields[1], // 品种代码 + orderNo: fields[2], // 订单号 + direction: fields[3], // 1 做多 2 做空 + lots: fields[4], // 手数 + takeProfit: fields[5], // 止盈 + stopLoss: fields[6], // 止损 + entryPrice: fields[7], // 买入价格 + currentPrice: fields[8], // 当前价格 + floatingPL: (fields[9] * 1).toFixed(2), // 浮动盈亏 + margin: (fields[10] * 1).toFixed(2), // 保证金 + commission: (fields[11] * 1).toFixed(2), // 手续费 + overnightFee: (fields[12] * 1).toFixed(2), // 隔夜费 + timestamp: + fields[13] === -1 || fields[13] === '' || fields[13] === '-1' + ? '' + : formatDate(fields[13] * 1000, 'YYYY-MM-DD HH:mm:ss'), // 时间戳 + singleId: fields[14] * 1 === 0 ? '--' : fields[14], // 带单人id + sort: fields[13] * 1, + } + listFrameThrottler.push(positionObj) + } + + return { + positionOrderList, + handlePositionOrderList, + } +}) diff --git a/src/stores/modules/transaction.ts b/src/stores/modules/transaction.ts new file mode 100644 index 0000000..6e752f0 --- /dev/null +++ b/src/stores/modules/transaction.ts @@ -0,0 +1,131 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { MQTTClient } from '@/utils/mqttClient' +import { useUserStore } from './user' +import { useOrderPositionStore } from '@/stores/modules/orderPosition.ts' + +// ============================================================ +// 类型声明 +// ============================================================ + +/** 持仓单项数据结构(根据实际业务接口调整字段) */ +export interface PositionItem { + id: string + code: string + name?: string + volume?: number + direction?: number // 1-做多, 2-做空 + open_price?: number + current_price?: number + profit?: number + take_profit?: number + stop_loss?: number + open_time?: string + [key: string]: any +} + +/** 用户信息最小化类型(用于类型断言) */ +interface UserInfo { + redis_key: string + token: string + account_id: string +} + +/** Vite 环境变量扩展(如项目已有 env.d.ts 可忽略) */ +declare const __VITE_MQTT_TRANSACTION_HOST__: string + +// ============================================================ +// Store 定义 +// ============================================================ + +export const useTransactionStore = defineStore('transaction', () => { + // ------------------ 依赖注入 ------------------ + const mqttClient = new MQTTClient() + const userStore = useUserStore() + const orderStore = useOrderPositionStore() + + // ------------------ 状态 ------------------ + /** 持仓列表 */ + const positionList = ref([]) + + // ------------------ 初始化 ------------------ + + /** + * 初始化行情 MQTT 连接 + * 建立连接 → 订阅主题 → 注册消息回调 + */ + const initMarketConditionsMQTT = async (): Promise => { + const { redis_key, token } = userStore.userInfo as UserInfo + + await mqttClient.connect(import.meta.env.VITE_MQTT_TRANSACTION_HOST as string, { + username: `${redis_key}`, + password: `${token}`, + }) + + await subscribeTheme() + } + + /** + * 订阅主题并注册消息处理器 + * 仅保留持仓列表推送(POSITION_FLOATING_BACK) + */ + const subscribeTheme = async (): Promise => { + const { id } = userStore.userInfo as UserInfo + + if (mqttClient.isConnected) { + const topic = `push/${id}` + await mqttClient.subscribe([topic]) + + mqttClient.onTopicMessages({ + [topic]: (_topic: string, msg: string): void => { + console.log(_topic, msg) + + const [type, data] = msg.split('|') + switch (type) { + case 'POSITION_FLOATING_BACK': { + // 持仓列表回调:交由 orderStore 处理 + orderStore.handlePositionOrderList(data) + break + } + default: + break + } + }, + }) + } else { + setTimeout(subscribeTheme, 30) + } + } + + // ------------------ 持仓列表 ------------------ + + /** + * 向服务端请求持仓列表 + * 服务端通过 POSITION_FLOATING_BACK 推送结果 + */ + const getPositionList = (account_id: string): void => { + + if (mqttClient.isConnected) { + console.log('持仓 POSITION 持仓列表数据', `POSITION::${account_id}`) + mqttClient.publish('server/trade', `POSITION::${account_id}`) + } else { + setTimeout(getPositionList, 30) + } + } + + /** + * 关闭订阅,清空数组 + * */ + const disconnect = () => { + mqttClient.disconnect() + positionList.value = [] + } + + // ------------------ 暴露 ------------------ + return { + mqttClient, + positionList, + initMarketConditionsMQTT, + getPositionList, + } +}) diff --git a/src/stores/types/user.d.ts b/src/stores/types/user.d.ts index 9d551a0..23fd0e9 100644 --- a/src/stores/types/user.d.ts +++ b/src/stores/types/user.d.ts @@ -2,24 +2,25 @@ import type { ApiResponse } from './index'; // 用户信息子类型 export interface UserInfo { - id: number; - username: string; - email: string; - mobile: string; - role_id: number; - pass_admin: string; - salt_code_admin: string; - real_check: number; - par_uid: number; - path: string; - lang_type: number; - reg_type: number; - status: number; - invite_code: string; - created_at: string; - updated_at: string; - deleted_at: string | null; - token: string; + id: number + username: string + email: string + mobile: string + role_id: number + pass_admin: string + salt_code_admin: string + real_check: number + par_uid: number + path: string + lang_type: number + reg_type: number + status: number + invite_code: string + created_at: string + updated_at: string + deleted_at: string | null + token: string + redis_key: string } // 菜单子项类型 diff --git a/src/utils/ListFrameThrottler.ts b/src/utils/ListFrameThrottler.ts new file mode 100644 index 0000000..87da20f --- /dev/null +++ b/src/utils/ListFrameThrottler.ts @@ -0,0 +1,74 @@ +/** + * 帧级节流器数据项接口 + */ +interface ThrottlerDataItem { + id: string | number; + [key: string]: any; +} + +/** + * listFrameThrottler - 帧级节流器 -- 只用于持仓订单 + * 收集同一帧内的所有数据,帧结束时只推送最后一个值 + */ +export class ListFrameThrottler { + private callback: (data: T) => void; + private pendingData: Record; + private rafId: number | null; + + constructor(callback: (data: T) => void) { + this.callback = callback; + this.pendingData = {}; + this.rafId = null; + } + + /** + * 推送数据(同一帧内多次调用只保留最后一个) + * @param data - 要推送的数据 + */ + push(data: T): void { + // 将数据放入待处理数组 + this.pendingData[data.id] = data; + + // 如果还没有安排帧回调,则注册一个 + if (!this.rafId) { + this.rafId = requestAnimationFrame(() => this._flush()); + } +} + + /** + * 帧结束时的回调:只推送最后一个数据 + */ + private _flush(): void { + this.rafId = null; + const list = Object.values(this.pendingData); + if (list.length === 0) return; + + this.pendingData = {}; // 清空缓存 + for (let i = 0; i < list.length; i++) { + this.callback(list[i]); + } +} + + /** + * 立即刷新(手动触发) + */ + flush(): void { + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this._flush(); +} + + /** + * 销毁实例 + */ + destroy(): void { + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this.pendingData = {}; + this.callback = () => {}; // 避免 null 后可能的调用问题,或保持 null 但需调整类型 +} +} \ No newline at end of file diff --git a/src/utils/mqttClient.ts b/src/utils/mqttClient.ts new file mode 100644 index 0000000..255a8c2 --- /dev/null +++ b/src/utils/mqttClient.ts @@ -0,0 +1,314 @@ +import mqtt from 'mqtt' +import type { MqttClient, IClientOptions, IClientPublishOptions } from 'mqtt' +import type { QoS } from 'mqtt-packet' +/** + * 消息处理器类型定义 + * 接收主题名称和消息内容两个参数 + */ +type MessageHandler = (topic: string, message: string) => void + +/** + * 主题-回调映射对象类型 + */ +type TopicCallbackMap = Record + +/** + * MQTT 客户端封装类 - 支持多主题订阅管理 + */ +class MQTTClient { + /** MQTT 连接实例 */ + private client: MqttClient | null = null + + /** 连接状态标志 */ + private isConnected: boolean = false + + /** 使用 Set 存储已订阅的主题,自动去重,便于重连时恢复订阅 */ + private subscribedTopics: Set = new Set() + + /** 使用 Map 存储主题与回调函数的映射关系,实现精准的消息分发 */ + private messageHandlers: Map = new Map() + + /** + * 构造函数 - 初始化客户端状态 + */ + constructor() { + // 状态已在属性声明时初始化 + } + + /** + * 建立 MQTT 连接 + * @param url - 服务器地址,如 'wss://broker.emqx.io:8084/mqtt' + * @param options - 连接配置选项 + * @returns 客户端实例,便于链式调用或外部访问 + */ + connect(url: string, options: Partial = {}): MqttClient { + // 合并默认配置与用户传入的配置 + const defaultOptions: IClientOptions = { + clean: true, // true: 清除会话,false: 保留会话(离线消息) + connectTimeout: 4000, // 连接超时时间(毫秒) + reconnectPeriod: 5000, // 自动重连间隔(毫秒) + // 生成唯一客户端ID,格式:vue3-client-8位随机十六进制 + clientId: 'vue3-client-' + Math.random().toString(16).substr(2, 8), + ...options, // 展开用户自定义选项,覆盖默认值 + } + + // 创建 MQTT 连接实例 + this.client = mqtt.connect(url, defaultOptions) + + /** + * 连接成功事件 + * 触发时机:首次连接成功或断线重连成功 + */ + this.client.on('connect', () => { + console.log('✅ MQTT 连接成功') + this.isConnected = true + + // 重连场景:自动恢复之前的所有订阅 + // 避免网络波动导致订阅丢失 + this.resubscribeAll() + }) + + /** + * 消息到达事件 + * 触发时机:收到已订阅主题的任何消息 + */ + this.client.on('message', (topic: string, message: Uint8Array) => { + // 从映射表中查找该主题对应的处理函数 + const handler = this.messageHandlers.get(topic) + + if (handler) { + // 将 Buffer 转换为字符串后传递给回调函数 + handler(topic, message.toString()) + } else { + // 未找到处理函数时的降级处理 + console.warn(`收到未处理的主题消息: ${topic}`, message.toString()) + } + }) + + /** + * 错误事件 + * 触发时机:连接过程中发生错误(认证失败、网络异常等) + */ + this.client.on('error', (error: Error) => { + console.error('❌ MQTT 连接错误:', error) + this.isConnected = false + }) + + /** + * 重连事件 + * 触发时机:连接断开,客户端尝试自动重连时 + */ + this.client.on('reconnect', () => { + console.log('🔄 MQTT 正在重连...') + }) + + /** + * 连接关闭事件 + * 触发时机:连接被关闭(网络断开、手动断开、服务器踢出) + */ + this.client.on('close', () => { + console.log('🔌 MQTT 连接关闭') + this.isConnected = false + }) + + // 返回客户端实例,便于链式调用或外部访问 + return this.client + } + + /** + * 订阅一个或多个主题 + * 支持单主题字符串或主题数组,实现批量订阅 + * + * @param topics - 单个主题或主题数组 + * @param qos - 服务质量等级 (0:最多一次, 1:至少一次, 2:恰好一次) + * @returns 返回 Promise,resolve 时传入成功订阅的主题列表 + * + * 使用示例: + * subscribe('sensor/temp') // 订阅单个主题 + * subscribe(['sensor/temp', 'sensor/hum']) // 批量订阅两个主题 + */ + subscribe(topics: string | string[], qos: QoS = 0): Promise { + // 检查连接状态,未连接时拒绝订阅请求 + if (!this.isConnected || !this.client) { + console.warn('MQTT 未连接,无法订阅') + return Promise.reject('MQTT 未连接') + } + + // 统一转换为数组处理:如果是字符串则包装成数组,数组则直接使用 + const topicArray: string[] = Array.isArray(topics) ? topics : [topics] + + // 返回 Promise 便于使用 async/await 等待订阅完成 + return new Promise((resolve, reject) => { + /** + * 调用 mqtt.js 的 subscribe 方法 + * 第一个参数:主题或主题数组 + * 第二个参数:选项对象,这里设置 QoS 等级 + * 第三个参数:回调函数,err 为 null 表示成功 + */ + this.client!.subscribe(topicArray, { qos }, (err?: Error | null) => { + if (err) { + // 订阅失败(权限不足、主题格式错误等) + console.error('订阅失败:', err) + reject(err) + } else { + // 订阅成功,将每个主题加入追踪集合 + topicArray.forEach((topic: string) => { + this.subscribedTopics.add(topic) + console.log(`✅ 已订阅主题: ${topic} (QoS: ${qos})`) + }) + // resolve 返回成功订阅的主题数组 + resolve(topicArray) + } + }) + }) + } + + /** + * 为单个主题注册消息处理函数 + * 实现主题与回调的一对一绑定 + * + * @param topic - 主题名称 + * @param callback - 回调函数,接收 (topic, message) 两个参数 + * + * 使用示例: + * onTopicMessage('sensor/temp', (topic, msg) => { + * console.log('温度:', JSON.parse(msg).value) + * }) + */ + onTopicMessage(topic: string, callback: MessageHandler): void { + // 将主题与回调函数存入 Map + this.messageHandlers.set(topic, callback) + } + + /** + * 批量注册主题处理函数 + * 通过对象映射一次性配置多个主题的处理逻辑 + * + * @param topicCallbackMap - 主题-回调映射对象 + * key: 主题名称 + * value: 处理该主题的回调函数 + * + * 使用示例: + * onTopicMessages({ + * 'sensor/temp': (t, m) => handleTemp(m), + * 'sensor/hum': (t, m) => handleHum(m), + * 'alert/urgent': (t, m) => showAlert(m) + * }) + */ + onTopicMessages(topicCallbackMap: TopicCallbackMap): void { + // 遍历对象的所有键值对 + Object.entries(topicCallbackMap).forEach(([topic, callback]) => { + // 逐个存入 messageHandlers Map + this.messageHandlers.set(topic, callback) + }) + } + + /** + * 重连时恢复所有订阅 + * 私有方法,在 connect 事件触发时自动调用 + * 确保网络波动断线重连后,订阅关系自动恢复 + */ + private resubscribeAll(): void { + // 检查是否有历史订阅记录 + if (this.subscribedTopics.size > 0) { + // 将 Set 转换为数组 + const topics: string[] = Array.from(this.subscribedTopics) + console.log('🔄 重连后恢复订阅:', topics) + + // 重新订阅所有主题 + this.subscribe(topics) + } + } + + /** + * 取消订阅一个或多个主题 + * 清理订阅关系,释放资源 + * + * @param topics - 要取消订阅的主题 + * @returns 操作完成的 Promise + */ + unsubscribe(topics: string | string[]): Promise { + // 统一处理为数组 + const topicArray: string[] = Array.isArray(topics) ? topics : [topics] + + return new Promise((resolve, reject) => { + if (!this.client) { + reject(new Error('MQTT 客户端未初始化')) + return + } + + this.client.unsubscribe(topicArray, (err?: Error) => { + if (err) { + reject(err) + } else { + // 清理内部状态:从追踪集合和处理器映射中移除 + topicArray.forEach((topic: string) => { + this.subscribedTopics.delete(topic) + this.messageHandlers.delete(topic) + console.log(`❌ 已取消订阅: ${topic}`) + }) + resolve() + } + }) + }) + } + + /** + * 向指定主题发布消息 + * + * @param topic - 目标主题 + * @param message - 消息内容,对象会自动序列化为 JSON + * @param qos - 服务质量等级 + * @returns 是否发送成功 + */ + publish(topic: string, message: string | object, qos: number = 0): boolean { + if (!this.isConnected || !this.client) { + console.warn('MQTT 未连接,无法发送消息') + return false + } + + // 自动序列化对象类型消息 + const payload: string = typeof message === 'string' ? message : JSON.stringify(message) + + // 调用 mqtt.js 的 publish 方法 + this.client.publish(topic, payload, { qos } as IClientPublishOptions) + return true + } + + /** + * 断开 MQTT 连接并清理资源 + * 应用退出或组件销毁时调用,防止内存泄漏 + */ + disconnect(): void { + // 清空所有内部状态 + this.subscribedTopics.clear() + this.messageHandlers.clear() + + // 关闭连接 + this.client?.end() + + // 重置状态标志 + this.isConnected = false + console.log('🔌 MQTT 已断开连接') + } + + /** + * 获取当前连接状态 + * @returns 是否已连接 + */ + getConnected(): boolean { + return this.isConnected + } + + /** + * 获取已订阅的主题列表 + * @returns 已订阅主题数组 + */ + getSubscribedTopics(): string[] { + return Array.from(this.subscribedTopics) + } +} + +// 导出实例 +export { MQTTClient } +export type { MessageHandler, TopicCallbackMap } diff --git a/src/utils/timeUtils.ts b/src/utils/timeUtils.ts new file mode 100644 index 0000000..b98ea2f --- /dev/null +++ b/src/utils/timeUtils.ts @@ -0,0 +1,300 @@ +/** + * 可接受的日期输入类型 + */ +type DateInput = Date | string | number; + +/** + * 时间单位类型 + */ +type TimeUnit = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'; + +/** + * 时间段类型 + */ +type RangeType = 'today' | 'week' | 'month' | 'year'; + +/** + * 时间范围对象 + */ +export interface TimeRange { + start: Date; + end: Date; +} + +/** + * 格式化日期后的完整对象 + */ +interface FormattedDate { + date: string; + datetime: string; + iso: string; +} + +/** + * 近七天时间范围返回结构 + */ +export interface Last7DaysRange { + startTime: Date; + endTime: Date; + startTimeStr: FormattedDate; + endTimeStr: FormattedDate; + startTimestamp: number; + endTimestamp: number; +} + +/** + * 格式化日期为指定格式 + * @param date - 日期对象、字符串或时间戳 + * @param format - 格式模板,如 'YYYY-MM-DD HH:mm:ss' + * @param format - 格式模板,如 'YYYY-MM-DD HH:mm:ss' + * @returns 格式化后的日期字符串 + */ +export function formatDate(date: DateInput, format = 'YYYY-MM-DD HH:mm:ss'): string { + const d = new Date(date); + + const map: Record = { + YYYY: d.getFullYear(), + MM: String(d.getMonth() + 1).padStart(2, '0'), + DD: String(d.getDate()).padStart(2, '0'), + HH: String(d.getHours()).padStart(2, '0'), + mm: String(d.getMinutes()).padStart(2, '0'), + ss: String(d.getSeconds()).padStart(2, '0'), + }; + + return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => String(map[match])); +} + +// 使用示例 +// formatDate(new Date(), 'YYYY-MM-DD'); // "2026-03-16" +// formatDate(Date.now(), 'YYYY年MM月DD日'); // "2026年03月16日" +// formatDate('2026-03-16', 'MM/DD/YYYY HH:mm'); // "03/16/2026 08:00" + +/** + * 计算相对于当前时间的友好提示 + * @param date - 目标日期 + * @returns 友好时间描述 + */ +function timeAgo(date: DateInput): string { + const now = new Date(); + const past = new Date(date); + const diff = now.getTime() - past.getTime(); + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (seconds < 60) return '刚刚'; + if (minutes < 60) return `${minutes}分钟前`; + if (hours < 24) return `${hours}小时前`; + if (days < 30) return `${days}天前`; + if (months < 12) return `${months}个月前`; + return `${years}年前`; +} + +// 使用示例 +// timeAgo(Date.now() - 1000 * 60 * 5); // "5分钟前" +// timeAgo('2026-03-14'); // "2天前" + +/** + * 对日期进行加减运算 + * @param date - 基准日期 + * @param amount - 数量(可为负数) + * @param unit - 单位:year|month|day|hour|minute|second + * @returns 运算后的新 Date 对象 + */ +function addTime(date: DateInput, amount: number, unit: TimeUnit): Date { + const d = new Date(date); + + const map: Record = { + year: 'FullYear', + month: 'Month', + day: 'Date', + hour: 'Hours', + minute: 'Minutes', + second: 'Seconds', + }; + + const method = `set${map[unit]}` as keyof Date; + const getMethod = `get${map[unit]}` as keyof Date; + + (d as any)[method]((d as any)[getMethod]() + amount); + return d; +} + +// 使用示例 +// addTime(new Date(), 3, 'day'); // 3天后 +// addTime(new Date(), -1, 'month'); // 1个月前 +// addTime('2026-03-16', 2, 'year'); // 2028-03-16 + +/** + * 获取指定时间段的开始和结束时间 + * @param type - 时间段类型 + * @param date - 基准日期,默认为今天 + * @returns 包含 start 和 end 的对象 + */ +function getTimeRange(type: RangeType, date: Date = new Date()): TimeRange { + const d = new Date(date); + let start: Date; + let end: Date; + + switch (type) { + case 'today': + start = new Date(d.setHours(0, 0, 0, 0)); + end = new Date(d.setHours(23, 59, 59, 999)); + break; + case 'week': { + const day = d.getDay() || 7; // 周日设为7 + start = new Date(d.setDate(d.getDate() - day + 1)); + start.setHours(0, 0, 0, 0); + end = new Date(d.setDate(start.getDate() + 6)); + end.setHours(23, 59, 59, 999); + break; + } + case 'month': + start = new Date(d.getFullYear(), d.getMonth(), 1); + end = new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999); + break; + case 'year': + start = new Date(d.getFullYear(), 0, 1); + end = new Date(d.getFullYear(), 11, 31, 23, 59, 59, 999); + break; + default: + // 兜底处理,理论上不会进入 + start = new Date(d.setHours(0, 0, 0, 0)); + end = new Date(d.setHours(23, 59, 59, 999)); + } + + return { start, end }; +} + +// 使用示例 +// getTimeRange('today'); // 今天 00:00:00 ~ 23:59:59 +// getTimeRange('week'); // 本周一 00:00:00 ~ 本周日 23:59:59 +// getTimeRange('month'); // 本月1号 00:00:00 ~ 本月最后一天 23:59:59 + +/** + * 将秒数转换为可读的持续时间 + * @param seconds - 秒数 + * @param showSeconds - 是否显示秒 + * @returns 格式化后的时长字符串 + */ +function formatDuration(seconds: number, showSeconds = true): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const parts: string[] = []; + if (hours > 0) parts.push(`${hours}小时`); + if (minutes > 0 || hours > 0) parts.push(`${minutes}分钟`); + if (showSeconds && (secs > 0 || parts.length === 0)) { + parts.push(`${secs}秒`); + } + + return parts.join('') || '0秒'; +} + +// 使用示例 +// formatDuration(3665); // "1小时1分钟5秒" +// formatDuration(125, false); // "2分钟" +// formatDuration(45); // "45秒" + +/** + * 判断是否为闰年 + * @param year - 年份 + */ +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); +} + +/** + * 获取某月的天数 + * @param year - 年份 + * @param month - 月份(1-12) + */ +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate(); +} + +/** + * 判断是否为工作日(周一至周五) + * @param date - 日期 + */ +function isWeekday(date: DateInput): boolean { + const day = new Date(date).getDay(); + return day !== 0 && day !== 6; +} + +// 使用示例 +// isLeapYear(2024); // true +// getDaysInMonth(2026, 2); // 28 +// isWeekday('2026-03-16'); // true (周一) + +/** + * 将 ISO 字符串转为时间戳 + * @param isoString - ISO 8601 格式字符串 + * @returns 毫秒时间戳 + */ +export function toTimestamp(isoString: string): number { + return new Date(isoString).getTime(); +} + +/** + * 获取近七天的开始和结束时间 + * @returns 包含开始时间、结束时间及其各种格式的对象 + */ +export function getLast7DaysRange(): Last7DaysRange { + const endDate = new Date(); + const startDate = new Date(); + + // 结束时间:今天 23:59:59.999 + endDate.setHours(23, 59, 59, 999); + + // 开始时间:7天前 00:00:00.000(今天也算一天,所以减6) + startDate.setDate(startDate.getDate() - 6); + startDate.setHours(0, 0, 0, 0); + + return { + startTime: startDate, + endTime: endDate, + startTimeStr: formatDates(startDate), + endTimeStr: formatDates(endDate), + startTimestamp: startDate.getTime(), + endTimestamp: endDate.getTime(), + }; +} + +/** + * 格式化日期为常用字符串格式 + * @param date - 日期 + * @returns 包含 date、datetime、iso 的对象 + */ +function formatDates(date: DateInput): FormattedDate { + const d = new Date(date); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const seconds = String(d.getSeconds()).padStart(2, '0'); + + return { + date: `${year}-${month}-${day}`, + datetime: `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`, + iso: d.toISOString(), + }; +} + +// ============ 使用示例 ============ + +// const range = getLast7DaysRange(); + +// console.log('近七天时间范围:'); +// console.log('开始时间对象:', range.startTime); +// console.log('结束时间对象:', range.endTime); +// console.log('开始日期:', range.startTimeStr.date); // 2026-03-24 +// console.log('结束日期:', range.endTimeStr.date); // 2026-03-30 +// console.log('开始时间戳:', range.startTimestamp); +// console.log('结束时间戳:', range.endTimestamp); \ No newline at end of file diff --git a/src/views/order/position/index.vue b/src/views/order/position/index.vue index 3705d68..255e91f 100644 --- a/src/views/order/position/index.vue +++ b/src/views/order/position/index.vue @@ -1,92 +1,80 @@ diff --git a/src/views/order/position/options.ts b/src/views/order/position/options.ts index ba18383..51fa3db 100644 --- a/src/views/order/position/options.ts +++ b/src/views/order/position/options.ts @@ -1,23 +1,11 @@ export const search = [ - // 订单号 - { prop: 'orderNo', label: '订单号', type: 'input' as const, placeholder: '请输入订单号',width: '200px' }, - // 交易类型 - { prop: 'tradeType', label: '交易类型', type: 'select' as const, placeholder: '请选择交易类型',width: '150px', options: [ - { label: '买入', value: '1' }, - { label: '卖出', value: '2' }, - ] }, - //账号 - { prop: 'account', label: '账号', type: 'select' as const, placeholder: '请选择账号',width: '150px' }, - //开始时间 - // { - // prop: 'startTime', label: '开始时间', type: 'date' as const, dateType: 'date' as const, // 时间范围选择器 - // valueFormat: 'YYYY-MM-DD', placeholder: '请选择开始时间', width: '150px' - // }, - // //结束时间 - // { - // prop: 'endTime', label: '结束时间', type: 'date' as const, dateType: 'date' as const, // 时间范围选择器 - // valueFormat: 'YYYY-MM-DD', placeholder: '请选择结束时间', width: '150px' - // }, + { + prop: 'account_id', + label: '账户ID', + type: 'input' as const, + placeholder: '请输入账户ID', + width: '200px', + }, ] export const tableColumns = [