Skip to content

说明 我已把“普遍标准”固化为两份文档(architecture.mdCONTRIBUTING.md)+ 三类模板(Flow、Hook、Component)。放进仓库后,后续开发直接照着用即可。

文件清单

  • docs/architecture.md —— 架构与分层约定(SSOT/flow 为核心)
  • docs/CONTRIBUTING.md —— 代码规范、提交规范、评审清单
  • templates/flow/flow.template.ts —— 纯函数 Flow 模板(SSOT)
  • templates/hooks/useXxx.template.ts —— Hook 桥接模板(对象参数)
  • templates/components/XxxField.template.vue —— 组件模板(LazyScope+Tailwind)
  • (可选)templates/types/types.template.ts —— 类型骨架

docs/architecture.md

md
# Architecture

## 目标

以“单一事实来源(SSOT)”为中心:**所有范围/建议值/盈亏仅在 Flow 产生**,UI/Hooks 不复写业务规则。

## 分层

- **core/flow**:纯函数域(无副作用)。职责:单位换算、方向规则、范围(meta)、建议初始价、盈亏指标。
- **hooks**:响应式桥接(对象参数),把 Flow 的结果暴露为只读的 `ref/computed`,可选节流。
- **components**:渲染与交互;复杂逻辑用 LazyScope 按需挂载;表单仅消费 meta/metrics。

## 数据口径

- **配置(Config)****行情(PriceInfo)** 分离;通过 `getEntryPrice(config, priceInfo)` 在 Flow 内“一次合成”。
- **网格对齐**:所有价格走 `alignToGrid(pointSize, precision)` 收口。
- **校验来源唯一**:范围/错误来自 Flow 的 `meta/errors`,UI 只展示。

## 扩展策略

- 新模式(percent/ticks)只改 Flow & 类型;Hook/Component 基本零改动。
- 可通过 `options` 扩展策略(如 `defaultOffsetPoints``throttleMs`),不破坏调用方。

docs/CONTRIBUTING.md

md
# Contributing Guide

## 代码风格

- TypeScript 开启严格模式;禁止 `any`(必要时用类型收窄 + 类型守卫)。
- 命名语义化:表达“是什么 + 为什么”(动词前缀:get/compute/build/format)。
- 复杂分支前一行**目的注释**(为何这样做,而非做了什么)。

## 目录与命名

- `core/flow`:文件名以业务域命名(如 `sltp.flow.ts`),导出 `computeFlow``getEntryPrice``alignToGrid` 等。
- `hooks/useXxx.ts`:对象参数 `{ config, priceInfo, isStopLoss, options }`;返回只读数据。
- `components/XxxField.vue`:Tailwind 样式;复杂逻辑用 `LazyScope`;不写业务判断。

## 提交流程

1. 编码前先改/补 `architecture.md`(如策略新增)。
2. 提交信息:`feat: …` / `fix: …` / `refactor: …` / `chore: …` / `test: …`
3. PR 自检清单:
   - [ ] Flow 是否单测易写(纯函数、无副作用)
   - [ ] UI 是否未重复实现规则
   - [ ] 价格是否统一网格对齐
   - [ ] 行情有效性判断为“**双侧 finite 且非零**
   - [ ] 对象参数是否有默认值 & 类型完整

templates/flow/flow.template.ts

ts
// templates/flow/flow.template.ts
import { Decimal } from "decimal.js";

export type OrderType = "market" | "limit";
export type OrderSide = "buy" | "sell";
export type ValueMode = "price" | "points";

export interface PriceInfo {
  ask?: number;
  bid?: number;
  openPrice?: number | string | null;
  limitPrice?: number | string | null;
}
export interface BaseConfig {
  orderType: OrderType;
  side: OrderSide;
  pointSize: number;
  precision: number;
  stopLevel: number;
}
export interface FieldMeta {
  min: number;
  max: number;
  step: number;
  precision: number;
}
export type Difference = [number | null, number, number];

