feat(locale): complete admin locale management

This commit is contained in:
2026-09-11 22:49:01 +08:00
parent 9b8d9c7391
commit c987d9c0b1
4 changed files with 112 additions and 51 deletions
+5 -2
View File
@@ -4,18 +4,21 @@ import type { ApiResponse, LocaleRow } from '@/api/types'
const BASE = '/api/admin/v1/locales' const BASE = '/api/admin/v1/locales'
export interface LocalePayload { export interface LocalePayload {
code?: string
name: string name: string
native_name: string native_name: string
is_active?: boolean is_active?: boolean
sort_order?: number sort_order?: number
} }
export interface CreateLocalePayload extends LocalePayload {
code: string
}
export function fetchLocales() { export function fetchLocales() {
return api.get<ApiResponse<LocaleRow[]>>(BASE) return api.get<ApiResponse<LocaleRow[]>>(BASE)
} }
export function createLocale(payload: LocalePayload) { export function createLocale(payload: CreateLocalePayload) {
return api.post<ApiResponse<LocaleRow>>(BASE, payload) return api.post<ApiResponse<LocaleRow>>(BASE, payload)
} }
+9 -9
View File
@@ -50,7 +50,7 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example all * @example trashed
* @enum {string|null} * @enum {string|null}
*/ */
state?: "active" | "archived" | "trashed" | "all" | null; state?: "active" | "archived" | "trashed" | "all" | null;
@@ -539,7 +539,7 @@ export interface paths {
*/ */
language_code?: string; language_code?: string;
/** /**
* @example awaiting_publish * @example published
* @enum {string} * @enum {string}
*/ */
status?: "draft" | "awaiting_publish" | "published"; status?: "draft" | "awaiting_publish" | "published";
@@ -4677,7 +4677,7 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example viewed * @example sent
* @enum {string|null} * @enum {string|null}
*/ */
status?: "sent" | "delivered" | "viewed" | "accepted" | "rejected" | "ignored" | "expired" | "taken_by_other" | null; status?: "sent" | "delivered" | "viewed" | "accepted" | "rejected" | "ignored" | "expired" | "taken_by_other" | null;
@@ -5452,12 +5452,12 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example private * @example friends
* @enum {string|null} * @enum {string|null}
*/ */
visibility?: "public" | "friends" | "private" | null; visibility?: "public" | "friends" | "private" | null;
/** /**
* @example all * @example blocked
* @enum {string|null} * @enum {string|null}
*/ */
status?: "blocked" | "visible" | "all" | null; status?: "blocked" | "visible" | "all" | null;
@@ -5786,7 +5786,7 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example all * @example pending
* @enum {string|null} * @enum {string|null}
*/ */
status?: "pending" | "blocked" | "all" | null; status?: "pending" | "blocked" | "all" | null;
@@ -7673,7 +7673,7 @@ export interface paths {
q?: string | null; q?: string | null;
/** @example true */ /** @example true */
status?: boolean | null; status?: boolean | null;
/** @example false */ /** @example true */
is_robot?: boolean | null; is_robot?: boolean | null;
/** /**
* @description Must not be greater than 100 characters. * @description Must not be greater than 100 characters.
@@ -9751,7 +9751,7 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example all * @example blocked
* @enum {string|null} * @enum {string|null}
*/ */
status?: "pending" | "blocked" | "all" | null; status?: "pending" | "blocked" | "all" | null;
@@ -10186,7 +10186,7 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
/** /**
* @example desktop * @example web
* @enum {string|null} * @enum {string|null}
*/ */
platform?: "ios" | "android" | "web" | "desktop" | null; platform?: "ios" | "android" | "web" | "desktop" | null;
+1 -1
View File
@@ -378,7 +378,7 @@ export interface SidCheckResult {
} }
export interface LocaleRow { export interface LocaleRow {
kind?: string kind: 'locale'
code: string code: string
name: string name: string
native_name: string native_name: string
+93 -35
View File
@@ -2,8 +2,11 @@ import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions' import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store' import { useCan } from '@/auth/store'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Plus, Trash2 } from 'lucide-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 { toast } from 'sonner'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import type { LocaleRow } from '@/api/types' import type { LocaleRow } from '@/api/types'
@@ -36,6 +39,7 @@ function LocalesPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales }) const locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales })
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<LocaleRow | null>(null)
const refresh = () => queryClient.invalidateQueries({ queryKey: ['locales'] }) const refresh = () => queryClient.invalidateQueries({ queryKey: ['locales'] })
@@ -78,9 +82,22 @@ function LocalesPage() {
}, },
{ {
header: '操作', header: '操作',
cell: ({ row }) => cell: ({ row }) => (
canDelete ? ( <div className="flex items-center gap-1">
<ConfirmDialog {canUpdate && (
<Button
variant="ghost"
size="icon-sm"
aria-label="编辑"
onClick={() => {
setEditing(row.original)
setDialogOpen(true)
}}
>
<Pencil className="size-4" />
</Button>
)}
{canDelete && <ConfirmDialog
trigger={ trigger={
<Button variant="ghost" size="icon-sm" aria-label="删除"> <Button variant="ghost" size="icon-sm" aria-label="删除">
<Trash2 className="size-4 text-destructive" /> <Trash2 className="size-4 text-destructive" />
@@ -90,9 +107,9 @@ function LocalesPage() {
confirmLabel="删除" confirmLabel="删除"
destructive destructive
onConfirm={() => remove(row.original)} onConfirm={() => remove(row.original)}
/> />}
) : ( {!canUpdate && !canDelete && <span className="text-xs text-muted-foreground"></span>}
<span className="text-xs text-muted-foreground"></span> </div>
), ),
}, },
] ]
@@ -104,7 +121,10 @@ function LocalesPage() {
description="平台支持的界面/内容语言" description="平台支持的界面/内容语言"
actions={ actions={
canCreate && ( canCreate && (
<Button onClick={() => setDialogOpen(true)}> <Button onClick={() => {
setEditing(null)
setDialogOpen(true)
}}>
<Plus className="size-4" /> <Plus className="size-4" />
</Button> </Button>
@@ -117,37 +137,70 @@ function LocalesPage() {
loading={locales.isPending} loading={locales.isPending}
emptyText="暂无语言" emptyText="暂无语言"
/> />
<CreateLocaleDialog <LocaleDialog
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
onCreated={() => void refresh()} locale={editing}
onSaved={() => void refresh()}
/> />
</div> </div>
) )
} }
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<typeof localeSchema>
function LocaleDialog({
open, open,
onOpenChange, onOpenChange,
onCreated, locale,
onSaved,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void 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 [busy, setBusy] = useState(false)
const form = useForm<LocaleFormValues>({
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) setBusy(true)
try { try {
await createLocale({ ...draft, is_active: true }) if (locale) {
toast.success(`已添加语言 ${draft.code}`) await updateLocale(locale.code, values)
setDraft({ code: '', name: '', native_name: '' }) } else {
onCreated() await createLocale(values)
}
toast.success(locale ? `已更新语言 ${locale.code}` : `已添加语言 ${values.code}`)
onSaved()
onOpenChange(false) onOpenChange(false)
} catch (error) { } catch (error) {
handleApiError(error) handleApiError(error, form.setError)
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -157,43 +210,48 @@ function CreateLocaleDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{locale ? `编辑语言 ${locale.code}` : '添加语言'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4"> <form className="grid gap-4" onSubmit={form.handleSubmit(submit)}>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label></Label> <Label></Label>
<Input <Input
className="font-mono" className="font-mono"
placeholder="zh-CN" placeholder="zh-CN"
value={draft.code} disabled={!!locale}
onChange={(e) => setDraft((d) => ({ ...d, code: e.target.value }))} {...form.register('code')}
/> />
{form.formState.errors.code && <p className="text-xs text-destructive">{form.formState.errors.code.message}</p>}
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label></Label> <Label></Label>
<Input <Input
placeholder="Simplified Chinese" placeholder="Simplified Chinese"
value={draft.name} {...form.register('name')}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/> />
{form.formState.errors.name && <p className="text-xs text-destructive">{form.formState.errors.name.message}</p>}
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label></Label> <Label></Label>
<Input <Input
placeholder="简体中文" placeholder="简体中文"
value={draft.native_name} {...form.register('native_name')}
onChange={(e) => setDraft((d) => ({ ...d, native_name: e.target.value }))}
/> />
{form.formState.errors.native_name && <p className="text-xs text-destructive">{form.formState.errors.native_name.message}</p>}
</div> </div>
<div className="grid gap-1.5">
<Label></Label>
<Input type="number" {...form.register('sort_order', { valueAsNumber: true })} />
</div>
<div className="flex items-center justify-between">
<Label></Label>
<Switch checked={form.watch('is_active')} onCheckedChange={(value) => form.setValue('is_active', value)} />
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button type="submit" disabled={busy}>{busy ? '保存中…' : '保存'}</Button>
</Button>
<Button onClick={submit} disabled={busy || !draft.code || !draft.name || !draft.native_name}>
{busy ? '添加中…' : '添加'}
</Button>
</DialogFooter> </DialogFooter>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )