从零跑通一套 WorkBuddy Skill 骨架:【能跑通+代码】生成HTML报告实战

承接上篇《# 从零搭一套 WorkBuddy Skill 骨架:调接口 → 生成 HTML 报告的分层设计与流转拆解》 juejin.cn/post/768598...
上篇讲的是设计 ------四层怎么切、边界怎么定、同一个结论该放哪儿。这一篇换一种交付方式:给一份可以直接照抄跑通的完整实现。

它按上篇第 2 章的四层架构落地,覆盖 前置检查 → 路由 → 取数 → 双层校验 → 口径归一 → 模版与板块 → 照壳拼接 → 数据内嵌 → 落盘交付 → 对账每一个环节 , 且下面出现的每一段输出都是实跑贴回来的,没有一行是示意。

前置条件只有一样:Node ≥ 18。 骨架不装任何 npm 包(只用内置 fetch),数据源是一份本地文件,不需要账号、密钥、外网,也不用起任何服务 ;两条命令跑完全链:node mock/build-report.mjs 出产物、node verify.mjs 自检。

开工前把三件事说清楚:

  1. 命名一律抽象 。真实场景里的提供方域名、业务端点名、条目编号,在本案例中替换为中性占位------item 模块、itemApi 提供方、http://127.0.0.1:8787、条目 1001数据结构、职责切分、调用协议 1:1 保留 :换掉 providers.mjs 里的 baseURL,再把 item.mjs 的六条端点声明换成真实路径,骨架其余部分一行不动
  2. 数据源用一份本地文件 。真实场景里数据由能力层经 call.mjs 取回;本案例把它们固化成 mock/data.json(见下文「数据源」一节),每段仍是 { code, msg, data } 双层信封、形状与线上 1:1------所以双层校验、字段口径、对账这些环节一个都没省,也不需要账号、密钥或外网,更不用起任何服务。
  3. 代码量的取舍 。骨架本体约 1500 行util.mjs / client.mjs / call.mjs 三处较长,给出骨架 + 关键实现 ,省略的是同类重复分支,省略处均有标注);其余文件按全文给出

1. 目录结构

text 复制代码
mini-report-skill/
├── SKILL.md                        # ① 入口层:前置检查 / 路由 / 公共约定 / 扩展规则 / 边界
├── scripts/                        # ② 能力层:零 npm 依赖的「axios 平替」
│   ├── call.mjs                    #    CLI 入口:--list / --describe / 调用
│   ├── lib/
│   │   ├── client.mjs              #    请求调度:create / 拦截器链 / timeout / params / data
│   │   ├── interceptors.mjs        #    拦截器管理:use / eject / 双向遍历
│   │   └── util.mjs                #    纯函数:argv / URL / query / headers / 参数校验
│   └── apis/
│       ├── providers.mjs           #    提供方连接配置(baseURL / timeout / headers)
│       ├── item.mjs                #    业务模块端点表(6 条声明)
│       └── index.mjs               #    两级组装 → ENDPOINT_MAP
├── references/                     # ③ 呈现层:四件公共资产
│   ├── shell.html                  #    壳本体(令牌 / 壳层 / 板块类 / 主题切换)
│   ├── html-visual-template.md     #    HTML 交付铁律 + 操作清单
│   ├── echarts-guide.md            #    图表:通用接入段 / 图型 option / 边界
│   └── environment-precheck.md     #    前置检查流程(双系统)
├── modules/                        # ④ 子技能层
│   └── item-report/
│       ├── index.md                #    唯一入口:编排(调用意图 / 取数 / 选模版 / 输出 / 边界)
│       ├── apis.md                 #    接口能力(端点表 / 消费范围 / 取数口径 / 调用 / 双层校验 / 新增端点)
│       ├── templates.md            #    模版(MT 总表 / 模版定义 / 字段口径与陷阱 / 范围与边界)
│       └── sections.md             #    板块顺序表(11 板块)+ 裁剪总则 + 视觉壳引用
├── mock/                           # 本案例专用的脚手架(非骨架组成,删掉不影响骨架)
│   ├── data.json                   #   唯一数据源:五段业务数据一次写全(每段仍是 { code, msg, data })
│   ├── report-body.html            #   报告壳(板块正文 + 图脚本,数据位置留一个占位符)
│   └── build-report.mjs            #   归一 → 注入 → 落盘:data.json → tmp/<模块>-<标识>-<日期>.html
├── verify.mjs                      # 产物自检:JSON 可解析 / 图脚本语法 / 数值对账
└── tmp/
    └── item-1001-20260917.html     # 产物:单文件自包含 HTML(build-report.mjs 生成)

骨架本体是 SKILL.md + scripts/ + references/ + modules/(1527 行);mock/verify.mjs为了让这条链路可复现 而加的脚手架。生产里把 providers.mjsbaseURL 换成真实域名、走能力层取数,mock/ 整个目录删掉,渲染与对账流程一个字节都不用改。
产物本身也是命令生成的 ,不是手存的静态文件:mock/report-body.html 是壳(板块正文 + 图表脚本,数据位置留一个占位符),mock/build-report.mjs 把归一后的数据块替换进去、再按数据里的 itemId / reportDate 命名落盘。壳与数据分离,才谈得上「产物里所有数值只有一个落点」------改数据不用碰 DOM,改 DOM 不用碰数据。

2. 入口层:SKILL.md

入口层只有一个文件、六节,下面给出全文:

markdown 复制代码
---
name: mini-report
description: 调用远程接口并按模版生成静态 HTML 报告的路由入口。负责前置检查、按意图路由到本地子技能(modules/*/index.md)、以及跨子技能复用的公共约定;不承载子技能执行细节。
version: 1.0.0
---

# mini-report

本 SKILL 是「调接口 → 生成静态 HTML 报告」的本地入口:负责前置检查、按意图路由到本地子技能、以及跨子技能复用的公共约定;**不承载子技能执行细节**。

## 1. 前置检查(每次执行前)

- 流程(Node 是否安装 → 版本 ≥ 18 → 冒烟;含 macOS / Windows 双系统指引):`references/environment-precheck.md`

## 2. 路由决策表

| 用户意图 | 进入子技能 |
|---|---|
| 给定条目 ID,要「条目详情报告 / 一页看全 / 生成 HTML」 | [条目详情报告](modules/item-report/index.md) |

上表只用于路由。命中后**必须先完整阅读目标子技能文档全文**,再按其要求执行。

## 3. 公共约定(跨子技能复用,写死)

### 3.1 调用协议

```bash
node scripts/call.mjs --list                                 # 列出全部端点
node scripts/call.mjs --describe <module>.<endpoint>         # 查看端点参数表
node scripts/call.mjs <module>.<endpoint> [--param k=v ...]  # 调用
```

- stdout 恒为单段 JSON `{ ok, message, ...payload }`,诊断信息走 stderr;进程退出码恒为 0。
- 信封 `ok` 为 `true` 表示成功、`false` 表示失败(失败原因见 `message`)。

### 3.2 双层信封判定(必做)

一次调用有**两层**成功标识,**必须都通过**才视为取数成功:

| 层 | 位置 | 成功判据 | 失败处理 |
|---|---|---|---|
| ① CLI 层 | stdout 信封 `ok` | `=== true` | `false` 时读 `message` 区分:网络 / 超时 = 接口不可用、参数写错 = 改参数,**不要盲目重试** |
| ② 业务层 | `data.code` | `=== "000000"` | 取 `data.msg` / `data.realMsg` 说明原因,终止生成 |

> `data` 是 HTTP 响应体;业务层之下再嵌一层 `data.data` 才是真实业务字段。
> 字段路径:`<stdout>.data.code`(业务码)→ `<stdout>.data.data.*`(业务字段)。

### 3.3 视觉壳(公共资产)

完整 HTML 报告页统一使用的壳层与规范:

- 规范与铁律:`references/html-visual-template.md`
- UI 壳(样式 / class / 主题切换):`references/shell.html`
- 图表(ECharts 图内配置 / 主题联动 / 尺寸自适应):`references/echarts-guide.md`

权责:**视觉壳以壳文件为准、图表代码以 echarts-guide 为准、内容与板块以子技能文档为准**。产出 HTML 前必须先完整阅读上述文件;**报告含图表时,echarts-guide 为必读**。

### 3.4 产物落盘

```
tmp/<模块>-<标识>-<日期>.html
```

示例:`tmp/item-1001-20260917.html`。

## 4. 如何扩展

新增一项能力 = **两步**,不改动既有文件:

1. 在入口层的路由决策表里**加一行**(意图 → 目标文档)。
2. 新增子技能,按「输入层 / 处理层 / 输出层 / 边界」四节撰写:
   - **单文件形态**:`modules/<名>.md`。
   - **文件夹形态**:`modules/<名>/index.md`(`<名>` 仅用于**目录名**;**入口文件名固定为 `index.md`**),可携带同目录子文件;入口只做编排,细节下沉到子文件。
   - 子文件按**功能命名**:一律用「内容本体词」,**禁用** `-catalog` / `-order` / `-dictionary` / `-spec` 等容器词后缀。如 `apis.md`(接口能力)、`templates.md`(模版与字段口径)、`sections.md`(板块顺序表)。
   - ⚠️ 子文件**不得命名为 `SKILL.md`**,否则可能被识别为独立顶层 skill。

## 5. 现有能力清单

| 子技能 | 能力 | 依赖端点 | 产物 |
|---|---|---|---|
| [条目详情报告](modules/item-report/index.md) | 单条目详情报告 | `item.getDetail`、`item.getTrend`、`item.getHistory`、`item.getMetrics`、`item.queryOwner`(依赖链:`getDetail` → `ownerIds` → `queryOwner`;`getDetail` → `latestDate` → `getTrend.startDate`) | 单文件 HTML |

## 6. 边界与禁止事项

- ❌ 不得把任务转发给本骨架之外的远程能力或远程任务资源;一切在本地完成。
- ❌ 未读目标子技能文档 + `references/shell.html` 全文,不得直接产出 HTML。
- ❌ 报告中所有数值必须 **100% 来自本次调用的接口返回**;禁止臆造、推测、照抄壳文件里的占位数字。
- ❌ 不得为了适配单个子技能而修改 `scripts/`(代理层与 CLI)。

3. 能力层 ①:CLI 入口 scripts/call.mjs

CLI 只做四件事:解析 → 校验 → 请求 → 输出信封 。它自己不发请求(交给 lib/client.mjs),也不做业务判断(交给使用方)。

后半段的骨架(main()):开头是 --list / --describe 两个只读分支,中间是「参数合并 → 校验 → 路径替换 → 组装 config → 发请求」,结尾是错误收敛

js 复制代码
#!/usr/bin/env node
import { create } from './lib/client.mjs';
import { buildURL, ParamError, parseArgs, parsePairs, UsageError, validateParams } from './lib/util.mjs';
import { listEndpoints, resolveEndpoint } from './apis/index.mjs';

// 带请求体的方法:其参数走 body,其余方法走 query
const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']);

/** 统一输出:写 stdout 后终止进程(语义全在信封里,退出码不承载语义) */
function out(ok, message, payload = {}) {
    process.stdout.write(JSON.stringify({ ok, message, ...payload }, null, 2) + '\n');
    process.exit(0);
}

async function main() {
    const { args, positional } = parseArgs(process.argv.slice(2));

    // ---- --list:列出全部端点 ----
    if (args.list) {
        const items = listEndpoints();
        const lines = items.map((item) => `  ${item.id}  [${item.method} ${item.path}]  ${item.summary}`);
        out(true, `共 ${items.length} 个端点:\n${lines.join('\n')}`, { endpoints: items });
    }

    // ---- --describe <id>:展示单个端点的参数表 ----
    if (args.describe !== undefined) {
        if (args.describe === true) out(false, `--describe 需要端点标识\n\n${USAGE}`);
        const target = resolveEndpoint(args.describe);
        if (!target) out(false, `未知端点: ${args.describe}(可执行 --list 查看全部端点)`);
        out(true, describeText(target), { endpoint: describeSpec(target) });
    }

    // ---- 具体调用 ----
    if (!positional.length) out(false, `缺少端点参数\n\n${USAGE}`);
    if (positional.length > 1) out(false, `只接受一个端点参数,收到: ${positional.join(' ')}\n\n${USAGE}`);

    const id = positional[0];
    const found = resolveEndpoint(id);
    if (!found) out(false, `未知端点: ${id}(可执行 --list 查看全部端点)`);

    const { module: moduleName, endpoint, def, defaults } = found;

    // 参数来源:--param 可重复,--json 覆盖同名项
    const params = { ...parsePairs(args.param || [], '--param') };
    if (args.json !== undefined) {
        if (args.json === true) out(false, '--json 需要一段 JSON 字符串');
        let parsed;
        try {
            parsed = JSON.parse(args.json);
        } catch (error) {
            out(false, `--json 不是合法 JSON: ${error.message}`);
        }
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
            out(false, '--json 必须是一个 JSON 对象');
        }
        Object.assign(params, parsed);
    }

    // 按声明位置校验并转换类型,一次给出全部错误
    const pathResult = validateParams(def.pathParams, params, 'pathParams');
    const queryResult = validateParams(def.query, params, 'query');
    const bodyResult = validateParams(def.body, params, 'body');
    const errors = [...pathResult.errors, ...queryResult.errors, ...bodyResult.errors];
    if (errors.length) out(false, `参数错误:\n  - ${errors.join('\n  - ')}`);

    // 未声明的参数只警告不阻断
    const warnings = [];
    const declared = new Set([...pathResult.declared, ...queryResult.declared, ...bodyResult.declared]);
    const unknown = Object.keys(params).filter((name) => !declared.has(name));
    if (unknown.length) {
        warnings.push(`以下参数未在端点声明中,已忽略: ${unknown.join(', ')}`);
    }

    // 路径占位替换
    let path = def.path;
    for (const [name, value] of Object.entries(pathResult.values)) {
        path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
    }
    if (/\{[^}]+\}/.test(path)) {
        out(false, `路径占位符未全部替换: ${path}(请检查端点的 pathParams 声明)`);
    }

    const method = String(def.method || 'GET').toUpperCase();
    const baseURL = args['base-url'] || def.baseURL || defaults.baseURL;
    const timeout = resolveTimeout(args.timeout, def, defaults);
    const config = {
        url: path,
        method,
        baseURL,
        timeout,
        headers: { ...(defaults.headers || {}), ...(def.headers || {}) },
        params: Object.keys(queryResult.values).length ? queryResult.values : undefined,
        data: BODY_METHODS.has(method) && Object.keys(bodyResult.values).length
            ? bodyResult.values
            : undefined,
    };

    const client = create({ baseURL: config.baseURL, headers: config.headers, timeout: config.timeout });

    const url = buildURL(config.baseURL, config.url, config.params);
    const startsAt = Date.now();
    let response;
    try {
        response = await client.request(config);
    } catch (error) {
        // 只剩两种可能:连接失败(TypeError)/ 超时(AbortError)
        const durationMs = Date.now() - startsAt;
        process.stderr.write(`[mini-report] 失败 ${method} ${url} (${durationMs}ms)\n`);
        const reason = error.name === 'AbortError' ? `超时(> ${timeout}ms)` : error.message;
        out(false, `请求未完成: ${reason}`, {
            request: { module: moduleName, endpoint, method, url, durationMs },
            error: { name: error.name, message: error.message },
        });
    }

    const durationMs = Date.now() - startsAt;
    if (args.debug) {
        process.stderr.write(`[mini-report] -> ${method} ${url} ${response.status} ${durationMs}ms\n`);
    }
    out(true, `${method} ${url} 成功 (${response.status}, ${durationMs}ms)`, {
        request: { module: moduleName, endpoint, method, url, status: response.status, durationMs },
        data: response.data,
        warnings,
    });
}

