terry-staging #1

Merged
terry merged 8 commits from terry-staging into main 2026-05-26 06:53:11 +00:00
19 changed files with 565 additions and 544 deletions
Showing only changes of commit e7a5952d58 - Show all commits

View File

@@ -15,7 +15,7 @@ status: draft
- API base前端通过 `VITE_API_URL` 指向后端;本地可同源 `/api` - API base前端通过 `VITE_API_URL` 指向后端;本地可同源 `/api`
- 上传文件可通过 `/uploads/...` 或完整 URL 返回;前端会用 `assetUrl()` 处理相对路径。 - 上传文件可通过 `/uploads/...` 或完整 URL 返回;前端会用 `assetUrl()` 处理相对路径。
- 所有时间字段使用 ISO 8601 字符串,例如 `2026-05-24T14:42:00.000Z` - 所有时间字段使用 ISO 8601 字符串,例如 `2026-05-24T14:42:00.000Z`
- 语言字段:`zh-TW` / `zh-CN` / `en` - 语言字段:`zh` / `en` / `ja` / `ko` / `vi` / `id` / `ms`;默认语言为 `en`。中文只有简体 `zh`,没有繁体中文
- 错误格式:非 2xx + text/plain 或 JSON 均可;前端会显示错误文本。 - 错误格式:非 2xx + text/plain 或 JSON 均可;前端会显示错误文本。
- Admin 接口需要 `Authorization: Bearer <token>` - Admin 接口需要 `Authorization: Bearer <token>`
@@ -57,7 +57,7 @@ type Post = {
id: string; id: string;
categoryId: number; categoryId: number;
categorySlug: string; categorySlug: string;
language: "zh-TW" | "zh-CN" | "en"; language: "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
text?: string; text?: string;
attachments: Attachment[]; attachments: Attachment[];
isRecommended: boolean; isRecommended: boolean;
@@ -88,7 +88,7 @@ type PostListResponse = {
### 2.1 分类列表 ### 2.1 分类列表
```http ```http
GET /api/categories?lang=zh-CN GET /api/categories?lang=en
``` ```
Response: Response:
@@ -104,29 +104,30 @@ Category[]
### 2.2 全部资料 / 分类资料流 ### 2.2 全部资料 / 分类资料流
```http ```http
GET /api/posts?lang=zh-CN&limit=20&cursor=<cursor>&type=all&language=&category=<slug> GET /api/posts?lang=en&limit=20&cursor=<cursor>&type=all&language=&category=<slug>
``` ```
Query: Query:
| 参数 | 必填 | 说明 | | 参数 | 必填 | 说明 |
|---|---:|---| | ---------- | ---: | ------------------------------------------- |
| `lang` | 是 | UI 语言 | | `lang` | 是 | UI 语言 |
| `limit` | 否 | 默认 20最大建议 50 | | `limit` | 否 | 默认 20最大建议 50 |
| `cursor` | 否 | 后端返回的不透明 cursor | | `cursor` | 否 | 后端返回的不透明 cursor |
| `category` | 否 | 不传 = 全部资料;传 slug = 单分类 | | `category` | 否 | 不传 = 全部资料;传 slug = 单分类 |
| `type` | 否 | `all/image/video/ppt/pdf/text/link/archive` | | `type` | 否 | `all/image/video/ppt/pdf/text/link/archive` |
| `language` | 否 | 资料语言:`zh-TW/zh-CN/en` | | `language` | 否 | 资料语言:`zh/en/ja/ko/vi/id/ms` |
Response: Response:
```ts ```ts
PostListResponse PostListResponse;
``` ```
排序:`publishedAt DESC` 排序:`publishedAt DESC`
用途: 用途:
- `/browse`:不传 `category` - `/browse`:不传 `category`
- `/category/:slug`:传 `category=<slug>` - `/category/:slug`:传 `category=<slug>`
@@ -135,7 +136,7 @@ PostListResponse
### 2.3 Home 推荐资料 ### 2.3 Home 推荐资料
```http ```http
GET /api/posts/recommended?lang=zh-CN&limit=12 GET /api/posts/recommended?lang=en&limit=12
``` ```
Response: Response:
@@ -153,7 +154,7 @@ Response:
### 2.4 Home 最新资料 ### 2.4 Home 最新资料
```http ```http
GET /api/posts/latest?lang=zh-CN&limit=8 GET /api/posts/latest?lang=en&limit=8
``` ```
Response: Response:
@@ -177,7 +178,7 @@ GET /api/posts/:id
Response: Response:
```ts ```ts
Post Post;
``` ```
用途:旧 `/resource/:id` 前端重定向:拿 `categorySlug` 后跳到 `/category/<slug>#post-<id>` 用途:旧 `/resource/:id` 前端重定向:拿 `categorySlug` 后跳到 `/category/<slug>#post-<id>`
@@ -189,13 +190,13 @@ Post
建议新接口: 建议新接口:
```http ```http
GET /api/posts/search?q=<keyword>&lang=zh-CN&type=all&language=&cursor=&limit=20 GET /api/posts/search?q=<keyword>&lang=en&type=all&language=&cursor=&limit=20
``` ```
Response: Response:
```ts ```ts
PostListResponse PostListResponse;
``` ```
搜索范围建议:`text``filename``categoryName`、tags。 搜索范围建议:`text``filename``categoryName`、tags。
@@ -246,7 +247,7 @@ Response204 或 `{ ok: true }`。
`GET /api/posts``type` 参数建议按以下规则命中: `GET /api/posts``type` 参数建议按以下规则命中:
| type | 命中条件 | | type | 命中条件 |
|---|---| | --------- | ----------------------------------------------------------------- |
| `all` | 全部 | | `all` | 全部 |
| `image` | 任一 attachment `kind === "image"` | | `image` | 任一 attachment `kind === "image"` |
| `video` | 任一 attachment `kind === "video"``mime.startsWith("video/")` | | `video` | 任一 attachment `kind === "video"``mime.startsWith("video/")` |
@@ -270,7 +271,9 @@ Content-Type: application/json
Response: Response:
```ts ```ts
{ message: string } {
message: string;
}
``` ```
### 4.2 验证签名并签发 token ### 4.2 验证签名并签发 token
@@ -289,7 +292,9 @@ Content-Type: application/json
Response: Response:
```ts ```ts
{ token: string } {
token: string;
}
``` ```
### 4.3 验证当前 wallet session ### 4.3 验证当前 wallet session
@@ -302,7 +307,9 @@ Authorization: Bearer <wallet-token>
Response: Response:
```ts ```ts
{ wallet: string } {
wallet: string;
}
``` ```
## 5. Admin API ## 5. Admin API
@@ -319,7 +326,9 @@ Content-Type: application/json
Response: Response:
```ts ```ts
{ token: string } {
token: string;
}
``` ```
--- ---
@@ -367,7 +376,9 @@ file=<File>
最低 Response: 最低 Response:
```ts ```ts
{ url: string } {
url: string;
}
``` ```
建议 Response更方便前端自动建 Attachment 建议 Response更方便前端自动建 Attachment
@@ -438,7 +449,7 @@ Request:
```ts ```ts
type UpsertPostPayload = { type UpsertPostPayload = {
categoryId: number; categoryId: number;
language: "zh-TW" | "zh-CN" | "en"; language: "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
text?: string; text?: string;
attachments: Attachment[]; attachments: Attachment[];
isPublic: boolean; isPublic: boolean;

View File

@@ -33,7 +33,7 @@ type Post = {
id: string; id: string;
categoryId: number; categoryId: number;
categorySlug: string; categorySlug: string;
language: string; // "zh-TW" | "zh-CN" | "en" language: string; // "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms"
text?: string; // 可选,纯文本/图说;前端做 https → 链接自动识别 text?: string; // 可选,纯文本/图说;前端做 https → 链接自动识别
attachments: Attachment[]; // 0~Ntext-only post 时为 [] attachments: Attachment[]; // 0~Ntext-only post 时为 []
isRecommended: boolean; isRecommended: boolean;
@@ -68,11 +68,11 @@ GET /api/posts
Query 参数: Query 参数:
| 参数 | 必填 | 说明 | | 参数 | 必填 | 说明 |
|---|---|---| | ---------- | ---- | ---------------------------------------------------------------------------------- |
| `lang` | 是 | UI 语言;后端可据此选择不同语言版本的 `text` | | `lang` | 是 | UI 语言;后端可据此选择不同语言版本的 `text` |
| `category` | 否 | category slug不传 = 全部分类 | | `category` | 否 | category slug不传 = 全部分类 |
| `type` | 否 | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`;语义见 §3 | | `type` | 否 | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`;语义见 §3 |
| `language` | 否 | 资源语言:`zh-TW` / `zh-CN` / `en` | | `language` | 否 | 资源语言:`zh` / `en` / `ja` / `ko` / `vi` / `id` / `ms` |
| `cursor` | 否 | 上一次返回的 `nextCursor`;不传 = 第一页 | | `cursor` | 否 | 上一次返回的 `nextCursor`;不传 = 第一页 |
| `limit` | 否 | 默认 20最大 50 | | `limit` | 否 | 默认 20最大 50 |
@@ -117,6 +117,7 @@ GET /api/admin/posts?... (含未发布草稿)
``` ```
需求: 需求:
- 支持多附件上传(一次 multipart 或先 `POST /api/admin/upload` 拿到 url 再创建 Post - 支持多附件上传(一次 multipart 或先 `POST /api/admin/upload` 拿到 url 再创建 Post
- Admin UI 需要一个开关:「图片以图片形式呈现 / 以文档形式呈现」,对应 attachment.kind 的 image vs document。 - Admin UI 需要一个开关:「图片以图片形式呈现 / 以文档形式呈现」,对应 attachment.kind 的 image vs document。
- 支持发布/隐藏、置顶/官方推荐。 - 支持发布/隐藏、置顶/官方推荐。
@@ -128,7 +129,7 @@ GET /api/admin/posts?... (含未发布草稿)
一个 Post 命中某个 `type`,规则: 一个 Post 命中某个 `type`,规则:
| type | 命中条件 | | type | 命中条件 |
|---|---| | --------- | -------------------------------------------------------------------------- |
| `all` | 全部 | | `all` | 全部 |
| `image` | `attachments` 中至少一个 `kind === "image"``mime.startsWith("image/")` | | `image` | `attachments` 中至少一个 `kind === "image"``mime.startsWith("image/")` |
| `video` | 至少一个 `kind === "video"``mime.startsWith("video/")` | | `video` | 至少一个 `kind === "video"``mime.startsWith("video/")` |
@@ -143,7 +144,7 @@ GET /api/admin/posts?... (含未发布草稿)
## 4. 删除 / 废弃 ## 4. 删除 / 废弃
| 项 | 处理 | | 项 | 处理 |
|---|---| | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/resources/:id/favorite` | 删除 | | `POST /api/resources/:id/favorite` | 删除 |
| `GET /api/favorites` / 收藏列表 | 删除(前端 `/favorites` 路由已移除) | | `GET /api/favorites` / 收藏列表 | 删除(前端 `/favorites` 路由已移除) |
| `/r/:id` 老前端路由 | 已合并到 `/resource/:id` 重定向 | | `/r/:id` 老前端路由 | 已合并到 `/resource/:id` 重定向 |

View File

@@ -52,7 +52,7 @@ npm test
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template. Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
| Variable | Purpose | | Variable | Purpose |
| --- | --- | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. | | `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. |
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. | | `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. |
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. | | `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
@@ -68,7 +68,7 @@ src/
App.tsx # public app + optional admin routes App.tsx # public app + optional admin routes
AppAdminOnly.tsx # admin-only app entry AppAdminOnly.tsx # admin-only app entry
api.ts # fetch helpers and shared API types api.ts # fetch helpers and shared API types
i18n.tsx # zh-TW / zh-CN / en copy dictionary i18n.tsx # zh / en / ja / ko / vi / id / ms dictionary
adminPaths.ts # admin UI prefix logic adminPaths.ts # admin UI prefix logic
adminRouteTree.tsx # admin routes adminRouteTree.tsx # admin routes
components/ # reusable public components components/ # reusable public components

View File

@@ -1,7 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { tLang } from "../i18n"; import { tLang } from "../i18n";
/** Admin area always uses 繁體中文, independent of site language. */ /** Admin area always uses Chinese, independent of site language. */
export function useAdminT() { export function useAdminT() {
return useCallback((key: string) => tLang("zh-TW", key), []); return useCallback((key: string) => tLang("zh", key), []);
} }

View File

@@ -1,10 +1,12 @@
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { LANG_OPTIONS, languageLabel } from "../../i18nLanguages";
import { typeFilterLabel } from "../../resourceTypeLabels"; import { typeFilterLabel } from "../../resourceTypeLabels";
const TYPE_FILTERS = [ const TYPE_FILTERS = [
"all", "all",
"image", "image",
"video", "video",
"music",
"ppt", "ppt",
"pdf", "pdf",
"text", "text",
@@ -12,14 +14,7 @@ const TYPE_FILTERS = [
"archive", "archive",
] as const; ] as const;
const LANG_FILTERS = ["", "zh-TW", "zh-CN", "en"] as const; const LANG_FILTERS = ["", ...LANG_OPTIONS.map((x) => x.code)] as const;
function langLabel(t: (k: string) => string, code: string) {
if (!code) return t("filterLanguageAll");
if (code === "zh-TW") return t("lang_zh_TW");
if (code === "zh-CN") return t("lang_zh_CN");
return t("lang_en");
}
export type FilterChipsProps = { export type FilterChipsProps = {
type: string; type: string;
@@ -70,7 +65,7 @@ export function FilterChips({
: "border-ark-line text-neutral-300 hover:border-ark-gold/50" : "border-ark-line text-neutral-300 hover:border-ark-gold/50"
}`} }`}
> >
{langLabel(t, code)} {languageLabel(t, code)}
</button> </button>
); );
})} })}

View File

@@ -26,8 +26,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
const { items, isLoading, error, hasMore, loadMore, reset } = const { items, isLoading, error, hasMore, loadMore, reset } =
usePostStream(params); usePostStream(params);
const groups = useGroupedByDay(items, lang); const groups = useGroupedByDay(items, lang);
const retryLabel = const retryLabel = lang === "zh" ? "重试" : "Retry";
lang === "zh-TW" ? "重試" : lang === "zh-CN" ? "重试" : "Retry";
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(hasMore); const hasMoreRef = useRef(hasMore);

View File

@@ -8,7 +8,7 @@ function makePost(id: string, isoDate: string): Post {
id, id,
categoryId: 1, categoryId: 1,
categorySlug: "x", categorySlug: "x",
language: "zh-CN", language: "zh",
attachments: [], attachments: [],
isRecommended: false, isRecommended: false,
publishedAt: isoDate, publishedAt: isoDate,
@@ -25,7 +25,7 @@ describe("useGroupedByDay", () => {
makePost("c", "2026-02-28T01:00:00.000Z"), makePost("c", "2026-02-28T01:00:00.000Z"),
makePost("d", "2026-05-16T12:00:00.000Z"), makePost("d", "2026-05-16T12:00:00.000Z"),
]; ];
const { result } = renderHook(() => useGroupedByDay(posts, "zh-CN")); const { result } = renderHook(() => useGroupedByDay(posts, "zh"));
expect(result.current.length).toBeGreaterThanOrEqual(2); expect(result.current.length).toBeGreaterThanOrEqual(2);
const allIds = result.current.flatMap((g) => g.items.map((p) => p.id)); const allIds = result.current.flatMap((g) => g.items.map((p) => p.id));
expect(allIds).toEqual(["a", "b", "c", "d"]); expect(allIds).toEqual(["a", "b", "c", "d"]);
@@ -47,7 +47,7 @@ describe("useGroupedByDay", () => {
}); });
it("returns empty array for empty input", () => { it("returns empty array for empty input", () => {
const { result } = renderHook(() => useGroupedByDay([], "zh-CN")); const { result } = renderHook(() => useGroupedByDay([], "zh"));
expect(result.current).toEqual([]); expect(result.current).toEqual([]);
}); });
}); });

