@sendevia/pi-ask-author 1.0.1 → 1.0.3
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/README.md +3 -3
- package/dist/component.d.ts +322 -0
- package/{src/format.ts → dist/format.d.ts} +12 -118
- package/dist/index.d.ts +51 -0
- package/dist/index.js +1 -0
- package/dist/model.d.ts +260 -0
- package/dist/novel-markdown.d.ts +28 -0
- package/dist/sanitize.d.ts +18 -0
- package/dist/schema.d.ts +129 -0
- package/dist/texts.d.ts +494 -0
- package/{src/theme.ts → dist/theme.d.ts} +0 -2
- package/dist/view-rows.d.ts +69 -0
- package/dist/view-summary.d.ts +27 -0
- package/package.json +10 -10
- package/src/component.ts +0 -1219
- package/src/index.ts +0 -419
- package/src/model.ts +0 -748
- package/src/novel-markdown.ts +0 -673
- package/src/sanitize.ts +0 -279
- package/src/schema.ts +0 -108
- package/src/texts.ts +0 -636
- package/src/view-rows.ts +0 -217
- package/src/view-summary.ts +0 -110
- package/tsconfig.json +0 -16
package/src/view-rows.ts
DELETED
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file view-rows.ts
|
|
3
|
-
* @description 纯函数视图排版层:题目菜单行、Tab 导航栏与底部框架 chrome 的统一生成器
|
|
4
|
-
*
|
|
5
|
-
* 核心架构特性:
|
|
6
|
-
* - 全部导出函数为无状态纯函数(无 `this` / 缓存 / IO),输出仅由入参决定,可独立单元测试与跨视图复用
|
|
7
|
-
* - 文案与几何常量一律取自 `texts.ts`,本层不直接书写任何可见字符
|
|
8
|
-
* - 折行与补齐一律经 `visibleWidth` / `wrapTextWithAnsi` / `safeLine` 处理,禁止使用 `string.length` 直接计算
|
|
9
|
-
*
|
|
10
|
-
* 依赖方向:view-rows.ts → format.ts / model.ts / texts.ts / theme.ts;禁止反向导入 `component.ts`
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
14
|
-
import { divider, padToVisibleWidth, pushWrapped, safeLine } from "./format.js";
|
|
15
|
-
import {
|
|
16
|
-
hasQuestionNote,
|
|
17
|
-
type AskAuthorFormModel,
|
|
18
|
-
type MenuItem,
|
|
19
|
-
type NormalizedQuestion,
|
|
20
|
-
type QuestionAnswerState,
|
|
21
|
-
} from "./model.js";
|
|
22
|
-
import { ICONS, LAYOUT_CONFIG, TEXTS } from "./texts.js";
|
|
23
|
-
import type { Theme } from "./theme.js";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* 解析菜单选项的展示标签(作者可见文案兜底入口)
|
|
27
|
-
* @param q - 归一化题目对象
|
|
28
|
-
* @param optionIndex - 0-based 索引(兜底文案按 1-based 展示)
|
|
29
|
-
* @returns 选项自带标签;索引越界获取不到条目时回退 `TEXTS.fallbacks.autoOptionLabel`(形如「选项 3」)
|
|
30
|
-
*/
|
|
31
|
-
export function optionLabelOf(q: NormalizedQuestion, optionIndex: number): string {
|
|
32
|
-
return q.options[optionIndex]?.label ?? TEXTS.fallbacks.autoOptionLabel(optionIndex + 1);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 判定菜单项是否处于已选中/已生效状态
|
|
37
|
-
* @param item - 目标菜单项
|
|
38
|
-
* @param q - 当前题目
|
|
39
|
-
* @param st - 作答状态
|
|
40
|
-
* @returns 多选查询 `selectedIndices` 成员资格,单选比对 `selectedIndex`;`custom_note` 条目在题目补充说明非空时生效;其余条目(含缺失 `optionIndex` 的选项)恒为 false
|
|
41
|
-
*/
|
|
42
|
-
function isItemSelected(item: MenuItem, q: NormalizedQuestion, st: QuestionAnswerState): boolean {
|
|
43
|
-
if (item.type === "option" && item.optionIndex !== undefined) {
|
|
44
|
-
return q.multiSelect ? st.selectedIndices.has(item.optionIndex) : st.selectedIndex === item.optionIndex;
|
|
45
|
-
}
|
|
46
|
-
if (item.type === "custom_note") {
|
|
47
|
-
return hasQuestionNote(st);
|
|
48
|
-
}
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* 生成选项前面的勾选/选中指示符
|
|
54
|
-
* @param theme - 主题样式提供者
|
|
55
|
-
* @param q - 当前题目
|
|
56
|
-
* @param st - 作答状态
|
|
57
|
-
* @param item - 菜单项
|
|
58
|
-
* @returns 带主题样式的指示符:多选已勾选 `success` / 未勾选 `dim`,单选已选中 `accent` / 未选中 `dim`;非选项条目或缺失 `optionIndex` 时返回空串
|
|
59
|
-
*/
|
|
60
|
-
function optionCheckMark(theme: Theme, q: NormalizedQuestion, st: QuestionAnswerState, item: MenuItem): string {
|
|
61
|
-
if (item.type !== "option" || item.optionIndex === undefined) return "";
|
|
62
|
-
if (q.multiSelect) {
|
|
63
|
-
return st.selectedIndices.has(item.optionIndex)
|
|
64
|
-
? theme.fg("success", ICONS.checkedMulti)
|
|
65
|
-
: theme.fg("dim", ICONS.uncheckedMulti);
|
|
66
|
-
}
|
|
67
|
-
return st.selectedIndex === item.optionIndex
|
|
68
|
-
? theme.fg("accent", ICONS.checkedSingle)
|
|
69
|
-
: theme.fg("dim", ICONS.uncheckedSingle);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* 格式化单个菜单条目行
|
|
74
|
-
*
|
|
75
|
-
* 排版不变量:
|
|
76
|
-
* - 首行携带「光标 + 勾选」前缀,后续折行等宽悬挂缩进、左缘与前缀一致;聚焦项全部折行套用 `selectedBg` 与 `bold` 样式至 `targetWidth`
|
|
77
|
-
* - 非聚焦未选中预设选项淡化 `dim`(其余 `text`)且不超限;带专属补充说明的选项追加 `ICONS.note`
|
|
78
|
-
* - 快路径:标签可见宽度未超限时直接复用单行,跳过 `wrapTextWithAnsi` 正则解析
|
|
79
|
-
*
|
|
80
|
-
* @param theme - 主题样式提供者
|
|
81
|
-
* @param item - 目标菜单项
|
|
82
|
-
* @param isFocused - 是否为当前光标聚焦项
|
|
83
|
-
* @param q - 当前题目
|
|
84
|
-
* @param st - 作答状态
|
|
85
|
-
* @param targetWidth - 单行可用列宽(内容区宽度)
|
|
86
|
-
* @returns 终端行数组;标签超长时按悬挂缩进折为多行(至少一行)
|
|
87
|
-
*/
|
|
88
|
-
export function formatMenuItemRow(
|
|
89
|
-
theme: Theme,
|
|
90
|
-
item: MenuItem,
|
|
91
|
-
isFocused: boolean,
|
|
92
|
-
q: NormalizedQuestion,
|
|
93
|
-
st: QuestionAnswerState,
|
|
94
|
-
targetWidth: number,
|
|
95
|
-
): string[] {
|
|
96
|
-
const isSelected = isItemSelected(item, q, st);
|
|
97
|
-
const cursor = isFocused ? theme.fg("accent", theme.bold(`${ICONS.cursor} `)) : " ";
|
|
98
|
-
const checkMark = optionCheckMark(theme, q, st, item);
|
|
99
|
-
const prefix = `${cursor}${checkMark}`;
|
|
100
|
-
const prefixW = visibleWidth(prefix);
|
|
101
|
-
const indent = " ".repeat(prefixW);
|
|
102
|
-
const textWidth = Math.max(8, targetWidth - prefixW);
|
|
103
|
-
|
|
104
|
-
let labelText = item.label;
|
|
105
|
-
if (item.type === "option" && item.optionIndex !== undefined) {
|
|
106
|
-
const optNote = st.optionNotes.get(item.optionIndex);
|
|
107
|
-
if (optNote) {
|
|
108
|
-
labelText = `${item.label} ${ICONS.note}`;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const contentLines = visibleWidth(labelText) <= textWidth ? [labelText] : wrapTextWithAnsi(labelText, textWidth);
|
|
113
|
-
|
|
114
|
-
return contentLines.map((line, idx) => {
|
|
115
|
-
if (isFocused) {
|
|
116
|
-
const row = idx === 0 ? `${prefix}${line}` : `${indent}${line}`;
|
|
117
|
-
return theme.bg("selectedBg", theme.fg("text", theme.bold(padToVisibleWidth(row, targetWidth))));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const isUnselectedOption = item.type === "option" && !isSelected;
|
|
121
|
-
const styledLine = isUnselectedOption ? theme.fg("dim", line) : theme.fg("text", line);
|
|
122
|
-
const row = idx === 0 ? `${prefix}${styledLine}` : `${indent}${styledLine}`;
|
|
123
|
-
return safeLine(row, targetWidth);
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* 渲染完整 Tab 导航栏区块(各题标签 + 提交页标签 + 末尾单横分隔线)
|
|
129
|
-
*
|
|
130
|
-
* 算法不变量:
|
|
131
|
-
* - 题目标签以 `ICONS.checked` / `ICONS.unchecked` 与 `success` / `muted` 配色表达完成度,激活题页恒为
|
|
132
|
-
* `selectedBg` + `accent` 的 `bold` 反白(激活提交页为 `selectedBg` + `success`)
|
|
133
|
-
* - 提交页标签在全部作答完成时用 `success`,否则 `dim`;激活判定为 `currentTab === model.questionCount`
|
|
134
|
-
* - 分段按 `renderWidth` 贪心折行(段间单空格连接),超宽分段独占一行而不被丢弃;末尾恒追加一条
|
|
135
|
-
* `dim` 配色的单横分隔线(`LAYOUT_CONFIG.dividerThin`)
|
|
136
|
-
*
|
|
137
|
-
* @param theme - 主题样式提供者
|
|
138
|
-
* @param model - 问卷表单数据模型(提供题目数、作答完成度)
|
|
139
|
-
* @param currentTab - 激活页索引(`0 ~ questionCount - 1` 为题页,`questionCount` 为总览页)
|
|
140
|
-
* @param renderWidth - 终端可视宽度限制
|
|
141
|
-
* @returns Tab 导航栏终端行数组(至少含末尾分隔线一行;超宽时标签折为多行)
|
|
142
|
-
*/
|
|
143
|
-
export function buildTabLines(
|
|
144
|
-
theme: Theme,
|
|
145
|
-
model: AskAuthorFormModel,
|
|
146
|
-
currentTab: number,
|
|
147
|
-
renderWidth: number,
|
|
148
|
-
): string[] {
|
|
149
|
-
const isSubmitTab = currentTab === model.questionCount;
|
|
150
|
-
const tabLines: string[] = [];
|
|
151
|
-
const tabSegments: string[] = [];
|
|
152
|
-
for (let i = 0; i < model.questionCount; i++) {
|
|
153
|
-
const isActive = i === currentTab;
|
|
154
|
-
const answered = model.isQuestionAnswered(i);
|
|
155
|
-
const icon = answered ? ICONS.checked : ICONS.unchecked;
|
|
156
|
-
const color = answered ? "success" : "muted";
|
|
157
|
-
const text = ` ${icon} ${TEXTS.tabs.questionTab(i + 1)} `;
|
|
158
|
-
tabSegments.push(isActive ? theme.bg("selectedBg", theme.fg("accent", theme.bold(text))) : theme.fg(color, text));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const submitText = ` ${TEXTS.tabs.submitTab} `;
|
|
162
|
-
tabSegments.push(
|
|
163
|
-
isSubmitTab
|
|
164
|
-
? theme.bg("selectedBg", theme.fg("success", theme.bold(submitText)))
|
|
165
|
-
: theme.fg(model.areAllQuestionsAnswered() ? "success" : "dim", submitText),
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
let row = "";
|
|
169
|
-
let rowWidth = 0;
|
|
170
|
-
for (const seg of tabSegments) {
|
|
171
|
-
const segWidth = visibleWidth(seg);
|
|
172
|
-
if (rowWidth > 0 && rowWidth + 1 + segWidth > renderWidth) {
|
|
173
|
-
tabLines.push(safeLine(row, renderWidth));
|
|
174
|
-
row = "";
|
|
175
|
-
rowWidth = 0;
|
|
176
|
-
} else if (rowWidth > 0) {
|
|
177
|
-
row += " ";
|
|
178
|
-
rowWidth += 1;
|
|
179
|
-
}
|
|
180
|
-
row += seg;
|
|
181
|
-
rowWidth += segWidth;
|
|
182
|
-
}
|
|
183
|
-
tabLines.push(safeLine(row, renderWidth));
|
|
184
|
-
tabLines.push(theme.fg("dim", divider(renderWidth)));
|
|
185
|
-
|
|
186
|
-
return tabLines;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* 以 `TEXTS.common.helpSeparator` 连接底部按键提示片段
|
|
191
|
-
* @param parts - 提示片段数组(只读)
|
|
192
|
-
* @returns 连接后的单行文本;空数组返回空串
|
|
193
|
-
*/
|
|
194
|
-
function joinHelpParts(parts: readonly string[]): string {
|
|
195
|
-
return parts.join(TEXTS.common.helpSeparator);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* 原地追加底部按键提示行(统一 `dim` 配色 + 单空格悬挂缩进,超宽时折行)
|
|
200
|
-
* @param lines - 输出行数组(原地追加)
|
|
201
|
-
* @param parts - 按键提示片段(只读;空数组时仍追加一行以保持布局稳定)
|
|
202
|
-
* @param theme - 主题样式提供者
|
|
203
|
-
* @param renderWidth - 终端可视宽度限制(超宽时按单空格悬挂缩进折行)
|
|
204
|
-
*/
|
|
205
|
-
export function pushHelpFooter(lines: string[], parts: readonly string[], theme: Theme, renderWidth: number): void {
|
|
206
|
-
pushWrapped(lines, theme.fg("dim", joinHelpParts(parts)), renderWidth, " ");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* 原地追加一条外层框架水平分隔线(`accent` 配色)
|
|
211
|
-
* @param lines - 输出行数组(原地追加)
|
|
212
|
-
* @param theme - 主题样式提供者
|
|
213
|
-
* @param width - 分隔线可视宽度(`<= 0` 时追加空串,不产出超宽行)
|
|
214
|
-
*/
|
|
215
|
-
export function pushFrameRule(lines: string[], theme: Theme, width: number): void {
|
|
216
|
-
lines.push(theme.fg("accent", divider(width, LAYOUT_CONFIG.dividerThick)));
|
|
217
|
-
}
|
package/src/view-summary.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file view-summary.ts
|
|
3
|
-
* @description 纯函数视图排版层:答卷总览确认页的内容行与底部按键提示构建器
|
|
4
|
-
*
|
|
5
|
-
* 核心架构特性:
|
|
6
|
-
* - 全部函数为无状态纯函数(无 `this` / 缓存 / IO):内容构建器返回未切片的完整内容行数组,提示构建器返回提示片段数组
|
|
7
|
-
* - 多选分支一律经 `model.getSelectedIndices`(升序不变量由模型层集中保证)
|
|
8
|
-
*
|
|
9
|
-
* 依赖方向:view-summary.ts → format.ts / model.ts / texts.ts / theme.ts;禁止反向导入 `component.ts`
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { pushWrapped } from "./format.js";
|
|
13
|
-
import type { AskAuthorFormModel } from "./model.js";
|
|
14
|
-
import { TEXTS } from "./texts.js";
|
|
15
|
-
import type { Theme } from "./theme.js";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* 构建总览页底部按键提示片段(依据题目数量动态增删跳转题目提示)
|
|
19
|
-
* @param model - 问卷表单数据模型(提供题目总数)
|
|
20
|
-
* @returns 包含切换题目 / 滚动 / 提交 / 取消四项,题目数 > 1 时额外插入跳转题目提示
|
|
21
|
-
*/
|
|
22
|
-
export function buildSummaryHelpParts(model: AskAuthorFormModel): string[] {
|
|
23
|
-
return [
|
|
24
|
-
TEXTS.help.switchQuestion,
|
|
25
|
-
...(model.questionCount > 1 ? [TEXTS.help.jumpQuestion] : []),
|
|
26
|
-
TEXTS.help.scrollSummary,
|
|
27
|
-
TEXTS.help.confirmSubmit,
|
|
28
|
-
TEXTS.help.cancel,
|
|
29
|
-
];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 构建未切片的完整答卷总览内容行
|
|
34
|
-
* 逐题输出四段(状态徽章 + 题目标题 / 已选项 / 选项专属批注 / 题目整体批注),题间以空行分隔;显式跳过与仅批注提交的题目同样入列,不静默消失
|
|
35
|
-
* @param model - 问卷表单数据模型(作答状态的唯一权威来源)
|
|
36
|
-
* @param theme - 主题样式提供者
|
|
37
|
-
* @param contentWidth - 总览主体可用列宽(列),超宽行沿各段悬挂前缀折行
|
|
38
|
-
* @returns 完整未切片的内容行数组;无效题目跳过,无有效题目时返回空数组
|
|
39
|
-
*/
|
|
40
|
-
export function buildSubmitSummaryContentLines(
|
|
41
|
-
model: AskAuthorFormModel,
|
|
42
|
-
theme: Theme,
|
|
43
|
-
contentWidth: number,
|
|
44
|
-
): string[] {
|
|
45
|
-
const summaryContentLines: string[] = [];
|
|
46
|
-
|
|
47
|
-
for (let i = 0; i < model.questionCount; i++) {
|
|
48
|
-
const q = model.getQuestion(i);
|
|
49
|
-
if (!q) continue;
|
|
50
|
-
const answered = model.isQuestionAnswered(i);
|
|
51
|
-
const st = model.getState(i);
|
|
52
|
-
const statusBadge = answered
|
|
53
|
-
? theme.fg("success", TEXTS.summary.statusDone)
|
|
54
|
-
: theme.fg("warning", TEXTS.summary.statusPending);
|
|
55
|
-
pushWrapped(
|
|
56
|
-
summaryContentLines,
|
|
57
|
-
`${statusBadge} ${theme.bold(TEXTS.summary.questionPrefix(i + 1, q.title))}`,
|
|
58
|
-
contentWidth,
|
|
59
|
-
" ",
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
const selectedLabels: string[] = [];
|
|
63
|
-
if (q.multiSelect) {
|
|
64
|
-
// 升序展示与 compileAnswers 保持一致(排序不变量由模型 getSelectedIndices 集中保证)
|
|
65
|
-
for (const idx of model.getSelectedIndices(i)) {
|
|
66
|
-
const opt = q.options[idx];
|
|
67
|
-
if (!opt) continue;
|
|
68
|
-
const note = st.optionNotes.get(idx);
|
|
69
|
-
selectedLabels.push(note ? `${opt.label} ${TEXTS.summary.optionNoteBadge}` : opt.label);
|
|
70
|
-
}
|
|
71
|
-
} else if (st.selectedIndex !== undefined) {
|
|
72
|
-
const opt = q.options[st.selectedIndex];
|
|
73
|
-
if (opt) {
|
|
74
|
-
const note = st.optionNotes.get(st.selectedIndex);
|
|
75
|
-
selectedLabels.push(note ? `${opt.label} ${TEXTS.summary.optionNoteBadge}` : opt.label);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const value = selectedLabels.length
|
|
80
|
-
? theme.fg("accent", selectedLabels.join(TEXTS.common.optionSeparator))
|
|
81
|
-
: theme.fg("dim", TEXTS.fallbacks.unselectedOption);
|
|
82
|
-
pushWrapped(summaryContentLines, `${theme.fg("muted", TEXTS.summary.selectedLabel)} ${value}`, contentWidth, " ");
|
|
83
|
-
|
|
84
|
-
// 显示各选项的专属批注
|
|
85
|
-
for (const [optIdx, note] of st.optionNotes.entries()) {
|
|
86
|
-
const opt = q.options[optIdx];
|
|
87
|
-
if (opt && note?.trim()) {
|
|
88
|
-
pushWrapped(
|
|
89
|
-
summaryContentLines,
|
|
90
|
-
`${theme.fg("muted", TEXTS.summary.optionNotePrefix(opt.label))} ${theme.fg("accent", note)}`,
|
|
91
|
-
contentWidth,
|
|
92
|
-
" ",
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// 显示题目整体批注
|
|
98
|
-
if (st.customText) {
|
|
99
|
-
pushWrapped(
|
|
100
|
-
summaryContentLines,
|
|
101
|
-
`${theme.fg("muted", TEXTS.summary.customNoteLabel)} ${theme.fg("accent", st.customText)}`,
|
|
102
|
-
contentWidth,
|
|
103
|
-
" ",
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
summaryContentLines.push("");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return summaryContentLines;
|
|
110
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"allowSyntheticDefaultImports": true,
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"forceConsistentCasingInFileNames": true,
|
|
10
|
-
"noImplicitAny": true,
|
|
11
|
-
"noImplicitOverride": true,
|
|
12
|
-
"noUncheckedIndexedAccess": true,
|
|
13
|
-
"skipLibCheck": true
|
|
14
|
-
},
|
|
15
|
-
"include": ["src"]
|
|
16
|
-
}
|