main().catch((error) => {
    // 可预期的输入问题:直接把 message 作为失败原因输出
    if (error instanceof ParamError || error instanceof UsageError) out(false, error.message);
    // 其余为内部崩溃:堆栈走 stderr,stdout 仍给单段 JSON
    const stack = error && error.stack ? error.stack : String(error);
    process.stderr.write(`[mini-report] 内部错误: ${stack}\n`);
    out(false, `内部错误: ${error && error.message ? error.message : String(error)}`);
});

describeSpec / describeText / resolveTimeout / USAGE 是纯展示与解析辅助,此处省略,全文见文件本身。)

这四段各自对应上篇第 4 章的一条设计:

代码位置 对应设计 为什么必须这样
function out(...) 单段信封协议 语义全部进 JSON,退出码恒为 0;调用方不必区分「两种失败」
errors 三处合并后一次性 out 「一次收全」 用户改一次就能跑,而不是修一个报一个
unknown.length → warnings.push 「未声明只警告」 拼错参数名要能看见,但不能因此阻断(上游可能新增可选参数)
catch 里只判 AbortError 「错误收敛」 请求阶段的异常只可能两种 :连接失败 / 超时;其余都是内部 bug,走 main().catch

要点--describe 与调用共用同一份端点声明resolveEndpoint)。所以「文档里写的参数」和「实际校验的参数」永远不可能不一致------这是「能力可发现」落到实处的方式。

4. 能力层 ②:代理层 scripts/lib/

三个文件职责严格分开:util.mjs 只放纯函数 (不发请求、不读写文件),interceptors.mjs 只管拦截器生命周期client.mjs 只管把请求发出去

4.1 interceptors.mjs ------ 拦截器管理(全文 47 行)

js 复制代码
/**
 * 拦截器管理(interceptors.mjs)· 零 npm 依赖
 *
 * 复刻 axios 的 InterceptorManager:use() 注册、eject() 注销。
 * 遍历顺序(对齐 axios 1.x,client.mjs 据此编排 Promise 链):
 *   - 请求拦截器:后注册的先执行(forEachReverse)
 *   - 响应拦截器:先注册的先执行(forEach)
 */
export class InterceptorManager {
    constructor() {
        this.handlers = [];
    }

    /**
     * 注册一个拦截器
     * @returns {{ id: number, eject: Function }}
     */
    use(onFulfilled, onRejected) {
        if (typeof onFulfilled !== 'function' && typeof onRejected !== 'function') {
            throw new TypeError('interceptor.use 至少需要一个函数');
        }
        this.handlers.push({ onFulfilled, onRejected });
        const id = this.handlers.length - 1;
        return {
            id,
            // 置 null 而非 splice,避免已发出的 id 因数组位移而失效
            eject: () => {
                this.handlers[id] = null;
            },
        };
    }

    /** 正序遍历(响应拦截器用) */
    forEach(fn) {
        for (const handler of this.handlers) {
            if (handler) fn(handler);
        }
    }

    /** 逆序遍历(请求拦截器用) */
    forEachReverse(fn) {
        for (let i = this.handlers.length - 1; i >= 0; i--) {
            const handler = this.handlers[i];
            if (handler) fn(handler);
        }
    }
}

ejectnull 而不是 splice------否则已发出去的 id 会因为数组位移而指向另一个拦截器,eject 就失效了。

4.2 client.mjs ------ 请求调度核心(关键实现)

create() 把 config 合成 Promise 链:请求拦截器(逆向)→ 真实请求 → 响应拦截器(正向)

js 复制代码
// 实例默认配置(可被 create(config) 覆盖)
const DEFAULT_CONFIG = {
    baseURL: '',
    headers: {},
    params: undefined,
    data: undefined,
    timeout: null,
    signal: undefined,
};

export function create(instanceConfig = {}) {
    const defaults = mergeConfig(DEFAULT_CONFIG, instanceConfig);

    const interceptors = {
        request: new InterceptorManager(),
        response: new InterceptorManager(),
    };

    /** 调度一次请求:请求拦截器(逆向)→ 真实请求 → 响应拦截器(正向) */
    function request(config) {
        const merged = mergeConfig(defaults, config);
        let chain = Promise.resolve(merged);

        // 请求拦截器可改写 config,后注册的先执行(对齐 axios)
        interceptors.request.forEachReverse(({ onFulfilled, onRejected }) => {
            chain = chain.then(onFulfilled, onRejected);
        });

        chain = chain.then((resolvedConfig) => dispatchRequest(resolvedConfig));

        // 响应拦截器可改写 response,先注册的先执行(对齐 axios)
        interceptors.response.forEach(({ onFulfilled, onRejected }) => {
            chain = chain.then(onFulfilled, onRejected);
        });

        return chain;
    }

    return {
        defaults,
        interceptors,
        request,
        get: (url, config) => request({ ...config, url, method: 'get' }),
        post: (url, data, config) => request({ ...config, url, method: 'post', data }),
        // 拼出最终 URL,便于排查 baseURL / params 的组合结果
        getUri: (config) => {
            const merged = mergeConfig(defaults, config);
            return buildURL(merged.baseURL, merged.url, merged.params);
        },
    };
}

真正发请求的一段------注意 try 里没有任何状态码判断

js 复制代码
/** 执行一次真实 fetch 请求 */
async function dispatchRequest(config) {
    const controller = new AbortController();
    let timer = null;

    // 超时:到期后 abort(fetch 无原生 timeout 选项)
    if (config.timeout != null && config.timeout > 0) {
        timer = setTimeout(() => controller.abort(), config.timeout);
    }

    // 外部 signal 透传取消语义
    const external = config.signal;
    if (external) {
        if (external.aborted) controller.abort();
        else external.addEventListener('abort', () => controller.abort(), { once: true });
    }

    const method = String(config.method || 'get').toUpperCase();
    const url = buildURL(config.baseURL, config.url, config.params);
    const headers = new Headers(normalizeHeaders(config.headers));
    const body = buildBody(config, headers);

    try {
        const res = await fetch(url, {
            method,
            headers,
            body,
            signal: controller.signal,
            redirect: 'follow',
        });
        // 不做状态码判定、不包装错误:非 2xx 原样返回,fetch 抛出的异常直接冒泡
        return {
            data: await parseBody(res),
            status: res.status,
            statusText: res.statusText,
            headers: normalizeHeaders(res.headers),
            config,
            request: { url, method },
        };
    } finally {
        if (timer) clearTimeout(timer);
    }
}

还有三个小函数,各自堵一个坑:

js 复制代码
/** 请求体编码:普通对象自动 JSON 序列化并补默认 content-type;其余原样透传 */
function buildBody(config, headers) {
    const data = config.data;
    if (data === undefined || data === null) return undefined;
    if (isPlainObject(data)) {
        // 仅在调用方未显式指定时补默认值,避免覆盖已有设置
        if (!headers.has('content-type')) headers.set('content-type', 'application/json');
        return JSON.stringify(data);
    }
    return data;
}

/** 响应体解析:按 JSON 解析,失败则降级回原文 */
async function parseBody(res) {
    const text = await res.text();
    if (text === '') return null;
    try {
        return JSON.parse(text);
    } catch {
        return text;
    }
}

/** 合并配置:patch 中显式给出的字段覆盖 base,headers 做浅合并 */
function mergeConfig(base, patch) {
    const merged = { ...(base || {}) };
    for (const [key, value] of Object.entries(patch || {})) {
        if (value !== undefined) merged[key] = value;
    }
    merged.headers = {
        ...normalizeHeaders(base && base.headers),
        ...normalizeHeaders(patch && patch.headers),
    };
    return merged;
}

三个坑值得单独点出:

  • buildBodyhassetcontent-type 只在调用方没给时才补,否则会把业务显式指定的头覆盖掉;
  • parseBody 降级回原文 :上游偶尔返回 text/html 错误页(比如网关拦截),此时不能因为 JSON.parse 失败就把原始信息丢了;
  • mergeConfig 跳过 undefinedundefined 表示「本次不覆盖」,null 表示「显式置空」------两者不能混为一谈。

4.3 util.mjs ------ 纯函数层(关键实现)

argv 解析:--key value 取值,后面没值(undefined 或以 -- 开头)就解析为 true ,非 -- 开头的一律进位置参数。

js 复制代码
// 可重复出现的参数名(--param k=v --param k2=v2 → 收集为数组)
const MULTI_KEYS = new Set(['param']);

export function parseArgs(argv) {
    const args = {};
    const positional = [];
    for (let i = 0; i < argv.length; i++) {
        const token = argv[i];
        if (!token.startsWith('--')) {
            positional.push(token);
            continue;
        }
        const name = token.slice(2);
        const next = argv[i + 1];
        let value;
        if (next === undefined || next.startsWith('--')) {
            value = true;
        } else {
            value = next;
            i += 1;
        }
        if (MULTI_KEYS.has(name)) {
            if (!Array.isArray(args[name])) args[name] = [];
            args[name].push(value);
        } else {
            args[name] = value;
        }
    }
    return { args, positional };
}

参数校验:一次收集全部错误,并在这里完成类型转换。

js 复制代码
export function validateParams(spec, values, location) {
    const errors = [];
    const converted = {};
    const entries = spec ? Object.entries(spec) : [];

    for (const [name, def] of entries) {
        const raw = values ? values[name] : undefined;
        if (raw === undefined || raw === null || raw === '') {
            if (def.required) {
                errors.push(`${location}.${name} 为必填${def.desc ? `(${def.desc})` : ''}`);
            }
            continue;
        }
        const value = coerceValue(raw, def.type, `${location}.${name}`, errors);
        if (value !== undefined) converted[name] = value;
    }

    return { errors, values: converted, declared: entries.map(([name]) => name) };
}

/** 单值类型转换(失败时 push 错误并返回 undefined) */
function coerceValue(raw, type, label, errors) {
    if (type === 'array') {
        // --json 传入的数组原样保留;--param a=1,2 按逗号切分
        if (Array.isArray(raw)) return raw;
        return String(raw).split(',').filter((item) => item !== '');
    }
    if (type === 'number') {
        const num = Number(raw);
        if (!Number.isFinite(num)) {
            errors.push(`${label} 应为数字,收到: ${raw}`);
            return undefined;
        }
        return num;
    }
    if (type === 'boolean') {
        if (raw === true || raw === 'true' || raw === '1') return true;
        if (raw === false || raw === 'false' || raw === '0') return false;
        errors.push(`${label} 应为布尔值(true / false),收到: ${raw}`);
        return undefined;
    }
    return String(raw);
}

URL 与 query 拼装(util.mjs 里最短但最容易出错的几行):

js 复制代码
/** 拼装最终请求 URL(去掉 baseURL 末尾斜杠,避免双斜杠) */
export function buildURL(baseURL, url, params) {
    const full = isAbsoluteUrl(url)
        ? String(url)
        : String(baseURL || '').replace(/\/+$/, '') + String(url || '');
    const query = serializeParams(params);
    if (!query) return full;
    return full + (full.includes('?') ? '&' : '?') + query;
}

