terry-staging #1
@@ -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>`。
|
||||||
|
|
||||||
@@ -26,10 +26,10 @@ status: draft
|
|||||||
```ts
|
```ts
|
||||||
type Category = {
|
type Category = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string; // 用于 /category/:slug 和 GET /api/posts?category=<slug>
|
slug: string; // 用于 /category/:slug 和 GET /api/posts?category=<slug>
|
||||||
name: string; // 已按 lang 返回本地化名称
|
name: string; // 已按 lang 返回本地化名称
|
||||||
description?: string;
|
description?: string;
|
||||||
iconKey: string; // folder/calendar/megaphone/video/image 等,前端已有 icon map
|
iconKey: string; // folder/calendar/megaphone/video/image 等,前端已有 icon map
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -42,22 +42,22 @@ type AttachmentKind = "image" | "video" | "document";
|
|||||||
type Attachment = {
|
type Attachment = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: AttachmentKind;
|
kind: AttachmentKind;
|
||||||
url: string; // 原始文件或可访问文件 URL
|
url: string; // 原始文件或可访问文件 URL
|
||||||
mime: string; // image/jpeg, application/pdf, video/mp4, ...
|
mime: string; // image/jpeg, application/pdf, video/mp4, ...
|
||||||
filename: string;
|
filename: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
width?: number; // image/video 建议提供
|
width?: number; // image/video 建议提供
|
||||||
height?: number;
|
height?: number;
|
||||||
durationSec?: number; // video 建议提供
|
durationSec?: number; // video 建议提供
|
||||||
posterUrl?: string; // video preview
|
posterUrl?: string; // video preview
|
||||||
thumbnailUrl?: string; // image/document preview
|
thumbnailUrl?: string; // image/document preview
|
||||||
};
|
};
|
||||||
|
|
||||||
type Post = {
|
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。
|
||||||
@@ -245,16 +246,16 @@ Response:204 或 `{ 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/")` |
|
||||||
| `pdf` | 任一 attachment 扩展名 `pdf` 或 `mime === "application/pdf"` |
|
| `pdf` | 任一 attachment 扩展名 `pdf` 或 `mime === "application/pdf"` |
|
||||||
| `ppt` | 任一 attachment 扩展名 `ppt/pptx/key` 或 mime 含 `presentation` |
|
| `ppt` | 任一 attachment 扩展名 `ppt/pptx/key` 或 mime 含 `presentation` |
|
||||||
| `archive` | 任一 attachment 扩展名 `zip/rar/7z/tar/gz` |
|
| `archive` | 任一 attachment 扩展名 `zip/rar/7z/tar/gz` |
|
||||||
| `text` | `text` 非空 |
|
| `text` | `text` 非空 |
|
||||||
| `link` | `text` 中包含 `https?://` |
|
| `link` | `text` 中包含 `https?://` |
|
||||||
|
|
||||||
## 4. Wallet Auth API
|
## 4. Wallet Auth API
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -335,18 +344,18 @@ Response:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
type AdminDashboard = {
|
type AdminDashboard = {
|
||||||
totalResources: number; // 若迁移到 Post,可理解为 totalPosts
|
totalResources: number; // 若迁移到 Post,可理解为 totalPosts
|
||||||
published: number;
|
published: number;
|
||||||
todayNew: number;
|
todayNew: number;
|
||||||
totalViews: number;
|
totalViews: number;
|
||||||
totalDownloads: number;
|
totalDownloads: number;
|
||||||
totalFavorites: number; // 收藏下线后可返回 0,避免旧 admin UI 崩
|
totalFavorites: number; // 收藏下线后可返回 0,避免旧 admin UI 崩
|
||||||
totalShares: number;
|
totalShares: number;
|
||||||
hotResources: {
|
hotResources: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
downloads: number;
|
downloads: number;
|
||||||
favorites: number; // 可返回 0
|
favorites: number; // 可返回 0
|
||||||
views: number;
|
views: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -16,34 +16,34 @@ status: draft
|
|||||||
type AttachmentKind = "image" | "video" | "document";
|
type AttachmentKind = "image" | "video" | "document";
|
||||||
|
|
||||||
type Attachment = {
|
type Attachment = {
|
||||||
id: string; // 唯一 id
|
id: string; // 唯一 id
|
||||||
kind: AttachmentKind; // 三大类,前端按此分支渲染
|
kind: AttachmentKind; // 三大类,前端按此分支渲染
|
||||||
url: string; // 原始文件地址
|
url: string; // 原始文件地址
|
||||||
mime: string; // image/jpeg, application/pdf, video/mp4, ...
|
mime: string; // image/jpeg, application/pdf, video/mp4, ...
|
||||||
filename: string; // 显示用文件名,含扩展名
|
filename: string; // 显示用文件名,含扩展名
|
||||||
sizeBytes: number; // 字节数;前端格式化为 "3.5 MB"
|
sizeBytes: number; // 字节数;前端格式化为 "3.5 MB"
|
||||||
width?: number; // image/video 用于占位比例(CLS 优化)
|
width?: number; // image/video 用于占位比例(CLS 优化)
|
||||||
height?: number;
|
height?: number;
|
||||||
durationSec?: number; // video 专用
|
durationSec?: number; // video 专用
|
||||||
posterUrl?: string; // video 海报缩略图
|
posterUrl?: string; // video 海报缩略图
|
||||||
thumbnailUrl?: string; // image 缩略,列表用减少流量
|
thumbnailUrl?: string; // image 缩略,列表用减少流量
|
||||||
};
|
};
|
||||||
|
|
||||||
type Post = {
|
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~N;text-only post 时为 []
|
attachments: Attachment[]; // 0~N;text-only post 时为 []
|
||||||
isRecommended: boolean;
|
isRecommended: boolean;
|
||||||
publishedAt: string; // ISO 8601;用于排序 + 日期分组
|
publishedAt: string; // ISO 8601;用于排序 + 日期分组
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PostListResponse = {
|
type PostListResponse = {
|
||||||
items: Post[];
|
items: Post[];
|
||||||
nextCursor?: string; // 不透明 cursor;undefined = 没有下一页
|
nextCursor?: string; // 不透明 cursor;undefined = 没有下一页
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -67,14 +67,14 @@ 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 |
|
||||||
|
|
||||||
返回:`PostListResponse`
|
返回:`PostListResponse`
|
||||||
|
|
||||||
@@ -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。
|
||||||
- 支持发布/隐藏、置顶/官方推荐。
|
- 支持发布/隐藏、置顶/官方推荐。
|
||||||
@@ -127,28 +128,28 @@ 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/")` |
|
||||||
| `pdf` | 至少一个 `mime === "application/pdf"` 或扩展名为 `pdf` |
|
| `pdf` | 至少一个 `mime === "application/pdf"` 或扩展名为 `pdf` |
|
||||||
| `ppt` | 至少一个扩展名为 `ppt` / `pptx` / `key` 或 mime 含 `presentation` |
|
| `ppt` | 至少一个扩展名为 `ppt` / `pptx` / `key` 或 mime 含 `presentation` |
|
||||||
| `archive` | 至少一个扩展名为 `zip` / `rar` / `7z` / `tar` / `gz` |
|
| `archive` | 至少一个扩展名为 `zip` / `rar` / `7z` / `tar` / `gz` |
|
||||||
| `text` | `text` 非空 |
|
| `text` | `text` 非空 |
|
||||||
| `link` | `text` 非空且匹配 `https?://` |
|
| `link` | `text` 非空且匹配 `https?://` |
|
||||||
|
|
||||||
前端 mock 已按此规则过滤,便于切真接口时口径一致。
|
前端 mock 已按此规则过滤,便于切真接口时口径一致。
|
||||||
|
|
||||||
## 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` 重定向 |
|
||||||
| 老 `/api/resources*` 系列 | 后端可保留过渡期。建议提供数据迁移脚本:每个老 Resource → 一个 Post(带 1 个 attachment 或 text-only)。`isRecommended` / `language` / `categorySlug` 字段迁移;`favorite count` 字段丢弃。 |
|
| 老 `/api/resources*` 系列 | 后端可保留过渡期。建议提供数据迁移脚本:每个老 Resource → 一个 Post(带 1 个 attachment 或 text-only)。`isRecommended` / `language` / `categorySlug` 字段迁移;`favorite count` 字段丢弃。 |
|
||||||
| Resource.coverImage 与 Resource.fileUrl 二选一 | 转为 attachments[0](kind 由后端判断 image vs document)|
|
| Resource.coverImage 与 Resource.fileUrl 二选一 | 转为 attachments[0](kind 由后端判断 image vs document) |
|
||||||
|
|
||||||
## 5. Search
|
## 5. Search
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -51,14 +51,14 @@ 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"`. |
|
||||||
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
|
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
|
||||||
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
|
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
|
||||||
| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` when set to `true` (default while backend `/api/posts` is not yet shipped). Set to `"false"` to hit the real API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. |
|
| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` when set to `true` (default while backend `/api/posts` is not yet shipped). Set to `"false"` to hit the real API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. |
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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), []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
705
src/i18n.tsx
705
src/i18n.tsx
@@ -6,377 +6,340 @@ 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 数据库",
|
||||||
brand: "ARK 資料庫",
|
mainNav: "网站导航",
|
||||||
mainNav: "網站導覽",
|
home: "首页",
|
||||||
home: "首頁",
|
all: "全部资料",
|
||||||
all: "全部資料",
|
categories: "分类浏览",
|
||||||
categories: "分類瀏覽",
|
latest: "最新更新",
|
||||||
latest: "最新更新",
|
official: "官方推荐",
|
||||||
official: "官方推薦",
|
popular: "热门资料",
|
||||||
popular: "熱門資料",
|
search: "搜索",
|
||||||
search: "搜尋",
|
searchPlaceholder: "搜索资料...",
|
||||||
searchPlaceholder: "搜尋資料...",
|
searchNow: "立即搜索资料",
|
||||||
searchNow: "立即搜尋資料",
|
viewAll: "查看全部",
|
||||||
viewAll: "查看全部",
|
heroTitle: "ARK 官方数据库",
|
||||||
heroTitle: "ARK 官方資料庫",
|
heroSub:
|
||||||
heroSub:
|
"集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
|
||||||
"集中、分類、管理 ARK 資料庫,讓你快速找到所需資源,推動社群共識與成長。",
|
categorySection: "资料分类",
|
||||||
categorySection: "資料分類",
|
officialSection: "官方推荐",
|
||||||
officialSection: "官方推薦",
|
latestSection: "最新更新",
|
||||||
latestSection: "最新更新",
|
popularSection: "热门资料",
|
||||||
popularSection: "熱門資料",
|
preview: "预览",
|
||||||
preview: "預覽",
|
download: "下载",
|
||||||
download: "下載",
|
share: "分享",
|
||||||
favorite: "收藏",
|
profile: "个人中心",
|
||||||
share: "分享",
|
langLabel: "语言",
|
||||||
profile: "個人中心",
|
admin: "后台",
|
||||||
langLabel: "語言",
|
login: "登录",
|
||||||
admin: "後台",
|
logout: "退出",
|
||||||
login: "登入",
|
email: "邮箱",
|
||||||
logout: "登出",
|
password: "密码",
|
||||||
email: "電子郵件",
|
dashboard: "仪表盘",
|
||||||
password: "密碼",
|
resources: "资料管理",
|
||||||
dashboard: "儀表板",
|
newResource: "新增资料",
|
||||||
resources: "資料管理",
|
save: "保存",
|
||||||
newResource: "新增資料",
|
title: "标题",
|
||||||
save: "儲存",
|
description: "简介",
|
||||||
title: "標題",
|
type: "类型",
|
||||||
description: "簡介",
|
language: "语言",
|
||||||
type: "類型",
|
category: "分类",
|
||||||
language: "語言",
|
status: "状态",
|
||||||
category: "分類",
|
public: "公开",
|
||||||
status: "狀態",
|
downloadable: "可下载",
|
||||||
public: "公開",
|
recommended: "首页推荐",
|
||||||
downloadable: "可下載",
|
cover: "封面图 URL",
|
||||||
recommended: "首頁推薦",
|
fileUrl: "文件 URL",
|
||||||
cover: "封面圖 URL",
|
externalUrl: "外部链接",
|
||||||
fileUrl: "檔案 URL",
|
body: "文案内容",
|
||||||
externalUrl: "外部連結",
|
badge: "推荐标签",
|
||||||
body: "文案內容",
|
published: "已发布",
|
||||||
badge: "推薦標籤",
|
draft: "草稿",
|
||||||
published: "已發布",
|
archived: "归档",
|
||||||
draft: "草稿",
|
noResults: "找不到符合的资料,请换个关键字或浏览分类。",
|
||||||
archived: "封存",
|
copyLink: "复制链接",
|
||||||
noResults: "找不到符合的資料,請換個關鍵字或瀏覽分類。",
|
related: "相关资料",
|
||||||
copyLink: "複製連結",
|
total: "总资料",
|
||||||
related: "相關資料",
|
views: "浏览",
|
||||||
total: "總資料",
|
downloads: "下载",
|
||||||
views: "瀏覽",
|
wallet: "钱包",
|
||||||
downloads: "下載",
|
walletPageTitle: "钱包登录",
|
||||||
wallet: "錢包",
|
walletPageIntro:
|
||||||
walletPageTitle: "錢包登入",
|
"连接 Web3 钱包以使用会员相关功能。采用标准签名登录,不发送交易、不消耗 gas。",
|
||||||
walletPageIntro:
|
walletStepExtension:
|
||||||
"連接 Web3 錢包以使用會員相關功能。採用標準簽名登入,不會發送交易、不消耗 gas。",
|
"电脑已安装浏览器扩展钱包(如 MetaMask)时,可直接连接。",
|
||||||
walletStepExtension:
|
walletStepQR:
|
||||||
"電腦已安裝擴充錢包(如 MetaMask)時,可直接在瀏覽器連線。",
|
"电脑未安装钱包时:在连接窗口选择 WalletConnect,用手机钱包扫描 QR Code。",
|
||||||
walletStepQR:
|
walletStepSign: "连接成功后,点击「签署登录」并在钱包内签名即可完成验证。",
|
||||||
"電腦未安裝錢包時:在連線視窗選擇 WalletConnect,用手機錢包掃描畫面上的 QR Code 即可連線。",
|
signInWallet: "签署登录",
|
||||||
walletStepSign:
|
walletSignedIn: "已验证登录",
|
||||||
"連線成功後,點「簽署登入」並在錢包內簽署訊息,即完成網站身分驗證。",
|
walletLogout: "退出钱包",
|
||||||
signInWallet: "簽署登入",
|
walletMissingProjectId:
|
||||||
walletSignedIn: "已驗證登入",
|
"请配置 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud),否则无法使用 WalletConnect/扫码。",
|
||||||
walletLogout: "登出錢包",
|
walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)",
|
||||||
walletMissingProjectId:
|
lang_zh: "中文",
|
||||||
"請設定 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud 免費申請),否則無法使用 WalletConnect/手機掃碼。",
|
lang_en: "English",
|
||||||
walletSetupNeeded: "錢包掃碼未啟用(請於伺服器設定環境變數)",
|
lang_ja: "日本語",
|
||||||
lang_zh_TW: "繁體中文",
|
lang_ko: "한국어",
|
||||||
lang_zh_CN: "简体中文",
|
lang_vi: "Tiếng Việt",
|
||||||
|
lang_id: "Bahasa Indonesia",
|
||||||
|
lang_ms: "Bahasa Melayu",
|
||||||
|
filterAll: "全部",
|
||||||
|
sortPublished: "发布时间",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "音乐",
|
||||||
|
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: "编号",
|
||||||
|
};
|
||||||
|
|
||||||
|
const enDict: Dict = {
|
||||||
|
brand: "ARK Library",
|
||||||
|
mainNav: "Site menu",
|
||||||
|
home: "Home",
|
||||||
|
all: "All assets",
|
||||||
|
categories: "Categories",
|
||||||
|
latest: "Latest",
|
||||||
|
official: "Official picks",
|
||||||
|
popular: "Popular",
|
||||||
|
search: "Search",
|
||||||
|
searchPlaceholder: "Search resources...",
|
||||||
|
searchNow: "Search now",
|
||||||
|
viewAll: "View all",
|
||||||
|
heroTitle: "ARK Official Library",
|
||||||
|
heroSub:
|
||||||
|
"Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.",
|
||||||
|
categorySection: "Categories",
|
||||||
|
officialSection: "Official recommendations",
|
||||||
|
latestSection: "Latest updates",
|
||||||
|
popularSection: "Popular assets",
|
||||||
|
preview: "Preview",
|
||||||
|
download: "Download",
|
||||||
|
share: "Share",
|
||||||
|
profile: "Profile",
|
||||||
|
langLabel: "Language",
|
||||||
|
admin: "Admin",
|
||||||
|
login: "Sign in",
|
||||||
|
logout: "Sign out",
|
||||||
|
email: "Email",
|
||||||
|
password: "Password",
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
resources: "Resources",
|
||||||
|
newResource: "New resource",
|
||||||
|
save: "Save",
|
||||||
|
title: "Title",
|
||||||
|
description: "Description",
|
||||||
|
type: "Type",
|
||||||
|
language: "Language",
|
||||||
|
category: "Category",
|
||||||
|
status: "Status",
|
||||||
|
public: "Public",
|
||||||
|
downloadable: "Downloadable",
|
||||||
|
recommended: "Featured",
|
||||||
|
cover: "Cover image URL",
|
||||||
|
fileUrl: "File URL",
|
||||||
|
externalUrl: "External URL",
|
||||||
|
body: "Text body",
|
||||||
|
badge: "Badge label",
|
||||||
|
published: "Published",
|
||||||
|
draft: "Draft",
|
||||||
|
archived: "Archived",
|
||||||
|
noResults: "No results. Try another keyword or browse categories.",
|
||||||
|
copyLink: "Copy link",
|
||||||
|
related: "Related",
|
||||||
|
total: "Total items",
|
||||||
|
views: "Views",
|
||||||
|
downloads: "Downloads",
|
||||||
|
wallet: "Wallet",
|
||||||
|
walletPageTitle: "Wallet sign-in",
|
||||||
|
walletPageIntro:
|
||||||
|
"Connect a Web3 wallet for member features. This uses a standard signed message — no transaction and no gas.",
|
||||||
|
walletStepExtension:
|
||||||
|
"On desktop with a browser extension (e.g. MetaMask), connect directly.",
|
||||||
|
walletStepQR:
|
||||||
|
"On desktop without an extension: choose WalletConnect in the modal and scan the QR code with your mobile wallet.",
|
||||||
|
walletStepSign:
|
||||||
|
'After connecting, tap "Sign in" and approve the message in your wallet to verify.',
|
||||||
|
signInWallet: "Sign in",
|
||||||
|
walletSignedIn: "Signed in",
|
||||||
|
walletLogout: "Disconnect",
|
||||||
|
walletMissingProjectId:
|
||||||
|
"Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.",
|
||||||
|
walletSetupNeeded: "Wallet QR login disabled (set env on server)",
|
||||||
|
lang_zh: "Chinese",
|
||||||
|
lang_en: "English",
|
||||||
|
lang_ja: "Japanese",
|
||||||
|
lang_ko: "Korean",
|
||||||
|
lang_vi: "Vietnamese",
|
||||||
|
lang_id: "Indonesian",
|
||||||
|
lang_ms: "Malay",
|
||||||
|
filterAll: "All types",
|
||||||
|
sortPublished: "Published date",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "Music",
|
||||||
|
type_video: "Video",
|
||||||
|
type_image: "Image",
|
||||||
|
type_pdf: "PDF",
|
||||||
|
type_link: "Link",
|
||||||
|
type_text: "Text",
|
||||||
|
type_archive: "Archive",
|
||||||
|
type_zip: "ZIP",
|
||||||
|
adminLoginTitle: "Admin sign in",
|
||||||
|
adminEditResource: "Edit resource",
|
||||||
|
adminVideoFileHint:
|
||||||
|
"Upload a video file (MP4/WebM/MOV, etc.) and set type to Video; the site will autoplay (muted by default — user can unmute).",
|
||||||
|
adminStatTodayNew: "New today",
|
||||||
|
adminStatFavorites: "Favorites",
|
||||||
|
adminMetricDownloads: "Downloads",
|
||||||
|
adminMetricFavorites: "Favorites",
|
||||||
|
adminMetricViews: "Views",
|
||||||
|
edit: "Edit",
|
||||||
|
backToList: "Back to list",
|
||||||
|
sortOrderLabel: "Sort order",
|
||||||
|
previewUrlLabel: "Preview URL",
|
||||||
|
tagsCommaLabel: "Tags (comma-separated)",
|
||||||
|
uploadFile: "Upload",
|
||||||
|
loading: "Loading…",
|
||||||
|
paginationPrev: "Previous",
|
||||||
|
paginationNext: "Next",
|
||||||
|
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
||||||
|
pageIndicator: "Page {{c}} / {{p}}",
|
||||||
|
resourceLangFilter: "Resource language",
|
||||||
|
filterTagClear: "Clear tag",
|
||||||
|
filterLanguageAll: "All languages",
|
||||||
|
aboutTitle: "About this site",
|
||||||
|
aboutIntro:
|
||||||
|
"The ARK library brings together official decks, announcements, videos, and common files so the community can find consistent, trustworthy versions quickly.\n\nThis site is for discovery and indexing only; rights remain with official notices.",
|
||||||
|
footerAbout: "About",
|
||||||
|
footerAdminLogin: "Admin sign-in",
|
||||||
|
adminSearchLogs: "Search logs",
|
||||||
|
adminMetricShares: "Shares",
|
||||||
|
adminSearchQuery: "Query",
|
||||||
|
adminSearchTime: "Time",
|
||||||
|
adminSearchId: "ID",
|
||||||
|
};
|
||||||
|
|
||||||
|
const languageNames: Record<Lang, Dict> = {
|
||||||
|
zh: {
|
||||||
|
lang_zh: "中文",
|
||||||
lang_en: "English",
|
lang_en: "English",
|
||||||
filterAll: "全部",
|
lang_ja: "日本語",
|
||||||
sortPublished: "發布時間",
|
lang_ko: "한국어",
|
||||||
type_ppt: "PPT",
|
lang_vi: "Tiếng Việt",
|
||||||
type_video: "影片",
|
lang_id: "Bahasa Indonesia",
|
||||||
type_image: "圖片",
|
lang_ms: "Bahasa Melayu",
|
||||||
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 数据库",
|
|
||||||
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_ID(Reown 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: "编号",
|
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
brand: "ARK Library",
|
lang_zh: "Chinese",
|
||||||
mainNav: "Site menu",
|
|
||||||
home: "Home",
|
|
||||||
all: "All assets",
|
|
||||||
categories: "Categories",
|
|
||||||
latest: "Latest",
|
|
||||||
official: "Official picks",
|
|
||||||
popular: "Popular",
|
|
||||||
search: "Search",
|
|
||||||
searchPlaceholder: "Search resources...",
|
|
||||||
searchNow: "Search now",
|
|
||||||
viewAll: "View all",
|
|
||||||
heroTitle: "ARK Official Library",
|
|
||||||
heroSub:
|
|
||||||
"Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.",
|
|
||||||
categorySection: "Categories",
|
|
||||||
officialSection: "Official recommendations",
|
|
||||||
latestSection: "Latest updates",
|
|
||||||
popularSection: "Popular assets",
|
|
||||||
preview: "Preview",
|
|
||||||
download: "Download",
|
|
||||||
share: "Share",
|
|
||||||
profile: "Profile",
|
|
||||||
langLabel: "Language",
|
|
||||||
admin: "Admin",
|
|
||||||
login: "Sign in",
|
|
||||||
logout: "Sign out",
|
|
||||||
email: "Email",
|
|
||||||
password: "Password",
|
|
||||||
dashboard: "Dashboard",
|
|
||||||
resources: "Resources",
|
|
||||||
newResource: "New resource",
|
|
||||||
save: "Save",
|
|
||||||
title: "Title",
|
|
||||||
description: "Description",
|
|
||||||
type: "Type",
|
|
||||||
language: "Language",
|
|
||||||
category: "Category",
|
|
||||||
status: "Status",
|
|
||||||
public: "Public",
|
|
||||||
downloadable: "Downloadable",
|
|
||||||
recommended: "Featured",
|
|
||||||
cover: "Cover image URL",
|
|
||||||
fileUrl: "File URL",
|
|
||||||
externalUrl: "External URL",
|
|
||||||
body: "Text body",
|
|
||||||
badge: "Badge label",
|
|
||||||
published: "Published",
|
|
||||||
draft: "Draft",
|
|
||||||
archived: "Archived",
|
|
||||||
noResults: "No results. Try another keyword or browse categories.",
|
|
||||||
copyLink: "Copy link",
|
|
||||||
related: "Related",
|
|
||||||
total: "Total items",
|
|
||||||
views: "Views",
|
|
||||||
downloads: "Downloads",
|
|
||||||
wallet: "Wallet",
|
|
||||||
walletPageTitle: "Wallet sign-in",
|
|
||||||
walletPageIntro:
|
|
||||||
"Connect a Web3 wallet for member features. This uses a standard signed message — no transaction and no gas.",
|
|
||||||
walletStepExtension:
|
|
||||||
"On desktop with a browser extension (e.g. MetaMask), connect directly.",
|
|
||||||
walletStepQR:
|
|
||||||
"On desktop without an extension: choose WalletConnect in the modal and scan the QR code with your mobile wallet.",
|
|
||||||
walletStepSign:
|
|
||||||
'After connecting, tap "Sign in" and approve the message in your wallet to verify.',
|
|
||||||
signInWallet: "Sign in",
|
|
||||||
walletSignedIn: "Signed in",
|
|
||||||
walletLogout: "Disconnect",
|
|
||||||
walletMissingProjectId:
|
|
||||||
"Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.",
|
|
||||||
walletSetupNeeded: "Wallet QR login disabled (set env on server)",
|
|
||||||
lang_zh_TW: "Traditional Chinese",
|
|
||||||
lang_zh_CN: "Simplified Chinese",
|
|
||||||
lang_en: "English",
|
lang_en: "English",
|
||||||
filterAll: "All types",
|
lang_ja: "Japanese",
|
||||||
sortPublished: "Published date",
|
lang_ko: "Korean",
|
||||||
type_ppt: "PPT",
|
lang_vi: "Vietnamese",
|
||||||
type_video: "Video",
|
lang_id: "Indonesian",
|
||||||
type_image: "Image",
|
lang_ms: "Malay",
|
||||||
type_pdf: "PDF",
|
},
|
||||||
type_link: "Link",
|
ja: {
|
||||||
type_text: "Text",
|
lang_zh: "中国語",
|
||||||
type_archive: "Archive",
|
lang_en: "英語",
|
||||||
type_zip: "ZIP",
|
lang_ja: "日本語",
|
||||||
adminLoginTitle: "Admin sign in",
|
lang_ko: "韓国語",
|
||||||
adminEditResource: "Edit resource",
|
lang_vi: "ベトナム語",
|
||||||
adminVideoFileHint:
|
lang_id: "インドネシア語",
|
||||||
"Upload a video file (MP4/WebM/MOV, etc.) and set type to Video; the site will autoplay (muted by default — user can unmute).",
|
lang_ms: "マレー語",
|
||||||
adminStatTodayNew: "New today",
|
},
|
||||||
adminStatFavorites: "Favorites",
|
ko: {
|
||||||
adminMetricDownloads: "Downloads",
|
lang_zh: "중국어",
|
||||||
adminMetricFavorites: "Favorites",
|
lang_en: "영어",
|
||||||
adminMetricViews: "Views",
|
lang_ja: "일본어",
|
||||||
edit: "Edit",
|
lang_ko: "한국어",
|
||||||
backToList: "Back to list",
|
lang_vi: "베트남어",
|
||||||
sortOrderLabel: "Sort order",
|
lang_id: "인도네시아어",
|
||||||
previewUrlLabel: "Preview URL",
|
lang_ms: "말레이어",
|
||||||
tagsCommaLabel: "Tags (comma-separated)",
|
},
|
||||||
uploadFile: "Upload",
|
vi: {
|
||||||
loading: "Loading…",
|
lang_zh: "Tiếng Trung",
|
||||||
paginationPrev: "Previous",
|
lang_en: "Tiếng Anh",
|
||||||
paginationNext: "Next",
|
lang_ja: "Tiếng Nhật",
|
||||||
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
lang_ko: "Tiếng Hàn",
|
||||||
pageIndicator: "Page {{c}} / {{p}}",
|
lang_vi: "Tiếng Việt",
|
||||||
resourceLangFilter: "Resource language",
|
lang_id: "Tiếng Indonesia",
|
||||||
filterTagClear: "Clear tag",
|
lang_ms: "Tiếng Mã Lai",
|
||||||
filterLanguageAll: "All languages",
|
},
|
||||||
aboutTitle: "About this site",
|
id: {
|
||||||
aboutIntro:
|
lang_zh: "Bahasa Tionghoa",
|
||||||
"The ARK library brings together official decks, announcements, videos, and common files so the community can find consistent, trustworthy versions quickly.\n\nThis site is for discovery and indexing only; rights remain with official notices.",
|
lang_en: "Bahasa Inggris",
|
||||||
footerAbout: "About",
|
lang_ja: "Bahasa Jepang",
|
||||||
footerAdminLogin: "Admin sign-in",
|
lang_ko: "Bahasa Korea",
|
||||||
adminSearchLogs: "Search logs",
|
lang_vi: "Bahasa Vietnam",
|
||||||
adminMetricShares: "Shares",
|
lang_id: "Bahasa Indonesia",
|
||||||
adminSearchQuery: "Query",
|
lang_ms: "Bahasa Melayu",
|
||||||
adminSearchTime: "Time",
|
},
|
||||||
adminSearchId: "ID",
|
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
17
src/i18nLanguages.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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"))}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
const label = t(key);
|
||||||
? "lang_zh_CN"
|
return label !== key ? label : langCode.trim();
|
||||||
: lc === "en"
|
|
||||||
? "lang_en"
|
|
||||||
: "";
|
|
||||||
if (key) {
|
|
||||||
const label = t(key);
|
|
||||||
if (label !== key) return label;
|
|
||||||
}
|
|
||||||
return langCode.trim();
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user