持仓订单接MQTT
This commit is contained in:
parent
639aa79892
commit
40621376e5
|
|
@ -9,3 +9,6 @@ VITE_REQUEST_TIMEOUT = 10000
|
|||
|
||||
# 可选:开发环境其他配置(如是否开启调试)
|
||||
VITE_DEBUG = true
|
||||
|
||||
# 交易mqtt
|
||||
VITE_MQTT_TRANSACTION_HOST=ws://43.198.76.61:8083/mqtt
|
||||
|
|
@ -9,3 +9,6 @@ VITE_REQUEST_TIMEOUT = 10000
|
|||
|
||||
# 可选:生产环境其他配置(如关闭调试)
|
||||
VITE_DEBUG = false
|
||||
|
||||
# 交易mqtt
|
||||
VITE_MQTT_TRANSACTION_HOST=ws://43.198.76.61:8083/mqtt
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -86,3 +86,10 @@ export async function editUserApi(params: {
|
|||
}) {
|
||||
return request.post<boolean>('/user/editUser', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号是否为子账号的交易账户
|
||||
*/
|
||||
export async function checkAccountIdIsSonForUser(params: { account_id: string }) {
|
||||
return request.get<boolean>('/user/checkAccountIdIsSonForUser', params)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PositionObj[]>([])
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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<PositionItem[]>([])
|
||||
|
||||
// ------------------ 初始化 ------------------
|
||||
|
||||
/**
|
||||
* 初始化行情 MQTT 连接
|
||||
* 建立连接 → 订阅主题 → 注册消息回调
|
||||
*/
|
||||
const initMarketConditionsMQTT = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
// 菜单子项类型
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* 帧级节流器数据项接口
|
||||
*/
|
||||
interface ThrottlerDataItem {
|
||||
id: string | number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* listFrameThrottler - 帧级节流器 -- 只用于持仓订单
|
||||
* 收集同一帧内的所有数据,帧结束时只推送最后一个值
|
||||
*/
|
||||
export class ListFrameThrottler<T extends ThrottlerDataItem> {
|
||||
private callback: (data: T) => void;
|
||||
private pendingData: Record<string | number, T>;
|
||||
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 但需调整类型
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, MessageHandler>
|
||||
|
||||
/**
|
||||
* MQTT 客户端封装类 - 支持多主题订阅管理
|
||||
*/
|
||||
class MQTTClient {
|
||||
/** MQTT 连接实例 */
|
||||
private client: MqttClient | null = null
|
||||
|
||||
/** 连接状态标志 */
|
||||
private isConnected: boolean = false
|
||||
|
||||
/** 使用 Set 存储已订阅的主题,自动去重,便于重连时恢复订阅 */
|
||||
private subscribedTopics: Set<string> = new Set()
|
||||
|
||||
/** 使用 Map 存储主题与回调函数的映射关系,实现精准的消息分发 */
|
||||
private messageHandlers: Map<string, MessageHandler> = new Map()
|
||||
|
||||
/**
|
||||
* 构造函数 - 初始化客户端状态
|
||||
*/
|
||||
constructor() {
|
||||
// 状态已在属性声明时初始化
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 MQTT 连接
|
||||
* @param url - 服务器地址,如 'wss://broker.emqx.io:8084/mqtt'
|
||||
* @param options - 连接配置选项
|
||||
* @returns 客户端实例,便于链式调用或外部访问
|
||||
*/
|
||||
connect(url: string, options: Partial<IClientOptions> = {}): 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<string[]> {
|
||||
// 检查连接状态,未连接时拒绝订阅请求
|
||||
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<void> {
|
||||
// 统一处理为数组
|
||||
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 }
|
||||
|
|
@ -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<string, string | number> = {
|
||||
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<TimeUnit, string> = {
|
||||
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);
|
||||
|
|
@ -1,76 +1,67 @@
|
|||
<template>
|
||||
<div class="position-stat">
|
||||
<!-- 搜索 -->
|
||||
<PositionStatSearch :search-form="searchForm" :search-options="searchOptions" @search="handleSearch"
|
||||
@reset="handleReset">
|
||||
<template #button="{ form }">
|
||||
<el-button type="primary" @click="handleExport()">
|
||||
导出
|
||||
</el-button>
|
||||
</template>
|
||||
<PositionStatSearch
|
||||
:search-form="searchForm"
|
||||
:search-options="searchOptions"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
>
|
||||
<!-- <template #button="{ form }">-->
|
||||
<!-- <el-button type="primary" @click="handleExport()">-->
|
||||
<!-- 导出-->
|
||||
<!-- </el-button>-->
|
||||
<!-- </template>-->
|
||||
</PositionStatSearch>
|
||||
<!-- 表格 -->
|
||||
<PositionStatTable :table-data="tableTree" :columns="tableColumnsOptions" row-key="id" />
|
||||
|
||||
<!-- 分页 -->
|
||||
<PositionStatPagination v-model="currentPage" v-model:pageSizeValue="pageSize" :total="total" @pagination-change="handlePaginationChange" />
|
||||
<PositionStatTable
|
||||
:table-data="orderPositionStore.positionOrderList"
|
||||
:columns="tableColumnsOptions"
|
||||
row-key="id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
defineOptions({
|
||||
name: 'Position'
|
||||
name: 'Position',
|
||||
})
|
||||
// 组件
|
||||
import PositionStatSearch from '@/components/common/Search.vue'
|
||||
import PositionStatTable from '@/components/common/Table.vue'
|
||||
import PositionStatPagination from '@/components/common/Pagination.vue'
|
||||
import { checkAccountIdIsSonForUser } from '@/api/modules/user.ts'
|
||||
// 配置以及表单验证
|
||||
import { search, tableColumns } from './options'
|
||||
|
||||
// 接口
|
||||
|
||||
import { useTransactionStore } from '@/stores/modules/transaction.ts'
|
||||
import { useOrderPositionStore } from '@/stores/modules/orderPosition.ts'
|
||||
|
||||
// 导出表格
|
||||
import {exportToExcel} from '@/utils/exportToExcel'
|
||||
import { exportToExcel } from '@/utils/exportToExcel'
|
||||
|
||||
const transactionStore = useTransactionStore()
|
||||
const orderPositionStore = useOrderPositionStore()
|
||||
/**
|
||||
* @description: 定义搜索数据
|
||||
*/
|
||||
const searchOptions = ref(search)
|
||||
const searchForm = ref({
|
||||
orderNo: '',
|
||||
tradeType: '',
|
||||
account: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
account_id: '',
|
||||
})
|
||||
/**
|
||||
* @description: 定义表格数据
|
||||
*/
|
||||
const tableTree = ref([])
|
||||
const tableColumnsOptions = ref(tableColumns)
|
||||
/**
|
||||
* @description: 定义分页数据
|
||||
*/
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
/**
|
||||
* @description: 定义公共数据处理方法
|
||||
*/
|
||||
/**
|
||||
* @description: 定义公共请求数据方法
|
||||
*/
|
||||
/**
|
||||
* @description: 页面加载完成需要请求的数据
|
||||
*/
|
||||
/**
|
||||
* @description: 定义搜索方法
|
||||
*/
|
||||
const handleSearch = async () => {
|
||||
console.log("搜索");
|
||||
|
||||
const data = await checkAccountIdIsSonForUser({ account_id: searchForm.value.account_id })
|
||||
if (!data.flag) {
|
||||
return ElMessage.warning('请输入下级ID')
|
||||
}
|
||||
transactionStore.getPositionList(searchForm.value.account_id)
|
||||
}
|
||||
const handleReset = () => {
|
||||
console.log('重置搜索')
|
||||
|
|
@ -83,10 +74,7 @@ const handleExport = () => {
|
|||
defaultFileNamePrefix: '资金明细',
|
||||
})
|
||||
}
|
||||
/**
|
||||
* @description: 定义分页方法
|
||||
*/
|
||||
const handlePaginationChange = (page: {currentPage: number, size: number}) => {
|
||||
currentPage.value = page.currentPage
|
||||
}
|
||||
onMounted(() => {
|
||||
transactionStore.initMarketConditionsMQTT()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue