feat(locale): add content translation operations
CI / Typecheck, test & build (push) Successful in 54s
CI / Deploy to Cloudflare Workers (push) Successful in 59s

This commit is contained in:
2026-09-21 05:52:56 +08:00
parent a2c1394946
commit 0fdb8a4b2e
7 changed files with 247 additions and 12 deletions
+14
View File
@@ -0,0 +1,14 @@
import { api } from '@/api/client'
import type { ApiResponse, ContentTranslation, ListParams, PaginatedResponse } from '@/api/types'
const BASE = '/api/admin/v1/content-translations'
export function fetchContentTranslations(params: ListParams) {
return api.get<PaginatedResponse<ContentTranslation>>(BASE, params)
}
export function retryContentTranslation(translation: ContentTranslation) {
return api.post<ApiResponse<ContentTranslation>>(
`${BASE}/${translation.content_type}/${translation.id}/retry`,
)
}
+120 -12
View File
@@ -759,6 +759,43 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/v1/content-translations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiAdminV1ContentTranslations"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/v1/content-translations/{contentType}/{localization}/retry": {
parameters: {
query?: never;
header?: never;
path: {
/** @example architecto */
contentType: string;
/** @example architecto */
localization: string;
};
cookie?: never;
};
get?: never;
put?: never;
post: operations["postApiAdminV1ContentTranslationsContentTypeLocalizationRetry"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/v1/studio/category-briefs": {
parameters: {
query?: never;
@@ -4357,7 +4394,7 @@ export interface paths {
/** @example architecto */
status?: string | null;
/**
* @example scheduled_booking
* @example quote
* @enum {string|null}
*/
dispatch_mode?: "instant_accept" | "quote" | "scheduled_booking" | "manual_selection" | null;
@@ -4677,7 +4714,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example expired
* @example taken_by_other
* @enum {string|null}
*/
status?: "sent" | "delivered" | "viewed" | "accepted" | "rejected" | "ignored" | "expired" | "taken_by_other" | null;
@@ -4871,12 +4908,12 @@ export interface paths {
content: {
"application/json": {
/**
* @example fulfilled
* @example canceled
* @enum {string|null}
*/
status?: "created" | "confirmed" | "started" | "consumed" | "paid" | "fulfilled" | "canceled" | null;
/**
* @example UNPAID
* @example PAID
* @enum {string|null}
*/
payment_status?: "UNPAID" | "PAID" | null;
@@ -5161,7 +5198,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example online
* @example busy
* @enum {string|null}
*/
status?: "offline" | "online" | "inactive" | "busy" | null;
@@ -5452,12 +5489,12 @@ export interface paths {
content: {
"application/json": {
/**
* @example public
* @example private
* @enum {string|null}
*/
visibility?: "public" | "friends" | "private" | null;
/**
* @example all
* @example visible
* @enum {string|null}
*/
status?: "blocked" | "visible" | "all" | null;
@@ -5786,7 +5823,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example pending
* @example blocked
* @enum {string|null}
*/
status?: "pending" | "blocked" | "all" | null;
@@ -6383,7 +6420,7 @@ export interface paths {
type?: string | null;
/** @example true */
verified?: boolean | null;
/** @example false */
/** @example true */
is_global?: boolean | null;
/**
* @description Must be at least 1.
@@ -7880,7 +7917,7 @@ export interface paths {
* @example b
*/
q?: string | null;
/** @example false */
/** @example true */
status?: boolean | null;
/** @example false */
is_robot?: boolean | null;
@@ -9960,7 +9997,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example all
* @example blocked
* @enum {string|null}
*/
status?: "pending" | "blocked" | "all" | null;
@@ -10395,7 +10432,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example android
* @example desktop
* @enum {string|null}
*/
platform?: "ios" | "android" | "web" | "desktop" | null;
@@ -10540,6 +10577,77 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
getApiAdminV1ContentTranslations: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/**
* @example filex
* @enum {string|null}
*/
content_type?: "filex" | "comment" | null;
/**
* @example stale
* @enum {string|null}
*/
status?: "pending" | "ready" | "failed" | "stale" | "skipped" | null;
/**
* @description Must not be greater than 35 characters.
* @example sr_BA
*/
target_locale?: string | null;
/**
* @description Must be at least 1.
* @example 22
*/
page?: number | null;
/**
* @description Must be at least 1. Must not be greater than 100.
* @example 7
*/
page_size?: number | null;
};
};
};
responses: {
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example false */
success?: boolean;
/** @example Authentication required */
message?: string;
/** @example UNAUTHENTICATED */
code?: string;
};
};
};
};
};
postApiAdminV1ContentTranslationsContentTypeLocalizationRetry: {
parameters: {
query?: never;
header?: never;
path: {
/** @example architecto */
contentType: string;
/** @example architecto */
localization: string;
};
cookie?: never;
};
requestBody?: never;
responses: never;
};
Dimzou: {
parameters: {
query?: never;
+20
View File
@@ -937,3 +937,23 @@ export interface ResumeImportItem {
metrics?: Record<string, number> | null
result?: Record<string, unknown> | null
}
export interface ContentTranslation {
kind: 'content_translation'
id: number
content_type: 'filex' | 'comment'
entity_id: number
source_locale: string | null
target_locale: string
source_hash: string
status: 'pending' | 'ready' | 'failed' | 'stale' | 'skipped'
translation_type: 'machine' | 'human' | null
provider: string | null
model: string | null
attempts: number
last_error: string | null
next_retry_at: string | null
completed_at: string | null
created_at: string
updated_at: string
}
+2
View File
@@ -26,6 +26,8 @@ export const PERM = {
LOCALE_CREATE: 'admin:locale:create',
LOCALE_UPDATE: 'admin:locale:update',
LOCALE_DELETE: 'admin:locale:delete',
TRANSLATION_READ: 'admin:translation:read',
TRANSLATION_RETRY: 'admin:translation:retry',
SID_MANAGE: 'admin:sid:manage',
SID_OVERRIDE: 'admin:sid:override',
NOTIFICATION_READ: 'admin:notification:read',
+1
View File
@@ -116,6 +116,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ label: '用户角色', to: '/admin-users', icon: Users, permission: 'auth:role:assign' },
{ label: 'SID 管理', to: '/sid', icon: Hash, permission: 'admin:sid:manage' },
{ label: '语言设置', to: '/locales', icon: Languages, permission: 'admin:locale:read' },
{ label: '内容翻译', to: '/content-translations', icon: Languages, permission: 'admin:translation:read' },
{ label: '推送设备', to: '/devices', icon: Smartphone, permission: 'admin:notification:read' },
{ label: '推送应用', to: '/push-apps', icon: Send, permission: 'admin:notification:read' },
{ label: '官方账号', to: '/official-accounts', icon: MessagesSquare, permission: 'admin:party:read' },
+22
View File
@@ -26,6 +26,7 @@ import { Route as AuthedEventReportsRouteImport } from './routes/_authed/event-r
import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations'
import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
import { Route as AuthedDbStudioRouteImport } from './routes/_authed/db-studio'
import { Route as AuthedContentTranslationsRouteImport } from './routes/_authed/content-translations'
import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports'
import { Route as AuthedAdminUsersRouteImport } from './routes/_authed/admin-users'
import { Route as AuthedUsersIndexRouteImport } from './routes/_authed/users/index'
@@ -131,6 +132,12 @@ const AuthedDbStudioRoute = AuthedDbStudioRouteImport.update({
path: '/db-studio',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedContentTranslationsRoute =
AuthedContentTranslationsRouteImport.update({
id: '/content-translations',
path: '/content-translations',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCommentReportsRoute = AuthedCommentReportsRouteImport.update({
id: '/comment-reports',
path: '/comment-reports',
@@ -238,6 +245,7 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/admin-users': typeof AuthedAdminUsersRoute
'/comment-reports': typeof AuthedCommentReportsRoute
'/content-translations': typeof AuthedContentTranslationsRoute
'/db-studio': typeof AuthedDbStudioRoute
'/devices': typeof AuthedDevicesRoute
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
@@ -274,6 +282,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/admin-users': typeof AuthedAdminUsersRoute
'/comment-reports': typeof AuthedCommentReportsRoute
'/content-translations': typeof AuthedContentTranslationsRoute
'/db-studio': typeof AuthedDbStudioRoute
'/devices': typeof AuthedDevicesRoute
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
@@ -313,6 +322,7 @@ export interface FileRoutesById {
'/login': typeof LoginRoute
'/_authed/admin-users': typeof AuthedAdminUsersRoute
'/_authed/comment-reports': typeof AuthedCommentReportsRoute
'/_authed/content-translations': typeof AuthedContentTranslationsRoute
'/_authed/db-studio': typeof AuthedDbStudioRoute
'/_authed/devices': typeof AuthedDevicesRoute
'/_authed/dimzou-translations': typeof AuthedDimzouTranslationsRoute
@@ -353,6 +363,7 @@ export interface FileRouteTypes {
| '/login'
| '/admin-users'
| '/comment-reports'
| '/content-translations'
| '/db-studio'
| '/devices'
| '/dimzou-translations'
@@ -389,6 +400,7 @@ export interface FileRouteTypes {
| '/login'
| '/admin-users'
| '/comment-reports'
| '/content-translations'
| '/db-studio'
| '/devices'
| '/dimzou-translations'
@@ -427,6 +439,7 @@ export interface FileRouteTypes {
| '/login'
| '/_authed/admin-users'
| '/_authed/comment-reports'
| '/_authed/content-translations'
| '/_authed/db-studio'
| '/_authed/devices'
| '/_authed/dimzou-translations'
@@ -587,6 +600,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedDbStudioRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/content-translations': {
id: '/_authed/content-translations'
path: '/content-translations'
fullPath: '/content-translations'
preLoaderRoute: typeof AuthedContentTranslationsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/comment-reports': {
id: '/_authed/comment-reports'
path: '/comment-reports'
@@ -726,6 +746,7 @@ declare module '@tanstack/react-router' {
interface AuthedRouteChildren {
AuthedAdminUsersRoute: typeof AuthedAdminUsersRoute
AuthedCommentReportsRoute: typeof AuthedCommentReportsRoute
AuthedContentTranslationsRoute: typeof AuthedContentTranslationsRoute
AuthedDbStudioRoute: typeof AuthedDbStudioRoute
AuthedDevicesRoute: typeof AuthedDevicesRoute
AuthedDimzouTranslationsRoute: typeof AuthedDimzouTranslationsRoute
@@ -762,6 +783,7 @@ interface AuthedRouteChildren {
const AuthedRouteChildren: AuthedRouteChildren = {
AuthedAdminUsersRoute: AuthedAdminUsersRoute,
AuthedCommentReportsRoute: AuthedCommentReportsRoute,
AuthedContentTranslationsRoute: AuthedContentTranslationsRoute,
AuthedDbStudioRoute: AuthedDbStudioRoute,
AuthedDevicesRoute: AuthedDevicesRoute,
AuthedDimzouTranslationsRoute: AuthedDimzouTranslationsRoute,
@@ -0,0 +1,68 @@
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { createFileRoute } from '@tanstack/react-router'
import { toast } from 'sonner'
import { fetchContentTranslations, retryContentTranslation } from '@/api/modules/content-translations'
import type { ContentTranslation } from '@/api/types'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page'
export const Route = createFileRoute('/_authed/content-translations')({
beforeLoad: () => requirePermission(PERM.TRANSLATION_READ),
component: ContentTranslationsPage,
})
const statusLabels: Record<ContentTranslation['status'], string> = {
pending: '等待中', ready: '已完成', failed: '失败', stale: '源内容已变更', skipped: '已跳过',
}
function ContentTranslationsPage() {
const canRetry = useCan(PERM.TRANSLATION_RETRY)
const [contentType, setContentType] = useState('all')
const [status, setStatus] = useState('all')
const [localeInput, setLocaleInput] = useState('')
const [targetLocale, setTargetLocale] = useState('')
const filters: Record<string, string> = {}
if (contentType !== 'all') filters.content_type = contentType
if (status !== 'all') filters.status = status
if (targetLocale) filters.target_locale = targetLocale
const list = useListPage('content-translations', fetchContentTranslations, filters)
const retry = async (translation: ContentTranslation) => {
try {
await retryContentTranslation(translation)
toast.success('已使用普通优先级重新入队')
void list.query.refetch()
} catch (error) {
handleApiError(error)
}
}
const columns: ColumnDef<ContentTranslation>[] = [
{ header: '内容', cell: ({ row }) => <div><Badge variant="outline">{row.original.content_type === 'filex' ? 'FileX' : '评论'}</Badge><code className="ml-2 text-xs text-muted-foreground">#{row.original.entity_id}</code></div> },
{ header: '语言', cell: ({ row }) => <span>{row.original.source_locale ?? '自动检测'} → <Badge variant="secondary">{row.original.target_locale}</Badge></span> },
{ header: '状态', cell: ({ row }) => <div><div>{statusLabels[row.original.status]}</div>{row.original.last_error && <div className="max-w-72 truncate text-xs text-destructive" title={row.original.last_error}>{row.original.last_error}</div>}</div> },
{ header: '提供方', cell: ({ row }) => row.original.provider ? <div><div>{row.original.provider}</div><div className="text-xs text-muted-foreground">{row.original.model}</div></div> : '—' },
{ header: '尝试', accessorKey: 'attempts' },
{ header: '更新时间', cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN') },
{ header: '操作', cell: ({ row }) => canRetry && row.original.status !== 'pending' ? <ConfirmDialog trigger={<Button size="sm" variant="outline">重新入队</Button>} title="重新执行此翻译?" description="任务会进入对应语言组和内容类型的普通优先级队列。" onConfirm={() => retry(row.original)} /> : <span className="text-xs text-muted-foreground">—</span> },
]
return <div>
<PageHeader title="内容翻译" description="FileX 和评论的按需机器翻译状态与重试" actions={<div className="flex items-center gap-2">
<Input value={localeInput} onChange={(event) => setLocaleInput(event.target.value)} onBlur={() => setTargetLocale(localeInput.trim())} onKeyDown={(event) => { if (event.key === 'Enter') setTargetLocale(localeInput.trim()) }} placeholder="目标语言" className="h-8 w-28" />
<Select value={contentType} onValueChange={setContentType}><SelectTrigger size="sm" className="w-28"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部内容</SelectItem><SelectItem value="filex">FileX</SelectItem><SelectItem value="comment">评论</SelectItem></SelectContent></Select>
<Select value={status} onValueChange={setStatus}><SelectTrigger size="sm" className="w-28"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部状态</SelectItem>{Object.entries(statusLabels).map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select>
</div>} />
<DataTable columns={columns} data={list.rows} pagination={list.pagination} loading={list.loading} emptyText="没有内容翻译任务" onPageChange={list.setPage} onPageSizeChange={list.setPageSize} />
</div>
}