chore: checkpoint ai search comments and i18n foundation
This commit is contained in:
144
frontend/src/lib/i18n/index.ts
Normal file
144
frontend/src/lib/i18n/index.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { messages } from './messages';
|
||||
|
||||
export const SUPPORTED_LOCALES = ['zh-CN', 'en'] as const;
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'zh-CN';
|
||||
export const LOCALE_COOKIE_NAME = 'termi_locale';
|
||||
|
||||
type TranslateParams = Record<string, string | number | null | undefined>;
|
||||
|
||||
function resolveMessage(locale: Locale, key: string): string | undefined {
|
||||
const segments = key.split('.');
|
||||
let current: unknown = messages[locale];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== 'object' || !(segment in current)) {
|
||||
current = undefined;
|
||||
break;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
|
||||
return typeof current === 'string' ? current : undefined;
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: TranslateParams): string {
|
||||
if (!params) {
|
||||
return template;
|
||||
}
|
||||
|
||||
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''));
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string | null | undefined): Locale | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('zh')) {
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('en')) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLocale(options: {
|
||||
query?: string | null;
|
||||
cookie?: string | null;
|
||||
acceptLanguage?: string | null;
|
||||
}): Locale {
|
||||
const fromQuery = normalizeLocale(options.query);
|
||||
if (fromQuery) {
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
const fromCookie = normalizeLocale(options.cookie);
|
||||
if (fromCookie) {
|
||||
return fromCookie;
|
||||
}
|
||||
|
||||
const acceptLanguages = String(options.acceptLanguage || '')
|
||||
.split(',')
|
||||
.map((part) => normalizeLocale(part.split(';')[0]))
|
||||
.filter(Boolean) as Locale[];
|
||||
|
||||
return acceptLanguages[0] || DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function translate(locale: Locale, key: string, params?: TranslateParams): string {
|
||||
const template =
|
||||
resolveMessage(locale, key) ??
|
||||
resolveMessage(DEFAULT_LOCALE, key) ??
|
||||
key;
|
||||
|
||||
return interpolate(template, params);
|
||||
}
|
||||
|
||||
export function getMessages(locale: Locale) {
|
||||
return messages[locale];
|
||||
}
|
||||
|
||||
export function buildLocaleUrl(url: URL, locale: Locale): string {
|
||||
const next = new URL(url);
|
||||
next.searchParams.set('lang', locale);
|
||||
return `${next.pathname}${next.search}${next.hash}`;
|
||||
}
|
||||
|
||||
export function getI18n(Astro: {
|
||||
url: URL;
|
||||
request: Request;
|
||||
cookies?: {
|
||||
get?: (name: string) => { value: string } | undefined;
|
||||
};
|
||||
}) {
|
||||
const requestUrl = new URL(Astro.request.url);
|
||||
const locale = resolveLocale({
|
||||
query: requestUrl.searchParams.get('lang'),
|
||||
cookie: Astro.cookies?.get?.(LOCALE_COOKIE_NAME)?.value ?? null,
|
||||
acceptLanguage: Astro.request.headers.get('accept-language'),
|
||||
});
|
||||
|
||||
const t = (key: string, params?: TranslateParams) => translate(locale, key, params);
|
||||
|
||||
return {
|
||||
locale,
|
||||
t,
|
||||
messages: getMessages(locale),
|
||||
buildLocaleUrl: (targetLocale: Locale) => buildLocaleUrl(requestUrl, targetLocale),
|
||||
};
|
||||
}
|
||||
|
||||
export function getReadTimeMinutes(value: string | number | null | undefined): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const match = String(value || '').match(/\d+/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(match[0]);
|
||||
}
|
||||
|
||||
export function formatReadTime(
|
||||
_locale: Locale,
|
||||
value: string | number | null | undefined,
|
||||
t: (key: string, params?: TranslateParams) => string
|
||||
): string {
|
||||
const minutes = getReadTimeMinutes(value);
|
||||
if (minutes === null) {
|
||||
return String(value || '');
|
||||
}
|
||||
|
||||
return t('common.readTimeMinutes', { count: minutes });
|
||||
}
|
||||
696
frontend/src/lib/i18n/messages.ts
Normal file
696
frontend/src/lib/i18n/messages.ts
Normal file
@@ -0,0 +1,696 @@
|
||||
export const messages = {
|
||||
'zh-CN': {
|
||||
common: {
|
||||
language: '语言',
|
||||
languages: {
|
||||
'zh-CN': '简体中文',
|
||||
en: 'English',
|
||||
},
|
||||
all: '全部',
|
||||
search: '搜索',
|
||||
ai: 'AI',
|
||||
article: '文章',
|
||||
tweet: '动态',
|
||||
posts: '文章',
|
||||
tags: '标签',
|
||||
categories: '分类',
|
||||
friends: '友链',
|
||||
location: '位置',
|
||||
unknown: '未知',
|
||||
other: '其他',
|
||||
current: '当前',
|
||||
readTime: '阅读时间',
|
||||
readTimeMinutes: '{count} 分钟',
|
||||
characters: '{count} 字',
|
||||
postsCount: '{count} 篇',
|
||||
tagsCount: '{count} 个标签',
|
||||
categoriesCount: '{count} 个分类',
|
||||
friendsCount: '{count} 个友链',
|
||||
reviewsCount: '{count} 条评价',
|
||||
resultsCount: '{count} 条结果',
|
||||
reviewedOnly: '仅展示已通过审核',
|
||||
noData: '暂无数据',
|
||||
noResults: '没有匹配结果',
|
||||
open: '打开',
|
||||
close: '关闭',
|
||||
submit: '提交',
|
||||
cancel: '取消',
|
||||
clear: '清除',
|
||||
reset: '重置',
|
||||
reply: '回复',
|
||||
like: '点赞',
|
||||
visit: '访问',
|
||||
readMore: '阅读全文',
|
||||
viewArticle: '打开文章',
|
||||
viewAllArticles: '查看所有文章',
|
||||
viewAllLinks: '查看全部友链',
|
||||
viewCategoryArticles: '查看分类文章',
|
||||
clearFilters: '清除筛选',
|
||||
resetFilters: '重置筛选',
|
||||
home: '首页',
|
||||
browsePosts: '浏览文章',
|
||||
goBack: '返回上一页',
|
||||
backToIndex: '返回索引',
|
||||
copyPermalink: '复制固定链接',
|
||||
locateParagraph: '定位段落',
|
||||
maxChars: '最多 {count} 字',
|
||||
optional: '可选',
|
||||
pending: '待审核',
|
||||
approved: '已审核',
|
||||
completed: '已完成',
|
||||
inProgress: '进行中',
|
||||
featureOn: '功能已开启',
|
||||
featureOff: '功能未开启',
|
||||
emptyState: '当前还没有内容。',
|
||||
apiUnavailable: 'API 暂时不可用',
|
||||
},
|
||||
nav: {
|
||||
articles: '文章',
|
||||
categories: '分类',
|
||||
tags: '标签',
|
||||
timeline: '时间轴',
|
||||
reviews: '评价',
|
||||
friends: '友链',
|
||||
about: '关于',
|
||||
ask: 'AI 问答',
|
||||
},
|
||||
header: {
|
||||
navigation: '导航',
|
||||
themeToggle: '切换主题',
|
||||
toggleMenu: '切换菜单',
|
||||
searchModeKeyword: '搜索',
|
||||
searchModeAi: 'AI',
|
||||
searchModeKeywordMobile: '关键词搜索',
|
||||
searchModeAiMobile: 'AI 搜索',
|
||||
searchPlaceholderKeyword: "'关键词'",
|
||||
searchPlaceholderAi: '输入问题,交给站内 AI',
|
||||
searchHintKeyword: 'articles/*.md',
|
||||
searchHintAi: '手动确认',
|
||||
aiModeTitle: 'AI 问答模式',
|
||||
aiModeHeading: '把这个问题交给站内 AI',
|
||||
aiModeDescription: 'AI 会先检索站内知识库,再给出总结式回答,并附带相关文章来源。',
|
||||
aiModeNotice: '进入问答页后不会自动调用模型,需要你手动确认发送。',
|
||||
aiModeCta: '前往 AI 问答页确认',
|
||||
liveResults: '实时搜索结果',
|
||||
searching: '正在搜索 {query} ...',
|
||||
searchFailed: '搜索失败,请稍后再试。',
|
||||
searchEmpty: '没有找到和 {query} 相关的内容。',
|
||||
searchEmptyCta: '去 AI 问答页确认提问',
|
||||
searchAiFooter: '去 AI 问答页手动确认',
|
||||
searchAllResults: '查看全部搜索结果',
|
||||
untitled: '未命名',
|
||||
manualConfirm: 'AI 必须手动确认后才会提问',
|
||||
},
|
||||
footer: {
|
||||
session: '会话',
|
||||
copyright: '© {year} {site}. 保留所有权利。',
|
||||
sitemap: '站点地图',
|
||||
rss: 'RSS 订阅',
|
||||
},
|
||||
home: {
|
||||
pinned: '置顶',
|
||||
about: '关于我',
|
||||
techStack: '技术栈',
|
||||
systemStatus: '系统状态',
|
||||
},
|
||||
articlesPage: {
|
||||
title: '文章索引',
|
||||
description: '按类型、分类和标签筛选内容,快速浏览整个内容目录。',
|
||||
totalPosts: '共 {count} 篇',
|
||||
allCategories: '全部分类',
|
||||
allTags: '全部标签',
|
||||
emptyTitle: '没有匹配结果',
|
||||
emptyDescription: '当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。',
|
||||
pageSummary: '第 {current} / {total} 页 · 共 {count} 条结果',
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
},
|
||||
article: {
|
||||
backToArticles: '返回文章索引',
|
||||
documentSession: '文档会话',
|
||||
filePath: '文件路径',
|
||||
},
|
||||
relatedPosts: {
|
||||
title: '相关文章',
|
||||
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
|
||||
linked: '{count} 条关联',
|
||||
},
|
||||
comments: {
|
||||
title: '评论终端',
|
||||
description: '这里是整篇文章的讨论区,当前缓冲区共有 {count} 条已展示评论,新的留言提交后会进入审核队列。',
|
||||
writeComment: '写评论',
|
||||
nickname: '昵称',
|
||||
email: '邮箱',
|
||||
message: '内容',
|
||||
messagePlaceholder: "$ echo '留下你的想法...'",
|
||||
maxChars: '最多 500 字',
|
||||
cancelReply: '取消回复',
|
||||
emptyTitle: '暂无评论',
|
||||
emptyDescription: '当前还没有留言。可以打开上面的输入面板,成为第一个在这个终端缓冲区里发言的人。',
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
daysAgo: '{count} 天前',
|
||||
weeksAgo: '{count} 周前',
|
||||
anonymous: '匿名',
|
||||
submitting: '正在提交评论...',
|
||||
submitSuccess: '评论已提交,审核通过后会显示在这里。',
|
||||
submitFailed: '提交失败:{message}',
|
||||
loadFailed: '加载评论失败',
|
||||
noSelection: '当前没有选中的评论。',
|
||||
},
|
||||
paragraphComments: {
|
||||
title: '段落评论已启用',
|
||||
intro: '正文里的自然段都会挂一个轻量讨论入口,适合只针对某一段补充上下文、指出问题或继续展开讨论。',
|
||||
scanning: '正在扫描段落缓冲区...',
|
||||
noParagraphs: '当前文章没有可挂载评论的自然段。',
|
||||
summary: '已为 {paragraphCount} 个自然段挂载评论入口,其中 {discussedCount} 段已有讨论,当前共展示 {approvedCount} 条已审核段落评论。',
|
||||
focusCurrent: '聚焦当前段落',
|
||||
panelTitle: '段落讨论面板',
|
||||
close: '关闭',
|
||||
nickname: '昵称',
|
||||
email: '邮箱',
|
||||
comment: '评论',
|
||||
commentPlaceholder: "$ echo '只评论这一段...'",
|
||||
maxChars: '最多 500 字',
|
||||
clearReply: '清除回复',
|
||||
replyTo: '回复给',
|
||||
approvedThread: '公开讨论',
|
||||
pendingQueue: '待审核队列',
|
||||
emptyTitle: '这段还没有公开评论',
|
||||
emptyDescription: '适合补充上下文、指出细节或者提出具体问题。新的留言提交后会先进入审核队列。',
|
||||
loadingThread: '正在拉取该段的已审核评论...',
|
||||
loadFailedShort: '加载失败',
|
||||
loadFailed: '加载失败:{message}',
|
||||
selectedRequired: '当前没有选中的段落。',
|
||||
contextMissing: '段落上下文丢失,请重新打开该段评论面板。',
|
||||
submitting: '正在提交段落评论...',
|
||||
submitSuccess: '评论已提交,已先放入本地待审核队列;审核通过后会进入公开讨论。',
|
||||
submitFailed: '提交失败:{message}',
|
||||
anonymous: '匿名',
|
||||
oneNote: '1 条评论',
|
||||
manyNotes: '{count} 条评论',
|
||||
zeroNotes: '评论',
|
||||
waitingReview: '等待审核',
|
||||
locateParagraph: '定位段落',
|
||||
},
|
||||
ask: {
|
||||
pageTitle: 'AI 问答',
|
||||
pageDescription: '基于 {siteName} 内容知识库的站内 AI 问答',
|
||||
title: 'AI 站内问答',
|
||||
subtitle: '基于博客 Markdown 内容建立索引,回答会优先引用站内真实资料。',
|
||||
disabledTitle: '后台暂未开启 AI 问答',
|
||||
disabledDescription: '这个入口已经接好了真实后端,但当前站点设置里没有开启公开问答。管理员开启后,这里会自动变成可用状态,导航也会同步显示。',
|
||||
textareaPlaceholder: '输入你想问的问题,比如:这个博客关于前端写过哪些内容?',
|
||||
submit: '开始提问',
|
||||
idleStatus: '知识库已接入,等待问题输入。',
|
||||
examples: '示例问题',
|
||||
workflow: '工作流',
|
||||
workflow1: '1. 后台开启 AI 开关并配置聊天模型。',
|
||||
workflow2: '2. 重建索引,把 Markdown 文章切块后由后端本地生成 embedding,并写入 PostgreSQL pgvector。',
|
||||
workflow3: '3. 前台提问时先在 pgvector 中做相似度检索,再交给聊天模型基于上下文回答。',
|
||||
emptyAnswer: '暂无回答。',
|
||||
requestFailed: '请求失败:{message}',
|
||||
streamUnsupported: '当前浏览器无法读取流式响应。',
|
||||
enterQuestion: '先输入一个问题。',
|
||||
cacheRestored: '已从当前会话缓存中恢复回答。',
|
||||
connecting: '正在建立流式连接,请稍候...',
|
||||
processing: '正在处理请求...',
|
||||
complete: '回答已生成。',
|
||||
streamFailed: '流式请求失败',
|
||||
streamInterrupted: '流式响应被提前中断。',
|
||||
retryLater: '这次请求没有成功,可以稍后重试。',
|
||||
prefixedQuestion: '已带入搜索词,确认后开始提问。',
|
||||
sources: '来源',
|
||||
},
|
||||
about: {
|
||||
pageTitle: '关于',
|
||||
title: '关于我',
|
||||
intro: '这里汇总站点主人、技术栈、系统状态和联系方式,并与全站语言设置保持一致。',
|
||||
techStackCount: '{count} 项技术栈',
|
||||
profile: '身份档案',
|
||||
contact: '联系方式',
|
||||
website: '网站',
|
||||
},
|
||||
categories: {
|
||||
pageTitle: '分类',
|
||||
title: '文章分类',
|
||||
intro: '按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。',
|
||||
quickJump: '快速跳转分类文章',
|
||||
categoryPosts: '浏览 {name} 主题下的全部文章和更新记录。',
|
||||
empty: '暂无分类数据',
|
||||
},
|
||||
friends: {
|
||||
pageTitle: '友情链接',
|
||||
pageDescription: '与 {siteName} 交换友情链接',
|
||||
title: '友情链接',
|
||||
intro: '这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。',
|
||||
collection: '友链分组',
|
||||
exchangeRules: '友链交换',
|
||||
exchangeIntro: '欢迎交换友情链接,请确保您的网站满足以下条件:',
|
||||
rule1: '原创内容为主',
|
||||
rule2: '网站稳定运行',
|
||||
rule3: '无不良内容',
|
||||
siteInfo: '本站信息:',
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
link: '链接',
|
||||
},
|
||||
friendForm: {
|
||||
title: '提交友链申请',
|
||||
intro: '填写站点信息后会提交到后台审核,审核通过后前台会自动展示。',
|
||||
reviewedOnline: '后台审核后上线',
|
||||
siteName: '站点名称',
|
||||
siteUrl: '站点链接',
|
||||
avatarUrl: '头像链接',
|
||||
category: '分类',
|
||||
description: '站点描述',
|
||||
reciprocal: '已添加本站友链',
|
||||
reciprocalHint: '这是提交申请前的必要条件。',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
submit: '提交申请',
|
||||
reset: '重置',
|
||||
addReciprocalFirst: '请先添加本站友链后再提交申请。',
|
||||
submitting: '正在提交友链申请...',
|
||||
submitSuccess: '友链申请已提交,我们会尽快审核。',
|
||||
submitFailed: '提交失败:{message}',
|
||||
categoryTech: '技术',
|
||||
categoryLife: '生活',
|
||||
categoryDesign: '设计',
|
||||
categoryOther: '其他',
|
||||
descriptionPlaceholder: '简要介绍一下你的网站...',
|
||||
},
|
||||
friendCard: {
|
||||
externalLink: '外部链接',
|
||||
},
|
||||
tags: {
|
||||
pageTitle: '标签',
|
||||
title: '标签云',
|
||||
intro: '用更轻量的关键词维度检索文章。选中标签时,下方结果区会延续同一套终端卡片风格。',
|
||||
currentTag: '当前: #{tag}',
|
||||
selectedSummary: '标签 #{tag} 找到 {count} 篇文章',
|
||||
browseTags: '浏览标签',
|
||||
emptyTags: '暂无标签数据',
|
||||
emptyPosts: '没有找到该标签的文章',
|
||||
},
|
||||
timeline: {
|
||||
pageTitle: '时间轴',
|
||||
pageDescription: '记录 {ownerName} 的技术成长与生活点滴',
|
||||
title: '时间轴',
|
||||
subtitle: '共 {count} 篇内容 · 记录 {ownerName} 的技术成长与生活点滴',
|
||||
allYears: '全部',
|
||||
},
|
||||
reviews: {
|
||||
pageTitle: '评价',
|
||||
pageDescription: '记录游戏、音乐、动画、书籍与影视的体验和评价',
|
||||
title: '评价',
|
||||
subtitle: '记录游戏、音乐、动画、书籍与影视的体验和感悟',
|
||||
total: '总评价',
|
||||
average: '平均评分',
|
||||
completed: '已完成',
|
||||
inProgress: '进行中',
|
||||
emptyData: '暂无评价数据,请检查后端 API 连接',
|
||||
emptyFiltered: '当前筛选下暂无评价',
|
||||
currentFilter: '当前筛选: {type}',
|
||||
typeAll: '全部',
|
||||
typeGame: '游戏',
|
||||
typeAnime: '动画',
|
||||
typeMusic: '音乐',
|
||||
typeBook: '书籍',
|
||||
typeMovie: '影视',
|
||||
},
|
||||
notFound: {
|
||||
pageTitle: '页面未找到',
|
||||
pageDescription: '您访问的页面不存在',
|
||||
title: '404 - 页面未找到',
|
||||
intro: '当前请求没有命中任何内容节点。下面保留了终端化错误信息、可执行操作,以及可回退到的真实文章入口。',
|
||||
terminalLog: '终端错误日志',
|
||||
requestedRouteNotFound: '错误:请求的路由不存在',
|
||||
path: '路径',
|
||||
time: '时间',
|
||||
actions: '可执行操作',
|
||||
actionsIntro: '像命令面板一样,优先给出直接可走的恢复路径。',
|
||||
searchHint: '也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。',
|
||||
recommended: '推荐入口',
|
||||
recommendedIntro: '使用真实文章数据,避免 404 页面再把人带进不存在的地址。',
|
||||
cannotLoad: '暂时无法读取文章列表。',
|
||||
},
|
||||
toc: {
|
||||
title: '目录',
|
||||
intro: '实时跟踪当前文档的标题节点,像终端侧栏一样快速跳转。',
|
||||
},
|
||||
codeCopy: {
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
failed: '失败',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
common: {
|
||||
language: 'Language',
|
||||
languages: {
|
||||
'zh-CN': '简体中文',
|
||||
en: 'English',
|
||||
},
|
||||
all: 'All',
|
||||
search: 'Search',
|
||||
ai: 'AI',
|
||||
article: 'Article',
|
||||
tweet: 'Update',
|
||||
posts: 'Posts',
|
||||
tags: 'Tags',
|
||||
categories: 'Categories',
|
||||
friends: 'Links',
|
||||
location: 'Location',
|
||||
unknown: 'Unknown',
|
||||
other: 'Other',
|
||||
current: 'Current',
|
||||
readTime: 'Read time',
|
||||
readTimeMinutes: '{count} min read',
|
||||
characters: '{count} chars',
|
||||
postsCount: '{count} posts',
|
||||
tagsCount: '{count} tags',
|
||||
categoriesCount: '{count} categories',
|
||||
friendsCount: '{count} links',
|
||||
reviewsCount: '{count} reviews',
|
||||
resultsCount: '{count} results',
|
||||
reviewedOnly: 'Approved links only',
|
||||
noData: 'No data yet',
|
||||
noResults: 'No matching results',
|
||||
open: 'Open',
|
||||
close: 'Close',
|
||||
submit: 'Submit',
|
||||
cancel: 'Cancel',
|
||||
clear: 'Clear',
|
||||
reset: 'Reset',
|
||||
reply: 'Reply',
|
||||
like: 'Like',
|
||||
visit: 'Visit',
|
||||
readMore: 'Read more',
|
||||
viewArticle: 'Open article',
|
||||
viewAllArticles: 'View all articles',
|
||||
viewAllLinks: 'View all links',
|
||||
viewCategoryArticles: 'View category posts',
|
||||
clearFilters: 'Clear filters',
|
||||
resetFilters: 'Reset filters',
|
||||
home: 'Home',
|
||||
browsePosts: 'Browse posts',
|
||||
goBack: 'Go back',
|
||||
backToIndex: 'Back to index',
|
||||
copyPermalink: 'Copy permalink',
|
||||
locateParagraph: 'Locate paragraph',
|
||||
maxChars: 'Max {count} chars',
|
||||
optional: 'Optional',
|
||||
pending: 'Pending',
|
||||
approved: 'Approved',
|
||||
completed: 'Completed',
|
||||
inProgress: 'In progress',
|
||||
featureOn: 'Feature on',
|
||||
featureOff: 'Feature off',
|
||||
emptyState: 'Nothing here yet.',
|
||||
apiUnavailable: 'API temporarily unavailable',
|
||||
},
|
||||
nav: {
|
||||
articles: 'Articles',
|
||||
categories: 'Categories',
|
||||
tags: 'Tags',
|
||||
timeline: 'Timeline',
|
||||
reviews: 'Reviews',
|
||||
friends: 'Links',
|
||||
about: 'About',
|
||||
ask: 'Ask AI',
|
||||
},
|
||||
header: {
|
||||
navigation: 'Navigation',
|
||||
themeToggle: 'Toggle theme',
|
||||
toggleMenu: 'Toggle menu',
|
||||
searchModeKeyword: 'Search',
|
||||
searchModeAi: 'AI',
|
||||
searchModeKeywordMobile: 'Keyword Search',
|
||||
searchModeAiMobile: 'AI Search',
|
||||
searchPlaceholderKeyword: "'keyword'",
|
||||
searchPlaceholderAi: 'Type a question for the site AI',
|
||||
searchHintKeyword: 'articles/*.md',
|
||||
searchHintAi: 'manual confirm',
|
||||
aiModeTitle: 'AI Q&A mode',
|
||||
aiModeHeading: 'Send this question to the site AI',
|
||||
aiModeDescription: 'The AI will search the site knowledge base first, then answer with source-backed summaries.',
|
||||
aiModeNotice: 'The model will not run automatically after navigation. You must confirm manually.',
|
||||
aiModeCta: 'Open AI Q&A to confirm',
|
||||
liveResults: 'Live results',
|
||||
searching: 'Searching {query} ...',
|
||||
searchFailed: 'Search failed. Please try again later.',
|
||||
searchEmpty: 'No content matched {query}.',
|
||||
searchEmptyCta: 'Ask AI instead',
|
||||
searchAiFooter: 'Open AI Q&A manually',
|
||||
searchAllResults: 'View all results',
|
||||
untitled: 'Untitled',
|
||||
manualConfirm: 'AI questions must be confirmed manually',
|
||||
},
|
||||
footer: {
|
||||
session: 'Session',
|
||||
copyright: '© {year} {site}. All rights reserved.',
|
||||
sitemap: 'Sitemap',
|
||||
rss: 'RSS feed',
|
||||
},
|
||||
home: {
|
||||
pinned: 'Pinned',
|
||||
about: 'About',
|
||||
techStack: 'Tech stack',
|
||||
systemStatus: 'System status',
|
||||
},
|
||||
articlesPage: {
|
||||
title: 'Article Index',
|
||||
description: 'Filter content by type, category, and tag to browse the full archive quickly.',
|
||||
totalPosts: '{count} posts',
|
||||
allCategories: 'All categories',
|
||||
allTags: 'All tags',
|
||||
emptyTitle: 'No matching results',
|
||||
emptyDescription: 'No posts matched the current filters. Clear a tag or keyword to browse the full archive again.',
|
||||
pageSummary: 'Page {current}/{total} · {count} results',
|
||||
previous: 'Prev',
|
||||
next: 'Next',
|
||||
},
|
||||
article: {
|
||||
backToArticles: 'Back to article index',
|
||||
documentSession: 'Document session',
|
||||
filePath: 'File path',
|
||||
},
|
||||
relatedPosts: {
|
||||
title: 'Related Posts',
|
||||
description: 'More nearby reading paths based on the current category and shared tags.',
|
||||
linked: '{count} linked',
|
||||
},
|
||||
comments: {
|
||||
title: 'Comment Terminal',
|
||||
description: 'This is the discussion thread for the whole article. {count} approved comments are shown right now, and new messages enter moderation first.',
|
||||
writeComment: 'Write comment',
|
||||
nickname: 'Nickname',
|
||||
email: 'Email',
|
||||
message: 'Message',
|
||||
messagePlaceholder: "$ echo 'Leave your thoughts here...'",
|
||||
maxChars: 'Max 500 chars',
|
||||
cancelReply: 'Cancel reply',
|
||||
emptyTitle: 'No comments yet',
|
||||
emptyDescription: 'No one has posted here yet. Open the input panel above and be the first voice in this buffer.',
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
daysAgo: '{count} days ago',
|
||||
weeksAgo: '{count} weeks ago',
|
||||
anonymous: 'Anonymous',
|
||||
submitting: 'Submitting comment...',
|
||||
submitSuccess: 'Comment submitted. It will appear here after moderation.',
|
||||
submitFailed: 'Submit failed: {message}',
|
||||
loadFailed: 'Failed to load comments',
|
||||
noSelection: 'No comment is selected.',
|
||||
},
|
||||
paragraphComments: {
|
||||
title: 'Paragraph comments are enabled',
|
||||
intro: 'Each natural paragraph in the article gets a lightweight discussion entry point, perfect for focused context, corrections, or follow-up questions.',
|
||||
scanning: 'Scanning paragraph buffer...',
|
||||
noParagraphs: 'No commentable paragraphs were found in this article.',
|
||||
summary: '{paragraphCount} paragraphs have comment entries, {discussedCount} already have discussion, and {approvedCount} approved paragraph comments are currently visible.',
|
||||
focusCurrent: 'Focus current paragraph',
|
||||
panelTitle: 'Paragraph discussion panel',
|
||||
close: 'Close',
|
||||
nickname: 'Nickname',
|
||||
email: 'Email',
|
||||
comment: 'Comment',
|
||||
commentPlaceholder: "$ echo 'Comment on this paragraph only...'",
|
||||
maxChars: 'Max 500 chars',
|
||||
clearReply: 'Clear reply',
|
||||
replyTo: 'Reply to',
|
||||
approvedThread: 'Approved thread',
|
||||
pendingQueue: 'Pending queue',
|
||||
emptyTitle: 'No public comments on this paragraph yet',
|
||||
emptyDescription: 'Use this space to add context, point out details, or ask a specific question. New comments go through moderation first.',
|
||||
loadingThread: 'Loading approved comments for this paragraph...',
|
||||
loadFailedShort: 'Load failed',
|
||||
loadFailed: 'Load failed: {message}',
|
||||
selectedRequired: 'No paragraph is currently selected.',
|
||||
contextMissing: 'Paragraph context was lost. Please reopen the paragraph panel.',
|
||||
submitting: 'Submitting paragraph comment...',
|
||||
submitSuccess: 'Comment submitted. It has been placed into the local pending queue and will join the public thread after moderation.',
|
||||
submitFailed: 'Submit failed: {message}',
|
||||
anonymous: 'Anonymous',
|
||||
oneNote: '1 note',
|
||||
manyNotes: '{count} notes',
|
||||
zeroNotes: 'comment',
|
||||
waitingReview: 'waiting review',
|
||||
locateParagraph: 'Locate paragraph',
|
||||
},
|
||||
ask: {
|
||||
pageTitle: 'Ask AI',
|
||||
pageDescription: 'An on-site AI Q&A experience grounded in the {siteName} knowledge base',
|
||||
title: 'On-site AI Q&A',
|
||||
subtitle: 'Answers are grounded in indexed Markdown content from the blog and prioritize real on-site references.',
|
||||
disabledTitle: 'AI Q&A is not enabled yet',
|
||||
disabledDescription: 'The real backend integration is already in place, but public Q&A is still disabled in site settings. Once it is enabled, this page and the navigation entry will become available automatically.',
|
||||
textareaPlaceholder: 'Ask anything, for example: what has this blog written about frontend topics?',
|
||||
submit: 'Ask now',
|
||||
idleStatus: 'Knowledge base connected. Waiting for a question.',
|
||||
examples: 'Example questions',
|
||||
workflow: 'Workflow',
|
||||
workflow1: '1. Enable the AI switch in the admin and configure the chat model.',
|
||||
workflow2: '2. Rebuild the index so Markdown content is chunked, embedded locally by the backend, and written into PostgreSQL pgvector.',
|
||||
workflow3: '3. Each user question retrieves similar chunks from pgvector first, then the chat model answers with that context.',
|
||||
emptyAnswer: 'No answer yet.',
|
||||
requestFailed: 'Request failed: {message}',
|
||||
streamUnsupported: 'This browser cannot read streaming responses.',
|
||||
enterQuestion: 'Enter a question first.',
|
||||
cacheRestored: 'Restored the answer from the current session cache.',
|
||||
connecting: 'Opening stream connection...',
|
||||
processing: 'Processing request...',
|
||||
complete: 'Answer generated.',
|
||||
streamFailed: 'Streaming request failed',
|
||||
streamInterrupted: 'The streaming response ended early.',
|
||||
retryLater: 'This request did not complete successfully. Please try again later.',
|
||||
prefixedQuestion: 'The search query has been prefilled. Confirm manually to ask AI.',
|
||||
sources: 'Sources',
|
||||
},
|
||||
about: {
|
||||
pageTitle: 'About',
|
||||
title: 'About',
|
||||
intro: 'This page gathers the site owner profile, tech stack, system stats, and contact details while following the same language setting as the rest of the site.',
|
||||
techStackCount: '{count} tech items',
|
||||
profile: 'Profile',
|
||||
contact: 'Contact',
|
||||
website: 'Website',
|
||||
},
|
||||
categories: {
|
||||
pageTitle: 'Categories',
|
||||
title: 'Categories',
|
||||
intro: 'Browse posts by topic. This page now follows the same terminal language as the other list views.',
|
||||
quickJump: 'Jump straight into category posts',
|
||||
categoryPosts: 'Browse all posts and updates under {name}.',
|
||||
empty: 'No category data yet',
|
||||
},
|
||||
friends: {
|
||||
pageTitle: 'Links',
|
||||
pageDescription: 'Exchange links with {siteName}',
|
||||
title: 'Friend Links',
|
||||
intro: 'This page gathers approved sites and keeps the application panel in the same visual language so the list and form feel like one screen.',
|
||||
collection: 'Friend collection',
|
||||
exchangeRules: 'Link exchange',
|
||||
exchangeIntro: 'You are welcome to exchange links. Please make sure your site meets these conditions:',
|
||||
rule1: 'Original content as the main focus',
|
||||
rule2: 'Stable uptime',
|
||||
rule3: 'No harmful content',
|
||||
siteInfo: 'Site info:',
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
link: 'Link',
|
||||
},
|
||||
friendForm: {
|
||||
title: 'Submit a link request',
|
||||
intro: 'Fill in your site info and it will be sent to the moderation queue. Once approved, it will appear on the frontend automatically.',
|
||||
reviewedOnline: 'Published after review',
|
||||
siteName: 'Site name',
|
||||
siteUrl: 'Site URL',
|
||||
avatarUrl: 'Avatar URL',
|
||||
category: 'Category',
|
||||
description: 'Site description',
|
||||
reciprocal: 'I already added this site to my links',
|
||||
reciprocalHint: 'This is required before submission.',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
submit: 'Submit request',
|
||||
reset: 'Reset',
|
||||
addReciprocalFirst: 'Please add this site to your links before submitting.',
|
||||
submitting: 'Submitting link request...',
|
||||
submitSuccess: 'Link request submitted. We will review it soon.',
|
||||
submitFailed: 'Submit failed: {message}',
|
||||
categoryTech: 'Tech',
|
||||
categoryLife: 'Life',
|
||||
categoryDesign: 'Design',
|
||||
categoryOther: 'Other',
|
||||
descriptionPlaceholder: 'Briefly describe your site...',
|
||||
},
|
||||
friendCard: {
|
||||
externalLink: 'External link',
|
||||
},
|
||||
tags: {
|
||||
pageTitle: 'Tags',
|
||||
title: 'Tag Cloud',
|
||||
intro: 'Browse posts through lightweight keyword slices. When a tag is selected, the result area keeps the same terminal card language.',
|
||||
currentTag: 'Current: #{tag}',
|
||||
selectedSummary: 'Tag #{tag} matched {count} posts',
|
||||
browseTags: 'Browse tags',
|
||||
emptyTags: 'No tag data yet',
|
||||
emptyPosts: 'No posts found for this tag',
|
||||
},
|
||||
timeline: {
|
||||
pageTitle: 'Timeline',
|
||||
pageDescription: 'A timeline of {ownerName}\'s technical growth and life notes',
|
||||
title: 'Timeline',
|
||||
subtitle: '{count} entries · tracing {ownerName}\'s technical growth and life notes',
|
||||
allYears: 'All',
|
||||
},
|
||||
reviews: {
|
||||
pageTitle: 'Reviews',
|
||||
pageDescription: 'Notes and ratings for games, music, anime, books, and films',
|
||||
title: 'Reviews',
|
||||
subtitle: 'Tracking thoughts on games, music, anime, books, and films',
|
||||
total: 'Total reviews',
|
||||
average: 'Average rating',
|
||||
completed: 'Completed',
|
||||
inProgress: 'In progress',
|
||||
emptyData: 'No review data yet. Please check the backend API connection.',
|
||||
emptyFiltered: 'No reviews match the current filter',
|
||||
currentFilter: 'Current filter: {type}',
|
||||
typeAll: 'All',
|
||||
typeGame: 'Games',
|
||||
typeAnime: 'Anime',
|
||||
typeMusic: 'Music',
|
||||
typeBook: 'Books',
|
||||
typeMovie: 'Films',
|
||||
},
|
||||
notFound: {
|
||||
pageTitle: 'Page not found',
|
||||
pageDescription: 'The page you requested does not exist',
|
||||
title: '404 - Page not found',
|
||||
intro: 'The current request did not resolve to any content node. This page keeps the terminal-style error output, direct recovery actions, and real fallback articles.',
|
||||
terminalLog: 'Terminal error log',
|
||||
requestedRouteNotFound: 'error: requested route not found',
|
||||
path: 'path',
|
||||
time: 'time',
|
||||
actions: 'Actions',
|
||||
actionsIntro: 'Like a command palette, this page surfaces the most direct recovery paths first.',
|
||||
searchHint: 'You can also use the search box in the header and grep through `articles/*.md` again.',
|
||||
recommended: 'Recommended entries',
|
||||
recommendedIntro: 'These use real article data so the 404 page does not send people into more dead ends.',
|
||||
cannotLoad: 'Unable to load the article list right now.',
|
||||
},
|
||||
toc: {
|
||||
title: 'Contents',
|
||||
intro: 'Track document headings in real time and jump around like a terminal side panel.',
|
||||
},
|
||||
codeCopy: {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
failed: 'Failed',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type MessageCatalog = typeof messages;
|
||||
Reference in New Issue
Block a user