/** 将任意输入安全转 Decimal */
export function toDec(v: unknown): Decimal | null {
  if (v === null || v === undefined || v === "") return null;
  const n = typeof v === "string" ? Number(v) : (v as number);
  if (!Number.isFinite(n)) return null;
  return new Decimal(n);
}

/** 统一网格对齐:pointSize 网格 + precision 处理 */
export function alignToGrid(
  p: Decimal,
  pointSize: Decimal,
  precision: number
): Decimal {
  return p.div(pointSize).round().mul(pointSize).toDecimalPlaces(precision);
}

/** 依据 config+priceInfo 求入场价(entryPrice) */
export function getEntryPrice(cfg: BaseConfig, p: PriceInfo): Decimal | null {
  const live = cfg.side === "buy" ? p?.bid : p?.ask;
  const raw = cfg.orderType === "limit" ? p?.limitPrice : live;
  return toDec(raw);
}

/** 依据 side/isStopLoss 决定方向,从 entry 偏移 offsetPoints*pointSize */
export function offsetFromEntry(
  entry: Decimal,
  side: OrderSide,
  isStopLoss: boolean,
  offsetPoints: Decimal,
  pointSize: Decimal
) {
  const step = offsetPoints.mul(pointSize);
  if (side === "buy") return isStopLoss ? entry.minus(step) : entry.plus(step);
  return isStopLoss ? entry.plus(step) : entry.minus(step);
}

/** 生成核心范围 meta(单一事实来源) */
export function getFieldMeta(
  cfg: BaseConfig,
  p: PriceInfo,
  isStopLoss: boolean
): FieldMeta {
  const entry = getEntryPrice(cfg, p);
  const ps = new Decimal(cfg.pointSize);
  const precision = cfg.precision;
  const stopPts = new Decimal(cfg.stopLevel ?? 0);
  const minPts = Decimal.max(stopPts, new Decimal(1)); // 至少 1 点

  if (!entry)
    return { min: -Infinity, max: Infinity, step: ps.toNumber(), precision };

  const minDist = minPts.mul(ps);
  let min: Decimal, max: Decimal;
  if (isStopLoss) {
    if (cfg.side === "buy") {
      min = alignToGrid(entry.minus(new Decimal(1e9).mul(ps)), ps, precision);
      max = alignToGrid(entry.minus(minDist), ps, precision);
    } else {
      min = alignToGrid(entry.plus(minDist), ps, precision);
      max = alignToGrid(entry.plus(new Decimal(1e9).mul(ps)), ps, precision);
    }
  } else {
    if (cfg.side === "buy") {
      min = alignToGrid(entry.plus(minDist), ps, precision);
      max = alignToGrid(entry.plus(new Decimal(1e9).mul(ps)), ps, precision);
    } else {
      min = alignToGrid(entry.minus(new Decimal(1e9).mul(ps)), ps, precision);
      max = alignToGrid(entry.minus(minDist), ps, precision);
    }
  }
  return {
    min: min.toNumber(),
    max: max.toNumber(),
    step: ps.toNumber(),
    precision,
  };
}

/** 建议初始价:max(stopLevel, defaultOffsetPoints) → 方向偏移 → 网格对齐 → clamp */
export function suggestInitialPrice(
  cfg: BaseConfig,
  p: PriceInfo,
  isStopLoss: boolean,
  defaultOffsetPoints = 1
): Decimal | null {
  const entry = getEntryPrice(cfg, p);
  if (!entry) return null;
  const ps = new Decimal(cfg.pointSize);
  const precision = cfg.precision;
  const stopPts = new Decimal(cfg.stopLevel ?? 0);
  const off = Decimal.max(stopPts, new Decimal(defaultOffsetPoints));
  const aligned = alignToGrid(
    offsetFromEntry(entry, cfg.side, isStopLoss, off, ps),
    ps,
    precision
  );
  const { min, max } = getFieldMeta(cfg, p, isStopLoss);
  const lo = new Decimal(min);
  const hi = new Decimal(max);
  return Decimal.max(lo, Decimal.min(hi, aligned));
}

