From 77c55351568eb8f81418047a6976998ffe246dfa Mon Sep 17 00:00:00 2001 From: kongkx Date: Sun, 13 Sep 2026 20:32:24 +0800 Subject: [PATCH] feat: add official account management --- src/api/modules/official-accounts.ts | 32 +++ src/api/types.gen.ts | 225 +++++++++++++++++- src/api/types.ts | 32 +++ src/auth/permissions.ts | 2 + src/components/layout/nav.ts | 2 + .../party/official-account-edit-sheet.tsx | 129 ++++++++++ src/routeTree.gen.ts | 21 ++ src/routes/_authed/official-accounts.tsx | 59 +++++ 8 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 src/api/modules/official-accounts.ts create mode 100644 src/features/party/official-account-edit-sheet.tsx create mode 100644 src/routes/_authed/official-accounts.tsx diff --git a/src/api/modules/official-accounts.ts b/src/api/modules/official-accounts.ts new file mode 100644 index 0000000..832c463 --- /dev/null +++ b/src/api/modules/official-accounts.ts @@ -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>(BASE, params) +} + +export function createOfficialAccount(payload: OfficialAccountPayload) { + return api.post>(BASE, payload) +} + +export function updateOfficialAccount(id: string, payload: Partial) { + return api.put>(`${BASE}/${id}`, payload) +} + +export function deleteOfficialAccount(id: string) { + return api.delete>(`${BASE}/${id}`) +} + +export function uploadOfficialAccountAvatar(id: string, file: File) { + const body = new FormData() + body.append('avatar', file) + return api.upload>(`${BASE}/${id}/avatar`, body) +} diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 67f7fac..63ca278 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -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; diff --git a/src/api/types.ts b/src/api/types.ts index f21ad29..dc7c36f 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 | 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 | null + is_global?: boolean +} + export type ReportStatus = 'open' | 'resolved' | 'dismissed' export interface ReportUser { diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index 92b2187..c9e0a20 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -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', diff --git a/src/components/layout/nav.ts b/src/components/layout/nav.ts index 9ebac09..ca3c7fc 100644 --- a/src/components/layout/nav.ts +++ b/src/components/layout/nav.ts @@ -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' }, ], }, diff --git a/src/features/party/official-account-edit-sheet.tsx b/src/features/party/official-account-edit-sheet.tsx new file mode 100644 index 0000000..a676a8f --- /dev/null +++ b/src/features/party/official-account-edit-sheet.tsx @@ -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 + +interface Props { + account: OfficialAccount | null + creating: boolean + onOpenChange: (open: boolean) => void + onSaved: () => void +} + +export function OfficialAccountEditSheet({ account, creating, onOpenChange, onSaved }: Props) { + const form = useForm({ 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) + : 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 ( + + + {account ? '编辑官方账号' : '新建官方账号'} +
+ + + } /> +