View File

@@ -8,9 +8,16 @@ export type DayGroup = {
}; };
function localeFor(lang: string): string { function localeFor(lang: string): string {
if (lang === "zh-TW") return "zh-TW"; const locales: Record<string, string> = {
if (lang === "zh-CN") return "zh-CN"; zh: "zh-CN",
return "en-US"; en: "en-US",
ja: "ja-JP",
ko: "ko-KR",
vi: "vi-VN",
id: "id-ID",
ms: "ms-MY",
};
return locales[lang] ?? "en-US";
} }
function dayKey(iso: string): string { function dayKey(iso: string): string {
@@ -28,12 +35,10 @@ function dayLabel(iso: string, lang: string): string {
a.getMonth() === b.getMonth() && a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate(); a.getDate() === b.getDate();
if (isSameDay(d, today)) { if (isSameDay(d, today)) {
if (lang === "en") return "Today"; return lang === "zh" ? "今天" : "Today";
return "今天";
} }
if (isSameDay(d, yesterday)) { if (isSameDay(d, yesterday)) {
if (lang === "en") return "Yesterday"; return lang === "zh" ? "昨天" : "Yesterday";
return "昨天";
} }
return new Intl.DateTimeFormat(localeFor(lang), { return new Intl.DateTimeFormat(localeFor(lang), {
month: "long", month: "long",

View File

@@ -35,6 +35,7 @@ function postMatchesType(post: Post, type: string): boolean {
return a.kind === "image" || a.mime.startsWith("image/"); return a.kind === "image" || a.mime.startsWith("image/");
if (type === "video") if (type === "video")
return a.kind === "video" || a.mime.startsWith("video/"); return a.kind === "video" || a.mime.startsWith("video/");
if (type === "music") return a.mime.startsWith("audio/") || ext === "mp3";
if (type === "pdf") return ext === "pdf" || a.mime === "application/pdf"; if (type === "pdf") return ext === "pdf" || a.mime === "application/pdf";
if (type === "ppt") if (type === "ppt")
return ( return (

View File

@@ -1,7 +1,14 @@
function localeFor(lang: string): string { function localeFor(lang: string): string {
if (lang === "zh-TW") return "zh-TW"; const locales: Record<string, string> = {
if (lang === "zh-CN") return "zh-CN"; zh: "zh-CN",
return "en-US"; en: "en-US",
ja: "ja-JP",
ko: "ko-KR",
vi: "vi-VN",
id: "id-ID",
ms: "ms-MY",
};
return locales[lang] ?? "en-US";
} }
function formatDate(iso: string, lang: string): string { function formatDate(iso: string, lang: string): string {

View File

@@ -6,133 +6,11 @@ import React, {
useState, useState,
} from "react"; } from "react";
export type Lang = "zh-TW" | "zh-CN" | "en"; export type Lang = "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
type Dict = Record<string, string>; type Dict = Record<string, string>;
const dict: Record<Lang, Dict> = { const zhDict: Dict = {
"zh-TW": {
brand: "ARK 資料庫",
mainNav: "網站導覽",
home: "首頁",
all: "全部資料",
categories: "分類瀏覽",
latest: "最新更新",
official: "官方推薦",
popular: "熱門資料",
search: "搜尋",
searchPlaceholder: "搜尋資料...",
searchNow: "立即搜尋資料",
viewAll: "查看全部",
heroTitle: "ARK 官方資料庫",
heroSub:
"集中、分類、管理 ARK 資料庫,讓你快速找到所需資源,推動社群共識與成長。",
categorySection: "資料分類",
officialSection: "官方推薦",
latestSection: "最新更新",
popularSection: "熱門資料",
preview: "預覽",
download: "下載",
favorite: "收藏",
share: "分享",
profile: "個人中心",
langLabel: "語言",
admin: "後台",
login: "登入",
logout: "登出",
email: "電子郵件",
password: "密碼",
dashboard: "儀表板",
resources: "資料管理",
newResource: "新增資料",
save: "儲存",
title: "標題",
description: "簡介",
type: "類型",
language: "語言",
category: "分類",
status: "狀態",
public: "公開",
downloadable: "可下載",
recommended: "首頁推薦",
cover: "封面圖 URL",
fileUrl: "檔案 URL",
externalUrl: "外部連結",
body: "文案內容",
badge: "推薦標籤",
published: "已發布",
draft: "草稿",
archived: "封存",
noResults: "找不到符合的資料,請換個關鍵字或瀏覽分類。",
copyLink: "複製連結",
related: "相關資料",
total: "總資料",
views: "瀏覽",
downloads: "下載",
wallet: "錢包",
walletPageTitle: "錢包登入",
walletPageIntro:
"連接 Web3 錢包以使用會員相關功能。採用標準簽名登入,不會發送交易、不消耗 gas。",
walletStepExtension:
"電腦已安裝擴充錢包(如 MetaMask可直接在瀏覽器連線。",
walletStepQR:
"電腦未安裝錢包時:在連線視窗選擇 WalletConnect用手機錢包掃描畫面上的 QR Code 即可連線。",
walletStepSign:
"連線成功後,點「簽署登入」並在錢包內簽署訊息,即完成網站身分驗證。",
signInWallet: "簽署登入",
walletSignedIn: "已驗證登入",
walletLogout: "登出錢包",
walletMissingProjectId:
"請設定 VITE_WALLETCONNECT_PROJECT_IDReown Cloud 免費申請),否則無法使用 WalletConnect手機掃碼。",
walletSetupNeeded: "錢包掃碼未啟用(請於伺服器設定環境變數)",
lang_zh_TW: "繁體中文",
lang_zh_CN: "简体中文",
lang_en: "English",
filterAll: "全部",
sortPublished: "發布時間",
type_ppt: "PPT",
type_video: "影片",
type_image: "圖片",
type_pdf: "PDF",
type_link: "連結",
type_text: "文字",
type_archive: "壓縮檔",
type_zip: "ZIP",
adminLoginTitle: "管理後台登入",
adminEditResource: "編輯資料",
adminVideoFileHint:
"上傳影片檔MP4/WebM/MOV 等),類型請選「影片」;儲存後前台會自動播放(預設靜音,可點喇叭開聲音)。",
adminStatTodayNew: "今日新增",
adminStatFavorites: "收藏",
adminMetricDownloads: "下載",
adminMetricFavorites: "收藏",
adminMetricViews: "瀏覽",
edit: "編輯",
backToList: "返回列表",
sortOrderLabel: "排序權重",
previewUrlLabel: "預覽網址",
tagsCommaLabel: "標籤(逗號分隔)",
uploadFile: "上傳檔案",
loading: "載入中…",
paginationPrev: "上一頁",
paginationNext: "下一頁",
listRange: "顯示 {{from}}{{to}},共 {{total}} 筆",
pageIndicator: "{{c}} / {{p}} 頁",
resourceLangFilter: "資料語言",
filterTagClear: "清除標籤",
filterLanguageAll: "全部語言",
aboutTitle: "關於本站",
aboutIntro:
"ARK 資料庫彙整官方教材、公告、影片與常用檔案,協助社群快速取得一致版本的可信內容。\n\n本站僅作展示與索引資料權利仍以官方公告為準。",
footerAbout: "關於本站",
footerAdminLogin: "管理員登入",
adminSearchLogs: "搜尋紀錄",
adminMetricShares: "分享",
adminSearchQuery: "查詢詞",
adminSearchTime: "時間",
adminSearchId: "編號",
},
"zh-CN": {
brand: "ARK 数据库", brand: "ARK 数据库",
mainNav: "网站导航", mainNav: "网站导航",
home: "首页", home: "首页",
@@ -154,7 +32,6 @@ const dict: Record<Lang, Dict> = {
popularSection: "热门资料", popularSection: "热门资料",
preview: "预览", preview: "预览",
download: "下载", download: "下载",
favorite: "收藏",
share: "分享", share: "分享",
profile: "个人中心", profile: "个人中心",
langLabel: "语言", langLabel: "语言",
@@ -205,12 +82,17 @@ const dict: Record<Lang, Dict> = {
walletMissingProjectId: walletMissingProjectId:
"请配置 VITE_WALLETCONNECT_PROJECT_IDReown Cloud否则无法使用 WalletConnect/扫码。", "请配置 VITE_WALLETCONNECT_PROJECT_IDReown Cloud否则无法使用 WalletConnect/扫码。",
walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)", walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)",
lang_zh_TW: "繁体中文", lang_zh: "中文",
lang_zh_CN: "简体中文",
lang_en: "English", lang_en: "English",
lang_ja: "日本語",
lang_ko: "한국어",
lang_vi: "Tiếng Việt",
lang_id: "Bahasa Indonesia",
lang_ms: "Bahasa Melayu",
filterAll: "全部", filterAll: "全部",
sortPublished: "发布时间", sortPublished: "发布时间",
type_ppt: "PPT", type_ppt: "PPT",
type_music: "音乐",
type_video: "视频", type_video: "视频",
type_image: "图片", type_image: "图片",
type_pdf: "PDF", type_pdf: "PDF",
@@ -251,8 +133,9 @@ const dict: Record<Lang, Dict> = {
adminSearchQuery: "查询词", adminSearchQuery: "查询词",
adminSearchTime: "时间", adminSearchTime: "时间",
adminSearchId: "编号", adminSearchId: "编号",
}, };
en: {
const enDict: Dict = {
brand: "ARK Library", brand: "ARK Library",
mainNav: "Site menu", mainNav: "Site menu",
home: "Home", home: "Home",
@@ -325,12 +208,17 @@ const dict: Record<Lang, Dict> = {
walletMissingProjectId: walletMissingProjectId:
"Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.", "Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.",
walletSetupNeeded: "Wallet QR login disabled (set env on server)", walletSetupNeeded: "Wallet QR login disabled (set env on server)",
lang_zh_TW: "Traditional Chinese", lang_zh: "Chinese",
lang_zh_CN: "Simplified Chinese",
lang_en: "English", lang_en: "English",
lang_ja: "Japanese",
lang_ko: "Korean",
lang_vi: "Vietnamese",
lang_id: "Indonesian",
lang_ms: "Malay",
filterAll: "All types", filterAll: "All types",
sortPublished: "Published date", sortPublished: "Published date",
type_ppt: "PPT", type_ppt: "PPT",
type_music: "Music",
type_video: "Video", type_video: "Video",
type_image: "Image", type_image: "Image",
type_pdf: "PDF", type_pdf: "PDF",
@@ -371,12 +259,87 @@ const dict: Record<Lang, Dict> = {
adminSearchQuery: "Query", adminSearchQuery: "Query",
adminSearchTime: "Time", adminSearchTime: "Time",
adminSearchId: "ID", adminSearchId: "ID",
};
const languageNames: Record<Lang, Dict> = {
zh: {
lang_zh: "中文",
lang_en: "English",
lang_ja: "日本語",
lang_ko: "한국어",
lang_vi: "Tiếng Việt",
lang_id: "Bahasa Indonesia",
lang_ms: "Bahasa Melayu",
},
en: {
lang_zh: "Chinese",
lang_en: "English",
lang_ja: "Japanese",
lang_ko: "Korean",
lang_vi: "Vietnamese",
lang_id: "Indonesian",
lang_ms: "Malay",
},
ja: {
lang_zh: "中国語",
lang_en: "英語",
lang_ja: "日本語",
lang_ko: "韓国語",
lang_vi: "ベトナム語",
lang_id: "インドネシア語",
lang_ms: "マレー語",
},
ko: {
lang_zh: "중국어",
lang_en: "영어",
lang_ja: "일본어",
lang_ko: "한국어",
lang_vi: "베트남어",
lang_id: "인도네시아어",
lang_ms: "말레이어",
},
vi: {
lang_zh: "Tiếng Trung",
lang_en: "Tiếng Anh",
lang_ja: "Tiếng Nhật",
lang_ko: "Tiếng Hàn",
lang_vi: "Tiếng Việt",
lang_id: "Tiếng Indonesia",
lang_ms: "Tiếng Mã Lai",
},
id: {
lang_zh: "Bahasa Tionghoa",
lang_en: "Bahasa Inggris",
lang_ja: "Bahasa Jepang",
lang_ko: "Bahasa Korea",
lang_vi: "Bahasa Vietnam",
lang_id: "Bahasa Indonesia",
lang_ms: "Bahasa Melayu",
},
ms: {
lang_zh: "Bahasa Cina",
lang_en: "Bahasa Inggeris",
lang_ja: "Bahasa Jepun",
lang_ko: "Bahasa Korea",
lang_vi: "Bahasa Vietnam",
lang_id: "Bahasa Indonesia",
lang_ms: "Bahasa Melayu",
}, },
}; };
/** Fixed locale lookup (for admin UI always in Traditional Chinese). */ const dict: Record<Lang, Dict> = {
zh: { ...zhDict, ...languageNames.zh },
en: { ...enDict, ...languageNames.en },
ja: { ...enDict, ...languageNames.ja },
ko: { ...enDict, ...languageNames.ko },
vi: { ...enDict, ...languageNames.vi },
id: { ...enDict, ...languageNames.id },
ms: { ...enDict, ...languageNames.ms },
};
/** Fixed locale lookup (admin UI uses Simplified Chinese). */
export function tLang(lang: Lang, key: string): string { export function tLang(lang: Lang, key: string): string {
return dict[lang][key] || dict["zh-TW"][key] || key; return dict[lang][key] || dict.en[key] || key;
} }
type Ctx = { lang: Lang; setLang: (l: Lang) => void; t: (k: string) => string }; type Ctx = { lang: Lang; setLang: (l: Lang) => void; t: (k: string) => string };
@@ -387,16 +350,26 @@ const LANG_KEY = "ark_lang";
export function I18nProvider({ children }: { children: React.ReactNode }) { export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Lang>(() => { const [lang, setLangState] = useState<Lang>(() => {
const s = localStorage.getItem(LANG_KEY) as Lang | null; const s = localStorage.getItem(LANG_KEY);
if (s === "zh-CN" || s === "en" || s === "zh-TW") return s; if (s === "zh-CN" || s === "zh-TW") return "zh";
return "zh-TW"; if (
s === "zh" ||
s === "en" ||
s === "ja" ||
s === "ko" ||
s === "vi" ||
s === "id" ||
s === "ms"
)
return s;
return "en";
}); });
const setLang = (l: Lang) => { const setLang = (l: Lang) => {
localStorage.setItem(LANG_KEY, l); localStorage.setItem(LANG_KEY, l);
setLangState(l); setLangState(l);
}; };
const t = useCallback( const t = useCallback(
(k: string) => dict[lang][k] || dict["zh-TW"][k] || k, (k: string) => dict[lang][k] || dict.en[k] || k,
[lang], [lang],
); );
const v = useMemo(() => ({ lang, setLang, t }), [lang, t]); const v = useMemo(() => ({ lang, setLang, t }), [lang, t]);
@@ -410,7 +383,5 @@ export function useI18n() {
} }
export function langQuery(lang: Lang) { export function langQuery(lang: Lang) {
if (lang === "zh-TW") return "zh-TW"; return lang;
if (lang === "zh-CN") return "zh-CN";
return "en";
} }

17
src/i18nLanguages.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { Lang } from "./i18n";
export const LANG_OPTIONS: { code: Lang; label: string }[] = [
{ code: "zh", label: "中文" },
{ code: "en", label: "English" },
{ code: "ja", label: "日本語" },
{ code: "ko", label: "한국어" },
{ code: "vi", label: "Tiếng Việt" },
{ code: "id", label: "Bahasa Indonesia" },
{ code: "ms", label: "Bahasa Melayu" },
];
export function languageLabel(t: (key: string) => string, code: string) {
if (!code) return t("filterLanguageAll");
const label = t(`lang_${code}`);
return label === `lang_${code}` ? code : label;
}

View File

@@ -3,6 +3,7 @@ import { useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { ArkLogoMark } from "../components/ArkLogoMark"; import { ArkLogoMark } from "../components/ArkLogoMark";
import { useI18n, type Lang } from "../i18n"; import { useI18n, type Lang } from "../i18n";
import { LANG_OPTIONS } from "../i18nLanguages";
import { adminUiPrefix } from "../adminPaths"; import { adminUiPrefix } from "../adminPaths";
type PublicNavWhich = type PublicNavWhich =
@@ -165,9 +166,11 @@ export function PublicLayout() {
onChange={(e) => setLang(e.target.value as Lang)} onChange={(e) => setLang(e.target.value as Lang)}
aria-label={t("langLabel")} aria-label={t("langLabel")}
> >
<option value="zh-TW"></option> {LANG_OPTIONS.map((option) => (
<option value="zh-CN"></option> <option key={option.code} value={option.code}>
<option value="en">English</option> {option.label}
</option>
))}
</select> </select>
</div> </div>
<button <button
@@ -194,6 +197,21 @@ export function PublicLayout() {
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]" className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
/> />
</div> </div>
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
<Globe size={16} className="shrink-0 text-ark-gold/80" />
<select
className="w-full bg-transparent text-sm text-neutral-200 outline-none"
value={lang}
onChange={(e) => setLang(e.target.value as Lang)}
aria-label={t("langLabel")}
>
{LANG_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</div>
<Link <Link
to="/" to="/"
className={navClassName(na("home"))} className={navClassName(na("home"))}

View File

@@ -31,7 +31,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-001", id: "p-001",
categoryId: 1, categoryId: 1,
categorySlug: "project", categorySlug: "project",
language: "zh-CN", language: "zh",
attachments: [ attachments: [
{ {
id: "a-001", id: "a-001",
@@ -55,7 +55,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-002", id: "p-002",
categoryId: 1, categoryId: 1,
categorySlug: "project", categorySlug: "project",
language: "zh-CN", language: "zh",
attachments: [ attachments: [
{ {
id: "a-002", id: "a-002",
@@ -79,7 +79,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-013", id: "p-013",
categoryId: 1, categoryId: 1,
categorySlug: "project", categorySlug: "project",
language: "zh-CN", language: "zh",
attachments: [ attachments: [
{ {
id: "a-013", id: "a-013",
@@ -100,7 +100,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-003", id: "p-003",
categoryId: 2, categoryId: 2,
categorySlug: "guide", categorySlug: "guide",
language: "zh-TW", language: "zh",
attachments: [ attachments: [
{ {
id: "a-003", id: "a-003",
@@ -121,7 +121,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-004", id: "p-004",
categoryId: 2, categoryId: 2,
categorySlug: "guide", categorySlug: "guide",
language: "zh-TW", language: "zh",
attachments: [ attachments: [
{ {
id: "a-004", id: "a-004",
@@ -142,7 +142,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-005", id: "p-005",
categoryId: 3, categoryId: 3,
categorySlug: "data", categorySlug: "data",
language: "zh-CN", language: "zh",
text: text:
"📊 ARK DeFAI 各大平台现已上线 🔥\n\n" + "📊 ARK DeFAI 各大平台现已上线 🔥\n\n" +
"🔷 市场数据平台\n" + "🔷 市场数据平台\n" +
@@ -163,7 +163,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-006", id: "p-006",
categoryId: 3, categoryId: 3,
categorySlug: "data", categorySlug: "data",
language: "zh-CN", language: "zh",
text: text:
"📌 收取协议固定 2.5% 手续费。\n\n" + "📌 收取协议固定 2.5% 手续费。\n\n" +
"🔷 贡献值合约\n0x7736b5B84cADDB7661D250D10e60E31F3C905c99\n📌 用于新贡献值机制的 USDT 购买与资金流向管理(通缩销毁 / 储备 RBS", "🔷 贡献值合约\n0x7736b5B84cADDB7661D250D10e60E31F3C905c99\n📌 用于新贡献值机制的 USDT 购买与资金流向管理(通缩销毁 / 储备 RBS",
@@ -178,7 +178,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-007", id: "p-007",
categoryId: 4, categoryId: 4,
categorySlug: "videos", categorySlug: "videos",
language: "zh-CN", language: "zh",
text: "ARK 山东·东营社区 招商复盘·势位重塑\n🔥 ARK DeFai 相位偏移锁死增值弧度。质能裂变诱发认知风暴,海岱动能正于中原合围!🚀", text: "ARK 山东·东营社区 招商复盘·势位重塑\n🔥 ARK DeFai 相位偏移锁死增值弧度。质能裂变诱发认知风暴,海岱动能正于中原合围!🚀",
attachments: [ attachments: [
{ {
@@ -204,7 +204,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-008", id: "p-008",
categoryId: 5, categoryId: 5,
categorySlug: "poster", categorySlug: "poster",
language: "zh-CN", language: "zh",
attachments: [ attachments: [
{ {
id: "a-008", id: "a-008",
@@ -228,7 +228,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-009", id: "p-009",
categoryId: 5, categoryId: 5,
categorySlug: "poster", categorySlug: "poster",
language: "zh-CN", language: "zh",
attachments: [ attachments: [
{ {
id: "a-009", id: "a-009",
@@ -252,7 +252,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-010", id: "p-010",
categoryId: 6, categoryId: 6,
categorySlug: "meeting", categorySlug: "meeting",
language: "zh-CN", language: "zh",
text: "📌 ARK DeFAI 方舟晨间时刻\n\n🧠 会议主题:市场概况交流 & 市场问题讨论。\n🕙 会议时间3月1日10:00\n🎬 直播腾讯会议链接https://meeting.tencent.com/l/G718S4Sedm38", text: "📌 ARK DeFAI 方舟晨间时刻\n\n🧠 会议主题:市场概况交流 & 市场问题讨论。\n🕙 会议时间3月1日10:00\n🎬 直播腾讯会议链接https://meeting.tencent.com/l/G718S4Sedm38",
attachments: [ attachments: [
{ {
@@ -277,7 +277,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-011", id: "p-011",
categoryId: 5, categoryId: 5,
categorySlug: "poster", categorySlug: "poster",
language: "zh-CN", language: "zh",
attachments: [ attachments: [
{ {
id: "a-011a", id: "a-011a",
@@ -323,7 +323,7 @@ export const MOCK_POSTS: Post[] = [
id: "p-012", id: "p-012",
categoryId: 5, categoryId: 5,
categorySlug: "poster", categorySlug: "poster",
language: "zh-CN", language: "zh",
attachments: Array.from({ length: 7 }).map((_, i) => ({ attachments: Array.from({ length: 7 }).map((_, i) => ({
id: `a-012-${i}`, id: `a-012-${i}`,
kind: "image" as const, kind: "image" as const,

View File

@@ -8,26 +8,21 @@ import {
type Resource, type Resource,
} from "../api"; } from "../api";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { LANG_OPTIONS, languageLabel } from "../i18nLanguages";
import { typeFilterLabel } from "../resourceTypeLabels"; import { typeFilterLabel } from "../resourceTypeLabels";
const types = [ const types = [
"all", "all",
"image", "image",
"video", "video",
"music",
"ppt", "ppt",
"pdf", "pdf",
"text", "text",
"link", "link",
"archive", "archive",
] as const; ] as const;
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const; const resourceLangCodes = ["", ...LANG_OPTIONS.map((x) => x.code)] as const;
function resourceLangLabel(t: (k: string) => string, code: string) {
if (!code) return t("filterLanguageAll");
if (code === "zh-TW") return t("lang_zh_TW");
if (code === "zh-CN") return t("lang_zh_CN");
return t("lang_en");
}
function ResultRow({ r }: { r: Resource }) { function ResultRow({ r }: { r: Resource }) {
const target = r.externalUrl || (r.fileUrl ? assetUrl(r.fileUrl) : null); const target = r.externalUrl || (r.fileUrl ? assetUrl(r.fileUrl) : null);
@@ -152,7 +147,7 @@ export function SearchPage() {
: "border-ark-line text-neutral-300" : "border-ark-line text-neutral-300"
}`} }`}
> >
{resourceLangLabel(t, code)} {languageLabel(t, code)}
</button> </button>
))} ))}
</div> </div>

View File

@@ -17,6 +17,7 @@ import { useAdminRouterMode } from "../../adminRouterMode";
const types = [ const types = [
"image", "image",
"video", "video",
"music",
"ppt", "ppt",
"pdf", "pdf",
"text", "text",
@@ -36,7 +37,7 @@ export function AdminResourceForm() {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [rtype, setRtype] = useState<string>("image"); const [rtype, setRtype] = useState<string>("image");
const [language, setLanguage] = useState("zh-TW"); const [language, setLanguage] = useState("zh");
const [categoryId, setCategoryId] = useState(1); const [categoryId, setCategoryId] = useState(1);
const [coverImage, setCoverImage] = useState(""); const [coverImage, setCoverImage] = useState("");
const [fileUrl, setFileUrl] = useState(""); const [fileUrl, setFileUrl] = useState("");
@@ -53,7 +54,7 @@ export function AdminResourceForm() {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
getJSON<Category[]>("/api/categories?lang=zh-TW") getJSON<Category[]>("/api/categories?lang=zh")
.then(setCats) .then(setCats)
.catch(() => setCats([])); .catch(() => setCats([]));
}, []); }, []);
@@ -65,7 +66,7 @@ export function AdminResourceForm() {
setTitle(r.title || ""); setTitle(r.title || "");
setDescription(r.description || ""); setDescription(r.description || "");
setRtype(r.type || "image"); setRtype(r.type || "image");
setLanguage(r.language || "zh-TW"); setLanguage(r.language || "zh");
setCategoryId(r.categoryId || 1); setCategoryId(r.categoryId || 1);
setCoverImage(r.coverImage || ""); setCoverImage(r.coverImage || "");
setFileUrl(r.fileUrl || ""); setFileUrl(r.fileUrl || "");
@@ -182,9 +183,13 @@ export function AdminResourceForm() {
value={language} value={language}
onChange={(e) => setLanguage(e.target.value)} onChange={(e) => setLanguage(e.target.value)}
> >
<option value="zh-TW">{t("lang_zh_TW")}</option> <option value="zh">{t("lang_zh")}</option>
<option value="zh-CN">{t("lang_zh_CN")}</option>
<option value="en">{t("lang_en")}</option> <option value="en">{t("lang_en")}</option>
<option value="ja">{t("lang_ja")}</option>
<option value="ko">{t("lang_ko")}</option>
<option value="vi">{t("lang_vi")}</option>
<option value="id">{t("lang_id")}</option>
<option value="ms">{t("lang_ms")}</option>
</select> </select>
</Field> </Field>
<Field label={t("status")}> <Field label={t("status")}>

View File

@@ -32,7 +32,7 @@ export function AdminResources() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
useEffect(() => { useEffect(() => {
getJSON<Category[]>("/api/categories?lang=zh-TW") getJSON<Category[]>("/api/categories?lang=zh")
.then((cats) => { .then((cats) => {
const m: Record<number, string> = {}; const m: Record<number, string> = {};
for (const c of cats) m[c.id] = c.name; for (const c of cats) m[c.id] = c.name;

View File

@@ -9,25 +9,29 @@ import {
const t = (key: string) => const t = (key: string) =>
({ ({
filterAll: "全部", filterAll: "全部",
type_image: "片", type_image: "片",
type_video: "影片", type_video: "视频",
lang_zh_TW: "繁中", type_music: "音乐",
lang_zh_CN: "中", lang_zh: "中",
lang_en: "英文", lang_en: "English",
lang_ja: "日本語",
})[key] ?? key; })[key] ?? key;
describe("resource labels", () => { describe("resource labels", () => {
it("localizes known resource types and falls back to raw type", () => { it("localizes known resource types and falls back to raw type", () => {
expect(typeFilterLabel(t, "all")).toBe("全部"); expect(typeFilterLabel(t, "all")).toBe("全部");
expect(resourceTypeLabel(t, "image")).toBe("片"); expect(resourceTypeLabel(t, "image")).toBe("片");
expect(resourceTypeDisplay(t, "video")).toBe("影片"); expect(resourceTypeDisplay(t, "video")).toBe("视频");
expect(resourceTypeLabel(t, "music")).toBe("音乐");
expect(resourceTypeLabel(t, "unknown")).toBe("unknown"); expect(resourceTypeLabel(t, "unknown")).toBe("unknown");
}); });
it("normalizes resource language codes", () => { it("normalizes resource language codes", () => {
expect(resourceLanguageLabel(t, "zh-TW")).toBe("中"); expect(resourceLanguageLabel(t, "zh-TW")).toBe("中");
expect(resourceLanguageLabel(t, "zh-hans")).toBe("中"); expect(resourceLanguageLabel(t, "zh-CN")).toBe("中");
expect(resourceLanguageLabel(t, "EN")).toBe("文"); expect(resourceLanguageLabel(t, "zh-hans")).toBe("文");
expect(resourceLanguageLabel(t, "ja")).toBe("ja"); expect(resourceLanguageLabel(t, "EN")).toBe("English");
expect(resourceLanguageLabel(t, "ja")).toBe("日本語");
expect(resourceLanguageLabel(t, "xx")).toBe("xx");
}); });
}); });

View File

@@ -27,23 +27,15 @@ export function resourceTypeLabel(
return label !== key ? label : type; return label !== key ? label : type;
} }
/** Localized label for resource `language` code (zh-TW, en, ). */ /** Localized label for resource language code (zh, en, ja, ko, vi, id, ms). */
export function resourceLanguageLabel( export function resourceLanguageLabel(
t: (key: string) => string, t: (key: string) => string,
langCode: string, langCode: string,
): string { ): string {
const lc = langCode.trim().toLowerCase(); const lc = langCode.trim().toLowerCase();
const key = const normalized =
lc === "zh-tw" lc === "zh-cn" || lc === "zh-tw" || lc === "zh-hans" ? "zh" : lc;
? "lang_zh_TW" const key = `lang_${normalized}`;
: lc === "zh-cn" || lc === "zh-hans"
? "lang_zh_CN"
: lc === "en"
? "lang_en"
: "";
if (key) {
const label = t(key); const label = t(key);
if (label !== key) return label; return label !== key ? label : langCode.trim();
}
return langCode.trim();
} }