/** 盈亏指标:[点数, 金额差, 百分比] */
export function difference(
  userPrice: Decimal,
  cfg: BaseConfig,
  p: PriceInfo
): Difference {
  const ps = new Decimal(cfg.pointSize);
  const entry = getEntryPrice(cfg, p);
  const base = toDec(p?.openPrice) ?? entry;
  if (!base) return [null, 0, 0];
  const delta = userPrice.minus(base);
  const pts = delta.div(ps).toNumber();
  const amt = delta.toNumber();
  const pct = base.eq(0) ? 0 : delta.div(base).mul(100).toNumber();
  return [pts, amt, pct];
}

/** 统一计算流:①范围 → ②建议/输入价 → ③盈亏 */
export function computeFlow(params: {
  config: BaseConfig;
  priceInfo: PriceInfo;
  isStopLoss: boolean;
  valueMode: ValueMode;
  inputValue?: number | null;
  defaultOffsetPoints?: number;
}) {
  const {
    config: cfg,
    priceInfo: p,
    isStopLoss,
    valueMode,
    inputValue,
    defaultOffsetPoints = 1,
  } = params;
  const meta = getFieldMeta(cfg, p, isStopLoss);
  const entry = getEntryPrice(cfg, p);
  const ps = new Decimal(cfg.pointSize);
  const precision = cfg.precision;

  let priceD: Decimal | null = null;
  const iv = inputValue == null ? null : toDec(inputValue);
  if (iv && valueMode === "price") priceD = alignToGrid(iv, ps, precision);
  else if (iv && valueMode === "points" && entry)
    priceD = alignToGrid(entry.plus(iv.mul(ps)), ps, precision);
  else priceD = suggestInitialPrice(cfg, p, isStopLoss, defaultOffsetPoints);

  const metrics = priceD ? difference(priceD, cfg, p) : null;
  return { range: meta, suggested: priceD, metrics, entryPrice: entry };
}

templates/hooks/useXxx.template.ts

ts
// templates/hooks/useXxx.template.ts
import { computed, readonly, ref, toValue, watchEffect } from "vue";
import type { MaybeRefOrGetter } from "@/core/types";
import type { PriceInfo } from "@/core/types";
import type { BaseConfig, ValueMode } from "@/core/flow";
import { computeFlow } from "@/core/flow";

export interface UseXxxOptions {
  defaultOffsetPoints?: number;
  valueMode?: ValueMode;
  throttleMs?: number;
}

export default function useXxx(args: {
  config: MaybeRefOrGetter<BaseConfig>;
  priceInfo: MaybeRefOrGetter<PriceInfo>;
  isStopLoss: MaybeRefOrGetter<boolean>;
  options?: MaybeRefOrGetter<UseXxxOptions | undefined>;
}) {
  const meta = ref({ min: -Infinity, max: Infinity, step: 1, precision: 2 });
  const metrics = ref<[number | null, number, number] | null>(null);
  const entryPrice = ref<number | null>(null);
  const suggested = ref<number | null>(null);

  const run = () => {
    const cfg = toValue(args.config);
    const pinfo = toValue(args.priceInfo);
    const isSL = toValue(args.isStopLoss);
    const opts = toValue(args.options);
    const flow = computeFlow({
      config: cfg,
      priceInfo: pinfo,
      isStopLoss: isSL,
      valueMode: opts?.valueMode ?? "price",
      inputValue: undefined,
      defaultOffsetPoints: opts?.defaultOffsetPoints ?? 1,
    });
    meta.value = flow.range;
    metrics.value = flow.metrics;
    entryPrice.value = flow.entryPrice ? flow.entryPrice.toNumber() : null;
    suggested.value = flow.suggested ? flow.suggested.toNumber() : null;
  };

  let timer: any;
  watchEffect(() => {
    clearTimeout(timer);
    const ms = toValue(args.options)?.throttleMs ?? 0;
    timer = setTimeout(run, ms);
  });

  return {
    meta: readonly(meta),
    metrics: readonly(metrics),
    entryPrice: readonly(entryPrice),
    suggested: readonly(suggested),
  };
}

