feat(locale): add content translation operations
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user