li.shaoyi před 2 roky
rodič
revize
787183e28b

+ 476 - 0
src/business/trade/index.ts

@@ -0,0 +1,476 @@
+import { reactive, ref, shallowRef, computed } from 'vue'
+import { v4 } from 'uuid'
+import { ClientType, OrderSrc } from '@/constants/client'
+import { useLoginStore, useAccountStore } from '@/stores'
+import {
+    spotPresaleDestingOrder,
+    spotPresaleTransferCancel,
+    spotPresaleTransferDesting,
+    spotPresaleTransferListing,
+    spotPresalePlayment,
+    wrListingCancelOrder,
+    spotPresaleDeliveryConfirm,
+    wrOutApply,
+    spotPresaleBreachOfContractApply,
+    hdWROrder,
+    hdWRDealOrder,
+    thjProfitDrawApply,
+    spotPresalePointPrice,
+} from '@/services/api/trade'
+import { formatDate } from "@/filters";
+import Long from 'long'
+import { BuyOrSell } from '@/constants/order'
+
+const loginStore = useLoginStore()
+const accountStore = useAccountStore()
+
+// 采购摘牌
+export function usePurchaseOrderDesting() {
+    const loading = shallowRef(false)
+
+    const formData = ref<Partial<Proto.SpotPresaleDestingOrderReq>>({
+        UserID: loginStore.userId, // 用户ID,必填
+        AccountID: accountStore.accountId, // 资金账号,必填
+        ClientType: ClientType.Web, // 终端类型
+        UpdatorID: loginStore.loginId, // 操作人,必填
+    })
+
+    const formSubmit = async () => {
+        try {
+            loading.value = true
+            return await spotPresaleDestingOrder({
+                data: {
+                    ...formData.value,
+                    Qty: Number(formData.value.Qty),
+                    ClientSerialNo: v4() // 客户端流水号
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        formData,
+        formSubmit,
+    }
+}
+
+// 铁合金现货预售交收确认
+export function useSpotPresaleDeliveryConfirm() {
+    const loading = shallowRef(false)
+
+    const confirmSubmit = async (id: string) => {
+        try {
+            loading.value = true
+            return await spotPresaleDeliveryConfirm({
+                data: {
+                    UserID: loginStore.userId,
+                    Remark: '',
+                    WRTradeDetailID: Long.fromString(id),
+                    ClientSerialNo: v4(),
+                    ClientType: ClientType.Web // 终端类型
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        confirmSubmit
+    }
+}
+
+// 铁合金现货预售违约申请
+export function useSpotPresaleBreachOfContractApply() {
+    const loading = shallowRef(false)
+
+    const applySubmit = async (id: string) => {
+        try {
+            loading.value = true
+            return await spotPresaleBreachOfContractApply({
+                data: {
+                    UserID: loginStore.userId,
+                    WRTradeDetailID: Long.fromString(id),
+                    ClientSerialNo: v4(),
+                    ClientType: ClientType.Web // 终端类型
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        applySubmit,
+    }
+}
+
+// 铁合金现货预售付款处理接口
+export function useSpotPresalePlayment() {
+    const loading = shallowRef(false)
+
+    const formData = reactive<Partial<Proto.SpotPresalePlaymentReq>>({
+        UserID: loginStore.userId, // 用户ID,必填
+        ClientType: ClientType.Web, // 终端类型
+        ClientSerialNo: v4(), // 客户端流水号
+    })
+
+    const playmentSubmit = async (id: string) => {
+        try {
+            loading.value = true
+            return await spotPresalePlayment({
+                data: {
+                    ...formData,
+                    WRTradeDetailID: Long.fromString(id),
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        playmentSubmit
+    }
+}
+
+// 铁合金现货预售转让挂牌接口
+export function useSpotPresaleTransferListing() {
+    const loading = shallowRef(false)
+
+    const formData = reactive<Partial<Proto.SpotPresaleTransferListingReq>>({
+        UserID: loginStore.userId, // 用户ID,必填
+        ClientType: ClientType.Web, // 终端类型
+        ClientSerialNo: v4(), // 客户端流水号
+    })
+
+    const listingSubmit = async (id: string) => {
+        try {
+            loading.value = true
+            /// 转让价格不能为0
+            if (!formData.TransferPrice) {
+                return Promise.reject('转让价格不能为0')
+            }
+
+            return await spotPresaleTransferListing({
+                data: {
+                    ...formData,
+                    WRTradeDetailID: Long.fromString(id),
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        formData,
+        listingSubmit
+    }
+}
+
+// 铁合金现货预售转让撤销接口请求
+export function useSpotPresaleTransferCancel() {
+    const loading = shallowRef(false)
+
+    const transferCancelSubmit = async (wrtradedetailid?: string) => {
+        try {
+            loading.value = true
+            return await spotPresaleTransferCancel({
+                data: {
+                    UserID: loginStore.userId,
+                    WRTradeDetailID: Long.fromString(wrtradedetailid ?? '0'),
+                    ClientSerialNo: v4(),
+                    ClientType: ClientType.Web // 终端类型
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        transferCancelSubmit
+    }
+}
+
+// 铁合金现货预售转让摘牌接口请求
+export function useSpotPresaleTransferDesting() {
+    const loading = shallowRef(false)
+
+    const formData = reactive<Proto.SpotPresaleTransferDestingReq>({
+        UserID: loginStore.userId,
+        AccountID: accountStore.accountId,
+        TransferID: Long.fromNumber(0),
+        ClientType: ClientType.Web // 终端类型
+    })
+
+    const destingSubmit = async () => {
+        try {
+            loading.value = true
+            return await spotPresaleTransferDesting({
+                data: {
+                    ...formData,
+                    ClientSerialNo: v4(), // 客户端流水号
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        formData,
+        destingSubmit
+    }
+}
+
+// 挂牌撤单请求接口
+export function useWrListingCancelOrder() {
+    const loading = shallowRef(false)
+
+    const cancelSubmit = async (id: string, buyorsell: number) => {
+        try {
+            loading.value = true
+            return await wrListingCancelOrder({
+                data: {
+                    UserID: loginStore.userId,
+                    AccountID: accountStore.accountId,
+                    OperatorID: loginStore.loginId,
+                    OrderSrc: OrderSrc.ORDERSRC_CLIENT,
+                    OldWRTradeOrderID: Long.fromString(id),
+                    ClientOrderTime: formatDate(new Date().toString(), 'YYYY-MM-DD HH:mm:ss'),
+                    ClientSerialNo: v4(),
+                    ClientType: ClientType.Web,
+                    BuyOrSell: buyorsell,
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        cancelSubmit
+    }
+}
+
+// 仓单明细提货请求接口
+export function useWrOutInApply(holdlb?: Model.HoldLBRsp) {
+    const loading = shallowRef(false)
+    const orderQty = shallowRef(0.0)
+    const checked = shallowRef(3)
+
+    const formData = reactive<Partial<Proto.WROutApplyReq>>({
+        AppointmentRemark: '',
+        UserID: loginStore.userId,             // 用户ID,必填
+        AccountID: accountStore.accountId,  // 申请人账户ID
+        CreatorID: loginStore.loginId,         // 创建人ID
+        WRStandardID: holdlb?.wrstandardid,
+        WarehouseID: holdlb?.warehouseid,
+        ClientSerialID: new Date().getTime(),    // 客户端流水号
+        AppointmentModel: checked.value,
+        AppointmentDate: formatDate(new Date().toISOString(), 'YYYY-MM-DD'),
+    })
+
+    const applySubmit = async () => {
+        try {
+            loading.value = true
+            return await wrOutApply({
+                data: {
+                    ...formData,
+                    WROutInDetails: [{
+                        LadingBillID: holdlb?.ladingbillid,
+                        SubNum: holdlb?.subnum,
+                        Qty: orderQty.value,
+                        OutQty: orderQty.value,
+                    }]
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        formData,
+        applySubmit,
+        orderQty,
+        checked
+    }
+}
+
+// 仓单明细挂牌请求接口
+export function useHdWROrder() {
+    const loading = shallowRef(false)
+
+    const formData = reactive<Proto.HdWROrderReq>({
+        IsSpecified: 0,
+        PriceFactor: 1.0,
+        FirstRatio: 0.0,
+        CanBargain: 0,
+        CanPart: 1,
+        UserID: loginStore.userId,
+        AccountID: accountStore.accountId,
+        OperatorID: loginStore.loginId,
+        ClientType: ClientType.Web,
+        BuyOrSell: BuyOrSell.Sell,
+        WRPriceType: 1,
+        MarginFlag: 0,
+        TimevalidType: 4,
+        HasWr: 1,
+        PerformanceTemplateID: -1,
+    })
+
+    const amount = computed(() => {
+        const { OrderQty = 0, FixedPrice = 0 } = formData
+        return (OrderQty * FixedPrice).toFixed(2)
+    })
+
+    const listingSubmit = async () => {
+        try {
+            loading.value = true
+            const date = new Date().toISOString()
+
+            return await hdWROrder({
+                data: {
+                    ...formData,
+                    TradeDate: formatDate(date, 'YYYYMMDD'),
+                    ClientSerialNo: v4(),
+                    ClientOrderTime: formatDate(date),
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        formData,
+        listingSubmit,
+        amount
+    }
+}
+
+// 仓单摘牌
+export function useHdWRDealOrder() {
+    const loading = shallowRef(false)
+
+    const formData = reactive<Proto.HdWRDealOrderReq>({
+        UserID: loginStore.userId, // 用户ID
+        AccountID: accountStore.accountId, // 资金账号
+        RelatedWRTradeOrderID: Long.fromNumber(0), // 关联委托单号(摘牌委托关联挂牌委托单ID)
+        WRTransferUserID: loginStore.userId, // 仓单受让用户
+        OrderQty: 0, // 委托数量
+        OrderSrc: OrderSrc.ORDERSRC_CLIENT, // 委托来源
+        ClientSerialNo: '', // 客户端流水号
+        ClientOrderTime: '', // 客户端委托时间
+        ClientType: ClientType.Web, // 终端类型
+        OperatorID: loginStore.loginId, // 操作员账号ID
+        TradeDate: '', // 交易日
+        HasWr: 1, // 是否有仓单-0:没有仓单1:有仓单
+        IsFinancing: 0, // 是否融资购买(买摘牌时有效)-0:否1:是
+    })
+
+    const formSubmit = async () => {
+        try {
+            loading.value = true
+            const date = new Date().toISOString()
+
+            return await hdWRDealOrder({
+                data: {
+                    ...formData,
+                    TradeDate: formatDate(date, 'YYYYMMDD'),
+                    ClientSerialNo: v4(),
+                    ClientOrderTime: formatDate(date),
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        formData,
+        formSubmit,
+    }
+}
+
+// 铁合金收益支取申请接口
+export function useTHJProfitDrawApplyReq() {
+    const loading = shallowRef(false)
+
+    const formData = reactive<Proto.THJProfitDrawApplyReq>({
+        UserID: loginStore.userId, // 用户ID
+        ApplySrc: ClientType.Web,
+        ApplyerID: loginStore.loginId,
+        ClientType: ClientType.Web
+    })
+
+    const onSubmit = async (drawMonth: string, amount: number) => {
+        try {
+            loading.value = true
+            return await thjProfitDrawApply({
+                data: {
+                    ...formData,
+                    DrawMonth: formatDate(drawMonth, 'YYYYMM'),
+                    DrawAmount: amount,
+                    ClientSerialNo: v4(),
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        formData,
+        onSubmit,
+    }
+}
+
+// 铁合金预售点价
+export function useSpotPresalePointPrice(WRTradeDetailID = '0') {
+    const loading = shallowRef(false)
+
+    const formData = reactive<Proto.SpotPresalePointPriceReq>({
+        UserID: loginStore.userId, // 用户ID,必填
+        WRTradeDetailID: Long.fromString(WRTradeDetailID), // 预售成交明细ID
+        ClientType: ClientType.Web, // 终端类型
+        ClientSerialNo: '' // 客户端流水号
+    })
+
+    const formSubmit = async () => {
+        try {
+            loading.value = true
+            return await spotPresalePointPrice({
+                data: {
+                    ...formData,
+                    ClientSerialNo: v4(),
+                }
+            })
+        } finally {
+            loading.value = false
+        }
+    }
+
+    return {
+        loading,
+        formData,
+        formSubmit,
+    }
+}

+ 14 - 14
src/hooks/request/index.ts

@@ -49,20 +49,6 @@ export function useRequest<TParams extends object, TResponse>(fetcher: (params:
             data: { ...params, ...payload } as TParams
         })
 
-        if (isCommonResult(res)) {
-            total.value = res.total
-            if (Array.isArray(res.data)) {
-                dataList.value = res.data
-            } else {
-                data.value = res.data as ResultType
-            }
-        } else {
-            if (Array.isArray(res)) {
-                dataList.value = res
-            } else {
-                data.value = res as ResultType
-            }
-        }
         return res
     }
 
@@ -70,6 +56,20 @@ export function useRequest<TParams extends object, TResponse>(fetcher: (params:
     const run = (payload: Partial<TParams> = {}) => {
         loading.value = true
         runAsync(payload).then((res) => {
+            if (isCommonResult(res)) {
+                total.value = res.total
+                if (Array.isArray(res.data)) {
+                    dataList.value = res.data
+                } else {
+                    data.value = res.data as ResultType
+                }
+            } else {
+                if (Array.isArray(res)) {
+                    dataList.value = res
+                } else {
+                    data.value = res as ResultType
+                }
+            }
             onSuccess && onSuccess(res)
         }).catch((err) => {
             onError && onError(err)

+ 1 - 0
src/packages/mobile/components/modules/waterfall/index.less

@@ -0,0 +1 @@
+.app-waterfall {}

+ 20 - 0
src/packages/mobile/components/modules/waterfall/index.vue

@@ -0,0 +1,20 @@
+<template>
+    <div class="app-waterfall">
+        <div class="app-waterfall-item" v-for="(item, index) in dataList" :key="index">
+            <slot :item="item"></slot>
+        </div>
+    </div>
+</template>
+
+<script lang="ts" setup>
+defineProps({
+    dataList: {
+        type: Array,
+        default: () => ([])
+    }
+})
+</script>
+
+<style lang="less">
+@import './index.less';
+</style>

+ 11 - 0
src/packages/mobile/router/index.ts

@@ -155,6 +155,17 @@ const routes: Array<RouteRecordRaw> = [
     ],
   },
   {
+    path: '/transfer',
+    component: Page,
+    children: [
+      {
+        path: 'detail',
+        name: 'transfer-detail',
+        component: () => import('../views/transfer/detail/Index.vue'),
+      },
+    ],
+  },
+  {
     path: '/swap',
     component: Page,
     children: [

+ 191 - 3
src/packages/mobile/views/actuals/detail/Index.vue

@@ -1,8 +1,196 @@
 <template>
-    <app-view>
-
+    <app-view class="supply-demand-details g-form">
+        <template #header>
+            <app-navbar title="供求详情" />
+        </template>
+        <div v-if="quoteItem" class="supply-demand-details__content">
+            <Swipe class="banner" :autoplay="5000" indicator-color="white" lazy-render>
+                <SwipeItem v-for="(url, index) in topBanners" :key="index">
+                    <img :src="url" />
+                </SwipeItem>
+            </Swipe>
+            <div class="goods">
+                <table cellspacing="0" cellpadding="0">
+                    <tr>
+                        <th colspan="3">
+                            <h1>{{ quoteItem.wrstandardname }}</h1>
+                        </th>
+                    </tr>
+                    <tr>
+                        <td>
+                            <span>卖价:</span>
+                            <span>{{ quoteItem.sellprice }}</span>
+                        </td>
+                        <td>
+                            <span>卖量:</span>
+                            <span>{{ quoteItem.sellqty }}</span>
+                        </td>
+                        <td rowspan="3" style="vertical-align:top">
+                            <div class="goods-price">
+                                <h4>参考价(元/{{ quoteItem.enumdicname }})</h4>
+                                <h2>{{ quoteItem.spotgoodsprice }}</h2>
+                            </div>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>
+                            <span>买价:</span>
+                            <span>{{ quoteItem.buyprice }}</span>
+                        </td>
+                        <td>
+                            <span>买量:</span>
+                            <span>{{ quoteItem.buyqty }}</span>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td colspan="2">
+                            <span>仓库:</span>
+                            <span>{{ quoteItem.warehousename }}</span>
+                        </td>
+                    </tr>
+                </table>
+            </div>
+            <div class="trade">
+                <div class="trade-section sell" v-if="sellList.length">
+                    <Cell title="卖价" />
+                    <app-list :columns="columns" :data-list="sellList">
+                        <template #operate="{ row }">
+                            <Button size="small" round @click="delistingListing(row, BuyOrSell.Buy)">买入</Button>
+                        </template>
+                    </app-list>
+                </div>
+                <div class="trade-section buy" v-if="buyList.length">
+                    <Cell title="买价" />
+                    <app-list :columns="columns" :data-list="buyList">
+                        <template #operate="{ row }">
+                            <Button size="small" round @click="delistingListing(row, BuyOrSell.Sell)">卖出</Button>
+                        </template>
+                    </app-list>
+                </div>
+            </div>
+            <div class="gallery">
+                <Divider>商品详情</Divider>
+                <template v-for="(url, index) in goodsImages" :key="index">
+                    <img :src="url" alt="" />
+                </template>
+            </div>
+        </div>
+        <Empty v-else />
+        <template #footer>
+            <div class="g-form__footer" v-if="quoteItem">
+                <Button type="warning" block round @click="toggleListing(BuyOrSell.Sell)">我要卖</Button>
+                <Button type="primary" block round @click="toggleListing(BuyOrSell.Buy)">我要买</Button>
+            </div>
+            <component ref="componentRef" :is="componentMap.get(componentId)" v-bind="{ quoteItem, quoteDetail, buyorsell }"
+                @closed="closeComponent" v-if="componentId" />
+        </template>
     </app-view>
 </template>
 
 <script lang="ts" setup>
-</script>
+import { shallowRef, computed, defineAsyncComponent, onUnmounted } from 'vue'
+import { Cell, Swipe, SwipeItem, Empty, Divider, Button } from 'vant'
+import { getFileUrl } from '@/filters'
+import { useRequest } from '@/hooks/request'
+import { useComponent } from '@/hooks/component'
+import { useNavigation } from '@/hooks/navigation'
+import { BuyOrSell } from '@/constants/order'
+import { queryOrderQuote, queryOrderQuoteDetail } from '@/services/api/goods'
+import eventBus from '@/services/bus'
+import AppList from '@mobile/components/base/list/index.vue'
+
+const componentMap = new Map<string, unknown>([
+    ['listing', defineAsyncComponent(() => import('./components/listing/index.vue'))], // 挂牌
+    ['delisting', defineAsyncComponent(() => import('./components/delisting/index.vue'))], // 摘牌
+])
+
+const { getQueryString } = useNavigation()
+const { componentRef, componentId, openComponent, closeComponent } = useComponent()
+const quoteDetail = shallowRef<Model.OrderQuoteDetailRsp>() // 买卖详情
+const buyorsell = shallowRef(BuyOrSell.Buy) // 买卖方向
+
+const buyList = shallowRef<Model.OrderQuoteDetailRsp[]>([])
+const sellList = shallowRef<Model.OrderQuoteDetailRsp[]>([])
+const wrfactortypeid = getQueryString('wrfactortypeid')
+
+const { data: quoteItem, run: getOrderQuote } = useRequest(queryOrderQuote, {
+    manual: true,
+    params: {
+        wrpricetype: 1,
+        wrfactortypeid
+    },
+    onSuccess: (res) => {
+        quoteItem.value = res.data[0]
+    }
+})
+
+const { runAsync: getOrderQuoteDetail } = useRequest(queryOrderQuoteDetail, {
+    manual: true,
+    params: {
+        wrpricetype: 1,
+        haswr: 1,
+        wrfactortypeid,
+        buyorsell: buyorsell.value
+    },
+})
+
+const columns: Model.TableColumn[] = [
+    { prop: 'username', label: '挂牌方' },
+    { prop: 'orderqty', label: '数量' },
+    { prop: 'fixedprice', label: '价格' },
+    { prop: 'operate', label: '操作' },
+]
+
+// 商品banner
+const topBanners = computed(() => {
+    const bannerpicurl = quoteItem.value?.bannerpicurl ?? ''
+    return bannerpicurl?.split(',').map((path) => getFileUrl(path))
+})
+
+// 商品图片列表
+const goodsImages = computed(() => {
+    const pictureurls = quoteItem.value?.pictureurls ?? ''
+    return pictureurls.split(',').map((path) => getFileUrl(path))
+})
+
+// 挂牌下单
+const toggleListing = (value: BuyOrSell) => {
+    buyorsell.value = value
+    openComponent('listing')
+}
+
+// 摘牌下单
+const delistingListing = (row: Model.OrderQuoteDetailRsp, value: BuyOrSell) => {
+    buyorsell.value = value
+    quoteDetail.value = row
+    openComponent('delisting')
+}
+
+// 刷新数据
+const refresh = () => {
+    getOrderQuote()
+    getOrderQuoteDetail({
+        buyorsell: BuyOrSell.Buy
+    }).then((res) => {
+        buyList.value = res.data
+    })
+    getOrderQuoteDetail({
+        buyorsell: BuyOrSell.Sell
+    }).then((res) => {
+        sellList.value = res.data
+    })
+}
+
+// 接收仓单贸易成交通知
+const wrTradeDealedNtf = eventBus.$on('ListingOrderChangeNtf', () => refresh())
+
+onUnmounted(() => {
+    wrTradeDealedNtf.cancel()
+})
+
+refresh()
+</script>
+
+<style lang="less">
+@import './index.less';
+</style>

+ 13 - 0
src/packages/mobile/views/actuals/detail/components/delisting/index.less

@@ -0,0 +1,13 @@
+.supply-demand-listing {
+    &__form {
+        .van-stepper {
+            display: flex;
+            align-items: center;
+            width: 100%;
+
+            &__input {
+                flex: 1;
+            }
+        }
+    }
+}

+ 180 - 0
src/packages/mobile/views/actuals/detail/components/delisting/index.vue

@@ -0,0 +1,180 @@
+<template>
+    <app-popup class="supply-demand-listing" :title="buyorsell === BuyOrSell.Sell ? '卖出' : '买入'" v-model:show="showModal"
+        :refresh="refresh">
+        <Form class="supply-demand-listing__form" ref="formRef" @submit="onSubmit">
+            <Field label="挂牌方">
+                <template #input>
+                    <span>{{ quoteDetail.username }}</span>
+                </template>
+            </Field>
+            <Field label="挂牌价格">
+                <template #input>
+                    <span>{{ quoteDetail.fixedprice }}</span>
+                </template>
+            </Field>
+            <Field label="剩余数量">
+                <template #input>
+                    <span>{{ quoteDetail.orderqty }}</span>
+                </template>
+            </Field>
+            <!-- <Field name="WRFactorTypeId" :rules="formRules.WRFactorTypeId" label="现货仓单" is-link
+                v-if="buyorsell === BuyOrSell.Sell">
+                <template #input>
+                    <app-select :options="dataList" :optionProps="{ label: 'wrholdeno', value: 'wrid' }"
+                        @confirm="onConfirm" />
+                </template>
+            </Field> -->
+            <Field label="可用数量" v-if="buyorsell === BuyOrSell.Sell">
+                <template #input>
+                    <span>{{ selectedRow?.enableqty ?? 0 }}</span>
+                </template>
+            </Field>
+            <Field name="OrderQty" :rules="formRules.OrderQty" label="摘牌数量">
+                <template #input>
+                    <Stepper v-model="formData.OrderQty" theme="round" button-size="22" :auto-fixed="false" integer />
+                </template>
+            </Field>
+            <Field label="货款金额">
+                <template #input>
+                    <span>{{ amount }}</span>
+                </template>
+            </Field>
+            <Field label="可用资金" v-if="buyorsell === BuyOrSell.Buy">
+                <template #input>
+                    <span>{{ accountStore.avaiableMoney.toFixed(2) }}</span>
+                </template>
+            </Field>
+        </Form>
+        <template #footer>
+            <Button type="primary" block round @click="formRef?.submit">确定</Button>
+        </template>
+    </app-popup>
+</template>
+
+<script lang="ts" setup>
+import { shallowRef, PropType, computed } from 'vue'
+import { Form, Field, Stepper, Button, FieldRule, FormInstance } from 'vant'
+import { fullloading, dialog } from '@/utils/vant'
+import { useAccountStore } from '@/stores'
+import { BuyOrSell } from '@/constants/order'
+import { queryHoldLB } from '@/services/api/order'
+import { useHdWRDealOrder } from '@/business/trade'
+import Long from 'long'
+import AppPopup from '@mobile/components/base/popup/index.vue'
+//import AppSelect from '@mobile/components/base/select/index.vue'
+
+const props = defineProps({
+    quoteItem: {
+        type: Object as PropType<Model.OrderQuoteRsp>,
+        required: true
+    },
+    quoteDetail: {
+        type: Object as PropType<Model.OrderQuoteDetailRsp>,
+        required: true
+    },
+    buyorsell: {
+        type: Number,
+        required: true
+    }
+})
+
+const { formData, formSubmit } = useHdWRDealOrder()
+const accountStore = useAccountStore()
+const formRef = shallowRef<FormInstance>()
+const refresh = shallowRef(false) // 是否刷新父组件数据
+const showModal = shallowRef(true)
+//const dataList = shallowRef<Model.HoldLBRsp[]>([]) //现货仓单列表
+const selectedRow = shallowRef<Model.HoldLBRsp>() //选中的现货仓单
+
+// 货款金额
+const amount = computed(() => {
+    const { OrderQty = 0 } = formData
+    const { fixedprice = 0 } = props.quoteDetail
+    return (OrderQty * fixedprice).toFixed(2)
+})
+
+// 表单验证规则
+const formRules: { [key in keyof Proto.HdWRDealOrderReq]?: FieldRule[] } = {
+    WRFactorTypeId: [{
+        message: '请选择现货仓单',
+        validator: () => {
+            return !!selectedRow.value
+        }
+    }],
+    OrderQty: [{
+        message: '请输入数量',
+        validator: (val) => {
+            if (val) {
+                const { enableqty = 0 } = selectedRow.value ?? {}
+                if (val > props.quoteDetail.orderqty) {
+                    return '剩余数量不足'
+                }
+                if (props.buyorsell === BuyOrSell.Sell && val > enableqty) {
+                    return '可用数量不足'
+                }
+                return true
+            }
+            return false
+        }
+    }],
+}
+
+// // 选择仓单
+// const onConfirm = (value: string) => {
+//     selectedRow.value = dataList.value.find((e) => e.wrid === value)
+//     formRef.value?.validate('WRFactorTypeId')
+//     formRef.value?.validate('OrderQty')
+// }
+
+// 关闭弹窗
+const closed = (isRefresh = false) => {
+    refresh.value = isRefresh
+    showModal.value = false
+}
+
+// 提交摘牌
+const onSubmit = () => {
+    const { wrfactortypeid = '0' } = props.quoteItem ?? {}
+    const { wrtradeorderid = '0', marketid } = props.quoteDetail ?? {}
+
+    formData.Header = {
+        MarketID: marketid
+    }
+    formData.BuyOrSell = props.buyorsell
+    formData.RelatedWRTradeOrderID = Long.fromString(wrtradeorderid)
+    formData.WRFactorTypeId = Long.fromString(wrfactortypeid)
+
+    if (formData.BuyOrSell === BuyOrSell.Sell) {
+        const { subnum, ladingbillid = '0', wrfactortypeid = '0' } = selectedRow.value ?? {}
+        formData.LadingBillId = Long.fromString(ladingbillid)
+        formData.SubNum = subnum
+        formData.WRFactorTypeId = Long.fromString(wrfactortypeid)
+    }
+
+    fullloading((hideLoading) => {
+        formSubmit().then(() => {
+            hideLoading()
+            dialog('摘牌提交成功。').then(() => closed(true))
+        }).catch((err) => {
+            hideLoading(err, 'fail')
+        })
+    })
+}
+
+queryHoldLB({
+    data: {
+        wrfactortypeid: props.quoteItem.wrfactortypeid
+    }
+}).then((res) => {
+    selectedRow.value = res.data[0]
+})
+
+// 暴露组件属性给父组件调用
+defineExpose({
+    closed,
+})
+</script>
+
+<style lang="less">
+@import './index.less';
+</style>

+ 13 - 0
src/packages/mobile/views/actuals/detail/components/listing/index.less

@@ -0,0 +1,13 @@
+.supply-demand-listing {
+    &__form {
+        .van-stepper {
+            display: flex;
+            align-items: center;
+            width: 100%;
+
+            &__input {
+                flex: 1;
+            }
+        }
+    }
+}

+ 162 - 0
src/packages/mobile/views/actuals/detail/components/listing/index.vue

@@ -0,0 +1,162 @@
+<template>
+    <app-popup class="supply-demand-listing" :title="buyorsell === BuyOrSell.Sell ? '我要卖' : '我要买'" v-model:show="showModal"
+        :refresh="refresh">
+        <Form class="supply-demand-listing__form" ref="formRef" @submit="onSubmit">
+            <Field name="FixedPrice" :rules="formRules.FixedPrice" label="挂牌价格">
+                <template #input>
+                    <Stepper v-model="formData.FixedPrice" :default-value="quoteItem.spotgoodsprice" theme="round"
+                        :decimal-length="2" :auto-fixed="false" button-size="22" />
+                </template>
+            </Field>
+            <!-- <Field name="WRFactorTypeId" :rules="formRules.WRFactorTypeId" label="现货仓单" is-link
+                v-if="buyorsell === BuyOrSell.Sell">
+                <template #input>
+                    <app-select :options="dataList" :optionProps="{ label: 'wrholdeno', value: 'wrid' }"
+                        @confirm="onConfirm" />
+                </template>
+            </Field> -->
+            <Field label="可用数量" v-if="buyorsell === BuyOrSell.Sell">
+                <template #input>
+                    <span>{{ selectedRow?.enableqty ?? 0 }}</span>
+                </template>
+            </Field>
+            <Field name="OrderQty" :rules="formRules.OrderQty" label="挂牌数量">
+                <template #input>
+                    <Stepper v-model="formData.OrderQty" theme="round" button-size="22" :auto-fixed="false" integer />
+                </template>
+            </Field>
+            <Field label="货款金额">
+                <template #input>
+                    <span>{{ amount }}</span>
+                </template>
+            </Field>
+            <Field label="可用资金" v-if="buyorsell === BuyOrSell.Buy">
+                <template #input>
+                    <span>{{ accountStore.avaiableMoney.toFixed(2) }}</span>
+                </template>
+            </Field>
+        </Form>
+        <template #footer>
+            <Button type="primary" block round @click="formRef?.submit">确定</Button>
+        </template>
+    </app-popup>
+</template>
+
+<script lang="ts" setup>
+import { shallowRef, PropType } from 'vue'
+import { Form, Field, Stepper, Button, FieldRule, FormInstance } from 'vant'
+import { fullloading, dialog } from '@/utils/vant'
+import { useAccountStore } from '@/stores'
+import { BuyOrSell } from '@/constants/order'
+import { queryHoldLB } from '@/services/api/order'
+import { useHdWROrder } from '@/business/trade'
+import Long from 'long'
+import AppPopup from '@mobile/components/base/popup/index.vue'
+//import AppSelect from '@mobile/components/base/select/index.vue'
+
+const props = defineProps({
+    quoteItem: {
+        type: Object as PropType<Model.OrderQuoteRsp>,
+        required: true
+    },
+    buyorsell: {
+        type: Number,
+        required: true
+    }
+})
+
+const { formData, listingSubmit, amount } = useHdWROrder()
+const accountStore = useAccountStore()
+const formRef = shallowRef<FormInstance>()
+const refresh = shallowRef(false) // 是否刷新父组件数据
+const showModal = shallowRef(true)
+//const dataList = shallowRef<Model.HoldLBRsp[]>([]) //现货仓单列表
+const selectedRow = shallowRef<Model.HoldLBRsp>() //选中的现货仓单
+
+// 表单验证规则
+const formRules: { [key in keyof Proto.HdWROrderReq]?: FieldRule[] } = {
+    FixedPrice: [{
+        message: '请输入价格',
+        validator: () => {
+            return !!formData.FixedPrice
+        }
+    }],
+    WRFactorTypeId: [{
+        message: '请选择现货仓单',
+        validator: () => {
+            return !!selectedRow.value
+        }
+    }],
+    OrderQty: [{
+        message: '请输入数量',
+        validator: (val) => {
+            if (val) {
+                const { enableqty = 0 } = selectedRow.value ?? {}
+                if (props.buyorsell === BuyOrSell.Buy || val <= enableqty) {
+                    return true
+                }
+                return '可用数量不足'
+            }
+            return false
+        }
+    }],
+}
+
+// 选择仓单
+// const onConfirm = (value: string) => {
+//     selectedRow.value = dataList.value.find((e) => e.wrid === value)
+//     formRef.value?.validate('WRFactorTypeId')
+//     formRef.value?.validate('OrderQty')
+// }
+
+// 关闭弹窗
+const closed = (isRefresh = false) => {
+    refresh.value = isRefresh
+    showModal.value = false
+}
+
+// 提交挂牌
+const onSubmit = () => {
+    formData.BuyOrSell = props.buyorsell
+
+    if (formData.BuyOrSell === BuyOrSell.Buy) {
+        const { wrstandardid, deliverygoodsid, wrfactortypeid = '0' } = props.quoteItem ?? {}
+        formData.WRStandardID = wrstandardid
+        formData.DeliveryGoodsID = deliverygoodsid
+        formData.WRFactorTypeId = Long.fromString(wrfactortypeid)
+    } else {
+        const { wrstandardid, subnum, deliverygoodsid, ladingbillid = '0', wrfactortypeid = '0' } = selectedRow.value ?? {}
+        formData.WRStandardID = wrstandardid
+        formData.DeliveryGoodsID = deliverygoodsid
+        formData.LadingBillId = Long.fromString(ladingbillid)
+        formData.WRFactorTypeId = Long.fromString(wrfactortypeid)
+        formData.SubNum = subnum
+    }
+
+    fullloading((hideLoading) => {
+        listingSubmit().then(() => {
+            hideLoading()
+            dialog('挂牌提交成功。').then(() => closed(true))
+        }).catch((err) => {
+            hideLoading(err, 'fail')
+        })
+    })
+}
+
+queryHoldLB({
+    data: {
+        wrfactortypeid: props.quoteItem.wrfactortypeid
+    }
+}).then((res) => {
+    selectedRow.value = res.data[0]
+})
+
+// 暴露组件属性给父组件调用
+defineExpose({
+    closed,
+})
+</script>
+
+<style lang="less">
+@import './index.less';
+</style>

+ 77 - 0
src/packages/mobile/views/actuals/detail/index.less

@@ -0,0 +1,77 @@
+.supply-demand-details {
+    .g-form__footer {
+        background-color: #fff;
+    }
+
+    &__content {
+        .banner {
+            background-color: #999;
+
+            .van-swipe {
+                min-height: 3rem;
+
+                &-item {
+                    height: 3rem;
+                    font-size: 0;
+
+                    img {
+                        width: 100%;
+                        height: 100%;
+                        object-fit: cover;
+                    }
+                }
+            }
+        }
+
+        .goods {
+            padding: .32rem;
+            background-color: #fff;
+            margin-bottom: .2rem;
+
+            table {
+                width: 100%;
+
+                th {
+                    text-align: left;
+
+                    h1 {
+                        font-size: .36rem;
+                        font-weight: bold;
+                    }
+                }
+
+                td {
+                    padding: .08rem 0;
+
+                    span {
+                        &:first-child {
+                            color: #999;
+                            margin-right: .2rem;
+                        }
+                    }
+                }
+            }
+
+            &-price {
+                text-align: center;
+
+                h2 {
+                    font-size: .48rem;
+                    font-weight: bold;
+                    color: #E84F42;
+                }
+            }
+        }
+
+        .trade {
+            &-section {
+                margin-bottom: .2rem;
+
+                .van-button {
+                    width: 1rem;
+                    border-width: 1px;
+                }
+            }
+        }
+    }
+}

+ 36 - 4
src/packages/mobile/views/actuals/list/Index.vue

@@ -1,24 +1,46 @@
 <template>
-    <app-view>
+    <app-view class="actuals-list">
         <template #header>
             <app-navbar title="现货挂牌" />
         </template>
         <app-pull-refresh ref="pullRefreshRef" class="purchase__container" v-model:loading="loading" v-model:error="error"
             v-model:pageIndex="pageIndex" :page-count="pageCount" @refresh="run">
-            {{ dataList }}
+            <div class="waterfall">
+                <div class="waterfall-item" v-for="(item, index) in dataList" :key="index">
+                    <div class="goods"
+                        @click="$router.push({ name: 'actuals-detail', query: { wrfactortypeid: item.wrfactortypeid } })">
+                        <div class="goods-image">
+                            <img :src="getFirstImage(item.thumurls)" />
+                        </div>
+                        <div class="goods-info">
+                            <div class="goods-info__title">{{ item.wrstandardname }}</div>
+                            <div class="goods-info__price">
+                                <Tag type="danger">卖价</Tag>
+                                <span class="unit">{{ item.sellprice }}</span>
+                            </div>
+                            <div class="goods-info__price">
+                                <Tag type="warning">买价</Tag>
+                                <span class="unit">{{ item.buyprice }}</span>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
         </app-pull-refresh>
     </app-view>
 </template>
 
 <script lang="ts" setup>
 import { shallowRef } from 'vue'
+import { Tag } from 'vant'
+import { getFileUrl } from '@/filters'
 import { useRequest } from '@/hooks/request'
 import { queryOrderQuote } from '@/services/api/goods'
 import AppPullRefresh from '@mobile/components/base/pull-refresh/index.vue'
 
 const error = shallowRef(false)
 const pullRefreshRef = shallowRef()
-const dataList = shallowRef<Model.THJWrstandardRsp[]>([])
+const dataList = shallowRef<Model.OrderQuoteRsp[]>([])
 
 const { loading, pageIndex, pageCount, run } = useRequest(queryOrderQuote, {
     manual: true,
@@ -37,4 +59,14 @@ const { loading, pageIndex, pageCount, run } = useRequest(queryOrderQuote, {
         error.value = true
     }
 })
-</script>
+
+// 获取商品首图
+const getFirstImage = (url: string) => {
+    const images = url.split(',').map((path) => getFileUrl(path))
+    return images[0] ?? ''
+}
+</script>
+
+<style lang="less">
+@import './index.less';
+</style>

+ 36 - 0
src/packages/mobile/views/actuals/list/index.less

@@ -0,0 +1,36 @@
+.actuals-list {
+    .waterfall {
+        display: flex;
+        flex-wrap: wrap;
+        padding: .12rem;
+
+        &-item {
+            width: 50%;
+            padding: .12rem;
+
+            .goods {
+                background-color: #fff;
+                border-radius: .12rem;
+                overflow: hidden;
+
+                &-image {
+                    font-size: 0;
+                }
+
+                &-info {
+                    padding: .2rem;
+
+                    &__price {
+                        .unit {
+                            &::before {
+                                content: '¥';
+                            }
+
+                            color: red;
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 8 - 0
src/packages/mobile/views/transfer/detail/Index.vue

@@ -0,0 +1,8 @@
+<template>
+    <app-view>
+
+    </app-view>
+</template>
+
+<script lang="ts" setup>
+</script>

+ 17 - 1
src/packages/mobile/views/transfer/list/Index.vue

@@ -3,14 +3,30 @@
         <template #header>
             <app-navbar title="订单转让" :show-back-button="false" />
         </template>
+        <app-list :columns="columns" :data-list="futuresStore.quoteList">
+            <template #goodscode="{ value }">
+                <span @click="$router.push({ name: 'transfer-detail' })">{{ value }}</span>
+            </template>
+        </app-list>
     </app-view>
 </template>
 
 <script lang="ts" setup>
 import { onActivated, onDeactivated } from 'vue'
+import { useFuturesStore } from '@/stores'
 import quoteSocket from '@/services/websocket/quote'
+import AppList from '@mobile/components/base/list/index.vue'
 
-const subscribe = quoteSocket.addSubscribe(['XAUUSD'])
+const futuresStore = useFuturesStore()
+const goodsCodes = futuresStore.goodsList.map((e) => e.goodscode.toUpperCase())
+const subscribe = quoteSocket.addSubscribe(goodsCodes)
+
+const columns: Model.TableColumn[] = [
+    { prop: 'goodscode', label: '商品代码/名称' },
+    { prop: 'last', label: '最新价' },
+    { prop: 'ask', label: '卖价' },
+    { prop: 'askvolume', label: '卖量' },
+]
 
 onActivated(() => subscribe.start())
 onDeactivated(() => subscribe.stop())

+ 99 - 110
src/services/api/trade/index.ts

@@ -1,179 +1,168 @@
+import http from '@/services/http'
+import { RequestConfig } from '@/services/http/types'
+
 /**
  * 铁合金现货预售摘牌
  */
-export function spotPresaleDestingOrder(params: Partial<Proto.SpotPresaleDestingOrderReq>) {
-    // return tradeServer.sendMessage<Proto.SpotPresaleDestingOrderRsp>({
-    //     params,
-    //     reqName: 'SpotPresaleDestingOrderReq',
-    //     rspName: 'SpotPresaleDestingOrderRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresaleDestingOrder')
+export function spotPresaleDestingOrder(config: RequestConfig<Partial<Proto.SpotPresaleDestingOrderReq>>) {
+    return http.mqRequest<Proto.SpotPresaleDestingOrderRsp>({
+        data: config.data,
+        requestCode: 'SpotPresaleDestingOrderReq',
+        responseCode: 'SpotPresaleDestingOrderRsp',
+        marketId: 64201
+    })
 }
 
 /**
  * 铁合金现货预售交收确认
  */
-export function spotPresaleDeliveryConfirm(params: Proto.SpotPresaleDeliveryConfirmReq) {
-    // return tradeServer.sendMessage<Proto.SpotPresaleDeliveryConfirmRsp>({
-    //     params,
-    //     reqName: 'SpotPresaleDeliveryConfirmReq',
-    //     rspName: 'SpotPresaleDeliveryConfirmRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresaleDeliveryConfirm')
+export function spotPresaleDeliveryConfirm(config: RequestConfig<Proto.SpotPresaleDeliveryConfirmReq>) {
+    return http.mqRequest<Proto.SpotPresaleDeliveryConfirmRsp>({
+        data: config.data,
+        requestCode: 'SpotPresaleDeliveryConfirmReq',
+        responseCode: 'SpotPresaleDeliveryConfirmRsp',
+        marketId: 64201
+    })
 }
 
 /**
  * 铁合金现货预售违约确认
  */
-export function spotPresaleBreachOfContractConfirm(params: Proto.SpotPresaleBreachOfContractConfirmReq) {
-    // return tradeServer.sendMessage<Proto.SpotPresaleBreachOfContractConfirmRsp>({
-    //     params,
-    //     reqName: 'SpotPresaleBreachOfContractConfirmReq',
-    //     rspName: 'SpotPresaleBreachOfContractConfirmRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresaleBreachOfContractConfirm')
+export function spotPresaleBreachOfContractConfirm(config: RequestConfig<Proto.SpotPresaleBreachOfContractConfirmReq>) {
+    return http.mqRequest<Proto.SpotPresaleBreachOfContractConfirmRsp>({
+        data: config.data,
+        requestCode: 'SpotPresaleBreachOfContractConfirmReq',
+        responseCode: 'SpotPresaleBreachOfContractConfirmRsp',
+        marketId: 64201
+    })
 }
 
 /**
  * 铁合金现货预售违约申请接口请求
  */
-export function spotPresaleBreachOfContractApply(params: Proto.SpotPresaleBreachOfContractApplyReq) {
-    // return tradeServer.sendMessage<Proto.SpotPresaleBreachOfContractApplyRsp>({
-    //     params,
-    //     reqName: 'SpotPresaleBreachOfContractApplyReq',
-    //     rspName: 'SpotPresaleBreachOfContractApplyRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresaleBreachOfContractApply')
+export function spotPresaleBreachOfContractApply(config: RequestConfig<Proto.SpotPresaleBreachOfContractApplyReq>) {
+    return http.mqRequest<Proto.SpotPresaleBreachOfContractApplyRsp>({
+        data: config.data,
+        requestCode: 'SpotPresaleBreachOfContractApplyReq',
+        responseCode: 'SpotPresaleBreachOfContractApplyRsp',
+        marketId: 64201
+    })
 }
 
 /**
  * 铁合金现货预售付款处理接口请求
  */
-export function spotPresalePlayment(params: Proto.SpotPresalePlaymentReq) {
-    // return tradeServer.sendMessage<Proto.SpotPresalePlaymentRsp>({
-    //     params,
-    //     reqName: 'SpotPresalePlaymentReq',
-    //     rspName: 'SpotPresalePlaymentRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresalePlayment')
+export function spotPresalePlayment(config: RequestConfig<Proto.SpotPresalePlaymentReq>) {
+    return http.mqRequest<Proto.SpotPresalePlaymentRsp>({
+        data: config.data,
+        requestCode: 'SpotPresalePlaymentReq',
+        responseCode: 'SpotPresalePlaymentRsp',
+        marketId: 64201
+    })
 }
 
 /**
  * 挂牌撤单请求
  */
-export function wrListingCancelOrder(params: Proto.WRListingCancelOrderReq) {
-    // return tradeServer.sendMessage<Proto.WRListingCancelOrderRsp>({
-    //     params,
-    //     reqName: 'WRListingCancelOrderReq',
-    //     rspName: 'WRListingCancelOrderRsp',
-    //     marketId: 65201
-    // })
-    return Promise.reject('wrListingCancelOrder')
+export function wrListingCancelOrder(config: RequestConfig<Proto.WRListingCancelOrderReq>) {
+    return http.mqRequest<Proto.WRListingCancelOrderRsp>({
+        data: config.data,
+        requestCode: 'WRListingCancelOrderReq',
+        responseCode: 'WRListingCancelOrderRsp',
+        marketId: 65201
+    })
 }
 
 /**
  * 仓单出库申请
  */
-export function wrOutApply(params: Proto.WROutApplyReq) {
-    // return tradeServer.sendMessage<Proto.WROutApplyRsp>({
-    //     params,
-    //     reqName: 'WROutApplyReq',
-    //     rspName: 'WROutApplyRsp'
-    // })
-    return Promise.reject('wrOutApply')
+export function wrOutApply(config: RequestConfig<Proto.WROutApplyReq>) {
+    return http.mqRequest<Proto.WROutApplyRsp>({
+        data: config.data,
+        requestCode: 'WROutApplyReq',
+        responseCode: 'WROutApplyRsp'
+    })
 }
 
 /**
  * 持仓单挂牌请求
  */
-export function hdWROrder(params: Proto.HdWROrderReq) {
-    // return tradeServer.sendMessage<Proto.HdWROrderRsp>({
-    //     params,
-    //     reqName: 'HdWROrderReq',
-    //     rspName: 'HdWROrderRsp',
-    //     marketId: 65201
-    // })
-    return Promise.reject('hdWROrder')
+export function hdWROrder(config: RequestConfig<Proto.HdWROrderReq>) {
+    return http.mqRequest<Proto.HdWROrderRsp>({
+        data: config.data,
+        requestCode: 'HdWROrderReq',
+        responseCode: 'HdWROrderRsp',
+        marketId: 17201
+    })
 }
 
 /**
  * 持仓单摘牌请求
  */
-export function hdWRDealOrder(params: Proto.HdWRDealOrderReq) {
-    // return tradeServer.sendMessage<Proto.HdWRDealOrderRsp>({
-    //     params,
-    //     reqName: 'HdWRDealOrderReq',
-    //     rspName: 'HdWRDealOrderRsp'
-    // })
-    return Promise.reject('hdWRDealOrder')
+export function hdWRDealOrder(config: RequestConfig<Proto.HdWRDealOrderReq>) {
+    return http.mqRequest<Proto.HdWRDealOrderRsp>({
+        data: config.data,
+        requestCode: 'HdWRDealOrderReq',
+        responseCode: 'HdWRDealOrderRsp'
+    })
 }
 
 /**
  * 铁合金现货预售转让挂牌接口请求
  */
-export function spotPresaleTransferListing(params: Proto.SpotPresaleTransferListingReq) {
-    // return tradeServer.sendMessage<Proto.SpotPresaleTransferListingRsp>({
-    //     params,
-    //     reqName: 'SpotPresaleTransferListingReq',
-    //     rspName: 'SpotPresaleTransferListingRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresaleTransferListing')
+export function spotPresaleTransferListing(config: RequestConfig<Proto.SpotPresaleTransferListingReq>) {
+    return http.mqRequest<Proto.SpotPresaleTransferListingRsp>({
+        data: config.data,
+        requestCode: 'SpotPresaleTransferListingReq',
+        responseCode: 'SpotPresaleTransferListingRsp',
+        marketId: 64201
+    })
 }
 
 /**
  * 铁合金现货预售转让撤销接口请求
  */
-export function spotPresaleTransferCancel(params: Proto.SpotPresaleTransferCancelReq) {
-    // return tradeServer.sendMessage<Proto.SpotPresaleTransferCancelRsp>({
-    //     params,
-    //     reqName: 'SpotPresaleTransferCancelReq',
-    //     rspName: 'SpotPresaleTransferCancelRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresaleTransferCancel')
+export function spotPresaleTransferCancel(config: RequestConfig<Proto.SpotPresaleTransferCancelReq>) {
+    return http.mqRequest<Proto.SpotPresaleTransferCancelRsp>({
+        data: config.data,
+        requestCode: 'SpotPresaleTransferCancelReq',
+        responseCode: 'SpotPresaleTransferCancelRsp',
+        marketId: 64201
+    })
 }
 
 /**
  * 铁合金现货预售转让摘牌接口请求
  */
-export function spotPresaleTransferDesting(params: Proto.SpotPresaleTransferDestingReq) {
-    // return tradeServer.sendMessage<Proto.SpotPresaleTransferDestingRsp>({
-    //     params,
-    //     reqName: 'SpotPresaleTransferDestingReq',
-    //     rspName: 'SpotPresaleTransferDestingRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresaleTransferDesting')
+export function spotPresaleTransferDesting(config: RequestConfig<Proto.SpotPresaleTransferDestingReq>) {
+    return http.mqRequest<Proto.SpotPresaleTransferDestingRsp>({
+        data: config.data,
+        requestCode: 'SpotPresaleTransferDestingReq',
+        responseCode: 'SpotPresaleTransferDestingRsp',
+        marketId: 64201
+    })
 }
 
 /**
  * 铁合金收益支取申请接口请求
  */
-export function thjProfitDrawApply(params: Proto.THJProfitDrawApplyReq) {
-    // return tradeServer.sendMessage<Proto.THJProfitDrawApplyRsp>({
-    //     params,
-    //     reqName: 'THJProfitDrawApplyReq',
-    //     rspName: 'THJProfitDrawApplyRsp',
-    //     marketId: 65201
-    // })
-    return Promise.reject('thjProfitDrawApply')
+export function thjProfitDrawApply(config: RequestConfig<Proto.THJProfitDrawApplyReq>) {
+    return http.mqRequest<Proto.THJProfitDrawApplyRsp>({
+        data: config.data,
+        requestCode: 'THJProfitDrawApplyReq',
+        responseCode: 'THJProfitDrawApplyRsp',
+        marketId: 65201
+    })
 }
 
 /**
  * 铁合金预售点价
  */
-export function spotPresalePointPrice(params: Proto.SpotPresalePointPriceReq) {
-    // return tradeServer.sendMessage<Proto.SpotPresalePointPriceRsp>({
-    //     params,
-    //     reqName: 'SpotPresalePointPriceReq',
-    //     rspName: 'SpotPresalePointPriceRsp',
-    //     marketId: 64201
-    // })
-    return Promise.reject('spotPresalePointPrice')
+export function spotPresalePointPrice(config: RequestConfig<Proto.SpotPresalePointPriceReq>) {
+    return http.mqRequest<Proto.SpotPresalePointPriceRsp>({
+        data: config.data,
+        requestCode: 'SpotPresalePointPriceReq',
+        responseCode: 'SpotPresalePointPriceRsp',
+        marketId: 64201
+    })
 }

+ 2 - 1
src/services/http/index.ts

@@ -164,7 +164,7 @@ export default new (class {
      * @param errMsg 
      * @returns 
      */
-    async mqRequest<T>({ data, requestCode, responseCode }: SendMsgToMQ<{ Header?: IMessageHead }>, errMsg?: string) {
+    async mqRequest<T>({ data, requestCode, responseCode, marketId = 0 }: SendMsgToMQ<{ Header?: IMessageHead }>, errMsg?: string) {
         const loginStore = useLoginStore()
         const accountStore = useAccountStore()
         const requestId = FunCode[requestCode]
@@ -176,6 +176,7 @@ export default new (class {
                 FunCode: requestId,
                 UUID: v4(),
                 UserID: loginStore.userId,
+                MarketID: marketId,
                 ...data.Header,
             }
         }

+ 1 - 0
src/services/http/types.ts

@@ -36,4 +36,5 @@ export interface SendMsgToMQ<T> {
     data?: T; // 请求参数
     requestCode: keyof typeof FunCode; // 请求功能码
     responseCode: keyof typeof FunCode; // 回复功能码
+    marketId?: number;
 }

+ 297 - 197
src/services/websocket/build/index.ts

@@ -1,278 +1,378 @@
 import { v4 } from 'uuid'
-import { Message, Options, ReadyState, RequestCode, MessageType, AsyncTask } from './types'
+import { Package40, Package50 } from '../package';
+import { Package, ConnectionState, SendMessage, AsyncTask } from './types';
+import moment from 'moment'
 
-export default class {
-    readonly host;
+/**
+ * MTP2.0 长链通信类
+ */
+export class MTP2WebSocket<T extends Package40 | Package50>{
+    /** 报文协议 */
+    private readonly Package: Package<T>;
     /** 当前实例标识 */
     private uuid = '';
+    /** 服务端地址 */
+    private host;
     /** WebSocket 对象 */
     private ws?: WebSocket;
-    /** WebSocket 准备完成 */
-    private wsReady?: Promise<WebSocket>;
-    /** WebSocket 子协议 */
-    private protocols?: string | string[];
-
-    /** 信息发送异步建值对 */
-    private asyncTaskMap = new Map<string, AsyncTask>();
-
-    /** 超时定时器 */
-    private timeoutTimer = 0;
-    /** 消息回复超时时间 */
-    private timeout = 1000 * 15;
-
     /** 心跳定时器 */
-    private heartbeatTimer = 0;
-    /** 心跳间隔时长 */
-    private heartbeatInterval = 1000 * 30;
-
+    private beatTimer = 0;
+    /** 心跳发送时间 */
+    private beatTimerId = '';
     /** 重连定时器 */
     private reconnectTimer = 0;
-    /** 本次重连次数 */
-    private reconnectCount = 0;
-    /** 限制重连次数,0 = 无限制 */
-    private reconnectLimit = 10;
-
-    /** 是否启用心跳检测 */
-    heartbeat;
-    /** WebSocket 连接状态 */
-    readyState = ReadyState.Closed;
+    /** 超时定时器 */
+    private timeoutTimer = 0;
+    /** 当前流水号 */
+    private currentSerial = 1;
+    /** 信息发送异步建值对 */
+    private asyncTaskMap = new Map<number, AsyncTask<T>>();
+    /** 连接准备完成 */
+    private onReady?: Promise<MTP2WebSocket<T>>;
 
     /** 连接成功的回调 */
-    onOpen?: () => void;
+    onOpen?: (socket: MTP2WebSocket<T>) => void;
     /** 连接断开的回调 */
     onClose?: () => void;
     /** 连接发生错误的回调 */
-    onError?: (err: Event) => void;
-    /** 接收推送消息的回调 */
-    onPush?: (message: { requestCode: RequestCode; data: unknown; }) => void;
+    onError?: (e: Event) => void;
+    /** 接收到推送类报文的回调 */
+    onPush?: (data: T) => void;
     /** 在重连之前回调 */
     onBeforeReconnect?: (count: number) => void;
     /** 在重连成功之后回调 */
     onReconnect?: () => void;
 
-    constructor(host: string, options?: Options) {
-        this.host = host
-        if (options) {
-            this.heartbeat = options.heartbeat
-            this.protocols = options.protocols
-        }
-        if (!options?.manual) {
-            this.connect()
-        }
+    /** 当前连接状态 */
+    connState: keyof typeof ConnectionState = 'Unconnected';
+    /** 重连次数 */
+    reconnectCount = 0;
+    /** 心跳间隔时长,默认为30秒 */
+    beatInterval = 30 * 1000;
+    /** 消息超时时长,默认为15秒 */
+    timeoutInterval = 15 * 1000;
+
+    constructor(pkg: Package<T>, host = '') {
+        this.Package = pkg;
+        this.host = host;
     }
 
     /**
      * 连接服务器
      */
-    connect() {
-        if (!this.wsReady || this.readyState === ReadyState.Closed) {
-            clearTimeout(this.reconnectTimer)
-            this.stopHeartbeat()
-            this.readyState = ReadyState.Connecting
-            console.log(this.host, '正在连接')
+    async connect(host?: string, protocols?: string | string[]): Promise<MTP2WebSocket<T>> {
+        if (!this.onReady) {
+            clearTimeout(this.reconnectTimer);
+            this.stopHeartBeat();
+            this.host = host || this.host;
+            this.connState = 'Connecting';
+            console.log(this.host, '正在连接');
 
-            this.wsReady = new Promise((resolve, reject) => {
-                try {
-                    const ws = new WebSocket(this.host, this.protocols)
-                    const uuid = v4()
-                    this.uuid = uuid
+            this.onReady = new Promise((resolve, reject) => {
+                const errMsg = this.host + ' 连接发生错误';
+                const uuid = v4();
+                this.uuid = uuid;
 
+                try {
+                    this.ws = new WebSocket(this.host, protocols);
                     // 连接成功
-                    ws.onopen = () => {
-                        console.log(this.host, '连接成功')
-                        this.readyState = ReadyState.Open
-                        this.startHeartbeat()
-                        this.onOpen && this.onOpen()
-                        resolve(ws)
+                    this.ws.onopen = () => {
+                        console.log(this.host, '连接成功');
+                        this.connState = 'Connected';
+                        this.startHeartBeat();
+                        this.onOpen && this.onOpen(this);
+                        resolve(this);
                     }
                     // 连接断开(CLOSING有可能延迟)
-                    ws.onclose = () => {
+                    this.ws.onclose = () => {
                         // 判断是否当前实例
                         // 如果连接断开后不等待 onclose 响应,由于 onclose 延迟的原因可能会在创建新的 ws 实例后触发,导致刚创建的实例被断开进入重连机制
                         if (this.uuid === uuid) {
-                            console.warn(this.host, '连接已断开')
-                            this.reset()
-                            if (this.reconnectLimit === 0 || this.reconnectCount < this.reconnectLimit) {
-                                this.reconnect() // 重连失败会不断尝试,直到成功为止
-                            } else {
-                                this.reconnectCount = 0
-                            }
+                            console.warn(this.host, '连接已断开');
+                            this.reset();
+                            this.reconnect(); // 重连失败会不断尝试,直到成功为止
                         }
                     }
                     // 连接发生错误
-                    ws.onerror = (e) => {
-                        console.error('连接发生错误', e)
-                        this.onError && this.onError(e)
-                        reject(e)
+                    this.ws.onerror = (e) => {
+                        this.onError && this.onError(e);
+                        reject(errMsg);
                     }
-                    // 接收消息
-                    ws.onmessage = (e) => {
-                        console.log('收到消息', e.data)
-                        this.response(e.data)
+                    // 接收数据
+                    this.ws.onmessage = (e) => {
+                        // 接收数据
+                        new Response(e.data).arrayBuffer().then((res) => {
+                            this.disposeReceiveDatas(new Uint8Array(res));
+                        })
                     }
-
-                    this.ws = ws
-                } catch (e) {
-                    reject(e)
+                } catch {
+                    reject(errMsg);
                 }
             })
         }
-        return this.wsReady
+        return this.onReady
     }
 
     /**
-     * 重置连接状态
+     * 主动断开连接,断开后不会自动重连
      */
-    private reset() {
-        this.ws = undefined
-        this.wsReady = undefined
-        this.readyState = ReadyState.Closed
-        this.asyncTaskMap.clear()
-        this.onClose && this.onClose()
+    close() {
+        clearTimeout(this.reconnectTimer);
+        this.stopHeartBeat();
+        this.uuid = v4(); // 改变实例标识,取消重连状态
+        this.reconnectCount = 0;
+
+        if (this.ws) {
+            this.ws.close();
+            console.warn(this.host, '主动断开');
+        }
+
+        this.reset(); // 不等待 ws.onclose 响应强制重置实例
     }
 
     /**
-     * 主动断开连接,断开后不会自动重连
-     * @param forced 是否强制断开
+     * 重置实例
      */
-    close(forced = false) {
-        return new Promise<void>((resolve) => {
-            clearTimeout(this.reconnectTimer)
-            this.stopHeartbeat()
-            this.uuid = v4() // 改变实例标识,取消重连状态
-            this.reconnectCount = 0
+    private reset() {
+        this.ws = undefined;
+        this.onReady = undefined;
+        this.connState = 'Unconnected';
+        this.onClose && this.onClose();
+    }
 
-            if (!forced && this.ws) {
-                const listener = () => {
-                    console.warn(this.host, '主动断开')
-                    this.ws?.removeEventListener('close', listener)
-                    this.reset()
-                    resolve()
+    /**
+    * 启动心跳请求包发送Timer
+    */
+    private startHeartBeat() {
+        this.beatTimer = window.setTimeout(() => {
+            this.beatTimerId = moment(new Date()).format('HH:mm:ss');
+            console.log(this.host, '发送心跳', this.beatTimerId);
+            // 发送心跳
+            switch (this.Package) {
+                case Package40: {
+                    // 4.0
+                    this.send({
+                        data: { rspCode: -1, payload: new this.Package(0x12) }
+                    })
+                    break;
                 }
-                this.ws.addEventListener('close', listener)
-                this.ws.close()
-            } else {
-                if (this.ws) {
-                    console.warn(this.host, '主动断开')
-                    this.ws.close()
+                case Package50: {
+                    // 5.0
+                    this.send({
+                        data: { rspCode: 0, payload: new this.Package(0) }
+                    })
+                    break;
                 }
-                this.reset()
-                resolve()
             }
-        })
+            // 如果已经超过或心跳超时时长没有收到心跳回复,则认为网络已经异常,进行断网重连
+            // WARNING: - 注意在正常断网时停止心跳逻辑
+            this.timeoutTimer = window.setTimeout(() => {
+                console.warn(this.host, '心跳超时', this.beatTimerId);
+                this.reconnect();
+            }, this.timeoutInterval)
+        }, this.beatInterval)
     }
 
     /**
-     * 发送消息
-     * @param message 
+     * 停止心跳请求包发送Timer
      */
-    async send<T>(params: Message) {
-        const ws = await this.connect()
-        return new Promise<T>((resolve, reject) => {
-            const { requestId } = params.headers
+    private stopHeartBeat() {
+        clearTimeout(this.timeoutTimer);
+        clearTimeout(this.beatTimer);
+    }
 
-            // 消息超时回调
-            const timeoutId = window.setTimeout(() => {
-                reject('业务超时')
-                this.asyncTaskMap.delete(requestId)
-            }, this.timeout)
+    /**
+     * 断网重连方法,在重连尝试失败时会再次重试
+     */
+    private reconnect() {
+        this.stopHeartBeat();
+        if (this.connState !== 'Connecting') {
+            this.reconnectCount++;
+            this.onBeforeReconnect && this.onBeforeReconnect(this.reconnectCount);
 
-            this.asyncTaskMap.set(requestId, {
-                timeoutId,
-                callback: (data) => resolve(data as T),
-            })
+            // 自动计算每次重试的延时,重试次数越多,延时越大
+            const delay = this.reconnectCount * 5000
+            console.log(this.host, `${delay / 1000}秒后将进行第${this.reconnectCount}次重连`);
 
-            ws.send(JSON.stringify(params))
-        })
+            this.reconnectTimer = window.setTimeout(() => {
+                this.onReady = undefined;
+                this.connect().then(() => {
+                    console.log(this.host, '重连成功,可开始进行业务操作');
+                    this.reconnectCount = 0;
+                    this.onReconnect && this.onReconnect();
+                }).catch(() => {
+                    // 重连失败会进入 ws.onclose 再次发起重连
+                    if (this.reconnectCount) {
+                        console.warn(this.host, `第${this.reconnectCount}次重连失败`);
+                    }
+                })
+            }, delay);
+        }
     }
 
     /**
-     * 响应消息
-     * @param data 
+     * 发送报文
+     * @param data 目标报文
+     * @param fncode 回复号。P为Package40对象时为回复大类号,同时必传;P为Package50对象为回复功能码,可不传。
+     * @param success 成功回调
+     * @param fail 失败回调
      */
-    private response(message: string) {
-        const { headers, data }: Message = JSON.parse(message)
-        const asyncTask = this.asyncTaskMap.get(headers.requestId)
+    send(msg: SendMessage<T>) {
+        const { data, success, fail } = msg;
+        const { payload } = data;
 
-        switch (headers.messageType) {
-            case MessageType.Heartbeat:
-                this.stopHeartbeat()
-                this.startHeartbeat()
-                break
-            case MessageType.Request:
-                asyncTask?.callback && asyncTask.callback(data)
-                break
-            case MessageType.Push:
-                this.onPush && this.onPush({
-                    requestCode: headers.requestCode,
-                    data
-                })
-                break
-        }
+        if (this.onReady) {
+            this.onReady.then(() => {
+                const mapKey = this.currentSerial;
+                payload.serialNumber = mapKey; // 设置流水号
 
-        if (asyncTask) {
-            clearTimeout(asyncTask.timeoutId)
-            this.asyncTaskMap.delete(headers.requestId)
+                // 判断是否需要异步回调
+                if (success || fail) {
+                    // 保存发送任务,为任务设置一个自动超时定时器
+                    this.asyncTaskMap.set(mapKey, {
+                        sendMessage: msg,
+                        timeoutId: window.setTimeout(() => this.asyncTimeout(mapKey), this.timeoutInterval),
+                    })
+                }
+
+                // 发送信息
+                this.ws?.send(payload.data());
+                this.currentSerial++;
+            })
+        } else {
+            fail && fail('服务未连接');
         }
     }
 
-    /** 
-     * 发送心跳检测
+    /**
+     * 异步任务超时
+     * @param key key
      */
-    private startHeartbeat() {
-        if (this.heartbeat) {
-            this.heartbeatTimer = window.setTimeout(() => {
-                this.send({
-                    headers: {
-                        requestId: v4(),
-                        requestCode: RequestCode.Heartbeat,
-                        messageType: MessageType.Heartbeat,
-                        timestamp: new Date().getTime()
-                    },
-                    data: '心跳检测'
-                })
-                // 没有收到心跳回复,则认为网络已经异常,进行断网重连
-                this.timeoutTimer = window.setTimeout(() => {
-                    console.warn(this.host, '心跳超时')
-                    this.reconnect()
-                }, this.timeout)
-            }, this.heartbeatInterval)
-        }
+    private asyncTimeout(key: number) {
+        // 获取对应异步任务对象
+        const asyncTask = this.asyncTaskMap.get(key);
+        const onFail = asyncTask?.sendMessage.fail;
+        onFail && onFail('业务超时');
+        this.asyncTaskMap.delete(key);
     }
 
-    /** 
-     * 停止心跳检测
+    /**
+     * 处理接收数据
+     * @param bytes 接收的数据
      */
-    private stopHeartbeat() {
-        clearTimeout(this.timeoutTimer)
-        clearTimeout(this.heartbeatTimer)
+    private disposeReceiveDatas(bytes: Uint8Array) {
+        const cache: number[] = [];
+        let cachePackageLength = 0;
+
+        for (let i = 0; i < bytes.length; i++) {
+            const byte = bytes[i];
+            cache.push(byte);
+
+            if (i === 0 && byte !== 0xff) {
+                console.error('接收到首字节不是0xFF');
+                return;
+            } else {
+                // 取报文长度
+                if (cache.length === 5) {
+                    switch (this.Package) {
+                        // 4.0报文
+                        case Package40: {
+                            cachePackageLength = new DataView(new Uint8Array(new Uint8Array(cache).subarray(1, 5)).buffer).getUint32(0, false);
+                            if (cachePackageLength > 65535) {
+                                console.error('接收到长度超过65535的行情包');
+                                return;
+                            }
+                            break;
+                        }
+                        // 5.0报文
+                        case Package50: {
+                            const sub = new Uint8Array(cache).subarray(1, 5);
+                            const dataView = new DataView(new Uint8Array(sub).buffer); // 注意这里要new一个新的Uint8Array,直接buffer取到的还是原数组的buffer
+                            cachePackageLength = dataView.getUint32(0, true);
+                            break;
+                        }
+                    }
+                }
+                // 判断是否已经到包尾
+                if (cache.length === cachePackageLength) {
+                    if (byte !== 0x0) {
+                        console.error('接收到尾字节不是0x0的错误数据包');
+                        return;
+                    }
+
+                    const content = new Uint8Array(cache);
+                    const result = new this.Package(content);
+
+                    if (result.packageLength === 0) {
+                        console.error('报文装箱失败');
+                        return;
+                    }
+                    this.disposePackageResult(result);
+                }
+            }
+        }
     }
 
     /**
-     * 断开重连,在重连尝试失败时会再次重试
+     * 处理完整的报文
      */
-    private reconnect() {
-        this.stopHeartbeat() // 重连之前停止心跳检测
-        if (this.readyState !== ReadyState.Connecting) {
-            this.reconnectCount++
-            this.onBeforeReconnect && this.onBeforeReconnect(this.reconnectCount)
+    private disposePackageResult(p: T) {
+        const asyncTask = this.asyncTaskMap.get(p.serialNumber);
+        const sendMessage = asyncTask?.sendMessage;
 
-            // 自动计算每次重试的延时,重试次数越多,延时越大
-            const delay = this.reconnectCount * 3000
-            console.log(this.host, `${delay / 1000}秒后将进行第${this.reconnectCount}次重连`)
+        // 4.0报文
+        if (p instanceof Package40) {
+            // 推送类报文, 0x12 - 心跳, 0x41 - 实时行情推送, 0x42 - 控制信号
+            switch (p.mainClassNumber) {
+                case 0x12: {
+                    // 接收到心跳回复
+                    console.log(this.host, '收到心跳回复', this.beatTimerId);
+                    this.stopHeartBeat();
+                    this.startHeartBeat();
+                    break;
+                }
+                case 0x41:
+                case 0x42: {
+                    // 推送类报文
+                    this.onPush && this.onPush(p);
+                    break;
+                }
+                default: {
+                    // 非推送类报文
+                    sendMessage?.success && sendMessage.success(p);
+                }
+            }
+        }
 
-            this.reconnectTimer = window.setTimeout(() => {
-                this.connect().then(() => {
-                    console.log(this.host, '重连成功,可开始进行业务操作')
-                    this.reconnectCount = 0
-                    this.onReconnect && this.onReconnect()
-                }).catch(() => {
-                    // 重连失败会进入 ws.onclose 再次发起重连
-                    if (this.reconnectCount) {
-                        console.warn(this.host, `第${this.reconnectCount}次重连失败`)
-                    }
-                })
-            }, delay)
+        // 5.0报文
+        if (p instanceof Package50) {
+            if (p.funCode === 0) {
+                // 接收到心跳回复
+                console.log(this.host, '收到心跳回复', this.beatTimerId);
+                this.stopHeartBeat();
+                this.startHeartBeat();
+            } else if (p.serialNumber === 0) {
+                // 推送类报文
+                this.onPush && this.onPush(p);
+            } else if (sendMessage) {
+                // 非推送类报文
+                const { data, success } = sendMessage;
+
+                // 可能会收到多个流水号相同的数据包,需判断是否正确的回复码
+                if (data.rspCode === p.funCode) {
+                    success && success(p);
+                } else {
+                    // 跳过当前收到的数据包,继续等待直到asyncTask超时
+                    //console.log('无效的回复码', p.funCode)
+                    return
+                }
+            }
+        }
+
+        // 移除当前发送的任务请求
+        if (asyncTask) {
+            clearTimeout(asyncTask.timeoutId);
+            this.asyncTaskMap.delete(p.serialNumber);
         }
     }
 }

+ 27 - 38
src/services/websocket/build/types.ts

@@ -1,48 +1,37 @@
-export enum ReadyState {
-    Connecting = 0,
-    Open = 1,
-    Closing = 2,
-    Closed = 3,
-}
-
-export interface Options {
-    heartbeat?: boolean; // 是否启用心跳检测
-    protocols?: string | string[];
-    manual?: boolean; // 是否手动连接
-}
-
-export enum RequestCode {
-    Heartbeat = 1000, // 心跳检测
-    QuoteSubscribe = 2033, // 行情订阅
-    OrderPayment = 3345, // 订单支付
-    PaymentNotify = 9999, // 订单支付通知
-}
-
-export enum MessageType {
-    Heartbeat, // 心跳检测
-    Request, // 请求响应
-    Push, // 消息推送
+/**
+ * 连接状态
+ */
+export enum ConnectionState {
+    Unconnected, // 未连接
+    Connecting, // 连接中
+    Connected, // 已连接
 }
 
 /**
- * WebSocket 消息包
+ * 发送数据
  */
-export interface Message {
-    headers: {
-        requestId: string;
-        requestCode: RequestCode;
-        messageType: MessageType;
-        token?: string;
-        version?: number;
-        timestamp?: number;
+export interface SendMessage<T = unknown> {
+    data: {
+        rspCode: number; // 回调码
+        payload: T // 待发送报文
     };
-    data: unknown;
+    success?: (res: T) => void; // 成功回调
+    fail?: (err: string) => void; // 失败回调
 }
 
 /**
- * 信息发送异步任务
+ * 信息发送异步任务类
  */
-export interface AsyncTask {
+export interface AsyncTask<T> {
+    /** 5.0报文直接为流水号;4.0报文为流水号+"_"+大类号(由于服务端行情推送会使用流水号自增) */
+    key?: string;
+    /** 向服务器发送的数据 */
+    sendMessage: SendMessage<T>;
+    /** 超时计时器 */
     timeoutId: number;
-    callback: (data: unknown) => void;
-}
+}
+
+/**
+ * 参考https://typescript.bootcss.com/generics.html#在泛型里使用类类型
+ */
+export type Package<T> = new (contentOrNumber: number | Uint8Array, content?: Uint8Array) => T;

+ 317 - 0
src/services/websocket/package/package40/decode.ts

@@ -0,0 +1,317 @@
+/**
+ * 将二进制数组转化为无符号整型数值的方法
+ *   array             二进制数组
+ *   islittleEndian    是否小端
+ * @returns {number}
+ */
+function byteArrayToUInt(array: Uint8Array, isLittleEndian: boolean): number {
+    const dataView = new DataView(new Uint8Array(array).buffer);
+    let value = 0;
+    switch (array.length) {
+        case 2:
+            value = dataView.getUint16(0, isLittleEndian);
+            break;
+        case 4:
+            value = dataView.getUint32(0, isLittleEndian);
+            break;
+    }
+    return value;
+}
+
+/**
+ * 解析行情订阅请求包
+ * @param rspPackage
+ */
+export function parseSubscribeRsp(content: Uint8Array): Promise<Proto.QuoteRsp[]> {
+    const result: Proto.QuoteRsp[] = [];
+    return new Promise((resolve, reject) => {
+        if (content.length === 0) {
+            // 有可能空订阅,也有可能订阅返回数据为空,但其实已经订阅了商品
+            // 暂做正常返回 空字符串 处理,如遇到问题继续优化即可
+            console.warn('行情订阅请求包返回为空');
+            resolve(result);
+        } else {
+            const count = byteArrayToUInt(content.subarray(0, 4), false);
+            if (count > 1000) {
+                // 游客模式 刷新成长问题,为了不让页面卡死,暂时处理
+                resolve(result);
+            } else {
+                switch (count) {
+                    case -1:
+                        reject('订阅失败');
+                        break;
+                    case -2:
+                        reject('Token校验失败');
+                        break;
+                    case -3:
+                        reject('无对应商品信息');
+                        break;
+                    default:
+                        // 判断返回的Count是否有问题
+                        if (!count || count > (content.length - 4) / 66) {
+                            reject('行情订阅返回数据不正确');
+                        } else {
+                            let position = 4;
+                            for (let i = 0; i < count; i++) {
+                                const dataArray = content.subarray(position, position + 66);
+                                const subState = dataArray[0];
+                                // FIXME: 目前行情接入服务不能正确返回交易所代码
+                                const exchangeCode = dataArray[2];
+                                const goodsCode = String.fromCharCode(...dataArray.subarray(2, dataArray.length)).trim();
+                                result.push({ subState, goodsCode, exchangeCode });
+                                position += 66;
+                            }
+                            resolve(result);
+                        }
+                }
+            }
+        }
+    })
+}
+
+/**
+ * 解析行情推送报文
+ * @param {Array} quotationData	行情信息
+ */
+export function parseReceivePush(quotationData: Uint8Array) {
+    const result: Proto.Quote[] = [];
+
+    // 目前发现可能会传入空数组
+    if (quotationData.length) {
+        // 分解行正则
+        const regRow = /10\s.*?11/g;
+        // 分解单行 key 正则
+        const regKey = /01\s.*?02/g;
+        // 分解单行 value 正则
+        const regValue = /02\s.*?01|02\s.*?11/g;
+
+        // 记录已经更新盘面的商品信息数组
+        // 0x10 ... 0x01 key 0x02 value 0x01 key 0x02 value ... 0x11
+        const hexString = Array.prototype.map.call(quotationData, (x) => ('00' + x.toString(16)).slice(-2)).join(' ');
+        // let hexString = 'FF 10 01 55 02 45 46 47 48 01 66 02 48 47 46 45 11 10 01 77 02 AA BB CC DD 11 00';
+
+        // 获取单行行情
+        const rows = hexString.match(regRow);
+        if (rows) {
+            for (const low of rows) {
+                // 获取 key value 表列
+                const keys = low.match(regKey);
+                const values = low.match(regValue);
+
+                if (keys && values) {
+                    const quote: Proto.Quote = {};
+
+                    for (let i = 0; i < keys.length; i++) {
+                        const key = parseInt(keys[i].substring(3, 5), 16);
+                        const tmpValue = values[i];
+                        const value = new Uint8Array(tmpValue.substring(3, tmpValue.length - 3).split(' ').map((byte) => parseInt(byte, 16)));
+
+                        setQuoteTikFieldByByte(quote, key, value);
+                    }
+
+                    if (Object.keys(quote).length) {
+                        result.push(quote);
+                        //console.log('行情推送', quote);
+                    }
+                }
+            }
+        }
+    }
+
+    return result;
+}
+
+/**
+ * 分解缓存行情字段的方法
+ * @param quote 
+ * @param key 
+ * @param value 
+ */
+function setQuoteTikFieldByByte(quote: Proto.Quote, key: number, value: Uint8Array) {
+    const strValue = String.fromCharCode(...value);
+    switch (key) {
+        case 0x56:
+            quote.exchangecode = Number(strValue);
+            break;
+        case 0x21:
+            quote.goodscode = strValue;
+            break;
+        case 0x24:
+            quote.last = Number(strValue);
+            break;
+        case 0x5b:
+            quote.holdvolume = Number(strValue);
+            break;
+        case 0x25:
+            quote.lastvolume = Number(strValue);
+            break;
+        case 0x3c:
+            quote.preholdvolume = Number(strValue);
+            break;
+        case 0x32:
+            quote.presettle = Number(strValue);
+            break;
+        case 0x33:
+            quote.settle = Number(strValue);
+            break;
+        case 0x29:
+            quote.totalturnover = Number(strValue);
+            break;
+        case 0x28:
+            quote.totalvolume = Number(strValue);
+            break;
+        case 0x35:
+            quote.limithigh = Number(strValue);
+            break;
+        case 0x36:
+            quote.limitlow = Number(strValue);
+            break;
+        case 'L'.charCodeAt(0):
+            quote.ask = Number(strValue);
+            break;
+        case 'M'.charCodeAt(0):
+            quote.ask2 = Number(strValue);
+            break;
+        case 'N'.charCodeAt(0):
+            quote.ask3 = Number(strValue);
+            break;
+        case 'O'.charCodeAt(0):
+            quote.ask4 = Number(strValue);
+            break;
+        case 'P'.charCodeAt(0):
+            quote.ask5 = Number(strValue);
+            break;
+        case 'Q'.charCodeAt(0):
+            quote.askvolume = Number(strValue);
+            break;
+        case 'R'.charCodeAt(0):
+            quote.askvolume2 = Number(strValue);
+            break;
+        case 'S'.charCodeAt(0):
+            quote.askvolume3 = Number(strValue);
+            break;
+        case 'T'.charCodeAt(0):
+            quote.askvolume4 = Number(strValue);
+            break;
+        case 'U'.charCodeAt(0):
+            quote.askvolume5 = Number(strValue);
+            break;
+        case 'B'.charCodeAt(0):
+            quote.bid = Number(strValue);
+            break;
+        case 'C'.charCodeAt(0):
+            quote.bid2 = Number(strValue);
+            break;
+        case 'D'.charCodeAt(0):
+            quote.bid3 = Number(strValue);
+            break;
+        case 'E'.charCodeAt(0):
+            quote.bid4 = Number(strValue);
+            break;
+        case 'F'.charCodeAt(0):
+            quote.bid5 = Number(strValue);
+            break;
+        case 'G'.charCodeAt(0):
+            quote.bidvolume = Number(strValue);
+            break;
+        case 'H'.charCodeAt(0):
+            quote.bidvolume2 = Number(strValue);
+            break;
+        case 'I'.charCodeAt(0):
+            quote.bidvolume3 = Number(strValue);
+            break;
+        case 'J'.charCodeAt(0):
+            quote.bidvolume4 = Number(strValue);
+            break;
+        case 'K'.charCodeAt(0):
+            quote.bidvolume5 = Number(strValue);
+            break;
+        case ','.charCodeAt(0):
+            quote.highest = Number(strValue);
+            break;
+        case '-'.charCodeAt(0):
+            quote.lowest = Number(strValue);
+            break;
+        case '@'.charCodeAt(0):
+            quote.date = strValue;
+            break;
+        case 'A'.charCodeAt(0):
+            quote.time = strValue;
+            break;
+        case '+'.charCodeAt(0):
+            quote.preclose = Number(strValue);
+            break;
+        case '.'.charCodeAt(0):
+            quote.opened = Number(strValue);
+            break;
+        case 0x5c:
+            quote.exerciseprice = Number(strValue);
+            break;
+        case 0x7a:
+            quote.inventory = Number(strValue);
+            break;
+        case 0x7c:
+            quote.exchangedate = Number(strValue);
+            break;
+        case 0x70:
+            quote.strbidorder = strValue;
+            break;
+        case 0x71:
+            quote.strbidorder2 = strValue;
+            break;
+        case 0x72:
+            quote.strbidorder3 = strValue;
+            break;
+        case 0x73:
+            quote.strbidorder4 = strValue;
+            break;
+        case 0x74:
+            quote.strbidorder5 = strValue;
+            break;
+        case 0x75:
+            quote.straskorder = strValue;
+            break;
+        case 0x76:
+            quote.straskorder2 = strValue;
+            break;
+        case 0x77:
+            quote.straskorder3 = strValue;
+            break;
+        case 0x78:
+            quote.straskorder4 = strValue;
+            break;
+        case 0x79:
+            quote.straskorder5 = strValue;
+            break;
+        case 0x7d:
+            quote.putoptionpremiums = Number(strValue);
+            break;
+        case 0x7e:
+            quote.putoptionpremiums2 = Number(strValue);
+            break;
+        case 0x80:
+            quote.putoptionpremiums3 = Number(strValue);
+            break;
+        case 0x81:
+            quote.putoptionpremiums4 = Number(strValue);
+            break;
+        case 0x82:
+            quote.putoptionpremiums5 = Number(strValue);
+            break;
+        case 0x83:
+            quote.calloptionpremiums = Number(strValue);
+            break;
+        case 0x84:
+            quote.calloptionpremiums2 = Number(strValue);
+            break;
+        case 0x85:
+            quote.calloptionpremiums3 = Number(strValue);
+            break;
+        case 0x86:
+            quote.calloptionpremiums4 = Number(strValue);
+            break;
+        case 0x87:
+            quote.calloptionpremiums5 = Number(strValue);
+            break;
+    }
+}

+ 119 - 0
src/services/websocket/package/package40/encode.ts

@@ -0,0 +1,119 @@
+import Long from 'long'
+
+/**
+ * 将某个订阅商品转为二进制
+ * @param param SubscribeInfoType
+ * @returns {Uint8Array}
+ */
+function subscribeInfoToByteArrary(param: Proto.QuoteReq): Uint8Array {
+    const { subState, exchangeCode, goodsCode } = param;
+    const dataArray = new Uint8Array(66);
+
+    dataArray[0] = subState;
+    dataArray[1] = exchangeCode ? exchangeCode : 250;
+    // 不足64byte需要补足
+    const a = stringToByteArray(goodsCode, false);
+    if (a.length <= 64) {
+        const b = new Uint8Array(64);
+        b.set(a);
+        dataArray.set(b, 2);
+    } else {
+        // 此处有疑问,超了未做处理,
+        console.warn(`goodsCode:${goodsCode} 转化为二进制后长度超过了 64位`);
+        return new Uint8Array();
+    }
+
+    return dataArray;
+}
+
+/**
+ * 将字符串转化为二进制数组的方法
+ *   value             字符串
+ *   islittleEndian    是否小端
+ * @returns {Uint8Array}
+ */
+function stringToByteArray(value: string, isLittleEndian: boolean): Uint8Array {
+    const buf = [];
+    let offset = 0;
+    for (let i = 0; i < value.length; i++) {
+        if (value.charCodeAt(i) > 255) {
+            const tmp = uIntToByteArray(value.charCodeAt(i), isLittleEndian, 2);
+            buf[i + offset] = tmp[0];
+            buf[i + offset + 1] = tmp[1];
+            offset++;
+        } else {
+            buf[i + offset] = value.charCodeAt(i);
+        }
+    }
+    return new Uint8Array(buf);
+}
+
+/**
+ * 将无符号整型数值型转化为二进制数组的方法
+ *   value             无符号整型数值
+ *   isLittleEndian    是否小端
+ *   length            长度
+ *  * @returns {Uint8Array}
+ */
+function uIntToByteArray(value: number, isLittleEndian: boolean, length: number): Uint8Array {
+    const dataView = new DataView(new Uint8Array(length).buffer);
+    switch (length) {
+        case 2:
+            dataView.setUint16(0, value, isLittleEndian);
+            break;
+        case 4:
+            dataView.setUint32(0, value, isLittleEndian);
+            break;
+        case 8:
+            dataView.setFloat64(0, value, isLittleEndian);
+            break;
+        default:
+            // 此处暂时返回空的 Uint8Array , 是否做异常处理,遇到情况再说
+            console.warn('将无符号整型数值型转化为二进制数组传入的 【length】 不匹配');
+            return new Uint8Array();
+    }
+    const array = new Uint8Array(dataView.buffer);
+    return array;
+}
+
+/**
+ * 将订阅商品列表转为二进制数组
+ * @param arr 订阅商品列表
+ * @param token
+ * @param loginId 登录id
+ */
+export function subscribeListToByteArrary(arr: Proto.QuoteReq[], token: string, loginId: Long): Uint8Array {
+    const count = arr.length;
+    const dataArray = new Uint8Array(76 + 66 * count);
+    // 处理登录账号id
+    let postion = 0;
+    dataArray.set(loginId.toBytes(false), postion);
+
+    // 处理 token
+    postion += 8;
+    // Token,不足64byte需要补足
+    const a = stringToByteArray(token, false);
+    if (a.length <= 64) {
+        const b = new Uint8Array(64);
+        b.set(a);
+        dataArray.set(b, postion);
+    } else {
+        // 此处有疑问,超了未做处理,
+        console.warn('【token】 转化为二进制后长度超过了 64位');
+        // return null;
+    }
+
+    // 处理 条数
+    postion += 64;
+    const coutTemp = uIntToByteArray(count, false, 4);
+    dataArray.set(coutTemp, postion);
+    postion += 4;
+    // 处理 订阅的商品
+    for (let i = 0; i < count; i++) {
+        const item = subscribeInfoToByteArrary(arr[i]);
+        dataArray.set(item, postion);
+        postion += 66;
+    }
+
+    return dataArray;
+}

+ 31 - 32
src/services/websocket/quote.ts

@@ -3,36 +3,38 @@ import { useLoginStore } from '@/stores'
 import service from '@/services'
 import eventBus from '@/services/bus'
 import { quoteSubscribe } from '@/services/api/quote'
-import Socket from './build'
+import { MTP2WebSocket } from './build'
+import { Package40 } from './package'
+import { parseReceivePush } from './package/package40/decode'
 
 export default new (class {
+    private loginStore = useLoginStore()
+
     /** 行情链路 */
-    private socket?: Socket;
+    private socket = new MTP2WebSocket(Package40)
 
     /** 行情订阅集合 */
     private subscribeMap = new Map<string, string[]>()
 
-    private async connect() {
-        if (!this.socket) {
-            const loginStore = useLoginStore()
-            const config = await service.onReady()
-
-            this.socket = new Socket(config.quoteUrl, {
-                heartbeat: true,
-                protocols: loginStore.token,
-            })
-
-            this.socket.onReconnect = () => {
-                // 重新订阅商品
-                this.subscribe()
-            }
+    constructor() {
+        this.socket.onReconnect = () => {
+            // 重新订阅商品
+            this.subscribe()
+        }
 
-            this.socket.onPush = ({ data }) => {
-                // 推送实时行情通知
-                eventBus.$emit('QuotePushNotify', data)
+        this.socket.onPush = (p) => {
+            const { mainClassNumber, content } = p;
+            if (mainClassNumber === 65 && content) {
+                const result = parseReceivePush(content);
+                // 通知上层 行情推送
+                eventBus.$emit('QuotePushNotify', result);
             }
         }
-        return this.socket
+    }
+
+    private async connect() {
+        const res = await service.onReady();
+        await this.socket.connect(res.quoteUrl, this.loginStore.token);
     }
 
     /**
@@ -51,17 +53,15 @@ export default new (class {
 
         if (quoteGoodses.length) {
             console.log('开始行情订阅', quoteGoodses)
-            this.connect().then((ws) => {
-                ws.connect().then(() => {
-                    quoteSubscribe({
-                        data: {
-                            quoteGoodses
-                        }
-                    }).then((res) => {
-                        console.log('行情订阅成功', res)
-                    }).catch((err) => {
-                        console.error('行情订阅失败', err)
-                    })
+            this.connect().then(() => {
+                quoteSubscribe({
+                    data: {
+                        quoteGoodses
+                    }
+                }).then((res) => {
+                    console.log('行情订阅成功', res)
+                }).catch((err) => {
+                    console.error('行情订阅失败', err)
                 })
             })
         } else {
@@ -128,6 +128,5 @@ export default new (class {
      */
     close() {
         this.socket?.close()
-        this.socket = undefined
     }
 })

+ 32 - 30
src/services/websocket/trade.ts

@@ -1,51 +1,53 @@
 import { v4 } from 'uuid'
+import { timerInterceptor } from '@/utils/timer'
+import { FunCode } from '@/constants/funcode'
+import { useLoginStore } from '@/stores'
 import service from '@/services'
 import eventBus from '@/services/bus'
-import Socket from './build'
-import { RequestCode, MessageType } from './build/types'
+import { MTP2WebSocket } from './build'
+import { Package50 } from './package'
 
 export default new (class {
+    private loginStore = useLoginStore()
+
     /** 交易链路 */
-    private socket?: Socket;
+    private socket = new MTP2WebSocket(Package50);
 
-    private async connect() {
-        if (!this.socket) {
-            const config = await service.onReady()
-            this.socket = new Socket(config.tradeUrl)
+    constructor() {
+        this.socket.onPush = (p) => {
+            const { funCode } = p;
+            const delay = 1000; // 延迟推送消息,防止短时间内重复请求
 
-            this.socket.onPush = ({ requestCode, data }) => {
-                switch (requestCode) {
-                    case RequestCode.PaymentNotify:
-                        // 推送支付通知
-                        //eventBus.$emit('PaymentNotify', data)
-                        break
-                    default: {
-                        if (requestCode !== RequestCode.Heartbeat) {
-                            console.warn('接收到未定义的通知', requestCode)
-                        }
+            switch (funCode) {
+                case FunCode.MoneyChangedNotify: {
+                    timerInterceptor.debounce(() => {
+                        // 通知上层 资金变动
+                        eventBus.$emit('MoneyChangedNotify');
+                    }, delay, funCode.toString())
+                    break;
+                }
+                default: {
+                    if (funCode) {
+                        console.warn('接收到未定义的通知', funCode);
                     }
                 }
             }
         }
-        return this.socket
+    }
+
+    private async connect() {
+        const res = await service.onReady();
+        await this.socket.connect(res.tradeUrl, this.loginStore.token);
     }
 
     /**
      * 交易请求
      */
     async send() {
-        const ws = await this.connect()
-        return ws.send({
-            headers: {
-                requestId: v4(),
-                requestCode: RequestCode.OrderPayment,
-                messageType: MessageType.Request,
-            },
-            data: {
-                orderNumber: '202304241630113',
-                amount: 109.00
-            }
-        })
+        // await this.connect().catch((err) => {
+        //     msg.fail && msg.fail(err);
+        // })
+        // return this.socket.send(msg);
     }
 
     /**