From c6f0701659085105b796826103147fd3c01fc739 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 25 Mar 2026 21:48:33 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20Apple-style=20design=20overhaul=20?= =?UTF-8?q?=E2=80=94=20simplify=20menu,=20remove=20clutter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce main menu from 9 to 5 items: Events, Docs, Status, Links, Settings - Merge repair.ts + website.ts into unified links.ts sub-menu - Move About into Settings alongside language and theme - Remove emoji prefixes from all sub-menu labels — clean text only - Simplify status table: remove URL and code columns, keep service/health/latency - Remove visual dividers between menu iterations - Remove README.md from docs categories (meta file, not user content) - Add document title search in docs browser - Add version update checker (nbtca update + non-blocking startup check) - Add --today and --next=N flags for events command - Fix ANSI escape codes breaking table alignment (stripAnsi in visualWidth) - Add ranger-style hjkl vim navigation (h=back, l=enter, g/G=home/end) - Extract shared getConfigDir() to config/paths.ts - Move GITHUB_REPO constant to config/data.ts - Fix tsconfig module/moduleResolution to Node16 - Delete stale JS artifacts and unused type files - Tighten help text: shorter labels, aligned columns - All CLI commands (nbtca website, nbtca repair, etc.) remain backward compatible --- package.json | 3 + scripts/test-cli.sh | 13 + src/config/data.js | 484 -------------------------------------- src/config/data.ts | 6 + src/config/paths.ts | 6 + src/config/preferences.ts | 9 +- src/config/theme.js | 138 ----------- src/config/theme.ts | 26 -- src/core/icons.ts | 18 +- src/core/logo.ts | 2 +- src/core/menu.ts | 121 +--------- src/core/text.ts | 12 +- src/core/vim-keys.ts | 10 +- src/features/calendar.ts | 62 ++++- src/features/docs.ts | 203 +++++++++++----- src/features/links.ts | 46 ++++ src/features/repair.ts | 30 --- src/features/settings.ts | 136 +++++++++++ src/features/status.ts | 43 ++-- src/features/theme.ts | 114 +-------- src/features/update.ts | 80 +++++++ src/features/website.ts | 52 ---- src/i18n/index.ts | 98 +++----- src/i18n/locales/en.json | 97 ++++---- src/i18n/locales/zh.json | 97 ++++---- src/index.ts | 224 ++++++++++-------- src/logo/printLogo.js | 26 -- src/main.ts | 21 +- src/types.ts | 51 ---- tsconfig.json | 8 +- 30 files changed, 826 insertions(+), 1410 deletions(-) delete mode 100644 src/config/data.js create mode 100644 src/config/paths.ts delete mode 100644 src/config/theme.js delete mode 100644 src/config/theme.ts create mode 100644 src/features/links.ts delete mode 100644 src/features/repair.ts create mode 100644 src/features/settings.ts create mode 100644 src/features/update.ts delete mode 100644 src/features/website.ts delete mode 100644 src/logo/printLogo.js delete mode 100644 src/types.ts diff --git a/package.json b/package.json index 2060856..5c1bdc9 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.23", "type": "module", "main": "dist/index.js", + "exports": { + ".": "./dist/index.js" + }, "bin": { "nbtca": "bin/nbtca-welcome.js", "nbtca-welcome": "bin/nbtca-welcome.js" diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh index 1944f72..66bd684 100644 --- a/scripts/test-cli.sh +++ b/scripts/test-cli.sh @@ -1,6 +1,18 @@ #!/usr/bin/env bash set -euo pipefail +version_output="$(node dist/index.js --version)" +if [[ ! "$version_output" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "version output not semver: $version_output" >&2 + exit 1 +fi + +version_v_output="$(node dist/index.js -v)" +if [[ "$version_v_output" != "$version_output" ]]; then + echo "-v output mismatch: $version_v_output vs $version_output" >&2 + exit 1 +fi + help_output="$(node dist/index.js --help)" if [[ "$help_output" != *"Usage:"* ]]; then echo "help output missing Usage section" >&2 @@ -20,6 +32,7 @@ if [[ "$docs_output" != "https://docs.nbtca.space" ]]; then fi tmp_home="$(mktemp -d)" +trap 'rm -rf "$tmp_home"' EXIT HOME="$tmp_home" node dist/index.js theme icon ascii >/dev/null if ! grep -q '"iconMode": "ascii"' "$tmp_home/.nbtca/preferences.json"; then echo "theme preference was not persisted" >&2 diff --git a/src/config/data.js b/src/config/data.js deleted file mode 100644 index 32a8f2e..0000000 --- a/src/config/data.js +++ /dev/null @@ -1,484 +0,0 @@ -// Virtual data configuration for NBTCA Welcome. - -/** - * Official website services data. - */ -export const officialData = { - homepage: { - url: "https://nbtca.space/", - title: "NBTCA 官方网站", - description: "浙大宁波理工学院计算机协会官方网站", - features: [ - "🏠 协会介绍", - "📰 最新资讯", - "📅 活动安排", - "👥 团队展示", - "📞 联系方式" - ] - }, - news: { - url: "https://nbtca.space/news/", - title: "新闻资讯", - description: "最新技术资讯和协会动态", - categories: [ - "📰 技术资讯", - "📰 协会动态", - "📰 竞赛信息", - "📰 活动通知", - "📰 技术分享" - ] - }, - events: { - url: "https://nbtca.space/events/", - title: "活动日历", - description: "技术讲座、竞赛、培训等活动安排", - upcoming: [ - "🎯 ACM程序设计竞赛培训", - "🎯 蓝桥杯编程大赛", - "🎯 技术分享会", - "🎯 创新创业大赛", - "🎯 网络安全竞赛" - ] - }, - team: { - url: "https://nbtca.space/team/", - title: "团队介绍", - description: "核心成员和技术团队介绍", - members: [ - "👨‍💻 技术总监 - 张三", - "👩‍💻 开发主管 - 李四", - "👨‍🎨 设计主管 - 王五", - "👩‍🔬 研究主管 - 赵六", - "👨‍🏫 培训主管 - 孙七" - ] - }, - contact: { - url: "https://nbtca.space/contact/", - title: "联系我们", - description: "联系方式和服务时间", - info: [ - "📧 邮箱: contact@nbtca.space", - "📞 电话: 0574-12345678", - "📍 地址: 浙江省宁波市鄞州区", - "⏰ 服务时间: 周一至周五 9:00-18:00", - "🌐 官网: https://nbtca.space" - ] - } -}; - -/** - * Technical support services data. - */ -export const techData = { - repair: { - title: "电脑维修服务", - description: "硬件故障诊断、软件问题解决、系统优化", - services: [ - "💻 硬件故障诊断", - "🔧 软件问题解决", - "⚡ 系统性能优化", - "🛡️ 病毒清理", - "💾 数据恢复" - ], - contact: "维修热线: 0574-12345678", - location: "维修地点: 图书馆一楼技术服务中心", - hours: "服务时间: 周一至周日 9:00-21:00", - price: "收费标准: 免费(学生)/ 50元起(教职工)" - }, - software: { - title: "软件安装服务", - description: "正版软件安装、配置优化、使用培训", - services: [ - "📱 操作系统安装", - "🖥️ 办公软件安装", - "🎨 设计软件安装", - "💻 开发环境配置", - "🔧 软件使用培训" - ], - contact: "软件服务: 0574-12345679", - location: "服务地点: 计算机学院实验室", - hours: "服务时间: 周一至周五 9:00-18:00", - price: "收费标准: 免费(正版软件)" - }, - network: { - title: "网络配置服务", - description: "网络连接配置、WiFi设置、网络故障排除", - services: [ - "🌐 网络连接配置", - "📶 WiFi设置优化", - "🔧 网络故障排除", - "🛡️ 网络安全配置", - "📱 移动设备网络" - ], - contact: "网络服务: 0574-12345680", - location: "服务地点: 网络中心", - hours: "服务时间: 周一至周五 8:00-22:00", - price: "收费标准: 免费" - }, - mobile: { - title: "移动设备支持", - description: "手机、平板电脑维修和软件安装", - services: [ - "📱 手机维修服务", - "📱 平板电脑维修", - "📱 移动软件安装", - "📱 数据迁移服务", - "📱 设备优化" - ], - contact: "移动设备服务: 0574-12345681", - location: "服务地点: 移动设备维修中心", - hours: "服务时间: 周一至周日 10:00-20:00", - price: "收费标准: 成本价" - }, - hardware: { - title: "硬件升级咨询", - description: "硬件升级建议、配件推荐、性能提升方案", - services: [ - "💾 内存升级建议", - "💿 硬盘升级方案", - "🎮 显卡升级咨询", - "🔋 电池更换服务", - "🖥️ 显示器升级" - ], - contact: "硬件咨询: 0574-12345682", - location: "咨询地点: 硬件服务中心", - hours: "服务时间: 周一至周五 9:00-17:00", - price: "收费标准: 免费咨询" - }, - booking: { - title: "服务预约系统", - description: "在线预约维修服务,实时查看服务状态", - url: "https://nbtca.space/booking/", - features: [ - "📅 在线预约服务", - "⏰ 实时服务状态", - "📱 短信通知提醒", - "⭐ 服务评价系统", - "📊 服务记录查询" - ], - contact: "预约热线: 0574-12345683", - hours: "预约时间: 24小时在线" - } -}; - -/** - * Learning resources data. - */ -export const learningData = { - docs: { - title: "技术文档中心", - description: "全面的技术文档和教程", - url: "https://docs.nbtca.space/", - categories: [ - "📚 编程语言文档", - "🖥️ 操作系统教程", - "🌐 网络技术文档", - "🔧 开发工具指南", - "📱 移动开发教程" - ], - languages: ["Python", "Java", "C++", "JavaScript", "Go", "Rust"], - topics: ["Web开发", "移动开发", "人工智能", "网络安全", "云计算"] - }, - videos: { - title: "视频教程库", - description: "高质量的技术视频教程", - url: "https://nbtca.space/videos/", - categories: [ - "🎥 编程入门教程", - "🎥 高级技术讲座", - "🎥 项目实战演示", - "🎥 技术分享会", - "🎥 竞赛培训视频" - ], - duration: "总时长: 500+ 小时", - quality: "画质: 1080P", - format: "格式: MP4, WebM" - }, - programming: { - title: "编程学习平台", - description: "在线编程练习和项目实战", - url: "https://code.nbtca.space/", - features: [ - "💻 在线编程环境", - "🎯 编程练习题", - "🚀 项目实战训练", - "👥 代码审查服务", - "🏆 编程竞赛平台" - ], - languages: ["Python", "Java", "C++", "JavaScript", "SQL"], - difficulty: ["初级", "中级", "高级", "专家级"] - }, - design: { - title: "设计资源库", - description: "UI/UX设计资源和工具", - url: "https://design.nbtca.space/", - resources: [ - "🎨 UI设计模板", - "🎨 图标和插画", - "🎨 设计工具教程", - "🎨 设计规范文档", - "🎨 设计灵感库" - ], - tools: ["Figma", "Sketch", "Adobe XD", "Photoshop", "Illustrator"], - categories: ["移动端设计", "Web设计", "品牌设计", "插画设计"] - }, - research: { - title: "学术研究资源", - description: "计算机科学学术研究资料", - url: "https://research.nbtca.space/", - topics: [ - "🔬 人工智能研究", - "🔬 机器学习论文", - "🔬 数据科学应用", - "🔬 网络安全研究", - "🔬 软件工程实践" - ], - journals: ["IEEE", "ACM", "Springer", "Elsevier"], - conferences: ["ICSE", "SIGCHI", "ICML", "CVPR"] - }, - books: { - title: "推荐书籍清单", - description: "精选技术书籍和学习资料", - url: "https://books.nbtca.space/", - categories: [ - "📖 编程语言书籍", - "📖 算法与数据结构", - "📖 系统设计书籍", - "📖 技术管理书籍", - "📖 计算机科学经典" - ], - recommendations: [ - "《算法导论》- Thomas H. Cormen", - "《设计模式》- Gang of Four", - "《代码整洁之道》- Robert C. Martin", - "《重构》- Martin Fowler", - "《人月神话》- Frederick P. Brooks" - ] - } -}; - -/** - * Community services data. - */ -export const communityData = { - chat: { - title: "技术交流群", - description: "实时技术讨论和交流平台", - url: "https://chat.nbtca.space/", - features: [ - "💬 实时技术讨论", - "👥 专家在线答疑", - "📱 移动端支持", - "🔔 消息通知", - "📊 讨论记录" - ], - members: "1000+ 活跃用户", - topics: ["编程技术", "项目分享", "竞赛讨论", "求职交流"] - }, - qq: { - title: "官方QQ群", - description: "NBTCA官方QQ交流群", - groups: [ - { name: "NBTCA技术交流群", number: "123456789", members: "500+", topic: "技术讨论" }, - { name: "NBTCA竞赛群", number: "987654321", members: "300+", topic: "竞赛交流" }, - { name: "NBTCA学习群", number: "456789123", members: "400+", topic: "学习分享" }, - { name: "NBTCA项目群", number: "789123456", members: "200+", topic: "项目合作" } - ] - }, - github: { - title: "GitHub组织", - description: "开源项目和技术分享", - url: "https://github.com/nbtca", - projects: [ - "📦 nbtca-welcome - 欢迎工具", - "🌐 nbtca-website - 官方网站", - "📚 nbtca-docs - 技术文档", - "🎨 nbtca-design - 设计资源", - "🔧 nbtca-tools - 开发工具" - ], - stars: "500+ 星标", - forks: "200+ 复刻", - contributors: "50+ 贡献者" - }, - wechat: { - title: "微信公众号", - description: "NBTCA官方微信公众号", - account: "NBTCA计算机协会", - features: [ - "📰 技术资讯推送", - "🎯 活动通知", - "💡 技术分享", - "📱 移动端阅读", - "🔗 资源链接" - ], - followers: "2000+ 关注者", - posts: "每周3-5篇技术文章" - }, - projects: { - title: "项目合作", - description: "技术项目合作和团队组建", - url: "https://projects.nbtca.space/", - categories: [ - "🤝 开源项目合作", - "🎯 竞赛项目组队", - "💼 企业项目对接", - "🎓 学术研究合作", - "🚀 创新项目孵化" - ], - activeProjects: "20+ 进行中项目", - completedProjects: "50+ 已完成项目" - }, - competitions: { - title: "竞赛信息", - description: "各类技术竞赛和比赛信息", - url: "https://competitions.nbtca.space/", - events: [ - "🏆 ACM程序设计竞赛", - "🏆 蓝桥杯编程大赛", - "🏆 数学建模竞赛", - "🏆 创新创业大赛", - "🏆 网络安全竞赛" - ], - achievements: "100+ 获奖项目", - participants: "500+ 参赛学生" - } -}; - -/** - * Settings data. - */ -export const settingsData = { - theme: { - title: "主题设置", - description: "自定义界面外观和颜色主题", - themes: [ - { name: "默认主题", value: "default", description: "经典蓝白配色" }, - { name: "深色主题", value: "dark", description: "护眼深色模式" }, - { name: "浅色主题", value: "light", description: "清新浅色模式" }, - { name: "NBTCA主题", value: "nbtca", description: "协会专属主题" } - ] - }, - network: { - title: "网络配置", - description: "网络连接设置和代理配置", - options: [ - "🌐 网络连接测试", - "🔧 代理服务器设置", - "📶 WiFi配置优化", - "🛡️ 网络安全设置", - "📊 网络性能监控" - ] - }, - performance: { - title: "性能监控", - description: "系统性能监控和优化建议", - metrics: [ - "💾 内存使用率: 75%", - "⚡ CPU使用率: 45%", - "🌐 网络延迟: 15ms", - "💿 磁盘使用率: 60%", - "🔋 电池状态: 充电中" - ] - }, - notifications: { - title: "通知设置", - description: "消息通知和提醒设置", - options: [ - "🔔 系统通知", - "📱 消息推送", - "⏰ 定时提醒", - "🎯 重要事件提醒", - "📧 邮件通知" - ] - }, - update: { - title: "检查更新", - description: "检查软件更新和版本信息", - currentVersion: "v2.3.0", - latestVersion: "v2.3.0", - status: "已是最新版本", - lastCheck: "2024-01-15 10:30:00" - } -}; - -/** - * Help and about data. - */ -export const helpData = { - help: { - title: "使用帮助", - description: "NBTCA Welcome 使用指南", - sections: [ - "📖 快速开始指南", - "🎯 功能介绍", - "⌨️ 快捷键说明", - "🔧 常见问题解决", - "📞 技术支持" - ] - }, - faq: { - title: "常见问题", - description: "用户常见问题解答", - questions: [ - "❓ 如何获取技术支持?", - "❓ 如何加入技术交流群?", - "❓ 如何参与竞赛活动?", - "❓ 如何访问学习资源?", - "❓ 如何联系协会成员?" - ] - }, - feedback: { - title: "问题反馈", - description: "报告问题或提出建议", - channels: [ - "📧 邮箱反馈: feedback@nbtca.space", - "🐙 GitHub Issues", - "💬 QQ群反馈", - "📱 微信反馈", - "🌐 官网反馈" - ] - }, - terms: { - title: "用户协议", - description: "NBTCA Welcome 使用条款", - url: "https://nbtca.space/terms/" - }, - privacy: { - title: "隐私政策", - description: "用户隐私保护说明", - url: "https://nbtca.space/privacy/" - }, - about: { - title: "关于我们", - description: "NBTCA 团队信息", - info: [ - "🎓 浙大宁波理工学院计算机协会", - "📍 地址: 浙江省宁波市鄞州区", - "📧 邮箱: contact@nbtca.space", - "🌐 官网: https://nbtca.space", - "🐙 GitHub: https://github.com/nbtca" - ] - } -}; - -/** - * System information data. - */ -export const systemData = { - platform: { - darwin: { name: "macOS", icon: "🍎" }, - win32: { name: "Windows", icon: "🪟" }, - linux: { name: "Linux", icon: "🐧" } - }, - performance: { - excellent: { threshold: 80, label: "优秀 🚀" }, - good: { threshold: 60, label: "良好 ⚡" }, - normal: { threshold: 40, label: "一般 📉" }, - poor: { threshold: 0, label: "较差 ⚠️" } - }, - services: { - online: "🟢 在线", - offline: "🔴 离线", - maintenance: "🟡 维护中" - } -}; \ No newline at end of file diff --git a/src/config/data.ts b/src/config/data.ts index 5a2acde..4bfc0f4 100644 --- a/src/config/data.ts +++ b/src/config/data.ts @@ -30,6 +30,12 @@ export const URLS = { email: 'contact@nbtca.space', } as const; +export const GITHUB_REPO = { + owner: 'nbtca', + repo: 'documents', + branch: 'main', +} as const; + export const APP_INFO = { name: 'Prompt', version: readPackageVersion(), diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 0000000..68dd6b0 --- /dev/null +++ b/src/config/paths.ts @@ -0,0 +1,6 @@ +import path from 'path'; + +export function getConfigDir(): string { + const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; + return path.join(homeDir, '.nbtca'); +} diff --git a/src/config/preferences.ts b/src/config/preferences.ts index 0d2047c..0373d88 100644 --- a/src/config/preferences.ts +++ b/src/config/preferences.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { getConfigDir } from './paths.js'; export type IconMode = 'auto' | 'ascii' | 'unicode'; export type ColorMode = 'auto' | 'on' | 'off'; @@ -14,11 +15,6 @@ const DEFAULT_PREFERENCES: Preferences = { colorMode: 'auto', }; -function getConfigDir(): string { - const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; - return path.join(homeDir, '.nbtca'); -} - function getPreferencesPath(): string { return path.join(getConfigDir(), 'preferences.json'); } @@ -33,9 +29,6 @@ function ensureConfigDir(): void { export function loadPreferences(): Preferences { try { const prefPath = getPreferencesPath(); - if (!fs.existsSync(prefPath)) { - return { ...DEFAULT_PREFERENCES }; - } const raw = JSON.parse(fs.readFileSync(prefPath, 'utf-8')) as Partial; const iconMode: IconMode = raw.iconMode === 'ascii' || raw.iconMode === 'unicode' || raw.iconMode === 'auto' diff --git a/src/config/theme.js b/src/config/theme.js deleted file mode 100644 index 35bddb1..0000000 --- a/src/config/theme.js +++ /dev/null @@ -1,138 +0,0 @@ -// Theme configuration for NBTCA Welcome. - -export const themes = { - default: { - name: '默认主题', - colors: { - primary: [23, 147, 209], // archBlue - secondary: [34, 197, 94], // nbtcaGreen - accent: [147, 51, 234], // nbtcaPurple - warning: [249, 115, 22], // nbtcaOrange - error: [239, 68, 68], // red - success: [34, 197, 94], // green - info: [59, 130, 246], // blue - text: [255, 255, 255], // white - muted: [156, 163, 175] // gray - }, - symbols: { - logo: '🎓', - loading: ['⚡', '🚀', '💻', '🔧', '⚙️', '🎯', '🌟', '💡'], - success: '✅', - warning: '⚠️', - error: '❌', - info: 'ℹ️' - } - }, - - dark: { - name: '深色主题', - colors: { - primary: [59, 130, 246], // blue - secondary: [16, 185, 129], // emerald - accent: [139, 92, 246], // violet - warning: [245, 158, 11], // amber - error: [239, 68, 68], // red - success: [34, 197, 94], // green - info: [6, 182, 212], // cyan - text: [255, 255, 255], // white - muted: [107, 114, 128] // gray - }, - symbols: { - logo: '🌙', - loading: ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'], - success: '✅', - warning: '⚠️', - error: '❌', - info: 'ℹ️' - } - }, - - light: { - name: '浅色主题', - colors: { - primary: [37, 99, 235], // blue - secondary: [5, 150, 105], // emerald - accent: [124, 58, 237], // violet - warning: [217, 119, 6], // orange - error: [220, 38, 38], // red - success: [22, 163, 74], // green - info: [8, 145, 178], // cyan - text: [17, 24, 39], // gray-900 - muted: [107, 114, 128] // gray - }, - symbols: { - logo: '☀️', - loading: ['🌞', '🌤️', '⛅', '🌥️', '☁️', '🌦️', '🌧️', '🌈'], - success: '✅', - warning: '⚠️', - error: '❌', - info: 'ℹ️' - } - }, - - nbtca: { - name: 'NBTCA主题', - colors: { - primary: [23, 147, 209], // archBlue - secondary: [34, 197, 94], // nbtcaGreen - accent: [147, 51, 234], // nbtcaPurple - warning: [249, 115, 22], // nbtcaOrange - error: [236, 72, 153], // nbtcaPink - success: [34, 197, 94], // green - info: [59, 130, 246], // blue - text: [255, 255, 255], // white - muted: [156, 163, 175] // gray - }, - symbols: { - logo: '🎓', - loading: ['⚡', '🚀', '💻', '🔧', '⚙️', '🎯', '🌟', '💡'], - success: '✅', - warning: '⚠️', - error: '❌', - info: 'ℹ️' - } - } -}; - -/** - * Get current theme configuration. - * @param {string} themeName - Theme name. - * @returns {Object} Theme configuration. - */ -export function getTheme(themeName = 'default') { - return themes[themeName] || themes.default; -} - -/** - * Get color from theme. - * @param {string} themeName - Theme name. - * @param {string} colorName - Color name. - * @returns {Array} RGB color array. - */ -export function getThemeColor(themeName, colorName) { - const theme = getTheme(themeName); - return theme.colors[colorName] || theme.colors.primary; -} - -/** - * Get symbol from theme. - * @param {string} themeName - Theme name. - * @param {string} symbolName - Symbol name. - * @returns {string|Array} Symbol or symbol array. - */ -export function getThemeSymbol(themeName, symbolName) { - const theme = getTheme(themeName); - return theme.symbols[symbolName] || theme.symbols.logo; -} - -/** - * List available themes. - * @returns {Array} Array of theme names and descriptions. - */ -export function listThemes() { - return Object.entries(themes).map(([key, theme]) => ({ - name: theme.name, - value: key, - description: `使用 ${theme.name}` - })); -} \ No newline at end of file diff --git a/src/config/theme.ts b/src/config/theme.ts deleted file mode 100644 index 35a0bc1..0000000 --- a/src/config/theme.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 极简主题配置 - * 只保留基础颜色定义 - */ - -/** - * 基础颜色常量 - */ -export const COLORS = { - // 主题色 - cyan: '#00bcd4', - blue: '#2196f3', - green: '#4caf50', - yellow: '#ffeb3b', - red: '#f44336', - gray: '#9e9e9e', - white: '#ffffff', - - // 语义色 - primary: '#00bcd4', - success: '#4caf50', - warning: '#ffeb3b', - error: '#f44336', - info: '#2196f3', - muted: '#9e9e9e' -} as const; diff --git a/src/core/icons.ts b/src/core/icons.ts index 0c5a7fa..c734570 100644 --- a/src/core/icons.ts +++ b/src/core/icons.ts @@ -5,14 +5,24 @@ function localeSupportsUnicode(): boolean { return locale.includes('utf-8') || locale.includes('utf8'); } +let cachedUseUnicode: boolean | null = null; + export function useUnicodeIcons(): boolean { + if (cachedUseUnicode !== null) return cachedUseUnicode; + const configured = resolveIconMode(); - if (configured === 'ascii') return false; - if (configured === 'unicode') return true; + if (configured === 'ascii') { cachedUseUnicode = false; return false; } + if (configured === 'unicode') { cachedUseUnicode = true; return true; } const term = (process.env['TERM'] || '').toLowerCase(); - if (!process.stdout.isTTY || term === 'dumb') return false; - return localeSupportsUnicode(); + if (!process.stdout.isTTY || term === 'dumb') { cachedUseUnicode = false; return false; } + cachedUseUnicode = localeSupportsUnicode(); + return cachedUseUnicode; +} + +/** Invalidate the cached icon mode (call after theme changes). */ +export function resetIconCache(): void { + cachedUseUnicode = null; } export function pickIcon(unicodeIcon: string, asciiIcon: string): string { diff --git a/src/core/logo.ts b/src/core/logo.ts index 3b09052..b835f0c 100644 --- a/src/core/logo.ts +++ b/src/core/logo.ts @@ -40,7 +40,7 @@ function printDescription(): void { /** * Attempt to read and display logo file */ -export async function printLogo(): Promise { +export function printLogo(): void { if (!process.stdout.isTTY) { return; } diff --git a/src/core/menu.ts b/src/core/menu.ts index 87f53d8..43b8c1e 100644 --- a/src/core/menu.ts +++ b/src/core/menu.ts @@ -1,44 +1,29 @@ /** * Minimalist menu system - * Modern @clack/prompts select() with emoji icons and hint text */ -import { select, isCancel, outro, note } from '@clack/prompts'; +import { select, isCancel, outro } from '@clack/prompts'; import chalk from 'chalk'; import { showCalendar } from '../features/calendar.js'; -import { openRepairService } from '../features/repair.js'; import { showDocsMenu } from '../features/docs.js'; import { showServiceStatus } from '../features/status.js'; -import { showThemeMenu } from '../features/theme.js'; -import { openHomepage, openGithub, openRoadmap } from '../features/website.js'; -import { printDivider, printNewLine, success, warning } from './ui.js'; -import { pickIcon } from './icons.js'; -import { padEndV } from './text.js'; -import { APP_INFO, URLS } from '../config/data.js'; -import { t, getCurrentLanguage, setLanguage, clearTranslationCache, type Language } from '../i18n/index.js'; +import { showLinksMenu } from '../features/links.js'; +import { showSettingsMenu } from '../features/settings.js'; +import { t } from '../i18n/index.js'; -export type MenuAction = 'events' | 'repair' | 'docs' | 'status' | 'links' | 'website' | 'github' | 'roadmap' | 'about' | 'language' | 'theme'; +export type MenuAction = 'events' | 'docs' | 'status' | 'links' | 'settings'; -/** - * Get main menu options — 6 items - */ function getMainMenuOptions() { const trans = t(); return [ { value: 'events', label: trans.menu.events, hint: trans.menu.eventsDesc }, - { value: 'repair', label: trans.menu.repair, hint: trans.menu.repairDesc }, { value: 'docs', label: trans.menu.docs, hint: trans.menu.docsDesc }, { value: 'status', label: trans.menu.status, hint: trans.menu.statusDesc }, { value: 'links', label: trans.menu.links, hint: trans.menu.linksDesc }, - { value: 'about', label: trans.menu.about, hint: trans.menu.aboutDesc }, - { value: 'theme', label: trans.menu.theme, hint: trans.menu.themeDesc }, - { value: 'language', label: trans.menu.language, hint: trans.menu.languageDesc }, + { value: 'settings', label: trans.menu.settings, hint: trans.menu.settingsDesc }, ]; } -/** - * Display main menu — loops until user exits via Ctrl+C - */ export async function showMainMenu(): Promise { while (true) { const trans = t(); @@ -54,105 +39,15 @@ export async function showMainMenu(): Promise { } await runMenuAction(action as MenuAction); - - printNewLine(); - printDivider(); - printNewLine(); } } -/** - * Handle user action - */ export async function runMenuAction(action: MenuAction): Promise { switch (action) { case 'events': await showCalendar(); break; - case 'repair': await openRepairService(); break; case 'docs': await showDocsMenu(); break; case 'status': await showServiceStatus(); break; - case 'links': await showLinksMenu(); break; - case 'website': await openHomepage(); break; - case 'github': await openGithub(); break; - case 'roadmap': await openRoadmap(); break; - case 'about': showAbout(); break; - case 'theme': await showThemeMenu(); break; - case 'language': await showLanguageMenu(); break; - } -} - -/** - * Links submenu — website, GitHub, roadmap - */ -async function showLinksMenu(): Promise { - const trans = t(); - - const link = await select({ - message: trans.menu.chooseLink, - options: [ - { value: 'website', label: trans.menu.website, hint: 'nbtca.space' }, - { value: 'github', label: 'GitHub', hint: 'github.com/nbtca' }, - { value: 'roadmap', label: trans.menu.roadmap, hint: trans.menu.roadmapDesc }, - ], - }); - - if (isCancel(link)) return; - - switch (link) { - case 'website': await openHomepage(); break; - case 'github': await openGithub(); break; - case 'roadmap': await openRoadmap(); break; - } -} - -/** - * Display about information using clack note() box - */ -function showAbout(): void { - const trans = t(); - const pad = 12; - const row = (label: string, value: string) => `${chalk.dim(padEndV(label, pad))}${value}`; - const link = (label: string, url: string) => row(label, chalk.cyan(url)); - - const content = [ - row(trans.about.project, APP_INFO.name), - row(trans.about.version, `v${APP_INFO.version}`), - row(trans.about.description, APP_INFO.fullDescription), - '', - link(trans.about.github, APP_INFO.repository), - link(trans.about.website, URLS.homepage), - link(trans.about.email, URLS.email), - '', - row(trans.about.license, `MIT ${pickIcon('·', '|')} Author: m1ngsama`), - ].join('\n'); - - note(content, trans.about.title); -} - -/** - * Display language selection menu - */ -async function showLanguageMenu(): Promise { - const trans = t(); - const currentLang = getCurrentLanguage(); - - const language = await select({ - message: trans.language.selectLanguage, - options: [ - { value: 'zh' as Language, label: trans.language.zh, hint: currentLang === 'zh' ? `${pickIcon('✓', '*')} current` : undefined }, - { value: 'en' as Language, label: trans.language.en, hint: currentLang === 'en' ? `${pickIcon('✓', '*')} current` : undefined }, - ], - initialValue: currentLang, - }); - - if (isCancel(language)) return; - - if (language !== currentLang) { - const persisted = setLanguage(language); - clearTranslationCache(); - if (persisted) { - success(t().language.changed); - } else { - warning(t().language.changedSessionOnly); - } + case 'links': await showLinksMenu(); break; + case 'settings': await showSettingsMenu(); break; } } diff --git a/src/core/text.ts b/src/core/text.ts index 258957e..c5527a5 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -15,10 +15,18 @@ function charWidth(ch: string): 1 | 2 { ) ? 2 : 1; } -/** Total visual width of a string (CJK characters count as 2). */ +/** Strip ANSI escape sequences from a string. */ +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g; +export function stripAnsi(str: string): string { + return str.replace(ANSI_RE, ''); +} + +/** Total visual width of a string (CJK characters count as 2, ANSI codes ignored). */ export function visualWidth(str: string): number { + const plain = stripAnsi(str); let w = 0; - for (const ch of str) w += charWidth(ch); + for (const ch of plain) w += charWidth(ch); return w; } diff --git a/src/core/vim-keys.ts b/src/core/vim-keys.ts index 9a6fa7c..18fe136 100644 --- a/src/core/vim-keys.ts +++ b/src/core/vim-keys.ts @@ -5,13 +5,15 @@ * breaks in Node.js v25+ (emitKeys generator crash). */ -// Maps single-byte vim keys to terminal escape sequences +// Maps single-byte vim keys to terminal escape sequences (ranger-style hjkl) const VIM_TO_SEQ: Record = { + h: Buffer.from('\u0003'), // back/cancel (ranger: go to parent) j: Buffer.from('\u001b[B'), // down arrow k: Buffer.from('\u001b[A'), // up arrow - g: Buffer.from('\u001b[H'), // home - G: Buffer.from('\u001b[F'), // end - q: Buffer.from('\u0003'), // ctrl+c (cancel) + l: Buffer.from('\r'), // enter/confirm (ranger: open/enter) + g: Buffer.from('\u001b[H'), // home (first item) + G: Buffer.from('\u001b[F'), // end (last item) + q: Buffer.from('\u0003'), // quit }; export function enableVimKeys(): void { diff --git a/src/features/calendar.ts b/src/features/calendar.ts index 12a80bf..886903b 100644 --- a/src/features/calendar.ts +++ b/src/features/calendar.ts @@ -6,16 +6,19 @@ import axios from 'axios'; import ICAL from 'ical.js'; import chalk from 'chalk'; -import { info, printDivider, createSpinner } from '../core/ui.js'; +import { select, isCancel } from '@clack/prompts'; +import { info, createSpinner } from '../core/ui.js'; import { pickIcon } from '../core/icons.js'; import { padEndV, truncate } from '../core/text.js'; import { t } from '../i18n/index.js'; +import { APP_INFO, URLS } from '../config/data.js'; export interface Event { date: string; time: string; title: string; location: string; + description: string; startDate: Date; } @@ -24,6 +27,7 @@ export interface EventOutputItem { time: string; title: string; location: string; + description: string; startDateISO: string; } @@ -32,7 +36,7 @@ export async function fetchEvents(): Promise { const response = await axios.get('https://ical.nbtca.space', { timeout: 5000, headers: { - 'User-Agent': 'NBTCA-CLI/2.3.1' + 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` } }); @@ -50,14 +54,15 @@ export async function fetchEvents(): Promise { if (startDate >= now && startDate <= thirtyDaysLater) { const trans = t(); - const untitledEvent = trans.calendar.eventName === 'Event Name' ? 'Untitled Event' : '未命名活动'; - const tbdLocation = trans.calendar.location === 'Location' ? 'TBD' : '待定'; + const untitledEvent = trans.calendar.untitledEvent; + const tbdLocation = trans.calendar.tbdLocation; events.push({ date: formatDate(startDate), time: formatTime(startDate), title: event.summary || untitledEvent, location: event.location || tbdLocation, + description: event.description || '', startDate }); } @@ -65,8 +70,9 @@ export async function fetchEvents(): Promise { events.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); return events; - } catch { - throw new Error(t().calendar.error); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`${t().calendar.error}: ${detail}`); } } @@ -76,6 +82,7 @@ export function serializeEvents(events: Event[]): EventOutputItem[] { time: event.time, title: event.title, location: event.location, + description: event.description, startDateISO: event.startDate.toISOString() })); } @@ -147,7 +154,7 @@ export function renderEventsTable(events: Event[], options?: { color?: boolean } return lines.join('\n'); } -export function displayEvents(events: Event[]): void { +function displayEvents(events: Event[]): void { if (events.length === 0) { info(t().calendar.noEvents); return; @@ -155,7 +162,24 @@ export function displayEvents(events: Event[]): void { console.log(); console.log(renderEventsTable(events, { color: true })); - printDivider(); + console.log(chalk.dim(` ${pickIcon('📅', '[ical]')} ${t().calendar.subscribeHint}: ${URLS.calendar}`)); + console.log(); +} + +async function showEventDetail(event: Event): Promise { + const trans = t(); + console.log(); + console.log(chalk.bold.cyan(` ${event.title}`)); + console.log(chalk.dim(` ${event.date} ${event.time} ${pickIcon('·', '|')} ${event.location}`)); + if (event.description) { + console.log(); + const lines = event.description.trim().split('\n'); + for (const line of lines) { + console.log(chalk.white(` ${line}`)); + } + } else { + console.log(chalk.dim(` ${trans.calendar.noDescription}`)); + } console.log(); } @@ -166,6 +190,28 @@ export async function showCalendar(): Promise { const events = await fetchEvents(); s.stop(`${events.length} ${trans.calendar.eventsFound}`); displayEvents(events); + + if (events.length > 0) { + const options = [ + ...events.map((e, i) => ({ + value: String(i), + label: `${e.date} ${e.time} ${e.title}`, + hint: e.location, + })), + { value: '__back__', label: chalk.dim(trans.common.back) }, + ]; + + const selected = await select({ + message: trans.calendar.viewDetail, + options, + }); + + if (!isCancel(selected) && selected !== '__back__') { + const idx = Number.parseInt(selected, 10); + const event = events[idx]; + if (event) await showEventDetail(event); + } + } } catch { s.error(trans.calendar.error); console.log(chalk.gray(' ' + trans.calendar.errorHint)); diff --git a/src/features/docs.ts b/src/features/docs.ts index b65c9f3..26c3f27 100644 --- a/src/features/docs.ts +++ b/src/features/docs.ts @@ -8,11 +8,11 @@ import { marked } from 'marked'; import TerminalRenderer from 'marked-terminal'; import chalk from 'chalk'; import open from 'open'; -import { select, isCancel, confirm } from '@clack/prompts'; +import { select, isCancel, confirm, text } from '@clack/prompts'; import { error, warning, success, createSpinner } from '../core/ui.js'; import { pickIcon } from '../core/icons.js'; import { spawn, execFileSync } from 'child_process'; -import { URLS } from '../config/data.js'; +import { APP_INFO, GITHUB_REPO, URLS } from '../config/data.js'; import { t } from '../i18n/index.js'; // ─── Terminal capability detection ─────────────────────────────────────────── @@ -45,12 +45,29 @@ function commandExists(cmd: string): boolean { } } -const TERMINAL_TYPE = detectTerminalType(); -const HAS_GLOW = commandExists('glow'); +let _terminalType: TerminalType | null = null; +function getTerminalType(): TerminalType { + if (_terminalType === null) _terminalType = detectTerminalType(); + return _terminalType; +} + +let _hasGlow: boolean | null = null; +function hasGlow(): boolean { + if (_hasGlow === null) _hasGlow = commandExists('glow'); + return _hasGlow; +} + +let _markedConfigured = false; +function ensureMarkedConfigured(): void { + if (_markedConfigured) return; + _markedConfigured = true; + // @ts-ignore - marked v11 / marked-terminal v7 type incompatibility + marked.setOptions({ renderer: new TerminalRenderer(getRendererOptions(getTerminalType())) }); +} // ─── marked-terminal renderer ───────────────────────────────────────────────── -function getRendererOptions(type: TerminalType): any { +function getRendererOptions(type: TerminalType): Record { // Cap at 80 columns — optimal prose reading width regardless of terminal size const width = Math.min(process.stdout.columns || 80, 80); @@ -102,12 +119,10 @@ function getRendererOptions(type: TerminalType): any { }; } -// @ts-ignore -marked.setOptions({ renderer: new TerminalRenderer(getRendererOptions(TERMINAL_TYPE)) }); // ─── GitHub data layer ──────────────────────────────────────────────────────── -const GITHUB_REPO = { owner: 'nbtca', repo: 'documents', branch: 'main' }; + const GITHUB_TOKEN = process.env['GITHUB_TOKEN'] || process.env['GH_TOKEN']; interface DocItem { @@ -146,16 +161,13 @@ const renderCache = new Map>(); function getDocCategories() { const trans = t(); - const withIcon = (unicodeIcon: string, asciiIcon: string, label: string) => - `${pickIcon(unicodeIcon, asciiIcon)} ${label}`; return [ - { name: withIcon('📖', '[DOC]', trans.docs.categoryTutorial), path: 'tutorial' }, - { name: withIcon('🔧', '[LOG]', trans.docs.categoryRepairLogs), path: '维修日' }, - { name: withIcon('🎉', '[EVT]', trans.docs.categoryEvents), path: '相关活动举办' }, - { name: withIcon('📋', '[PROC]', trans.docs.categoryProcess), path: 'process' }, - { name: withIcon('🛠', '[FIX]', trans.docs.categoryRepair), path: 'repair' }, - { name: withIcon('📦', '[ARC]', trans.docs.categoryArchived), path: 'archived' }, - { name: withIcon('📄', '[README]', trans.docs.categoryReadme), path: 'README.md' }, + { name: trans.docs.categoryTutorial, path: 'tutorial' }, + { name: trans.docs.categoryRepairLogs, path: '维修日' }, + { name: trans.docs.categoryEvents, path: '相关活动举办' }, + { name: trans.docs.categoryProcess, path: 'process' }, + { name: trans.docs.categoryRepair, path: 'repair' }, + { name: trans.docs.categoryArchived, path: 'archived' }, ]; } @@ -202,24 +214,25 @@ async function fetchGitHubDirectory( const url = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/contents/${path}?ref=${GITHUB_REPO.branch}`; try { - const headers: any = { + const headers: Record = { 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'NBTCA-CLI' + 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` }; if (GITHUB_TOKEN) headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; const response = await axios.get(url, { timeout: 10000, headers }); - const items = response.data - .filter((item: any) => + interface GitHubContentItem { name: string; path: string; type: string; sha: string } + const items = (response.data as GitHubContentItem[]) + .filter((item) => !item.name.startsWith('.') && !SKIP_NAMES.has(item.name) && !(item.type === 'file' && !item.name.endsWith('.md')) ) - .map((item: any) => ({ + .map((item) => ({ name: item.name, path: item.path, - type: item.type === 'dir' ? 'dir' : 'file', + type: (item.type === 'dir' ? 'dir' : 'file') as 'dir' | 'file', sha: item.sha })) .sort((a: DocItem, b: DocItem) => { @@ -230,7 +243,7 @@ async function fetchGitHubDirectory( setCacheValue(dirCache, cacheKey, items, DIR_CACHE_TTL_MS); return { data: items, fromCache: false, staleFallback: false }; - } catch (err: any) { + } catch (err: unknown) { const staleCached = getAnyCacheValue(dirCache, cacheKey); if (staleCached) { return { data: staleCached, fromCache: true, staleFallback: true }; @@ -238,11 +251,12 @@ async function fetchGitHubDirectory( const trans = t(); const errorMessage = err instanceof Error ? err.message : String(err); - if (err.response?.status === 403) { - const rateLimitRemaining = err.response.headers['x-ratelimit-remaining']; - const rateLimitReset = err.response.headers['x-ratelimit-reset']; - if (rateLimitRemaining === '0') { - const resetDate = new Date(parseInt(rateLimitReset) * 1000); + const axiosErr = err as { response?: { status?: number; headers?: Record } }; + if (axiosErr.response?.status === 403) { + const rateLimitRemaining = axiosErr.response.headers?.['x-ratelimit-remaining']; + const rateLimitReset = axiosErr.response.headers?.['x-ratelimit-reset']; + if (rateLimitRemaining === '0' && rateLimitReset) { + const resetDate = new Date(Number.parseInt(rateLimitReset, 10) * 1000); throw new Error( `${trans.docs.githubRateLimited.replace('{time}', resetDate.toLocaleTimeString())}\n${trans.docs.githubTokenHint}` ); @@ -266,11 +280,11 @@ async function fetchGitHubRawContent( const url = `https://raw.githubusercontent.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/${GITHUB_REPO.branch}/${path}`; try { - const response = await axios.get(url, { timeout: 15000, headers: { 'User-Agent': 'NBTCA-CLI' } }); + const response = await axios.get(url, { timeout: 15000, headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` } }); const content = String(response.data); setCacheValue(fileCache, path, content, FILE_CACHE_TTL_MS); return { data: content, fromCache: false, staleFallback: false }; - } catch (err: any) { + } catch (err: unknown) { const staleCached = getAnyCacheValue(fileCache, path); if (staleCached) { return { data: staleCached, fromCache: true, staleFallback: true }; @@ -291,7 +305,7 @@ const CONTAINER_ICONS_UNICODE: Record = { info: 'ℹ️', tip: '💡', warning: '⚠️', danger: '🚨', details: '▶️' }; -function cleanMarkdownContent(content: string, type: TerminalType): string { +function cleanMarkdownContent(content: string, type: TerminalType = getTerminalType()): string { let c = content; // 1. YAML frontmatter @@ -349,9 +363,16 @@ function cleanMarkdownContent(content: string, type: TerminalType): string { return c.trim(); } -function extractDocTitle(content: string): string | null { - const match = content.match(/^#\s+(.+)$/m); - return match?.[1]?.trim() ?? null; +function extractDocTitle(rawContent: string, cleanedContent: string): string | null { + // 1. Try YAML frontmatter title: field (before it was stripped) + const fmMatch = rawContent.match(/^---\n[\s\S]*?\n---/m); + if (fmMatch) { + const titleMatch = fmMatch[0].match(/^title:\s*['"]?(.+?)['"]?\s*$/m); + if (titleMatch?.[1]) return titleMatch[1].trim(); + } + // 2. Fallback to first # H1 heading in cleaned content + const h1Match = cleanedContent.match(/^#\s+(.+)$/m); + return h1Match?.[1]?.trim() ?? null; } /** Approximate reading time: ~200 words/min for technical Chinese/English prose. */ @@ -450,7 +471,7 @@ async function browseDirectory(dirPath: string = ''): Promise { const s = createSpinner(dirPath ? `${trans.docs.loadingDir}: ${dirPath}` : trans.docs.loading); const result = await fetchGitHubDirectory(dirPath); const items = result.data; - s.stop(''); + s.stop(dirPath || trans.docs.chooseDoc); if (result.staleFallback) { warning(trans.docs.usingCachedData); @@ -462,15 +483,15 @@ async function browseDirectory(dirPath: string = ''): Promise { } const options = [ - ...(dirPath ? [{ value: '__back__', label: chalk.gray(`${pickIcon('↑', '[..]')} ${trans.docs.upToParent}`) }] : []), + ...(dirPath ? [{ value: '__back__', label: chalk.dim(trans.docs.upToParent) }] : []), ...items.map(item => ({ value: item.path, label: item.type === 'dir' - ? chalk.cyan(`${pickIcon('📁', '[DIR]')} ${item.name}/`) - : chalk.white(`${pickIcon('📄', '[MD]')} ${item.name}`), - hint: item.type === 'dir' ? 'dir' : '.md', + ? chalk.cyan(`${item.name}/`) + : item.name, + hint: item.type === 'dir' ? 'dir' : undefined, })), - { value: '__exit__', label: chalk.gray(`${pickIcon('✕', '[x]')} ${trans.docs.returnToMenu}`) }, + { value: '__exit__', label: chalk.dim(trans.docs.returnToMenu) }, ]; const selected = await select({ @@ -492,9 +513,10 @@ async function browseDirectory(dirPath: string = ''): Promise { await browseDirectory(dirPath); } } - } catch (err: any) { + } catch (err: unknown) { error(trans.docs.loadError); - console.log(chalk.gray(` ${trans.docs.errorHint}: ${err.message}`)); + const errMsg = err instanceof Error ? err.message : String(err); + console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`)); const retry = await confirm({ message: trans.docs.retry }); if (!isCancel(retry) && retry) { @@ -508,6 +530,7 @@ async function browseDirectory(dirPath: string = ''): Promise { async function viewMarkdownFile(filePath: string): Promise { const trans = t(); try { + ensureMarkedConfigured(); const s = createSpinner(`${trans.docs.loading.replace('...', '')}: ${filePath}`); const rawResult = await fetchGitHubRawContent(filePath); @@ -523,8 +546,8 @@ async function viewMarkdownFile(filePath: string): Promise { if (cachedRendered && cachedRendered.fingerprint === fingerprint) { renderedDoc = cachedRendered; } else { - const cleaned = cleanMarkdownContent(rawContent, TERMINAL_TYPE); - const title = extractDocTitle(cleaned) || filePath.split('/').pop() || filePath; + const cleaned = cleanMarkdownContent(rawContent, getTerminalType()); + const title = extractDocTitle(rawContent, cleaned) || filePath.split('/').pop() || filePath; const readTime = estimateReadTime(cleaned); const rendered = await marked(cleaned) as string; renderedDoc = { fingerprint, cleaned, rendered, title, readTime }; @@ -533,7 +556,7 @@ async function viewMarkdownFile(filePath: string): Promise { s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`); - if (HAS_GLOW) { + if (hasGlow()) { await displayWithGlow(renderedDoc.cleaned); } else { await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime); @@ -546,9 +569,9 @@ async function viewMarkdownFile(filePath: string): Promise { const action = await select({ message: trans.docs.chooseAction, options: [ - { value: 'back', label: `${pickIcon('←', '[<]')} ${trans.docs.backToList}` }, - { value: 'reread', label: `${pickIcon('↻', '[r]')} ${trans.docs.reread}` }, - { value: 'browser', label: `${pickIcon('🌐', '[*]')} ${trans.docs.openBrowser}` }, + { value: 'back', label: trans.docs.backToList }, + { value: 'reread', label: trans.docs.reread }, + { value: 'browser', label: trans.docs.openBrowser }, ], }); @@ -556,9 +579,10 @@ async function viewMarkdownFile(filePath: string): Promise { if (action === 'browser') await openDocsInBrowser(filePath); if (action === 'reread') await viewMarkdownFile(filePath); - } catch (err: any) { + } catch (err: unknown) { error(trans.docs.loadError); - console.log(chalk.gray(` ${trans.docs.errorHint}: ${err.message}`)); + const errMsg = err instanceof Error ? err.message : String(err); + console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`)); const openBrowser = await confirm({ message: trans.docs.openBrowserPrompt }); if (!isCancel(openBrowser) && openBrowser) { @@ -585,6 +609,70 @@ export async function openDocsInBrowser(path?: string): Promise { console.log(); } +// ─── Search ──────────────────────────────────────────────────────────────────── + +async function searchDocs(): Promise { + const trans = t(); + const query = await text({ + message: trans.docs.searchPrompt, + placeholder: trans.docs.searchPlaceholder, + }); + + if (isCancel(query) || !query.trim()) return; + + const keyword = query.trim().toLowerCase(); + const s = createSpinner(trans.docs.searching); + + // Fetch all category directories in parallel + const categories = getDocCategories().filter(c => c.path !== 'README.md'); + const results: { name: string; path: string; category: string }[] = []; + + try { + const fetches = await Promise.allSettled( + categories.map(async cat => { + const res = await fetchGitHubDirectory(cat.path); + return { items: res.data, category: cat.name }; + }) + ); + + for (const result of fetches) { + if (result.status !== 'fulfilled') continue; + for (const item of result.value.items) { + const nameLC = item.name.toLowerCase(); + if (nameLC.includes(keyword)) { + results.push({ name: item.name, path: item.path, category: result.value.category }); + } + } + } + + s.stop(`${results.length} ${trans.docs.searchResults}`); + } catch { + s.error(trans.docs.loadError); + return; + } + + if (results.length === 0) { + warning(trans.docs.searchNoResults); + return; + } + + const selected = await select({ + message: trans.docs.chooseDoc, + options: [ + ...results.map(r => ({ + value: r.path, + label: r.name, + hint: r.category, + })), + { value: '__back__', label: chalk.dim(trans.docs.returnToMenu) }, + ], + }); + + if (isCancel(selected) || selected === '__back__') return; + + await viewMarkdownFile(selected); +} + // ─── Menu ───────────────────────────────────────────────────────────────────── export async function showDocsMenu(): Promise { @@ -593,9 +681,10 @@ export async function showDocsMenu(): Promise { const categories = getDocCategories(); const options = [ ...categories.map(cat => ({ value: cat.path, label: cat.name })), - { value: 'refresh-cache', label: chalk.gray(`${pickIcon('♻️', '[r]')} ${trans.docs.refreshCache}`) }, - { value: 'browser', label: chalk.gray(`${pickIcon('🌐', '[*]')} ${trans.docs.openBrowser}`) }, - { value: 'back', label: chalk.gray(`${pickIcon('←', '[^]')} ${trans.docs.returnToMenu}`) }, + { value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) }, + { value: 'refresh-cache', label: chalk.dim(trans.docs.refreshCache) }, + { value: 'browser', label: chalk.dim(trans.docs.openBrowser) }, + { value: 'back', label: chalk.dim(trans.docs.returnToMenu) }, ]; const action = await select({ @@ -605,13 +694,13 @@ export async function showDocsMenu(): Promise { if (isCancel(action) || action === 'back') return; - if (action === 'refresh-cache') { + if (action === 'search') { + await searchDocs(); + } else if (action === 'refresh-cache') { clearDocsCache(); success(trans.docs.cacheCleared); } else if (action === 'browser') { await openDocsInBrowser(); - } else if (action === 'README.md') { - await viewMarkdownFile('README.md'); } else { await browseDirectory(action); } diff --git a/src/features/links.ts b/src/features/links.ts new file mode 100644 index 0000000..50367c8 --- /dev/null +++ b/src/features/links.ts @@ -0,0 +1,46 @@ +/** + * Links — open NBTCA resources in browser + */ + +import open from 'open'; +import chalk from 'chalk'; +import { select, isCancel } from '@clack/prompts'; +import { createSpinner } from '../core/ui.js'; +import { URLS } from '../config/data.js'; +import { t } from '../i18n/index.js'; + +async function openUrl(url: string): Promise { + const trans = t(); + const s = createSpinner(trans.links.opening); + try { + await open(url); + s.stop(trans.links.opened); + } catch { + s.error(trans.links.error); + console.log(chalk.dim(` ${url}`)); + } +} + +export async function showLinksMenu(): Promise { + const trans = t(); + + const selected = await select({ + message: trans.links.choose, + options: [ + { value: URLS.homepage, label: trans.links.website }, + { value: URLS.github, label: trans.links.github }, + { value: URLS.roadmap, label: trans.links.roadmap }, + { value: URLS.repair, label: trans.links.repair }, + { value: '__back__', label: chalk.dim(trans.common.back) }, + ], + }); + + if (isCancel(selected) || selected === '__back__') return; + await openUrl(selected); +} + +/** Direct openers for CLI non-interactive mode */ +export async function openHomepage(): Promise { await openUrl(URLS.homepage); } +export async function openGithub(): Promise { await openUrl(URLS.github); } +export async function openRoadmap(): Promise { await openUrl(URLS.roadmap); } +export async function openRepairService(): Promise { await openUrl(URLS.repair); } diff --git a/src/features/repair.ts b/src/features/repair.ts deleted file mode 100644 index 1a40b73..0000000 --- a/src/features/repair.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * 维修服务模块 - * 打开维修服务网页 - */ - -import open from 'open'; -import chalk from 'chalk'; -import { createSpinner } from '../core/ui.js'; -import { URLS } from '../config/data.js'; -import { t } from '../i18n/index.js'; - -// Derived from the single URL source — index.ts depends on this name -export const REPAIR_URL = URLS.repair; - -/** - * 打开维修服务页面 - */ -export async function openRepairService(): Promise { - const trans = t(); - const s = createSpinner(trans.repair.opening); - try { - await open(REPAIR_URL); - s.stop(trans.repair.opened); - console.log(); - } catch { - s.error(trans.repair.error); - console.log(chalk.yellow(' ' + trans.repair.errorHint)); - console.log(); - } -} diff --git a/src/features/settings.ts b/src/features/settings.ts new file mode 100644 index 0000000..1cbb622 --- /dev/null +++ b/src/features/settings.ts @@ -0,0 +1,136 @@ +/** + * Unified settings — language, theme, about + */ + +import { select, isCancel, note } from '@clack/prompts'; +import chalk from 'chalk'; +import { + applyColorModePreference, + loadPreferences, + resetPreferences, + setColorMode, + setIconMode, + type ColorMode, + type IconMode, +} from '../config/preferences.js'; +import { pickIcon } from '../core/icons.js'; +import { resetIconCache } from '../core/icons.js'; +import { padEndV } from '../core/text.js'; +import { success, warning } from '../core/ui.js'; +import { APP_INFO, URLS } from '../config/data.js'; +import { t, getCurrentLanguage, setLanguage, clearTranslationCache, type Language } from '../i18n/index.js'; + +function notifyResult(saved: boolean, successMsg: string, warningMsg: string): void { + if (saved) { + success(successMsg); + } else { + warning(warningMsg); + } +} + +function showAbout(): void { + const trans = t(); + const pad = 12; + const row = (label: string, value: string) => `${chalk.dim(padEndV(label, pad))}${value}`; + const link = (label: string, url: string) => row(label, chalk.cyan(url)); + + const content = [ + row(trans.about.project, APP_INFO.name), + row(trans.about.version, `v${APP_INFO.version}`), + row(trans.about.description, APP_INFO.fullDescription), + '', + link(trans.about.github, APP_INFO.repository), + link(trans.about.website, URLS.homepage), + link(trans.about.email, URLS.email), + '', + row(trans.about.license, `MIT ${pickIcon('·', '|')} ${trans.about.author}: m1ngsama`), + ].join('\n'); + + note(content, trans.about.title); +} + +export async function showSettingsMenu(): Promise { + while (true) { + const trans = t(); + const prefs = loadPreferences(); + const currentLang = getCurrentLanguage(); + + const action = await select({ + message: trans.theme.chooseAction, + options: [ + { value: 'language', label: trans.language.selectLanguage.replace(':', ''), hint: currentLang === 'zh' ? trans.language.zh : trans.language.en }, + { value: 'icon', label: trans.theme.iconMode, hint: prefs.iconMode }, + { value: 'color', label: trans.theme.colorMode, hint: prefs.colorMode }, + { value: 'reset', label: trans.theme.reset }, + { value: 'about', label: trans.about.title }, + { value: 'back', label: chalk.dim(trans.common.back) }, + ], + }); + + if (isCancel(action) || action === 'back') return; + + if (action === 'about') { + showAbout(); + continue; + } + + if (action === 'language') { + const language = await select({ + message: trans.language.selectLanguage, + options: [ + { value: 'zh' as Language, label: trans.language.zh, hint: currentLang === 'zh' ? trans.common.current : undefined }, + { value: 'en' as Language, label: trans.language.en, hint: currentLang === 'en' ? trans.common.current : undefined }, + ], + initialValue: currentLang, + }); + if (isCancel(language)) continue; + if (language !== currentLang) { + const saved = setLanguage(language); + clearTranslationCache(); + notifyResult(saved, t().language.changed, t().language.changedSessionOnly); + } + continue; + } + + if (action === 'icon') { + const mode = await select({ + message: trans.theme.chooseIconMode, + options: [ + { value: 'auto', label: trans.theme.modeAuto, hint: prefs.iconMode === 'auto' ? trans.common.current : undefined }, + { value: 'ascii', label: trans.theme.modeAscii, hint: prefs.iconMode === 'ascii' ? trans.common.current : undefined }, + { value: 'unicode', label: trans.theme.modeUnicode, hint: prefs.iconMode === 'unicode' ? trans.common.current : undefined }, + ], + initialValue: prefs.iconMode, + }); + if (isCancel(mode)) continue; + const saved = setIconMode(mode); + resetIconCache(); + notifyResult(saved, trans.theme.updated, trans.theme.updatedSessionOnly); + continue; + } + + if (action === 'color') { + const mode = await select({ + message: trans.theme.chooseColorMode, + options: [ + { value: 'auto', label: trans.theme.modeAuto, hint: prefs.colorMode === 'auto' ? trans.common.current : undefined }, + { value: 'on', label: trans.theme.modeOn, hint: prefs.colorMode === 'on' ? trans.common.current : undefined }, + { value: 'off', label: trans.theme.modeOff, hint: prefs.colorMode === 'off' ? trans.common.current : undefined }, + ], + initialValue: prefs.colorMode, + }); + if (isCancel(mode)) continue; + const saved = setColorMode(mode); + applyColorModePreference(false); + notifyResult(saved, trans.theme.updated, trans.theme.updatedSessionOnly); + continue; + } + + if (action === 'reset') { + const saved = resetPreferences(); + resetIconCache(); + applyColorModePreference(false); + notifyResult(saved, trans.theme.reset, trans.theme.resetSessionOnly); + } + } +} diff --git a/src/features/status.ts b/src/features/status.ts index 694e902..c786dc5 100644 --- a/src/features/status.ts +++ b/src/features/status.ts @@ -1,9 +1,9 @@ import axios from 'axios'; import chalk from 'chalk'; -import { URLS } from '../config/data.js'; +import { APP_INFO, URLS } from '../config/data.js'; import { pickIcon } from '../core/icons.js'; import { padEndV } from '../core/text.js'; -import { createSpinner, success, warning } from '../core/ui.js'; +import { createSpinner } from '../core/ui.js'; import { t } from '../i18n/index.js'; export interface ServiceStatus { @@ -20,13 +20,16 @@ export interface StatusCheckOptions { retries?: number; } -const SERVICE_TARGETS = [ - { name: 'Website', url: URLS.homepage }, - { name: 'Docs', url: URLS.docs }, - { name: 'Calendar', url: URLS.calendar }, - { name: 'GitHub', url: URLS.github }, - { name: 'Roadmap', url: URLS.roadmap }, -] as const; +function getServiceTargets() { + const trans = t(); + return [ + { name: trans.status.serviceWebsite, url: URLS.homepage }, + { name: trans.status.serviceDocs, url: URLS.docs }, + { name: trans.status.serviceCalendar, url: URLS.calendar }, + { name: trans.status.serviceGithub, url: URLS.github }, + { name: trans.status.serviceRoadmap, url: URLS.roadmap }, + ]; +} async function checkService(name: string, url: string, timeoutMs: number): Promise { const start = Date.now(); @@ -35,7 +38,7 @@ async function checkService(name: string, url: string, timeoutMs: number): Promi timeout: timeoutMs, maxRedirects: 5, validateStatus: () => true, - headers: { 'User-Agent': 'NBTCA-CLI/2.4.0' }, + headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` }, }); const latencyMs = Date.now() - start; const ok = response.status >= 200 && response.status < 400; @@ -72,7 +75,7 @@ export async function checkServices(options: StatusCheckOptions = {}): Promise checkServiceWithRetry(service.name, service.url, timeoutMs, retries)) + getServiceTargets().map((service) => checkServiceWithRetry(service.name, service.url, timeoutMs, retries)) ); } @@ -115,7 +118,6 @@ export function renderServiceStatusTable(items: ServiceStatus[], options?: { col const trans = t(); const nameWidth = 10; const statusWidth = 9; - const codeWidth = 7; const latencyWidth = 10; const h = pickIcon('─', '-'); @@ -130,24 +132,22 @@ export function renderServiceStatusTable(items: ServiceStatus[], options?: { col const bottomMid = pickIcon('┴', '+'); const bottomRight = pickIcon('┘', '+'); - const top = `${topLeft}${h.repeat(nameWidth + 2)}${topMid}${h.repeat(statusWidth + 2)}${topMid}${h.repeat(codeWidth + 2)}${topMid}${h.repeat(latencyWidth + 2)}${topMid}${h.repeat(34)}${topRight}`; - const divider = `${midLeft}${h.repeat(nameWidth + 2)}${midMid}${h.repeat(statusWidth + 2)}${midMid}${h.repeat(codeWidth + 2)}${midMid}${h.repeat(latencyWidth + 2)}${midMid}${h.repeat(34)}${midRight}`; - const bottom = `${bottomLeft}${h.repeat(nameWidth + 2)}${bottomMid}${h.repeat(statusWidth + 2)}${bottomMid}${h.repeat(codeWidth + 2)}${bottomMid}${h.repeat(latencyWidth + 2)}${bottomMid}${h.repeat(34)}${bottomRight}`; + const top = `${topLeft}${h.repeat(nameWidth + 2)}${topMid}${h.repeat(statusWidth + 2)}${topMid}${h.repeat(latencyWidth + 2)}${topRight}`; + const divider = `${midLeft}${h.repeat(nameWidth + 2)}${midMid}${h.repeat(statusWidth + 2)}${midMid}${h.repeat(latencyWidth + 2)}${midRight}`; + const bottom = `${bottomLeft}${h.repeat(nameWidth + 2)}${bottomMid}${h.repeat(statusWidth + 2)}${bottomMid}${h.repeat(latencyWidth + 2)}${bottomRight}`; const header = - `${v} ${padEndV(trans.status.service, nameWidth)} ${v} ${padEndV(trans.status.health, statusWidth)} ${v} ${padEndV(trans.status.code, codeWidth)} ${v} ${padEndV(trans.status.latency, latencyWidth)} ${v} ${padEndV(trans.status.url, 34)} ${v}`; + `${v} ${padEndV(trans.status.service, nameWidth)} ${v} ${padEndV(trans.status.health, statusWidth)} ${v} ${padEndV(trans.status.latency, latencyWidth)} ${v}`; const lines = [dim(top), header, dim(divider)]; for (const item of items) { const statusLabel = item.ok ? green(`${pickIcon('●', 'OK')} ${trans.status.up}`) : red(`${pickIcon('●', '!!')} ${trans.status.down}`); - const code = item.statusCode ? String(item.statusCode) : '-'; const latency = item.latencyMs != null ? `${item.latencyMs}ms` : '-'; - const url = item.url.length > 34 ? `${item.url.slice(0, 31)}...` : item.url; lines.push( - `${v} ${padEndV(cyan(item.name), nameWidth)} ${v} ${padEndV(statusLabel, statusWidth)} ${v} ${padEndV(code, codeWidth)} ${v} ${padEndV(latency, latencyWidth)} ${v} ${padEndV(url, 34)} ${v}` + `${v} ${padEndV(cyan(item.name), nameWidth)} ${v} ${padEndV(statusLabel, statusWidth)} ${v} ${padEndV(latency, latencyWidth)} ${v}` ); } lines.push(dim(bottom)); @@ -165,10 +165,5 @@ export async function showServiceStatus(): Promise { spinner.stop(trans.status.summaryOk); } console.log(renderServiceStatusTable(items, { color: !!process.stdout.isTTY })); - if (hasFailures) { - warning(trans.status.summaryFail); - } else { - success(trans.status.summaryOk); - } return items; } diff --git a/src/features/theme.ts b/src/features/theme.ts index 35ba1a2..de9f389 100644 --- a/src/features/theme.ts +++ b/src/features/theme.ts @@ -1,5 +1,7 @@ -import chalk from 'chalk'; -import { isCancel, note, select } from '@clack/prompts'; +/** + * Theme CLI command handler (non-interactive) + */ + import { applyColorModePreference, loadPreferences, @@ -9,8 +11,7 @@ import { type ColorMode, type IconMode, } from '../config/preferences.js'; -import { pickIcon } from '../core/icons.js'; -import { success, warning } from '../core/ui.js'; +import { resetIconCache } from '../core/icons.js'; import { t } from '../i18n/index.js'; export interface ThemeCommandResult { @@ -21,106 +22,10 @@ export interface ThemeCommandResult { const ICON_MODES: IconMode[] = ['auto', 'ascii', 'unicode']; const COLOR_MODES: ColorMode[] = ['auto', 'on', 'off']; -export function printThemeSummary(): void { +function formatThemeSummary(): string { const trans = t(); const prefs = loadPreferences(); - const content = [ - `${chalk.dim(trans.theme.iconMode + ':')} ${prefs.iconMode}`, - `${chalk.dim(trans.theme.colorMode + ':')} ${prefs.colorMode}`, - '', - chalk.dim('NBTCA_ICON_MODE / NBTCA_COLOR_MODE can override saved preferences'), - ].join('\n'); - note(content, trans.theme.current); -} - -function notifyThemeChange(saved: boolean, successMessage: string, warningMessage: string): void { - if (saved) { - success(successMessage); - } else { - warning(warningMessage); - } -} - -export async function showThemeMenu(): Promise { - while (true) { - const trans = t(); - const prefs = loadPreferences(); - - const action = await select({ - message: trans.theme.chooseAction, - options: [ - { - value: 'summary', - label: `${pickIcon('ℹ️', '[i]')} ${trans.theme.current}`, - hint: `${trans.theme.iconMode}: ${prefs.iconMode} | ${trans.theme.colorMode}: ${prefs.colorMode}`, - }, - { - value: 'icon', - label: `${pickIcon('🎨', '[*]')} ${trans.theme.iconMode}`, - hint: prefs.iconMode, - }, - { - value: 'color', - label: `${pickIcon('🖌️', '[*]')} ${trans.theme.colorMode}`, - hint: prefs.colorMode, - }, - { - value: 'reset', - label: `${pickIcon('♻️', '[r]')} ${trans.theme.reset}`, - }, - { - value: 'back', - label: `${pickIcon('←', '[^]')} ${trans.theme.backToMenu}`, - }, - ], - }); - - if (isCancel(action) || action === 'back') return; - - if (action === 'summary') { - printThemeSummary(); - continue; - } - - if (action === 'icon') { - const mode = await select({ - message: trans.theme.chooseIconMode, - options: [ - { value: 'auto', label: trans.theme.modeAuto, hint: prefs.iconMode === 'auto' ? `${pickIcon('✓', '*')} current` : undefined }, - { value: 'ascii', label: trans.theme.modeAscii, hint: prefs.iconMode === 'ascii' ? `${pickIcon('✓', '*')} current` : undefined }, - { value: 'unicode', label: trans.theme.modeUnicode, hint: prefs.iconMode === 'unicode' ? `${pickIcon('✓', '*')} current` : undefined }, - ], - initialValue: prefs.iconMode, - }); - if (isCancel(mode)) continue; - const saved = setIconMode(mode); - notifyThemeChange(saved, trans.theme.updated, trans.theme.updatedSessionOnly); - continue; - } - - if (action === 'color') { - const mode = await select({ - message: trans.theme.chooseColorMode, - options: [ - { value: 'auto', label: trans.theme.modeAuto, hint: prefs.colorMode === 'auto' ? `${pickIcon('✓', '*')} current` : undefined }, - { value: 'on', label: trans.theme.modeOn, hint: prefs.colorMode === 'on' ? `${pickIcon('✓', '*')} current` : undefined }, - { value: 'off', label: trans.theme.modeOff, hint: prefs.colorMode === 'off' ? `${pickIcon('✓', '*')} current` : undefined }, - ], - initialValue: prefs.colorMode, - }); - if (isCancel(mode)) continue; - const saved = setColorMode(mode); - applyColorModePreference(false); - notifyThemeChange(saved, trans.theme.updated, trans.theme.updatedSessionOnly); - continue; - } - - if (action === 'reset') { - const saved = resetPreferences(); - applyColorModePreference(false); - notifyThemeChange(saved, trans.theme.reset, trans.theme.resetSessionOnly); - } - } + return `${trans.theme.iconMode}: ${prefs.iconMode}, ${trans.theme.colorMode}: ${prefs.colorMode}`; } export function runThemeCommand(args: string[]): ThemeCommandResult { @@ -128,12 +33,12 @@ export function runThemeCommand(args: string[]): ThemeCommandResult { const [scope, value] = args; if (!scope) { - printThemeSummary(); - return { ok: true, message: '' }; + return { ok: true, message: formatThemeSummary() }; } if (scope === 'reset') { const saved = resetPreferences(); + resetIconCache(); applyColorModePreference(false); const message = saved ? trans.theme.reset : trans.theme.resetSessionOnly; return { ok: true, message }; @@ -145,6 +50,7 @@ export function runThemeCommand(args: string[]): ThemeCommandResult { return { ok: false, message: `${trans.theme.invalidValue} auto, ascii, unicode` }; } const saved = setIconMode(mode); + resetIconCache(); return { ok: true, message: saved ? trans.theme.updated : trans.theme.updatedSessionOnly }; } diff --git a/src/features/update.ts b/src/features/update.ts new file mode 100644 index 0000000..052b724 --- /dev/null +++ b/src/features/update.ts @@ -0,0 +1,80 @@ +/** + * Version update checker + * Non-blocking check against npm registry for newer versions. + */ + +import chalk from 'chalk'; +import { APP_INFO } from '../config/data.js'; +import { t } from '../i18n/index.js'; + +const NPM_REGISTRY_URL = `https://registry.npmjs.org/@nbtca/prompt/latest`; + +interface NpmLatest { + version: string; +} + +/** + * Fetch latest version from npm registry. + * Returns null on any failure (network, timeout, parse). + */ +async function fetchLatestVersion(): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const res = await fetch(NPM_REGISTRY_URL, { + signal: controller.signal, + headers: { 'Accept': 'application/json' }, + }); + clearTimeout(timeout); + if (!res.ok) return null; + const data = (await res.json()) as NpmLatest; + return data.version ?? null; + } catch { + return null; + } +} + +/** + * Compare semver strings. Returns true if remote > local. + */ +function isNewer(local: string, remote: string): boolean { + const parse = (v: string) => v.split('.').map(Number); + const l = parse(local); + const r = parse(remote); + for (let i = 0; i < 3; i++) { + if ((r[i] ?? 0) > (l[i] ?? 0)) return true; + if ((r[i] ?? 0) < (l[i] ?? 0)) return false; + } + return false; +} + +/** + * Non-blocking update check for TUI startup. + * Resolves to a notification string or null. + */ +export async function checkForUpdate(): Promise { + const latest = await fetchLatestVersion(); + if (!latest || !isNewer(APP_INFO.version, latest)) return null; + const trans = t(); + return `${trans.update.available.replace('{latest}', latest).replace('{current}', APP_INFO.version)} ${chalk.dim(trans.update.command)}`; +} + +/** + * Explicit update check command (nbtca update). + */ +export async function runUpdateCheck(): Promise { + const trans = t(); + const latest = await fetchLatestVersion(); + + if (!latest) { + console.log(chalk.yellow(trans.update.checkFailed)); + return; + } + + if (isNewer(APP_INFO.version, latest)) { + console.log(chalk.yellow(`${trans.update.available.replace('{latest}', latest).replace('{current}', APP_INFO.version)}`)); + console.log(chalk.dim(trans.update.command)); + } else { + console.log(chalk.green(`${trans.update.upToDate.replace('{version}', APP_INFO.version)}`)); + } +} diff --git a/src/features/website.ts b/src/features/website.ts deleted file mode 100644 index 6b7a8f2..0000000 --- a/src/features/website.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 官网访问模块 - * 打开NBTCA官方网站或GitHub - */ - -import open from 'open'; -import chalk from 'chalk'; -import { createSpinner } from '../core/ui.js'; -import { URLS } from '../config/data.js'; -import { t } from '../i18n/index.js'; - -// Re-export from the single URL source — index.ts depends on this name -export const WEBSITE_URLS = URLS; - -/** - * 打开指定URL - */ -export async function openWebsite(url: string): Promise { - const trans = t(); - const s = createSpinner(trans.website.opening); - try { - await open(url); - s.stop(trans.website.opened); - console.log(chalk.gray(` ${url}`)); - console.log(); - } catch { - s.error(trans.website.error); - console.log(chalk.yellow(' ' + trans.website.errorHint + ': ') + chalk.cyan(url)); - console.log(); - } -} - -/** - * Open NBTCA homepage - */ -export async function openHomepage(): Promise { - await openWebsite(WEBSITE_URLS.homepage); -} - -/** - * Open GitHub page - */ -export async function openGithub(): Promise { - await openWebsite(WEBSITE_URLS.github); -} - -/** - * Open NBTCA Roadmap project - */ -export async function openRoadmap(): Promise { - await openWebsite(WEBSITE_URLS.roadmap); -} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index e8ffc72..9f9b461 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -7,6 +7,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { getConfigDir } from '../config/paths.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -26,34 +27,19 @@ export interface Translations { error: string; success: string; goodbye: string; + current: string; }; menu: { - title: string; events: string; eventsDesc: string; - repair: string; - repairDesc: string; docs: string; docsDesc: string; - website: string; - websiteDesc: string; - github: string; - githubDesc: string; - roadmap: string; - roadmapDesc: string; - links: string; - linksDesc: string; - chooseLink: string; - about: string; - aboutDesc: string; status: string; statusDesc: string; - language: string; - languageDesc: string; - theme: string; - themeDesc: string; - navigationHint: string; - quickCommandHint: string; + links: string; + linksDesc: string; + settings: string; + settingsDesc: string; chooseAction: string; }; about: { @@ -68,8 +54,6 @@ export interface Translations { author: string; }; calendar: { - title: string; - subtitle: string; loading: string; noEvents: string; error: string; @@ -78,10 +62,13 @@ export interface Translations { dateTime: string; eventName: string; location: string; + untitledEvent: string; + tbdLocation: string; + subscribeHint: string; + viewDetail: string; + noDescription: string; }; docs: { - title: string; - subtitle: string; loading: string; loadingDir: string; categoryTutorial: string; @@ -113,33 +100,27 @@ export interface Translations { browserError: string; browserErrorHint: string; retry: string; - pagerNotAvailable: string; endOfDocument: string; - terminalProfile: string; - terminalSupport: string; - terminalBasic: string; - terminalEnhanced: string; - terminalAdvanced: string; - navigationHint: string; githubRateLimited: string; githubForbidden: string; githubTokenHint: string; fetchDirFailed: string; fetchFileFailed: string; + searchPrompt: string; + searchPlaceholder: string; + searching: string; + searchResults: string; + searchNoResults: string; }; - repair: { - title: string; - subtitle: string; - opening: string; - opened: string; - error: string; - errorHint: string; - }; - website: { + links: { + choose: string; + website: string; + github: string; + roadmap: string; + repair: string; opening: string; opened: string; error: string; - errorHint: string; }; status: { checking: string; @@ -152,6 +133,11 @@ export interface Translations { url: string; up: string; down: string; + serviceWebsite: string; + serviceDocs: string; + serviceCalendar: string; + serviceGithub: string; + serviceRoadmap: string; watchStarted: string; watchUpdated: string; watchHint: string; @@ -183,14 +169,18 @@ export interface Translations { invalidValue: string; }; language: { - title: string; - currentLanguage: string; selectLanguage: string; zh: string; en: string; changed: string; changedSessionOnly: string; }; + update: { + available: string; + upToDate: string; + checkFailed: string; + command: string; + }; } /** @@ -198,14 +188,6 @@ export interface Translations { */ let currentLanguage: Language = 'zh'; // Default to Chinese -/** - * Get configuration directory path - */ -function getConfigDir(): string { - const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; - return path.join(homeDir, '.nbtca'); -} - /** * Get language configuration file path */ @@ -219,14 +201,12 @@ function getLanguageConfigPath(): string { export function loadLanguagePreference(): Language { try { const configPath = getLanguageConfigPath(); - if (fs.existsSync(configPath)) { - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - if (config.language === 'zh' || config.language === 'en') { - currentLanguage = config.language; - } + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + if (config.language === 'zh' || config.language === 'en') { + currentLanguage = config.language; } - } catch (err) { - // If loading fails, use default (Chinese) + } catch { + // If loading fails (file missing or invalid), use default (Chinese) } return currentLanguage; } @@ -244,7 +224,7 @@ export function saveLanguagePreference(language: Language): boolean { fs.writeFileSync(configPath, JSON.stringify({ language }, null, 2)); currentLanguage = language; return true; - } catch (err) { + } catch { return false; } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index abeba93..450b0d7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -7,35 +7,20 @@ "loading": "Loading...", "error": "Error", "success": "Success", - "goodbye": "Goodbye!" + "goodbye": "Goodbye!", + "current": "current" }, "menu": { - "title": "Choose an action", - "events": "Recent Events", - "eventsDesc": "View upcoming events in next 30 days", - "repair": "Repair Service", - "repairDesc": "Computer repair and software installation", - "docs": "Knowledge Base", - "docsDesc": "Technical docs and tutorials", - "website": "Official Website", - "websiteDesc": "Visit NBTCA homepage", - "github": "GitHub", - "githubDesc": "Open source projects and code", - "roadmap": "Roadmap", - "roadmapDesc": "Open NBTCA roadmap project board", + "events": "Events", + "eventsDesc": "Upcoming activities", + "docs": "Docs", + "docsDesc": "Knowledge base", + "status": "Status", + "statusDesc": "Service health", "links": "Links", - "linksDesc": "Website | GitHub | Roadmap", - "chooseLink": "Choose a link:", - "about": "About", - "aboutDesc": "Project info and help", - "status": "Service Status", - "statusDesc": "Check service health in terminal", - "language": "Language", - "languageDesc": "Change display language", - "theme": "Theme", - "themeDesc": "Configure icons and color mode", - "navigationHint": "Navigation: j/k or up/down | Jump: g/G | Quit: q or Ctrl+C", - "quickCommandHint": "Tip: use `nbtca --help` for script-friendly mode", + "linksDesc": "Website, GitHub, Roadmap", + "settings": "Settings", + "settingsDesc": "Language, theme, about", "chooseAction": "Choose an action" }, "about": { @@ -50,8 +35,6 @@ "author": "Author" }, "calendar": { - "title": "Recent Events", - "subtitle": "(Next 30 days)", "loading": "Loading event calendar...", "noEvents": "No upcoming events", "error": "Failed to load event calendar", @@ -59,11 +42,14 @@ "eventsFound": "events found", "dateTime": "Date & Time", "eventName": "Event Name", - "location": "Location" + "location": "Location", + "untitledEvent": "Untitled Event", + "tbdLocation": "TBD", + "subscribeHint": "Subscribe to calendar", + "viewDetail": "Select an event for details:", + "noDescription": "No additional details" }, "docs": { - "title": "Knowledge Base", - "subtitle": "Browse documentation from terminal or open in browser", "loading": "Loading documentation list...", "loadingDir": "Loading directory", "categoryTutorial": "Tutorials", @@ -95,33 +81,27 @@ "browserError": "Failed to open browser", "browserErrorHint": "Please visit manually: https://docs.nbtca.space", "retry": "Retry?", - "pagerNotAvailable": "Pager not available, using standard output", "endOfDocument": "End of document - Press q to quit", - "terminalProfile": "Terminal profile", - "terminalSupport": "Support", - "terminalBasic": "Basic", - "terminalEnhanced": "Enhanced", - "terminalAdvanced": "Advanced", - "navigationHint": "Navigation: j/k or up/down | Enter: open | q/Ctrl+C: quit", "githubRateLimited": "GitHub API rate limit reached. Resets at {time}.", "githubForbidden": "GitHub API access denied (403).", "githubTokenHint": "Tip: set GITHUB_TOKEN for a higher rate limit.", "fetchDirFailed": "Failed to fetch directory: {error}", - "fetchFileFailed": "Failed to fetch file: {error}" - }, - "repair": { - "title": "Repair Service", - "subtitle": "Computer repair and software installation support", - "opening": "Opening repair service page...", - "opened": "Repair service page opened in browser", - "error": "Failed to open browser", - "errorHint": "Please visit manually: https://nbtca.space/repair" + "fetchFileFailed": "Failed to fetch file: {error}", + "searchPrompt": "Search documents by title:", + "searchPlaceholder": "Enter keyword...", + "searching": "Searching documents...", + "searchResults": "results found", + "searchNoResults": "No documents match your search" }, - "website": { - "opening": "Opening website...", + "links": { + "choose": "Open a link:", + "website": "Official Website", + "github": "GitHub", + "roadmap": "Roadmap", + "repair": "Repair Service", + "opening": "Opening...", "opened": "Opened in browser", - "error": "Failed to open browser", - "errorHint": "Please visit manually" + "error": "Failed to open browser" }, "status": { "checking": "Checking service status...", @@ -134,6 +114,11 @@ "url": "URL", "up": "UP", "down": "DOWN", + "serviceWebsite": "Website", + "serviceDocs": "Docs", + "serviceCalendar": "Calendar", + "serviceGithub": "GitHub", + "serviceRoadmap": "Roadmap", "watchStarted": "Starting status watch (every {seconds}s)", "watchUpdated": "Last updated", "watchHint": "Press Ctrl+C to stop", @@ -146,7 +131,7 @@ }, "theme": { "current": "Current theme settings", - "chooseAction": "Theme settings", + "chooseAction": "Settings", "chooseIconMode": "Choose icon mode:", "chooseColorMode": "Choose color mode:", "modeAuto": "Auto", @@ -165,12 +150,16 @@ "invalidValue": "Invalid value. Use one of:" }, "language": { - "title": "Language Settings", - "currentLanguage": "Current Language", "selectLanguage": "Select a language:", "zh": "简体中文 (Simplified Chinese)", "en": "English", "changed": "Language changed successfully", "changedSessionOnly": "Language changed for this session only (failed to save config)" + }, + "update": { + "available": "Update available: {current} → {latest}", + "upToDate": "You are on the latest version ({version})", + "checkFailed": "Could not check for updates", + "command": "Run: npm i -g @nbtca/prompt" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cd08e2a..fba2186 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -7,35 +7,20 @@ "loading": "加载中...", "error": "错误", "success": "成功", - "goodbye": "再见!" + "goodbye": "再见!", + "current": "当前" }, "menu": { - "title": "选择操作", - "events": "近期活动", - "eventsDesc": "查看未来30天的活动", - "repair": "维修服务", - "repairDesc": "电脑维修和软件安装", - "docs": "知识库", - "docsDesc": "技术文档和教程", - "website": "官方网站", - "websiteDesc": "访问 NBTCA 主页", - "github": "GitHub", - "githubDesc": "开源项目和代码", - "roadmap": "Roadmap 看板", - "roadmapDesc": "打开 NBTCA 路线图项目看板", + "events": "活动", + "eventsDesc": "近期活动安排", + "docs": "文档", + "docsDesc": "知识库", + "status": "状态", + "statusDesc": "服务健康检查", "links": "链接", - "linksDesc": "主页 / GitHub / 路线图", - "chooseLink": "选择链接:", - "about": "关于", - "aboutDesc": "项目信息和帮助", - "status": "服务状态", - "statusDesc": "在终端检查服务健康状态", - "language": "切换语言", - "languageDesc": "更改显示语言", - "theme": "主题设置", - "themeDesc": "配置图标和颜色模式", - "navigationHint": "导航: j/k 或 up/down | 跳转: g/G | 退出: q 或 Ctrl+C", - "quickCommandHint": "提示: 使用 `nbtca --help` 获取可脚本化模式", + "linksDesc": "官网、GitHub、路线图", + "settings": "设置", + "settingsDesc": "语言、主题、关于", "chooseAction": "选择一个操作" }, "about": { @@ -50,8 +35,6 @@ "author": "作者" }, "calendar": { - "title": "近期活动", - "subtitle": "(最近30天)", "loading": "正在获取活动日历...", "noEvents": "近期暂无活动安排", "error": "无法获取活动日历", @@ -59,11 +42,14 @@ "eventsFound": "个活动", "dateTime": "日期时间", "eventName": "活动名称", - "location": "地点" + "location": "地点", + "untitledEvent": "未命名活动", + "tbdLocation": "待定", + "subscribeHint": "订阅日历", + "viewDetail": "选择活动查看详情:", + "noDescription": "暂无详细信息" }, "docs": { - "title": "知识库", - "subtitle": "从终端浏览文档或在浏览器中打开", "loading": "正在加载文档列表...", "loadingDir": "正在加载目录", "categoryTutorial": "教程", @@ -95,33 +81,27 @@ "browserError": "无法打开浏览器", "browserErrorHint": "请手动访问: https://docs.nbtca.space", "retry": "是否重试?", - "pagerNotAvailable": "Pager不可用,使用标准输出", "endOfDocument": "文档结束 - 按 q 退出", - "terminalProfile": "终端类型", - "terminalSupport": "支持", - "terminalBasic": "基础", - "terminalEnhanced": "增强", - "terminalAdvanced": "高级", - "navigationHint": "导航: j/k 或 up/down | Enter: 打开 | q/Ctrl+C: 退出", "githubRateLimited": "GitHub API 速率限制已达上限,将在 {time} 重置。", "githubForbidden": "GitHub API 拒绝访问 (403)。", "githubTokenHint": "提示: 设置 GITHUB_TOKEN 环境变量可获得更高的速率限制。", "fetchDirFailed": "无法获取目录内容: {error}", - "fetchFileFailed": "无法获取文件内容: {error}" - }, - "repair": { - "title": "维修服务", - "subtitle": "电脑维修和软件安装支持", - "opening": "正在打开维修服务页面...", - "opened": "已在浏览器中打开维修服务页面", - "error": "无法打开浏览器", - "errorHint": "请手动访问: https://nbtca.space/repair" + "fetchFileFailed": "无法获取文件内容: {error}", + "searchPrompt": "按标题搜索文档:", + "searchPlaceholder": "输入关键词...", + "searching": "正在搜索文档...", + "searchResults": "个结果", + "searchNoResults": "未找到匹配的文档" }, - "website": { - "opening": "正在打开网站...", + "links": { + "choose": "打开链接:", + "website": "官方网站", + "github": "GitHub", + "roadmap": "路线图", + "repair": "维修服务", + "opening": "正在打开...", "opened": "已在浏览器中打开", - "error": "无法打开浏览器", - "errorHint": "请手动访问" + "error": "无法打开浏览器" }, "status": { "checking": "正在检查服务状态...", @@ -134,6 +114,11 @@ "url": "URL", "up": "正常", "down": "异常", + "serviceWebsite": "官网", + "serviceDocs": "文档", + "serviceCalendar": "日历", + "serviceGithub": "GitHub", + "serviceRoadmap": "看板", "watchStarted": "开始监控服务状态(每 {seconds} 秒)", "watchUpdated": "最近更新", "watchHint": "按 Ctrl+C 停止", @@ -146,7 +131,7 @@ }, "theme": { "current": "当前主题设置", - "chooseAction": "主题设置", + "chooseAction": "设置", "chooseIconMode": "选择图标模式:", "chooseColorMode": "选择颜色模式:", "modeAuto": "自动", @@ -165,12 +150,16 @@ "invalidValue": "无效取值,可选:" }, "language": { - "title": "语言设置", - "currentLanguage": "当前语言", "selectLanguage": "选择语言:", "zh": "简体中文", "en": "English", "changed": "语言已切换", "changedSessionOnly": "语言仅在当前会话生效(无法写入配置文件)" + }, + "update": { + "available": "有新版本可用: {current} → {latest}", + "upToDate": "已是最新版本 ({version})", + "checkFailed": "无法检查更新", + "command": "运行: npm i -g @nbtca/prompt" } } diff --git a/src/index.ts b/src/index.ts index 9541ea8..3864266 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,20 +3,22 @@ */ import chalk from 'chalk'; +import open from 'open'; import { main } from './main.js'; -import { runMenuAction, type MenuAction } from './core/menu.js'; import { fetchEvents, renderEventsTable, serializeEvents } from './features/calendar.js'; import { checkServices, countServiceHealth, hasServiceFailures, renderServiceStatusTable, serializeServiceStatus } from './features/status.js'; import { pickIcon } from './core/icons.js'; import { applyColorModePreference } from './config/preferences.js'; import { openDocsInBrowser } from './features/docs.js'; import { runThemeCommand } from './features/theme.js'; -import { REPAIR_URL } from './features/repair.js'; -import { WEBSITE_URLS } from './features/website.js'; import { setLanguage, t, type Language } from './i18n/index.js'; import { clearScreen } from './core/ui.js'; +import { APP_INFO, URLS } from './config/data.js'; +import { runUpdateCheck } from './features/update.js'; -const ACTION_ALIASES: Record = { +type CliAction = 'events' | 'status' | 'docs' | 'repair' | 'website' | 'github' | 'roadmap' | 'about'; + +const ACTION_ALIASES: Record = { events: 'events', event: 'events', repair: 'repair', @@ -32,11 +34,11 @@ const ACTION_ALIASES: Record = { status: 'status', }; -const URL_ACTIONS: Partial> = { - repair: REPAIR_URL, - website: WEBSITE_URLS.homepage, - github: WEBSITE_URLS.github, - roadmap: WEBSITE_URLS.roadmap +const URL_ACTIONS: Partial> = { + repair: URLS.repair, + website: URLS.homepage, + github: URLS.github, + roadmap: URLS.roadmap }; interface ParsedArgs { @@ -45,8 +47,8 @@ interface ParsedArgs { flags: Set; } -const KNOWN_FLAGS = new Set(['--help', '--open', '--json', '--plain', '--no-logo', '--watch']); -const KNOWN_FLAG_PREFIXES = ['--interval=', '--timeout=', '--retries=']; +const KNOWN_FLAGS = new Set(['--help', '--version', '--open', '--json', '--plain', '--no-logo', '--watch', '--today']); +const KNOWN_FLAG_PREFIXES = ['--interval=', '--timeout=', '--retries=', '--next=']; const STATUS_WATCH_INTERVAL_MIN = 3; const STATUS_WATCH_INTERVAL_MAX = 300; const STATUS_TIMEOUT_MIN = 1000; @@ -82,24 +84,20 @@ function getAllowedFlagsFor(command?: string): Set { if (!command) { allowed.add('--no-logo'); + allowed.add('--version'); return allowed; } - if (command === 'lang' || command === 'language') { - return allowed; - } - if (command === 'theme') { - return allowed; - } + if (command === 'lang' || command === 'language') return allowed; + if (command === 'theme') return allowed; const action = ACTION_ALIASES[command]; - if (!action) { - return allowed; - } + if (!action) return allowed; switch (action) { case 'events': allowed.add('--json'); + allowed.add('--today'); return allowed; case 'status': allowed.add('--json'); @@ -121,9 +119,8 @@ function getAllowedFlagsFor(command?: string): Set { function getAllowedFlagPrefixesFor(command?: string): string[] { if (!command) return []; const action = ACTION_ALIASES[command]; - if (action === 'status') { - return ['--interval=', '--timeout=', '--retries=']; - } + if (action === 'events') return ['--next=']; + if (action === 'status') return ['--interval=', '--timeout=', '--retries=']; return []; } @@ -155,37 +152,54 @@ function printHelp(): void { console.log(chalk.bold('NBTCA Prompt')); console.log(); console.log('Usage:'); - console.log(' nbtca Start interactive menu'); - console.log(' nbtca [flags] Run one command directly'); - console.log(' nbtca lang Set language preference'); - console.log(' nbtca theme ... Configure icon and color mode'); + console.log(' nbtca Interactive menu'); + console.log(' nbtca [flags] Run a command'); console.log(); console.log('Commands:'); - console.log(' events | status | repair | docs | website | github | roadmap | about | theme'); + console.log(' events Upcoming activities'); + console.log(' docs Knowledge base'); + console.log(' status Service health'); + console.log(' website Official website URL'); + console.log(' github GitHub organization URL'); + console.log(' roadmap Project roadmap URL'); + console.log(' repair Repair service URL'); + console.log(' theme View or set theme'); + console.log(' lang Set language'); + console.log(' update Check for updates'); console.log(); console.log('Flags:'); - console.log(' --help Show help'); - console.log(' --open Open browser for URL commands'); - console.log(' --json JSON output (supported by `events`, `status`)'); - console.log(' --watch Continuously refresh status output (`status` only)'); - console.log(' --interval= Refresh interval for `status --watch` (3-300)'); - console.log(' --timeout= HTTP timeout for status checks (1000-20000)'); - console.log(' --retries= Retry count for transient status failures (0-5)'); - console.log(' --plain Disable color output'); - console.log(' --no-logo Skip startup logo in menu mode'); - console.log(); - console.log('Examples:'); - console.log(' nbtca events --json'); - console.log(' nbtca status --json'); - console.log(' nbtca status --watch --interval=10'); - console.log(' nbtca status --timeout=8000 --retries=2'); - console.log(' nbtca theme icon ascii'); - console.log(' nbtca roadmap'); - console.log(' nbtca roadmap --open'); + console.log(' --version Show version'); + console.log(' --help Show help'); + console.log(' --open Open in browser (URL commands)'); + console.log(' --json JSON output (events, status)'); + console.log(' --today Today only (events)'); + console.log(' --next= Limit to next N (events)'); + console.log(' --watch Live refresh (status)'); + console.log(' --interval= Refresh interval (status --watch)'); + console.log(' --timeout= HTTP timeout (status)'); + console.log(' --retries= Retry count (status)'); + console.log(' --plain No color'); + console.log(' --no-logo Skip logo'); } async function runEventsCommand(flags: Set): Promise { - const events = await fetchEvents(); + let events = await fetchEvents(); + + if (flags.has('--today')) { + const now = new Date(); + const todayStr = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + events = events.filter(e => e.date === todayStr); + } + + const nextFlag = Array.from(flags).find(f => f.startsWith('--next=')); + if (nextFlag) { + const n = Number.parseInt(nextFlag.split('=')[1] || '', 10); + if (!Number.isInteger(n) || n < 1) { + console.error(chalk.red('Invalid --next value. Use --next= (>= 1).')); + process.exit(1); + } + events = events.slice(0, n); + } if (flags.has('--json')) { process.stdout.write(JSON.stringify(serializeEvents(events), null, 2) + '\n'); @@ -211,40 +225,25 @@ async function runStatusCommand(flags: Set): Promise { process.exit(1); } if (!Number.isInteger(timeoutMs) || timeoutMs < STATUS_TIMEOUT_MIN || timeoutMs > STATUS_TIMEOUT_MAX) { - console.error( - chalk.red( - trans.status.invalidTimeout - .replace('{min}', String(STATUS_TIMEOUT_MIN)) - .replace('{max}', String(STATUS_TIMEOUT_MAX)) - ) - ); + console.error(chalk.red( + trans.status.invalidTimeout.replace('{min}', String(STATUS_TIMEOUT_MIN)).replace('{max}', String(STATUS_TIMEOUT_MAX)) + )); process.exit(1); } if (!Number.isInteger(retries) || retries < STATUS_RETRIES_MIN || retries > STATUS_RETRIES_MAX) { - console.error( - chalk.red( - trans.status.invalidRetries - .replace('{min}', String(STATUS_RETRIES_MIN)) - .replace('{max}', String(STATUS_RETRIES_MAX)) - ) - ); + console.error(chalk.red( + trans.status.invalidRetries.replace('{min}', String(STATUS_RETRIES_MIN)).replace('{max}', String(STATUS_RETRIES_MAX)) + )); process.exit(1); } if (watch && flags.has('--json')) { console.error(chalk.red(trans.status.watchJsonConflict)); process.exit(1); } - if ( - watch && - (!Number.isInteger(intervalSeconds) || intervalSeconds < STATUS_WATCH_INTERVAL_MIN || intervalSeconds > STATUS_WATCH_INTERVAL_MAX) - ) { - console.error( - chalk.red( - trans.status.invalidInterval - .replace('{min}', String(STATUS_WATCH_INTERVAL_MIN)) - .replace('{max}', String(STATUS_WATCH_INTERVAL_MAX)) - ) - ); + if (watch && (!Number.isInteger(intervalSeconds) || intervalSeconds < STATUS_WATCH_INTERVAL_MIN || intervalSeconds > STATUS_WATCH_INTERVAL_MAX)) { + console.error(chalk.red( + trans.status.invalidInterval.replace('{min}', String(STATUS_WATCH_INTERVAL_MIN)).replace('{max}', String(STATUS_WATCH_INTERVAL_MAX)) + )); process.exit(1); } if (watch && !hasInteractiveTerminal()) { @@ -254,16 +253,12 @@ async function runStatusCommand(flags: Set): Promise { if (watch) { let stopped = false; - const onSigint = () => { - stopped = true; - }; + const onSigint = () => { stopped = true; }; process.once('SIGINT', onSigint); - console.log( - chalk.dim( - `${trans.status.watchStarted.replace('{seconds}', String(intervalSeconds))} | ${trans.status.watchHint}` - ) - ); + console.log(chalk.dim( + `${trans.status.watchStarted.replace('{seconds}', String(intervalSeconds))} | ${trans.status.watchHint}` + )); try { while (!stopped) { @@ -271,14 +266,8 @@ async function runStatusCommand(flags: Set): Promise { const hasFailures = hasServiceFailures(services); const health = countServiceHealth(services); clearScreen(); - console.log( - chalk.bold( - `${pickIcon('📡', '[status]')} ${trans.status.watchUpdated}: ${new Date().toLocaleString()}` - ) - ); - console.log( - chalk.dim(`${trans.status.up}: ${health.up} | ${trans.status.down}: ${health.down} | ${trans.status.watchHint}`) - ); + console.log(chalk.bold(`${trans.status.watchUpdated}: ${new Date().toLocaleString()}`)); + console.log(chalk.dim(`${trans.status.up}: ${health.up} | ${trans.status.down}: ${health.down} | ${trans.status.watchHint}`)); console.log(); const useColor = !flags.has('--plain') && !!process.stdout.isTTY; console.log(renderServiceStatusTable(services, { color: useColor })); @@ -331,13 +320,19 @@ function maybeDisableColor(flags: Set): void { async function runCommandMode(argv: string[]): Promise { const { command, args, flags } = parseArgs(argv); maybeDisableColor(flags); - validateFlags(command, flags); + + if (flags.has('--version') || command === '--version' || command === '-v' || command === 'version') { + console.log(APP_INFO.version); + return; + } if (flags.has('--help') || command === '--help' || command === '-h' || command === 'help') { printHelp(); return; } + validateFlags(command, flags); + if (!command) { if (!hasInteractiveTerminal()) { console.error(chalk.red('Interactive mode requires a TTY terminal.')); @@ -375,6 +370,11 @@ async function runCommandMode(argv: string[]): Promise { return; } + if (command === 'update') { + await runUpdateCheck(); + return; + } + const action = ACTION_ALIASES[command]; if (!action) { console.error(chalk.red(`Unknown command: ${command}`)); @@ -389,28 +389,52 @@ async function runCommandMode(argv: string[]): Promise { if (action === 'status') { const ok = await runStatusCommand(flags); - if (!ok) { - process.exit(1); - } + if (!ok) process.exit(1); return; } - if (action === 'docs' && !hasInteractiveTerminal()) { - if (flags.has('--open')) { - await openDocsInBrowser(); + if (action === 'docs') { + if (!hasInteractiveTerminal()) { + if (flags.has('--open')) { + await openDocsInBrowser(); + } else { + process.stdout.write(URLS.docs + '\n'); + } } else { - process.stdout.write(WEBSITE_URLS.docs + '\n'); + const { showDocsMenu } = await import('./features/docs.js'); + await showDocsMenu(); } return; } - const mappedUrl = URL_ACTIONS[action]; - if (mappedUrl && !flags.has('--open')) { - process.stdout.write(mappedUrl + '\n'); + if (action === 'about') { + const { note } = await import('@clack/prompts'); + const { padEndV } = await import('./core/text.js'); + const pad = 12; + const row = (label: string, value: string) => `${chalk.dim(padEndV(label, pad))}${value}`; + const link = (label: string, url: string) => row(label, chalk.cyan(url)); + const trans = t(); + const content = [ + row(trans.about.project, APP_INFO.name), + row(trans.about.version, `v${APP_INFO.version}`), + '', + link(trans.about.github, APP_INFO.repository), + link(trans.about.website, URLS.homepage), + ].join('\n'); + note(content, trans.about.title); return; } - await runMenuAction(action); + // URL actions: repair, website, github, roadmap + const mappedUrl = URL_ACTIONS[action]; + if (mappedUrl) { + if (flags.has('--open')) { + await open(mappedUrl); + } else { + process.stdout.write(mappedUrl + '\n'); + } + return; + } } runCommandMode(process.argv.slice(2)).catch((err: any) => { diff --git a/src/logo/printLogo.js b/src/logo/printLogo.js deleted file mode 100644 index dc62f06..0000000 --- a/src/logo/printLogo.js +++ /dev/null @@ -1,26 +0,0 @@ -// Print the ASCII logo from logo.txt. - -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * Reads and prints the ASCII logo from logo.txt. - */ -export function printLogo() { - const logoPath = path.resolve(__dirname, "./logo.txt"); - - try { - const data = fs.readFileSync(logoPath, "utf8"); - console.log(data.trim()); - } catch (err) { - if (err.code === "ENOENT") { - console.error("Error: logo.txt not found. Please ensure the file exists!"); - } else { - console.error("Error reading logo.txt:", err.message); - } - } -} diff --git a/src/main.ts b/src/main.ts index bb7ef5b..4b56c83 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import { showMainMenu } from './core/menu.js'; import { APP_INFO } from './config/data.js'; import { enableVimKeys } from './core/vim-keys.js'; import { t } from './i18n/index.js'; +import { checkForUpdate } from './features/update.js'; export interface MainOptions { skipLogo?: boolean; @@ -31,23 +32,33 @@ export async function main(options: MainOptions = {}): Promise { // Display logo (smart fallback) if (!options.skipLogo) { - await printLogo(); + printLogo(); } + // Non-blocking update check (fire and forget, print before menu if resolved in time) + const updatePromise = checkForUpdate(); + // Open session frame intro(chalk.cyan('NBTCA Prompt') + chalk.dim(` v${APP_INFO.version}`)); + // Show update notification if ready + const updateMsg = await Promise.race([ + updatePromise, + new Promise(r => setTimeout(r, 500, null)), + ]); + if (updateMsg) console.log(chalk.yellow(updateMsg)); + // Show main menu (loop) await showMainMenu(); - } catch (err: any) { - // Handle Ctrl+C exit - if (err.message?.includes('SIGINT') || err.message?.includes('User force closed')) { + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err ?? ''); + if (message.includes('SIGINT') || message.includes('User force closed')) { console.log(); console.log(chalk.dim(t().common.goodbye)); process.exit(0); } else { - console.error('Error occurred:', err); + console.error('Error occurred:', message || err); process.exit(1); } } diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index a79d7e3..0000000 --- a/src/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 极简类型定义 - * 只保留核心类型 - */ - -/** - * 菜单项类型 - */ -export interface MenuItem { - name: string; - value: string; - description?: string; - short?: string; - disabled?: boolean; -} - -/** - * 活动事件类型 - */ -export interface Event { - date: string; - time: string; - title: string; - location: string; - startDate: Date; -} - -/** - * URL配置类型 - */ -export interface URLConfig { - homepage: string; - github: string; - docs: string; - repair: string; - calendar: string; - email: string; -} - -/** - * 应用信息类型 - */ -export interface AppInfo { - name: string; - version: string; - description: string; - fullDescription: string; - author: string; - license: string; - repository: string; -} diff --git a/tsconfig.json b/tsconfig.json index 9a5cfbb..45849ea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,18 +3,14 @@ /* Language and Environment */ "target": "ES2022", "lib": ["ES2022"], - "module": "ES2022", - "moduleResolution": "node", + "module": "Node16", + "moduleResolution": "Node16", /* Emit */ "outDir": "./dist", "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "sourceMap": true, "removeComments": false, "importHelpers": false, - "downlevelIteration": true, /* Interop Constraints */ "esModuleInterop": true,