feat(locale): complete admin locale management
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user