feat: 新增高铁游与车宿徒步游技能及相关基础设施
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
/* ============================================================
|
||||
* run_new_travel_skill.js —— 「游山玩水」新技能脚手架
|
||||
*
|
||||
* 用途:一条命令产出新 skill 的最小骨架(前后端各一套空壳),之后自己填。
|
||||
*
|
||||
* 用法:
|
||||
* node dev_test_scripts/runners/run_new_travel_skill.js <skill_id> "技能中文名"
|
||||
* 例:node dev_test_scripts/runners/run_new_travel_skill.js japan_7d "日本七天游"
|
||||
*
|
||||
* 产出(两棵树下各建同名目录):
|
||||
* src/server/thought_lab/labs/travel_citytour/skills/<id>/
|
||||
* ├── config.json 聚合页元信息 + 本技能 AI 参数(含注释)
|
||||
* ├── index.js 导出 bindRoutes(router, ctx) 的空壳(含注释与示例路由)
|
||||
* └── data/ 本技能私有数据目录(.gitignore 已排除,不进库)
|
||||
* public/tools/thought_lab/labs/travel_citytour/skills/<id>/
|
||||
* ├── index.html 空页面:含「返回列表」+ 鉴权失败引导面板(可复制片段)
|
||||
* ├── icon.svg 占位图标
|
||||
* └── assets/ 本技能私有静态资源目录
|
||||
*
|
||||
* 刻意不产出(按约定「从 0 开始」):
|
||||
* 渲染器 / 报告样式 / 导出器 / 问答表单 —— 这些由各 skill 自己写或自己复制改。
|
||||
*
|
||||
* 位置遵循项目规则:所有脚本统一放 dev_test_scripts/。
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..')
|
||||
const LAB_ID = 'travel_citytour'
|
||||
const BE_SKILLS = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', LAB_ID, 'skills')
|
||||
const FE_SKILLS = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', LAB_ID, 'skills')
|
||||
|
||||
const die = (msg) => { console.error('✗ ' + msg); process.exit(1) }
|
||||
|
||||
const rawId = String(process.argv[2] || '').trim()
|
||||
const name = String(process.argv[3] || '').trim()
|
||||
|
||||
if (!rawId) die('用法:node dev_test_scripts/runners/run_new_travel_skill.js <skill_id> "技能中文名"')
|
||||
if (/[^A-Za-z0-9_]/.test(rawId)) die('skill_id 只允许字母 / 数字 / 下划线(会作为目录名与 API 路径段):' + rawId)
|
||||
if (!name) die('缺少技能中文名(聚合页卡片标题)')
|
||||
if (!/^[a-z][a-z0-9_]*$/.test(rawId)) die('skill_id 建议小写下划线风格,例如 japan_7d')
|
||||
|
||||
const beDir = path.join(BE_SKILLS, rawId)
|
||||
const feDir = path.join(FE_SKILLS, rawId)
|
||||
|
||||
if (fs.existsSync(beDir) || fs.existsSync(feDir)) {
|
||||
die('同名 skill 已存在,拒绝覆盖:\n ' + beDir + '\n ' + feDir)
|
||||
}
|
||||
|
||||
/* ---------------- 后端 config.json ---------------- */
|
||||
|
||||
const beConfig = {
|
||||
_comment: '技能:' + name + ' —— 本文件是【聚合页与 lab 扫描层唯一读取的文件】,必须保持极简。lab 层只看 id/name/desc/icon/enabled/sort_order,不读任何业务内容。',
|
||||
id_comment: '技能唯一标识,必须与目录名完全一致(不一致时 lab 会标记 broken 并在聚合页置灰)',
|
||||
id: rawId,
|
||||
name_comment: '聚合页卡片标题',
|
||||
name: name,
|
||||
desc_comment: '聚合页卡片一句话简介(tooltip)',
|
||||
desc: '(待填)这套行程打法适用什么场景',
|
||||
icon_comment: '聚合页卡片图标(emoji)。若本目录存在 icon.svg,聚合页优先用 icon.svg',
|
||||
icon: '🧭',
|
||||
sort_order_comment: '聚合页排序,数值越小越靠前',
|
||||
sort_order: 50,
|
||||
enabled_comment: '是否启用(false 时 lab 扫描层直接跳过本 skill)',
|
||||
enabled: false,
|
||||
ui_mode_comment: '弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗',
|
||||
ui_mode: 'debug',
|
||||
ai_comment: '本技能生成链路的 AI 参数。密钥不在这里,由 lab 层 ctx.callLLM 从环境变量读取(红线:skill 不接触密钥)',
|
||||
ai: {
|
||||
model_comment: '纯文本生成默认 qwen-plus-latest(项目统一规则:自动跟随官方最新稳定快照)',
|
||||
model: 'qwen-plus-latest',
|
||||
temperature_comment: '采样温度(0-1)。结构化输出建议 0.2-0.4',
|
||||
temperature: 0.3,
|
||||
max_tokens_comment: '单次最大输出 token。按本技能的行程长度自行评估(天数越多、节点越多,需要越大)',
|
||||
max_tokens: 20000,
|
||||
timeout_ms_comment: '单次生成超时(毫秒)',
|
||||
timeout_ms: 600000
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 后端 index.js ---------------- */
|
||||
|
||||
const beIndex = `// ============================================================
|
||||
// skills/${rawId}/index.js —— 本技能的后端路由(技能私有、自包含)
|
||||
//
|
||||
// 契约(lab 扫描层唯一要求):
|
||||
// module.exports = { bindRoutes(router, ctx) }
|
||||
// - router:lab 层已经 .use(labAuth) 之后再交给我 → 本文件【不写任何鉴权代码】
|
||||
// - ctx :lab 层提供的红线能力(见文末清单),本文件只管业务
|
||||
//
|
||||
// 挂载点:/api/thought_lab/${LAB_ID}/skills/${rawId}/*
|
||||
// 本技能与同 lab 其他技能零耦合:删除本目录即完全卸载。
|
||||
// ============================================================
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const SKILL_DIR = __dirname
|
||||
|
||||
// TODO: 本技能需要的技能包文件清单(读法自定,格式自定;不一定要沿用别家 skill)
|
||||
const SKILL_FILES = {
|
||||
// skill: 'skill.json',
|
||||
// rules: 'rules.md'
|
||||
}
|
||||
|
||||
const loadBundle = () => {
|
||||
const bundle = { id: path.basename(SKILL_DIR), dir: SKILL_DIR, rules: '' }
|
||||
Object.keys(SKILL_FILES).forEach((key) => {
|
||||
const file = path.join(SKILL_DIR, SKILL_FILES[key])
|
||||
if (!fs.existsSync(file)) { bundle[key] = key === 'rules' ? '' : {}; return }
|
||||
const txt = fs.readFileSync(file, 'utf-8')
|
||||
bundle[key] = key === 'rules' ? txt : JSON.parse(txt)
|
||||
})
|
||||
return bundle
|
||||
}
|
||||
|
||||
const bindRoutes = (router, ctx) => {
|
||||
// 本技能元信息:页面双模式依据 + 本技能自己的配置
|
||||
router.get('/meta', (req, res) => {
|
||||
const cfg = ctx.readConfig()
|
||||
return res.json({
|
||||
ok: true,
|
||||
ui_mode: String(cfg.ui_mode || 'debug'),
|
||||
ai_model: String((cfg.ai && cfg.ai.model) || 'qwen-plus-latest'),
|
||||
ai_ready: ctx.aiReady(),
|
||||
skill: { id: '${rawId}', name: '${name}', version: '0.0.1' },
|
||||
// TODO: 按本技能需要补充 qa 定义 / 渲染描述 / 目的地清单等
|
||||
qa: {},
|
||||
report_spec: {},
|
||||
destinations: []
|
||||
})
|
||||
})
|
||||
|
||||
// 生成(鉴权已由 lab 层完成;ctx.genGuard 负责按 skill 单飞 + 全局并发上限 + 按 IP 限流)
|
||||
router.post('/generate', ctx.jsonParser, ctx.genGuard, async (req, res) => {
|
||||
const body = ctx.sanitizeObject(req.body || {})
|
||||
const answers = (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) ? body.answers : {}
|
||||
try {
|
||||
ctx.log('generate start', { answers })
|
||||
const bundle = loadBundle()
|
||||
const cfg = ctx.readConfig()
|
||||
const aiConfig = cfg.ai || {}
|
||||
|
||||
// TODO: 这里写本技能的 prompt 与输出契约。
|
||||
// ctx.callLLM({ messages, model, temperature, maxTokens, timeoutMs })
|
||||
// → { content, finishReason, usage }
|
||||
// ctx.extractJson(content) → 解析出的对象(容忍代码块与前后废话)
|
||||
const messages = [
|
||||
{ role: 'system', content: 'TODO: 本技能的 system prompt' },
|
||||
{ role: 'user', content: JSON.stringify(answers) }
|
||||
]
|
||||
const r = await ctx.callLLM({
|
||||
messages,
|
||||
model: String(aiConfig.model || 'qwen-plus-latest'),
|
||||
temperature: Number(aiConfig.temperature ?? 0.3),
|
||||
maxTokens: Number(aiConfig.max_tokens || 8000),
|
||||
timeoutMs: Number(aiConfig.timeout_ms || 600000)
|
||||
})
|
||||
const payload = ctx.extractJson(r.content)
|
||||
payload.meta = payload.meta || {}
|
||||
payload.meta.skill_id = '${rawId}'
|
||||
payload.meta.generated_at = ctx.nowBeijing() // 红线:北京时间唯一实现
|
||||
ctx.log('generate done', { finish_reason: r.finishReason })
|
||||
return res.json({ ok: true, payload })
|
||||
} catch (e) {
|
||||
const msg = String((e && e.message) || e)
|
||||
ctx.log('generate failed', { error: msg })
|
||||
return res.status(msg === 'dashscope_key_missing' || msg === 'dashscope_host_missing' ? 500 : 502)
|
||||
.json({ ok: false, error: msg })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ctx 可用能力(红线统一实现,不要自己另写一套):
|
||||
// ctx.log(event, data) 日志(自动带 [travel_citytour/<skill_id>] 前缀)
|
||||
// ctx.nowBeijing() 北京时间字符串(唯一实现)
|
||||
// ctx.escapeHtml(s) HTML 转义(唯一实现)
|
||||
// ctx.callLLM(opts) 调模型(唯一持有密钥的地方)
|
||||
// ctx.extractJson(text) 稳健取 JSON
|
||||
// ctx.sanitizeObject(obj) 原型污染防护
|
||||
// ctx.jsonParser 请求体解析中间件
|
||||
// ctx.genGuard 生成并发/限流中间件
|
||||
// ctx.readLimit 只读限流中间件
|
||||
// ctx.readConfig() 读本技能自己的 config.json
|
||||
// ctx.dataDir 本技能私有 data/ 绝对路径
|
||||
// ctx.skillId / ctx.prefix skill_id 与 API 前缀
|
||||
module.exports = { bindRoutes, loadBundle }
|
||||
`
|
||||
|
||||
/* ---------------- 前端 index.html ---------------- */
|
||||
|
||||
const feHtml = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${name} · 游山玩水</title>
|
||||
<style>
|
||||
/* 本页样式自己维护,建议沿用 tc- 前缀,避免与其他 skill 的样式互相污染 */
|
||||
:root { --tc-bg:#f6f7f9; --tc-card:#fff; --tc-line:#e5e7eb; --tc-text:#1f2328; --tc-sub:#6b7280; --tc-accent:#2f6fed; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--tc-bg); color:var(--tc-text); font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; }
|
||||
.tc-page { max-width:1080px; margin:0 auto; padding:20px 16px 72px; }
|
||||
.tc-top { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-bottom:6px; }
|
||||
.tc-top h1 { font-size:20px; margin:0; }
|
||||
.tc-sub { font-size:12px; color:var(--tc-sub); }
|
||||
.tc-back { font-size:12px; color:var(--tc-accent); text-decoration:none; }
|
||||
.tc-meta { font-size:12px; color:var(--tc-sub); background:#fff; border:1px solid var(--tc-line);
|
||||
border-radius:8px; padding:8px 12px; margin:12px 0 16px; }
|
||||
.tc-block { background:var(--tc-card); border:1px solid var(--tc-line); border-radius:10px; padding:16px; margin-bottom:16px; }
|
||||
.tc-block-title { font-size:15px; margin:0 0 4px; }
|
||||
.tc-block-hint { font-size:12px; color:var(--tc-sub); }
|
||||
.tc-note { border-radius:8px; padding:10px 12px; font-size:13px; border:1px solid; margin-bottom:12px; }
|
||||
.tc-note-info { background:#f2f7ff; border-color:#cfe0ff; }
|
||||
.tc-note-danger { background:#fdece8; border-color:#f3c0b6; }
|
||||
.tc-note b { display:block; margin-bottom:2px; }
|
||||
.tc-note ol { margin:6px 0 0; padding-left:20px; }
|
||||
.tc-note ol li { margin-bottom:3px; }
|
||||
.tc-btn { display:inline-block; text-decoration:none; font-size:13px; border:none; cursor:pointer;
|
||||
background:var(--tc-accent); color:#fff; border-radius:8px; padding:9px 18px; font-family:inherit; }
|
||||
.tc-btn-ghost { background:#fff; color:var(--tc-accent); border:1px solid #cfdcff; }
|
||||
.tc-pre { background:#f7f8fa; border:1px solid var(--tc-line); border-radius:8px; padding:10px;
|
||||
font:12px/1.5 Consolas,Monaco,monospace; white-space:pre-wrap; word-break:break-all;
|
||||
max-height:280px; overflow:auto; margin:0; }
|
||||
</style>
|
||||
<!-- 若需要报告渲染,请把渲染器与报告样式复制到本目录后在此引入(本 skill 私有,不引用别家 skill) -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="tc-page">
|
||||
<div class="tc-top">
|
||||
<h1>${name}</h1>
|
||||
<span class="tc-sub">游山玩水 · 单 skill 页</span>
|
||||
<a class="tc-back" href="../../index.html">← 返回技能列表</a>
|
||||
</div>
|
||||
<div class="tc-meta" id="tc_meta">正在读取技能状态…</div>
|
||||
|
||||
<!-- 鉴权失败引导:契约要求每个 skill 页面都要有,否则用户只会看到白屏 401。
|
||||
诊断接口归 lab 层所有(/auth/diag),本 skill 不重复实现。 -->
|
||||
<div class="tc-note tc-note-danger" id="tc_auth_note" style="display:none">
|
||||
<b>缺少导航通行凭证(nav_gate),接口被拒绝</b>
|
||||
本工具的<b>页面</b>允许同源导航进入,但<b>接口</b>只认 nav_gate cookie,所以会出现「页面能打开、接口全是 401」。按下面做一次即可恢复:
|
||||
<ol>
|
||||
<li>从<b>首页九宫格</b>点「游山玩水」卡片进来(必须经过首页,它才会种 cookie)</li>
|
||||
<li>全程用<b>同一个地址</b>:<code>localhost</code> 与 <code>127.0.0.1</code> 的 cookie 不通用</li>
|
||||
<li>nav_gate 是<b>会话级 cookie</b>,关闭浏览器后失效,需要重新从首页进一次</li>
|
||||
</ol>
|
||||
<div style="margin-top:10px">
|
||||
<a class="tc-btn" href="/">重新从首页进入</a>
|
||||
<button class="tc-btn tc-btn-ghost" id="tc_auth_retry">我已重新进入,重试</button>
|
||||
</div>
|
||||
<div id="tc_auth_diag"></div>
|
||||
</div>
|
||||
|
||||
<div class="tc-block">
|
||||
<h2 class="tc-block-title">1 · 填写行程参数</h2>
|
||||
<div class="tc-block-hint" id="tc_hint">TODO: 本技能的问答表单(按本技能的 qa 定义自己渲染)</div>
|
||||
<!-- TODO: 表单 -->
|
||||
</div>
|
||||
|
||||
<div id="tc_result"></div>
|
||||
|
||||
<div class="tc-note tc-note-info">
|
||||
<b>打不开 / 提示未授权?</b>
|
||||
本技能受导航鉴权保护,必须<b>从首页九宫格进入思想实验室、再点本卡片</b>;直接粘贴链接会被拒绝(无同源来源)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict'
|
||||
// 本 skill 的接口前缀(由 lab 层挂载,鉴权已由 lab 层包好)
|
||||
var API = '/api/thought_lab/${LAB_ID}/skills/${rawId}'
|
||||
var LAB_API = '/api/thought_lab/${LAB_ID}'
|
||||
var $ = function (id) { return document.getElementById(id) }
|
||||
|
||||
function esc(s) {
|
||||
return String(s === undefined || s === null ? '' : s)
|
||||
.replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] })
|
||||
}
|
||||
function isUnauthorized(err) { return /unauthorized|401/i.test(String((err && err.message) || '')) }
|
||||
|
||||
// 鉴权失败引导 + 拉取 lab 级公开诊断(这段是契约要求的必备片段,照抄即可)
|
||||
function showAuthNote() {
|
||||
$('tc_auth_note').style.display = ''
|
||||
fetch(LAB_API + '/auth/diag?t=' + Date.now(), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (d) {
|
||||
$('tc_auth_diag').innerHTML =
|
||||
'<div style="font-size:12px;font-weight:600;margin:12px 0 6px;color:#374151">当前请求诊断(公开接口,不含任何 cookie 值)</div>' +
|
||||
'<pre class="tc-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre>'
|
||||
})
|
||||
.catch(function () {})
|
||||
}
|
||||
|
||||
function requestJson(url, options) {
|
||||
return fetch(url + '?t=' + Date.now(), Object.assign({ cache: 'no-store' }, options || {}))
|
||||
.then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null
|
||||
try { j = JSON.parse(t) } catch (e) {}
|
||||
if (!r.ok) {
|
||||
var err = new Error((j && (j.error || j.hint)) || ('HTTP ' + r.status))
|
||||
err.detail = j || t.slice(0, 400)
|
||||
throw err
|
||||
}
|
||||
if (!j) throw new Error('返回不是合法 JSON')
|
||||
return j
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 读本技能元信息(ui_mode / 模型 / 本技能自己的 qa、report_spec…)
|
||||
requestJson(API + '/meta').then(function (r) {
|
||||
$('tc_meta').innerHTML =
|
||||
'ui_mode:<b>' + esc(r.ui_mode) + '</b>' +
|
||||
' · 技能:<b>' + esc(r.skill && r.skill.name) + '</b>' +
|
||||
' · 生成模型:<b>' + esc(r.ai_model) + '</b>' +
|
||||
' · AI 通道:<b>' + (r.ai_ready ? '就绪' : '未配置密钥') + '</b>'
|
||||
$('tc_hint').textContent = '本技能骨架已就绪,接下来按本技能需要自己实现表单与渲染。'
|
||||
}).catch(function (e) {
|
||||
if (isUnauthorized(e)) showAuthNote()
|
||||
$('tc_meta').textContent = '技能元信息加载失败:' + (e && e.message || '')
|
||||
})
|
||||
|
||||
$('tc_auth_retry').addEventListener('click', function () { location.reload() })
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
/* ---------------- 前端 icon.svg 占位 ---------------- */
|
||||
|
||||
const feIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"
|
||||
stroke="#2f6fed" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" role="img"
|
||||
aria-label="${name}">
|
||||
<title>${name}</title>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7.5v9M7.5 12h9" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
/* ---------------- 落盘 ---------------- */
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(beDir, 'data'), { recursive: true })
|
||||
fs.mkdirSync(path.join(feDir, 'assets'), { recursive: true })
|
||||
fs.writeFileSync(path.join(beDir, 'config.json'), JSON.stringify(beConfig, null, 2) + '\n', 'utf-8')
|
||||
fs.writeFileSync(path.join(beDir, 'index.js'), beIndex, 'utf-8')
|
||||
fs.writeFileSync(path.join(feDir, 'index.html'), feHtml, 'utf-8')
|
||||
fs.writeFileSync(path.join(feDir, 'icon.svg'), feIcon, 'utf-8')
|
||||
// data/ 与 assets/ 是空目录:放一个说明文件,避免「空目录进不了 git」导致目录消失
|
||||
fs.writeFileSync(path.join(beDir, 'data', '.gitkeep'), '', 'utf-8')
|
||||
fs.writeFileSync(path.join(feDir, 'assets', '.gitkeep'), '', 'utf-8')
|
||||
} catch (e) {
|
||||
die('写入失败:' + String(e.message || e))
|
||||
}
|
||||
|
||||
console.log('✓ 新技能骨架已生成:' + rawId + '(' + name + ')')
|
||||
console.log('')
|
||||
console.log('后端:' + path.relative(ROOT, beDir))
|
||||
console.log(' config.json ← 聚合页元信息 + 本技能 AI 参数(enabled 默认 false)')
|
||||
console.log(' index.js ← 导出 bindRoutes(router, ctx),业务路由空壳')
|
||||
console.log(' data/ ← 本技能私有数据(.gitignore 已排除,不进库)')
|
||||
console.log('前端:' + path.relative(ROOT, feDir))
|
||||
console.log(' index.html ← 空页面(含返回列表 + 鉴权失败引导片段)')
|
||||
console.log(' icon.svg ← 占位图标')
|
||||
console.log(' assets/ ← 本技能私有静态资源')
|
||||
console.log('')
|
||||
console.log('接下来(按需自己写,脚手架刻意不产出):')
|
||||
console.log(' 1. 写本技能的技能包文件(格式自定),在 index.js 里读进来')
|
||||
console.log(' 2. 写本技能的 prompt 与输出契约(可参考兄弟 skill,但请【复制后再改】而不是引用它)')
|
||||
console.log(' 3. 写本技能的问答表单与结果渲染;要渲染器/导出器就从兄弟 skill 复制一份改')
|
||||
console.log(' 4. 自测:node dev_test_scripts/unit/test_travel_citytour_isolation.js')
|
||||
console.log(' ↑ 含隔离校验:会断言本 skill 没有跨目录引用(引用兄弟 skill 或 lab 业务文件都会失败)')
|
||||
console.log(' 5. 自写本技能的契约测试:照 test_travel_citytour_rail_skill.js 再写一份(每个 skill 一份)')
|
||||
console.log(' 6. 自检:GET /api/thought_lab/' + LAB_ID + '/skills/_health')
|
||||
console.log(' 7. 一切就绪后,把 config.json 的 enabled 改成 true(默认 false 是为了避免半成品进聚合页)')
|
||||
console.log('')
|
||||
console.log('★ 隔离规则(本 lab 的宪法,务必遵守):')
|
||||
console.log(' · 本 skill 只能引用自己目录内的文件;唯一允许向上引用的是 lab 级 assets/vendor/(第三方库)')
|
||||
console.log(' · 禁止引用兄弟 skill 的任何文件(哪怕只是一行代码);要复用就复制过来再改')
|
||||
console.log(' · 鉴权/凭据/北京时间/XSS 转义 不许自己实现,一律用 lab 层注入的 ctx.*')
|
||||
console.log(' · 数据落在本目录的 data/(已被 .gitignore 排除,不会进库)')
|
||||
console.log(' · 每个源文件头部请写清「为什么这个文件在 skill 目录而不是 lab 层」,方便后人理解')
|
||||
Reference in New Issue
Block a user