承接上篇《# 从零搭一套 WorkBuddy Skill 骨架:调接口 → 生成 HTML 报告的分层设计与流转拆解》 juejin.cn/post/768598...
上篇讲的是设计 ------四层怎么切、边界怎么定、同一个结论该放哪儿。这一篇换一种交付方式:给一份可以直接照抄跑通的完整实现。它按上篇第 2 章的四层架构落地,覆盖 前置检查 → 路由 → 取数 → 双层校验 → 口径归一 → 模版与板块 → 照壳拼接 → 数据内嵌 → 落盘交付 → 对账 的每一个环节 , 且下面出现的每一段输出都是实跑贴回来的,没有一行是示意。
前置条件只有一样:Node ≥ 18。 骨架不装任何 npm 包(只用内置
fetch),数据源是一份本地文件,不需要账号、密钥、外网,也不用起任何服务 ;两条命令跑完全链:node mock/build-report.mjs出产物、node verify.mjs自检。开工前把三件事说清楚:
- 命名一律抽象 。真实场景里的提供方域名、业务端点名、条目编号,在本案例中替换为中性占位------
item模块、itemApi提供方、http://127.0.0.1:8787、条目1001。数据结构、职责切分、调用协议 1:1 保留 :换掉providers.mjs里的baseURL,再把item.mjs的六条端点声明换成真实路径,骨架其余部分一行不动。- 数据源用一份本地文件 。真实场景里数据由能力层经
call.mjs取回;本案例把它们固化成mock/data.json(见下文「数据源」一节),每段仍是{ code, msg, data }双层信封、形状与线上 1:1------所以双层校验、字段口径、对账这些环节一个都没省,也不需要账号、密钥或外网,更不用起任何服务。- 代码量的取舍 。骨架本体约 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.mjs的baseURL换成真实域名、走能力层取数,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);
}
}
}
eject 用置 null 而不是 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;
}
三个坑值得单独点出:
buildBody先has再set:content-type只在调用方没给时才补,否则会把业务显式指定的头覆盖掉;parseBody降级回原文 :上游偶尔返回text/html错误页(比如网关拦截),此时不能因为JSON.parse失败就把原始信息丢了;mergeConfig跳过undefined:undefined表示「本次不覆盖」,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>
三条硬规则:
- 一图一段 IIFE ------
dispose()/resize()按实例绑定,多图合写会漏重建; - 数据只从
#report-data取------不得在 option 里写数字字面量(数据单一落点); - 序列名用业务可读名,不用接口字段名。
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 |
YYYYMMDD → YYYY-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/36486是String.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 的
pick:code !== "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. 结语
一句话总结 :这份实现的价值不在于「能跑」------让它跑一次很容易;价值在于「长期跑不坏 」。差别不在功能多少,而在每一条容易漂移的地方,都被改造成了一次可执行的检查:能查的不许猜,会漂的不许写两遍。