templates/components/XxxField.template.vue

vue
<!-- templates/components/XxxField.template.vue -->
<template>
  <div class="flex flex-col gap-1">
    <!-- Header -->
    <div
      class="flex items-center justify-between text-ink-700 ink-14 font-medium"
    >
      <span class="flex items-center gap-1.5">
        <a-switch :disabled="!hasMarket" v-model:checked="enabled" />
        {{ label }}
      </span>
      <div class="flex items-center gap-1">
        <span>{{ rangeTip }}</span>
        <a-tooltip placement="top" overlayClassName="button-tooltips">
          <template #title
            ><div>{{ desc }}</div></template
          >
          <span
            class="w-4 h-4 cursor-pointer flex items-center justify-center ink-12 rounded-full bg-rim-200 text-ink-700"
            >?</span
          >
        </a-tooltip>
      </div>
    </div>

    <!-- Body: 惰性挂载 -->
    <LazyScope :when="needRender" v-model:ctx="scopeCtx" :factory="createCtx">
      <template #default="scopeCtx">
        <div class="flex items-center gap-2">
          <NumericInput
            :key="`${scopeCtx.valueMode}`"
            ref="numericRef"
            v-model="scopeCtx.inputValue"
            :default-value="scopeCtx.presetValue"
            :min="scopeCtx.meta.min"
            :max="scopeCtx.meta.max"
            :step="scopeCtx.meta.step"
            :precision="scopeCtx.meta.precision"
            :pad-fraction-zeros="true"
            :show-error-message="false"
            :placeholder="placeholder"
            adjust-trigger="blur"
            :validate-fn="scopeCtx.validateFn"
            class="flex-1"
          />
          <a-select v-model:value="scopeCtx.valueMode" class="w-20">
            <a-select-option v-for="m in modes" :key="m" :value="m">{{
              m
            }}</a-select-option>
          </a-select>
        </div>
        <div
          class="h-4 flex items-center text-xs leading-4"
          :class="scopeCtx.errorMsgs.length ? 'text-red-500' : 'text-gray-500'"
        >
          {{ scopeCtx.displayMessage }}
        </div>
      </template>
    </LazyScope>

    <div
      v-if="!hasMarket"
      class="h-4 flex items-center text-xs leading-4 text-gray-500"
    >
      无有效报价,功能已禁用
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, toRef, computed } from "vue";
import { NumericInput } from "@/components";
import LazyScope from "@/components/LazyScope";
import useXxxField from "@/hooks/useXxxField";
import type { PriceInfo } from "@/core/flow";
import type { BaseConfig, ValueMode } from "@/core/flow";

const props = withDefaults(
  defineProps<{
    modelValue?: number;
    config: BaseConfig;
    priceInfo: PriceInfo;
    isStopLoss: boolean;
    currency: string;
    label: string;
    desc?: string;
    modes?: ValueMode[];
    defaultOffsetPoints?: number;
    placeholder?: string;
  }>(),
  {
    modes: ["price", "points"],
    defaultOffsetPoints: 100,
    placeholder: "未设置",
  }
);

const enabled = defineModel<boolean>("enabled", { default: true });
const numericRef = ref();
const scopeCtx = ref<any>(null);

const hasMarket = computed(() => {
  const a = props.priceInfo?.ask;
  const b = props.priceInfo?.bid;
  return Number.isFinite(a) && a !== 0 && Number.isFinite(b) && b !== 0;
});
const needRender = computed(() => enabled.value && hasMarket.value);

