/** Thin admin API client → /api/v1/admin (proxied to Go). */ export type ApiEnvelope = { code: number; message: string; data?: T } const TOKEN_KEY = 'yuxingu_admin_token' /** Under /psy/admin use /psy/api; local vite uses /api. */ function adminAPIBase(): string { if (typeof location !== 'undefined' && location.pathname.startsWith('/psy/admin')) { return '/psy/api/v1/admin' } return '/api/v1/admin' } export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) } export function setToken(token: string | null) { if (token) localStorage.setItem(TOKEN_KEY, token) else localStorage.removeItem(TOKEN_KEY) } async function request(method: string, path: string, body?: unknown): Promise { const headers: Record = { Accept: 'application/json' } if (body !== undefined) headers['Content-Type'] = 'application/json' const token = getToken() if (token) headers.Authorization = `Bearer ${token}` const res = await fetch(`${adminAPIBase()}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), }) const text = await res.text() let env: ApiEnvelope try { env = JSON.parse(text) as ApiEnvelope } catch { throw new Error( res.status === 404 ? `接口不存在或后端未更新(${path})` : `响应不是 JSON(HTTP ${res.status})`, ) } if (!res.ok || env.code !== 0) { throw new Error(env.message || `HTTP ${res.status}`) } return env.data as T } export type DashboardStats = { users_total: number membership_active: number orders_today: number paid_cents_today: number ask_replies_today: number profiles_total: number reports_total: number series?: Array<{ day: string new_users: number orders: number paid_cents: number ask_replies: number }> reports_by_type?: Array<{ type: string; count: number }> } export type UserListItem = { id: string status: string phone?: string | null nickname?: string | null ask_paid_quota_left: number profile_count: number membership_active: boolean membership_plan?: string | null created_at: string } export type UserDetail = { id: string status: string phone?: string | null nickname?: string | null ask_paid_quota_left: number created_at: string profiles: Array<{ id: string; relation: string; display_name: string; birth_date?: string }> reports: Array<{ id: string; type: string; created_at: string }> membership: { active: boolean plan?: string status: string expires_at?: string | null ask_quota_left?: number } recent_orders: Array<{ id: string kind: string plan?: string status: string amount_cents: number created_at: string }> } export type AdminRole = 'super' | 'ops' export type AdminMe = { id: string username: string role?: string permissions?: string[] } export type PushJob = { id: string title: string body: string audience: string status: 'draft' | 'cancelled' created_by: string created_at: string updated_at: string } export type AdminListItem = { id: string username: string role: AdminRole status: string created_at: string } export const adminApi = { login: (username: string, password: string) => request<{ token: string; admin: AdminMe }>('POST', '/auth/login', { username, password, }), logout: () => request<{ ok: boolean }>('POST', '/auth/logout'), me: () => request('GET', '/me'), roles: () => request<{ items: Array<{ id: string; name: string; system: boolean }> }>('GET', '/roles'), role: (id: string) => request<{ id: string; name: string; system: boolean; permissions: string[] }>('GET', `/roles/${id}`), stats: () => request('GET', '/stats'), users: (q = '') => request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`), user: (id: string) => request('GET', `/users/${id}`), setUserStatus: (id: string, status: string, reason: string) => request('POST', `/users/${id}/status`, { status, reason }), statusTransitions: (id: string) => request<{ items: Array<{ id: string from_status: string to_status: string admin_id: string reason: string created_at: string }> }>('GET', `/users/${id}/status-transitions`), userInsight: (id: string) => request<{ user_id: string profiles_count: number reports_by_type: Array<{ type: string; count: number }> recent_reports: Array<{ id: string; type: string; created_at: string }> tags: Array<{ code: string; label: string }> behavior: { events: Array<{ name: string; page_path?: string; received_at: string }> ask_thread_count: number } }>('GET', `/users/${id}/insight`), userEntitlements: (id: string) => request<{ user_id: string membership: { plan?: string status: string expires_at?: string ask_quota_left?: number active: boolean } ask_paid_quota_left: number flags: { report_detail_via_membership: boolean deep_access_count: number } deep_accesses: Array<{ id: string report_id: string report_type?: string created_at: string }> }>('GET', `/users/${id}/entitlements`), grant: (id: string, plan: string) => request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }), grantAskQuota: (id: string, delta: number) => request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, { delta, }), admins: () => request<{ items: AdminListItem[] }>('GET', '/admins'), patchAdminRole: (id: string, role: AdminRole) => request<{ ok: boolean }>('PATCH', `/admins/${id}`, { role }), pushJobs: () => request<{ items: PushJob[] }>('GET', '/push-jobs'), createPushJob: (body: { title: string; body?: string; audience?: string }) => request('POST', '/push-jobs', body), patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) => request('PATCH', `/push-jobs/${id}`, body), membershipPlans: () => request<{ items: Array<{ code: string title: string duration_days: number amount_cents: number active: boolean updated_at: string }> }>('GET', '/membership-plans'), updateMembershipPlan: ( code: string, body: { title: string; duration_days: number; amount_cents: number; active: boolean }, ) => request<{ code: string title: string duration_days: number amount_cents: number active: boolean }>('PUT', `/membership-plans/${code}`, body), createRedemptionBatch: (body: { label: string; plan_code: string; quantity: number }) => request<{ batch: { id: string; label: string; plan_code: string; quantity: number } codes: Array<{ id: string; code: string; status: string }> }>('POST', '/redemption-batches', body), redemptionBatches: () => request<{ items: Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }> }>( 'GET', '/redemption-batches', ), redemptionCodes: (batchId: string) => request<{ items: Array<{ id: string; code: string; status: string; plan_code: string }> }>( 'GET', `/redemption-batches/${batchId}/codes`, ), disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`), askThreads: (userId = '') => { const q = userId ? `?user_id=${encodeURIComponent(userId)}` : '' return request<{ items: Array<{ id: string user_id: string profile_id: string scene?: string message_count: number created_at: string updated_at: string }> }>('GET', `/ask/threads${q}`) }, askThread: (id: string) => request<{ id: string user_id: string profile_id: string scene?: string message_count: number created_at: string updated_at: string }>('GET', `/ask/threads/${id}`), askThreadMessages: (id: string) => request<{ id: string user_id: string profile_id: string scene?: string message_count: number created_at: string updated_at: string messages: Array<{ id: string; role: string; content: string; created_at: string }> }>('GET', `/ask/threads/${id}/messages`), filterRules: () => request<{ items: Array<{ id: string code: string title: string category: string pattern: string action: string active: boolean system: boolean updated_at: string }> }>('GET', '/content-safety/filter-rules'), filterRule: (id: string) => request<{ id: string code: string title: string category: string pattern: string action: string active: boolean system: boolean updated_at: string }>('GET', `/content-safety/filter-rules/${id}`), evaluateContent: (text: string) => request<{ matches: Array<{ code: string; title: string; category: string; action: string }> }>( 'POST', '/content-safety/evaluate', { text }, ), askFeedback: () => request<{ items: Array<{ id: string thread_id: string message_id?: string source: string rating: number tag?: string note?: string created_at: string }> }>('GET', '/ask/feedback'), createAskFeedback: (threadId: string, body: { rating: number; tag?: string; note?: string; message_id?: string }) => request<{ id: string thread_id: string rating: number source: string }>('POST', `/ask/threads/${threadId}/feedback`, body), systemPrompts: () => request<{ items: Array<{ id: string code: string title: string scene?: string body: string version: number active: boolean system: boolean updated_at: string }> }>('GET', '/ai/system-prompts'), systemPrompt: (id: string) => request<{ id: string code: string title: string scene?: string body: string version: number active: boolean system: boolean updated_at: string }>('GET', `/ai/system-prompts/${id}`), knowledgeSources: () => request<{ items: Array<{ id: string code: string title: string description?: string source_kind: string version: number active: boolean system: boolean updated_at: string }> }>('GET', '/ai/knowledge-sources'), knowledgeSource: (id: string) => request<{ id: string code: string title: string description?: string source_kind: string version: number active: boolean system: boolean updated_at: string }>('GET', `/ai/knowledge-sources/${id}`), crisisPolicies: () => request<{ items: Array<{ id: string code: string title: string severity: string pattern: string action: string helpline_text?: string active: boolean system: boolean updated_at: string }> }>('GET', '/crisis/policies'), crisisPolicy: (id: string) => request<{ id: string code: string title: string severity: string pattern: string action: string helpline_text?: string active: boolean system: boolean updated_at: string }>('GET', `/crisis/policies/${id}`), evaluateCrisis: (text: string) => request<{ matches: Array<{ code: string title: string severity: string action: string helpline_text?: string }> }>('POST', '/crisis/evaluate', { text }), banners: () => request<{ items: Array<{ id: string code: string title: string placement: string image_url?: string link_path?: string sort_order: number active: boolean system: boolean updated_at: string }> }>('GET', '/cms/banners'), banner: (id: string) => request<{ id: string code: string title: string placement: string image_url?: string link_path?: string sort_order: number active: boolean system: boolean updated_at: string }>('GET', `/cms/banners/${id}`), createBanner: (body: { code: string title: string placement: string image_url?: string | null link_path?: string | null sort_order: number active: boolean }) => request<{ id: string code: string title: string placement: string image_url?: string link_path?: string sort_order: number active: boolean system: boolean updated_at: string }>('POST', '/cms/banners', body), updateBanner: ( id: string, body: { code: string title: string placement: string image_url?: string | null link_path?: string | null sort_order: number active: boolean }, ) => request<{ id: string code: string title: string placement: string image_url?: string link_path?: string sort_order: number active: boolean system: boolean updated_at: string }>('PUT', `/cms/banners/${id}`, body), feedSlots: () => request<{ items: Array<{ id: string code: string title: string slot_key: string placement: string active: boolean system: boolean updated_at: string }> }>('GET', '/cms/feed-slots'), feedSlot: (id: string) => request<{ id: string code: string title: string slot_key: string placement: string active: boolean system: boolean updated_at: string }>('GET', `/cms/feed-slots/${id}`), publications: () => request<{ items: Array> }>('GET', '/cms/publications'), publication: (id: string) => request>('GET', `/cms/publications/${id}`), knowledgeChunks: () => request<{ items: Array> }>('GET', '/ai/knowledge-chunks'), knowledgeChunk: (id: string) => request>('GET', `/ai/knowledge-chunks/${id}`), tools: () => request<{ items: Array> }>('GET', '/ai/tools'), tool: (id: string) => request>('GET', `/ai/tools/${id}`), blockPolicies: () => request<{ items: Array> }>('GET', '/content-safety/block-policies'), blockPolicy: (id: string) => request>('GET', `/content-safety/block-policies/${id}`), cases: () => request<{ items: Array> }>('GET', '/content-safety/cases'), case: (id: string) => request>('GET', `/content-safety/cases/${id}`), events: () => request<{ items: Array> }>('GET', '/crisis/events'), event: (id: string) => request>('GET', `/crisis/events/${id}`), interventions: () => request<{ items: Array> }>('GET', '/crisis/interventions'), intervention: (id: string) => request>('GET', `/crisis/interventions/${id}`), handoffs: () => request<{ items: Array> }>('GET', '/ask/handoffs'), handoff: (id: string) => request>('GET', `/ask/handoffs/${id}`), requests: () => request<{ items: Array> }>('GET', '/privacy/requests'), privacyRequest: (id: string) => request>('GET', `/privacy/requests/${id}`), starConfigs: () => request<{ items: Array> }>('GET', '/explore/star-configs'), starConfig: (id: string) => request>('GET', `/explore/star-configs/${id}`), rhythmConfigs: () => request<{ items: Array> }>('GET', '/explore/rhythm-configs'), rhythmConfig: (id: string) => request>('GET', `/explore/rhythm-configs/${id}`), imageCardDecks: () => request<{ items: Array> }>('GET', '/explore/image-card-decks'), imageCardDeck: (id: string) => request>('GET', `/explore/image-card-decks/${id}`), reportTemplates: () => request<{ items: Array> }>('GET', '/growth/report-templates'), reportTemplate: (id: string) => request>('GET', `/growth/report-templates/${id}`), funnelDefinitions: () => request<{ items: Array> }>('GET', '/analytics/funnel-definitions'), funnelDefinition: (id: string) => request>('GET', `/analytics/funnel-definitions/${id}`), exploreScales: () => request<{ items: Array> }>('GET', '/explore/scales'), exploreScale: (id: string) => request>('GET', `/explore/scales/${id}`), orders: (params?: { status?: string kind?: string from?: string to?: string user_id?: string limit?: number offset?: number }) => { const q = new URLSearchParams() if (params?.status) q.set('status', params.status) if (params?.kind) q.set('kind', params.kind) if (params?.from) q.set('from', params.from) if (params?.to) q.set('to', params.to) if (params?.user_id) q.set('user_id', params.user_id) if (params?.limit != null) q.set('limit', String(params.limit)) if (params?.offset != null) q.set('offset', String(params.offset)) const qs = q.toString() return request<{ items: Array<{ id: string user_id: string kind: string plan?: string amount_cents: number status: string refund_status: string created_at: string }> }>('GET', `/orders${qs ? `?${qs}` : ''}`) }, planPrices: () => request<{ items: Array<{ plan: string; display_cents: number; updated_at: string }> }>( 'GET', '/membership/plan-prices', ), savePlanPrices: (items: Array<{ plan: string; display_cents: number }>) => request<{ ok: boolean }>('PUT', '/membership/plan-prices', { items }), audit: () => request<{ items: Array<{ id: string admin_id: string action: string target_type: string target_id: string meta: unknown created_at: string }> }>('GET', '/audit-logs'), analyticsOverview: (from: string, to: string) => request('GET', `/analytics/overview?from=${from}&to=${to}`), analyticsPages: (from: string, to: string) => request<{ items: AnalyticsPageRow[] }>('GET', `/analytics/pages?from=${from}&to=${to}`), analyticsExits: (from: string, to: string) => request<{ items: AnalyticsExitRow[] }>('GET', `/analytics/exits?from=${from}&to=${to}`), analyticsClicks: (from: string, to: string) => request<{ items: AnalyticsClickRow[] }>('GET', `/analytics/clicks?from=${from}&to=${to}`), analyticsFunnel: (from: string, to: string) => request<{ steps: AnalyticsFunnelStep[] }>('GET', `/analytics/funnel?from=${from}&to=${to}`), homeTools: () => request<{ items: HomeToolAdmin[] }>('GET', '/home/tools'), saveHomeTools: (items: HomeToolAdmin[]) => request<{ ok: boolean }>('PUT', '/home/tools', { items }), scales: () => request<{ items: ScaleAdmin[] }>('GET', '/scales'), patchScale: (id: string, status: 'published' | 'draft') => request<{ ok: boolean }>('PATCH', `/scales/${id}`, { status }), } export type HomeToolAdmin = { id?: string row_index: number sort_order: number path: string icon: string label: string badge?: string | null badge_tone?: string | null enabled: boolean } export type ScaleAdmin = { id: string slug: string title: string description: string status: string } export type AnalyticsOverview = { dau: number new_users: number sessions: number avg_session_ms: number series?: Array<{ day: string; dau: number; sessions: number }> } export type AnalyticsPageRow = { page_path: string pv: number uv: number avg_dwell_ms: number exit_count: number } export type AnalyticsExitRow = { exit_page: string; count: number } export type AnalyticsClickRow = { element_id: string; count: number } export type AnalyticsFunnelStep = { name: string; count: number }