create-dwy 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/package.json +1 -1
- package/src/cli-help.js +33 -16
- package/src/skill-store.js +4 -4
- package/src/sync-all.js +111 -41
- package/src/sync-codex.js +36 -21
- package/src/sync-cursor.js +2 -1
- package/src/sync-inheritance.js +119 -0
- package/src/sync-opencode.js +1 -1
- package/src/sync-plan.js +137 -50
- package/src/sync.js +20 -23
- package/src/template-source.js +218 -0
- package/templates/ai-tools/AGENTS.md +4 -4
- package/templates/ai-tools/skills//351/200/232/347/224/250/dwy-scope-guard/SKILL.md +91 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import fs from 'fs-extra'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { spinner } from '@clack/prompts'
|
|
8
|
+
import { PACKAGE_ROOT } from './utils.js'
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile)
|
|
11
|
+
const REMOTE = 'https://github.com/danweiyuancircle/dwy-ai-experience.git'
|
|
12
|
+
const TEMPLATE_PATH = ['dwy-cli', 'templates', 'ai-tools']
|
|
13
|
+
const REVISION_FILE = '.dwy-source-revision'
|
|
14
|
+
|
|
15
|
+
async function statEntry(file) {
|
|
16
|
+
return fs.lstat(file).catch(error => {
|
|
17
|
+
if (error.code === 'ENOENT') return null
|
|
18
|
+
throw error
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function requireDirectory(directory) {
|
|
23
|
+
const stat = await fs.lstat(directory)
|
|
24
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`模板目录不能是软链接或文件:${directory}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function validateTemplates(directory) {
|
|
28
|
+
await requireDirectory(directory)
|
|
29
|
+
await validateTree(directory)
|
|
30
|
+
for (const name of ['skills', 'rules', 'hooks']) await requireDirectory(path.join(directory, name))
|
|
31
|
+
const baseline = path.join(directory, 'AGENTS.md')
|
|
32
|
+
if (!(await fs.lstat(baseline)).isFile() || !(await fs.readFile(baseline, 'utf8')).trim()) {
|
|
33
|
+
throw new Error('模板缺少有效 AGENTS.md')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function validateTree(directory) {
|
|
38
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
39
|
+
if (entry.isSymbolicLink()) throw new Error(`模板不能包含软链接:${entry.name}`)
|
|
40
|
+
if (entry.isDirectory()) await validateTree(path.join(directory, entry.name))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function deadLockExists(lockPath) {
|
|
45
|
+
const stat = await statEntry(lockPath)
|
|
46
|
+
if (!stat) return false
|
|
47
|
+
if (!stat.isFile()) throw new Error(`模板锁无效,保留原文件:${lockPath}`)
|
|
48
|
+
const owner = await fs.readFile(lockPath, 'utf8').catch(error => {
|
|
49
|
+
if (error.code === 'ENOENT') return null
|
|
50
|
+
throw error
|
|
51
|
+
})
|
|
52
|
+
if (owner === null) return false
|
|
53
|
+
const pid = Number(owner.trim())
|
|
54
|
+
if (!/^[1-9]\d*\s*$/.test(owner) || !Number.isSafeInteger(pid) || pid > 2147483647) {
|
|
55
|
+
throw new Error(`模板锁无效,保留原文件:${lockPath}`)
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error.code === 'ESRCH') return true
|
|
61
|
+
if (error.code !== 'EPERM') throw error
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`进程 ${pid} 正在更新模板,保留现有锁。`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function acquireLock(lockPath, candidate) {
|
|
67
|
+
while (true) {
|
|
68
|
+
try {
|
|
69
|
+
await fs.link(candidate, lockPath)
|
|
70
|
+
return
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code !== 'EEXIST') throw error
|
|
73
|
+
}
|
|
74
|
+
if (!await deadLockExists(lockPath)) continue
|
|
75
|
+
// 回收也须互斥,避免两个进程删掉对方的新锁;回收中断时按同一协议恢复。
|
|
76
|
+
const recovery = `${lockPath}.recovery`
|
|
77
|
+
await acquireLock(recovery, candidate)
|
|
78
|
+
try {
|
|
79
|
+
if (await deadLockExists(lockPath)) await fs.unlink(lockPath)
|
|
80
|
+
try {
|
|
81
|
+
await fs.link(candidate, lockPath)
|
|
82
|
+
return
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code !== 'EEXIST') throw error
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
await fs.unlink(recovery)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function git(args, cwd) {
|
|
93
|
+
try {
|
|
94
|
+
const { stdout } = await execFileAsync('git', ['-c', 'core.hooksPath=/dev/null', ...args], {
|
|
95
|
+
cwd,
|
|
96
|
+
// 不加载用户安装的 hooks 或过滤器,避免检出远端内容时执行脚本。
|
|
97
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' },
|
|
98
|
+
// 首次检出要获取整套模板,不能沿用轻量版本检查的短超时。
|
|
99
|
+
timeout: ['clone', 'checkout', 'fetch', 'reset'].includes(args[0]) ? 300000 : 15000,
|
|
100
|
+
maxBuffer: 1024 * 1024,
|
|
101
|
+
})
|
|
102
|
+
return stdout.trim()
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(`Git 获取模板失败(${error.code || error.signal})`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function refreshTemplates(root, cache) {
|
|
109
|
+
await fs.ensureDir(root)
|
|
110
|
+
await requireDirectory(root)
|
|
111
|
+
const lockPath = path.join(root, 'template-source.lock')
|
|
112
|
+
const candidate = path.join(root, `.template-source-lock-${randomUUID()}`)
|
|
113
|
+
await fs.writeFile(candidate, `${process.pid}\n`, { flag: 'wx' })
|
|
114
|
+
try {
|
|
115
|
+
await acquireLock(lockPath, candidate)
|
|
116
|
+
} finally {
|
|
117
|
+
await fs.unlink(candidate)
|
|
118
|
+
}
|
|
119
|
+
let staging
|
|
120
|
+
const previous = path.join(root, '.template-source.previous')
|
|
121
|
+
try {
|
|
122
|
+
if (await statEntry(cache)) await requireDirectory(cache)
|
|
123
|
+
if (await statEntry(previous)) {
|
|
124
|
+
await validateTemplates(previous)
|
|
125
|
+
if (await statEntry(cache)) {
|
|
126
|
+
await validateTemplates(cache)
|
|
127
|
+
await fs.remove(previous)
|
|
128
|
+
} else {
|
|
129
|
+
await fs.rename(previous, cache)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const revision = (await git(['ls-remote', '--exit-code', REMOTE, 'refs/heads/master'], root)).split(/\s/)[0]
|
|
133
|
+
if (!/^[a-f\d]{40,64}$/.test(revision)) throw new Error('远端 master 版本无效')
|
|
134
|
+
if (await statEntry(cache)) {
|
|
135
|
+
try {
|
|
136
|
+
await validateTemplates(cache)
|
|
137
|
+
if ((await fs.readFile(path.join(cache, REVISION_FILE), 'utf8')).trim() === revision) return
|
|
138
|
+
} catch {
|
|
139
|
+
// 本地缓存不完整时重新获取,不能只凭版本标记跳过内容校验。
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
staging = await fs.mkdtemp(path.join(root, '.template-source-'))
|
|
143
|
+
const storedRepository = path.join(root, 'template-repository')
|
|
144
|
+
const reuse = !!await statEntry(storedRepository)
|
|
145
|
+
const repository = reuse ? storedRepository : path.join(staging, 'repository')
|
|
146
|
+
if (reuse) {
|
|
147
|
+
await requireDirectory(repository)
|
|
148
|
+
await requireDirectory(path.join(repository, '.git'))
|
|
149
|
+
await git(['fetch', '--depth=1', '--filter=blob:none', REMOTE, 'master'], repository)
|
|
150
|
+
await git(['reset', '--hard', 'FETCH_HEAD'], repository)
|
|
151
|
+
} else {
|
|
152
|
+
await git(['clone', '--depth=1', '--filter=blob:none', '--no-checkout', '--single-branch',
|
|
153
|
+
'--branch', 'master', REMOTE, repository], staging)
|
|
154
|
+
await git(['sparse-checkout', 'set', '--cone', TEMPLATE_PATH.join('/')], repository)
|
|
155
|
+
await git(['checkout', '--force', 'master'], repository)
|
|
156
|
+
}
|
|
157
|
+
let candidate = repository
|
|
158
|
+
for (const segment of TEMPLATE_PATH) {
|
|
159
|
+
candidate = path.join(candidate, segment)
|
|
160
|
+
await requireDirectory(candidate)
|
|
161
|
+
}
|
|
162
|
+
await validateTemplates(candidate)
|
|
163
|
+
const next = path.join(staging, 'templates')
|
|
164
|
+
await fs.copy(candidate, next)
|
|
165
|
+
await fs.writeFile(path.join(next, REVISION_FILE), await git(['rev-parse', 'HEAD'], repository))
|
|
166
|
+
if (!reuse) await fs.rename(repository, storedRepository)
|
|
167
|
+
const hadCache = !!await statEntry(cache)
|
|
168
|
+
if (hadCache) await fs.rename(cache, previous)
|
|
169
|
+
try {
|
|
170
|
+
await fs.rename(next, cache)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (hadCache) await fs.rename(previous, cache)
|
|
173
|
+
throw error
|
|
174
|
+
}
|
|
175
|
+
if (hadCache) await fs.remove(previous)
|
|
176
|
+
} finally {
|
|
177
|
+
try {
|
|
178
|
+
if (staging) await fs.remove(staging)
|
|
179
|
+
} finally {
|
|
180
|
+
await fs.unlink(lockPath)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 从固定公开仓库更新用户模板缓存;只读取已验证的目录,不执行模板脚本。
|
|
187
|
+
* 显式目录优先;DWY_HOME 优先于注入 homeDir,更新失败保留缓存,无有效缓存才用随包模板。
|
|
188
|
+
*/
|
|
189
|
+
export async function resolveTemplateSource({ homeDir = os.homedir() } = {}) {
|
|
190
|
+
const explicitSourceDir = process.env.DWY_TEMPLATE_SOURCE_DIR
|
|
191
|
+
if (explicitSourceDir) {
|
|
192
|
+
if (!await fs.pathExists(explicitSourceDir)) {
|
|
193
|
+
throw new Error(`DWY_TEMPLATE_SOURCE_DIR not found: ${explicitSourceDir}`)
|
|
194
|
+
}
|
|
195
|
+
return explicitSourceDir
|
|
196
|
+
}
|
|
197
|
+
const root = process.env.DWY_HOME || path.join(homeDir, '.dwy')
|
|
198
|
+
const cache = path.join(root, 'template-source')
|
|
199
|
+
const progress = process.stdout.isTTY ? spinner() : null
|
|
200
|
+
progress?.start('正在检查 AI 模板更新,首次获取可能较慢')
|
|
201
|
+
try {
|
|
202
|
+
await refreshTemplates(root, cache)
|
|
203
|
+
progress?.stop('AI 模板已就绪')
|
|
204
|
+
return cache
|
|
205
|
+
} catch (error) {
|
|
206
|
+
progress?.stop('模板更新未完成,正在检查本地来源')
|
|
207
|
+
try {
|
|
208
|
+
await validateTemplates(cache)
|
|
209
|
+
} catch {
|
|
210
|
+
const bundled = path.join(PACKAGE_ROOT, 'templates', 'ai-tools')
|
|
211
|
+
await validateTemplates(bundled)
|
|
212
|
+
console.warn(`模板更新失败(${error.message});无有效缓存,回退随包模板。`)
|
|
213
|
+
return bundled
|
|
214
|
+
}
|
|
215
|
+
console.warn(`模板更新失败(${error.message});继续使用本地缓存。`)
|
|
216
|
+
return cache
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -120,16 +120,16 @@
|
|
|
120
120
|
- 需要用户决策的复杂多步骤流程
|
|
121
121
|
- 用户明确要求详细解释时
|
|
122
122
|
|
|
123
|
-
##
|
|
123
|
+
## 使用新框架或工具前先找配套能力
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
首次使用新的框架、SDK、平台、服务或开发工具时,在操作前必须查找其配套的 Skill、MCP 或 CLI。有适用的就按说明使用,帮助完成设计、实现或验证;没有合适的就按正常方式继续,不强求安装。已核实且仍适用的结果直接复用,普通文案修改或熟悉的小修复不重复检索。
|
|
126
126
|
|
|
127
127
|
### 查找与选择
|
|
128
128
|
|
|
129
|
-
1. 先检查当前已安装、已连接的 Skill、MCP、CLI
|
|
129
|
+
1. 先检查当前已安装、已连接的 Skill、MCP、CLI,以及项目已有工具,寻找与当前框架和任务匹配的能力;匹配时先读使用说明和权限要求。
|
|
130
130
|
2. 再查对应平台、框架或产品的官方文档、官方仓库与工具目录,寻找官方提供或明确推荐的能力;本轮已核实且覆盖当前任务的来源可以复用。
|
|
131
131
|
3. 官方方案不适用或不存在时,查找当前生态中用户基础广、持续维护的开源社区方案。结合维护者身份、近期发布、问题处理、文档和实际使用情况判断成熟度,不能只看 Star 数、下载量或搜索排名。
|
|
132
|
-
4.
|
|
132
|
+
4. 按任务匹配度、风险、接入成本和维护成本决定是否采用。已有工具能完成任务时优先复用;找到合适能力后实际用于当前任务,不能只列出名称。未找到或不适用时简要说明,继续当前任务,不为凑工具而安装或反复扩大检索。
|
|
133
133
|
|
|
134
134
|
Skill 用于工作指导,MCP 用于连接能力,CLI 用于执行操作,按任务选择需要的类型,不要求三类都安装。无法联网或无法核实来源时,明确说明限制,不把未验证的候选当作可信工具执行。
|
|
135
135
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dwy-scope-guard
|
|
3
|
+
description: 用户提出新增功能、顺手扩展、收录想法,或开始规划下一个版本、查看待办想法、担心本期功能过多时使用。适用于持续迭代中的版本范围与想法管理;单纯修复既有行为、执行已确认范围内的任务时不重复评审。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 版本克制与想法收录
|
|
7
|
+
|
|
8
|
+
帮助用户判断想法是否值得现在做,让每个版本先完成可用闭环。敢于指出范围膨胀,把暂缓的想法可靠保存,并在新版本规划时重新提醒。
|
|
9
|
+
|
|
10
|
+
## 工作边界
|
|
11
|
+
|
|
12
|
+
- 针对需求证据和交付取舍提出异议,不评价用户的性格或欲望,也不迎合每个想法。
|
|
13
|
+
- 用户明确限定“只分析、先分析、不改文件”时不开发、不持久化,返回待收录内容;常规想法评估按第 3 节收录。明确要求实施时,给出一次简短范围建议后,在授权范围内执行。不能擅自删除已约定功能;用户听取建议后仍决定继续,不反复劝阻。
|
|
14
|
+
- 安全、数据完整性、输入校验、错误处理、可访问性和必要验证属于可用性,不因控制范围而省略。
|
|
15
|
+
- 提醒发生在本 Skill 被调用、识别到新增功能或新版本规划时。自动触发取决于宿主发现能力;不承诺后台监控、定时或跨会话主动推送,不自行配置通知服务。
|
|
16
|
+
|
|
17
|
+
## 1. 先确认当前版本的负担
|
|
18
|
+
|
|
19
|
+
读取当前产品的版本计划、变更记录、验收记录及相关改动,优先复用已有资料。多包仓库按当前产品或包判断,不把其他包的工作算进来。
|
|
20
|
+
|
|
21
|
+
- 确认上一个已发布或已验收版本、本期目标和已承诺范围;最新提交或标签本身不能证明版本可用。
|
|
22
|
+
- 区分本期已验证、已实现未验证、尚未完成的内容,关注新增用户能力、跨模块依赖、数据迁移和验收成本,不只数功能或代码行数。
|
|
23
|
+
- 缺少基线或进度证据时明确说明,不编造版本差异、工期或完成率。仅在影响决策时问一个关键问题,其余给出带假设的建议。
|
|
24
|
+
|
|
25
|
+
## 2. 每个新想法先给判断
|
|
26
|
+
|
|
27
|
+
结合上下文简短分析:解决谁的什么问题、证据是什么、不做的影响、能否用现有能力满足,以及最小方案引入多少实现、运维和验证负担。无证据的收益标为假设,不机械追问一整套问题。
|
|
28
|
+
|
|
29
|
+
先给结论:**本期做 / 缩小后做 / 放到后续版本 / 先验证需求**,随后说明理由、最小范围和收尾标准。
|
|
30
|
+
|
|
31
|
+
- 新想法支撑本期核心闭环、有实际需求依据且负担可控时,建议本期做。
|
|
32
|
+
- 已有多项未完成或未验收内容、新增独立使用场景、引入较重依赖,或不断以“顺手、以后可能用到、既然做了就一起”扩张时,具体指出对交付的影响,优先建议收尾或拆阶段。
|
|
33
|
+
- 需要拆阶段时,每阶段都要有独立可用结果和可判断的验收条件;后续增强进入清单,不作为本期交付的前置条件。
|
|
34
|
+
- 本期确需增加范围时,说明新增成本,并建议替换或推迟什么;取舍由用户决定。不设任意功能数量上限,不为凑齐固定数量增加需求。
|
|
35
|
+
- 已分析并确认过的同一想法,直接沿用结论;只有范围、证据或依赖发生变化时才重评。
|
|
36
|
+
|
|
37
|
+
## 3. 把想法存到项目里
|
|
38
|
+
|
|
39
|
+
先查项目指定或已有的想法池、backlog、版本路线图,沿用其位置和格式;没有时在项目根创建 `.dwy/ideas.md`。不写进 Skill 安装目录、全局个人记忆或其他项目,不另建重复清单。多产品清单按产品或包分组。
|
|
40
|
+
|
|
41
|
+
用户要求收录,或常规分析产生暂缓想法时,在任务允许写文件的情况下直接记录,不逐条重复确认。明确限定仅分析、纯只读请求或没有写权限时只给待收录内容,明确尚未保存。不得把敏感数据、凭据写入清单,也不因已有外部系统而擅自创建远端任务。
|
|
42
|
+
|
|
43
|
+
记录前按问题和方案查重;已有同一想法时补充证据和决定,不重复新增,不覆盖无关记录。每项保留:
|
|
44
|
+
|
|
45
|
+
- 稳定编号、简短名称、提出日期与来源。
|
|
46
|
+
- 用户问题和需求证据;用户原意与 AI 建议分开,未知项写“待确认”。
|
|
47
|
+
- 最小可用方案、暂缓原因与重评条件。
|
|
48
|
+
- 状态:候选 / 本期已选 / 已交付 / 已放弃;目标版本未定就写“未定”,不自行承诺下版必做。
|
|
49
|
+
|
|
50
|
+
没有既有格式时,可用下列 Markdown 结构,按真实信息填写:
|
|
51
|
+
|
|
52
|
+
```markdown
|
|
53
|
+
# 想法清单
|
|
54
|
+
|
|
55
|
+
## 当前版本
|
|
56
|
+
|
|
57
|
+
- 上一可用版本:待确认
|
|
58
|
+
- 本期目标与范围:待确认
|
|
59
|
+
- 收尾与验收条件:待确认
|
|
60
|
+
|
|
61
|
+
## IDEA-001:想法名称
|
|
62
|
+
|
|
63
|
+
- 提出日期与来源:实际日期;用户原话或需求链接
|
|
64
|
+
- 状态 / 目标版本:候选 / 未定
|
|
65
|
+
- 问题与证据:具体问题;无证据时注明假设
|
|
66
|
+
- 最小方案:最小可用结果;AI 建议单独标明
|
|
67
|
+
- 暂缓原因:当前取舍
|
|
68
|
+
- 重评条件:下一版本规划,或具体反馈、依赖条件满足时
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
已有版本计划时引用它,不重复维护一份当前范围。实际写入后回读相关条目,报告保存位置和编号;写入失败不得声称已收录。仅更新清单不代表功能实现或版本交付。
|
|
72
|
+
|
|
73
|
+
## 4. 新版本规划时重新提醒
|
|
74
|
+
|
|
75
|
+
用户说“开始下个版本、下一版做什么、排版本、还有哪些想法”等时,先读取持久化清单,再提出计划,不依赖聊天记忆。
|
|
76
|
+
|
|
77
|
+
1. 核对上一版实际交付和未完成项;未完成项先明确处置,不默认为已完成或偷偷带入下一版。
|
|
78
|
+
2. 主动提醒候选想法,说明哪些到了重评时机、哪些仍缺证据或依赖;没有到期候选时如实说明。
|
|
79
|
+
3. 结合新反馈和本期目标推荐少量最相关项,解释取舍,避免把整个想法池塞进版本。用户未选定前保持候选状态。
|
|
80
|
+
4. 用户确定范围后,记录本期目标、已选编号、最小范围和收尾标准,更新对应状态及目标版本。用户决定暂缓或撤回本期项时恢复候选并保留原因;同一轮规划不反复提醒已决定的事项。
|
|
81
|
+
5. 只有对应验收证据成立才标为已交付;未验收的实现仍保留未完成说明。用户明确放弃时标为已放弃并保留原因,不删除历史、不再次推荐;用户主动重提时再更新。
|
|
82
|
+
|
|
83
|
+
清单不存在时说明没有可读取的历史想法,不编造遗漏记录。不要只因查询清单就创建空文件。
|
|
84
|
+
|
|
85
|
+
## 输出示例
|
|
86
|
+
|
|
87
|
+
用户:“记账功能还没验收,我还想这版一起加 AI 分析、家庭共享和多币种。”
|
|
88
|
+
|
|
89
|
+
> **建议:先完成本期验收,三个想法放到后续版本。** 基础录入尚未验收,家庭共享会增加权限和同步,多币种会增加换算与数据处理,都会扩大本期验证范围。先走通“录入—保存—查看”及关键失败路径;三个想法分别收录,需求证据先标为待确认。开始规划下一版时,再结合反馈提醒并重新排序。
|
|
90
|
+
|
|
91
|
+
只有实际保存后才补充收录位置与编号;此示例不代表已经写入想法清单。
|