@nbtca/prompt 1.1.3 → 1.3.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.
@@ -1,7 +1,4 @@
1
- /**
2
- * 核心URL配置
3
- * 集中管理所有外部链接
4
- */
1
+ /** Core URL and application constants. */
5
2
  import { readFileSync } from 'fs';
6
3
  import { fileURLToPath } from 'url';
7
4
  import { dirname, join } from 'path';
@@ -25,6 +22,8 @@ export const URLS = {
25
22
  repair: 'https://nbtca.space/repair',
26
23
  calendar: 'https://ical.nbtca.space',
27
24
  email: 'contact@nbtca.space',
25
+ cloud: 'https://cloud.nbtca.space',
26
+ mirror: 'https://i.nbtca.space',
28
27
  };
29
28
  export const GITHUB_REPO = {
30
29
  owner: 'nbtca',
@@ -34,7 +33,7 @@ export const GITHUB_REPO = {
34
33
  export const APP_INFO = {
35
34
  name: 'Prompt',
36
35
  version: readPackageVersion(),
37
- description: '浙大宁波理工学院计算机协会',
36
+ description: 'NingboTech Computer Association',
38
37
  author: 'm1ngsama <contact@m1ng.space>',
39
38
  license: 'MIT',
40
39
  repository: 'https://github.com/nbtca/prompt'
package/dist/core/menu.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { select, isCancel, outro } from '@clack/prompts';
5
5
  import chalk from 'chalk';
6
- import { showCalendar } from '../features/calendar.js';
6
+ import { showCalendarMenu } from '../features/calendar.js';
7
7
  import { showDocsMenu } from '../features/docs.js';
8
8
  import { showServiceStatus } from '../features/status.js';
9
9
  import { showLinksMenu } from '../features/links.js';
@@ -36,7 +36,7 @@ export async function showMainMenu() {
36
36
  export async function runMenuAction(action) {
37
37
  switch (action) {
38
38
  case 'events':
39
- await showCalendar();
39
+ await showCalendarMenu();
40
40
  break;
41
41
  case 'docs':
42
42
  await showDocsMenu();
@@ -0,0 +1,22 @@
1
+ import chalk from 'chalk';
2
+ export const c = {
3
+ brand: (s) => chalk.hex('#0ea5e9')(s),
4
+ accent: (s) => chalk.cyan(s),
5
+ success: (s) => chalk.green(s),
6
+ error: (s) => chalk.red(s),
7
+ warn: (s) => chalk.yellow(s),
8
+ heading: (s) => chalk.bold.white(s),
9
+ muted: (s) => chalk.dim(s),
10
+ subtle: (s) => chalk.gray(s),
11
+ label: (s) => chalk.bold.cyan(s),
12
+ url: (s) => chalk.dim.underline(s),
13
+ version: (s) => chalk.dim(s),
14
+ latency: (ms) => {
15
+ const s = `${ms}ms`;
16
+ if (ms < 200)
17
+ return chalk.green(s);
18
+ if (ms < 1000)
19
+ return chalk.yellow(s);
20
+ return chalk.red(s);
21
+ },
22
+ };
@@ -1,12 +1,8 @@
1
- /**
2
- * Calendar module
3
- * Fetches and renders upcoming events with Unicode box table.
4
- * Data layer powered by @nbtca/nbtcal.
5
- */
6
1
  import { loadCalendar, FeedFetchError, FeedParseError } from '@nbtca/nbtcal';
7
2
  import chalk from 'chalk';
8
3
  import { select, isCancel } from '@clack/prompts';
9
4
  import { info, createSpinner } from '../core/ui.js';
5
+ import { c } from '../core/theme.js';
10
6
  import { pickIcon } from '../core/icons.js';
11
7
  import { padEndV, truncate } from '../core/text.js';
12
8
  import { t } from '../i18n/index.js';
@@ -26,9 +22,6 @@ function formatTime(date) {
26
22
  const minutes = String(date.getMinutes()).padStart(2, '0');
27
23
  return `${hours}:${minutes}`;
28
24
  }
29
- /**
30
- * Load the calendar, wrapping errors with a localized message.
31
- */
32
25
  async function loadCalendarOrThrow() {
33
26
  try {
34
27
  return await loadCalendar({ timeoutMs: 15000 });
@@ -40,9 +33,6 @@ async function loadCalendarOrThrow() {
40
33
  throw new Error(`${t().calendar.error}: ${detail}`);
41
34
  }
42
35
  }
43
- /**
44
- * Map a nbtcal CalendarEvent to prompt's Event type.
45
- */
46
36
  export function toDisplayEvent(e) {
47
37
  const trans = t();
48
38
  return {
@@ -54,15 +44,9 @@ export function toDisplayEvent(e) {
54
44
  startDate: e.start,
55
45
  };
56
46
  }
57
- /**
58
- * Fetch upcoming events (next 30 days), including recurring occurrences.
59
- */
60
47
  export async function fetchEvents() {
61
48
  return (await loadCalendarOrThrow()).upcoming({ days: 30 }).map(toDisplayEvent);
62
49
  }
63
- /**
64
- * Fetch trailing-year heatmap buckets.
65
- */
66
50
  export async function fetchHeatmapBuckets() {
67
51
  const now = new Date();
68
52
  const start = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
@@ -78,76 +62,139 @@ export function serializeEvents(events) {
78
62
  startDateISO: event.startDate.toISOString(),
79
63
  }));
80
64
  }
81
- /**
82
- * Render events as a Unicode box-drawing table
83
- */
84
65
  export function renderEventsTable(events, options) {
85
66
  const trans = t();
86
- const color = options?.color !== false;
67
+ const useColor = options?.color !== false;
87
68
  if (events.length === 0)
88
- return trans.calendar.noEvents;
89
- const dateWidth = 16;
90
- const titleWidth = 30;
91
- const locationWidth = 16;
92
- const h = pickIcon('─', '-');
93
- const v = pickIcon('│', '|');
94
- const topLeft = pickIcon('┌', '+');
95
- const topMid = pickIcon('┬', '+');
96
- const topRight = pickIcon('┐', '+');
97
- const midLeft = pickIcon('├', '+');
98
- const midMid = pickIcon('┼', '+');
99
- const midRight = pickIcon('┤', '+');
100
- const bottomLeft = pickIcon('└', '+');
101
- const bottomMid = pickIcon('┴', '+');
102
- const bottomRight = pickIcon('┘', '+');
103
- const top = `${topLeft}${h.repeat(dateWidth + 2)}${topMid}${h.repeat(titleWidth + 2)}${topMid}${h.repeat(locationWidth + 2)}${topRight}`;
104
- const divider = `${midLeft}${h.repeat(dateWidth + 2)}${midMid}${h.repeat(titleWidth + 2)}${midMid}${h.repeat(locationWidth + 2)}${midRight}`;
105
- const bottom = `${bottomLeft}${h.repeat(dateWidth + 2)}${bottomMid}${h.repeat(titleWidth + 2)}${bottomMid}${h.repeat(locationWidth + 2)}${bottomRight}`;
106
- const headerRow = `${v} ${padEndV(trans.calendar.dateTime, dateWidth)} ${v} ${padEndV(trans.calendar.eventName, titleWidth)} ${v} ${padEndV(trans.calendar.location, locationWidth)} ${v}`;
107
- // Formatters are identity functions when color is off — one loop, no duplication
69
+ return ` ${trans.calendar.noEvents}`;
108
70
  const id = (s) => s;
109
- const dim = color ? chalk.dim : id;
110
- const bold = color ? chalk.bold : id;
111
- const fmtDate = color ? chalk.cyan : id;
112
- const fmtTitle = color ? chalk.white : id;
113
- const fmtLoc = color ? chalk.gray : id;
114
- const lines = [dim(top), bold(headerRow), dim(divider)];
71
+ const applyDim = useColor ? chalk.dim : id;
72
+ const applyCyan = useColor ? chalk.cyan : id;
73
+ const applyBold = useColor ? chalk.bold : id;
74
+ const applyGray = useColor ? chalk.gray : id;
75
+ // dateWidth must fit YYYY-MM-DD HH:MM (16 chars) for cross-year events
76
+ const dateWidth = 16;
77
+ const titleWidth = 32;
78
+ const locWidth = 14;
79
+ const sep = pickIcon('─', '-');
80
+ const headerDate = padEndV(applyDim(trans.calendar.dateTime), dateWidth);
81
+ const headerTitle = padEndV(applyDim(trans.calendar.eventName), titleWidth);
82
+ const headerLoc = applyDim(trans.calendar.location);
83
+ // divider covers exactly: dateWidth + 2-char sep + titleWidth + 2-char sep + locWidth
84
+ const divider = applyDim(sep.repeat(dateWidth + 2 + titleWidth + 2 + locWidth));
85
+ const lines = [
86
+ ` ${headerDate} ${headerTitle} ${headerLoc}`,
87
+ ` ${divider}`,
88
+ ];
115
89
  for (const event of events) {
116
- const dateTime = `${event.date} ${event.time}`;
117
- const title = truncate(event.title, titleWidth);
118
- const location = truncate(event.location, locationWidth);
119
- lines.push(`${v} ${fmtDate(padEndV(dateTime, dateWidth))} ${v} ${fmtTitle(padEndV(title, titleWidth))} ${v} ${fmtLoc(padEndV(location, locationWidth))} ${v}`);
90
+ const dateTime = event.time ? `${event.date} ${event.time}` : event.date;
91
+ const dateCol = padEndV(applyCyan(dateTime), dateWidth);
92
+ const titleCol = padEndV(applyBold(truncate(event.title, titleWidth)), titleWidth);
93
+ const locCol = applyGray(truncate(event.location, locWidth));
94
+ lines.push(` ${dateCol} ${titleCol} ${locCol}`);
120
95
  }
121
- lines.push(dim(bottom));
122
96
  return lines.join('\n');
123
97
  }
124
- function displayEvents(events) {
125
- if (events.length === 0) {
126
- info(t().calendar.noEvents);
127
- return;
128
- }
129
- console.log();
130
- console.log(renderEventsTable(events, { color: true }));
131
- console.log(chalk.dim(` ${pickIcon('📅', '[ical]')} ${t().calendar.subscribeHint}: ${URLS.calendar}`));
132
- console.log();
98
+ function renderSubscribeHint() {
99
+ const icon = pickIcon('◆', '*');
100
+ console.log(c.muted(` ${icon} ${t().calendar.subscribeHint}: ${URLS.calendar}`));
133
101
  }
134
102
  async function showEventDetail(event) {
135
103
  const trans = t();
136
104
  console.log();
137
105
  console.log(chalk.bold.cyan(` ${event.title}`));
138
- console.log(chalk.dim(` ${event.date} ${event.time} ${pickIcon('·', '|')} ${event.location}`));
106
+ console.log(c.muted(` ${event.date}${event.time ? ' ' + event.time : ''} ${pickIcon('·', '|')} ${event.location}`));
139
107
  if (event.description) {
140
108
  console.log();
141
- const lines = event.description.trim().split('\n');
142
- for (const line of lines) {
143
- console.log(chalk.white(` ${line}`));
109
+ for (const line of event.description.trim().split('\n')) {
110
+ console.log(` ${line}`);
144
111
  }
145
112
  }
146
113
  else {
147
- console.log(chalk.dim(` ${trans.calendar.noDescription}`));
114
+ console.log(c.muted(` ${trans.calendar.noDescription}`));
148
115
  }
149
116
  console.log();
150
117
  }
118
+ /** Startup preview: auto-loads and displays upcoming events, then returns. */
119
+ export async function showEventsPreview() {
120
+ const trans = t();
121
+ const s = createSpinner(trans.calendar.loading);
122
+ try {
123
+ const cal = await loadCalendarOrThrow();
124
+ const events = cal.upcoming({ days: 30 }).map(toDisplayEvent);
125
+ if (events.length === 0) {
126
+ s.stop(trans.calendar.noEvents);
127
+ }
128
+ else {
129
+ s.stop(`${events.length} ${trans.calendar.eventsFound}`);
130
+ }
131
+ console.log();
132
+ console.log(renderEventsTable(events.slice(0, 5), { color: !!process.stdout.isTTY }));
133
+ console.log();
134
+ if (events.length > 0)
135
+ renderSubscribeHint();
136
+ console.log();
137
+ }
138
+ catch {
139
+ s.error(trans.calendar.error);
140
+ console.log();
141
+ }
142
+ }
143
+ /** Submenu: choose between upcoming and past events. */
144
+ export async function showCalendarMenu() {
145
+ const trans = t();
146
+ const choice = await select({
147
+ message: trans.menu.chooseAction,
148
+ options: [
149
+ { value: 'upcoming', label: trans.menu.events, hint: trans.menu.eventsDesc },
150
+ { value: 'past', label: trans.calendar.pastEvents, hint: trans.calendar.pastEventsDesc },
151
+ { value: '__back__', label: c.muted(trans.common.back) },
152
+ ],
153
+ });
154
+ if (isCancel(choice) || choice === '__back__')
155
+ return;
156
+ if (choice === 'upcoming')
157
+ await showCalendar();
158
+ else if (choice === 'past')
159
+ await showPastEvents();
160
+ }
161
+ /** Past events: shows historical events from the last 30 days with detail selection. */
162
+ export async function showPastEvents() {
163
+ const trans = t();
164
+ const s = createSpinner(trans.calendar.pastLoading);
165
+ try {
166
+ const cal = await loadCalendarOrThrow();
167
+ const events = cal.past({ days: 30 }).reverse().map(toDisplayEvent);
168
+ s.stop(`${events.length} ${trans.calendar.eventsFound}`);
169
+ console.log();
170
+ console.log(renderEventsTable(events, { color: true }));
171
+ console.log();
172
+ if (events.length === 0) {
173
+ info(trans.calendar.noPastEvents);
174
+ return;
175
+ }
176
+ const options = [
177
+ ...events.map((e, i) => ({
178
+ value: String(i),
179
+ label: `${e.date}${e.time ? ' ' + e.time : ''} ${e.title}`,
180
+ hint: e.location,
181
+ })),
182
+ { value: '__back__', label: c.muted(trans.common.back) },
183
+ ];
184
+ const selected = await select({ message: trans.calendar.viewPastDetail, options });
185
+ if (!isCancel(selected) && selected !== '__back__') {
186
+ const event = events[Number.parseInt(selected, 10)];
187
+ if (event)
188
+ await showEventDetail(event);
189
+ }
190
+ }
191
+ catch {
192
+ s.error(trans.calendar.error);
193
+ console.log(c.muted(' ' + trans.calendar.errorHint));
194
+ console.log();
195
+ }
196
+ }
197
+ /** Full interactive calendar: heatmap + event list + detail selection. */
151
198
  export async function showCalendar() {
152
199
  const trans = t();
153
200
  const s = createSpinner(trans.calendar.loading);
@@ -155,7 +202,6 @@ export async function showCalendar() {
155
202
  const cal = await loadCalendarOrThrow();
156
203
  const events = cal.upcoming({ days: 30 }).map(toDisplayEvent);
157
204
  s.stop(`${events.length} ${trans.calendar.eventsFound}`);
158
- // Render heatmap header
159
205
  const now = new Date();
160
206
  const heatmapBuckets = cal.heatmap({
161
207
  start: new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000),
@@ -164,31 +210,33 @@ export async function showCalendar() {
164
210
  });
165
211
  console.log();
166
212
  console.log(renderHeatmap(heatmapBuckets, now, { color: true }));
167
- displayEvents(events);
168
- if (events.length > 0) {
169
- const options = [
170
- ...events.map((e, i) => ({
171
- value: String(i),
172
- label: `${e.date} ${e.time} ${e.title}`,
173
- hint: e.location,
174
- })),
175
- { value: '__back__', label: chalk.dim(trans.common.back) },
176
- ];
177
- const selected = await select({
178
- message: trans.calendar.viewDetail,
179
- options,
180
- });
181
- if (!isCancel(selected) && selected !== '__back__') {
182
- const idx = Number.parseInt(selected, 10);
183
- const event = events[idx];
184
- if (event)
185
- await showEventDetail(event);
186
- }
213
+ console.log();
214
+ console.log(renderEventsTable(events, { color: true }));
215
+ console.log();
216
+ renderSubscribeHint();
217
+ console.log();
218
+ if (events.length === 0) {
219
+ info(trans.calendar.noEvents);
220
+ return;
221
+ }
222
+ const options = [
223
+ ...events.map((e, i) => ({
224
+ value: String(i),
225
+ label: `${e.date}${e.time ? ' ' + e.time : ''} ${e.title}`,
226
+ hint: e.location,
227
+ })),
228
+ { value: '__back__', label: c.muted(trans.common.back) },
229
+ ];
230
+ const selected = await select({ message: trans.calendar.viewDetail, options });
231
+ if (!isCancel(selected) && selected !== '__back__') {
232
+ const event = events[Number.parseInt(selected, 10)];
233
+ if (event)
234
+ await showEventDetail(event);
187
235
  }
188
236
  }
189
237
  catch {
190
238
  s.error(trans.calendar.error);
191
- console.log(chalk.gray(' ' + trans.calendar.errorHint));
239
+ console.log(c.muted(' ' + trans.calendar.errorHint));
192
240
  console.log();
193
241
  }
194
242
  }
@@ -9,7 +9,7 @@ import { spawn, execFileSync } from 'child_process';
9
9
  import { URLS } from '../config/data.js';
10
10
  import { t, fmt } from '../i18n/index.js';
11
11
  import { setVimKeysActive } from '../core/vim-keys.js';
12
- import { createDocsClient, DocsFetchError } from '@nbtca/docs';
12
+ import { createDocsClient } from '@nbtca/docs';
13
13
  function detectTerminalType() {
14
14
  const term = (process.env['TERM'] || '').toLowerCase();
15
15
  const termProgram = (process.env['TERM_PROGRAM'] || '').toLowerCase();
@@ -57,7 +57,6 @@ function ensureMarkedConfigured() {
57
57
  }
58
58
  // ─── marked-terminal renderer ─────────────────────────────────────────────────
59
59
  function getRendererOptions(type) {
60
- // Cap at 80 columns — optimal prose reading width regardless of terminal size
61
60
  const width = Math.min(process.stdout.columns || 80, 80);
62
61
  const unicodeTableChars = {
63
62
  top: '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
@@ -75,23 +74,16 @@ function getRendererOptions(type) {
75
74
  width,
76
75
  emoji: true,
77
76
  unescape: true,
78
- // Heading hierarchy: h1 cyan, h2+ white bold
79
77
  firstHeading: chalk.bold.cyan,
80
78
  heading: chalk.bold.white,
81
- // Inline code: bright yellow, distinct from prose
82
79
  codespan: chalk.yellowBright,
83
- // Block code: yellow (marked-terminal applies per-line)
84
80
  code: chalk.yellow,
85
- // Blockquotes: italic gray, visually recessed
86
81
  blockquote: chalk.italic.gray,
87
- // Prose emphasis
88
82
  strong: chalk.bold,
89
83
  em: chalk.italic,
90
84
  del: chalk.dim.strikethrough,
91
- // Links: cyan underline
92
85
  link: chalk.cyan,
93
86
  href: chalk.cyan.underline,
94
- // Tables with Unicode borders (fallback to ASCII on basic terminals)
95
87
  tableOptions: {
96
88
  chars: type === 'basic' ? asciiTableChars : unicodeTableChars
97
89
  }
@@ -101,17 +93,6 @@ const RENDER_CACHE_TTL_MS = 10 * 60 * 1000;
101
93
  const RENDER_CACHE_MAX = 50;
102
94
  const renderCache = new Map();
103
95
  let docsClient = createDocsClient();
104
- function getDocCategories() {
105
- const trans = t();
106
- return [
107
- { name: trans.docs.categoryTutorial, path: 'tutorial' },
108
- { name: trans.docs.categoryRepairLogs, path: '维修日' },
109
- { name: trans.docs.categoryEvents, path: '相关活动举办' },
110
- { name: trans.docs.categoryProcess, path: 'process' },
111
- { name: trans.docs.categoryRepair, path: 'repair' },
112
- { name: trans.docs.categoryArchived, path: 'archived' },
113
- ];
114
- }
115
96
  function getFreshRender(key) {
116
97
  const entry = renderCache.get(key);
117
98
  return entry && entry.expiresAt > Date.now() ? entry.value : null;
@@ -131,18 +112,6 @@ export function clearDocsCache() {
131
112
  docsClient.clear();
132
113
  renderCache.clear();
133
114
  }
134
- async function fetchDirectory(path = '') {
135
- try {
136
- return await docsClient.listDir(path);
137
- }
138
- catch (err) {
139
- const trans = t();
140
- const msg = err instanceof DocsFetchError
141
- ? (err.status === 403 ? `${trans.docs.githubForbidden}\n${trans.docs.githubTokenHint}` : `HTTP ${err.status}`)
142
- : String(err);
143
- throw new Error(fmt(trans.docs.fetchDirFailed, { error: msg }));
144
- }
145
- }
146
115
  async function fetchFileContent(path) {
147
116
  try {
148
117
  return await docsClient.getFile(path);
@@ -167,14 +136,12 @@ function cleanMarkdownContent(content, type = getTerminalType()) {
167
136
  c = c.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
168
137
  c = c.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
169
138
  // 3. VitePress containers → blockquote with icon
170
- // ::: warning Title\ncontent\n:::
171
139
  c = c.replace(/^:::\s*(info|tip|warning|danger|details)\s*(.*?)\n([\s\S]*?)^:::\s*$/gm, (_m, type, title, body) => {
172
140
  const label = (title.trim() || type.charAt(0).toUpperCase() + type.slice(1));
173
141
  const icon = pickIcon(CONTAINER_ICONS_UNICODE[type] ?? '', CONTAINER_ICONS_ASCII[type] ?? '');
174
142
  const quoted = body.trimEnd().split('\n').map(l => `> ${l}`).join('\n');
175
143
  return `> ${icon} **${label}**\n>\n${quoted}\n`;
176
144
  });
177
- // Remaining bare ::: markers
178
145
  c = c.replace(/^:::\s*\w*.*$/gm, '');
179
146
  // 4. GitHub / GitLab callout alerts (> [!NOTE])
180
147
  c = c.replace(/^>\s*\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/gim, (_, type) => `> **${type.charAt(0) + type.slice(1).toLowerCase()}:**`);
@@ -200,32 +167,111 @@ function cleanMarkdownContent(content, type = getTerminalType()) {
200
167
  return c.trim();
201
168
  }
202
169
  function extractDocTitle(rawContent, cleanedContent) {
203
- // 1. Try YAML frontmatter title: field (before it was stripped)
204
170
  const fmMatch = rawContent.match(/^---\n[\s\S]*?\n---/m);
205
171
  if (fmMatch) {
206
172
  const titleMatch = fmMatch[0].match(/^title:\s*['"]?(.+?)['"]?\s*$/m);
207
173
  if (titleMatch?.[1])
208
174
  return titleMatch[1].trim();
209
175
  }
210
- // 2. Fallback to first # H1 heading in cleaned content
211
176
  const h1Match = cleanedContent.match(/^#\s+(.+)$/m);
212
177
  return h1Match?.[1]?.trim() ?? null;
213
178
  }
214
179
  /** Approximate reading time: ~200 words/min for technical Chinese/English prose. */
215
180
  function estimateReadTime(text) {
216
- const cjkChars = (text.match(/[\u3400-\u9fff]/g) || []).length;
217
- const nonCjk = text.replace(/[\u3400-\u9fff]/g, ' ');
181
+ const cjkChars = (text.match(/[㐀-鿿]/g) || []).length;
182
+ const nonCjk = text.replace(/[㐀-鿿]/g, ' ');
218
183
  const words = nonCjk.trim().split(/\s+/).filter(Boolean).length;
219
- // Rough equivalence: 2 CJK chars ~= 1 "word" unit
220
184
  const units = words + cjkChars / 2;
221
185
  const mins = Math.max(1, Math.ceil(units / 220));
222
186
  return mins === 1 ? '~1 min' : `~${mins} min`;
223
187
  }
224
- // ─── Pager layer ──────────────────────────────────────────────────────────────
188
+ /** Extract h2/h3 headings for TOC display (skips the h1 title). */
189
+ function extractTOC(content) {
190
+ const lines = content.split('\n').filter(l => /^#{2,3}\s/.test(l));
191
+ return lines.map(l => {
192
+ const m = l.match(/^(#+)/);
193
+ const level = m?.[1]?.length ?? 2;
194
+ const text = l.replace(/^#+\s+/, '').trim();
195
+ return (level === 3 ? ' ' : '') + text;
196
+ });
197
+ }
198
+ /** True if the markdown source contains a table (pipe-delimited with separator row). */
199
+ function hasMarkdownTable(content) {
200
+ return /^\|.+\|/m.test(content) && /^\|[-: |]+\|/m.test(content);
201
+ }
202
+ // ─── Document tree ────────────────────────────────────────────────────────────
203
+ const TOP_SECTION_ORDER = ['tutorial', 'process', 'repair', 'archived'];
204
+ const TOP_SECTION_SKIP = new Set(['docs', 'index.md', 'README.md']);
225
205
  /**
226
- * Display markdown via `glow` (Charmbracelet) if available — best-in-class
227
- * terminal markdown rendering with built-in pager and mouse support.
206
+ * Convert a kebab-case filename to a display-friendly title.
207
+ * Preserves Chinese characters and date prefixes.
228
208
  */
209
+ function cleanFileName(name) {
210
+ const base = name.replace(/\.md$/, '');
211
+ if (/^[\d.]/.test(base))
212
+ return base;
213
+ return base
214
+ .replace(/[-_]/g, ' ')
215
+ .replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
216
+ }
217
+ /** Group flat DocItem list into top-level sections. */
218
+ function buildSections(all) {
219
+ const trans = t();
220
+ const labelMap = {
221
+ tutorial: trans.docs.categoryTutorial,
222
+ process: trans.docs.categoryProcess,
223
+ repair: trans.docs.categoryRepair,
224
+ archived: trans.docs.categoryArchived,
225
+ };
226
+ const groups = new Map();
227
+ for (const item of all) {
228
+ const parts = item.path.split('/');
229
+ if (parts.length < 2)
230
+ continue;
231
+ const top = parts[0];
232
+ if (TOP_SECTION_SKIP.has(top))
233
+ continue;
234
+ if (!TOP_SECTION_ORDER.includes(top))
235
+ continue;
236
+ if (!groups.has(top))
237
+ groups.set(top, []);
238
+ groups.get(top).push(item);
239
+ }
240
+ return TOP_SECTION_ORDER
241
+ .filter(k => groups.has(k))
242
+ .map(k => ({
243
+ key: k,
244
+ label: labelMap[k] ?? k,
245
+ count: groups.get(k).length,
246
+ files: groups.get(k),
247
+ }));
248
+ }
249
+ /** Group archived files by their second path component (year / manual / etc.). */
250
+ function getArchivedGroups(files) {
251
+ const groups = new Map();
252
+ for (const item of files) {
253
+ const group = item.path.split('/')[1] ?? 'other';
254
+ if (!groups.has(group))
255
+ groups.set(group, []);
256
+ groups.get(group).push(item);
257
+ }
258
+ return groups;
259
+ }
260
+ async function loadSections() {
261
+ const trans = t();
262
+ const s = createSpinner(trans.docs.loading);
263
+ try {
264
+ const all = await docsClient.listAll();
265
+ const sections = buildSections(all);
266
+ s.stop();
267
+ return sections;
268
+ }
269
+ catch {
270
+ s.error(trans.docs.loadError);
271
+ return null;
272
+ }
273
+ }
274
+ // ─── Pager layer ──────────────────────────────────────────────────────────────
229
275
  async function displayWithGlow(cleanedMarkdown) {
230
276
  const cols = String(Math.min(process.stdout.columns || 80, 80));
231
277
  return new Promise(resolve => {
@@ -235,33 +281,34 @@ async function displayWithGlow(cleanedMarkdown) {
235
281
  child.stdin.write(cleanedMarkdown, 'utf-8');
236
282
  child.stdin.end();
237
283
  child.on('close', resolve);
238
- child.on('error', resolve); // glow vanished mid-run — caller handles fallback
284
+ child.on('error', resolve);
239
285
  });
240
286
  }
241
- /**
242
- * Display rendered markdown via `less` with a structured document frame.
243
- * Flags:
244
- * -R pass raw ANSI codes through
245
- * -F exit immediately if content fits on one screen
246
- * -X don't clear the screen on exit
247
- * -i case-insensitive search (/ to search)
248
- * -j4 place search hits 4 lines from the top (less jarring)
249
- */
250
- async function displayWithLess(rendered, title, filePath, readTime) {
287
+ async function displayWithLess(rendered, title, filePath, readTime, toc) {
251
288
  const trans = t();
252
289
  const cols = Math.min(process.stdout.columns || 80, 80);
253
- const rule = chalk.dim('-'.repeat(cols));
290
+ const rule = chalk.dim('─'.repeat(cols));
291
+ const tocBlock = toc.length >= 3
292
+ ? [
293
+ chalk.dim(` ${trans.docs.tocTitle}`),
294
+ chalk.dim(` ${'─'.repeat(36)}`),
295
+ ...toc.map(h => chalk.dim(` ${h}`)),
296
+ chalk.dim(` ${'─'.repeat(36)}`),
297
+ '',
298
+ ].join('\n')
299
+ : '';
254
300
  const header = [
255
301
  '',
256
302
  chalk.bold.cyan(` ${title}`),
257
- chalk.dim(` ${filePath}`) + chalk.dim(` | ${readTime}`),
303
+ chalk.dim(` ${filePath}`) + chalk.dim(` · ${readTime}`),
258
304
  rule,
305
+ ...(tocBlock ? [tocBlock] : []),
259
306
  '',
260
307
  ].join('\n');
261
308
  const footer = [
262
309
  '',
263
310
  rule,
264
- chalk.dim(` ${trans.docs.endOfDocument} | / to search`),
311
+ chalk.dim(` ${trans.docs.endOfDocument} · / to search`),
265
312
  '',
266
313
  ].join('\n');
267
314
  const fullContent = header + rendered + footer;
@@ -286,66 +333,75 @@ async function displayWithLess(rendered, title, filePath, readTime) {
286
333
  }
287
334
  });
288
335
  }
289
- // ─── Directory browser ────────────────────────────────────────────────────────
290
- async function browseDirectory(initialPath = '') {
291
- let currentPath = initialPath;
292
- while (true) {
293
- const trans = t();
294
- let items;
295
- try {
296
- const s = createSpinner(currentPath ? `${trans.docs.loadingDir}: ${currentPath}` : trans.docs.loading);
297
- items = await fetchDirectory(currentPath);
298
- s.stop(currentPath || trans.docs.chooseDoc);
299
- }
300
- catch (err) {
301
- error(trans.docs.loadError);
302
- const errMsg = err instanceof Error ? err.message : String(err);
303
- console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
304
- setVimKeysActive(false);
305
- const retry = await confirm({ message: trans.docs.retry });
306
- setVimKeysActive(true);
307
- if (!isCancel(retry) && retry)
308
- continue;
309
- return;
310
- }
311
- if (items.length === 0) {
312
- warning(trans.docs.emptyDir);
313
- if (currentPath) {
314
- currentPath = currentPath.split('/').slice(0, -1).join('/');
315
- continue;
316
- }
317
- return;
318
- }
319
- const options = [
320
- ...(currentPath ? [{ value: '__back__', label: chalk.dim(trans.docs.upToParent) }] : []),
321
- ...items.map(item => ({
322
- value: item.path,
323
- label: item.type === 'dir'
324
- ? chalk.cyan(`${item.name}/`)
325
- : item.name,
326
- hint: item.type === 'dir' ? 'dir' : undefined,
327
- })),
328
- { value: '__exit__', label: chalk.dim(trans.docs.returnToMenu) },
329
- ];
330
- const selected = await select({
331
- message: currentPath ? `${trans.docs.currentDir}: ${currentPath}` : trans.docs.chooseDoc,
332
- options,
333
- });
334
- if (isCancel(selected) || selected === '__exit__')
335
- return;
336
- if (selected === '__back__') {
337
- currentPath = currentPath.split('/').slice(0, -1).join('/');
338
- continue;
339
- }
340
- const item = items.find(i => i.path === selected);
341
- if (item?.type === 'dir') {
342
- currentPath = selected;
343
- continue;
344
- }
345
- if (item?.type === 'file') {
346
- await viewMarkdownFile(selected);
347
- }
336
+ // ─── Section browsers ─────────────────────────────────────────────────────────
337
+ /** Show a flat file list for tutorial / process / repair. */
338
+ async function showDocSection(section) {
339
+ const trans = t();
340
+ if (section.key === 'archived') {
341
+ await showArchivedSection(section.files);
342
+ return;
348
343
  }
344
+ const files = section.files.filter(f => f.name !== 'index.md' && !f.name.startsWith('index.'));
345
+ if (files.length === 0)
346
+ return;
347
+ const selected = await select({
348
+ message: section.label,
349
+ options: [
350
+ ...files.map(f => {
351
+ const parts = f.path.split('/');
352
+ const hint = parts.length > 2 ? parts.slice(1, -1).join('/') : '';
353
+ return { value: f.path, label: cleanFileName(f.name), hint };
354
+ }),
355
+ { value: '__back__', label: chalk.dim(trans.common.back) },
356
+ ],
357
+ });
358
+ if (isCancel(selected) || selected === '__back__')
359
+ return;
360
+ await viewMarkdownFile(selected);
361
+ }
362
+ /** Show archived docs grouped by year, then files within the year. */
363
+ async function showArchivedSection(files) {
364
+ const trans = t();
365
+ const groups = getArchivedGroups(files);
366
+ const sortedKeys = [...groups.keys()].sort((a, b) => {
367
+ const aYear = /^\d{4}$/.test(a);
368
+ const bYear = /^\d{4}$/.test(b);
369
+ if (aYear && bYear)
370
+ return Number(b) - Number(a);
371
+ if (aYear)
372
+ return -1;
373
+ if (bYear)
374
+ return 1;
375
+ return a.localeCompare(b);
376
+ });
377
+ const groupKey = await select({
378
+ message: trans.docs.categoryArchived,
379
+ options: [
380
+ ...sortedKeys.map(k => ({
381
+ value: k,
382
+ label: k,
383
+ hint: `${groups.get(k).length} docs`,
384
+ })),
385
+ { value: '__back__', label: chalk.dim(trans.common.back) },
386
+ ],
387
+ });
388
+ if (isCancel(groupKey) || groupKey === '__back__')
389
+ return;
390
+ const groupFiles = groups.get(groupKey) ?? [];
391
+ const fileSelected = await select({
392
+ message: `${trans.docs.categoryArchived} / ${groupKey}`,
393
+ options: [
394
+ ...groupFiles.map(f => ({
395
+ value: f.path,
396
+ label: cleanFileName(f.name),
397
+ hint: f.path.split('/').slice(2, -1).join('/'),
398
+ })),
399
+ { value: '__back__', label: chalk.dim(trans.common.back) },
400
+ ],
401
+ });
402
+ if (isCancel(fileSelected) || fileSelected === '__back__')
403
+ return;
404
+ await viewMarkdownFile(fileSelected);
349
405
  }
350
406
  // ─── Document viewer ──────────────────────────────────────────────────────────
351
407
  async function viewMarkdownFile(filePath) {
@@ -363,28 +419,31 @@ async function viewMarkdownFile(filePath) {
363
419
  }
364
420
  else {
365
421
  const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
366
- const title = extractDocTitle(rawContent, cleaned) || filePath.split('/').pop() || filePath;
422
+ const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
367
423
  const readTime = estimateReadTime(cleaned);
368
424
  const rendered = await marked(cleaned);
369
425
  renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
370
426
  setRender(filePath, renderedDoc);
371
427
  }
372
428
  s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
429
+ const toc = extractTOC(renderedDoc.cleaned);
373
430
  if (hasGlow()) {
374
431
  await displayWithGlow(renderedDoc.cleaned);
375
432
  }
376
433
  else {
377
- await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime);
434
+ await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
378
435
  }
379
436
  console.log();
380
437
  success(trans.docs.docCompleted);
381
438
  console.log();
439
+ const hasTable = hasMarkdownTable(rawContent);
382
440
  const action = await select({
383
441
  message: trans.docs.chooseAction,
384
442
  options: [
385
443
  { value: 'back', label: trans.docs.backToList },
386
444
  { value: 'reread', label: trans.docs.reread },
387
- { value: 'browser', label: trans.docs.openBrowser },
445
+ { value: 'browser', label: trans.docs.openBrowser,
446
+ hint: hasTable ? trans.docs.tableHint : undefined },
388
447
  ],
389
448
  });
390
449
  if (isCancel(action) || action === 'back')
@@ -393,7 +452,7 @@ async function viewMarkdownFile(filePath) {
393
452
  await openDocsInBrowser(filePath);
394
453
  return;
395
454
  }
396
- // action === 'reread' → continue loop
455
+ // 'reread' → loop
397
456
  }
398
457
  catch (err) {
399
458
  error(trans.docs.loadError);
@@ -439,63 +498,53 @@ async function searchDocs() {
439
498
  return;
440
499
  const keyword = query.trim().toLowerCase();
441
500
  const s = createSpinner(trans.docs.searching);
442
- // Fetch all category directories in parallel
443
- const categories = getDocCategories().filter(c => c.path !== 'README.md');
444
- const results = [];
445
501
  try {
446
- const fetches = await Promise.allSettled(categories.map(async (cat) => {
447
- const items = await fetchDirectory(cat.path);
448
- return { items, category: cat.name };
449
- }));
450
- for (const result of fetches) {
451
- if (result.status !== 'fulfilled')
452
- continue;
453
- for (const item of result.value.items) {
454
- if (item.name.toLowerCase().includes(keyword)) {
455
- results.push({ name: item.name, path: item.path, category: result.value.category });
456
- }
457
- }
458
- }
502
+ const all = await docsClient.listAll();
503
+ const results = all.filter(item => item.path.toLowerCase().includes(keyword));
459
504
  s.stop(`${results.length} ${trans.docs.searchResults}`);
505
+ if (results.length === 0) {
506
+ warning(trans.docs.searchNoResults);
507
+ return;
508
+ }
509
+ const selected = await select({
510
+ message: trans.docs.chooseDoc,
511
+ options: [
512
+ ...results.map(r => ({
513
+ value: r.path,
514
+ label: cleanFileName(r.name),
515
+ hint: r.path.includes('/') ? r.path.split('/').slice(0, -1).join('/') : '',
516
+ })),
517
+ { value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
518
+ ],
519
+ });
520
+ if (isCancel(selected) || selected === '__back__')
521
+ return;
522
+ await viewMarkdownFile(selected);
460
523
  }
461
524
  catch {
462
525
  s.error(trans.docs.loadError);
463
- return;
464
- }
465
- if (results.length === 0) {
466
- warning(trans.docs.searchNoResults);
467
- return;
468
526
  }
469
- const selected = await select({
470
- message: trans.docs.chooseDoc,
471
- options: [
472
- ...results.map(r => ({
473
- value: r.path,
474
- label: r.name,
475
- hint: r.category,
476
- })),
477
- { value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
478
- ],
479
- });
480
- if (isCancel(selected) || selected === '__back__')
481
- return;
482
- await viewMarkdownFile(selected);
483
527
  }
484
528
  // ─── Menu ─────────────────────────────────────────────────────────────────────
485
529
  export async function showDocsMenu() {
530
+ let sections = await loadSections();
531
+ if (!sections)
532
+ return;
486
533
  while (true) {
487
534
  const trans = t();
488
- const categories = getDocCategories();
489
- const options = [
490
- ...categories.map(cat => ({ value: cat.path, label: cat.name })),
491
- { value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
492
- { value: 'refresh-cache', label: chalk.dim(trans.docs.refreshCache) },
493
- { value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
494
- { value: 'back', label: chalk.dim(trans.docs.returnToMenu) },
495
- ];
496
535
  const action = await select({
497
536
  message: trans.docs.chooseCategory,
498
- options,
537
+ options: [
538
+ ...sections.map(sec => ({
539
+ value: sec.key,
540
+ label: sec.label,
541
+ hint: `${sec.count} docs`,
542
+ })),
543
+ { value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
544
+ { value: 'refresh-cache', label: chalk.dim(trans.docs.refreshCache) },
545
+ { value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
546
+ { value: 'back', label: chalk.dim(trans.docs.returnToMenu) },
547
+ ],
499
548
  });
500
549
  if (isCancel(action) || action === 'back')
501
550
  return;
@@ -504,13 +553,16 @@ export async function showDocsMenu() {
504
553
  }
505
554
  else if (action === 'refresh-cache') {
506
555
  clearDocsCache();
556
+ sections = (await loadSections()) ?? sections;
507
557
  success(trans.docs.cacheCleared);
508
558
  }
509
559
  else if (action === 'browser') {
510
560
  await openDocsInBrowser();
511
561
  }
512
562
  else {
513
- await browseDirectory(action);
563
+ const section = sections.find(s => s.key === action);
564
+ if (section)
565
+ await showDocSection(section);
514
566
  }
515
567
  }
516
568
  }
@@ -1,17 +1,24 @@
1
1
  import chalk from 'chalk';
2
2
  import { APP_INFO, URLS } from '../config/data.js';
3
3
  import { pickIcon } from '../core/icons.js';
4
- import { padEndV } from '../core/text.js';
4
+ import { padEndV, visualWidth } from '../core/text.js';
5
+ import { c } from '../core/theme.js';
5
6
  import { createSpinner } from '../core/ui.js';
6
7
  import { t } from '../i18n/index.js';
7
8
  function getServiceTargets() {
8
9
  const trans = t();
9
10
  return [
10
- { name: trans.status.serviceWebsite, url: URLS.homepage },
11
- { name: trans.status.serviceDocs, url: URLS.docs },
12
- { name: trans.status.serviceCalendar, url: URLS.calendar },
13
- { name: trans.status.serviceGithub, url: URLS.github },
14
- { name: trans.status.serviceRoadmap, url: URLS.roadmap },
11
+ // NBTCA-owned services
12
+ { name: trans.status.serviceHomepage, url: URLS.homepage, group: 'nbtca' },
13
+ { name: trans.status.serviceDocs, url: URLS.docs, group: 'nbtca' },
14
+ { name: trans.status.serviceIcal, url: URLS.calendar, group: 'nbtca' },
15
+ { name: trans.status.serviceRepair, url: URLS.repair, group: 'nbtca' },
16
+ // External platforms
17
+ { name: trans.status.serviceGithub, url: URLS.github, group: 'external' },
18
+ { name: trans.status.serviceRoadmap, url: URLS.roadmap, group: 'external' },
19
+ // Intranet services (campus LAN only)
20
+ { name: trans.status.serviceCloud, url: URLS.cloud, group: 'intranet', intranet: true },
21
+ { name: trans.status.serviceMirror, url: URLS.mirror, group: 'intranet', intranet: true },
15
22
  ];
16
23
  }
17
24
  async function checkService(name, url, timeoutMs) {
@@ -37,25 +44,23 @@ async function checkService(name, url, timeoutMs) {
37
44
  return { name, url, ok: false, latencyMs, error };
38
45
  }
39
46
  }
40
- async function checkServiceWithRetry(name, url, timeoutMs, retries) {
41
- let lastResult = await checkService(name, url, timeoutMs);
42
- if (lastResult.ok)
43
- return lastResult;
44
- for (let attempt = 0; attempt < retries; attempt++) {
45
- // Retry for transient transport errors and upstream server errors.
46
- if (!lastResult.error && !(lastResult.statusCode != null && lastResult.statusCode >= 500)) {
47
- break;
47
+ async function checkServiceWithRetry(target, timeoutMs, retries) {
48
+ let lastResult = await checkService(target.name, target.url, timeoutMs);
49
+ if (!lastResult.ok) {
50
+ for (let attempt = 0; attempt < retries; attempt++) {
51
+ if (!lastResult.error && !(lastResult.statusCode != null && lastResult.statusCode >= 500))
52
+ break;
53
+ lastResult = await checkService(target.name, target.url, timeoutMs);
54
+ if (lastResult.ok)
55
+ break;
48
56
  }
49
- lastResult = await checkService(name, url, timeoutMs);
50
- if (lastResult.ok)
51
- return lastResult;
52
57
  }
53
- return lastResult;
58
+ return { ...lastResult, group: target.group, intranet: target.intranet };
54
59
  }
55
60
  export async function checkServices(options = {}) {
56
61
  const timeoutMs = options.timeoutMs ?? 6000;
57
62
  const retries = options.retries ?? 1;
58
- return Promise.all(getServiceTargets().map((service) => checkServiceWithRetry(service.name, service.url, timeoutMs, retries)));
63
+ return Promise.all(getServiceTargets().map(t => checkServiceWithRetry(t, timeoutMs, retries)));
59
64
  }
60
65
  export function serializeServiceStatus(items) {
61
66
  return items.map((item) => ({
@@ -65,59 +70,69 @@ export function serializeServiceStatus(items) {
65
70
  statusCode: item.statusCode ?? null,
66
71
  latencyMs: item.latencyMs ?? null,
67
72
  error: item.error ?? null,
73
+ group: item.group ?? null,
74
+ intranet: item.intranet ?? false,
68
75
  }));
69
76
  }
70
77
  export function hasServiceFailures(items) {
71
- return items.some((item) => !item.ok);
78
+ return items.some(item => !item.ok && !item.intranet);
72
79
  }
73
80
  export function countServiceHealth(items) {
74
81
  let up = 0;
75
82
  let down = 0;
76
- for (const item of items) {
77
- if (item.ok) {
78
- up += 1;
79
- }
80
- else {
81
- down += 1;
82
- }
83
+ for (const item of items.filter(i => !i.intranet)) {
84
+ if (item.ok)
85
+ up++;
86
+ else
87
+ down++;
83
88
  }
84
89
  return { up, down };
85
90
  }
86
91
  export function renderServiceStatusTable(items, options) {
87
- const color = options?.color !== false;
92
+ const useColor = options?.color !== false;
88
93
  const id = (s) => s;
89
- const dim = color ? chalk.dim : id;
90
- const green = color ? chalk.green : id;
91
- const red = color ? chalk.red : id;
92
- const cyan = color ? chalk.cyan : id;
94
+ const applyDim = useColor ? chalk.dim : id;
95
+ const applyGreen = useColor ? chalk.green : id;
96
+ const applyRed = useColor ? chalk.red : id;
97
+ const applyCyan = useColor ? chalk.cyan : id;
98
+ const applyLatency = useColor ? c.latency : (ms) => `${ms}ms`;
93
99
  const trans = t();
94
- const nameWidth = 10;
95
- const statusWidth = 9;
96
- const latencyWidth = 10;
97
- const h = pickIcon('─', '-');
98
- const v = pickIcon('│', '|');
99
- const topLeft = pickIcon('┌', '+');
100
- const topMid = pickIcon('┬', '+');
101
- const topRight = pickIcon('┐', '+');
102
- const midLeft = pickIcon('├', '+');
103
- const midMid = pickIcon('┼', '+');
104
- const midRight = pickIcon('┤', '+');
105
- const bottomLeft = pickIcon('└', '+');
106
- const bottomMid = pickIcon('┴', '+');
107
- const bottomRight = pickIcon('┘', '+');
108
- const top = `${topLeft}${h.repeat(nameWidth + 2)}${topMid}${h.repeat(statusWidth + 2)}${topMid}${h.repeat(latencyWidth + 2)}${topRight}`;
109
- const divider = `${midLeft}${h.repeat(nameWidth + 2)}${midMid}${h.repeat(statusWidth + 2)}${midMid}${h.repeat(latencyWidth + 2)}${midRight}`;
110
- const bottom = `${bottomLeft}${h.repeat(nameWidth + 2)}${bottomMid}${h.repeat(statusWidth + 2)}${bottomMid}${h.repeat(latencyWidth + 2)}${bottomRight}`;
111
- const header = `${v} ${padEndV(trans.status.service, nameWidth)} ${v} ${padEndV(trans.status.health, statusWidth)} ${v} ${padEndV(trans.status.latency, latencyWidth)} ${v}`;
112
- const lines = [dim(top), header, dim(divider)];
100
+ const nameWidth = Math.max(...items.map(i => visualWidth(i.name)), visualWidth(trans.status.service));
101
+ const statusWidth = 10;
102
+ const onIcon = pickIcon('●', '+');
103
+ const offIcon = pickIcon('✕', '!');
104
+ const lanIcon = pickIcon('○', 'o');
105
+ const sep = pickIcon('─', '-');
106
+ const lines = [];
107
+ let currentGroup;
113
108
  for (const item of items) {
114
- const statusLabel = item.ok
115
- ? green(`${pickIcon('●', 'OK')} ${trans.status.up}`)
116
- : red(`${pickIcon('●', '!!')} ${trans.status.down}`);
117
- const latency = item.latencyMs != null ? `${item.latencyMs}ms` : '-';
118
- lines.push(`${v} ${padEndV(cyan(item.name), nameWidth)} ${v} ${padEndV(statusLabel, statusWidth)} ${v} ${padEndV(latency, latencyWidth)} ${v}`);
109
+ if (item.group !== currentGroup) {
110
+ if (currentGroup !== undefined)
111
+ lines.push('');
112
+ const groupLabel = item.group === 'nbtca' ? trans.status.groupNbtca :
113
+ item.group === 'external' ? trans.status.groupExternal :
114
+ item.group === 'intranet' ? trans.status.groupIntranet : '';
115
+ lines.push(` ${applyDim(groupLabel)}`);
116
+ lines.push(` ${applyDim(sep.repeat(nameWidth + statusWidth + 12))}`);
117
+ currentGroup = item.group;
118
+ }
119
+ const nameCol = padEndV(item.intranet ? applyDim(item.name) : applyCyan(item.name), nameWidth);
120
+ let statusLabel;
121
+ if (item.ok) {
122
+ statusLabel = applyGreen(`${onIcon} ${trans.status.up}`);
123
+ }
124
+ else if (item.intranet) {
125
+ statusLabel = applyDim(`${lanIcon} ${trans.status.down}`);
126
+ }
127
+ else {
128
+ statusLabel = applyRed(`${offIcon} ${trans.status.down}`);
129
+ }
130
+ const statusCol = padEndV(statusLabel, statusWidth);
131
+ const latencyCol = item.ok && item.latencyMs != null
132
+ ? applyLatency(item.latencyMs)
133
+ : applyDim('—');
134
+ lines.push(` ${nameCol} ${statusCol} ${latencyCol}`);
119
135
  }
120
- lines.push(dim(bottom));
121
136
  return lines.join('\n');
122
137
  }
123
138
  export async function showServiceStatus() {
@@ -131,6 +146,8 @@ export async function showServiceStatus() {
131
146
  else {
132
147
  spinner.stop(trans.status.summaryOk);
133
148
  }
149
+ console.log();
134
150
  console.log(renderServiceStatusTable(items, { color: !!process.stdout.isTTY }));
151
+ console.log();
135
152
  return items;
136
153
  }
@@ -53,7 +53,12 @@
53
53
  "title": "Activity (last 12 months)",
54
54
  "legendLess": "Less",
55
55
  "legendMore": "More"
56
- }
56
+ },
57
+ "pastLoading": "Loading past events...",
58
+ "pastEvents": "Past Events",
59
+ "pastEventsDesc": "Recent activity history",
60
+ "noPastEvents": "No past events in the last 30 days",
61
+ "viewPastDetail": "Select an event for details:"
57
62
  },
58
63
  "docs": {
59
64
  "loading": "Loading documentation list...",
@@ -87,6 +92,8 @@
87
92
  "browserError": "Failed to open browser",
88
93
  "browserErrorHint": "Please visit manually: https://docs.nbtca.space",
89
94
  "retry": "Retry?",
95
+ "tocTitle": "Table of Contents",
96
+ "tableHint": "tables render better in browser",
90
97
  "endOfDocument": "End of document - Press q to quit",
91
98
  "githubRateLimited": "GitHub API rate limit reached. Resets at {time}.",
92
99
  "githubForbidden": "GitHub API access denied (403).",
@@ -119,13 +126,19 @@
119
126
  "code": "Code",
120
127
  "latency": "Latency",
121
128
  "url": "URL",
122
- "up": "UP",
123
- "down": "DOWN",
124
- "serviceWebsite": "Website",
129
+ "up": "online",
130
+ "down": "offline",
131
+ "groupNbtca": "NBTCA Services",
132
+ "groupExternal": "External",
133
+ "groupIntranet": "Intranet (LAN only)",
134
+ "serviceHomepage": "Homepage",
125
135
  "serviceDocs": "Docs",
126
- "serviceCalendar": "Calendar",
136
+ "serviceIcal": "iCal Feed",
137
+ "serviceRepair": "Repair",
127
138
  "serviceGithub": "GitHub",
128
139
  "serviceRoadmap": "Roadmap",
140
+ "serviceCloud": "Cloud",
141
+ "serviceMirror": "Mirror",
129
142
  "watchStarted": "Starting status watch (every {seconds}s)",
130
143
  "watchUpdated": "Last updated",
131
144
  "watchHint": "Press Ctrl+C to stop",
@@ -53,7 +53,12 @@
53
53
  "title": "近一年活跃度",
54
54
  "legendLess": "少",
55
55
  "legendMore": "多"
56
- }
56
+ },
57
+ "pastLoading": "正在加载历史活动...",
58
+ "pastEvents": "历史活动",
59
+ "pastEventsDesc": "近期活动记录",
60
+ "noPastEvents": "最近 30 天内暂无历史活动",
61
+ "viewPastDetail": "选择活动查看详情:"
57
62
  },
58
63
  "docs": {
59
64
  "loading": "正在加载文档列表...",
@@ -87,6 +92,8 @@
87
92
  "browserError": "无法打开浏览器",
88
93
  "browserErrorHint": "请手动访问: https://docs.nbtca.space",
89
94
  "retry": "是否重试?",
95
+ "tocTitle": "目录",
96
+ "tableHint": "表格在浏览器中效果更佳",
90
97
  "endOfDocument": "文档结束 - 按 q 退出",
91
98
  "githubRateLimited": "GitHub API 速率限制已达上限,将在 {time} 重置。",
92
99
  "githubForbidden": "GitHub API 拒绝访问 (403)。",
@@ -119,13 +126,19 @@
119
126
  "code": "代码",
120
127
  "latency": "延迟",
121
128
  "url": "URL",
122
- "up": "正常",
123
- "down": "异常",
124
- "serviceWebsite": "官网",
129
+ "up": "在线",
130
+ "down": "离线",
131
+ "groupNbtca": "NBTCA 服务",
132
+ "groupExternal": "外部平台",
133
+ "groupIntranet": "内网服务(仅校园网)",
134
+ "serviceHomepage": "主页",
125
135
  "serviceDocs": "文档",
126
- "serviceCalendar": "日历",
136
+ "serviceIcal": "iCal 源",
137
+ "serviceRepair": "维修服务",
127
138
  "serviceGithub": "GitHub",
128
- "serviceRoadmap": "看板",
139
+ "serviceRoadmap": "路线图",
140
+ "serviceCloud": "云存储",
141
+ "serviceMirror": "镜像站",
129
142
  "watchStarted": "开始监控服务状态(每 {seconds} 秒)",
130
143
  "watchUpdated": "最近更新",
131
144
  "watchHint": "按 Ctrl+C 停止",
package/dist/main.js CHANGED
@@ -1,42 +1,29 @@
1
- /**
2
- * NBTCA Welcome Tool
3
- * Minimalist startup flow
4
- */
5
- import chalk from 'chalk';
6
- import { intro } from '@clack/prompts';
7
1
  import { printLogo } from './core/logo.js';
8
2
  import { clearScreen, handleGracefulExit } from './core/ui.js';
9
3
  import { showMainMenu } from './core/menu.js';
10
- import { APP_INFO } from './config/data.js';
4
+ import { c } from './core/theme.js';
11
5
  import { enableVimKeys } from './core/vim-keys.js';
12
6
  import { checkForUpdate } from './features/update.js';
13
- /**
14
- * Main program entry point
15
- */
7
+ import { showEventsPreview } from './features/calendar.js';
16
8
  export async function main(options = {}) {
17
9
  try {
18
- // Enable Vim key bindings
19
10
  enableVimKeys();
20
- // Clear screen
21
11
  if (process.stdout.isTTY) {
22
12
  clearScreen();
23
13
  }
24
- // Display logo (smart fallback)
25
14
  if (!options.skipLogo) {
26
15
  printLogo();
27
16
  }
28
- // Non-blocking update check (fire and forget, print before menu if resolved in time)
17
+ // Fire update check in background; events fetch provides natural wait time
29
18
  const updatePromise = checkForUpdate();
30
- // Open session frame
31
- intro(chalk.cyan('NBTCA Prompt') + chalk.dim(` v${APP_INFO.version}`));
32
- // Show update notification if ready
19
+ await showEventsPreview();
20
+ // Update check is very likely done by now; give it a short window if not
33
21
  const updateMsg = await Promise.race([
34
22
  updatePromise,
35
- new Promise(r => setTimeout(r, 500, null)),
23
+ new Promise(r => setTimeout(r, 100, null)),
36
24
  ]);
37
25
  if (updateMsg)
38
- console.log(chalk.yellow(updateMsg));
39
- // Show main menu (loop)
26
+ console.log(c.warn(updateMsg));
40
27
  await showMainMenu();
41
28
  }
42
29
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/prompt",
3
- "version": "1.1.3",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -39,8 +39,8 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "@clack/prompts": "^1.2.0",
42
- "@nbtca/docs": "^0.1.2",
43
- "@nbtca/nbtcal": "^0.2.1",
42
+ "@nbtca/docs": "^0.2.0",
43
+ "@nbtca/nbtcal": "^0.3.0",
44
44
  "chalk": "^5.6.2",
45
45
  "gradient-string": "^3.0.0",
46
46
  "marked": "^15.0.12",