diff --git a/src/api/modules/content-translations.ts b/src/api/modules/content-translations.ts new file mode 100644 index 0000000..e1d9fe5 --- /dev/null +++ b/src/api/modules/content-translations.ts @@ -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>(BASE, params) +} + +export function retryContentTranslation(translation: ContentTranslation) { + return api.post>( + `${BASE}/${translation.content_type}/${translation.id}/retry`, + ) +} diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 348e310..475eaec 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -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; 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; diff --git a/src/api/types.ts b/src/api/types.ts index b0523ce..46940bf 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -937,3 +937,23 @@ export interface ResumeImportItem { metrics?: Record | null result?: Record | 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 +} diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index c9e0a20..769dec9 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -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', diff --git a/src/components/layout/nav.ts b/src/components/layout/nav.ts index ca3c7fc..ceb0dfb 100644 --- a/src/components/layout/nav.ts +++ b/src/components/layout/nav.ts @@ -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' }, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index e1435aa..de981ec 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/_authed/content-translations.tsx b/src/routes/_authed/content-translations.tsx new file mode 100644 index 0000000..c959dbe --- /dev/null +++ b/src/routes/_authed/content-translations.tsx @@ -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 = { + 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 = {} + 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[] = [ + { header: '内容', cell: ({ row }) =>
{row.original.content_type === 'filex' ? 'FileX' : '评论'}#{row.original.entity_id}
}, + { header: '语言', cell: ({ row }) => {row.original.source_locale ?? '自动检测'} → {row.original.target_locale} }, + { header: '状态', cell: ({ row }) =>
{statusLabels[row.original.status]}
{row.original.last_error &&
{row.original.last_error}
}
}, + { header: '提供方', cell: ({ row }) => row.original.provider ?
{row.original.provider}
{row.original.model}
: '—' }, + { header: '尝试', accessorKey: 'attempts' }, + { header: '更新时间', cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN') }, + { header: '操作', cell: ({ row }) => canRetry && row.original.status !== 'pending' ? 重新入队} title="重新执行此翻译?" description="任务会进入对应语言组和内容类型的普通优先级队列。" onConfirm={() => retry(row.original)} /> : — }, + ] + + return
+ + setLocaleInput(event.target.value)} onBlur={() => setTargetLocale(localeInput.trim())} onKeyDown={(event) => { if (event.key === 'Enter') setTargetLocale(localeInput.trim()) }} placeholder="目标语言" className="h-8 w-28" /> + + +
} /> + + +}