feat: add official account management

This commit is contained in:
2026-09-13 20:32:24 +08:00
parent 3c850528a4
commit 77c5535156
8 changed files with 494 additions and 8 deletions
+32
View File
@@ -0,0 +1,32 @@
import { api } from '@/api/client'
import type {
ListParams,
OfficialAccount,
OfficialAccountPayload,
PaginatedResponse,
ApiResponse,
} from '@/api/types'
const BASE = '/api/admin/v1/party/official-accounts'
export function fetchOfficialAccounts(params: ListParams) {
return api.get<PaginatedResponse<OfficialAccount>>(BASE, params)
}
export function createOfficialAccount(payload: OfficialAccountPayload) {
return api.post<ApiResponse<OfficialAccount>>(BASE, payload)
}
export function updateOfficialAccount(id: string, payload: Partial<OfficialAccountPayload>) {
return api.put<ApiResponse<OfficialAccount>>(`${BASE}/${id}`, payload)
}
export function deleteOfficialAccount(id: string) {
return api.delete<ApiResponse<null>>(`${BASE}/${id}`)
}
export function uploadOfficialAccountAvatar(id: string, file: File) {
const body = new FormData()
body.append('avatar', file)
return api.upload<ApiResponse<OfficialAccount>>(`${BASE}/${id}/avatar`, body)
}
+217 -8
View File
@@ -50,7 +50,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example trashed
* @example all
* @enum {string|null}
*/
state?: "active" | "archived" | "trashed" | "all" | null;
@@ -4357,7 +4357,7 @@ export interface paths {
/** @example architecto */
status?: string | null;
/**
* @example scheduled_booking
* @example instant_accept
* @enum {string|null}
*/
dispatch_mode?: "instant_accept" | "quote" | "scheduled_booking" | "manual_selection" | null;
@@ -4871,12 +4871,12 @@ export interface paths {
content: {
"application/json": {
/**
* @example paid
* @example fulfilled
* @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,11 +5161,11 @@ export interface paths {
content: {
"application/json": {
/**
* @example offline
* @example inactive
* @enum {string|null}
*/
status?: "offline" | "online" | "inactive" | "busy" | null;
/** @example false */
/** @example true */
is_disabled?: boolean | null;
/** @example true */
is_verified?: boolean | null;
@@ -5452,7 +5452,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example friends
* @example private
* @enum {string|null}
*/
visibility?: "public" | "friends" | "private" | null;
@@ -5786,7 +5786,7 @@ export interface paths {
content: {
"application/json": {
/**
* @example pending
* @example blocked
* @enum {string|null}
*/
status?: "pending" | "blocked" | "all" | null;
@@ -6356,6 +6356,215 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/v1/party/official-accounts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 官方账号列表 */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/**
* @description Must not be greater than 255 characters.
* @example b
*/
q?: string | null;
/** @example null */
type?: string | null;
/** @example false */
verified?: boolean | null;
/** @example false */
is_global?: boolean | 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;
};
};
};
};
};
put?: never;
/** 创建官方账号 */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: never;
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/v1/party/official-accounts/{id}": {
parameters: {
query?: never;
header?: never;
path: {
/**
* @description The ID of the official account.
* @example architecto
*/
id: string;
};
cookie?: never;
};
/** 官方账号详情 */
get: {
parameters: {
query?: never;
header?: never;
path: {
/**
* @description The ID of the official account.
* @example architecto
*/
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @example false */
success?: boolean;
/** @example Authentication required */
message?: string;
/** @example UNAUTHENTICATED */
code?: string;
};
};
};
};
};
/** 更新官方账号 */
put: {
parameters: {
query?: never;
header?: never;
path: {
/**
* @description The ID of the official account.
* @example architecto
*/
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: never;
};
post?: never;
/** 删除官方账号 */
delete: {
parameters: {
query?: never;
header?: never;
path: {
/**
* @description The ID of the official account.
* @example architecto
*/
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: never;
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/v1/party/official-accounts/{id}/avatar": {
parameters: {
query?: never;
header?: never;
path: {
/**
* @description The ID of the official account.
* @example architecto
*/
id: string;
};
cookie?: never;
};
get?: never;
put?: never;
/** 上传官方账号头像 */
post: {
parameters: {
query?: never;
header?: never;
path: {
/**
* @description The ID of the official account.
* @example architecto
*/
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @example architecto */
avatar: string;
};
};
};
responses: never;
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/v1/api-docs/public": {
parameters: {
query?: never;
+32
View File
@@ -445,6 +445,38 @@ export interface PushAppVerification {
ok: boolean
}
export type OfficialAccountType = 'subscription' | 'service' | 'system'
export interface OfficialAccount {
kind: 'official_account'
id: string
owner_uid: string | null
slug: string
name: string
description: string | null
avatar_url: string | null
avatar_path: string | null
verified: boolean
type: OfficialAccountType
auto_reply_config: Record<string, unknown> | null
is_global: boolean
followers_count: number
created_at: string
updated_at: string
}
export interface OfficialAccountPayload {
slug: string
name: string
description?: string | null
avatar_url?: string | null
owner_uid?: string | null
verified?: boolean
type: OfficialAccountType
auto_reply_config?: Record<string, unknown> | null
is_global?: boolean
}
export type ReportStatus = 'open' | 'resolved' | 'dismissed'
export interface ReportUser {
+2
View File
@@ -29,6 +29,8 @@ export const PERM = {
SID_MANAGE: 'admin:sid:manage',
SID_OVERRIDE: 'admin:sid:override',
NOTIFICATION_READ: 'admin:notification:read',
PARTY_READ: 'admin:party:read',
PARTY_MANAGE: 'admin:party:manage',
PUSH_APP_MANAGE: 'auth:push-app:manage',
RESUME_READ: 'admin:resume:read',
RESUME_MANAGE: 'admin:resume:manage',
+2
View File
@@ -20,6 +20,7 @@ import {
LayoutList,
ListChecks,
MessageSquareWarning,
MessagesSquare,
Newspaper,
Palette,
Receipt,
@@ -117,6 +118,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ label: '语言设置', to: '/locales', icon: Languages, permission: 'admin:locale: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' },
{ label: '机器人会话', to: '/robots', icon: Inbox, permission: 'auth:robot-token:issue' },
],
},
@@ -0,0 +1,129 @@
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import {
createOfficialAccount,
updateOfficialAccount,
uploadOfficialAccountAvatar,
} from '@/api/modules/official-accounts'
import type { OfficialAccount, OfficialAccountPayload } from '@/api/types'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import { handleApiError } from '@/lib/errors'
const schema = z.object({
slug: z.string().trim().min(1, '请填写账号标识').max(100).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, '仅支持小写字母、数字和连字符'),
name: z.string().trim().min(1, '请填写显示名称').max(255),
description: z.string(),
avatar_url: z.union([z.literal(''), z.url('请输入有效的头像 URL')]),
owner_uid: z.union([z.literal(''), z.string().length(26, '用户 UID 必须为 26 个字符')]),
type: z.enum(['subscription', 'service', 'system']),
auto_reply_config: z.string().refine((value) => {
if (!value.trim()) return true
try {
const parsed: unknown = JSON.parse(value)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
} catch {
return false
}
}, '请输入有效的 JSON 对象'),
verified: z.boolean(),
is_global: z.boolean(),
avatar: z.instanceof(File).optional(),
})
type Values = z.infer<typeof schema>
interface Props {
account: OfficialAccount | null
creating: boolean
onOpenChange: (open: boolean) => void
onSaved: () => void
}
export function OfficialAccountEditSheet({ account, creating, onOpenChange, onSaved }: Props) {
const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: defaults() })
useEffect(() => {
form.reset(defaults(account))
}, [account, creating, form])
const submit = async (values: Values) => {
const payload: OfficialAccountPayload = {
slug: values.slug.trim(),
name: values.name.trim(),
description: values.description.trim() || null,
avatar_url: values.avatar_url.trim() || null,
owner_uid: values.owner_uid.trim() || null,
type: values.type,
auto_reply_config: values.auto_reply_config.trim()
? (JSON.parse(values.auto_reply_config) as Record<string, unknown>)
: null,
verified: values.verified,
is_global: values.is_global,
}
try {
const response = account
? await updateOfficialAccount(account.id, payload)
: await createOfficialAccount(payload)
if (values.avatar) await uploadOfficialAccountAvatar(response.data.id, values.avatar)
toast.success(account ? '官方账号已更新' : '官方账号已创建')
onSaved()
onOpenChange(false)
} catch (error) {
handleApiError(error, form.setError)
}
}
return (
<Sheet open={creating || account !== null} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
<SheetHeader><SheetTitle>{account ? '编辑官方账号' : '新建官方账号'}</SheetTitle></SheetHeader>
<form className="grid gap-4 px-4 pb-8" onSubmit={form.handleSubmit(submit)}>
<Field label="账号标识" error={form.formState.errors.slug?.message}><Input placeholder="billing-support" {...form.register('slug')} /></Field>
<Field label="显示名称" error={form.formState.errors.name?.message}><Input {...form.register('name')} /></Field>
<Field label="账号类型"><Controller control={form.control} name="type" render={({ field }) => <Select value={field.value} onValueChange={field.onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="subscription"></SelectItem><SelectItem value="service"></SelectItem><SelectItem value="system"></SelectItem></SelectContent></Select>} /></Field>
<Field label="简介"><Textarea rows={3} {...form.register('description')} /></Field>
<Field label="所有者 UID" error={form.formState.errors.owner_uid?.message}><Input placeholder="留空表示平台所有" {...form.register('owner_uid')} /></Field>
<Field label="外部头像 URL" error={form.formState.errors.avatar_url?.message}><Input placeholder="可选;上传头像后将自动清除" {...form.register('avatar_url')} /></Field>
<Field label="上传头像" error={form.formState.errors.avatar?.message}><Input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" onChange={(event) => form.setValue('avatar', event.target.files?.[0], { shouldValidate: true })} /></Field>
<Field label="自动回复配置" error={form.formState.errors.auto_reply_config?.message}><Textarea className="font-mono text-xs" rows={7} placeholder='{"mode":"local","rules":[]}' {...form.register('auto_reply_config')} /></Field>
<Check control={form.control} name="verified" label="已认证" />
<Check control={form.control} name="is_global" label="全局账号" />
<Button type="submit" disabled={form.formState.isSubmitting}>{form.formState.isSubmitting ? '保存中…' : '保存'}</Button>
</form>
</SheetContent>
</Sheet>
)
}
function defaults(account?: OfficialAccount | null): Values {
return {
slug: account?.slug ?? '',
name: account?.name ?? '',
description: account?.description ?? '',
avatar_url: account?.avatar_path ? '' : account?.avatar_url ?? '',
owner_uid: account?.owner_uid ?? '',
type: account?.type ?? 'subscription',
auto_reply_config: account?.auto_reply_config ? JSON.stringify(account.auto_reply_config, null, 2) : '',
verified: account?.verified ?? false,
is_global: account?.is_global ?? false,
avatar: undefined,
}
}
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
return <div className="grid gap-2"><Label>{label}</Label>{children}{error && <p className="text-sm text-destructive">{error}</p>}</div>
}
function Check({ control, name, label }: { control: ReturnType<typeof useForm<Values>>['control']; name: 'verified' | 'is_global'; label: string }) {
return <Controller control={control} name={name} render={({ field }) => <label className="flex items-center gap-2 text-sm"><Checkbox checked={field.value} onCheckedChange={(value) => field.onChange(value === true)} />{label}</label>} />
}
+21
View File
@@ -17,6 +17,7 @@ import { Route as AuthedSidRouteImport } from './routes/_authed/sid'
import { Route as AuthedRolesRouteImport } from './routes/_authed/roles'
import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots'
import { Route as AuthedPushAppsRouteImport } from './routes/_authed/push-apps'
import { Route as AuthedOfficialAccountsRouteImport } from './routes/_authed/official-accounts'
import { Route as AuthedMinioConsoleRouteImport } from './routes/_authed/minio-console'
import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales'
import { Route as AuthedHorizonRouteImport } from './routes/_authed/horizon'
@@ -84,6 +85,11 @@ const AuthedPushAppsRoute = AuthedPushAppsRouteImport.update({
path: '/push-apps',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedOfficialAccountsRoute = AuthedOfficialAccountsRouteImport.update({
id: '/official-accounts',
path: '/official-accounts',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedMinioConsoleRoute = AuthedMinioConsoleRouteImport.update({
id: '/minio-console',
path: '/minio-console',
@@ -240,6 +246,7 @@ export interface FileRoutesByFullPath {
'/horizon': typeof AuthedHorizonRoute
'/locales': typeof AuthedLocalesRoute
'/minio-console': typeof AuthedMinioConsoleRoute
'/official-accounts': typeof AuthedOfficialAccountsRoute
'/push-apps': typeof AuthedPushAppsRoute
'/robots': typeof AuthedRobotsRoute
'/roles': typeof AuthedRolesRoute
@@ -275,6 +282,7 @@ export interface FileRoutesByTo {
'/horizon': typeof AuthedHorizonRoute
'/locales': typeof AuthedLocalesRoute
'/minio-console': typeof AuthedMinioConsoleRoute
'/official-accounts': typeof AuthedOfficialAccountsRoute
'/push-apps': typeof AuthedPushAppsRoute
'/robots': typeof AuthedRobotsRoute
'/roles': typeof AuthedRolesRoute
@@ -313,6 +321,7 @@ export interface FileRoutesById {
'/_authed/horizon': typeof AuthedHorizonRoute
'/_authed/locales': typeof AuthedLocalesRoute
'/_authed/minio-console': typeof AuthedMinioConsoleRoute
'/_authed/official-accounts': typeof AuthedOfficialAccountsRoute
'/_authed/push-apps': typeof AuthedPushAppsRoute
'/_authed/robots': typeof AuthedRobotsRoute
'/_authed/roles': typeof AuthedRolesRoute
@@ -352,6 +361,7 @@ export interface FileRouteTypes {
| '/horizon'
| '/locales'
| '/minio-console'
| '/official-accounts'
| '/push-apps'
| '/robots'
| '/roles'
@@ -387,6 +397,7 @@ export interface FileRouteTypes {
| '/horizon'
| '/locales'
| '/minio-console'
| '/official-accounts'
| '/push-apps'
| '/robots'
| '/roles'
@@ -424,6 +435,7 @@ export interface FileRouteTypes {
| '/_authed/horizon'
| '/_authed/locales'
| '/_authed/minio-console'
| '/_authed/official-accounts'
| '/_authed/push-apps'
| '/_authed/robots'
| '/_authed/roles'
@@ -512,6 +524,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedPushAppsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/official-accounts': {
id: '/_authed/official-accounts'
path: '/official-accounts'
fullPath: '/official-accounts'
preLoaderRoute: typeof AuthedOfficialAccountsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/minio-console': {
id: '/_authed/minio-console'
path: '/minio-console'
@@ -715,6 +734,7 @@ interface AuthedRouteChildren {
AuthedHorizonRoute: typeof AuthedHorizonRoute
AuthedLocalesRoute: typeof AuthedLocalesRoute
AuthedMinioConsoleRoute: typeof AuthedMinioConsoleRoute
AuthedOfficialAccountsRoute: typeof AuthedOfficialAccountsRoute
AuthedPushAppsRoute: typeof AuthedPushAppsRoute
AuthedRobotsRoute: typeof AuthedRobotsRoute
AuthedRolesRoute: typeof AuthedRolesRoute
@@ -750,6 +770,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedHorizonRoute: AuthedHorizonRoute,
AuthedLocalesRoute: AuthedLocalesRoute,
AuthedMinioConsoleRoute: AuthedMinioConsoleRoute,
AuthedOfficialAccountsRoute: AuthedOfficialAccountsRoute,
AuthedPushAppsRoute: AuthedPushAppsRoute,
AuthedRobotsRoute: AuthedRobotsRoute,
AuthedRolesRoute: AuthedRolesRoute,
+59
View File
@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import { deleteOfficialAccount, fetchOfficialAccounts } from '@/api/modules/official-accounts'
import type { OfficialAccount } 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 { OfficialAccountEditSheet } from '@/features/party/official-account-edit-sheet'
import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page'
export const Route = createFileRoute('/_authed/official-accounts')({
beforeLoad: () => requirePermission(PERM.PARTY_READ),
component: OfficialAccountsPage,
})
const typeLabels = { subscription: '订阅', service: '服务', system: '系统' } as const
function OfficialAccountsPage() {
const canManage = useCan(PERM.PARTY_MANAGE)
const queryClient = useQueryClient()
const [query, setQuery] = useState('')
const [type, setType] = useState('all')
const [editing, setEditing] = useState<OfficialAccount | null>(null)
const [creating, setCreating] = useState(false)
const list = useListPage('official-accounts', fetchOfficialAccounts, {
...(query.trim() ? { q: query.trim() } : {}),
...(type === 'all' ? {} : { type }),
})
const refresh = () => { void queryClient.invalidateQueries({ queryKey: ['official-accounts'] }) }
const remove = async (account: OfficialAccount) => {
try {
await deleteOfficialAccount(account.id)
toast.success('官方账号已删除')
refresh()
} catch (error) {
handleApiError(error)
}
}
const columns = useMemo<ColumnDef<OfficialAccount>[]>(() => [
{ header: '账号', cell: ({ row }) => <div className="flex items-center gap-3">{row.original.avatar_url ? <img src={row.original.avatar_url} alt="" className="size-9 rounded-full object-cover" /> : <div className="flex size-9 items-center justify-center rounded-full bg-muted text-sm font-medium">{row.original.name.slice(0, 1)}</div>}<div><div className="font-medium">{row.original.name}</div><code className="text-xs text-muted-foreground">{row.original.slug}</code></div></div> },
{ header: '类型', cell: ({ row }) => <Badge variant="secondary">{typeLabels[row.original.type]}</Badge> },
{ header: '认证', cell: ({ row }) => row.original.verified ? <Badge className="bg-emerald-500/12 text-emerald-700 dark:text-emerald-400"></Badge> : <Badge variant="outline"></Badge> },
{ header: '范围', cell: ({ row }) => row.original.is_global ? '全局' : '普通' },
{ header: '订阅者', accessorKey: 'followers_count' },
{ header: '操作', cell: ({ row }) => canManage ? <div className="flex gap-1.5"><Button size="sm" variant="outline" onClick={() => setEditing(row.original)}></Button>{row.original.type !== 'system' && <ConfirmDialog trigger={<Button size="sm" variant="destructive"></Button>} title={`删除“${row.original.name}”?`} confirmLabel="删除" destructive onConfirm={() => remove(row.original)} />}</div> : null },
], [canManage])
return <div><PageHeader title="官方账号" description="管理系统通知、服务与订阅消息账号" actions={<div className="flex flex-wrap gap-2"><Input className="w-52" placeholder="搜索名称或标识" value={query} onChange={(event) => setQuery(event.target.value)} /><Select value={type} onValueChange={setType}><SelectTrigger size="sm" className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all"></SelectItem><SelectItem value="subscription"></SelectItem><SelectItem value="service"></SelectItem><SelectItem value="system"></SelectItem></SelectContent></Select>{canManage && <Button onClick={() => setCreating(true)}></Button>}</div>} /><OfficialAccountEditSheet account={editing} creating={creating} onOpenChange={(open) => { if (!open) { setEditing(null); setCreating(false) } }} onSaved={refresh} /><DataTable columns={columns} data={list.rows} pagination={list.pagination} loading={list.loading} emptyText="暂无官方账号" onPageChange={list.setPage} onPageSizeChange={list.setPageSize} /></div>
}