const rangeTip = computed(() => {
  if (!scopeCtx.value) return "";
  const { min, max, precision } = scopeCtx.value.coreMeta?.value ?? {};
  if (!Number.isFinite(min) || !Number.isFinite(max)) return "";
  const fmt = (n: number) => Number(n).toFixed(precision ?? 0);
  return `${props.currency} ${fmt(min)} ~ ${props.currency} ${fmt(max)}`;
});

const createCtx = () =>
  useXxxField({
    modelValue: toRef(props, "modelValue"),
    // 组件桥的 useXxxField 你可复用现有 useSltpField 写法
    updateModel: () => {},
    config: toRef(props, "config"),
    priceInfo: toRef(props, "priceInfo"),
    isStopLoss: () => props.isStopLoss,
    currency: () => props.currency,
    defaultOffsetPoints: toRef(props, "defaultOffsetPoints"),
  });
</script>

<style scoped>
:deep(.ant-select-selection-item) {
  display: flex;
  align-items: center;
}
</style>

templates/types/types.template.ts

ts
// templates/types/types.template.ts
import type { Ref, ComputedRef } from "vue";

export type OrderType = "market" | "limit";
export type OrderSide = "buy" | "sell";
export type ValueMode = "price" | "points";
export const ValueModeOptions: ValueMode[] = ["price", "points"];

export interface PriceInfo {
  ask?: number;
  bid?: number;
  openPrice?: number | string | null;
  limitPrice?: number | string | null;
}

export interface BaseConfig {
  orderType: OrderType;
  side: OrderSide;
  pointSize: number;
  precision: number;
  stopLevel: number;
}

export interface FieldMeta {
  min: number;
  max: number;
  step: number;
  precision: number;
}
export type Difference = [number | null, number, number];

export type MaybeRefOrGetter<T> = T | Ref<T> | ComputedRef<T> | (() => T);

使用方式

1)落库路径

/docs/architecture.md
/docs/CONTRIBUTING.md
/templates/flow/flow.template.ts
/templates/hooks/useXxx.template.ts
/templates/components/XxxField.template.vue
/templates/types/types.template.ts

2)新功能开发步骤(通用)

  1. 复制模板

    • 选择 flow.template.ts → 重命名为 src/core/yourFeature.flow.ts,补全业务规则。
    • useXxx.template.tssrc/hooks/useYourFeature.ts,按需暴露只读数据。
    • XxxField.template.vuesrc/components/YourFeatureField.vue,改 props/插槽即可。
  2. 类型对齐

    • 若有新模式或字段,先改 types(或在 Flow 文件内本地声明)。
  3. 实现要点

    • 算法只写在 Flow;Hook 不复写规则;Component 仅渲染/交互。
    • 价格相关统一走 alignToGrid;行情有效性判断用“双侧 finite 且非零”。
  4. 联调

    • 在页面中以对象参数方式引入 Hook:

      ts
      const core = useYourFeature({
        config: computed(() => cfg),
        priceInfo: computed(() => pinfo),
        isStopLoss: computed(() => isSL),
        options: computed(() => ({ defaultOffsetPoints: 100, throttleMs: 60 })),
      });
    • 组件使用 LazyScope 包裹,when 条件为“启用 && hasMarket”。

3)评审清单(快速自检)

  • [ ] Flow 能否单测、是否无副作用
  • [ ] UI 是否未重复写规则
  • [ ] 价格是否都经 alignToGrid
  • [ ] 行情判定是否“双侧 finite 且非零”
  • [ ] Hook 是否对象参数、只读输出
  • [ ] 复杂分支前是否有目的注释

需要我把这些文件直接按上述路径一次性提交到你的仓库结构里吗?你给我具体路径,我就输出“最小改动提交说明”。

本站总访问