Appearance
说明 我已把“普遍标准”固化为两份文档(
architecture.md、CONTRIBUTING.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.ts2)新功能开发步骤(通用)
复制模板
- 选择
flow.template.ts→ 重命名为src/core/yourFeature.flow.ts,补全业务规则。 - 选
useXxx.template.ts→src/hooks/useYourFeature.ts,按需暴露只读数据。 - 选
XxxField.template.vue→src/components/YourFeatureField.vue,改 props/插槽即可。
- 选择
类型对齐
- 若有新模式或字段,先改
types(或在 Flow 文件内本地声明)。
- 若有新模式或字段,先改
实现要点
- 算法只写在 Flow;Hook 不复写规则;Component 仅渲染/交互。
- 价格相关统一走
alignToGrid;行情有效性判断用“双侧 finite 且非零”。
联调
在页面中以对象参数方式引入 Hook:
tsconst 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 是否对象参数、只读输出
- [ ] 复杂分支前是否有目的注释
需要我把这些文件直接按上述路径一次性提交到你的仓库结构里吗?你给我具体路径,我就输出“最小改动提交说明”。