/** 序列化 query:数组展开为重复 key,null / undefined 跳过 */
export function serializeParams(params) {
    if (!params || typeof params !== 'object') return '';
    const parts = [];
    for (const [key, value] of Object.entries(params)) {
        if (value === null || value === undefined) continue;
        const values = Array.isArray(value) ? value : [value];
        for (const item of values) {
            if (item === null || item === undefined) continue;
            parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`);
        }
    }
    return parts.join('&');
}

要点util.mjs没有一句 await。纯函数层能做到「输入同样、输出必同样」,才能被单独测------这是把「机制」和「副作用」分开的收益(上篇第 4 章)。

5. 能力层 ③:端点声明 scripts/apis/

端点声明是两级 的:providers.mjs 管「连哪里」,<module>.mjs 管「有哪些业务端点」,index.mjs 把两者拼起来。这样新增业务域时不必重写 baseURL,切换环境也只改一处。

5.1 providers.mjs ------ 提供方连接配置(全文 16 行)

js 复制代码
/**
 * 提供方连接配置表(providers.mjs)· 零 npm 依赖
 *
 * 只描述「连哪里、怎么连」,不含任何业务语义。
 * 模块文件通过 provider 字段引用这里的条目,因此新增模块时 baseURL 不必重写。
 *
 * 本案例的取数走一份本地文件(mock/data.json),不经过这一层;生产里换成真实
 * 提供方域名,临时切环境用 CLI 的 --base-url 覆盖,不必改本文件。
 */
export default {
    itemApi: {
        baseURL: 'http://127.0.0.1:8787',
        timeout: 15000,
        headers: { accept: 'application/json' },
    },
};

5.2 item.mjs ------ 业务模块端点表(节选)

js 复制代码
export default {
    module: 'item',
    provider: 'itemApi',
    endpoints: {
        getDetail: {
            method: 'POST',
            path: '/v2/item/detail',
            summary: '获取条目详情(主数据:身份 / 数值 / 规则 / 关联编号)',
            body: {
                itemId: { type: 'string', required: true, desc: '条目 ID,如 1001' },
                includes: {
                    type: 'array',
                    required: false,
                    desc: '附加返回项,逗号分隔:01 费率明细 / 02 加息率',
                },
            },
        },
        getTrend: {
            method: 'POST',
            path: '/v1/item/trend',
            // 返回序列恒按日期倒序(list[0] 最新)------上游不采纳排序参数,故不声明排序参数
            summary: '查询逐日数值序列(恒按日期倒序,list[0] 为最新)',
            body: {
                itemId: { type: 'string', required: true, desc: '条目 ID,如 1001' },
                startDate: { type: 'string', required: false, desc: '起始日期 YYYYMMDD,缺省返回全量序列' },
                endDate: { type: 'string', required: false, desc: '结束日期 YYYYMMDD' },
            },
        },
        // ...getHistory / getMetrics / queryOwner / queryText 同构声明,共 6 条
    },
};

两条注释比代码更重要:

  • getTrend排序参数根本没声明 ------因为上游不采纳它。声明表只写真实存在的能力,不写「希望存在的参数」;
  • 文件头部写明「报告消费 5 个 / 已声明未消费 1 个」,并指向 modules/item-report/apis.md 的「消费范围」------消费范围的口径不在代码里,代码只声明能力(上篇讲端点声明组装的那节)。

5.3 index.mjs ------ 两级组装(全文 47 行)

js 复制代码
/**
 * 端点注册表(apis/index.mjs)· 零 npm 依赖
 *
 * 两级组装,把「提供方连接配置」与「业务模块端点声明」拼起来:
 *   providers.mjs ------ 提供方连接配置(baseURL / headers / timeout)
 *   <module>.mjs  ------ 业务模块端点表,通过 provider 字段引用提供方
 * 组装结果按 "<module>.<endpoint>" 建索引,供 CLI 查表。
 *
 * 新增业务模块:在 apis/ 下新建 <module>.mjs,然后 import 并加入 MODULES。
 * 新增提供方:在 providers.mjs 中加一条即可,已有模块可复用。
 */
import providers from './providers.mjs';
import item from './item.mjs';

const MODULES = [item];

// key: "<module>.<endpoint>";value: { module, endpoint, def, defaults }
const ENDPOINT_MAP = new Map();
for (const mod of MODULES) {
    const defaults = providers[mod.provider];
    if (!defaults) {
        throw new Error(`模块 ${mod.module} 引用了未声明的 provider: ${mod.provider}`);
    }
    for (const [endpoint, def] of Object.entries(mod.endpoints || {})) {
        ENDPOINT_MAP.set(`${mod.module}.${endpoint}`, {
            module: mod.module,
            endpoint,
            def,
            defaults,
        });
    }
}

/** 按 "<module>.<endpoint>" 查找端点定义;未命中返回 null */
export function resolveEndpoint(id) {
    return ENDPOINT_MAP.get(id) || null;
}

/** 列出全部端点(供 --list 展示) */
export function listEndpoints() {
    return [...ENDPOINT_MAP.values()].map(({ module, endpoint, def }) => ({
        id: `${module}.${endpoint}`,
        method: String(def.method || 'GET').toUpperCase(),
        path: def.path,
        summary: def.summary || '',
    }));
}

要点provider 引用错会在模块加载时 直接抛错,而不是等到请求时才发现。把错误提前到最不可能上线的那一刻------这是声明式配置相对硬编码的主要收益。

6. 呈现层:references/

四件公共资产,一份壳 + 一分规范 + 一套图内配置 + 一个前置检查流程 。它们与子技能的关系是「被引用」而非「被复制」:子技能文档里一个 class 名都不出现

6.1 shell.html ------ 壳本体(结构节选)

shell.html 是模版、不是产物:它只提供「报告长什么样」------令牌、壳层结构、类名、样式、主题切换,不含数据、不含渲染脚本,换一份报告它也不用改。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN" data-color-mode="light">
<head>
<title>报告标题(占位,拼接时替换)</title>
<style>
    /* ① 设计令牌:改配色只改这里 */
    :root {
        --accentPrimary: #FC6047;    /* 品牌主色 */
        --accentUp: #E5482F;         /* 上行色 */
        --accentDown: #1FA97B;       /* 下行色 */
        --textPrimary: #1D2129;
        --textMuted: #86909C;
        --bgPage: #F7F8FA;
        --bgCard: #FFFFFF;
        --borderBase: #E5E6EB;
        --shadowCard: 0 1px 3px rgba(29, 33, 41, .06);
        --radiusCard: 12px;
        --watermarkAlpha: .050;
        --series-1: #FC6047;         /* 图表系列色:令牌是唯一入口,图里不写死色值 */
        --series-2: #3D6BE5;
        --series-3: #F2A93B;
        --series-4: #1FA97B;
    }
    [data-color-mode="dark"] {       /* 深色:只整段覆盖令牌,其余样式不动 */
        --textPrimary: #F2F3F5;
        --textMuted: #9AA0A6;
        --bgPage: #17171A;
        --bgCard: #232324;
        --borderBase: #333335;
        --shadowCard: 0 1px 3px rgba(0, 0, 0, .35);
        --watermarkAlpha: .032;
    }

    /* ② 壳层样式 */
    body { margin: 0; padding: 24px 16px 40px; background: var(--bgPage); color: var(--textPrimary);
           font-family: -apple-system, 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif; }
    .watermarkLayer { position: fixed; top: 40px; right: 32px; width: 220px; height: 220px;
                      background: var(--accentPrimary); border-radius: 50%; opacity: var(--watermarkAlpha);
                      pointer-events: none; }
    .reportWrap { max-width: 720px; margin: 0 auto; position: relative; }
    .reportTopbar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
    .reportTitle { margin: 0; font-size: 20px; font-weight: 700; }
    .reportSubtitle { margin: 4px 0 0; font-size: 13px; color: var(--textMuted); }
    .themeToggle { padding: 4px 10px; font-size: 12px; border-radius: 999px; cursor: pointer;
                   border: 1px solid var(--borderBase); background: var(--bgCard); color: var(--textMuted); }

    /* ③ 可复用板块类:拼接时只允许用这些 */
    .summaryPanel, .panel { background: var(--bgCard); border: 1px solid var(--borderBase);
                            border-radius: var(--radiusCard); box-shadow: var(--shadowCard);
                            padding: 20px 24px; margin-bottom: 16px; }
    .panelTitle { margin: 0 0 14px; font-size: 15px; font-weight: 700; }
    .tagRow { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
    .tag { padding: 3px 10px; font-size: 12px; border-radius: 999px;
           background: var(--bgPage); border: 1px solid var(--borderBase); color: var(--textMuted); }
    .metricGrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
    .metricCard { padding: 12px; background: var(--bgPage); border-radius: 8px; }
    .metricLabel { font-size: 12px; color: var(--textMuted); }
    .metricValue { margin-top: 6px; font-size: 17px; font-weight: 700; }
    .up { color: var(--accentUp); }
    .down { color: var(--accentDown); }
    .dataTable { width: 100%; border-collapse: collapse; font-size: 13px; }
    .dataTable th, .dataTable td { padding: 8px 6px; text-align: right;
                                   border-bottom: 1px solid var(--borderBase); }
    .dataTable th { color: var(--textMuted); font-weight: 500; }
    .dataTable th:first-child, .dataTable td:first-child { text-align: left; }
    .fieldGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .fieldCell { padding: 10px 12px; background: var(--bgPage); border-radius: 8px; }
    .fieldLabel { font-size: 12px; color: var(--textMuted); }
    .fieldValue { margin-top: 4px; font-size: 15px; font-weight: 600; word-break: break-all; }
    .chartWrap { margin-top: 16px; }
    .chartTitle { font-size: 13px; color: var(--textMuted); margin-bottom: 8px; }
    .chartCanvas { width: 100%; height: 260px; }   /* 必须有确定高度 */
    .insightLine { margin: 0 0 10px; font-size: 13px; line-height: 1.7; }
    .legalNote { font-size: 12px; color: var(--textMuted); line-height: 1.7; }
    .pageFooter { margin-top: 8px; text-align: center; font-size: 12px; color: var(--textMuted); }
</style>
</head>
<body>
<!-- ④ 壳层结构:类名固定,拼接时只在此骨架上「长」出板块 -->
<div class="watermarkLayer"></div>
<div class="reportWrap">
    <div class="reportTopbar">
        <div>
            <h1 class="reportTitle">报告标题</h1>
            <p class="reportSubtitle">副标题</p>
        </div>
        <button class="themeToggle" type="button">深色</button>
    </div>

    <!-- 板块槽:按子技能板块顺序表逐段拼接(示例段见壳内注释,数据一律来自接口) -->
    <div class="summaryPanel">
        <div class="tagRow"><span class="tag">示例标签</span></div>
        <div class="metricGrid">
            <div class="metricCard">
                <div class="metricLabel">指标名</div>
                <div class="metricValue">0.00</div>
            </div>
        </div>
    </div>

    <div class="panel">
        <h2 class="panelTitle">板块标题</h2>
        <table class="dataTable">
            <thead><tr><th>列一</th><th>列二</th></tr></thead>
            <tbody><tr><td>键</td><td>值</td></tr></tbody>
        </table>
        <div class="chartWrap">
            <div class="chartTitle">图题</div>
            <div id="chart-1" class="chartCanvas"></div>   <!-- 一图一段独立 IIFE + 唯一挂载点 id -->
        </div>
    </div>

    <div class="panel">
        <h2 class="panelTitle">说明</h2>
        <p class="legalNote">免责声明(占位,拼接时替换)。</p>
    </div>
    <div class="pageFooter">Powered by mini-report · {{reportDate}}</div>
</div>

<!-- ⑤ 壳行为:只切一个属性,与数据无关 -->
<script>
document.querySelector('.themeToggle').addEventListener('click', function () {
    var root = document.documentElement;
    var dark = root.dataset.colorMode === 'dark';
    root.dataset.colorMode = dark ? 'light' : 'dark';
    this.textContent = dark ? '深色' : '浅色';
});
</script>
</body>
</html>

两处细节决定了后面能不能「零漂移」:

  • 令牌里带 --series-1--series-4 :图表颜色属于宿主令牌、不属于图------所以换主题时图自动跟着变(见下面图表接入那一节);
  • 板块类只有一套.summaryPanel / .panel / .dataTable / .fieldGrid / .metricGrid / .chartCanvas...),拼接时只复用、不自造

6.2 html-visual-template.md ------ 铁律 + 操作清单(摘)

markdown 复制代码
## HTML 交付铁律(强制,写死)

1. **必读 ① --- 场景技能全文**:完整阅读本次生效的子技能目录下 index.md 与它指向的
   apis.md / templates.md / sections.md。板块顺序、表格列、图表语义、字段口径与结论边界均以该文档体系为准。
2. **必读 ② --- 统一视觉壳**:完整阅读 shell.html。生成物须原样嵌入其中整段 <style>(含 .watermarkLayer
   水印、:root 设计令牌)与壳层 DOM / class,以及主题切换的 <script>;有图表再按 echarts-guide.md 追加。
3. **权责划分(写死)**:shell.html 只是 UI 壳与视觉示例,其中的标题、指标文案、示例表格/图表、占位说明
   不得作为真实报告内容输出。真实内容的板块与字段来自「场景技能 + 本次接口返回」;
   若壳内示例段与场景技能的板块表不一致,内容以场景技能为准,样式以壳为准。
4. **禁止事项**:未读「场景技能 + shell.html」就输出 HTML;凭想象编业务结构;省略场景技能要求的板块;
   自写大段 CSS、自造 class、拼「类似但不同」的 DOM;把壳里的占位数字/结论写进产物;删除水印或顶栏。

## 生成 HTML 时(操作清单)

1. 已完成上文「必读 ① + 必读 ②」,再开始拼 DOM。
2. 把 shell.html 的 <style> 完整放进输出 <head>。
3. 壳层结构与 class 与壳保持一致;各板块是否出现、顺序、内部字段以场景技能为准。
4. 脚本:有图表则引入 ECharts;图内 option、系列色接入、主题联动与尺寸自适应一律照抄 echarts-guide.md。
5. 数据内嵌:把报告用到的全部数值写成一段 <script type="application/json" id="report-data">,
   放在图表 <script> 之前;正文展示值由拼接直接写入 DOM。

6.3 echarts-guide.md ------ 通用接入段(原样照抄,只改 buildOption 与挂载点 id)

html 复制代码
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
(function () {
    var DATA = JSON.parse(document.getElementById('report-data').textContent);
    var root = document.documentElement;
    var dom = document.getElementById('chart-1');        // ← 唯一挂载点 id
    var chart = null;

    function token(name) {
        return getComputedStyle(root).getPropertyValue(name).trim();
    }

    // 系列色:读宿主令牌;缺任一即整组放弃 → 交给 ECharts 内置色板
    function readSeriesColors(count) {
        var out = [];
        for (var i = 0; i < count; i++) {
            var c = token('--series-' + (i + 1));
            if (!c) { return null; }
            out.push(c);
        }
        return out;
    }

    // 文字 / 描边:同一套令牌规矩(缺则 undefined → ECharts 内置)
    // 不读令牌的后果:ECharts 默认 legend #333、分割线 #E0E6F1 在深色卡片上不可读
    function readChartTokens() {
        return {
            text: token('--textPrimary'),
            muted: token('--textMuted'),
            line: token('--borderBase'),
            card: token('--bgCard')
        };
    }

    function buildOption() {
        /* ← 按需要的图型替换本函数 */
    }

    function render() {
        if (chart) { chart.dispose(); chart = null; }    // 换主题必须 dispose 重建
        chart = echarts.init(dom);
        chart.setOption(buildOption());
    }

    // 主题联动:外部感知 data-color-mode,零侵入壳文件
    new MutationObserver(render).observe(root, {
        attributes: true, attributeFilter: ['data-color-mode']
    });
    // 尺寸自适应:ResizeObserver + window.resize 双保险
    var resize = function () { chart && chart.resize(); };
    if (window.ResizeObserver) { new ResizeObserver(resize).observe(dom); }
    window.addEventListener('resize', resize);

    render();
})();
</script>

三条硬规则

  1. 一图一段 IIFE ------dispose() / resize() 按实例绑定,多图合写会漏重建;
  2. 数据只从 #report-data------不得在 option 里写数字字面量(数据单一落点);
  3. 序列名用业务可读名,不用接口字段名。

readSeriesColors 里的 if (!c) { return null; } 是关键:只要有一个令牌缺失就整组放弃,回退到 ECharts 内置色板------绝不允许出现「半品牌半默认」的杂色。

readChartTokens 是把同一条规矩从「系列色」推到「文字与描边」:ECharts 的默认 legend 文字是 #333、默认分割线是 #E0E6F1,都是按浅色底设计 的------前者在深色卡片上糊成一团,后者反而比底色还亮、横线抢戏。所以图内凡是画出来的颜色,要么来自令牌,要么来自整组回退,不允许存在「没读过令牌」的颜色。

7. 子技能层:modules/item-report/

这是四层里唯一「每个业务域各写一份」的层。入口 index.md 只做编排,细节下沉到三个子文件------职责按「取数 / 呈现 / 顺序」切开

7.1 index.md ------ 唯一入口(索引 / 调用意图 / 取数 / 选模版 / 输出 / 边界)

markdown 复制代码
## 0. 资源索引

| 文件 | 承载 | 何时读 |
|---|---|---|
| **本文件** | 调用意图 / 接口调用 / 选择模版输出 / 输出规范 / 边界 | 每次执行 |
| apis.md | **接口能力**:端点能力表 / 调用命令 / 双层校验 | 取数前 |
| templates.md | **模版**:模版总表(序 = 输出序 = 模版编号)/ 模版定义(含取值字段)/ **字段口径与陷阱** | 组装前 |
| sections.md | **板块顺序表**(按序组装,缺一不可)/ 裁剪总则 | 组装前 |
| references/shell.html + html-visual-template.md | 视觉壳 / 视觉铁律 / 操作清单 | 产出 HTML 前(**必读**) |
| references/echarts-guide.md | **图表**:通用接入段 / 图型 option / 常见坑 | 组装**图表板块**前(**必读**) |

## 1. 调用意图

用户给出**条目 ID**(如 `1001`)或等价条目标识,并要求「条目详情报告 / 一页看全 / 生成 HTML / 条目分析报告」。

**输入层**

| 项 | 必填 | 说明 | 缺失处理 |
|---|---|---|---|
| `itemId` | 是 | 条目 ID,纯数字字符串(如 `1001`) | 向用户追问;**不得猜测或编造** |
| 环境 | 否 | 默认 prd 生产;用 `--base-url <url>` 切换 | 默认 prd |
| `includes` | 否 | 附加返回项,逗号分隔:`01` 费率明细 / `02` 加息率 | 按需传入 |

> 用户只给条目名称但**无条目 ID** → 本 MVP **不支持名称搜索**(无对应端点),须请用户提供条目 ID。

## 2. 接口调用

本报告消费 **5 个端点**(清单见 apis.md 的「消费范围」),按下列顺序取数:

1. 调 `item.getDetail`(**必调,先跑**);
2. 从 `data.data.ownerIds` 取关联方编号、从 `data.data.latestDate` 推 `getTrend` 的 `startDate`;
3. 调 `item.getTrend` / `item.getHistory` / `item.getMetrics`(三者**相互无依赖**,顺序不限);
4. 调 `item.queryOwner`(`ownerIds` 取自步骤 2,**不得写死**)。

每条调用都须通过**双层校验**:CLI 层 `ok === true` **且** 业务层 `data.code === "000000"`,两条都过才继续------判据与失败处理见 apis.md 的「双层校验」(权威)。

> 业务字段路径:`<stdout>.data.data.*`。调用命令清单见 apis.md 的「调用」。

## 3. 选择模版输出

模版**由板块决定**------按 sections.md 的「使用模版」列逐段取用,**按序组装,缺一不可**:

1. 逐行读板块顺序表,取该行「使用模版」的模版 ID;
2. 到 templates.md 的「模版定义」取该模版的「形态 / 列定义 / 取值字段 / 组装要点 / 裁剪规则」;
3. 按该模版的「裁剪规则」处理空列 / 空块;
4. 字段的单位 / 格式 / 易错点统一按 templates.md 的「字段口径与陷阱」处理;
5. 观点段(MT-2)仅做基于返回字段的**客观归纳**,**不下结论建议**。

> 模版总表(**序 = 输出序 = 模版编号**)见 templates.md:按**模版**去重;sections.md 则按**板块**逐行。二者同源,不一致**以板块顺序表为准**。

## 4. 输出规范

- **产物**:单文件自包含 HTML(`<!DOCTYPE html>` → `</html>`),落盘路径 `tmp/item-<itemId>-<YYYYMMDD>.html`
- **视觉壳**:产出前**必须完整阅读** html-visual-template.md 与 shell.html;壳层 class 与 DOM **以壳为准**。
- **完整板块**:11 个板块按 sections.md 顺序全部输出,末尾必须有免责声明 + 页脚。
- **交付**:完成后向用户给出产物的**绝对路径**。

## 5. 边界与禁止事项

- ✅ 数值 **100% 来自本次 `data.data`**;字段缺失 / 空列 / 空块的裁剪总则见 sections.md(权威)。
- ✅ 观点仅做**基于返回字段的客观归纳**,**不下结论建议**。
- ❌ 不照抄示例段的占位文案 / 数字 / 结论。
- ❌ 不臆造字段、不做名称→ID 搜索(无端点)、不做多条目对比(MVP 范围外)。
- ❌ 不消费 `item.queryText`(原因见 apis.md 的「消费范围」)。
- ❌ 不修改壳的 CSS / class 体系,不自造整页版式。

「何时读」这一列是资源索引的真正价值 :它把「读文档」也变成了流程的一步------取数前读 apis、组装前读 templates/sections、产出前读壳与图表指南。文档再多也不会漏读,因为漏读会在流程上暴露。

入口层这份还有两个设计值得抄:「调用意图」一节自带输入层表itemId 必填、环境与 includes 选填,缺失就追问、不得编造 ),把「用户没给 ID 怎么办」也写进规范;「选择模版输出」是五步流程 (读板块表 → 取模版定义 → 按裁剪规则处理 → 按口径与陷阱统一格式 → 观点段只做客观归纳),等于把「怎么把三份子文件串起来用」也固化了------入口不写内容,但写死了读内容的顺序

7.2 apis.md ------ 接口能力(端点表 / 消费范围 / 取数口径 / 调用 / 双层校验 / 新增端点)

markdown 复制代码
# 条目详情报告 · 接口能力

> 主入口:[index.md](index.md)。本文件承载**取数能力**:本子技能可调用哪些端点、怎么调、如何判定成功。取到的字段该怎么展示,见 [templates.md](templates.md)。

## 1. 端点能力表

**新增端点 = 在下方加一行**(步骤见「新增端点」节)。

| 端点 ID | method | path | 用途 | 必填参数 |
|---|---|---|---|---|
| `item.getDetail`  | POST | `/v2/item/detail`   | 条目主数据:身份 / 数值 / 规则 / 关联编号;并提供 `ownerIds` 供 `queryOwner` 使用、`latestDate` 作为 `getTrend` 的区间上界 | `itemId` |
| `item.getTrend`   | POST | `/v1/item/trend`    | 逐日数值序列(单位数值 / 累计数值,恒按日期倒序) | `itemId` |
| `item.getHistory` | POST | `/v1/item/history`  | 区间表现(10 期)+ 年度表现子表 | `itemId` |
| `item.getMetrics` | POST | `/v1/item/metrics`  | 分区间指标(最大回撤 / 波动率 / 夏普比率) | `itemId` |
| `item.queryOwner` | POST | `/v2/item/queryOwner` | 关联方详情 | `itemId`、`ownerIds` |
| `item.queryText`  | POST | `/v1/item/queryText`  | 条目文本信息(目标 / 策略等) | `itemId` |

> 本报告**实际消费哪些端点**见「消费范围」(权威)。
>
> 各端点的**完整参数表**用 `node scripts/call.mjs --describe <module>.<endpoint>` 查看,不在此重复。

- 端点声明位置:`scripts/apis/item.mjs`;提供方(baseURL / timeout / headers)见 `scripts/apis/providers.mjs`。

### 1.1 本报告消费范围

本报告消费下列 **5 个**端点;各板块与端点的对应关系见 sections.md 的「数据来源」列。

| 端点 | 消费方式 |
|---|---|
| `item.getDetail` | **主数据**:身份 / 数值 / 规则 / 关联编号;并**提供 `ownerIds` 作为 `queryOwner` 的入参**与 `latestDate` 作为 `getTrend` 的区间上界(依赖链起点) |
| `item.getTrend` | 走势:**近一年**逐日单位数值 / 累计数值序列(`startDate` = `latestDate` 前一年) |
| `item.getHistory` | 区间表现:区间(10 期)+ 同类平均 + 同类排名 + **年度子表** |
| `item.getMetrics` | 区间指标:6 区间最大回撤 / 波动率 / 夏普比率 |
| `item.queryOwner` | 关联方信息(**依赖 `getDetail` 的 `ownerIds`**) |

其余 **1 个**端点已声明但**不消费**:

| 端点 | 不消费原因 |
|---|---|
| `item.queryText` | 返回目标 / 策略等文本,**在现有 11 个板块中无承载板块**;为其新增板块与 sections.md「本表即板块全集」冲突 |

**未消费端点不得在报告中引用其数据**;如需启用,须先更新本表、sections.md 板块表与 templates.md 模版定义。

**参数**(`item.getDetail`)

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `itemId` | string | 是 | 条目 ID,如 `1001` |
| `includes` | array | 否 | 附加返回项,逗号分隔:`01` 费率明细 / `02` 加息率 |

**参数**(`item.getTrend`)

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `itemId` | string | 是 | 条目 ID,如 `1001` |
| `startDate` | string | 否 | 起始日期 `YYYYMMDD`;本报告传**最新日期前一年**以截出近一年序列。缺省返回自最早以来的全量序列 |
| `endDate` | string | 否 | 结束日期 `YYYYMMDD`;本报告不传(默认取至最新日期) |

> **序列顺序**:本端点返回**恒按日期倒序**(`list[0]` 为最新)------上游不采纳排序参数,故**未声明排序参数**;绘制折线前须自行反转为升序。

### 1.2 取数口径(本报告传参约定)

本报告按「**最小必需参数**」调用------只用必填项与确有需要者,其余**一律不传**:

| 端点 | 不传的参数 | 原因 |
|---|---|---|
| `getDetail` | `includes` | 本报告所需的费率与申赎门槛**均在默认返回中**;`includes` 追加的字段本报告不使用 |
| `getTrend` | `endDate` / 排序参数 | 区间用 `startDate` 表达;后端不采纳排序参数(返回恒倒序,见上注),绘图前自行反转升序 |

其余端点(`getHistory` / `getMetrics`)只传必填 `itemId`。

**必须传**:`queryOwner` 除 `itemId` 外须带 `ownerIds`(取自 `getDetail` 返回)与 `parseType=14`(详情页口径)。

## 2. 调用

「调用」一节本身就是一段给执行模型照抄的 shell,原样放出来:

bash 复制代码
node scripts/call.mjs --list                            # 列出全部端点
node scripts/call.mjs --describe item.getDetail         # 查看端点参数表

# ① 主数据(必调,先跑;并从中取 ownerIds)
node scripts/call.mjs item.getDetail --param itemId=<id>

# ② 以下 3 个端点相互无依赖,可任意序调用
node scripts/call.mjs item.getTrend   --param itemId=<id> --param startDate=<最新日期前一年>
node scripts/call.mjs item.getHistory --param itemId=<id>
node scripts/call.mjs item.getMetrics --param itemId=<id>

# ③ 关联方:ownerIds 取自上一步 getDetail 的返回,不得写死
node scripts/call.mjs item.queryOwner --param itemId=<id> --param ownerIds=<ids> --param parseType=14
markdown 复制代码
**调用顺序**:`getDetail` 必须先跑------它是 `ownerIds` 与 `latestDate` 的唯一来源;其余 3 个端点相互无依赖。每条调用都须过「双层校验」。

> `getTrend` 的 `startDate` = `getDetail.latestDate` **减一年**(如 `20260916` → `20250916`),用于把序列截到近一年;不带该参数返回全量序列。

- stdout 恒为单段 JSON 信封 `{ ok, message, ...payload }`,诊断信息走 stderr(协议见 SKILL.md 的「调用协议」)。
- 切环境:追加 `--base-url <url>`(默认 prd 生产)。

## 3. 双层校验(必做)

一次调用有**两层**成功标识,**必须都通过**才视为取数成功:

| 层 | 位置 | 成功判据 | 失败处理 |
|---|---|---|---|
| ① CLI 层 | stdout 信封 `ok` | `=== true` | 读 `message` 区分:网络 / 超时 = 接口不可用、参数写错 = 改参数,**不要盲目重试** |
| ② 业务层 | `data.code` | `=== "000000"` | 打印 `data.msg` / `data.realMsg` 说明原因并终止生成 |

> 业务字段路径:`<stdout>.data.data.*`。

## 4. 新增端点(两步)

1. 在 `scripts/apis/<module>.mjs` 的 `endpoints` 中加一条声明(`method` / `path` / `summary` / `body`)。
2. 在「端点能力表」**加一行**。

> ❌ 不得为适配单个子技能而修改 `scripts/lib/`(代理层)、`scripts/call.mjs`(CLI)或 `scripts/apis/providers.mjs`(连接配置)。

注意这里出现了「能力」与「消费」的分离apis.md 里先是能力表 (上游能提供什么,6 条),再是消费范围 (本报告只吃 5 条,另 1 条明确写清为什么不消费)。

这个分离解决了一个很隐蔽的问题:如果只有能力表,任何人都可能顺手把 queryText 的文本塞进报告 ------数据是真的、接口也支持,但报告结构会因此漂移。写清「不消费」,是提前给漂移判死刑(上篇讲「单一权威归属」的那节)。

「取数口径」这一节最容易省,却最省事:它专门记「哪些参数不传 、为什么不传」------比如 getTrend 不传排序参数,因为后端根本不采纳 (返回恒倒序)。把「试出来的结论」写进文档,下一个人就不必再试一遍。末尾的「新增端点(两步)」同理,顺手把红线钉在文档里:不得为适配单个子技能去改 lib/ / call.mjs / providers.mjs

7.3 sections.md ------ 板块顺序表(11 板块,缺一不可)

它的骨架是「指针块 → 表 → 总则 → 引用 」四段,开篇先把权责划死------本文件不定义任何 class

markdown 复制代码
# 条目详情报告 · 板块顺序表

> 主入口:[index.md](index.md)。本文件只定义**报告由哪些板块、按什么顺序组装**;每个板块用哪个模版、模版内部结构与取值字段见 [templates.md](templates.md)。
>
> ⚠️ **本文件不定义任何 class**------壳层 class、DOM 结构与样式一律以 [shell.html](../../references/shell.html) 与 [HTML 报告视觉模板](../../references/html-visual-template.md) 为**唯一权威**。

## 1. 板块顺序表(按序组装,缺一不可)

| # | 板块名称 | 数据来源 | 使用模版 | 说明 |
|---|---|---|---|---|
| 1 | Topbar | --- | ---(壳) | 报告标题 + 副标题 + 深浅色切换 |
| 2 | Hero 摘要 | `item.getDetail` | MT-1 | 条目身份 + 标签行 + KPI 四宫格 |
| 3 | 核心要点 | `item.getHistory` + `item.getMetrics` | MT-2 | 表现 / 回撤 / 效率三段**客观归纳**,不下结论建议 |
| 4 | 区间表现 | `item.getHistory` | MT-3 | 10 期区间表 + 年度表现子表 |
| 5 | 走势 | `item.getTrend` | MT-4 | **近一年**逐日折线(单位数值 / 累计数值) |
| 6 | 条目 vs 基准 | `item.getDetail` | MT-4 | 11 期分组柱状图(同一模版被两个板块复用) |
| 7 | 区间指标 | `item.getMetrics` | MT-5 | 6 区间 × 最大回撤 / 波动率 / 夏普比率 |
| 8 | 交易规则 | `item.getDetail` | MT-5 | 费率与申赎门槛(与板块 7 共用 MT-5,按板块裁剪列) |
| 9 | 基础信息 | `item.getDetail` + `item.queryOwner` | MT-6 | 条目属性 / 主体 / 关联方三组 |
| 10 | 免责声明 | --- | ---(壳) | 免责条款 |
| 11 | 页脚 | --- | ---(壳) | `Powered by ... · <reportDate>` |

> **范围说明**:本表即报告板块的**全集**------**不得新增板块**(当前 11 个);本报告**消费哪些端点**见 [apis.md](apis.md) 的「消费范围」(权威);端点未提供的数据(条目文本信息等)与「不得臆造」的口径见 [templates.md](templates.md) 的「范围与边界」。
>
> 「使用模版」列取 `templates.md` 的模版 ID,其**形态**见 templates.md 的「模版总表」,**取值字段 / 裁剪规则**见「模版定义」。

## 2. 裁剪总则

- 数值 **100% 来自本次接口返回**;字段缺失时标注「数据暂未获取」。
- 某整列无数据 → **整列删除**;某整块无数据 → **整块省略**;不留空列、不填「---」。
- 板块 3(核心要点)仅做**基于返回字段的客观归纳**,**不下持有 / 买卖建议**。

## 3. 视觉壳(引用)

产出 HTML 前**必须完整阅读**:

- [HTML 报告视觉模板](../../references/html-visual-template.md)(视觉铁律 + 操作清单 + 可用类清单)
- [shell.html](../../references/shell.html)(壳本体:样式 / class / 主题切换)

壳层 class 与 DOM **以壳为准**;本文件与 [templates.md](templates.md) 只引用类名、不复制 DOM 结构,**禁止自造类名或整页版式**。

三点值得单拎出来:

  • 「唯一权威」写了三遍(头部、表下、视觉壳)------三处指向同一个东西:壳文件。规范性文档最怕「两处都写了,冲突时听谁的」,这里用重复把答案钉死;
  • 「不得新增板块」是硬约束 :板块数是产物的结构契约------下面「产物自检」那节里就有一条断言专门数它,改它等于改产物结构;
  • 视觉壳单列一节、不并入裁剪总则:它约束的不是数值而是 class 与 DOM,和「数据裁剪」不是一类事。

7.4 templates.md ------ 模版(总表 + 定义 + 口径与陷阱 + 范围边界)

模版按输出顺序编号,因此「序 = 输出序 = 模版编号」恒等:

markdown 复制代码
| 序 | 模版 ID | 模版名 | 形态 | 承载板块 |
| - | ----- | ------- | ---------- | -------------- |
| 1 | MT-1 | KPI 卡片组 | 卡片网格 | Hero 摘要 |
| 2 | MT-2 | 评估条目 | 逐行段落,无表格 | 核心要点 |
| 3 | MT-3 | 表现表 | 横向表格,一行一区间 | 区间表现 |
| 4 | MT-4 | 图表 | ECharts 容器 | 走势 / 条目 vs 基准 |
| 5 | MT-5 | 指标表 | 横向表格,一行一指标 | 区间指标 / 交易规则 |
| 6 | MT-6 | 键值网格 | 标签 - 值对网格 | 基础信息 |

> **输出约束**:按 [sections.md](sections.md) 的板块顺序**依次拼接 HTML**。
>
> 本表与 sections.md 的板块表**同源**:sections 按**板块**逐行(含 Topbar / 免责声明 / 页脚等非取数段落,共 11 行),本表按**模版**去重(6 行)。本表「序」= 该模版的**首次输出位置**;顺序一律以板块顺序表为准。
>
> 各板块的**取数端点**见 sections.md 板块表的「数据来源」列。

两个不变量藏在表下:

  • MT-5 被板块 7 / 8 复用 ------模版按模版 去重(6 行),板块表按板块 逐行(11 行),两张表同源但不相等;不一致时以板块顺序表为准(口径优先级写死);
  • 本文件不出现任何 class ------模版名就是结构语义(「指标表」= 横向表格),到壳文件里找同形态示例段照抄

「模版定义」是这份文档里最长的一节,但每个模版只交五件事------形态 / 列定义(或结构)/ 取值字段 / 组装要点 / 裁剪规则 ,且一行 DOM 都不给

markdown 复制代码
## 2. 模版定义

> 模版名即结构语义------对应何种壳层结构、用哪些类名,一律以壳文件为准,**禁止据本文件重建 DOM**;到壳文件里找**同形态示例段照抄**。

### MT-1 · KPI 卡片组

| 项 | 内容 |
| ---- | ---- |
| 形态 | 卡片网格(摘要区) |
| 列定义 | KPI 四宫格:最新数值(含净值日期)/ 日变化 / 近一年变化 / 成立以来变化 |
| 取值字段 | **条目身份**:`itemName` / `itemFullName` / `itemCode` / `categoryDesc` / `levelDesc` / `styleDesc` / `statusDesc`<br>**四宫格**:`latestValue` + `latestDate` / `dailyChange` / `latestYearChange` / `totalChange` |
| 组装要点 | 标签行 4 枚:类别 / 风险 / 风格 / 状态;正负值须区别着色(色值与类名以壳为准),**为 0 不着色** |
| 裁剪规则 | 某卡片无数据 → **删该卡片**;标签行某枚无数据 → **删该枚** |

### MT-2 · 评估条目

| 项 | 内容 |
| ---- | ---- |
| 形态 | 逐行段落,无表格 |
| 结构 | `维度名:评估文本`,维度名加粗 |
| 取值字段 | 维度固定三段:**表现**(`intervalList`)/ **回撤**(`metricList.maxDrawdown`)/ **效率**(`metricList.sharpe`,必要时带 `volatility`)------全部取自本次返回 |
| 组装要点 | 评估须**可溯源到返回字段**;**禁止持有 / 买卖建议**(如「适合稳健型用户」「建议持有一年」) |
| 裁剪规则 | 某维度无据可依 → **删该行** |

### MT-3 · 表现表

| 项 | 内容 |
| ---- | ---- |
| 形态 | 横向表格,一行一区间;**主表 + 年度表现子表**并列,同属本模版段落 |
| 列定义 | `区间 / 条目变化 / 同类平均 / 同类排名` |
| 取值字段 | **主表**(10 期)取 `getHistory.intervalList`:`label` / `value` / `avg` / `ranking`<br>**年度子表**取 `getHistory.yearList`:`year` / `value` / `avg` / `ranking`,按 `year` **降序** |
| 组装要点 | 主表覆盖 10 期:近一周 / 近一月 / 近三月 / 近六月 / 近一年 / 今年来 / 近二年 / 近三年 / 近五年 / 成立以来;子表紧随主表下方,**同一 MT-3 段落内**(不另起板块层级) |
| 裁剪规则 | `value === "--"` 的行 → **删该行**;主表为空 → **整块省略**;`yearList` 为空 → **仅省略子表**,主表保留 |

### MT-4 · 图表

| 项 | 内容 |
| ---- | ---- |
| 形态 | ECharts 容器------**走势**用折线图、**条目 vs 基准**用分组柱状图,两板块共用本模版 |
| 列定义 | **走势**:X 轴 = 日期;两支系列 = 单位数值 / 累计数值<br>**条目 vs 基准**:X 轴 = 区间;两支系列 = 条目变化 / 基准变化 |
| 取值字段 | **走势**(近一年逐日)取 `getTrend.list`:`date`(`YYYYMMDD`)/ `value` / `totalValue`<br>**条目 vs 基准**取 `getDetail.ranges` 11 期:`label` / `value` / `bench` |
| 组装要点 | 图表代码**一律照抄 `references/echarts-guide.md` 的通用接入段**------含 CDN 引入、option 骨架、系列色接入、主题联动(`dispose()` 重绘)与尺寸自适应,勿自行实现;**多图时每图一段独立 IIFE + 唯一挂载点 id** |
| 裁剪规则 | 任一侧数据缺失 → **整块省略**;序列中取到 `"--"` 的点 → 写 `null`(**不绘制、不填 0**) |

### MT-5 · 指标表

| 项 | 内容 |
| ---- | ---- |
| 形态 | 横向表格,一行一指标(区间指标分支为一行一区间);两板块**共用形态、按板块裁剪列** |
| 列定义 | 按板块裁剪:**区间指标** → `区间 / 最大回撤 / 波动率 / 夏普比率`;**交易规则** → `项目 / 数值` |
| 取值字段 | **区间指标**取 `getMetrics.metricList`(6 行,固定序 `近一月` → `成立以来`):`intervalDesc` / `maxDrawdown` / `volatility` / `sharpe`<br>**交易规则**取 `getDetail.fees`:`manageRatio` / `trusteeRatio` / `minBuy` / `minRedeem` / `confirmDays` / `arriveDays` |
| 组装要点 | 指标名不带区间前缀(指标自身无区间维度时);6 行**固定序**;「解读」须可溯源到返回字段 |
| 裁剪规则 | 某整列无数据 → **整列删除**,不留空列、不填「---」;`metricList` 为空 → 区间指标分支**整块省略**(交易规则分支不受影响) |

### MT-6 · 键值网格

| 项 | 内容 |
| ---- | ---- |
| 形态 | 标签 - 值对网格 |
| 取值字段 | **条目属性**:`itemCode` / `setupDate` / `scaleDesc` / `currencyDesc` / `latestDate`<br>**主体**:`companyName`(管理机构)/ `trusteeName`(托管机构)<br>**关联方**(取 `queryOwner.ownerList`):`name` / `company` / `experienceYears` / `tenureReturn` / `tag` |
| 组装要点 | 分三组,顺序:条目属性 → 主体 → 关联方 |
| 裁剪规则 | 某项无数据 → **删该项** |

第 2 节只管「一个模版长什么样、吃哪些字段、怎么裁剪」,跨模版共用的口径另立第 3 节------先是一张 11 期对照表,再是展示口径,最后是易错点清单:

markdown 复制代码
## 3. 字段口径与陷阱(跨模版共享)

> 本节只收**被多个模版共用、因而无法内联到单个模版**的口径;字段含义已随各模版的「取值字段」列出,此处不重复。

### 3.1 变化 ↔ 基准 11 期对照

| 项 | 内容 |
| ---- | ---- |
| 来源 | `getDetail.ranges[]`,**11 期固定序**:日涨跌 / 近一周 / 近一月 / 近三月 / 近六月 / 近一年 / 今年来 / 近二年 / 近三年 / 近五年 / 成立以来 |
| 条目变化 | 同项 `value` |
| 对应基准 | 同项 `bench`(逐期一一对应) |
| 谁在用 | MT-4(图表)用全表;MT-1(KPI 卡片组)取其中 **3 期**;MT-3(表现表)主表 `intervalList[].value` 与**同区间** `.value` 同源 |

`dailyChange` / `latestYearChange` / `totalChange` 是**同批数字的单值出口**(供 KPI 四宫格用),与 `ranges` 同区间必须同值。

### 3.2 展示口径

| 口径 | 适用字段 | 规则 |
| ---- | ---- | ---- |
| **百分数须自补 `%`** | value / bench / avg / manageRatio / trusteeRatio / maxDrawdown / volatility | 接口返回**百分数数值**:`1.69` 表示 `1.69%`,展示时自行补 `%` |
| **数值非百分数** | `latestValue` | 是金额,**禁止补 `%`**;展示保留 **4 位小数**(如 `2.0556`) |
| 小数位 | `sharpe` | 展示保留 **4 位小数** |
| 日期格式化 | `latestDate` / `setupDate` / `date` | 接口为 `YYYYMMDD`,展示前格式化为 `YYYY-MM-DD` |
| 涨跌着色 | 所有正负数值 | 正值 / 负值须区别着色;**为 0 不着色**(色值与类名以壳为准) |
| **区间标签强制** | 多区间端点的全部数值(变化 / 回撤 / 波动率 / 夏普) | 展示时必须带所属**区间标签**(如「近三年 最大回撤 −24.31」),**禁止裸数值** |
| **`--` 即缺失** | 全部字段 | 接口用 `"--"` 表示无数据,**等同缺失**------按裁剪规则删行 / 删项,不得原样展示、不得当 0 |

### 3.3 易错点(务必核对)

| 陷阱 | 说明 |
| ---- | ---- |
| **走势序列为倒序** | `getTrend.list` 按日期**倒序**返回(`list[0]` 最新、末项最旧)→ 绘制折线前必须按 `date` **升序重排**,否则时间轴反向 |
| **`value` ≠ `totalValue`** | `value` = 单位数值(分红除息日会跳空)、`totalValue` = 累计数值(含分红,平滑)------两者**不得混用**、不得并列成同一「数值」,也**不得相减**推算分红 |
| **`ownerIds` 依赖** | `queryOwner` 必填 `ownerIds`,**必须取自 `getDetail.ownerIds`**(如 `"6,9"`,不得写死) |
| **基准与同类平均不是一回事** | `ranges[].bench` = **基准**;`intervalList[].avg` = **同类平均**------两者不得并列成同一列、不得互相替代 |
| **回撤区间不得跨用** | `metricList` 各行为其**自身区间**;不得把某行数值当作全周期数值,也不得拿 `totalChange` 充当区间收益 |
| **`minRedeem` 是份额** | 最低赎回**份额(份)**,不是金额 |
| **`scaleDesc` 已含单位** | 接口已带单位(如「12.4 亿」),**不要**再拼接单位 |
| **可对账** | 四条对账关系:① `latestValue` ↔ `trend.list[0].value`;② `latestYearChange` ↔ `intervalList`「近一年」`value`;③ `ranges` 11 期 ↔ `intervalList` 10 期(**同区间必须同值**);④ `ownerIds` ↔ `ownerList[].ownerId`。偏差过大说明取错期 / 错源 |

## 4. 范围与边界

> 本节是「**本端点没有哪些数据、因而不得产出什么**」的唯一权威口径。

- 本报告**消费的 5 个端点均不提供**条目文本信息(目标 / 策略等)→ **不设**对应板块与模版,**不得为凑模版臆造数据**。
- 走势**限近一年**(`getTrend` 传 `startDate`),**不提供**切换区间的交互控件(静态产物)。
- 区间指标**无同类排名**(`metricList` 无排名字段)→ MT-5 区间指标主表落 **4 列**(`区间 / 最大回撤 / 波动率 / 夏普比率`),排名不得并入主表做列。
- `item.queryText` **不消费**(原因以 apis.md 的「消费范围」为准)→ **不得**为其新增板块、模版或字段。

-- 即缺失」这条是真实踩过的坑 :上游用字符串 "--" 表示无数据。若按「有值就展示」处理,页面上会出现一格 --;若按 Number("--") 转,会得到 NaN,图表直接画崩。正确做法是在数据搬运阶段就把 "--" 归一为 null ,正文按裁剪总则删行 、图表按 null 不绘制------两条路径同一口径(上面「一条命令」与下文「内嵌数据 + 图表」两节有实测)。

「可对账」四条则是这份文档的"自检清单":产物出来后拿这四个关系一验,就能发现「取错期 / 取错源」这类最难靠肉眼发现的错。

8. 跑起来(以下全部为实测输出)

8.0 数据源:一份文件

真实场景里数据由能力层经 call.mjs 取回;本案例把它们固化成一份 本地文件 mock/data.json(1561 行 / 34 KB)------形状与接口返回 1:1:每段仍是 { code, msg, data } 双层信封,真实业务字段在 data 里。 五个键就对应上文「取数顺序」那节的五段取数,全文如下:

json 复制代码
{
  "getDetail": {
    "code": "000000",
    "msg": "成功",
    "data": {
      "itemId": "1001",
      "itemName": "示例条目甲",
      "itemFullName": "示例条目甲成长型开放式示例产品",
      "itemCode": "1001",
      "categoryDesc": "A 类",
      "levelDesc": "中风险",
      "styleDesc": "均衡型",
      "statusDesc": "正常开放",
      "latestValue": "2.0556",
      "latestDate": "20260916",
      "dailyChange": 0.42,
      "latestYearChange": 18.26,
      "totalChange": 105.56,
      "ranges": [
        {
          "label": "日涨跌",
          "value": 0.42,
          "bench": 0.11
        },
        {
          "label": "近一周",
          "value": 1.03,
          "bench": 0.36
        },
        {
          "label": "近一月",
          "value": 2.14,
          "bench": 0.88
        },
        {
          "label": "近三月",
          "value": 5.37,
          "bench": 1.92
        },
        {
          "label": "近六月",
          "value": 8.61,
          "bench": 2.74
        },
        {
          "label": "近一年",
          "value": 18.26,
          "bench": 5.98
        },
        {
          "label": "近二年",
          "value": 24.05,
          "bench": 7.11
        },
        {
          "label": "近三年",
          "value": 31.42,
          "bench": 9.36
        },
        {
          "label": "近五年",
          "value": "--",
          "bench": "--"
        },
        {
          "label": "今年以来",
          "value": 11.47,
          "bench": 3.25
        },
        {
          "label": "成立以来",
          "value": 105.56,
          "bench": 28.63
        }
      ],
      "fees": {
        "manageRatio": 1.2,
        "trusteeRatio": 0.2,
        "minBuy": 100,
        "minRedeem": 1,
        "confirmDays": 1,
        "arriveDays": 1
      },
      "ownerIds": "6,9",
      "scaleDesc": "12.4 亿",
      "setupDate": "20180601",
      "currencyDesc": "人民币",
      "companyName": "示例资产管理有限公司",
      "trusteeName": "示例银行股份有限公司"
    }
  },
  "getTrend": {
    "code": "000000",
    "msg": "成功",
    "data": {
      "list": [
        {
          "date": "20260916",
          "value": "2.0556",
          "totalValue": "2.6928"
        },
        {
          "date": "20260915",
          "value": "2.0572",
          "totalValue": "2.6950"
        },
        {
          "date": "20260914",
          "value": "2.0589",
          "totalValue": "2.6972"
        },
        {
          "date": "20260911",
          "value": "2.0606",
          "totalValue": "2.6994"
        },
        {
          "date": "20260910",
          "value": "2.0624",
          "totalValue": "2.7017"
        },
        {
          "date": "20260909",
          "value": "2.0642",
          "totalValue": "2.7040"
        },
        {
          "date": "20260908",
          "value": "2.0659",
          "totalValue": "2.7064"
        },
        {
          "date": "20260907",
          "value": "2.0677",
          "totalValue": "2.7087"
        },
        {
          "date": "20260904",
          "value": "2.0695",
          "totalValue": "2.7111"
        },
        {
          "date": "20260903",
          "value": "2.0713",
          "totalValue": "2.7134"
        },
        {
          "date": "20260902",
          "value": "2.0731",
          "totalValue": "2.7158"
        },
        {
          "date": "20260901",
          "value": "2.0749",
          "totalValue": "2.7181"
        },
        {
          "date": "20260831",
          "value": "2.0766",
          "totalValue": "2.7204"
        },
        {
          "date": "20260828",
          "value": "2.0784",
          "totalValue": "2.7227"
        },
        {
          "date": "20260827",
          "value": "2.0801",
          "totalValue": "2.7249"
        },
        {
          "date": "20260826",
          "value": "2.0818",
          "totalValue": "2.7271"
        },
        {
          "date": "20260825",
          "value": "2.0834",
          "totalValue": "2.7292"
        },
        {
          "date": "20260824",
          "value": "2.0850",
          "totalValue": "2.7313"
        },
        {
          "date": "20260821",
          "value": "2.0865",
          "totalValue": "2.7333"
        },
        {
          "date": "20260820",
          "value": "2.0880",
          "totalValue": "2.7353"
        },
        {
          "date": "20260819",
          "value": "2.0894",
          "totalValue": "2.7371"
        },
        {
          "date": "20260818",
          "value": "2.0908",
          "totalValue": "2.7389"
        },
        {
          "date": "20260817",
          "value": "2.0921",
          "totalValue": "2.7406"
        },
        {
          "date": "20260814",
          "value": "2.0933",
          "totalValue": "2.7422"
        },
        {
          "date": "20260813",
          "value": "2.0944",
          "totalValue": "2.7437"
        },
        {
          "date": "20260812",
          "value": "2.0955",
          "totalValue": "2.7450"
        },
        {
          "date": "20260811",
          "value": "2.0964",
          "totalValue": "2.7463"
        },
        {
          "date": "20260810",
          "value": "2.0973",
          "totalValue": "2.7474"
        },
        {
          "date": "20260807",
          "value": "2.0980",
          "totalValue": "2.7484"
        },
        {
          "date": "20260806",
          "value": "2.0987",
          "totalValue": "2.7493"
        },
        {
          "date": "20260805",
          "value": "2.0993",
          "totalValue": "2.7500"
        },
        {
          "date": "20260804",
          "value": "2.0997",
          "totalValue": "2.7506"
        },
        {
          "date": "20260803",
          "value": "2.1001",
          "totalValue": "2.7511"
        },
        {
          "date": "20260731",
          "value": "2.1003",
          "totalValue": "2.7514"
        },
        {
          "date": "20260730",
          "value": "2.1004",
          "totalValue": "2.7515"
        },
        {
          "date": "20260729",
          "value": "2.1004",
          "totalValue": "2.7515"
        },
        {
          "date": "20260728",
          "value": "2.1003",
          "totalValue": "2.7514"
        },
        {
          "date": "20260727",
          "value": "2.1000",
          "totalValue": "2.7510"
        },
        {
          "date": "20260724",
          "value": "2.0997",
          "totalValue": "2.7505"
        },
        {
          "date": "20260723",
          "value": "2.0992",
          "totalValue": "2.7499"
        },
        {
          "date": "20260722",
          "value": "2.0985",
          "totalValue": "2.7491"
        },
        {
          "date": "20260721",
          "value": "2.0978",
          "totalValue": "2.7481"
        },
        {
          "date": "20260720",
          "value": "2.0969",
          "totalValue": "2.7469"
        },
        {
          "date": "20260717",
          "value": "2.0959",
          "totalValue": "2.7456"
        },
        {
          "date": "20260716",
          "value": "2.0947",
          "totalValue": "2.7441"
        },
        {
          "date": "20260715",
          "value": "2.0934",
          "totalValue": "2.7424"
        },
        {
          "date": "20260714",
          "value": "2.0920",
          "totalValue": "2.7406"
        },
        {
          "date": "20260713",
          "value": "2.0905",
          "totalValue": "2.7386"
        },
        {
          "date": "20260710",
          "value": "2.0889",
          "totalValue": "2.7364"
        },
        {
          "date": "20260709",
          "value": "2.0871",
          "totalValue": "2.7341"
        },
        {
          "date": "20260708",
          "value": "2.0852",
          "totalValue": "2.7316"
        },
        {
          "date": "20260707",
          "value": "2.0831",
          "totalValue": "2.7289"
        },
        {
          "date": "20260706",
          "value": "2.0810",
          "totalValue": "2.7261"
        },
        {
          "date": "20260703",
          "value": "2.0787",
          "totalValue": "2.7231"
        },
        {
          "date": "20260702",
          "value": "2.0763",
          "totalValue": "2.7200"
        },
        {
          "date": "20260701",
          "value": "2.0738",
          "totalValue": "2.7167"
        },
        {
          "date": "20260630",
          "value": "2.0712",
          "totalValue": "2.7133"
        },
        {
          "date": "20260629",
          "value": "2.0685",
          "totalValue": "2.7097"
        },
        {
          "date": "20260626",
          "value": "2.0657",
          "totalValue": "2.7060"
        },
        {
          "date": "20260625",
          "value": "2.0628",
          "totalValue": "2.7022"
        },
        {
          "date": "20260624",
          "value": "2.0597",
          "totalValue": "2.6983"
        },
        {
          "date": "20260623",
          "value": "2.0566",
          "totalValue": "2.6942"
        },
        {
          "date": "20260622",
          "value": "2.0534",
          "totalValue": "2.6900"
        },
        {
          "date": "20260619",
          "value": "2.0502",
          "totalValue": "2.6857"
        },
        {
          "date": "20260618",
          "value": "2.0468",
          "totalValue": "2.6813"
        },
        {
          "date": "20260617",
          "value": "2.0434",
          "totalValue": "2.6768"
        },
        {
          "date": "20260616",
          "value": "2.0399",
          "totalValue": "2.6722"
        },
        {
          "date": "20260615",
          "value": "2.0363",
          "totalValue": "2.6675"
        },
        {
          "date": "20260612",
          "value": "2.0326",
          "totalValue": "2.6628"
        },
        {
          "date": "20260611",
          "value": "2.0290",
          "totalValue": "2.6579"
        },
        {
          "date": "20260610",
          "value": "2.0252",
          "totalValue": "2.6530"
        },
        {
          "date": "20260609",
          "value": "2.0214",
          "totalValue": "2.6481"
        },
        {
          "date": "20260608",
          "value": "2.0176",
          "totalValue": "2.6431"
        },
        {
          "date": "20260605",
          "value": "2.0138",
          "totalValue": "2.6380"
        },
        {
          "date": "20260604",
          "value": "2.0099",
          "totalValue": "2.6329"
        },
        {
          "date": "20260603",
          "value": "2.0060",
          "totalValue": "2.6278"
        },
        {
          "date": "20260602",
          "value": "2.0020",
          "totalValue": "2.6227"
        },
        {
          "date": "20260601",
          "value": "1.9981",
          "totalValue": "2.6175"
        },
        {
          "date": "20260529",
          "value": "1.9941",
          "totalValue": "2.6123"
        },
        {
          "date": "20260528",
          "value": "1.9902",
          "totalValue": "2.6071"
        },
        {
          "date": "20260527",
          "value": "1.9862",
          "totalValue": "2.6020"
        },
        {
          "date": "20260526",
          "value": "1.9823",
          "totalValue": "2.5968"
        },
        {
          "date": "20260525",
          "value": "1.9784",
          "totalValue": "2.5917"
        },
        {
          "date": "20260522",
          "value": "1.9745",
          "totalValue": "2.5865"
        },
        {
          "date": "20260521",
          "value": "1.9706",
          "totalValue": "2.5814"
        },
        {
          "date": "20260520",
          "value": "1.9667",
          "totalValue": "2.5764"
        },
        {
          "date": "20260519",
          "value": "1.9629",
          "totalValue": "2.5714"
        },
        {
          "date": "20260518",
          "value": "1.9591",
          "totalValue": "2.5664"
        },
        {
          "date": "20260515",
          "value": "1.9554",
          "totalValue": "2.5616"
        },
        {
          "date": "20260514",
          "value": "1.9517",
          "totalValue": "2.5567"
        },
        {
          "date": "20260513",
          "value": "1.9481",
          "totalValue": "2.5520"
        },
        {
          "date": "20260512",
          "value": "1.9445",
          "totalValue": "2.5473"
        },
        {
          "date": "20260511",
          "value": "1.9410",
          "totalValue": "2.5427"
        },
        {
          "date": "20260508",
          "value": "1.9375",
          "totalValue": "2.5382"
        },
        {
          "date": "20260507",
          "value": "1.9342",
          "totalValue": "2.5337"
        },
        {
          "date": "20260506",
          "value": "1.9309",
          "totalValue": "2.5294"
        },
        {
          "date": "20260505",
          "value": "1.9276",
          "totalValue": "2.5252"
        },
        {
          "date": "20260504",
          "value": "1.9245",
          "totalValue": "2.5211"
        },
        {
          "date": "20260501",
          "value": "1.9214",
          "totalValue": "2.5171"
        },
        {
          "date": "20260430",
          "value": "1.9185",
          "totalValue": "2.5132"
        },
        {
          "date": "20260429",
          "value": "1.9156",
          "totalValue": "2.5095"
        },
        {
          "date": "20260428",
          "value": "1.9128",
          "totalValue": "2.5058"
        },
        {
          "date": "20260427",
          "value": "1.9102",
          "totalValue": "2.5023"
        },
        {
          "date": "20260424",
          "value": "1.9076",
          "totalValue": "2.4989"
        },
        {
          "date": "20260423",
          "value": "1.9051",
          "totalValue": "2.4957"
        },
        {
          "date": "20260422",
          "value": "1.9027",
          "totalValue": "2.4926"
        },
        {
          "date": "20260421",
          "value": "1.9005",
          "totalValue": "2.4896"
        },
        {
          "date": "20260420",
          "value": "1.8983",
          "totalValue": "2.4868"
        },
        {
          "date": "20260417",
          "value": "1.8963",
          "totalValue": "2.4841"
        },
        {
          "date": "20260416",
          "value": "1.8944",
          "totalValue": "2.4816"
        },
        {
          "date": "20260415",
          "value": "1.8925",
          "totalValue": "2.4792"
        },
        {
          "date": "20260414",
          "value": "1.8909",
          "totalValue": "2.4770"
        },
        {
          "date": "20260413",
          "value": "1.8893",
          "totalValue": "2.4749"
        },
        {
          "date": "20260410",
          "value": "1.8878",
          "totalValue": "2.4730"
        },
        {
          "date": "20260409",
          "value": "1.8865",
          "totalValue": "2.4713"
        },
        {
          "date": "20260408",
          "value": "1.8852",
          "totalValue": "2.4696"
        },
        {
          "date": "20260407",
          "value": "1.8841",
          "totalValue": "2.4682"
        },
        {
          "date": "20260406",
          "value": "1.8831",
          "totalValue": "2.4669"
        },
        {
          "date": "20260403",
          "value": "1.8822",
          "totalValue": "2.4657"
        },
        {
          "date": "20260402",
          "value": "1.8815",
          "totalValue": "2.4647"
        },
        {
          "date": "20260401",
          "value": "1.8808",
          "totalValue": "2.4639"
        },
        {
          "date": "20260331",
          "value": "1.8803",
          "totalValue": "2.4632"
        },
        {
          "date": "20260330",
          "value": "1.8799",
          "totalValue": "2.4627"
        },
        {
          "date": "20260327",
          "value": "1.8796",
          "totalValue": "2.4623"
        },
        {
          "date": "20260326",
          "value": "1.8794",
          "totalValue": "2.4620"
        },
        {
          "date": "20260325",
          "value": "1.8793",
          "totalValue": "2.4619"
        },
        {
          "date": "20260324",
          "value": "1.8793",
          "totalValue": "2.4619"
        },
        {
          "date": "20260323",
          "value": "1.8795",
          "totalValue": "2.4621"
        },
        {
          "date": "20260320",
          "value": "1.8797",
          "totalValue": "2.4624"
        },
        {
          "date": "20260319",
          "value": "1.8800",
          "totalValue": "2.4628"
        },
        {
          "date": "20260318",
          "value": "1.8805",
          "totalValue": "2.4634"
        },
        {
          "date": "20260317",
          "value": "1.8810",
          "totalValue": "2.4641"
        },
        {
          "date": "20260316",
          "value": "1.8816",
          "totalValue": "2.4649"
        },
        {
          "date": "20260313",
          "value": "1.8823",
          "totalValue": "2.4659"
        },
        {
          "date": "20260312",
          "value": "1.8831",
          "totalValue": "2.4669"
        },
        {
          "date": "20260311",
          "value": "1.8840",
          "totalValue": "2.4681"
        },
        {
          "date": "20260310",
          "value": "1.8850",
          "totalValue": "2.4693"
        },
        {
          "date": "20260309",
          "value": "1.8860",
          "totalValue": "2.4707"
        },
        {
          "date": "20260306",
          "value": "1.8871",
          "totalValue": "2.4721"
        },
        {
          "date": "20260305",
          "value": "1.8883",
          "totalValue": "2.4737"
        },
        {
          "date": "20260304",
          "value": "1.8896",
          "totalValue": "2.4753"
        },
        {
          "date": "20260303",
          "value": "1.8909",
          "totalValue": "2.4770"
        },
        {
          "date": "20260302",
          "value": "1.8922",
          "totalValue": "2.4788"
        },
        {
          "date": "20260227",
          "value": "1.8936",
          "totalValue": "2.4807"
        },
        {
          "date": "20260226",
          "value": "1.8951",
          "totalValue": "2.4826"
        },
        {
          "date": "20260225",
          "value": "1.8966",
          "totalValue": "2.4845"
        },
        {
          "date": "20260224",
          "value": "1.8981",
          "totalValue": "2.4865"
        },
        {
          "date": "20260223",
          "value": "1.8997",
          "totalValue": "2.4886"
        },
        {
          "date": "20260220",
          "value": "1.9013",
          "totalValue": "2.4907"
        },
        {
          "date": "20260219",
          "value": "1.9029",
          "totalValue": "2.4928"
        },
        {
          "date": "20260218",
          "value": "1.9045",
          "totalValue": "2.4949"
        },
        {
          "date": "20260217",
          "value": "1.9062",
          "totalValue": "2.4971"
        },
        {
          "date": "20260216",
          "value": "1.9078",
          "totalValue": "2.4993"
        },
        {
          "date": "20260213",
          "value": "1.9095",
          "totalValue": "2.5014"
        },
        {
          "date": "20260212",
          "value": "1.9111",
          "totalValue": "2.5036"
        },
        {
          "date": "20260211",
          "value": "1.9128",
          "totalValue": "2.5058"
        },
        {
          "date": "20260210",
          "value": "1.9144",
          "totalValue": "2.5079"
        },
        {
          "date": "20260209",
          "value": "1.9160",
          "totalValue": "2.5100"
        },
        {
          "date": "20260206",
          "value": "1.9176",
          "totalValue": "2.5121"
        },
        {
          "date": "20260205",
          "value": "1.9192",
          "totalValue": "2.5141"
        },
        {
          "date": "20260204",
          "value": "1.9207",
          "totalValue": "2.5161"
        },
        {
          "date": "20260203",
          "value": "1.9222",
          "totalValue": "2.5181"
        },
        {
          "date": "20260202",
          "value": "1.9236",
          "totalValue": "2.5200"
        },
        {
          "date": "20260130",
          "value": "1.9250",
          "totalValue": "2.5218"
        },
        {
          "date": "20260129",
          "value": "1.9264",
          "totalValue": "2.5236"
        },
        {
          "date": "20260128",
          "value": "1.9277",
          "totalValue": "2.5252"
        },
        {
          "date": "20260127",
          "value": "1.9289",
          "totalValue": "2.5268"
        },
        {
          "date": "20260126",
          "value": "1.9300",
          "totalValue": "2.5283"
        },
        {
          "date": "20260123",
          "value": "1.9311",
          "totalValue": "2.5298"
        },
        {
          "date": "20260122",
          "value": "1.9321",
          "totalValue": "2.5311"
        },
        {
          "date": "20260121",
          "value": "1.9330",
          "totalValue": "2.5323"
        },
        {
          "date": "20260120",
          "value": "1.9339",
          "totalValue": "2.5334"
        },
        {
          "date": "20260119",
          "value": "1.9346",
          "totalValue": "2.5344"
        },
        {
          "date": "20260116",
          "value": "1.9353",
          "totalValue": "2.5352"
        },
        {
          "date": "20260115",
          "value": "1.9359",
          "totalValue": "2.5360"
        },
        {
          "date": "20260114",
          "value": "1.9363",
          "totalValue": "2.5366"
        },
        {
          "date": "20260113",
          "value": "1.9367",
          "totalValue": "2.5371"
        },
        {
          "date": "20260112",
          "value": "1.9370",
          "totalValue": "2.5374"
        },
        {
          "date": "20260109",
          "value": "1.9371",
          "totalValue": "2.5376"
        },
        {
          "date": "20260108",
          "value": "1.9372",
          "totalValue": "2.5377"
        },
        {
          "date": "20260107",
          "value": "1.9371",
          "totalValue": "2.5376"
        },
        {
          "date": "20260106",
          "value": "1.9370",
          "totalValue": "2.5374"
        },
        {
          "date": "20260105",
          "value": "1.9367",
          "totalValue": "2.5370"
        },
        {
          "date": "20260102",
          "value": "1.9363",
          "totalValue": "2.5365"
        },
        {
          "date": "20260101",
          "value": "1.9358",
          "totalValue": "2.5358"
        },
        {
          "date": "20251231",
          "value": "1.9351",
          "totalValue": "2.5350"
        },
        {
          "date": "20251230",
          "value": "1.9344",
          "totalValue": "2.5340"
        },
        {
          "date": "20251229",
          "value": "1.9335",
          "totalValue": "2.5329"
        },
        {
          "date": "20251226",
          "value": "1.9325",
          "totalValue": "2.5316"
        },
        {
          "date": "20251225",
          "value": "1.9314",
          "totalValue": "2.5301"
        },
        {
          "date": "20251224",
          "value": "1.9301",
          "totalValue": "2.5285"
        },
        {
          "date": "20251223",
          "value": "1.9288",
          "totalValue": "2.5267"
        },
        {
          "date": "20251222",
          "value": "1.9273",
          "totalValue": "2.5248"
        },
        {
          "date": "20251219",
          "value": "1.9257",
          "totalValue": "2.5227"
        },
        {
          "date": "20251218",
          "value": "1.9240",
          "totalValue": "2.5205"
        },
        {
          "date": "20251217",
          "value": "1.9222",
          "totalValue": "2.5181"
        },
        {
          "date": "20251216",
          "value": "1.9203",
          "totalValue": "2.5156"
        },
        {
          "date": "20251215",
          "value": "1.9183",
          "totalValue": "2.5129"
        },
        {
          "date": "20251212",
          "value": "1.9161",
          "totalValue": "2.5101"
        },
        {
          "date": "20251211",
          "value": "1.9139",
          "totalValue": "2.5072"
        },
        {
          "date": "20251210",
          "value": "1.9115",
          "totalValue": "2.5041"
        },
        {
          "date": "20251209",
          "value": "1.9091",
          "totalValue": "2.5009"
        },
        {
          "date": "20251208",
          "value": "1.9065",
          "totalValue": "2.4975"
        },
        {
          "date": "20251205",
          "value": "1.9039",
          "totalValue": "2.4940"
        },
        {
          "date": "20251204",
          "value": "1.9011",
          "totalValue": "2.4905"
        },
        {
          "date": "20251203",
          "value": "1.8983",
          "totalValue": "2.4868"
        },
        {
          "date": "20251202",
          "value": "1.8954",
          "totalValue": "2.4830"
        },
        {
          "date": "20251201",
          "value": "1.8924",
          "totalValue": "2.4790"
        },
        {
          "date": "20251128",
          "value": "1.8893",
          "totalValue": "2.4750"
        },
        {
          "date": "20251127",
          "value": "1.8862",
          "totalValue": "2.4709"
        },
        {
          "date": "20251126",
          "value": "1.8830",
          "totalValue": "2.4667"
        },
        {
          "date": "20251125",
          "value": "1.8797",
          "totalValue": "2.4624"
        },
        {
          "date": "20251124",
          "value": "1.8764",
          "totalValue": "2.4581"
        },
        {
          "date": "20251121",
          "value": "1.8730",
          "totalValue": "2.4537"
        },
        {
          "date": "20251120",
          "value": "1.8696",
          "totalValue": "2.4492"
        },
        {
          "date": "20251119",
          "value": "1.8661",
          "totalValue": "2.4446"
        },
        {
          "date": "20251118",
          "value": "1.8626",
          "totalValue": "2.4400"
        },
        {
          "date": "20251117",
          "value": "1.8591",
          "totalValue": "2.4354"
        },
        {
          "date": "20251114",
          "value": "1.8555",
          "totalValue": "2.4307"
        },
        {
          "date": "20251113",
          "value": "1.8519",
          "totalValue": "2.4260"
        },
        {
          "date": "20251112",
          "value": "1.8483",
          "totalValue": "2.4213"
        },
        {
          "date": "20251111",
          "value": "1.8447",
          "totalValue": "2.4165"
        },
        {
          "date": "20251110",
          "value": "1.8410",
          "totalValue": "2.4117"
        },
        {
          "date": "20251107",
          "value": "1.8374",
          "totalValue": "2.4070"
        },
        {
          "date": "20251106",
          "value": "1.8337",
          "totalValue": "2.4022"
        },
        {
          "date": "20251105",
          "value": "1.8301",
          "totalValue": "2.3974"
        },
        {
          "date": "20251104",
          "value": "1.8265",
          "totalValue": "2.3927"
        },
        {
          "date": "20251103",
          "value": "1.8229",
          "totalValue": "2.3879"
        },
        {
          "date": "20251031",
          "value": "1.8193",
          "totalValue": "2.3832"
        },
        {
          "date": "20251030",
          "value": "1.8157",
          "totalValue": "2.3786"
        },
        {
          "date": "20251029",
          "value": "1.8122",
          "totalValue": "2.3739"
        },
        {
          "date": "20251028",
          "value": "1.8087",
          "totalValue": "2.3693"
        },
        {
          "date": "20251027",
          "value": "1.8052",
          "totalValue": "2.3648"
        },
        {
          "date": "20251024",
          "value": "1.8018",
          "totalValue": "2.3603"
        },
        {
          "date": "20251023",
          "value": "1.7984",
          "totalValue": "2.3559"
        },
        {
          "date": "20251022",
          "value": "1.7951",
          "totalValue": "2.3515"
        },
        {
          "date": "20251021",
          "value": "1.7918",
          "totalValue": "2.3472"
        },
        {
          "date": "20251020",
          "value": "1.7886",
          "totalValue": "2.3430"
        },
        {
          "date": "20251017",
          "value": "1.7854",
          "totalValue": "2.3389"
        },
        {
          "date": "20251016",
          "value": "1.7824",
          "totalValue": "2.3349"
        },
        {
          "date": "20251015",
          "value": "1.7793",
          "totalValue": "2.3309"
        },
        {
          "date": "20251014",
          "value": "1.7764",
          "totalValue": "2.3271"
        },
        {
          "date": "20251013",
          "value": "1.7736",
          "totalValue": "2.3234"
        },
        {
          "date": "20251010",
          "value": "1.7708",
          "totalValue": "2.3197"
        },
        {
          "date": "20251009",
          "value": "1.7681",
          "totalValue": "2.3162"
        },
        {
          "date": "20251008",
          "value": "1.7655",
          "totalValue": "2.3128"
        },
        {
          "date": "20251007",
          "value": "1.7630",
          "totalValue": "2.3095"
        },
        {
          "date": "20251006",
          "value": "1.7605",
          "totalValue": "2.3063"
        },
        {
          "date": "20251003",
          "value": "1.7582",
          "totalValue": "2.3033"
        },
        {
          "date": "20251002",
          "value": "1.7560",
          "totalValue": "2.3003"
        },
        {
          "date": "20251001",
          "value": "1.7538",
          "totalValue": "2.2975"
        },
        {
          "date": "20250930",
          "value": "1.7518",
          "totalValue": "2.2949"
        },
        {
          "date": "20250929",
          "value": "1.7499",
          "totalValue": "2.2923"
        },
        {
          "date": "20250926",
          "value": "1.7480",
          "totalValue": "2.2899"
        },
        {
          "date": "20250925",
          "value": "1.7463",
          "totalValue": "2.2877"
        },
        {
          "date": "20250924",
          "value": "1.7447",
          "totalValue": "2.2856"
        },
        {
          "date": "20250923",
          "value": "1.7432",
          "totalValue": "2.2836"
        },
        {
          "date": "20250922",
          "value": "1.7418",
          "totalValue": "2.2817"
        },
        {
          "date": "20250919",
          "value": "1.7405",
          "totalValue": "2.2800"
        },
        {
          "date": "20250918",
          "value": "1.7393",
          "totalValue": "2.2785"
        },
        {
          "date": "20250917",
          "value": "1.7382",
          "totalValue": "2.2770"
        }
      ]
    }
  },
  "getHistory": {
    "code": "000000",
    "msg": "成功",
    "data": {
      "intervalList": [
        {
          "label": "近一周",
          "value": 1.03,
          "avg": 0.58,
          "ranking": "前30%"
        },
        {
          "label": "近一月",
          "value": 2.14,
          "avg": 1.41,
          "ranking": "前30%"
        },
        {
          "label": "近三月",
          "value": 5.37,
          "avg": 3.07,
          "ranking": "前30%"
        },
        {
          "label": "近六月",
          "value": 8.61,
          "avg": 4.38,
          "ranking": "前30%"
        },
        {
          "label": "近一年",
          "value": 18.26,
          "avg": 9.57,
          "ranking": "前30%"
        },
        {
          "label": "近二年",
          "value": 24.05,
          "avg": 11.38,
          "ranking": "前30%"
        },
        {
          "label": "近三年",
          "value": 31.42,
          "avg": 14.98,
          "ranking": "前30%"
        },
        {
          "label": "近五年",
          "value": "--",
          "avg": "--",
          "ranking": "--"
        },
        {
          "label": "今年以来",
          "value": 11.47,
          "avg": 5.2,
          "ranking": "前30%"
        },
        {
          "label": "成立以来",
          "value": 105.56,
          "avg": 45.81,
          "ranking": "前30%"
        }
      ],
      "yearList": [
        {
          "year": "2025",
          "value": 14.82,
          "avg": 6.31,
          "ranking": "前18%"
        },
        {
          "year": "2024",
          "value": 9.37,
          "avg": 4.05,
          "ranking": "前25%"
        },
        {
          "year": "2023",
          "value": -4.16,
          "avg": -2.88,
          "ranking": "前42%"
        }
      ]
    }
  },
  "getMetrics": {
    "code": "000000",
    "msg": "成功",
    "data": {
      "metricList": [
        {
          "intervalDesc": "近一月",
          "maxDrawdown": -2.14,
          "volatility": 8.62,
          "sharpe": 1.94
        },
        {
          "intervalDesc": "近三月",
          "maxDrawdown": -6.37,
          "volatility": 12.41,
          "sharpe": 1.32
        },
        {
          "intervalDesc": "近六月",
          "maxDrawdown": -9.85,
          "volatility": 13.07,
          "sharpe": 1.06
        },
        {
          "intervalDesc": "近一年",
          "maxDrawdown": -12.48,
          "volatility": 14.22,
          "sharpe": 0.92
        },
        {
          "intervalDesc": "近三年",
          "maxDrawdown": -20.86,
          "volatility": 15.63,
          "sharpe": 0.61
        },
        {
          "intervalDesc": "成立以来",
          "maxDrawdown": -24.31,
          "volatility": 16.08,
          "sharpe": 0.55
        }
      ]
    }
  },
  "queryOwner": {
    "code": "000000",
    "msg": "成功",
    "data": {
      "ownerList": [
        {
          "ownerId": "6",
          "name": "示例经理甲",
          "company": "示例资产管理有限公司",
          "experienceYears": 11,
          "tenureReturn": 32.47,
          "tag": "从业 11 年"
        },
        {
          "ownerId": "9",
          "name": "示例经理乙",
          "company": "示例资产管理有限公司",
          "experienceYears": 6,
          "tenureReturn": 8.13,
          "tag": "从业 6 年"
        }
      ]
    }
  }
}

值按你的上游替换 :五个键里只有三段进产物数据块(getDetail / getTrend / getHistory),另两段只进正文;getTrend 的 261 条 = 一个自然年的交易日,一天一条,恒按日期倒序。口径与陷阱(倒序、-- 即缺失、两个数值口径不能混用)子技能模板里已列全,这里不重复。

8.1 一条命令:数据源 → 产物

mock/build-report.mjs(82 行)只做三件事:剥信封 → 口径归一 → 注入壳。它不产出 DOM------板块正文是执行模型照壳拼接时写进 DOM 的静态文本,脚本只负责数据块这一个落点。

js 复制代码
// 双层信封第二层:业务码为 "000000" 才算取数成功,否则终止生成
const pick = (name) => {
    const envelope = source[name];
    if (!envelope || envelope.code !== '000000') {
        throw new Error(`${name} 取数失败:code=${envelope && envelope.code} msg=${envelope && envelope.msg}`);
    }
    return envelope.data;              // 剥完两层信封,才是真实业务字段
};

const detail = pick('getDetail');
const trend = pick('getTrend').list;
const history = pick('getHistory');

// 口径归一:倒序 → 升序;"--" → null(不绘制、不填 0);YYYYMMDD → YYYY-MM-DD
const asc = [...trend].reverse();
const fmtDate = (ymd) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6)}`;
const num = (v) => (v === '--' ? null : Number(v));
js 复制代码
// 占位符只应出现一次:0 次或多次说明壳与数据对不上,直接报错而不是产出半成品
const hits = body.split(PLACEHOLDER).length - 1;
if (hits !== 1) throw new Error(`壳里 ${PLACEHOLDER} 出现 ${hits} 次,应为 1 次`);

const html = body.replace(PLACEHOLDER, reportData);
const outName = `${MODULE}-${data.meta.itemId}-${data.meta.reportDate.replace(/-/g, '')}.html`;

三行归一代码对应三条口径:

代码 对应口径 不这样做会怎样
[...trend].reverse() 倒序 → 升序 折线时间轴反向(子技能模板里「倒序」那条陷阱)
num = (v) => (v === '--' ? null : Number(v)) -- 即缺失 Number("--") = NaN,柱状图整块崩掉
fmtDate YYYYMMDDYYYY-MM-DD 轴上出现 20250916 这种不可读刻度
bash 复制代码
$ node mock/build-report.mjs
产物已生成:tmp/item-1001-20260917.html(数据块 19220 字符 / 全文 36486 字符 / 38403 字节)

产物文件名不是写死的,而是从数据里读出来的MODULE 取自子技能目录 modules/item-report 的首段,itemId / reportDate 来自 data.meta。换一个条目、换一天,文件名自动跟着变。

为什么值得把数据单独放一份、再注入:图表要画什么全由数据块决定,壳里不该再抄一份------两边一旦各留一份,就必然出现「改了 A 忘了 B」(自检第 ① 条防的正是这个)。同时产物的每个数字都能追到数据源的一处 ,对账才有单一锚点;产物也因此随时可重建,不会出现「文件在、来源丢」。

边界要说清楚:拆开的是「数据」和「版式」,不是「正文表格」 。正文里的那些数字是执行模型在拼接阶段写进 DOM 的静态文本(换条目要重走第 ⑧ 步),build-report.mjs 只负责把数据块注进占位符------它不做渲染,也不该做渲染。

8.2 产物自检(把「对账」变成一条命令)

「可对账」写在文档里只是约定,落成脚本才是可执行的质量关verify.mjs,42 行):

bash 复制代码
$ node verify.mjs
[1] report-data 可解析 · 字符 19220
[2.1] 图表 IIFE 语法 OK · 挂载点 #chart-trend
[2.2] 图表 IIFE 语法 OK · 挂载点 #chart-compare
[OK]   ① latestValue ↔ trend 末点 → 2.0556
[OK]   ② 近一年变化 ↔ latestYearChange → 18.26
[OK]   ③ 成立以来 ↔ totalChange → 105.56
[OK]   ④ 近五年为 "--" 已删行 → 正文无近五年行
[OK]   ⑤ 条形图 近五年 = null(不绘制不填 0) → null
[OK]   ⑥ 走势升序 · 首末 → 2025-09-17 → 2026-09-16
[OK]   ⑦ 走势点数 = 交易日数 → 261
[OK]   ⑧ 走势区间涨幅 ↔ latestYearChange → 18.26%
[9] 产物字符 36486 · 板块数 11

八条断言覆盖了四类最容易出错的环节:跨端点对账 (①②③)、裁剪口径 (④⑤)、序列重排 (⑥⑦⑧)、结构完整性(板块数 11、图表挂载点存在、JSON 可解析)。

其中第 ⑧ 条最有用:走势序列的首末点涨幅必须等于 latestYearChange------一旦取错起始日或漏了一天,这条立刻报警。

单位说明:19220 / 36486String.length字符数 ),与后面「落盘与交付」那节 ls -l字节数 不是一回事------产物含中文,1 字符 = 3 字节。

8.3 能力层:不接上游也能用

本案例的取数走的是文件,但骨架的能力层不依赖任何上游就能用------列出端点、读出参数表、拦住写错的参数,全是本地行为,用法一条一行:

bash 复制代码
node scripts/call.mjs --list                                # 列出全部端点
node scripts/call.mjs --describe item.getDetail             # 查某个端点的参数表
node scripts/call.mjs item.getDetail --param itemId=1001    # 按参数调用

node scripts/call.mjs item.getDetail                        # 必填缺失 → 阻断,原因在 message
node scripts/call.mjs item.queryOwner --param parseType=14  # 多个必填都缺 → 一次报全
node scripts/call.mjs item.noSuchThing --param itemId=1001  # 未知端点 → 发请求前就被拦住

stdout 恒为单段 JSON({ ok, message, ...payload }),诊断走 stderr------所以上面每一条都可以直接接管道取字段。

原先还有三条网络类 失败分支(HTTP 404 / 业务码失败 / 超时),判据一字不变------本案例不再起任何服务,现场证据就不再贴了。双层信封第二层的代码化就是 8.1 的 pickcode !== "000000" 直接抛错终止,绝不带着半份数据往下渲染。

8.4 可复现性边界

链路里没有网络、没有随机、没有时间戳,所以产物逐字节可复现------这也是它敢被逐段贴进文章的前提:

bash 复制代码
$ rm -rf tmp && node mock/build-report.mjs
产物已生成:tmp/item-1001-20260917.html(数据块 19220 字符 / 全文 36486 字符 / 38403 字节)

$ shasum -a 256 tmp/item-1001-20260917.html
6d4350eacf9667c83ccb7839fe5723a2acc655fe194d0c88fcbf87108fdf8458  tmp/item-1001-20260917.html

删掉产物重跑,SHA-256 恒为同一串。数字不必解释,只需可复现。

9. 端到端流转与落盘

9.1 一次执行的完整顺序(谁读谁)

text 复制代码
① 前置检查   Node ≥ 18 → 冒烟                                    references/environment-precheck.md
② 路由       SKILL.md 路由决策表 → modules/item-report/index.md
③ 读能力     apis.md(取数前必读)                                端点表 / 消费范围 / 双层校验
④ 取数       读唯一数据源 mock/data.json(生产里由 call.mjs 经能力层取回)
⑤ 读口径     templates.md / sections.md(组装前必读)             模版定义 / 板块顺序 / 字段口径
⑥ 读壳与图   shell.html + html-visual-template.md / echarts-guide.md(产出前必读)
⑦ 归一+内嵌  "--" → null / 倒序 → 升序 / 日期格式化 → #report-data
⑧ 拼接       壳层照抄 → 11 板块按序 → 真实值写入 DOM              模版不含数据
⑨ 落盘交付   tmp/item-1001-20260917.html → 8 条断言对账 → 给出绝对路径

其中 ③⑤⑥ 是「看起来可以不做、但不做就会出事 」的三步------它们都是读文档 。这正是这套骨架把「读文档」写进流程的原因:AI 漏读文档,从来不是因为文档写得不好,而是因为流程里没有那一步。

上面的 ①--⑨ 是执行模型 (由执行者照文档完成,⑦⑧ 是它的判断,不是一段代码)。要让这套流程可复现、可核查,本案例把其中能机械化的几步落成了脚手架------一一对应:

流转步骤 脚手架 说明
④ 取数 mock/data.json 五段业务数据一次写全,形状与线上返回 1:1------它是数据,不是脚本
⑦ 归一 + 内嵌 mock/build-report.mjs --null / 倒序 → 升序 / 日期格式化 / 注入占位符
⑧ 拼接 mock/report-body.html 执行模型的产出:壳 + 11 板块正文(本次运行的展示值已在其中),数据位置只留一个占位符
⑨ 落盘 mock/build-report.mjs 文件名由数据里的 itemId / reportDate 推出
⑨ 对账 verify.mjs 8 条断言,产物不对就非零退出

一眼就能看出脚手架管到哪为止:它替代的只有「归一、注入、落盘、对账」这类机械步骤;③⑤⑥ 那三步读文档、以及 ⑧ 里「照模版摆板块」的判断,脚本替代不了------那是留给执行模型的活。

9.2 产物的结构(关键片段)

html 复制代码
<!-- 板块 2 · Hero 摘要(MT-1,数据源 item.getDetail) -->
<div class="summaryPanel">
    <div class="tagRow">
        <span class="tag">A 类</span>
        <span class="tag">中风险</span>
        <span class="tag">均衡型</span>
        <span class="tag">正常开放</span>
    </div>
    <div class="metricGrid">
        <div class="metricCard">
            <div class="metricLabel">最新数值(2026-09-16)</div>
            <div class="metricValue">2.0556</div>
        </div>
        <div class="metricCard">
            <div class="metricLabel">日变化</div>
            <div class="metricValue up">+0.42%</div>
        </div>
        <div class="metricCard">
            <div class="metricLabel">近一年变化</div>
            <div class="metricValue up">+18.26%</div>
        </div>
        <div class="metricCard">
            <div class="metricLabel">成立以来变化</div>
            <div class="metricValue up">+105.56%</div>
        </div>
    </div>
</div>

<!-- 板块 4 · 区间表现(MT-3,数据源 getHistory;近五年为 "--" 已按裁剪总则删行) -->
<div class="panel">
    <h2 class="panelTitle">区间表现</h2>
    <table class="dataTable">
        <thead><tr><th>区间</th><th>条目变化</th><th>基准变化</th><th>同类平均</th><th>同类排名</th></tr></thead>
        <tbody>
            <tr><td>近一周</td><td class="up">+1.03%</td><td class="up">+0.36%</td><td>0.58%</td><td>前30%</td></tr>
            <tr><td>近一月</td><td class="up">+2.14%</td><td class="up">+0.88%</td><td>1.41%</td><td>前30%</td></tr>
            <!-- ... 近三月 / 近六月 / 近一年 / 近二年 / 近三年 / 今年以来 / 成立以来 ... -->
        </tbody>
    </table>
    <div class="chartTitle" style="margin-top:14px">年度表现</div>
    <table class="dataTable">
        <thead><tr><th>年度</th><th>条目变化</th><th>同类平均</th><th>同类排名</th></tr></thead>
        <tbody>
            <tr><td>2025</td><td class="up">+14.82%</td><td>6.31%</td><td>前18%</td></tr>
            <tr><td>2024</td><td class="up">+9.37%</td><td>4.05%</td><td>前25%</td></tr>
            <tr><td>2023</td><td class="down">-4.16%</td><td>-2.88%</td><td>前42%</td></tr>
        </tbody>
    </table>
</div>

(以上除 <!-- ... --> 省略行外均为产物原文。)

两个细节值得单独点出:

  • 2.0556 后面没有 %,而 +0.42% ------latestValue 是单位数值不是百分比,原样保留 4 位;百分比类统一由展示层自补 %(子技能的模板约定)。
  • 「近五年」这一行不存在 :上游返回了它,但 value === "--",于是整行被删掉 ------既没有原样展示 --,也没有当 0。这是 子技能的模板约定「-- 即缺失」的落地。同一份数据在图表里则是 null(下一节)。

9.3 内嵌数据 + 图表(自包含的关键)

html 复制代码
<!-- ① 数据(必须放在图表 <script> 之前) -->
<script type="application/json" id="report-data">
{
  "meta": { "title": "示例条目甲 · 条目详情报告", "subtitle": "条目 ID 1001 · 数据截至 2026-09-16", ... },
  "charts": {
    "trend": {
      "categories": ["2025-09-17", "...", "2026-09-16"],          // 261 个交易日,已升序
      "series": [
        { "name": "单位数值", "data": [1.7382, "...", 2.0556] },
        { "name": "累计数值", "data": [2.2770, "...", 2.6928] }
      ]
    },
    "compare": {
      "categories": ["日涨跌", "近一周", "...", "成立以来"],        // 11 期
      "series": [
        { "name": "条目变化", "data": [0.42, 1.03, "...", 105.56] },
        { "name": "基准变化", "data": [0.11, 0.36, "...", 28.63] }
      ]
    }
  },
  "tables": { "kpi": [ ... ], "history": [ ... ], "year": [ ... ] }
}
</script>

<!-- ② 图表脚本:一图一段独立 IIFE,各自唯一挂载点 id -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
(function () {
    var DATA = JSON.parse(document.getElementById('report-data').textContent);
    var root = document.documentElement;
    var dom = document.getElementById('chart-trend');
    var chart = null;
    /* ... token / readSeriesColors / readChartTokens / render / MutationObserver / ResizeObserver,照抄通用接入段... */

    function buildOption() {
        var block = DATA.charts.trend;
        var colors = readSeriesColors(block.series.length);
        var tk = readChartTokens();
        return {
            color: colors || undefined,
            textStyle: { color: tk.text || undefined },
            tooltip: { trigger: 'axis', backgroundColor: tk.card || undefined,
                       borderColor: tk.line || undefined, textStyle: { color: tk.text || undefined } },
            legend: { data: block.series.map(function (s) { return s.name; }),
                      textStyle: { color: tk.muted || undefined } },
            grid: { left: 56, right: 16, top: 32, bottom: 32 },
            xAxis: { type: 'category', data: block.categories, boundaryGap: false,
                     axisLabel: { color: tk.muted || undefined },
                     axisLine: { lineStyle: { color: tk.line || undefined } } },
            yAxis: { type: 'value', scale: true, axisLabel: { color: tk.muted || undefined },
                     splitLine: { lineStyle: { color: tk.line || undefined } } },
            series: block.series.map(function (s) {
                return { name: s.name, type: 'line', smooth: true, showSymbol: false, data: s.data };
            })
        };
    }
    render();
})();
</script>

「条目 vs 基准」那段完全同构,只有三处不同:挂载点 id(chart-compare)、buildOption 的图型(type: 'bar')、以及 x 轴的 axisLabel: { interval: 0, rotate: 30 }「一图一段」不是风格偏好dispose() / resize() 是绑在实例上的,两图合写一段,切主题时必然漏重建一张。

而「近五年」在柱状图里的表现,恰好是数据块里那个 null

json 复制代码
{ "name": "条目变化", "data": [0.42, 1.03, 2.14, 5.37, 8.61, 18.26, 24.05, 31.42, null, 11.47, 105.56] }

同一个「--」在表格里变成「删行」、在图表里变成「不绘制」------两条路径、同一个口径来源(子技能的模板约定)。这就是「单一权威」在产物层面的样子。

9.4 落盘与交付

产物是单文件自包含 HTML<!DOCTYPE html></html>):

  • 数值单落点 :正文展示值由拼接直接写入 DOM ,图表值从 #report-data ------两条路径同源于一个数据块(见上文「一条命令」那节);改数只改一处;
  • 壳与数据分离mock/report-body.html 只存版式与图脚本(数据位置留占位符),数值全部来自 mock/data.json 归一后的那一份数据块;产物因此随时可由 build-report.mjs 重建,不会出现「文件在、来源丢」;
  • 离线可开#report-data 内嵌,双击即可打开(ECharts 走 CDN;要完全离线就把 echarts.min.js 内联进产物,图内代码一行不改);
  • 两个主题 :点右上角切换按钮只改 <html data-color-mode>,令牌整体覆盖,图表经 MutationObserver 自动重建

