feat: add official account management
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user