diff --git a/src/api/modules/locales.ts b/src/api/modules/locales.ts index 5095ee5..627347c 100644 --- a/src/api/modules/locales.ts +++ b/src/api/modules/locales.ts @@ -4,18 +4,21 @@ import type { ApiResponse, LocaleRow } from '@/api/types' const BASE = '/api/admin/v1/locales' export interface LocalePayload { - code?: string name: string native_name: string is_active?: boolean sort_order?: number } +export interface CreateLocalePayload extends LocalePayload { + code: string +} + export function fetchLocales() { return api.get>(BASE) } -export function createLocale(payload: LocalePayload) { +export function createLocale(payload: CreateLocalePayload) { return api.post>(BASE, payload) } diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 9074af5..67f7fac 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -50,7 +50,7 @@ export interface paths { content: { "application/json": { /** - * @example all + * @example trashed * @enum {string|null} */ state?: "active" | "archived" | "trashed" | "all" | null; @@ -539,7 +539,7 @@ export interface paths { */ language_code?: string; /** - * @example awaiting_publish + * @example published * @enum {string} */ status?: "draft" | "awaiting_publish" | "published"; @@ -4677,7 +4677,7 @@ export interface paths { content: { "application/json": { /** - * @example viewed + * @example sent * @enum {string|null} */ status?: "sent" | "delivered" | "viewed" | "accepted" | "rejected" | "ignored" | "expired" | "taken_by_other" | null; @@ -5452,12 +5452,12 @@ export interface paths { content: { "application/json": { /** - * @example private + * @example friends * @enum {string|null} */ visibility?: "public" | "friends" | "private" | null; /** - * @example all + * @example blocked * @enum {string|null} */ status?: "blocked" | "visible" | "all" | null; @@ -5786,7 +5786,7 @@ export interface paths { content: { "application/json": { /** - * @example all + * @example pending * @enum {string|null} */ status?: "pending" | "blocked" | "all" | null; @@ -7673,7 +7673,7 @@ export interface paths { q?: string | null; /** @example true */ status?: boolean | null; - /** @example false */ + /** @example true */ is_robot?: boolean | null; /** * @description Must not be greater than 100 characters. @@ -9751,7 +9751,7 @@ export interface paths { content: { "application/json": { /** - * @example all + * @example blocked * @enum {string|null} */ status?: "pending" | "blocked" | "all" | null; @@ -10186,7 +10186,7 @@ export interface paths { content: { "application/json": { /** - * @example desktop + * @example web * @enum {string|null} */ platform?: "ios" | "android" | "web" | "desktop" | null; diff --git a/src/api/types.ts b/src/api/types.ts index 877ed7b..f21ad29 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -378,7 +378,7 @@ export interface SidCheckResult { } export interface LocaleRow { - kind?: string + kind: 'locale' code: string name: string native_name: string diff --git a/src/routes/_authed/locales.tsx b/src/routes/_authed/locales.tsx index 7e1c9d1..39bcbb2 100644 --- a/src/routes/_authed/locales.tsx +++ b/src/routes/_authed/locales.tsx @@ -2,8 +2,11 @@ import { createFileRoute } from '@tanstack/react-router' import { PERM, requirePermission } from '@/auth/permissions' import { useCan } from '@/auth/store' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' -import { Plus, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Pencil, Plus, Trash2 } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' import { toast } from 'sonner' import type { ColumnDef } from '@tanstack/react-table' import type { LocaleRow } from '@/api/types' @@ -36,6 +39,7 @@ function LocalesPage() { const queryClient = useQueryClient() const locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales }) const [dialogOpen, setDialogOpen] = useState(false) + const [editing, setEditing] = useState(null) const refresh = () => queryClient.invalidateQueries({ queryKey: ['locales'] }) @@ -78,9 +82,22 @@ function LocalesPage() { }, { header: '操作', - cell: ({ row }) => - canDelete ? ( - ( +
+ {canUpdate && ( + + )} + {canDelete && @@ -90,10 +107,10 @@ function LocalesPage() { confirmLabel="删除" destructive onConfirm={() => remove(row.original)} - /> - ) : ( - - ), + />} + {!canUpdate && !canDelete && } +
+ ), }, ] @@ -104,7 +121,10 @@ function LocalesPage() { description="平台支持的界面/内容语言" actions={ canCreate && ( - @@ -117,37 +137,70 @@ function LocalesPage() { loading={locales.isPending} emptyText="暂无语言" /> - void refresh()} + locale={editing} + onSaved={() => void refresh()} /> ) } -function CreateLocaleDialog({ +const localeSchema = z.object({ + code: z.string().trim().min(2, '请输入语言代码').max(10).regex( + /^[A-Za-z]{2,3}(?:[-_][A-Za-z]{4})?(?:[-_](?:[A-Za-z]{2}|[0-9]{3}))?$/, + '请输入有效的 BCP-47 语言代码', + ), + name: z.string().trim().min(1, '请输入名称').max(50), + native_name: z.string().trim().min(1, '请输入本地名称').max(50), + sort_order: z.number().int().min(0), + is_active: z.boolean(), +}) + +type LocaleFormValues = z.infer + +function LocaleDialog({ open, onOpenChange, - onCreated, + locale, + onSaved, }: { open: boolean onOpenChange: (open: boolean) => void - onCreated: () => void + locale: LocaleRow | null + onSaved: () => void }) { - const [draft, setDraft] = useState({ code: '', name: '', native_name: '' }) const [busy, setBusy] = useState(false) + const form = useForm({ + resolver: zodResolver(localeSchema), + defaultValues: { code: '', name: '', native_name: '', sort_order: 0, is_active: true }, + }) - const submit = async () => { + useEffect(() => { + if (!open) return + form.reset({ + code: locale?.code ?? '', + name: locale?.name ?? '', + native_name: locale?.native_name ?? '', + sort_order: locale?.sort_order ?? 0, + is_active: locale?.is_active ?? true, + }) + }, [form, locale, open]) + + const submit = async (values: LocaleFormValues) => { setBusy(true) try { - await createLocale({ ...draft, is_active: true }) - toast.success(`已添加语言 ${draft.code}`) - setDraft({ code: '', name: '', native_name: '' }) - onCreated() + if (locale) { + await updateLocale(locale.code, values) + } else { + await createLocale(values) + } + toast.success(locale ? `已更新语言 ${locale.code}` : `已添加语言 ${values.code}`) + onSaved() onOpenChange(false) } catch (error) { - handleApiError(error) + handleApiError(error, form.setError) } finally { setBusy(false) } @@ -157,43 +210,48 @@ function CreateLocaleDialog({ - 添加语言 + {locale ? `编辑语言 ${locale.code}` : '添加语言'} -
+
setDraft((d) => ({ ...d, code: e.target.value }))} + disabled={!!locale} + {...form.register('code')} /> + {form.formState.errors.code &&

{form.formState.errors.code.message}

}
setDraft((d) => ({ ...d, name: e.target.value }))} + {...form.register('name')} /> + {form.formState.errors.name &&

{form.formState.errors.name.message}

}
setDraft((d) => ({ ...d, native_name: e.target.value }))} + {...form.register('native_name')} /> + {form.formState.errors.native_name &&

{form.formState.errors.native_name.message}

}
-
- - - - +
+ + +
+
+ + form.setValue('is_active', value)} /> +
+ + + + +
)