最后向用户交付绝对路径/.../mini-report-skill/tmp/item-1001-20260917.html

10. 结语

一句话总结 :这份实现的价值不在于「能跑」------让它跑一次很容易;价值在于「长期跑不坏 」。差别不在功能多少,而在每一条容易漂移的地方,都被改造成了一次可执行的检查:能查的不许猜,会漂的不许写两遍。

相关推荐
data analyse 4561 小时前
能同时统计网站App小程序的分析平台怎么选?
前端·数据分析
Shinomiya1 小时前
Mysql之表的约束详解
后端
Java内核笔记1 小时前
Spring Boot 4 与 Spring AI 2.0 深度集成:ChatClient、Advisor 链与 MCP(源码级实战)
java·后端
Bmob后端云1 小时前
Bmob后端云备忘录项目迭代:实现笔记置顶,解决笔记列表信息杂乱问题
前端·github
吃饱了得干活1 小时前
一个订单的奇幻漂流:RabbitMQ 五大难题实战
spring boot·后端·rabbitmq
OpsEye1 小时前
从代理转发到效能分析,企业 AI 网关正在发生什么样的变化
javascript·ai编程
她的男孩1 小时前
账号锁定配了"错 4 次锁 30 分钟",我连错 100 次一次没锁上:扒完 4091 行认证源码,找到 5 个坑
java·后端·架构
涛涛ing1 小时前
2026年,前端框架开始为 AI 而生了
前端
三岁就很~酷~1 小时前
ai开发 python+claudecode环境搭建
python·ai编程