diff --git a/CLAUDE.md b/CLAUDE.md index f2165091b..f4c747806 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -MX Admin (admin-vue3) is the dashboard for MX Space, a personal blog management system. Built with Vue 3, Naive UI, and UnoCSS. This is the v4.0 admin interface for Mix Space Server v5.0. +MX Admin is the dashboard for MX Space, a personal blog management system. The active admin app is a React application built with Base UI primitives, React Router, TanStack Query, Sonner, and UnoCSS. ## Development Commands @@ -12,22 +12,22 @@ MX Admin (admin-vue3) is the dashboard for MX Space, a personal blog management pnpm install # Install dependencies pnpm dev # Start development server (opens browser automatically) pnpm build # Build for production -pnpm lint # Lint code with Biome +pnpm lint # Lint code with oxlint pnpm lint:fix # Lint and auto-fix -npx tsc --noEmit # Type check (use this instead of build for validation) +pnpm -C apps/admin exec tsc --noEmit --pretty false ``` ## Architecture Overview ### Technology Stack -- **Vue 3** with Composition API and TSX (JSX via `@vitejs/plugin-vue-jsx`) -- **Naive UI** - Component library with Vercel-style neutral theme +- **React** with TSX +- **Base UI** - Headless component primitives +- **React Router** - Route rendering and shell navigation - **UnoCSS** (preset-wind4) - Tailwind-compatible utility classes -- **Pinia** - State management -- **TanStack Query** (`@tanstack/vue-query`) - Server state management with localStorage persistence +- **TanStack Query** (`@tanstack/react-query`) - Server state management +- **Sonner** - Toast notifications - **Socket.IO** - Real-time WebSocket updates -- **CodeMirror/Monaco** - Code editors ### Path Aliases @@ -35,9 +35,9 @@ npx tsc --noEmit # Type check (use this instead of build for validation) import { something } from '~/utils/...' // ~ maps to ./src ``` -### API Layer (`src/api/`) +### API Layer (`src/app/api/`) -API services use the custom request layer built on `ofetch`. The backend wraps array responses as `{ data: [...] }`, which is automatically unwrapped by the request layer. +React app API services use the fetch-based helpers in `src/app/api/http.ts`. When using TanStack Query, extract arrays with: ```typescript @@ -48,14 +48,6 @@ select: (res: any) => Array.isArray(res) ? res : res?.data ?? [] - `BusinessError` - Application-level errors (4xx responses) - `SystemError` - Network/server errors (5xx responses, network failures) -### State Management - -**Pinia Stores (`src/stores/`):** -- `useUIStore` - Theme mode (light/dark/system), viewport dimensions, sidebar state -- `useUserStore` - User authentication state -- `useAppStore` - Global application state -- `useCategoryStore` - Category data - ### Responsive Breakpoints (UnoCSS) - `phone:` - max-width: 768px @@ -66,7 +58,7 @@ select: (res: any) => Array.isArray(res) ? res : res?.data ?? [] ### Validation -After modifying code, run type check only (`npx tsc --noEmit`). Do not run `pnpm build` for validation. +After modifying code, run focused type checking and linting. Run production build before reporting completion for broad application changes. ### Gray Scale Colors @@ -92,8 +84,8 @@ See `docs/typography.md` for full guidelines. ## Configuration Files - `uno.config.ts` - UnoCSS configuration with custom breakpoints and theme colors -- `src/utils/color.ts` - Naive UI theme overrides (Vercel-style neutral gray palette) -- `biome.json` - Linter/formatter configuration with Vue globals +- `src/app/theme.ts` - CSS token installation for the React shell +- `src/app/` - React routes, shell, API helpers, UI primitives, and migrated views - `.env` - Local dev API endpoint (`VITE_APP_BASE_API`) ## Related Projects @@ -102,9 +94,6 @@ See `docs/typography.md` for full guidelines. - **Shiroi** — Next.js frontend (blog), located at `../Shiroi` - **haklex** — Rich editor packages (`@haklex/*`), located at `../haklex` (standalone) or `../Shiroi/haklex` (original host) -### Rich Editor Integration (React-in-Vue) +### Rich Editor Integration -admin-vue3 is a Vue 3 project but embeds the React-based haklex editor via a bridge pattern in `src/components/editor/rich/RichEditor.tsx`: -- Uses `createRoot()` from `react-dom/client` inside Vue `defineComponent` to mount the local `ShiroEditor` (`packages/rich-react/src/shiro/`) -- Local Shiro lives in `packages/rich-react/src/shiro/` — composes `@haklex/rich-editor` + per-feature `@haklex/rich-ext-*` / `@haklex/rich-renderer-*` packages directly -- All `@haklex/*` packages are pinned npm versions (not workspace links). After haklex releases, update versions here. +The admin app no longer mounts rich editor surfaces through a framework bridge. React editor work should be integrated as ordinary React components and kept out of compatibility shims. diff --git a/apps/admin/index.html b/apps/admin/index.html index d02774d1b..4ce7eb789 100644 --- a/apps/admin/index.html +++ b/apps/admin/index.html @@ -15,7 +15,7 @@ link.href = favicon document.head.appendChild(link) - Mx Space Admin Vue 3 v2 + Mx Space Admin - + diff --git a/apps/admin/package.json b/apps/admin/package.json index 9b7ff9121..7395de083 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -12,100 +12,57 @@ }, "dependencies": { "@antv/g2": "^5.4.8", + "@base-ui/react": "1.5.0", "@better-auth/passkey": "1.4.18", - "@bytebase/vue-kbar": "0.1.8", - "@codemirror/commands": "6.10.3", - "@codemirror/lang-markdown": "6.5.0", - "@codemirror/language": "6.12.3", - "@codemirror/language-data": "6.5.2", - "@codemirror/search": "6.7.0", - "@codemirror/state": "6.6.0", - "@codemirror/theme-one-dark": "6.1.3", - "@codemirror/view": "6.42.1", - "@ddietr/codemirror-themes": "1.5.2", - "@emoji-mart/data": "1.2.1", "@excalidraw/excalidraw": "^0.18.0", - "@haklex/rich-agent-chat": "0.8.0", - "@haklex/rich-agent-core": "0.8.0", - "@haklex/rich-diff": "0.8.0", - "@haklex/rich-editor": "0.8.0", - "@haklex/rich-ext-ai-agent": "0.8.0", - "@haklex/rich-ext-nested-doc": "0.8.0", - "@haklex/rich-style-token": "0.8.0", - "@lexical/code-core": "^0.44.0", - "@lexical/markdown": "^0.44.0", - "@lezer/highlight": "1.2.3", "@mx-admin/rich-react": "workspace:*", - "@pierre/diffs": "1.1.3", "@simplewebauthn/browser": "13.3.0", "@tanstack/query-async-storage-persister": "5.95.0", "@tanstack/query-persist-client-core": "5.95.0", - "@tanstack/vue-query": "5.95.0", + "@tanstack/react-query": "5.100.13", "@types/canvas-confetti": "1.9.0", - "@typescript/ata": "0.9.8", - "@vicons/utils": "0.1.4", - "@vueuse/core": "14.2.1", - "@xterm/addon-fit": "0.11.0", - "@xterm/xterm": "6.0.0", - "ansi_up": "6.0.6", "better-auth": "1.4.18", "blurhash": "2.0.5", "buffer": "6.0.3", "canvas-confetti": "1.9.4", "date-fns": "4.1.0", - "ejs": "4.0.1", - "emoji-mart": "5.6.0", + "ejs": "5.0.2", "es-toolkit": "1.45.1", "event-source-polyfill": "1.0.31", "fuse.js": "7.1.0", - "highlight.js": "11.11.1", "js-cookie": "3.0.5", "js-yaml": "4.1.1", "json5": "2.2.3", - "jsondiffpatch": "0.7.3", - "katex": "0.16.40", - "lexical": "^0.44.0", "lit": "3.3.2", "lodash.transform": "4.6.0", - "lucide-vue-next": "0.574.0", - "markdown-escape": "2.0.0", + "lucide-react": "1.8.0", "marked": "17.0.5", - "monaco-editor": "0.55.1", - "naive-ui": "2.44.1", - "octokit": "5.0.5", "ofetch": "1.5.1", - "openai": "6.32.0", "os-browserify": "0.3.0", "path-browserify": "1.0.1", - "pinia": "3.0.4", "qier-progress": "1.0.4", "qs": "6.15.0", - "shiki": "3.21.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-resizable-panels": "4.11.1", + "react-router": "7.15.1", "socket.io-client": "4.8.3", - "sortablejs": "1.15.7", - "umi-request": "1.4.0", + "sonner": "2.0.7", "validator": "13.15.26", - "vue": "3.5.30", - "vue-router": "4.6.4", - "vue-sonner": "2.0.9", "xss": "1.0.15", - "xterm-theme": "1.1.0", "zod": "4.3.6" }, "devDependencies": { - "@types/ejs": "3.1.5", "@types/event-source-polyfill": "1.0.5", "@types/js-yaml": "4.0.9", - "@types/markdown-escape": "1.1.3", "@types/qs": "6.15.0", - "@types/sortablejs": "1.15.9", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", "@types/validator": "13.15.10", "@unocss/postcss": "^66.6.8", "@unocss/preset-typography": "66.6.7", - "@vitejs/plugin-vue": "6.0.5", - "@vitejs/plugin-vue-jsx": "5.1.5", - "@vue/compiler-sfc": "3.5.30", - "@vue/test-utils": "^2.4.0", + "@vitejs/plugin-react": "6.0.2", + "code-inspector-plugin": "1.5.1", "cors": "2.8.6", "happy-dom": "^15.11.0", "postcss": "8.5.8", @@ -117,7 +74,6 @@ "vite": "8.0.1", "vite-plugin-checker": "0.12.0", "vite-plugin-mkcert": "1.17.10", - "vite-plugin-vue-inspector": "5.4.0", "vitest": "^4.1.5" } } diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 4a5f783cb..c62f1c81b 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -1,132 +1,77 @@ -import { - darkTheme, - dateZhCN, - lightTheme, - NConfigProvider, - NDialogProvider, - NElement, - useDialog, - useThemeVars, - zhCN, -} from 'naive-ui' -import { defineComponent, onMounted, provide, ref, watchEffect } from 'vue' -import { RouterView } from 'vue-router' -import { Toaster } from 'vue-sonner' -import type { VNode } from 'vue' +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { HashRouter, Navigate, useLocation } from 'react-router' -import { AiTaskQueue } from '~/components/ai-task-queue' -import { PortalInjectKey } from '~/hooks/use-portal-element' +import { checkLogged } from './app/api/auth' +import { useI18n } from './app/i18n' +import { AppProviders } from './app/providers' +import { AppRoutes } from './app/routes' +import { AdminShell } from './app/shell' +import { installThemeTokens } from './app/theme' -import { useUIStore } from './stores/ui' -import { - commonThemeVars, - componentThemeOverrides, - darkThemeColors, - lightThemeColors, -} from './utils/color' +function App() { + useEffect(() => { + document.title = 'Mx Space Admin' + installThemeTokens() + }, []) -const Root = defineComponent({ - name: 'RootView', + return ( + + + + + + ) +} - setup() { - onMounted(() => { - window.dialog = useDialog() - }) - const $portalElement = ref(null) +function AppContent() { + const location = useLocation() - provide(PortalInjectKey, { - setElement(el) { - $portalElement.value = el - return () => { - $portalElement.value = null - } - }, - }) + if ( + location.pathname === '/setup-api' || + location.pathname === '/setup' || + location.pathname === '/login' + ) { + return + } - return () => { - return ( - <> - - {$portalElement.value ?? <>} - - ) - } - }, -}) + return +} -const App = defineComponent({ - setup() { - const uiStore = useUIStore() - return () => { - const { isDark, naiveUIDark } = uiStore - const isCurrentDark = naiveUIDark || isDark +function ProtectedAdminApp() { + const location = useLocation() + const { t } = useI18n() + const loggedQuery = useQuery({ + queryFn: checkLogged, + queryKey: ['auth', 'check-logged'], + retry: false, + staleTime: 1000 * 60 * 5, + }) + const from = `${location.pathname}${location.search}` - return ( - - - - - - - - - - - ) - } - }, -}) + if (loggedQuery.isLoading) { + return ( +
+ {t('app.loading.auth')} +
+ ) + } -const AccentColorInjector = defineComponent({ - setup() { - const vars = useThemeVars() - watchEffect(() => { - const { primaryColor, primaryColorHover, primaryColorSuppl } = vars.value + if (!loggedQuery.data?.ok) { + return ( + + ) + } - document.documentElement.style.setProperty( - '--color-primary', - primaryColor, - ) - document.documentElement.style.setProperty( - '--color-primary-shallow', - primaryColorHover, - ) - document.documentElement.style.setProperty( - '--color-primary-deep', - primaryColorSuppl, - ) - }) + return ( + + + + ) +} - return () => <> - }, -}) // eslint-disable-next-line import/no-default-export export default App diff --git a/apps/admin/src/api/activity.ts b/apps/admin/src/api/activity.ts deleted file mode 100644 index 3e47f1ebb..000000000 --- a/apps/admin/src/api/activity.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { PaginateResult } from '~/models/base' -import type { NoteModel } from '~/models/note' -import type { PageModel } from '~/models/page' -import type { PostModel } from '~/models/post' -import type { RecentlyModel } from '~/models/recently' - -import { request } from '~/utils/request' - -export interface ActivityPresence { - operationTime: number - updatedAt: number - connectedAt: number - identity: string - roomName: string - position: number - sid: string - displayName?: string - ts?: number -} - -export interface ActivityItem { - id: string - created: string - payload: any - type: number -} - -export interface ActivityListResponse extends PaginateResult { - objects?: { - posts?: PostModel[] - notes?: NoteModel[] - pages?: PageModel[] - recentlies?: RecentlyModel[] - } -} - -export interface ReadingRankItem { - refId: string - count: number - ref: { - id?: string - title?: string - slug?: string - nid?: number - } -} - -export interface GetActivityParams { - page?: number - size?: number - type?: number - before?: string - after?: string -} - -export interface OnlineCountResponse { - total: number - rooms: Record -} - -export const activityApi = { - // 获取活动列表 - getList: (params?: GetActivityParams) => - request.get('/activity', { params }), - - // 获取阅读排行(轻量接口,带缓存) - getTopReadings: (params?: { top?: number; days?: number }) => - request.get('/activity/reading/top', { params }), - - // 获取阅读排行 - getReadingRank: (params?: { start?: number; end?: number; limit?: number }) => - request.get('/activity/reading/rank', { params }), - - // 获取最近动态列表 - getRecentlyList: (params?: GetActivityParams) => - request.get>('/recently/all', { params }), - - // 删除最近动态 - deleteRecently: (id: string) => request.delete(`/recently/${id}`), - - // 获取在线人数 - getOnlineCount: () => - request.get('/activity/online-count'), -} diff --git a/apps/admin/src/api/aggregate.ts b/apps/admin/src/api/aggregate.ts deleted file mode 100644 index 27854b180..000000000 --- a/apps/admin/src/api/aggregate.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { request } from '~/utils/request' - -export interface StatCount { - posts: number - notes: number - pages: number - categories: number - tags: number - comments: number - links: number - says: number - recently: number - unreadComments: number - online: number - todayMaxOnline: number - todayOnlineTotal: number - callTime: number - uv: number - todayIpAccessCount: number -} - -export interface CategoryDistribution { - id: string - name: string - slug: string - count: number -} - -export interface PublicationTrend { - date: string - posts: number - notes: number -} - -export interface TagCloudItem { - tag: string - count: number -} - -export interface TopArticle { - id: string - title: string - slug: string - reads: number - likes: number - category: { - name: string - slug: string - } | null -} - -export interface CommentActivityItem { - date: string - count: number -} - -export interface TrafficSourceData { - os: Array<{ name: string; count: number }> - browser: Array<{ name: string; count: number }> -} - -export interface WordCount { - count: number -} - -export interface ReadAndLikeCount { - totalLikes: number - totalReads: number -} - -export const aggregateApi = { - // 获取统计数据 - getStat: () => request.get('/aggregate/stat'), - - // 获取分类分布 - getCategoryDistribution: () => - request.get( - '/aggregate/stat/category-distribution', - ), - - // 获取发布趋势 - getPublicationTrend: () => - request.get('/aggregate/stat/publication-trend'), - - // 获取标签云 - getTagCloud: () => request.get('/aggregate/stat/tag-cloud'), - - // 获取热门文章 - getTopArticles: () => - request.get('/aggregate/stat/top-articles'), - - // 获取评论活动 - getCommentActivity: () => - request.get('/aggregate/stat/comment-activity'), - - // 获取流量来源 - getTrafficSource: () => - request.get('/aggregate/stat/traffic-source'), - - // 获取站点字数统计 - countSiteWords: () => request.get('/aggregate/count_site_words'), - - // 获取阅读和点赞统计 - countReadAndLike: () => - request.get('/aggregate/count_read_and_like'), - - // 获取站点点赞数 - getSiteLikeCount: () => request.get('/like_this'), - - // 清理缓存 - cleanCache: () => request.get('/clean_catch'), - - // 清理 Redis - cleanRedis: () => request.get('/clean_redis'), -} diff --git a/apps/admin/src/api/ai-agent.ts b/apps/admin/src/api/ai-agent.ts deleted file mode 100644 index b25f2332d..000000000 --- a/apps/admin/src/api/ai-agent.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { request } from '~/utils/request' - -export interface AgentConversation { - id: string - refId: string - refType: string - title?: string - model: string - providerId: string - createdAt: string - updatedAt: string - messageCount: number - messages?: Record[] - reviewState?: Record - diffState?: Record -} - -export const aiAgentApi = { - createConversation: (data: { - refId: string - refType: string - model: string - providerId: string - title?: string - messages?: Record[] - }) => request.post('/ai/agent/conversations', { data }), - - listConversations: (refId: string, refType: string) => - request.get('/ai/agent/conversations', { - params: { refId, refType }, - }), - - getConversation: (id: string) => - request.get(`/ai/agent/conversations/${id}`), - - appendMessages: (id: string, messages: Record[]) => - request.patch(`/ai/agent/conversations/${id}/messages`, { - data: { messages }, - }), - - replaceMessages: (id: string, messages: Record[]) => - request.put(`/ai/agent/conversations/${id}/messages`, { - data: { messages }, - }), - - updateConversation: ( - id: string, - data: { - title?: string - reviewState?: Record | null - diffState?: Record | null - }, - ) => - request.patch(`/ai/agent/conversations/${id}`, { data }), - - deleteConversation: (id: string) => - request.delete(`/ai/agent/conversations/${id}`), -} diff --git a/apps/admin/src/api/ai.ts b/apps/admin/src/api/ai.ts deleted file mode 100644 index a892a2b3f..000000000 --- a/apps/admin/src/api/ai.ts +++ /dev/null @@ -1,501 +0,0 @@ -import type { ContentFormat } from '~/shared/types/base' - -import { request } from '~/utils/request' - -// AI Writer 类型 -export enum AiQueryType { - TitleSlug = 'title-slug', - Slug = 'slug', -} - -export interface AIWriterGenerateData { - type: AiQueryType - text?: string // 当 type 为 title-slug 时需要 - title?: string // 当 type 为 slug 时需要 -} - -export interface AIWriterGenerateResponse { - title?: string - slug?: string -} - -// AI Summary 类型 -export interface AISummary { - id: string - createdAt: string - summary: string - hash: string - refId: string - lang: string -} - -export interface GroupedSummary { - type: string - items: AISummary[] -} - -export interface ArticleInfo { - type: 'Post' | 'Note' | 'Page' | 'Recently' - title: string - id: string -} - -export interface GroupedSummaryData { - article: ArticleInfo - summaries: AISummary[] -} - -export interface GroupedSummaryResponse { - data: GroupedSummaryData[] - pagination: { - total: number - currentPage: number - totalPage: number - size: number - hasNextPage: boolean - hasPrevPage: boolean - } -} - -export interface SummaryByRefResponse { - summaries: AISummary[] - article: { - type: 'Post' | 'Note' | 'Page' | 'Recently' - document: { title: string } - } -} - -// AI Insights 类型 -export interface AIInsights { - id: string - createdAt: string - refId: string - lang: string - hash: string - content: string - isTranslation: boolean - sourceInsightsId?: string - sourceLang?: string -} - -export interface GroupedInsightsData { - article: ArticleInfo - insights: AIInsights[] -} - -export interface GroupedInsightsResponse { - data: GroupedInsightsData[] - pagination: { - total: number - currentPage: number - totalPage: number - size: number - hasNextPage: boolean - hasPrevPage: boolean - } -} - -export interface InsightsByRefResponse { - insights: AIInsights[] - article: { - type: 'Post' | 'Note' | 'Page' | 'Recently' - document: { title: string } - } | null -} - -// AI Translation 类型 -export interface AITranslation { - id: string - createdAt: string - hash: string - refId: string - refType: string - lang: string - sourceLang: string - title: string - subtitle?: string - text: string - summary?: string - tags?: string[] - aiModel?: string - aiProvider?: string - contentFormat?: ContentFormat - content?: string -} - -export interface GroupedTranslationData { - article: ArticleInfo - translations: AITranslation[] -} - -export interface GroupedTranslationResponse { - data: GroupedTranslationData[] - pagination: { - total: number - currentPage: number - totalPage: number - size: number - hasNextPage: boolean - hasPrevPage: boolean - } -} - -export interface TranslationByRefResponse { - translations: AITranslation[] - article: { - type: 'Post' | 'Note' | 'Page' | 'Recently' - document: { title: string } - } -} - -export interface ProviderModel { - id: string - name: string -} - -export interface ProviderModelsResponse { - providerId: string - providerName: string - providerType: string - models: ProviderModel[] - error?: string -} - -export interface AITestData { - providerId: string - type: string - apiKey?: string - endpoint?: string - model?: string -} - -export interface AIModelListData { - providerId: string - type: string - apiKey?: string - endpoint?: string -} - -// AI Task 类型 -export enum AITaskType { - Summary = 'ai:summary', - Translation = 'ai:translation', - TranslationBatch = 'ai:translation:batch', - TranslationAll = 'ai:translation:all', - SlugBackfill = 'ai:slug:backfill', - Insights = 'ai:insights', - InsightsTranslation = 'ai:insights:translation', -} - -export enum AITaskStatus { - Pending = 'pending', - Running = 'running', - Completed = 'completed', - PartialFailed = 'partial_failed', - Failed = 'failed', - Cancelled = 'cancelled', -} - -export interface AITaskLog { - timestamp: number - level: 'info' | 'warn' | 'error' - message: string -} - -export interface SubTaskStats { - total: number - completed: number - failed: number - running: number - pending: number -} - -export interface AITask { - id: string - type: AITaskType - status: AITaskStatus - payload: Record - groupId?: string - - progress?: number - progressMessage?: string - totalItems?: number - completedItems?: number - tokensGenerated?: number - - createdAt: number - startedAt?: number - completedAt?: number - - result?: unknown - error?: string - logs: AITaskLog[] - - workerId?: string - retryCount: number - - // For batch tasks: sub-task statistics - subTaskStats?: SubTaskStats -} - -export interface AITasksResponse { - data: AITask[] - total: number -} - -export interface CreateTaskResponse { - taskId: string - created: boolean -} - -export interface AICommentReviewTestData { - text: string - author?: string -} - -export interface AICommentReviewTestResponse { - isSpam: boolean - score?: number - reason?: string -} - -// Translation Entry (词表) 类型 -export type TranslationEntryKeyPath = - | 'category.name' - | 'topic.name' - | 'topic.introduce' - | 'note.mood' - | 'note.weather' - -export interface TranslationEntry { - id: string - createdAt: string - keyPath: TranslationEntryKeyPath - lang: string - keyType: 'entity' | 'dict' - lookupKey: string - sourceText: string - translatedText: string - sourceUpdatedAt?: string -} - -export interface TranslationEntriesResponse { - data: TranslationEntry[] - pagination: { - total: number - page: number - size: number - } -} - -export interface GenerateEntriesResponse { - created: number - skipped: number -} - -export const aiApi = { - // AI 评论审核测试 - testCommentReview: (data: AICommentReviewTestData) => - request.post('/ai/comment-review/test', { - data, - }), - - // AI 写作生成标题/Slug - writerGenerate: (data: AIWriterGenerateData) => - request.post('/ai/writer/generate', { data }), - - // 获取摘要列表(分组) - getSummariesGrouped: (params?: { - page?: number - size?: number - search?: string - }) => - request.get('/ai/summaries/grouped', { params }), - - // 根据引用获取摘要 - getSummaryByRef: (refId: string) => - request.get(`/ai/summaries/ref/${refId}`), - - // 删除摘要 - deleteSummary: (id: string) => request.delete(`/ai/summaries/${id}`), - - // 更新摘要 - updateSummary: (id: string, data: { summary: string }) => - request.patch(`/ai/summaries/${id}`, { data }), - - // 生成摘要(创建任务) - createSummaryTask: (data: { refId: string; lang?: string }) => - request.post('/ai/summaries/task', { data }), - - // === AI Insights === - - // 获取精读列表(分组) - getInsightsGrouped: (params: { - page: number - size?: number - search?: string - }) => - request.get('/ai/insights/grouped', { params }), - - // 根据引用获取精读 - getInsightsByRef: (refId: string) => - request.get(`/ai/insights/ref/${refId}`), - - // 删除精读 - deleteInsights: (id: string) => request.delete(`/ai/insights/${id}`), - - // 更新精读 - updateInsights: (id: string, data: { content: string }) => - request.patch(`/ai/insights/${id}`, { data }), - - // 生成精读(创建任务) - createInsightsTask: (data: { refId: string }) => - request.post('/ai/insights/task', { data }), - - // 翻译精读(创建任务) - createInsightsTranslationTask: (data: { - refId: string - targetLang: string - }) => - request.post('/ai/insights/task/translate', { data }), - - // 获取可用模型列表 - getModels: () => request.get('/ai/models'), - - // 获取指定 provider 的模型列表 - getModelList: (data: AIModelListData) => - request.post<{ models: ProviderModel[]; error?: string }>( - '/ai/models/list', - { data }, - ), - - // 测试 AI 配置 - testConfig: (data: AITestData) => request.post('/ai/test', { data }), - - // === AI Translation === - - // 获取翻译列表(分组) - getTranslationsGrouped: (params?: { - page?: number - size?: number - search?: string - }) => - request.get('/ai/translations/grouped', { - params, - }), - - // 根据引用获取翻译 - getTranslationsByRef: (refId: string) => - request.get(`/ai/translations/ref/${refId}`), - - // 删除翻译 - deleteTranslation: (id: string) => - request.delete(`/ai/translations/${id}`), - - // 更新翻译 - updateTranslation: ( - id: string, - data: { - title?: string - subtitle?: string - text?: string - summary?: string - tags?: string[] - content?: string - }, - ) => request.patch(`/ai/translations/${id}`, { data }), - - // 生成翻译(创建任务) - createTranslationTask: (data: { - refId: string - targetLanguages?: string[] - }) => request.post('/ai/translations/task', { data }), - - // 批量生成翻译(创建任务) - createTranslationBatchTask: (data: { - refIds: string[] - targetLanguages?: string[] - }) => - request.post('/ai/translations/task/batch', { data }), - - // 为全部文章生成翻译(创建任务) - createTranslationAllTask: (data: { targetLanguages?: string[] }) => - request.post('/ai/translations/task/all', { data }), - - // === AI Tasks === - - // 获取任务列表 - getTasks: (params?: { - status?: AITaskStatus - type?: AITaskType - page?: number - size?: number - }) => request.get('/ai/tasks', { params }), - - // 获取单个任务 - getTask: (taskId: string) => request.get(`/ai/tasks/${taskId}`), - - // 重试任务 - retryTask: (taskId: string) => - request.post(`/ai/tasks/${taskId}/retry`), - - // 取消任务 - cancelTask: (taskId: string) => - request.post<{ success: boolean }>(`/ai/tasks/${taskId}/cancel`), - - // 删除单个任务 - deleteTask: (taskId: string) => - request.delete<{ success: boolean }>(`/ai/tasks/${taskId}`), - - // 批量删除任务 - deleteTasks: (params: { - status?: AITaskStatus - type?: AITaskType - before: number - }) => request.delete<{ deleted: number }>('/ai/tasks', { params }), - - // 获取组内所有任务(子任务) - getTasksByGroupId: (groupId: string) => - request.get(`/ai/tasks/group/${groupId}`), - - // 取消组内所有任务 - cancelTasksByGroupId: (groupId: string) => - request.delete<{ cancelled: number }>(`/ai/tasks/group/${groupId}`), - - // === Translation Entries (词表) === - - getTranslationEntries: (params?: { - keyPath?: TranslationEntryKeyPath - lang?: string - page?: number - size?: number - }) => - request.get('/ai/translations/entries', { - params, - }), - - generateTranslationEntries: (data?: { - keyPaths?: TranslationEntryKeyPath[] - targetLanguages?: string[] - }) => - request.post('/ai/translations/entries/generate', { - data, - }), - - updateTranslationEntry: (id: string, data: { translatedText: string }) => - request.patch(`/ai/translations/entries/${id}`, { data }), - - deleteTranslationEntry: (id: string) => - request.delete(`/ai/translations/entries/${id}`), - - // === Slug Backfill === - - getSlugBackfillStatus: () => - request.get<{ - count: number - notes: Array<{ id: string; title: string; nid: number }> - }>('/ai/writer/backfill-slugs/status'), - - createSlugBackfillTask: () => - request.post('/ai/writer/backfill-slugs'), -} diff --git a/apps/admin/src/api/analyze.ts b/apps/admin/src/api/analyze.ts deleted file mode 100644 index 1c04e45d7..000000000 --- a/apps/admin/src/api/analyze.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { UA } from '~/models/analyze' -import type { PaginateResult } from '~/models/base' - -import { request } from '~/utils/request' - -export type AnalyzeRecord = UA.Root & { - country?: string | null - referer?: string | null -} - -export interface IPAggregate { - today: Array<{ - hour: string - key: 'ip' | 'pv' - value: number - }> - weeks: Array<{ - day: string - key: 'ip' | 'pv' - value: number - }> - months: Array<{ - date: string - key: 'ip' | 'pv' - value: number - }> - paths: Array<{ - count: number - path: string - }> - total: { - callTime: number - uv: number - } - todayIps: string[] -} - -export interface GetAnalyzeParams { - page?: number - size?: number - from?: string - to?: string -} - -export interface TrafficSourceResponse { - categories: Array<{ name: string; value: number }> - details: Array<{ source: string; count: number }> -} - -export interface DeviceDistributionResponse { - browsers: Array<{ name: string; value: number }> - os: Array<{ name: string; value: number }> - devices: Array<{ name: string; value: number }> -} - -export const analyzeApi = { - // 获取分析列表 - getList: (params?: GetAnalyzeParams) => - request.get>('/analyze', { params }), - - // 获取聚合数据 - getAggregate: () => request.get('/analyze/aggregate'), - - // 获取流量来源 - getTrafficSource: (params?: { from?: string; to?: string }) => - request.get('/analyze/traffic-source', { params }), - - // 获取设备分布 - getDeviceDistribution: (params?: { from?: string; to?: string }) => - request.get('/analyze/device', { params }), - - // 清空分析数据 - deleteAll: () => request.delete('/analyze'), -} diff --git a/apps/admin/src/api/auth.ts b/apps/admin/src/api/auth.ts deleted file mode 100644 index f377a5fc5..000000000 --- a/apps/admin/src/api/auth.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { TokenModel } from '~/models/token' - -import { authClient } from '~/utils/authjs/auth' -import { request } from '~/utils/request' - -export interface CreateTokenData { - name: string - expired?: Date | string -} - -export interface PasskeyItem { - id: string - name?: string - credentialID: string - publicKey: string - createdAt: string -} - -export const authApi = { - // === Token 管理 === - - // 获取 Token 列表 - getTokens: () => request.get('/auth/token'), - - // 获取单个 Token - getToken: (id: string) => - request.get('/auth/token', { params: { id } }), - - // 创建 Token - createToken: (data: CreateTokenData) => - request.post('/auth/token', { data }), - - // 删除 Token - deleteToken: (id: string) => - request.delete('/auth/token', { params: { id } }), - - // === Passkey 管理(使用 Better Auth 客户端)=== - - // 获取 Passkey 列表 - getPasskeys: async () => { - const result = await authClient.passkey.listUserPasskeys() - if (result.error) { - throw new Error(result.error.message) - } - return (result.data || []).map((p: any) => ({ - id: p.id, - name: p.name, - credentialID: p.id, - publicKey: p.publicKey, - createdAt: p.createdAt, - })) - }, - - // 删除 Passkey - deletePasskey: async (id: string) => { - const result = await authClient.passkey.deletePasskey({ id }) - if (result.error) { - throw new Error(result.error.message) - } - }, - - // === 第三方认证 === - - // 获取 Session - getSession: () => request.get('/auth/session'), - - // 作为 Owner 认证 - authAsOwner: () => request.patch('/auth/as-owner'), -} diff --git a/apps/admin/src/api/backup.ts b/apps/admin/src/api/backup.ts deleted file mode 100644 index 6fa1c2872..000000000 --- a/apps/admin/src/api/backup.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { $api, request } from '~/utils/request' - -export interface BackupFile { - filename: string - size: string - createdAt: string -} - -export const backupApi = { - // 获取备份列表(响应会被自动解包) - getList: () => request.get('/backups'), - - // 创建新备份 - createNew: () => - $api('/backups/new', { - method: 'GET', - responseType: 'blob', - }) as Promise, - - // 下载备份文件 - download: (filename: string) => - $api(`/backups/${filename}`, { - method: 'GET', - responseType: 'blob', - }) as Promise, - - // 删除备份 - delete: (filename: string) => - request.delete(`/backups/${encodeURIComponent(filename)}`), - - // 从备份恢复 - rollback: (filename: string) => - request.patch(`/backups/rollback/${filename}`), - - // 上传备份文件并恢复 - uploadAndRestore: (file: File) => { - const formData = new FormData() - formData.append('file', file) - return request.post('/backups/rollback', { - data: formData, - }) - }, -} diff --git a/apps/admin/src/api/categories.ts b/apps/admin/src/api/categories.ts deleted file mode 100644 index b309a6050..000000000 --- a/apps/admin/src/api/categories.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { CategoryModel, TagModel } from '~/models/category' -import type { PostModel } from '~/models/post' - -import { request } from '~/utils/request' - -export interface GetCategoriesParams { - type?: 'Category' | 'Tag' | 'tag' -} - -export interface CreateCategoryData { - name: string - slug: string - type?: number -} - -export interface UpdateCategoryData extends Partial {} - -export const categoriesApi = { - // 获取分类列表(响应会被自动解包) - getList: (params?: GetCategoriesParams) => - request.get('/categories', { params }), - - // 获取单个分类(响应会被自动解包) - getById: (id: string) => request.get(`/categories/${id}`), - - // 创建分类(响应会被自动解包) - create: (data: CreateCategoryData) => - request.post('/categories', { data }), - - // 更新分类 - update: (id: string, data: UpdateCategoryData) => - request.put(`/categories/${id}`, { data }), - - // 删除分类 - delete: (id: string) => request.delete(`/categories/${id}`), - - // 获取标签列表(响应会被自动解包) - getTags: () => - request.get('/categories', { params: { type: 'tag' } }), - - // 获取标签关联的文章(响应会被自动解包) - getPostsByTag: (tagName: string) => - request.get(`/categories/${tagName}`, { - params: { tag: 'true' }, - }), -} diff --git a/apps/admin/src/api/comments.ts b/apps/admin/src/api/comments.ts deleted file mode 100644 index 7b15c2775..000000000 --- a/apps/admin/src/api/comments.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CommentModel, CommentsResponse } from '~/models/comment' - -import { request } from '~/utils/request' - -export interface GetCommentsParams { - page?: number - size?: number - state?: number -} - -export interface ReplyCommentData { - text: string - author: string - mail: string - source?: string -} - -export const commentsApi = { - // 获取评论列表 - getList: (params?: GetCommentsParams) => - request.get('/comments', { params }), - - // 获取单个评论 - getById: (id: string) => request.get(`/comments/${id}`), - - // 回复评论(普通) - reply: (id: string, data: ReplyCommentData) => - request.post(`/comments/reader/reply/${id}`, { data }), - - // 登录态回复评论(只需 text) - readerReply: (id: string, text: string) => - request.post(`/comments/reader/reply/${id}`, { - data: { text }, - }), - - // 更新评论状态 - updateState: (id: string, state: number) => - request.patch(`/comments/${id}`, { data: { state } }), - - // 批量更新状态 - batchUpdateState: ( - options: - | { ids: string[]; state: number } - | { all: true; state: number; currentState: number }, - ) => request.patch('/comments/batch/state', { data: options }), - - // 删除评论 - delete: (id: string) => request.delete(`/comments/${id}`), - - // 批量删除 - batchDelete: (options: { ids: string[] } | { all: true; state: number }) => - request.delete('/comments/batch', { data: options }), -} diff --git a/apps/admin/src/api/cron-task.ts b/apps/admin/src/api/cron-task.ts deleted file mode 100644 index 53c03c7b1..000000000 --- a/apps/admin/src/api/cron-task.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { request } from '~/utils/request' - -export enum CronTaskType { - CleanAccessRecord = 'cron:clean-access-record', - ResetIPAccess = 'cron:reset-ip-access', - ResetLikedOrReadArticleRecord = 'cron:reset-liked-or-read', - CleanTempDirectory = 'cron:clean-temp-directory', - PushToBaiduSearch = 'cron:push-to-baidu-search', - PushToBingSearch = 'cron:push-to-bing-search', - DeleteExpiredJWT = 'cron:delete-expired-jwt', - RebuildSearchIndex = 'cron:rebuild-search-index', - CleanCommentUploads = 'cron:clean-comment-uploads', -} - -export enum CronTaskStatus { - Pending = 'pending', - Running = 'running', - Completed = 'completed', - PartialFailed = 'partial_failed', - Failed = 'failed', - Cancelled = 'cancelled', -} - -export interface CronTaskDefinition { - type: CronTaskType - name: string - description: string - cronExpression: string - lastDate?: string | null - nextDate?: string | null -} - -export interface CronTaskLog { - timestamp: number - level: 'info' | 'warn' | 'error' - message: string -} - -export interface CronTask { - id: string - type: CronTaskType - status: CronTaskStatus - payload: Record - - progress?: number - progressMessage?: string - - createdAt: number - startedAt?: number - completedAt?: number - - result?: unknown - error?: string - logs: CronTaskLog[] - - workerId?: string - retryCount: number -} - -export interface CronTasksResponse { - data: CronTask[] - total: number -} - -export interface CreateTaskResponse { - taskId: string - created: boolean -} - -export const cronTaskApi = { - getDefinitions: () => request.get('/cron-task'), - - getTasks: (params?: { - status?: CronTaskStatus - type?: CronTaskType - page?: number - size?: number - }) => request.get('/cron-task/tasks', { params }), - - getTask: (taskId: string) => - request.get(`/cron-task/tasks/${taskId}`), - - runTask: (type: CronTaskType) => - request.post(`/cron-task/run/${type}`), - - cancelTask: (taskId: string) => - request.post<{ success: boolean }>(`/cron-task/tasks/${taskId}/cancel`), - - retryTask: (taskId: string) => - request.post(`/cron-task/tasks/${taskId}/retry`), - - deleteTask: (taskId: string) => - request.delete<{ success: boolean }>(`/cron-task/tasks/${taskId}`), - - deleteTasks: (params: { - status?: CronTaskStatus - type?: CronTaskType - before: number - }) => request.delete<{ deleted: number }>('/cron-task/tasks', { params }), -} diff --git a/apps/admin/src/api/debug.ts b/apps/admin/src/api/debug.ts deleted file mode 100644 index 9cbcf402a..000000000 --- a/apps/admin/src/api/debug.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { request } from '~/utils/request' - -export interface ServerlessFunctionData { - function: string -} - -export const debugApi = { - // 执行 Serverless 函数 - executeFunction: (data: ServerlessFunctionData) => - request.post('/debug/function', { data }), -} diff --git a/apps/admin/src/api/dependencies.ts b/apps/admin/src/api/dependencies.ts deleted file mode 100644 index d5df78c94..000000000 --- a/apps/admin/src/api/dependencies.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { request } from '~/utils/request' - -export interface DependencyGraph { - dependencies: Record -} - -export const dependenciesApi = { - // 获取依赖图 - getGraph: () => request.get('/dependencies/graph'), -} diff --git a/apps/admin/src/api/drafts.ts b/apps/admin/src/api/drafts.ts deleted file mode 100644 index c8a26ab4f..000000000 --- a/apps/admin/src/api/drafts.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Image, PaginateResult } from '~/models/base' -import type { - DraftHistoryListItem, - DraftModel, - DraftRefType, - TypeSpecificData, -} from '~/models/draft' - -import { request } from '~/utils/request' - -export type DraftSortOrder = 'asc' | 'desc' - -export interface GetDraftsParams { - page?: number - size?: number - refType?: DraftRefType - hasRef?: boolean - sort_by?: string - sort_order?: DraftSortOrder -} - -export interface CreateDraftData { - refType: DraftRefType - refId?: string - title?: string - text?: string - contentFormat?: 'markdown' | 'lexical' - content?: string - images?: Image[] - meta?: Record - typeSpecificData?: TypeSpecificData -} - -export interface UpdateDraftData extends Partial {} - -export const draftsApi = { - // 获取草稿列表 - getList: (params?: GetDraftsParams) => - request.get>('/drafts', { params }), - - // 获取单个草稿 - getById: (id: string) => request.get(`/drafts/${id}`), - - // 根据引用获取草稿 - getByRef: (refType: DraftRefType, refId: string) => - request.get(`/drafts/by-ref/${refType}/${refId}`), - - // 获取新草稿列表(无关联的草稿) - getNewDrafts: (refType: DraftRefType) => - request.get(`/drafts/by-ref/${refType}/new`), - - // 获取历史版本列表 - getHistory: (id: string) => - request.get(`/drafts/${id}/history`), - - // 获取特定历史版本 - getHistoryVersion: (id: string, version: number) => - request.get(`/drafts/${id}/history/${version}`), - - // 创建草稿 - create: (data: CreateDraftData) => - request.post('/drafts', { data }), - - // 更新草稿 - update: (id: string, data: UpdateDraftData) => - request.put(`/drafts/${id}`, { data }), - - // 删除草稿 - delete: (id: string) => request.delete<{ success: boolean }>(`/drafts/${id}`), - - // 恢复到特定版本 - restoreVersion: (id: string, version: number) => - request.post(`/drafts/${id}/restore/${version}`), -} diff --git a/apps/admin/src/api/enrichment.ts b/apps/admin/src/api/enrichment.ts deleted file mode 100644 index 744f4dac4..000000000 --- a/apps/admin/src/api/enrichment.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { - EnrichmentCaptureListResponse, - EnrichmentCaptureQuota, - EnrichmentImage, - EnrichmentListResponse, - EnrichmentProbeResult, - EnrichmentProviderMeta, - EnrichmentResult, - EnrichmentRowDetail, -} from '~/models/enrichment' - -import { request } from '~/utils/request' - -const encodeId = (id: string) => encodeURIComponent(id) - -export const enrichmentApi = { - resolve: (url: string, lang?: string) => - request.get('/enrichment/resolve', { - params: { url, ...(lang ? { lang } : {}) }, - }), - - list: ( - params: { - page?: number - size?: number - onlyFailed?: boolean - locale?: string - } = {}, - ) => - request.get('/enrichment/admin/list', { - params: { - ...params, - ...(params.onlyFailed ? { onlyFailed: true } : {}), - ...(params.locale !== undefined ? { locale: params.locale } : {}), - }, - }), - - providers: () => - request.get('/enrichment/admin/providers'), - - /** - * Refresh a single cache row. Pass `locale` (the row's locale, or empty for - * the default row) so the right per-locale row is updated. Omit (or pass - * empty string) to refresh the default (`''`) row. - */ - refresh: (provider: string, externalId: string, locale?: string) => - request.post( - `/enrichment/admin/refresh/${encodeURIComponent(provider)}/${encodeId(externalId)}`, - { - params: locale ? { lang: locale } : undefined, - }, - ), - - /** - * Drop cache for a (provider, externalId). Without `locale`, every locale - * variant of the resource is purged — admin "clear cache" semantics. - */ - invalidate: (provider: string, externalId: string, locale?: string) => - request.delete( - `/enrichment/admin/cache/${encodeURIComponent(provider)}/${encodeId(externalId)}`, - { - params: locale !== undefined ? { lang: locale } : undefined, - }, - ), - - byId: (id: string) => - request.get(`/enrichment/admin/by-id/${encodeId(id)}`), - - captures: { - list: ( - params: { - page?: number - size?: number - sort?: 'last_accessed' | 'created' | 'bytes' - order?: 'asc' | 'desc' - } = {}, - ) => { - const { sort = 'last_accessed', order = 'desc', ...rest } = params - return request.get( - '/enrichment/admin/captures', - { - params: { - ...rest, - sort, - order, - }, - }, - ) - }, - - quota: () => - request.get('/enrichment/admin/captures/quota'), - - delete: (enrichmentId: string) => - request.delete( - `/enrichment/admin/captures/${encodeId(enrichmentId)}`, - ), - - recapture: (enrichmentId: string) => - request.post( - `/enrichment/admin/captures/${encodeId(enrichmentId)}/recapture`, - ), - }, - - probe: (url: string, useCache?: boolean) => - request.post('/enrichment/admin/probe', { - data: { url, ...(useCache !== undefined ? { useCache } : {}) }, - }), -} diff --git a/apps/admin/src/api/files.ts b/apps/admin/src/api/files.ts deleted file mode 100644 index 561f7833b..000000000 --- a/apps/admin/src/api/files.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { request } from '~/utils/request' - -export interface FileItem { - name: string - url: string - created?: number -} - -export interface UploadResponse { - url: string - name: string -} - -export interface OrphanFile { - id: string - fileName: string - fileUrl: string - status?: 'pending' | 'active' | 'detached' - uploadedBy?: string | null - readerId?: string | null - mimeType?: string | null - byteSize?: number | null - refType?: string | null - refId?: string | null - detachedAt?: string | null - createdAt: string -} - -export interface OrphanListResponse { - data: OrphanFile[] - pagination: { - currentPage: number - totalPage: number - size: number - total: number - hasNextPage: boolean - hasPrevPage: boolean - } -} - -export interface CleanupResult { - deletedCount: number - totalOrphan: number -} - -export interface ImageStorageOptions { - enable?: boolean - endpoint?: string - bucket?: string - region?: string - customDomain?: string - prefix?: string -} - -export const filesApi = { - // 按类型获取文件列表 - getByType: (type: string) => request.get(`/files/${type}`), - - // 上传文件 - upload: (file: File, type?: string) => { - const formData = new FormData() - formData.append('file', file) - return request.post('/files/upload', { - data: formData, - params: type ? { type } : undefined, - }) - }, - - // 更新已有文件(覆盖内容,保持文件名不变) - update: (type: string, name: string, file: File) => { - const formData = new FormData() - formData.append('file', file) - return request.put(`/files/${type}/${name}`, { - data: formData, - }) - }, - - // 按类型和名称删除文件 - deleteByTypeAndName: (type: string, name: string) => - request.delete(`/files/${type}/${name}`), - - // 重命名文件 - rename: (type: string, name: string, newName: string) => - request.patch(`/files/${type}/${name}/rename`, { - data: { name: newName }, - }), - - // 孤儿图片相关 - orphans: { - // 获取孤儿文件列表 - list: (page = 1, size = 24) => - request.get('/files/orphans/list', { - query: { page, size }, - }), - - // 获取孤儿文件数量 - count: () => request.get<{ count: number }>('/files/orphans/count'), - - // 清理孤儿文件 - cleanup: (maxAgeMinutes = 60) => - request.post('/files/orphans/cleanup', { - query: { maxAgeMinutes }, - }), - - // 批量删除孤儿文件 - batchDelete: (options: { ids: string[] } | { all: true }) => - request.delete<{ deletedCount: number }>('/files/orphans/batch', { - data: options, - }), - }, - - // 评论图片管理(reader uploads) - commentUploads: { - list: (params: { - page?: number - size?: number - status?: 'pending' | 'active' | 'detached' - readerId?: string - refId?: string - }) => - request.get('/files/comment-uploads/list', { - query: params, - }), - - delete: (id: string) => - request.delete<{ storageRemoved: boolean }>( - `/files/comment-uploads/${id}`, - ), - }, -} - -export interface CommentUploadFile { - id: string - fileName: string - fileUrl: string - status: 'pending' | 'active' | 'detached' - readerId?: string - mimeType?: string - byteSize?: number - refType?: string - refId?: string - detachedAt?: string - createdAt: string -} - -export interface CommentUploadListResponse { - data: CommentUploadFile[] - pagination: { - currentPage: number - totalPage: number - size: number - total: number - hasNextPage: boolean - hasPrevPage: boolean - } -} diff --git a/apps/admin/src/api/health.ts b/apps/admin/src/api/health.ts deleted file mode 100644 index 40c84dfc4..000000000 --- a/apps/admin/src/api/health.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { request } from '~/utils/request' - -export const healthApi = { - // 发送测试邮件 - sendTestEmail: () => - request.get<{ message?: string; trace?: string }>('/health/email/test'), -} diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts deleted file mode 100644 index d82eda363..000000000 --- a/apps/admin/src/api/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -// API 服务层统一导出 -export * from './activity' -export * from './aggregate' -export * from './ai' -export * from './analyze' -export * from './auth' -export * from './backup' -export * from './categories' -export * from './comments' -export * from './debug' -export * from './dependencies' -export * from './drafts' -export * from './files' -export * from './links' -export * from './markdown' -export * from './meta-presets' -export * from './notes' -export * from './options' -export * from './pages' -export * from './posts' -export * from './projects' -export * from './readers' -export * from './recently' -export * from './says' -export * from './search-index' -export * from './serverless' -export * from './snippets' -export * from './system' -export * from './topics' -export * from './user' diff --git a/apps/admin/src/api/links.ts b/apps/admin/src/api/links.ts deleted file mode 100644 index 89c271f97..000000000 --- a/apps/admin/src/api/links.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { LinkModel, LinkResponse, LinkStateCount } from '~/models/link' - -import { request } from '~/utils/request' - -export interface GetLinksParams { - page?: number - size?: number - state?: number -} - -export interface CreateLinkData { - name: string - url: string - avatar?: string - description?: string - type?: number - state?: number -} - -export interface UpdateLinkData extends Partial {} - -export const linksApi = { - // 获取友链列表 - getList: (params?: GetLinksParams) => - request.get('/links', { params }), - - // 获取状态计数 - getStateCount: () => request.get('/links/state'), - - // 获取单个友链 - getById: (id: string) => request.get(`/links/${id}`), - - // 创建友链 - create: (data: CreateLinkData) => request.post('/links', { data }), - - // 更新友链 - update: (id: string, data: UpdateLinkData) => - request.put(`/links/${id}`, { data }), - - // 删除友链 - delete: (id: string) => request.delete(`/links/${id}`), - - // 更新友链状态 - updateState: (id: string, state: number) => - request.patch(`/links/${id}`, { data: { state } }), - - // 检查友链健康状态 - checkHealth: (options?: { timeout?: number }) => - request.get< - Record - >('/links/health', { timeout: options?.timeout }), - - // 审核通过友链 - auditPass: (id: string) => request.patch(`/links/audit/${id}`), - - // 审核友链并发送理由 - auditWithReason: (id: string, state: number, reason: string) => - request.post(`/links/audit/reason/${id}`, { - data: { state, reason }, - }), - - // 迁移头像 - migrateAvatars: (options?: { timeout?: number }) => - request.post('/links/avatar/migrate', { timeout: options?.timeout }), -} diff --git a/apps/admin/src/api/markdown.ts b/apps/admin/src/api/markdown.ts deleted file mode 100644 index 3c3d17030..000000000 --- a/apps/admin/src/api/markdown.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { request } from '~/utils/request' - -export interface MarkdownImportData { - content?: string - type?: 'post' | 'note' | 'page' - data?: any[] -} - -export interface MarkdownExportParams { - type?: 'post' | 'note' | 'page' - id?: string - slug?: boolean - yaml?: boolean - show_title?: boolean - with_meta_json?: boolean -} - -export const markdownApi = { - // 导入 Markdown - import: (data: MarkdownImportData) => - request.post<{ id: string }>('/markdown/import', { data }), - - // 导出 Markdown - export: async (params?: MarkdownExportParams): Promise => { - // Use $api directly for blob responses - const { $api } = await import('~/utils/request') - return $api('/markdown/export', { - params, - responseType: 'blob' as any, - }) as Promise - }, -} diff --git a/apps/admin/src/api/meta-presets.ts b/apps/admin/src/api/meta-presets.ts deleted file mode 100644 index 7e17b48cf..000000000 --- a/apps/admin/src/api/meta-presets.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { - CreateMetaPresetDto, - MetaPresetField, - MetaPresetScope, - UpdateMetaPresetDto, -} from '~/models/meta-preset' - -import { request } from '~/utils/request' - -export interface MetaPresetQueryParams { - scope?: MetaPresetScope - enabledOnly?: boolean -} - -export const metaPresetsApi = { - /** - * 获取所有预设字段 - */ - getAll: (params?: MetaPresetQueryParams) => - request.get('/meta-presets', { params }), - - /** - * 获取单个预设字段 - */ - getById: (id: string) => request.get(`/meta-presets/${id}`), - - /** - * 创建自定义预设字段 - */ - create: (data: CreateMetaPresetDto) => - request.post('/meta-presets', { data }), - - /** - * 更新预设字段 - */ - update: (id: string, data: UpdateMetaPresetDto) => - request.patch(`/meta-presets/${id}`, { data }), - - /** - * 删除预设字段 - */ - delete: (id: string) => request.delete(`/meta-presets/${id}`), - - /** - * 批量更新排序 - */ - updateOrder: (ids: string[]) => - request.put('/meta-presets/order', { data: { ids } }), -} diff --git a/apps/admin/src/api/notes.ts b/apps/admin/src/api/notes.ts deleted file mode 100644 index b0b615b34..000000000 --- a/apps/admin/src/api/notes.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { PaginateResult } from '~/models/base' -import type { NoteModel } from '~/models/note' - -import { request } from '~/utils/request' - -export type NoteSortKey = - | 'title' - | 'createdAt' - | 'modifiedAt' - | 'weather' - | 'mood' -export type SortOrder = 'asc' | 'desc' - -export interface GetNotesParams { - page?: number - size?: number - sort_by?: NoteSortKey - sort_order?: SortOrder - /** - * @deprecated backend dropped db_query in v12.10.x pager refactor; param is silently ignored - */ - db_query?: Record -} - -export interface CreateNoteData { - title: string - text: string - slug?: string - mood?: string - weather?: string - password?: string | null - publicAt?: Date | null - bookmark?: boolean - location?: string | null - coordinates?: { longitude: number; latitude: number } | null - topicId?: string | null - isPublished?: boolean - meta?: Record - /** 关联的草稿 ID,发布时传递以标记草稿为已发布 */ - draftId?: string -} - -export interface UpdateNoteData extends Partial {} - -// 用于 patch 操作的数据类型,允许将某些字段设为 null -export interface PatchNoteData { - topicId?: string | null - slug?: string | null - [key: string]: unknown -} - -export const notesApi = { - // 获取日记列表 - getList: (params?: GetNotesParams) => - request.get>('/notes', { params }), - - // 获取单篇日记 - getById: (id: string, params?: { single?: boolean }) => - request.get(`/notes/${id}`, { params }), - - // 创建日记 - create: (data: CreateNoteData) => request.post('/notes', { data }), - - // 更新日记 - update: (id: string, data: UpdateNoteData) => - request.put(`/notes/${id}`, { data }), - - // 删除日记 - delete: (id: string) => request.delete(`/notes/${id}`), - - // 更新部分字段 - patch: (id: string, data: PatchNoteData) => - request.patch(`/notes/${id}`, { data }), - - // 更新发布状态 - patchPublish: (id: string, isPublished: boolean) => - request.patch(`/notes/${id}/publish`, { data: { isPublished } }), - - // 获取专栏下的日记列表 - getByTopic: (topicId: string, params?: { page?: number; size?: number }) => - request.get>>( - `/notes/topics/${topicId}`, - { params }, - ), -} diff --git a/apps/admin/src/api/options.ts b/apps/admin/src/api/options.ts deleted file mode 100644 index bf5c83ca1..000000000 --- a/apps/admin/src/api/options.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { FormDSL } from '~/components/config-form/types' - -import { request } from '~/utils/request' - -export interface SystemOptions { - [key: string]: any -} - -export interface EmailTemplateResponse { - template: string - props: any -} - -export const optionsApi = { - // 获取所有配置 - getAll: () => request.get('/options'), - - // 获取指定配置(后端直接返回配置对象) - get: (key: string) => request.get(`/options/${key}`), - - // 获取 URL 配置 - getUrl: () => - request.get<{ - webUrl: string - adminUrl: string - serverUrl: string - wsUrl: string - }>('/options/url'), - - // 更新指定配置 - patch: (key: string, data: any) => - request.patch(`/options/${key}`, { data }), - - // 获取表单 DSL Schema - getFormSchema: () => request.get('/config/form-schema'), - - // 获取邮件模板 - getEmailTemplate: (params: { type: string }) => - request.get('/options/email/template', { - params, - bypassTransform: true, - }), - - // 更新邮件模板 - updateEmailTemplate: (params: { type: string }, data: { source: string }) => - request.put('/options/email/template', { params, data }), - - // 删除邮件模板 - deleteEmailTemplate: (params: { type: string }) => - request.delete('/options/email/template', { params }), -} diff --git a/apps/admin/src/api/pages.ts b/apps/admin/src/api/pages.ts deleted file mode 100644 index 014cf0dad..000000000 --- a/apps/admin/src/api/pages.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { PaginateResult } from '~/models/base' -import type { PageModel } from '~/models/page' - -import { request } from '~/utils/request' - -export interface GetPagesParams { - page?: number - size?: number -} - -export interface CreatePageData { - title: string - text: string - slug: string - subtitle?: string - order?: number - meta?: Record - /** 关联的草稿 ID,发布时传递以标记草稿为已发布 */ - draftId?: string -} - -export interface UpdatePageData extends Partial {} - -export const pagesApi = { - // 获取页面列表 - getList: (params?: GetPagesParams) => - request.get>('/pages', { params }), - - // 获取单个页面 - getById: (id: string) => request.get(`/pages/${id}`), - - // 创建页面 - create: (data: CreatePageData) => request.post('/pages', { data }), - - // 更新页面 - update: (id: string, data: UpdatePageData) => - request.put(`/pages/${id}`, { data }), - - // 删除页面 - delete: (id: string) => request.delete(`/pages/${id}`), - - // 重新排序 - reorder: (seq: Array<{ id: string; order: number }>) => - request.patch('/pages/reorder', { data: { seq } }), -} diff --git a/apps/admin/src/api/posts.ts b/apps/admin/src/api/posts.ts deleted file mode 100644 index 4b80fa427..000000000 --- a/apps/admin/src/api/posts.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { PaginateResult } from '~/models/base' -import type { PostModel } from '~/models/post' - -import { request } from '~/utils/request' - -export type PostSortKey = 'createdAt' | 'modifiedAt' | 'pinAt' -export type SortOrder = 'asc' | 'desc' - -export interface GetPostsParams { - page?: number - size?: number - sort_by?: PostSortKey - sort_order?: SortOrder - categoryIds?: string[] -} - -export interface CreatePostData { - title: string - text: string - categoryId: string - slug?: string - tags?: string[] - summary?: string | null - copyright?: boolean - isPublished?: boolean - pin?: string | null - pinOrder?: number - relatedId?: string[] - meta?: Record - /** 关联的草稿 ID,发布时传递以标记草稿为已发布 */ - draftId?: string -} - -export interface UpdatePostData extends Partial {} - -export const postsApi = { - // 获取文章列表 - getList: (params?: GetPostsParams) => - request.get>('/posts', { params }), - - // 获取单篇文章 - getById: (id: string) => request.get(`/posts/${id}`), - - // 创建文章 - create: (data: CreatePostData) => request.post('/posts', { data }), - - // 更新文章 - update: (id: string, data: UpdatePostData) => - request.put(`/posts/${id}`, { data }), - - // 删除文章 - delete: (id: string) => request.delete(`/posts/${id}`), - - // 更新发布状态 - patch: (id: string, data: Partial) => - request.patch(`/posts/${id}`, { data }), -} diff --git a/apps/admin/src/api/projects.ts b/apps/admin/src/api/projects.ts deleted file mode 100644 index 33afa9aae..000000000 --- a/apps/admin/src/api/projects.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ProjectModel, ProjectResponse } from '~/models/project' - -import { request } from '~/utils/request' - -export interface GetProjectsParams { - page?: number - size?: number -} - -export interface CreateProjectData { - name: string - description: string - text: string - previewUrl?: string - docUrl?: string - projectUrl?: string - images?: string[] - avatar?: string -} - -export interface UpdateProjectData extends Partial {} - -export const projectsApi = { - // 获取项目列表 - getList: (params?: GetProjectsParams) => - request.get('/projects', { params }), - - // 获取单个项目 - getById: (id: string) => request.get(`/projects/${id}`), - - // 创建项目 - create: (data: CreateProjectData) => - request.post('/projects', { data }), - - // 更新项目 - update: (id: string, data: UpdateProjectData) => - request.put(`/projects/${id}`, { data }), - - // 删除项目 - delete: (id: string) => request.delete(`/projects/${id}`), -} diff --git a/apps/admin/src/api/pty.ts b/apps/admin/src/api/pty.ts deleted file mode 100644 index 77b6dff4e..000000000 --- a/apps/admin/src/api/pty.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { request } from '~/utils/request' - -export interface PTYRecord { - id: string - command: string - output: string - exitCode?: number - created: string - duration?: number -} - -export const ptyApi = { - // 获取 PTY 记录列表 - getRecords: () => request.get('/pty/record'), - - // 获取单个 PTY 记录 - getRecord: (id: string) => request.get(`/pty/record/${id}`), - - // 删除 PTY 记录 - deleteRecord: (id: string) => request.delete(`/pty/record/${id}`), - - // 清空所有记录 - clearRecords: () => request.delete('/pty/record'), -} diff --git a/apps/admin/src/api/readers.ts b/apps/admin/src/api/readers.ts deleted file mode 100644 index b145fe6f9..000000000 --- a/apps/admin/src/api/readers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { PaginateResult } from '~/models/base' - -import { request } from '~/utils/request' - -export interface ReaderModel { - id: string - provider?: string - type?: string - name: string - email: string - image: string - handle?: string - role: 'reader' | 'owner' -} - -export interface GetReadersParams { - page?: number - size?: number -} - -export const readersApi = { - // 获取读者列表 - getList: (params?: GetReadersParams) => - request.get>('/readers', { params }), -} diff --git a/apps/admin/src/api/recently.ts b/apps/admin/src/api/recently.ts deleted file mode 100644 index 8f06895c7..000000000 --- a/apps/admin/src/api/recently.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { RecentlyModel } from '~/models/recently' - -import { request } from '~/utils/request' - -export interface RecentlyCreatePayload { - content: string -} - -export type RecentlyUpdatePayload = RecentlyCreatePayload - -export const recentlyApi = { - // 获取最近访问列表 - getAll: () => request.get('/recently/all'), - - // 创建速记 - create: (data: RecentlyCreatePayload) => - request.post('/recently', { data }), - - // 更新速记 - update: (id: string, data: RecentlyUpdatePayload) => - request.put(`/recently/${id}`, { data }), - - // 删除最近访问项 - delete: (id: string) => request.delete(`/recently/${id}`), - - // 清空最近访问 - clear: () => request.delete('/recently/all'), -} diff --git a/apps/admin/src/api/says.ts b/apps/admin/src/api/says.ts deleted file mode 100644 index b6ccd1714..000000000 --- a/apps/admin/src/api/says.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SayModel, SayResponse } from '~/models/say' - -import { request } from '~/utils/request' - -export interface GetSaysParams { - page?: number - size?: number -} - -export interface CreateSayData { - text: string - source?: string - author?: string -} - -export interface UpdateSayData extends Partial {} - -export const saysApi = { - // 获取一言列表 - getList: (params?: GetSaysParams) => - request.get('/says', { params }), - - // 获取单个一言 - getById: (id: string) => request.get(`/says/${id}`), - - // 创建一言 - create: (data: CreateSayData) => request.post('/says', { data }), - - // 更新一言 - update: (id: string, data: UpdateSayData) => - request.put(`/says/${id}`, { data }), - - // 删除一言 - delete: (id: string) => request.delete(`/says/${id}`), -} diff --git a/apps/admin/src/api/search-index.ts b/apps/admin/src/api/search-index.ts deleted file mode 100644 index e6235ce0b..000000000 --- a/apps/admin/src/api/search-index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { - SearchDocumentAdminListParams, - SearchDocumentAdminListResponse, - SearchIndexRebuildOneResult, - SearchIndexRebuildResult, -} from '~/models/search-index' - -import { request } from '~/utils/request' - -const encode = (v: string) => encodeURIComponent(v) - -export const searchIndexApi = { - /** Trigger a global rebuild. `force=true` clears the table before rebuilding. */ - rebuildAll: (force = false) => - request.post('/search/rebuild', { - params: force ? { force: true } : undefined, - }), - - /** Rebuild index rows for a single (refType, refId). */ - rebuildOne: (refType: string, refId: string) => - request.post( - `/search/rebuild/${encode(refType)}/${encode(refId)}`, - ), - - /** Paginated listing of admin index rows. */ - listDocuments: (params: SearchDocumentAdminListParams = {}) => { - const query: Record = {} - if (params.refType) query.refType = params.refType - if (params.lang) query.lang = params.lang - if (params.keyword) query.keyword = params.keyword - if (params.page) query.page = params.page - if (params.size) query.size = params.size - return request.get( - '/search/admin/documents', - { params: query }, - ) - }, -} diff --git a/apps/admin/src/api/search.ts b/apps/admin/src/api/search.ts deleted file mode 100644 index 3b035db91..000000000 --- a/apps/admin/src/api/search.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { PaginateResult } from '~/models/base' -import type { NoteModel } from '~/models/note' -import type { PostModel } from '~/models/post' - -import { request } from '~/utils/request' - -export interface SearchParams { - keyword: string - page?: number - size?: number -} - -export const searchApi = { - // 搜索博文 - searchPosts: (params: SearchParams) => - request.get>('/search/post', { params }), - - // 搜索手记 - searchNotes: (params: SearchParams) => - request.get>('/search/note', { params }), -} diff --git a/apps/admin/src/api/serverless.ts b/apps/admin/src/api/serverless.ts deleted file mode 100644 index f307ee24c..000000000 --- a/apps/admin/src/api/serverless.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { request } from '~/utils/request' - -export interface ServerlessLogEntry { - id: string - functionId: string - reference: string - name: string - method: string - ip: string - status: 'success' | 'error' - executionTime: number - createdAt: string - logs?: { level: string; timestamp: number; args: unknown[] }[] - error?: { name: string; message: string; stack?: string } -} - -export interface ServerlessLogPagination { - total: number - size: number - currentPage: number - totalPage: number - hasNextPage: boolean - hasPrevPage: boolean -} - -export interface ServerlessLogListResponse { - data: ServerlessLogEntry[] - pagination: ServerlessLogPagination -} - -export interface GetServerlessLogsParams { - page?: number - size?: number - status?: 'success' | 'error' -} - -export const serverlessApi = { - getInvocationLogs: (id: string, params?: GetServerlessLogsParams) => - request.get(`/fn/logs/${id}`, { - params, - }), - - getInvocationLogDetail: (id: string) => - request.get(`/fn/log/${id}`), - - getCompiledCode: (id: string) => request.get(`/fn/compiled/${id}`), -} diff --git a/apps/admin/src/api/snippets.ts b/apps/admin/src/api/snippets.ts deleted file mode 100644 index 2e9796083..000000000 --- a/apps/admin/src/api/snippets.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { PaginateResult } from '~/models/base' -import type { SnippetModel, SnippetType } from '~/models/snippet' - -import { request } from '~/utils/request' - -export interface GetSnippetsParams { - page?: number - size?: number - type?: SnippetType - reference?: string -} - -export interface CreateSnippetData { - name: string - type: SnippetType - raw: string - reference?: string - private?: boolean - comment?: string - metatype?: string - schema?: string - enable?: boolean - method?: string - secret?: Record - customPath?: string -} - -export interface UpdateSnippetData extends Partial {} - -export interface SnippetGroup { - reference: string - count: number -} - -export interface ImportSnippetsData { - snippets: SnippetModel[] - packages?: string[] -} - -export const snippetsApi = { - // 获取片段列表 - getList: (params?: GetSnippetsParams) => - request.get>('/snippets', { params }), - - // 获取单个片段 - getById: (id: string) => request.get(`/snippets/${id}`), - - // 创建片段 - create: (data: CreateSnippetData) => - request.post('/snippets', { data }), - - // 更新片段 - update: (id: string, data: UpdateSnippetData) => - request.put(`/snippets/${id}`, { data }), - - // 删除片段 - delete: (id: string) => request.delete(`/snippets/${id}`), - - // 获取分组列表 - getGroups: (params?: { page?: number; size?: number }) => - request.get>('/snippets/group', { params }), - - // 获取分组下的片段 - getGroupSnippets: (reference: string) => - request.get(`/snippets/group/${reference}`), - - // 重置函数片段(内置函数) - resetFunction: (id: string) => request.delete(`/fn/reset/${id}`), - - // 导入片段 - import: (data: ImportSnippetsData) => - request.post('/snippets/import', { data }), -} diff --git a/apps/admin/src/api/subscribe.ts b/apps/admin/src/api/subscribe.ts deleted file mode 100644 index 57ad6c947..000000000 --- a/apps/admin/src/api/subscribe.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { request } from '~/utils/request' - -export interface Subscriber { - id: string - email: string - cancelToken: string - subscribe: number - verified: boolean - createdAt: string -} - -export interface SubscribeResponse { - data: Subscriber[] - pagination: { - total: number - currentPage: number - totalPage: number - size: number - hasNextPage: boolean - hasPrevPage: boolean - } -} - -export const subscribeApi = { - // 获取订阅状态 - getStatus: () => request.get<{ enable: boolean }>('/subscribe/status'), - - // 获取订阅列表 - getList: (params?: { page?: number; size?: number }) => - request.get('/subscribe', { params }), - - // 取消订阅 (单个,需 cancelToken) - unsubscribe: (params: { email: string; cancelToken: string }) => - request.get('/subscribe/unsubscribe', { params }), - - // 批量取消订阅 - unsubscribeBatch: (params: { emails?: string[]; all?: boolean }) => - request.delete<{ deletedCount: number }>('/subscribe/unsubscribe/batch', { - data: params, - }), -} diff --git a/apps/admin/src/api/system.ts b/apps/admin/src/api/system.ts deleted file mode 100644 index dc1218543..000000000 --- a/apps/admin/src/api/system.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { request } from '~/utils/request' - -export interface AppInfo { - name: string - version: string - hash?: string -} - -export interface InitData { - username: string - password: string - name: string - mail: string - url: string -} - -export interface DebugEventData { - type: string - payload: any -} - -export interface PtyRecord { - id: string - data: any -} - -export interface CreateOwnerData { - username: string - password: string - name?: string - mail: string - url?: string - avatar?: string - introduce?: string -} - -export const systemApi = { - // 获取应用信息 - getAppInfo: () => request.get('/'), - - // 检查是否已初始化(静默错误) - checkInit: async (): Promise<{ isInit: boolean }> => { - try { - return await request.get<{ isInit: boolean }>('/init') - } catch (error: any) { - // 404 或 403 表示已初始化 - if (error?.statusCode === 404 || error?.statusCode === 403) { - return { isInit: true } - } - throw error - } - }, - - // 初始化系统 - init: (data: InitData) => request.post('/init', { data }), - - // 获取初始化默认配置 - getInitDefaultConfigs: () => request.get('/init/configs/default'), - - // 更新初始化配置 - patchInitConfig: (key: string, data: any) => - request.patch(`/init/configs/${key}`, { data }), - - // 从备份恢复 - restoreFromBackup: (formData: FormData, timeout?: number) => - request.post('/init/restore', { data: formData, timeout }), - - // 创建站点主人 - createOwner: (data: CreateOwnerData) => - request.post('/init/owner', { data }), - - // === Debug === - - // 发送调试事件 - sendDebugEvent: (data: DebugEventData) => - request.post('/debug/events', { data }), - - // 执行 Serverless 函数 - executeFunction: (data: { code: string; context?: any }) => - request.post('/debug/function', { data }), - - // === PTY === - - // 获取 PTY 记录 - getPtyRecords: () => request.get('/pty/record'), - - // === 内置函数 === - - // 执行内置函数 - callBuiltInFunction: (name: string, params?: Record) => - request.get(`/fn/built-in/${name}`, { params }), - - // 获取函数类型定义 - getFnTypes: () => request.get('/fn/types'), -} diff --git a/apps/admin/src/api/templates.ts b/apps/admin/src/api/templates.ts deleted file mode 100644 index 518eb4ae1..000000000 --- a/apps/admin/src/api/templates.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { request } from '~/utils/request' - -export interface EmailTemplate { - subject: string - content: string - type: string -} - -export interface UpdateTemplateData { - subject?: string - content?: string -} - -export const templatesApi = { - // 获取邮件模板(后端直接返回模板对象) - getEmailTemplate: (type: string) => - request.get(`/options/email/template`, { - params: { type }, - }), - - // 更新邮件模板 - updateEmailTemplate: (type: string, data: UpdateTemplateData) => - request.put(`/options/email/template`, { data: { ...data, type } }), - - // 删除邮件模板(恢复默认) - deleteEmailTemplate: (params: { type: string }) => - request.delete(`/options/email/template`, { params }), -} diff --git a/apps/admin/src/api/topics.ts b/apps/admin/src/api/topics.ts deleted file mode 100644 index 0bbe08627..000000000 --- a/apps/admin/src/api/topics.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { PaginateResult } from '~/models/base' -import type { TopicModel } from '~/models/topic' - -import { request } from '~/utils/request' - -export interface GetTopicsParams { - page?: number - size?: number -} - -export interface CreateTopicData { - name: string - slug: string - introduce: string - description?: string - icon?: string -} - -export interface UpdateTopicData extends Partial {} - -export const topicsApi = { - // 获取专栏列表 - getList: (params?: GetTopicsParams) => - request.get>('/topics', { params }), - - // 获取单个专栏 - getById: (id: string) => request.get(`/topics/${id}`), - - // 创建专栏 - create: (data: CreateTopicData) => - request.post('/topics', { data }), - - // 更新专栏 - update: (id: string, data: UpdateTopicData) => - request.put(`/topics/${id}`, { data }), - - // 部分更新专栏 - patch: (id: string, data: Partial) => - request.patch(`/topics/${id}`, { data }), - - // 删除专栏 - delete: (id: string) => request.delete(`/topics/${id}`), -} diff --git a/apps/admin/src/api/user.ts b/apps/admin/src/api/user.ts deleted file mode 100644 index c4c0535aa..000000000 --- a/apps/admin/src/api/user.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { UserModel } from '~/models/user' - -import { authClient } from '~/utils/authjs/auth' -import { request } from '~/utils/request' - -export interface LoginData { - username: string - password: string -} - -export interface LoginResponse { - token?: string - user?: { - id: string - email: string - name: string - image?: string | null - emailVerified: boolean - createdAt: string | Date - updatedAt: string | Date - role?: 'reader' | 'owner' - } -} - -export interface UpdateOwnerData { - name?: string - username?: string - mail?: string - url?: string - avatar?: string - introduce?: string - socialIds?: Record -} - -export interface Session { - id: string - token: string - ua: string - ip: string - lastActiveAt: string - current?: boolean -} - -export interface AllowLoginResponse { - password: boolean - passkey: boolean - github?: boolean - google?: boolean - [key: string]: boolean | undefined -} - -export const userApi = { - // 获取当前 Owner 信息 - getOwner: () => request.get('/owner'), - - // 检查是否已登录 - checkLogged: () => request.get<{ ok: number }>('/owner/check_logged'), - - // 用户名密码登录(Cookie Session,不返回 JWT) - loginWithPassword: async (data: LoginData) => { - const result = await authClient.signIn.username({ - username: data.username, - password: data.password, - }) - - if (result.error) { - throw new Error(result.error.message || '登录失败') - } - - return result.data as LoginResponse - }, - - // 获取允许的登录方式 - getAllowLogin: () => request.get('/owner/allow-login'), - - // 更新 Owner 信息 - updateOwner: (data: UpdateOwnerData) => - request.patch('/owner', { data }), - - // 登出当前会话 - logout: async () => { - const result = await authClient.signOut() - if (result.error) { - throw new Error(result.error.message || '登出失败') - } - }, - - // 获取会话列表(Better Auth) - getSessions: async () => { - const [sessionsResult, currentResult] = await Promise.all([ - authClient.listSessions(), - authClient.getSession(), - ]) - - if (sessionsResult.error) { - throw new Error(sessionsResult.error.message || '获取会话失败') - } - - const currentToken = currentResult.data?.session?.token - - return (sessionsResult.data || []).map((session: any) => { - const token = session.token || session.id - return { - id: token, - token, - ua: session.userAgent || '', - ip: session.ipAddress || '', - lastActiveAt: new Date( - session.updatedAt || session.createdAt || Date.now(), - ).toISOString(), - current: currentToken ? token === currentToken : false, - } - }) as Session[] - }, - - // 删除指定会话 - deleteSession: async (token: string) => { - const result = await authClient.revokeSession({ token }) - if (result.error) { - throw new Error(result.error.message || '删除会话失败') - } - }, - - // 删除所有其他会话 - deleteAllSessions: async () => { - const result = await authClient.revokeOtherSessions() - if (result.error) { - throw new Error(result.error.message || '删除会话失败') - } - }, -} diff --git a/apps/admin/src/api/webhooks.ts b/apps/admin/src/api/webhooks.ts deleted file mode 100644 index 523c2f017..000000000 --- a/apps/admin/src/api/webhooks.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { PaginateResult } from '~/models/base' - -import { request } from '~/utils/request' - -export interface WebhookModel { - id: string - url: string - payloadUrl: string - events: string[] - secret?: string - enabled: boolean - scope: number - created: string - updated: string -} - -export interface CreateWebhookData { - url?: string - payloadUrl?: string - events: string[] - secret?: string - enabled?: boolean - scope?: number -} - -export interface UpdateWebhookData extends Partial {} - -export interface WebhookEventRecord { - id: string - event: string - headers: Record - payload: unknown - response: unknown - success: boolean - status: number - hookId: string - timestamp: string -} - -export const webhooksApi = { - // 获取 Webhook 列表 - getList: () => request.get('/webhooks'), - - // 获取可用事件列表 - getEvents: () => request.get('/webhooks/events'), - - // 创建 Webhook - create: (data: CreateWebhookData) => - request.post('/webhooks', { data }), - - // 更新 Webhook - update: (id: string, data: UpdateWebhookData) => - request.patch(`/webhooks/${id}`, { data }), - - // 删除 Webhook - delete: (id: string) => request.delete(`/webhooks/${id}`), - - // 测试 Webhook - test: (id: string, event: string) => - request.post(`/webhooks/${id}/test`, { data: { event } }), - - // 获取 Webhook 推送记录 - getDispatches: (id: string, params?: { page?: number; size?: number }) => - request.get>(`/webhooks/${id}`, { - params: { page: params?.page ?? 1, size: params?.size ?? 20 }, - }), - - // 重新推送 - redispatch: (hookId: string, eventId: string) => - request.post(`/webhooks/${hookId}/redispatch/${eventId}`), -} diff --git a/apps/admin/src/app/api/activity.ts b/apps/admin/src/app/api/activity.ts new file mode 100644 index 000000000..2fbb2ee42 --- /dev/null +++ b/apps/admin/src/app/api/activity.ts @@ -0,0 +1,72 @@ +import type { ActivityReadDurationType } from '../models/activity' +import type { PaginateResult } from '../models/base' + +import { getJson } from './http' + +export enum ActivityType { + Like = 0, + ReadDuration = 1, +} + +export interface ActivityItem { + createdAt: string + id: string + payload: { + id?: string + ip?: string + [key: string]: unknown + } + ref?: { + id?: string + slug?: string + title?: string + } + refId?: string + type: ActivityType +} + +export interface ActivityListResponse extends PaginateResult< + ActivityItem | ActivityReadDurationType +> { + objects?: { + notes?: Array<{ id: string; title?: string }> + pages?: Array<{ id: string; title?: string }> + posts?: Array<{ id: string; title?: string }> + recentlies?: Array<{ id: string; title?: string }> + } +} + +export interface ReadingRankItem { + count: number + ref: { + id?: string + nid?: number + slug?: string + title?: string + } + refId: string +} + +export function getActivityList(params: { + page?: number + size?: number + type?: ActivityType +}) { + return getJson('/activity', params) +} + +export function getReadingRank(params?: { + end?: number + limit?: number + start?: number +}) { + return getJson('/activity/reading/rank', params) +} + +export function getTopReadings(params?: { days?: number; top?: number }) { + return getJson('/activity/reading/top', params) +} + +export function getReferenceUrl(id: string) { + return getJson(`/helper/url-builder/${id}`) +} diff --git a/apps/admin/src/app/api/aggregate.ts b/apps/admin/src/app/api/aggregate.ts new file mode 100644 index 000000000..a1f7cc1f3 --- /dev/null +++ b/apps/admin/src/app/api/aggregate.ts @@ -0,0 +1,111 @@ +import { getJson } from './http' + +export interface StatCount { + allComments?: number + callTime: number + categories: number + comments: number + linkApply?: number + links: number + notes: number + online: number + pages: number + posts: number + recently: number + says: number + tags?: number + todayIpAccessCount: number + todayMaxOnline: number + todayOnlineTotal: number + unreadComments: number + uv: number +} + +export interface CategoryDistribution { + count: number + id: string + name: string + slug: string +} + +export interface PublicationTrendItem { + date: string + notes: number + posts: number +} + +export interface TagCloudItem { + count: number + tag: string +} + +export interface TopArticle { + category: { name: string; slug: string } | null + id: string + likes: number + reads: number + slug: string + title: string +} + +export interface CommentActivityItem { + count: number + date: string +} + +export interface TrafficSourceData { + browser: Array<{ count: number; name: string }> + os: Array<{ count: number; name: string }> +} + +export function getAggregateStat() { + return getJson('/aggregate/stat') +} + +export function getCategoryDistribution() { + return getJson( + '/aggregate/stat/category-distribution', + ) +} + +export function getPublicationTrend() { + return getJson('/aggregate/stat/publication-trend') +} + +export function getTagCloud() { + return getJson('/aggregate/stat/tag-cloud') +} + +export function getTopArticles() { + return getJson('/aggregate/stat/top-articles') +} + +export function getCommentActivity() { + return getJson('/aggregate/stat/comment-activity') +} + +export function getTrafficSource() { + return getJson('/aggregate/stat/traffic-source') +} + +export function countSiteWords() { + return getJson<{ count: number }>('/aggregate/count_site_words') +} + +export function countReadAndLike() { + return getJson<{ totalLikes: number; totalReads: number }>( + '/aggregate/count_read_and_like', + ) +} + +export function getSiteLikeCount() { + return getJson('/like_this') +} + +export function cleanCache() { + return getJson('/clean_catch') +} + +export function cleanRedis() { + return getJson('/clean_redis') +} diff --git a/apps/admin/src/app/api/ai.ts b/apps/admin/src/app/api/ai.ts new file mode 100644 index 000000000..f3b218456 --- /dev/null +++ b/apps/admin/src/app/api/ai.ts @@ -0,0 +1,534 @@ +import { deleteJson, getJson, patchJson, postJson, requestJson } from './http' + +export enum AiQueryType { + Slug = 'slug', + TitleSlug = 'title-slug', +} + +export interface AIWriterGenerateData { + text?: string + title?: string + type: AiQueryType +} + +export interface AIWriterGenerateResponse { + slug?: string + title?: string +} + +export interface ArticleInfo { + id: string + title: string + type: 'Note' | 'Page' | 'Post' | 'Recently' +} + +export interface PaginationInfo { + currentPage?: number + hasNextPage?: boolean + hasPrevPage?: boolean + page?: number + size: number + total: number + totalPage?: number +} + +export interface AISummary { + createdAt: string + hash: string + id: string + lang: string + refId: string + summary: string +} + +export interface GroupedSummaryData { + article: ArticleInfo + summaries: AISummary[] +} + +export interface GroupedSummaryResponse { + data: GroupedSummaryData[] + pagination: PaginationInfo +} + +export interface SummaryByRefResponse { + article: { + document: { title: string } + type: 'Note' | 'Page' | 'Post' | 'Recently' + } + summaries: AISummary[] +} + +export interface AIInsights { + content: string + createdAt: string + hash: string + id: string + isTranslation: boolean + lang: string + refId: string + sourceInsightsId?: string + sourceLang?: string +} + +export interface GroupedInsightsData { + article: ArticleInfo + insights: AIInsights[] +} + +export interface GroupedInsightsResponse { + data: GroupedInsightsData[] + pagination: PaginationInfo +} + +export interface InsightsByRefResponse { + article: { + document: { title: string } + type: 'Note' | 'Page' | 'Post' | 'Recently' + } | null + insights: AIInsights[] +} + +export type AIContentFormat = 'lexical' | 'markdown' | string + +export interface AITranslation { + aiModel?: string + aiProvider?: string + content?: string + contentFormat?: AIContentFormat + createdAt: string + hash: string + id: string + lang: string + refId: string + refType: string + sourceLang: string + subtitle?: string + summary?: string + tags?: string[] + text: string + title: string +} + +export interface GroupedTranslationData { + article: ArticleInfo + translations: AITranslation[] +} + +export interface GroupedTranslationResponse { + data: GroupedTranslationData[] + pagination: PaginationInfo +} + +export interface TranslationByRefResponse { + article: { + document: { title: string } + type: 'Note' | 'Page' | 'Post' | 'Recently' + } + translations: AITranslation[] +} + +export interface ProviderModel { + id: string + name: string +} + +export interface ProviderModelsResponse { + error?: string + models: ProviderModel[] + providerId: string + providerName: string + providerType: string +} + +export interface AITestData { + apiKey?: string + endpoint?: string + model?: string + providerId: string + type: string +} + +export interface AIModelListData { + apiKey?: string + endpoint?: string + providerId: string + type: string +} + +export interface AICommentReviewTestData { + author?: string + text: string +} + +export interface AICommentReviewTestResponse { + isSpam: boolean + reason?: string + score?: number +} + +export type TranslationEntryKeyPath = + | 'category.name' + | 'note.mood' + | 'note.weather' + | 'topic.introduce' + | 'topic.name' + +export interface TranslationEntry { + createdAt: string + id: string + keyPath: TranslationEntryKeyPath + keyType: 'dict' | 'entity' + lang: string + lookupKey: string + sourceText: string + sourceUpdatedAt?: string + translatedText: string +} + +export interface TranslationEntriesResponse { + data: TranslationEntry[] + pagination: { + page: number + size: number + total: number + } +} + +export interface GenerateEntriesResponse { + created: number + skipped: number +} + +export enum AITaskType { + Summary = 'ai:summary', + Translation = 'ai:translation', + TranslationBatch = 'ai:translation:batch', + TranslationAll = 'ai:translation:all', + SlugBackfill = 'ai:slug:backfill', + Insights = 'ai:insights', + InsightsTranslation = 'ai:insights:translation', +} + +export enum AITaskStatus { + Pending = 'pending', + Running = 'running', + Completed = 'completed', + PartialFailed = 'partial_failed', + Failed = 'failed', + Cancelled = 'cancelled', +} + +export interface AITaskLog { + level: 'error' | 'info' | 'warn' + message: string + timestamp: number +} + +export interface SubTaskStats { + completed: number + failed: number + pending: number + running: number + total: number +} + +export interface AITask { + completedAt?: number + completedItems?: number + createdAt: number + error?: string + groupId?: string + id: string + logs: AITaskLog[] + payload: Record + progress?: number + progressMessage?: string + result?: unknown + retryCount: number + startedAt?: number + status: AITaskStatus + subTaskStats?: SubTaskStats + tokensGenerated?: number + totalItems?: number + type: AITaskType + workerId?: string +} + +export interface AITasksResponse { + data: AITask[] + total: number +} + +export interface CreateTaskResponse { + created: boolean + taskId: string +} + +export function testCommentReview(data: AICommentReviewTestData) { + return postJson( + '/ai/comment-review/test', + data, + ) +} + +export function writerGenerate(data: AIWriterGenerateData) { + return postJson( + '/ai/writer/generate', + data, + ) +} + +export function getSummariesGrouped(params?: { + page?: number + search?: string + size?: number +}) { + return getJson('/ai/summaries/grouped', params) +} + +export function getSummaryByRef(refId: string) { + return getJson(`/ai/summaries/ref/${refId}`) +} + +export function deleteSummary(id: string) { + return deleteJson(`/ai/summaries/${id}`) +} + +export function updateSummary(id: string, data: { summary: string }) { + return patchJson(`/ai/summaries/${id}`, data) +} + +export function createSummaryTask(data: { lang?: string; refId: string }) { + return postJson( + '/ai/summaries/task', + data, + ) +} + +export function getInsightsGrouped(params: { + page: number + search?: string + size?: number +}) { + return getJson('/ai/insights/grouped', params) +} + +export function getInsightsByRef(refId: string) { + return getJson(`/ai/insights/ref/${refId}`) +} + +export function deleteInsights(id: string) { + return deleteJson(`/ai/insights/${id}`) +} + +export function updateInsights(id: string, data: { content: string }) { + return patchJson(`/ai/insights/${id}`, data) +} + +export function createInsightsTask(data: { refId: string }) { + return postJson( + '/ai/insights/task', + data, + ) +} + +export function createInsightsTranslationTask(data: { + refId: string + targetLang: string +}) { + return postJson( + '/ai/insights/task/translate', + data, + ) +} + +export function getModels() { + return getJson('/ai/models') +} + +export function getModelList(data: AIModelListData) { + return postJson<{ error?: string; models: ProviderModel[] }, AIModelListData>( + '/ai/models/list', + data, + ) +} + +export function testConfig(data: AITestData) { + return postJson('/ai/test', data) +} + +export function getTranslationsGrouped(params?: { + page?: number + search?: string + size?: number +}) { + return getJson('/ai/translations/grouped', params) +} + +export function getTranslationsByRef(refId: string) { + return getJson(`/ai/translations/ref/${refId}`) +} + +export function deleteTranslation(id: string) { + return deleteJson(`/ai/translations/${id}`) +} + +export function updateTranslation( + id: string, + data: { + content?: string + subtitle?: string + summary?: string + tags?: string[] + text?: string + title?: string + }, +) { + return patchJson(`/ai/translations/${id}`, data) +} + +export function createTranslationTask(data: { + refId: string + targetLanguages?: string[] +}) { + return postJson< + CreateTaskResponse, + { refId: string; targetLanguages?: string[] } + >('/ai/translations/task', data) +} + +export function createTranslationBatchTask(data: { + refIds: string[] + targetLanguages?: string[] +}) { + return postJson< + CreateTaskResponse, + { refIds: string[]; targetLanguages?: string[] } + >('/ai/translations/task/batch', data) +} + +export function createTranslationAllTask(data: { targetLanguages?: string[] }) { + return postJson( + '/ai/translations/task/all', + data, + ) +} + +export interface GetAiTasksParams { + page?: number + size?: number + status?: AITaskStatus + type?: AITaskType +} + +export function getAiTasks(params: GetAiTasksParams = {}) { + return getJson('/ai/tasks', { + page: params.page, + size: params.size, + status: params.status, + type: params.type, + }).then(normalizeTasksResponse) +} + +export function getAiTask(taskId: string) { + return getJson(`/ai/tasks/${taskId}`) +} + +export function retryAiTask(taskId: string) { + return requestJson(`/ai/tasks/${taskId}/retry`, { + method: 'POST', + }) +} + +export function cancelAiTask(taskId: string) { + return requestJson<{ success: boolean }>(`/ai/tasks/${taskId}/cancel`, { + method: 'POST', + }) +} + +export function deleteAiTask(taskId: string) { + return deleteJson<{ success: boolean }>(`/ai/tasks/${taskId}`) +} + +export function deleteAiTasks(params: { + before: number + status?: AITaskStatus + type?: AITaskType +}) { + const searchParams = new URLSearchParams() + searchParams.set('before', String(params.before)) + if (params.status) searchParams.set('status', params.status) + if (params.type) searchParams.set('type', params.type) + + return requestJson<{ deleted: number }>(`/ai/tasks?${searchParams}`, { + method: 'DELETE', + }) +} + +export function getAiTasksByGroupId(groupId: string) { + return getJson(`/ai/tasks/group/${groupId}`) +} + +export function cancelAiTasksByGroupId(groupId: string) { + return deleteJson<{ cancelled: number }>(`/ai/tasks/group/${groupId}`) +} + +export function getTranslationEntries(params?: { + keyPath?: TranslationEntryKeyPath + lang?: string + page?: number + size?: number +}) { + return getJson('/ai/translations/entries', params) +} + +export function generateTranslationEntries(data?: { + keyPaths?: TranslationEntryKeyPath[] + targetLanguages?: string[] +}) { + return postJson< + GenerateEntriesResponse, + { keyPaths?: TranslationEntryKeyPath[]; targetLanguages?: string[] } | null + >('/ai/translations/entries/generate', data ?? null) +} + +export function updateTranslationEntry( + id: string, + data: { translatedText: string }, +) { + return patchJson( + `/ai/translations/entries/${id}`, + data, + ) +} + +export function deleteTranslationEntry(id: string) { + return deleteJson(`/ai/translations/entries/${id}`) +} + +export function getSlugBackfillStatus() { + return getJson<{ + count: number + notes: Array<{ id: string; nid: number; title: string }> + }>('/ai/writer/backfill-slugs/status') +} + +export function createSlugBackfillTask() { + return requestJson('/ai/writer/backfill-slugs', { + method: 'POST', + }) +} + +function normalizeTasksResponse( + response: AITasksResponse | AITask[], +): AITasksResponse { + if (Array.isArray(response)) { + return { + data: response, + total: response.length, + } + } + + return response +} diff --git a/apps/admin/src/app/api/analyze.ts b/apps/admin/src/app/api/analyze.ts new file mode 100644 index 000000000..18d1f7a64 --- /dev/null +++ b/apps/admin/src/app/api/analyze.ts @@ -0,0 +1,85 @@ +import type { UA } from '~/app/models/analyze' +import type { PaginateResult } from '~/app/models/base' + +import { deleteJson, getJson } from './http' + +export type AnalyzeRecord = UA.Root & { + country?: null | string + referer?: null | string +} + +export interface IPAggregate { + months: Array<{ + date: string + key: 'ip' | 'pv' + value: number + }> + paths: Array<{ + count: number + path: string + }> + today: Array<{ + hour: string + key: 'ip' | 'pv' + value: number + }> + todayIps: string[] + total: { + callTime: number + uv: number + } + weeks: Array<{ + day: string + key: 'ip' | 'pv' + value: number + }> +} + +export interface GetAnalyzeParams { + from?: string + page?: number + size?: number + to?: string +} + +export interface TrafficSourceResponse { + categories: Array<{ name: string; value: number }> + details: Array<{ count: number; source: string }> +} + +export interface DeviceDistributionResponse { + browsers: Array<{ name: string; value: number }> + devices: Array<{ name: string; value: number }> + os: Array<{ name: string; value: number }> +} + +export function getAnalyzeList(params: GetAnalyzeParams = {}) { + return getJson>('/analyze', { + from: params.from, + page: params.page, + size: params.size, + to: params.to, + }) +} + +export function getAnalyzeAggregate() { + return getJson('/analyze/aggregate') +} + +export function getTrafficSource(params?: { from?: string; to?: string }) { + return getJson('/analyze/traffic-source', { + from: params?.from, + to: params?.to, + }) +} + +export function getDeviceDistribution(params?: { from?: string; to?: string }) { + return getJson('/analyze/device', { + from: params?.from, + to: params?.to, + }) +} + +export function deleteAllAnalyzeRecords() { + return deleteJson('/analyze') +} diff --git a/apps/admin/src/app/api/auth.ts b/apps/admin/src/app/api/auth.ts new file mode 100644 index 000000000..8aaf56660 --- /dev/null +++ b/apps/admin/src/app/api/auth.ts @@ -0,0 +1,69 @@ +import type { TokenModel } from '~/app/models/token' + +import { authClient } from '~/app/utils/authjs/auth' + +import { getJson, postJson, requestJson } from './http' + +export interface CreateTokenData { + expired?: Date | string + name: string +} + +export interface PasskeyItem { + createdAt: string + credentialID: string + id: string + name?: string + publicKey?: string +} + +export interface LoggedStatus { + isGuest?: boolean + ok: boolean | number +} + +export function checkLogged() { + return getJson('/owner/check_logged') +} + +export function getTokens() { + return getJson('/auth/token') +} + +export function getToken(id: string) { + return getJson('/auth/token', { id }) +} + +export function createToken(data: CreateTokenData) { + return postJson('/auth/token', data) +} + +export function deleteToken(id: string) { + return requestJson(`/auth/token?id=${encodeURIComponent(id)}`, { + method: 'DELETE', + }) +} + +export function authAsOwner() { + return requestJson('/auth/as-owner', { + method: 'PATCH', + }) +} + +export async function listPasskeys() { + const result = await authClient.passkey.listUserPasskeys() + if (result.error) throw new Error(result.error.message || '获取 Passkey 失败') + + return (result.data ?? []).map((passkey: any) => ({ + createdAt: String(passkey.createdAt ?? new Date().toISOString()), + credentialID: String(passkey.credentialID ?? passkey.id), + id: String(passkey.id), + name: passkey.name ? String(passkey.name) : undefined, + publicKey: passkey.publicKey ? String(passkey.publicKey) : undefined, + })) as PasskeyItem[] +} + +export async function deletePasskey(id: string) { + const result = await authClient.passkey.deletePasskey({ id }) + if (result.error) throw new Error(result.error.message || '删除 Passkey 失败') +} diff --git a/apps/admin/src/app/api/backups.ts b/apps/admin/src/app/api/backups.ts new file mode 100644 index 000000000..aa6d2e36a --- /dev/null +++ b/apps/admin/src/app/api/backups.ts @@ -0,0 +1,85 @@ +import { API_URL } from '~/app/constants/env' + +import { deleteJson, patchJson } from './http' + +export interface BackupFile { + createdAt: string + filename: string + size: string +} + +export async function getBackups() { + return requestJson('/backups') +} + +export function createBackup() { + return requestBlob('/backups/new') +} + +export function downloadBackup(filename: string) { + return requestBlob(`/backups/${encodeURIComponent(filename)}`) +} + +export function deleteBackup(filename: string) { + return deleteJson(`/backups/${encodeURIComponent(filename)}`) +} + +export function rollbackBackup(filename: string) { + return patchJson>( + `/backups/rollback/${encodeURIComponent(filename)}`, + {}, + ) +} + +export async function uploadAndRestoreBackup(file: File) { + const formData = new FormData() + formData.append('file', file) + + const response = await fetch(`${API_URL}/backups/rollback`, { + body: formData, + credentials: 'include', + headers: { + 'x-skip-translation': '1', + }, + method: 'POST', + }) + + if (!response.ok) { + throw new Error(response.statusText || 'Upload restore failed') + } +} + +async function requestJson(path: string) { + const response = await fetch(`${API_URL}${path}`, { + credentials: 'include', + headers: { + 'x-skip-translation': '1', + }, + }) + + if (!response.ok) { + throw new Error(response.statusText || 'Request failed') + } + + const data = await response.json() + if (data && typeof data === 'object' && 'data' in data) { + return data.data as TResponse + } + + return data as TResponse +} + +async function requestBlob(path: string) { + const response = await fetch(`${API_URL}${path}`, { + credentials: 'include', + headers: { + 'x-skip-translation': '1', + }, + }) + + if (!response.ok) { + throw new Error(response.statusText || 'Request failed') + } + + return response.blob() +} diff --git a/apps/admin/src/app/api/categories.ts b/apps/admin/src/app/api/categories.ts new file mode 100644 index 000000000..ccc87fc07 --- /dev/null +++ b/apps/admin/src/app/api/categories.ts @@ -0,0 +1,54 @@ +import type { CategoryModel, TagModel } from '~/app/models/category' +import type { PostModel } from '~/app/models/post' + +import { deleteJson, getJson, postJson, putJson } from './http' + +export interface GetCategoriesParams { + type?: 'Category' | 'Tag' | 'tag' +} + +export interface CreateCategoryData { + name: string + slug: string + type?: number +} + +export type UpdateCategoryData = Partial + +export function getCategories(params?: GetCategoriesParams) { + return getJson('/categories', { type: params?.type }) +} + +export function getCategory(id: string) { + return getJson(`/categories/${id}`) +} + +export function createCategory(data: CreateCategoryData) { + return postJson('/categories', data) +} + +export function updateCategory(id: string, data: UpdateCategoryData) { + return putJson(`/categories/${id}`, data) +} + +export function deleteCategory(id: string) { + return deleteJson(`/categories/${id}`) +} + +export function getTags() { + return getJson('/categories', { type: 'tag' }) +} + +interface PostsByTagResponse { + data: PostModel[] + tag: string +} + +export async function getPostsByTag(tagName: string) { + const result = await getJson( + `/categories/${tagName}`, + { tag: 'true' }, + ) + + return Array.isArray(result) ? result : result.data +} diff --git a/apps/admin/src/app/api/comments.ts b/apps/admin/src/app/api/comments.ts new file mode 100644 index 000000000..0f28c94c9 --- /dev/null +++ b/apps/admin/src/app/api/comments.ts @@ -0,0 +1,52 @@ +import type { CommentModel, CommentsResponse } from '~/app/models/comment' + +import { deleteJson, getJson, patchJson, postJson } from './http' + +export interface GetCommentsParams { + page: number + size: number + state: number +} + +export interface ReplyCommentData { + text: string +} + +export function getComments(params: GetCommentsParams) { + return getJson('/comments', { + page: params.page, + size: params.size, + state: params.state, + }) +} + +export function replyComment(id: string, text: string) { + return postJson( + `/comments/reader/reply/${id}`, + { text }, + ) +} + +export function updateCommentState(id: string, state: number) { + return patchJson(`/comments/${id}`, { + state, + }) +} + +export function batchUpdateCommentState( + options: + | { currentState: number; all: true; state: number } + | { ids: string[]; state: number }, +) { + return patchJson('/comments/batch/state', options) +} + +export function deleteComment(id: string) { + return deleteJson(`/comments/${id}`) +} + +export function batchDeleteComments( + options: { all: true; state: number } | { ids: string[] }, +) { + return deleteJson('/comments/batch', options) +} diff --git a/apps/admin/src/app/api/cron-tasks.ts b/apps/admin/src/app/api/cron-tasks.ts new file mode 100644 index 000000000..b775edb1c --- /dev/null +++ b/apps/admin/src/app/api/cron-tasks.ts @@ -0,0 +1,128 @@ +import { deleteJson, getJson, requestJson } from './http' + +export enum CronTaskType { + CleanAccessRecord = 'cron:clean-access-record', + CleanCommentUploads = 'cron:clean-comment-uploads', + CleanTempDirectory = 'cron:clean-temp-directory', + DeleteExpiredJWT = 'cron:delete-expired-jwt', + PushToBaiduSearch = 'cron:push-to-baidu-search', + PushToBingSearch = 'cron:push-to-bing-search', + RebuildSearchIndex = 'cron:rebuild-search-index', + ResetIPAccess = 'cron:reset-ip-access', + ResetLikedOrReadArticleRecord = 'cron:reset-liked-or-read', +} + +export enum CronTaskStatus { + Cancelled = 'cancelled', + Completed = 'completed', + Failed = 'failed', + PartialFailed = 'partial_failed', + Pending = 'pending', + Running = 'running', +} + +export interface CronTaskDefinition { + cronExpression: string + description: string + lastDate?: string | null + name: string + nextDate?: string | null + type: CronTaskType +} + +export interface CronTaskLog { + level: 'error' | 'info' | 'warn' + message: string + timestamp: number +} + +export interface CronTask { + completedAt?: number + createdAt: number + error?: string + id: string + logs: CronTaskLog[] + payload: Record + progress?: number + progressMessage?: string + result?: unknown + retryCount: number + startedAt?: number + status: CronTaskStatus + type: CronTaskType + workerId?: string +} + +export interface CronTasksResponse { + data: CronTask[] + total: number +} + +export interface CreateTaskResponse { + created: boolean + taskId: string +} + +export interface CronTaskFilters { + page?: number + size?: number + status?: CronTaskStatus + type?: CronTaskType +} + +export function getCronTaskDefinitions() { + return getJson('/cron-task') +} + +export function getCronTasks(filters?: CronTaskFilters) { + return getJson('/cron-task/tasks', { + page: filters?.page, + size: filters?.size, + status: filters?.status, + type: filters?.type, + }) +} + +export function runCronTask(type: CronTaskType) { + return requestJson(`/cron-task/run/${type}`, { + method: 'POST', + }) +} + +export function cancelCronTask(taskId: string) { + return requestJson<{ success: boolean }>( + `/cron-task/tasks/${taskId}/cancel`, + { + method: 'POST', + }, + ) +} + +export function retryCronTask(taskId: string) { + return requestJson(`/cron-task/tasks/${taskId}/retry`, { + method: 'POST', + }) +} + +export function deleteCronTask(taskId: string) { + return deleteJson<{ success: boolean }>(`/cron-task/tasks/${taskId}`) +} + +export function deleteCronTasks(params: { + before: number + status?: CronTaskStatus + type?: CronTaskType +}) { + const searchParams = new URLSearchParams() + + searchParams.set('before', String(params.before)) + if (params.status) searchParams.set('status', params.status) + if (params.type) searchParams.set('type', params.type) + + return requestJson<{ deleted: number }>( + `/cron-task/tasks?${searchParams.toString()}`, + { + method: 'DELETE', + }, + ) +} diff --git a/apps/admin/src/app/api/dependencies.ts b/apps/admin/src/app/api/dependencies.ts new file mode 100644 index 000000000..12b39576f --- /dev/null +++ b/apps/admin/src/app/api/dependencies.ts @@ -0,0 +1,36 @@ +import { getJson } from './http' + +export interface DependencyGraph { + dependencies: Record +} + +export interface NpmPackageLatest { + name: string + version: string +} + +export function getDependencyGraph() { + return getJson('/dependencies/graph') +} + +export function installDependencies(packageNames: string | string[]) { + const names = Array.isArray(packageNames) + ? packageNames.join(',') + : packageNames + + return getJson('/dependencies/install_deps', { + packageNames: names, + }) +} + +export async function getNpmPackageLatest(name: string) { + const response = await fetch( + `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, + ) + + if (!response.ok) { + throw new Error(`获取 ${name} 最新版本失败`) + } + + return (await response.json()) as NpmPackageLatest +} diff --git a/apps/admin/src/app/api/drafts.ts b/apps/admin/src/app/api/drafts.ts new file mode 100644 index 000000000..73853b574 --- /dev/null +++ b/apps/admin/src/app/api/drafts.ts @@ -0,0 +1,82 @@ +import type { Image, PaginateResult } from '~/app/models/base' +import type { + DraftHistoryListItem, + DraftModel, + DraftRefType, + TypeSpecificData, +} from '~/app/models/draft' + +import { deleteJson, getJson, postJson, putJson } from './http' + +export type DraftSortOrder = 'asc' | 'desc' + +export interface GetDraftsParams { + hasRef?: boolean + page?: number + refType?: DraftRefType + size?: number + sort_by?: string + sort_order?: DraftSortOrder +} + +export interface CreateDraftData { + content?: string + contentFormat?: 'lexical' | 'markdown' + images?: Image[] + meta?: Record + refId?: string + refType: DraftRefType + text?: string + title?: string + typeSpecificData?: TypeSpecificData +} + +export function getDrafts(params: GetDraftsParams = {}) { + return getJson>('/drafts', { + hasRef: params.hasRef === undefined ? undefined : String(params.hasRef), + page: params.page, + refType: params.refType, + size: params.size, + sort_by: params.sort_by, + sort_order: params.sort_order, + }) +} + +export function getDraftById(id: string) { + return getJson(`/drafts/${id}`) +} + +export function getDraftByRef(refType: DraftRefType, refId: string) { + return getJson(`/drafts/by-ref/${refType}/${refId}`) +} + +export function getNewDrafts(refType: DraftRefType) { + return getJson(`/drafts/by-ref/${refType}/new`) +} + +export function getDraftHistory(id: string) { + return getJson(`/drafts/${id}/history`) +} + +export function getDraftHistoryVersion(id: string, version: number) { + return getJson(`/drafts/${id}/history/${version}`) +} + +export function createDraft(data: CreateDraftData) { + return postJson('/drafts', data) +} + +export function updateDraft(id: string, data: Partial) { + return putJson>(`/drafts/${id}`, data) +} + +export function deleteDraft(id: string) { + return deleteJson<{ success: boolean }>(`/drafts/${id}`) +} + +export function restoreDraftVersion(id: string, version: number) { + return postJson>( + `/drafts/${id}/restore/${version}`, + {}, + ) +} diff --git a/apps/admin/src/app/api/enrichment.ts b/apps/admin/src/app/api/enrichment.ts new file mode 100644 index 000000000..e27043b81 --- /dev/null +++ b/apps/admin/src/app/api/enrichment.ts @@ -0,0 +1,175 @@ +import type { + EnrichmentCaptureJoinedRow, + EnrichmentCaptureListResponse, + EnrichmentCaptureQuota, + EnrichmentImage, + EnrichmentListResponse, + EnrichmentProbeResult, + EnrichmentProviderMeta, + EnrichmentResult, + EnrichmentRow, + EnrichmentRowDetail, + LegacyPager, +} from '~/app/models/enrichment' + +import { deleteJson, getJson, requestJson } from './http' + +const encodeId = (id: string) => encodeURIComponent(id) + +export interface GetEnrichmentListParams { + locale?: string + onlyFailed?: boolean + page?: number + size?: number +} + +export interface GetEnrichmentCapturesParams { + order?: 'asc' | 'desc' + page?: number + size?: number + sort?: 'bytes' | 'created' | 'last_accessed' +} + +export function resolveEnrichment(url: string, lang?: string) { + return getJson('/enrichment/resolve', { lang, url }) +} + +export function getEnrichmentList(params: GetEnrichmentListParams = {}) { + return getJson( + '/enrichment/admin/list', + { + locale: params.locale, + onlyFailed: params.onlyFailed ? 'true' : undefined, + page: params.page, + size: params.size, + }, + ).then((response) => + normalizeListResponse(response, params.page ?? 1, params.size ?? 20), + ) +} + +export function getEnrichmentProviders() { + return getJson('/enrichment/admin/providers') +} + +export function refreshEnrichment( + provider: string, + externalId: string, + locale?: string, +) { + const query = locale ? `?lang=${encodeURIComponent(locale)}` : '' + + return requestJson( + `/enrichment/admin/refresh/${encodeURIComponent(provider)}/${encodeId(externalId)}${query}`, + { method: 'POST' }, + ) +} + +export function invalidateEnrichment( + provider: string, + externalId: string, + locale?: string, +) { + const query = + locale === undefined ? '' : `?lang=${encodeURIComponent(locale)}` + + return deleteJson( + `/enrichment/admin/cache/${encodeURIComponent(provider)}/${encodeId(externalId)}${query}`, + ) +} + +export function getEnrichmentById(id: string) { + return getJson(`/enrichment/admin/by-id/${encodeId(id)}`) +} + +export function getEnrichmentCaptures( + params: GetEnrichmentCapturesParams = {}, +) { + const sort = params.sort ?? 'last_accessed' + const order = params.order ?? 'desc' + + return getJson( + '/enrichment/admin/captures', + { + order, + page: params.page, + size: params.size, + sort, + }, + ).then((response) => + normalizeCaptureResponse(response, params.page ?? 1, params.size ?? 20), + ) +} + +export function getEnrichmentCaptureQuota() { + return getJson('/enrichment/admin/captures/quota') +} + +export function deleteEnrichmentCapture(enrichmentId: string) { + return deleteJson( + `/enrichment/admin/captures/${encodeId(enrichmentId)}`, + ) +} + +export function recaptureEnrichment(enrichmentId: string) { + return requestJson( + `/enrichment/admin/captures/${encodeId(enrichmentId)}/recapture`, + { method: 'POST' }, + ) +} + +export function probeEnrichment(url: string, useCache?: boolean) { + return requestJson('/enrichment/admin/probe', { + body: JSON.stringify({ + url, + ...(useCache === undefined ? {} : { useCache }), + }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }) +} + +function normalizeListResponse( + response: EnrichmentListResponse | EnrichmentRow[], + page: number, + size: number, +): EnrichmentListResponse { + if (Array.isArray(response)) { + return { + data: response, + pagination: fallbackPager(response.length, page, size), + } + } + + return response +} + +function normalizeCaptureResponse( + response: EnrichmentCaptureListResponse | EnrichmentCaptureJoinedRow[], + page: number, + size: number, +): EnrichmentCaptureListResponse { + if (Array.isArray(response)) { + return { + data: response, + pagination: fallbackPager(response.length, page, size), + } + } + + return response +} + +function fallbackPager(total: number, page: number, size: number): LegacyPager { + const totalPage = Math.max(1, Math.ceil(total / size)) + + return { + currentPage: page, + hasNextPage: page < totalPage, + hasPrevPage: page > 1, + size, + total, + totalPage, + } +} diff --git a/apps/admin/src/app/api/files.ts b/apps/admin/src/app/api/files.ts new file mode 100644 index 000000000..c544a1021 --- /dev/null +++ b/apps/admin/src/app/api/files.ts @@ -0,0 +1,229 @@ +import { API_URL } from '~/app/constants/env' + +import { deleteJson, getJson, patchJson, requestJson } from './http' + +export interface FileItem { + created?: number + name: string + url: string +} + +export interface UploadResponse { + name: string + url: string +} + +export interface OrphanFile { + byteSize?: null | number + createdAt: string + detachedAt?: null | string + fileName: string + fileUrl: string + id: string + mimeType?: null | string + readerId?: null | string + refId?: null | string + refType?: null | string + status?: 'active' | 'detached' | 'pending' + uploadedBy?: null | string +} + +export interface FileListPagination { + currentPage: number + hasNextPage: boolean + hasPrevPage: boolean + size: number + total: number + totalPage: number +} + +export interface OrphanListResponse { + data: OrphanFile[] + pagination: FileListPagination +} + +export interface CleanupResult { + deletedCount: number + totalOrphan: number +} + +export interface CommentUploadFile { + byteSize?: number + createdAt: string + detachedAt?: string + fileName: string + fileUrl: string + id: string + mimeType?: string + readerId?: string + refId?: string + refType?: string + status: 'active' | 'detached' | 'pending' +} + +export interface CommentUploadListResponse { + data: CommentUploadFile[] + pagination: FileListPagination +} + +export type FileType = 'avatar' | 'file' | 'icon' | 'image' +export type CommentUploadStatus = '' | 'active' | 'detached' | 'pending' + +export function getFilesByType(type: FileType) { + return getJson(`/files/${type}`) +} + +export function uploadFile(file: File, type?: FileType) { + const formData = new FormData() + formData.append('file', file) + + const query = type ? `?type=${encodeURIComponent(type)}` : '' + + return requestJson(`/files/upload${query}`, { + body: formData, + method: 'POST', + }) +} + +export function uploadFileWithProgress( + file: File, + options: { + onProgress: (progress: number) => void + type?: FileType + }, +) { + const formData = new FormData() + formData.append('file', file) + + const query = options.type ? `?type=${encodeURIComponent(options.type)}` : '' + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + + xhr.open('POST', `${API_URL}/files/upload${query}`) + xhr.withCredentials = true + xhr.setRequestHeader('x-skip-translation', '1') + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) return + options.onProgress(Math.round((event.loaded / event.total) * 100)) + } + + xhr.onload = () => { + const responseData = readXhrJson(xhr.responseText) + + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(readXhrError(responseData, xhr.statusText))) + return + } + + options.onProgress(100) + resolve(readUploadResponse(responseData)) + } + + xhr.onerror = () => reject(new Error('上传失败')) + xhr.send(formData) + }) +} + +export function updateFile(type: FileType, name: string, file: File) { + const formData = new FormData() + formData.append('file', file) + + return requestJson( + `/files/${type}/${encodeURIComponent(name)}`, + { + body: formData, + method: 'PUT', + }, + ) +} + +export function deleteFileByTypeAndName(type: FileType, name: string) { + return deleteJson(`/files/${type}/${encodeURIComponent(name)}`) +} + +export function renameFile(type: FileType, name: string, newName: string) { + return patchJson( + `/files/${type}/${encodeURIComponent(name)}/rename`, + { name: newName }, + ) +} + +export function getOrphanFiles(page = 1, size = 24) { + return getJson('/files/orphans/list', { page, size }) +} + +export function getOrphanFileCount() { + return getJson<{ count: number }>('/files/orphans/count') +} + +export function cleanupOrphanFiles(maxAgeMinutes = 60) { + return requestJson( + `/files/orphans/cleanup?maxAgeMinutes=${maxAgeMinutes}`, + { method: 'POST' }, + ) +} + +export function batchDeleteOrphanFiles( + options: { all: true } | { ids: string[] }, +) { + return deleteJson<{ deletedCount: number }, typeof options>( + '/files/orphans/batch', + options, + ) +} + +export function getCommentUploads(params: { + page?: number + readerId?: string + refId?: string + size?: number + status?: Exclude +}) { + return getJson('/files/comment-uploads/list', { + page: params.page, + readerId: params.readerId, + refId: params.refId, + size: params.size, + status: params.status, + }) +} + +export function deleteCommentUpload(id: string) { + return deleteJson<{ storageRemoved: boolean }>(`/files/comment-uploads/${id}`) +} + +function readXhrJson(text: string) { + try { + return JSON.parse(text) as unknown + } catch { + return null + } +} + +function readUploadResponse(responseData: unknown): UploadResponse { + if ( + responseData && + typeof responseData === 'object' && + 'data' in responseData + ) { + return (responseData as { data: UploadResponse }).data + } + + return responseData as UploadResponse +} + +function readXhrError(responseData: unknown, fallback: string) { + if (!responseData || typeof responseData !== 'object') return fallback + + const message = + 'error' in responseData + ? (responseData as { error?: { message?: string | string[] } }).error + ?.message + : 'message' in responseData + ? (responseData as { message?: string | string[] }).message + : undefined + + return Array.isArray(message) ? message.join(', ') : (message ?? fallback) +} diff --git a/apps/admin/src/app/api/github-repo.ts b/apps/admin/src/app/api/github-repo.ts new file mode 100644 index 000000000..32c3e200a --- /dev/null +++ b/apps/admin/src/app/api/github-repo.ts @@ -0,0 +1,38 @@ +const endpoint = 'https://api.github.com/' + +export interface GithubRepo { + name: string + html_url: string + description: string | null + homepage: string | null +} + +interface GithubReadme { + download_url: string +} + +export async function getRepoDetail(owner: string, repo: string) { + const response = await fetch(`${endpoint}repos/${owner}/${repo}`) + if (!response.ok) throw new Error('获取 GitHub 仓库信息失败') + + return response.json() as Promise +} + +export async function getRepoReadme(owner: string, repo: string) { + const response = await fetch(`${endpoint}repos/${owner}/${repo}/readme`) + if (!response.ok) return null + + const readme = (await response.json()) as GithubReadme + if (!readme.download_url) return null + + const split = readme.download_url.split('/') + const filename = split.pop() + const branch = split.pop() + if (!filename || !branch) return null + + const jsdelivrUrl = `https://fastly.jsdelivr.net/gh/${owner}/${repo}@${branch}/${filename}` + const readmeResponse = await fetch(jsdelivrUrl) + if (!readmeResponse.ok) return null + + return readmeResponse.text() +} diff --git a/apps/admin/src/app/api/github-snippets.ts b/apps/admin/src/app/api/github-snippets.ts new file mode 100644 index 000000000..51e1e50c7 --- /dev/null +++ b/apps/admin/src/app/api/github-snippets.ts @@ -0,0 +1,32 @@ +interface GitHubContentItem { + download_url?: string | null + html_url?: string | null + name: string + type: 'dir' | 'file' | string +} + +const repoContentsUrl = + 'https://api.github.com/repos/mx-space/snippets/contents' + +export async function fetchGitHubSnippetTree(path = '') { + const target = path + ? `${repoContentsUrl}/${path.split('/').map(encodeURIComponent).join('/')}` + : repoContentsUrl + const response = await fetch(target) + + if (!response.ok) { + throw new Error('获取 GitHub Snippets 仓库失败') + } + + return (await response.json()) as GitHubContentItem[] | GitHubContentItem +} + +export async function fetchGitHubText(downloadUrl: string) { + const response = await fetch(downloadUrl) + + if (!response.ok) { + throw new Error('获取文件内容失败') + } + + return response.text() +} diff --git a/apps/admin/src/app/api/health.ts b/apps/admin/src/app/api/health.ts new file mode 100644 index 000000000..6c2f4946b --- /dev/null +++ b/apps/admin/src/app/api/health.ts @@ -0,0 +1,5 @@ +import { getJson } from './http' + +export function sendTestEmail() { + return getJson<{ message?: string; trace?: string }>('/health/email/test') +} diff --git a/apps/admin/src/app/api/http.ts b/apps/admin/src/app/api/http.ts new file mode 100644 index 000000000..b60c793fd --- /dev/null +++ b/apps/admin/src/app/api/http.ts @@ -0,0 +1,251 @@ +import { API_URL } from '~/app/constants/env' +import { SESSION_WITH_LOGIN } from '~/app/constants/keys' + +type ResponseEnvelope = { + code?: number | string + data?: T + error?: { code?: number | string; message?: string | string[] } + meta?: { + pagination?: unknown + } + message?: string | string[] +} + +export async function postJson( + path: string, + data: TData, +): Promise { + return requestJson(path, { + body: JSON.stringify(data), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }) +} + +type QueryObject = Record +type QueryValue = + | Array + | QueryObject + | boolean + | number + | string + | undefined + +export async function getJson( + path: string, + params?: Record, +): Promise { + return requestJson(withQuery(path, params), { method: 'GET' }) +} + +export async function requestJson( + path: string, + init: RequestInit, +): Promise { + const response = await fetch(`${API_URL}${path}`, { + credentials: 'include', + ...init, + headers: { + 'x-skip-translation': '1', + ...init.headers, + }, + }) + + const responseData = normalizeResponseData( + camelcaseKeys(await readResponseData(response)), + ) + + if (isUnauthorizedResponse(response, responseData)) { + handleUnauthorized() + } + + if (!response.ok) { + const message = + responseData?.error?.message || + responseData?.message || + response.statusText + + throw new Error( + Array.isArray(message) ? message.join(', ') : message || 'Request failed', + ) + } + + if (responseData && 'data' in responseData) { + if (responseData.meta?.pagination) { + return { + data: responseData.data, + pagination: responseData.meta.pagination, + } as TResponse + } + + return responseData.data as TResponse + } + + return responseData as TResponse +} + +function isUnauthorizedResponse( + response: Response, + responseData: null | ResponseEnvelope, +) { + return ( + response.status === 401 || + responseData?.code === 401 || + responseData?.error?.code === 401 || + responseData?.error?.code === 'AUTH_NOT_LOGGED_IN' + ) +} + +function handleUnauthorized() { + sessionStorage.removeItem(SESSION_WITH_LOGIN) + + const current = `${window.location.pathname}${window.location.hash}` + const hash = window.location.hash.replace(/^#/, '') + const isAuthRoute = + hash.startsWith('/login') || + hash.startsWith('/setup') || + hash.startsWith('/setup-api') + + if (isAuthRoute) return + + window.location.hash = `/login?from=${encodeURIComponent( + hash || current || '/dashboard', + )}` +} + +export async function putJson( + path: string, + data: TData, +): Promise { + return requestJson(path, { + body: JSON.stringify(data), + headers: { + 'content-type': 'application/json', + }, + method: 'PUT', + }) +} + +export async function patchJson( + path: string, + data: TData, +): Promise { + return requestJson(path, { + body: JSON.stringify(data), + headers: { + 'content-type': 'application/json', + }, + method: 'PATCH', + }) +} + +export async function deleteJson( + path: string, + data?: TData, +): Promise { + return requestJson(path, { + ...(data === undefined + ? {} + : { + body: JSON.stringify(data), + headers: { + 'content-type': 'application/json', + }, + }), + method: 'DELETE', + }) +} + +function withQuery(path: string, params?: Record) { + if (!params) return path + + const searchParams = new URLSearchParams() + + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue + if (Array.isArray(value)) { + value.forEach((item) => searchParams.append(key, String(item))) + continue + } + if (typeof value === 'object') { + for (const [childKey, childValue] of Object.entries(value)) { + if (childValue !== undefined) { + searchParams.set(`${key}[${childKey}]`, String(childValue)) + } + } + continue + } + + searchParams.set(key, String(value)) + } + + const query = searchParams.toString() + + return query ? `${path}?${query}` : path +} + +async function readResponseData(response: Response) { + try { + return (await response.json()) as ResponseEnvelope + } catch { + return null + } +} + +function camelcaseKeys(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => camelcaseKeys(item)) as T + } + + if (!isPlainObject(value)) return value + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + toCamelCase(key), + camelcaseKeys(item), + ]), + ) as T +} + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') return false + const prototype = Object.getPrototypeOf(value) + + return prototype === Object.prototype || prototype === null +} + +function toCamelCase(value: string) { + return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()) +} + +function normalizeResponseData(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => normalizeResponseData(item)) as T + } + + if (!isPlainObject(value)) return value + + const next = Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + normalizeResponseData(item), + ]), + ) + + if ('totalPages' in next && !('totalPage' in next)) { + next.totalPage = next.totalPages + } + if ('totalPage' in next && !('totalPages' in next)) { + next.totalPages = next.totalPage + } + if ('page' in next && !('currentPage' in next)) { + next.currentPage = next.page + } + if ('currentPage' in next && !('page' in next)) { + next.page = next.currentPage + } + + return next as T +} diff --git a/apps/admin/src/app/api/links.ts b/apps/admin/src/app/api/links.ts new file mode 100644 index 000000000..01081416e --- /dev/null +++ b/apps/admin/src/app/api/links.ts @@ -0,0 +1,74 @@ +import type { LinkModel, LinkResponse, LinkStateCount } from '~/app/models/link' + +import { + deleteJson, + getJson, + patchJson, + postJson, + putJson, + requestJson, +} from './http' + +export interface GetLinksParams { + page: number + size: number + state: number +} + +export interface LinkInput { + avatar?: string + description?: string + name: string + state?: number + type?: number + url: string +} + +export function getLinks(params: GetLinksParams) { + return getJson('/links', { + page: params.page, + size: params.size, + state: params.state, + }) +} + +export function getLinkStateCount() { + return getJson('/links/state') +} + +export function createLink(data: LinkInput) { + return postJson('/links', data) +} + +export function updateLink(id: string, data: Partial) { + return putJson>(`/links/${id}`, data) +} + +export function deleteLink(id: string) { + return deleteJson(`/links/${id}`) +} + +export function auditPassLink(id: string) { + return requestJson(`/links/audit/${id}`, { method: 'PATCH' }) +} + +export function auditLinkWithReason( + id: string, + data: { reason: string; state: number }, +) { + return postJson(`/links/audit/reason/${id}`, data) +} + +export function updateLinkState(id: string, state: number) { + return patchJson(`/links/${id}`, { state }) +} + +export function checkLinksHealth() { + return getJson< + Record + >('/links/health') +} + +export function migrateLinkAvatars() { + return requestJson('/links/avatar/migrate', { method: 'POST' }) +} diff --git a/apps/admin/src/app/api/markdown.ts b/apps/admin/src/app/api/markdown.ts new file mode 100644 index 000000000..e5f0f4276 --- /dev/null +++ b/apps/admin/src/app/api/markdown.ts @@ -0,0 +1,49 @@ +import { API_URL } from '~/app/constants/env' + +import { postJson } from './http' + +export interface MarkdownImportData { + content?: string + data?: unknown[] + type?: 'note' | 'page' | 'post' +} + +export interface MarkdownExportParams { + id?: string + show_title?: boolean + slug?: boolean + type?: 'note' | 'page' | 'post' + with_meta_json?: boolean + yaml?: boolean +} + +export function importMarkdown(data: MarkdownImportData) { + return postJson<{ id: string }, MarkdownImportData>('/markdown/import', data) +} + +export async function exportMarkdown(params?: MarkdownExportParams) { + const searchParams = new URLSearchParams() + + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) searchParams.set(key, String(value)) + } + } + + const query = searchParams.toString() + const response = await fetch( + `${API_URL}/markdown/export${query ? `?${query}` : ''}`, + { + credentials: 'include', + headers: { + 'x-skip-translation': '1', + }, + }, + ) + + if (!response.ok) { + throw new Error(response.statusText || 'Export failed') + } + + return response.blob() +} diff --git a/apps/admin/src/app/api/meta-presets.ts b/apps/admin/src/app/api/meta-presets.ts new file mode 100644 index 000000000..8d0563c64 --- /dev/null +++ b/apps/admin/src/app/api/meta-presets.ts @@ -0,0 +1,45 @@ +import type { + CreateMetaPresetDto, + MetaPresetField, + MetaPresetScope, + UpdateMetaPresetDto, +} from '~/app/models/meta-preset' + +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' + +export interface MetaPresetQueryParams { + enabledOnly?: boolean + scope?: MetaPresetScope +} + +export function getMetaPresets(params?: MetaPresetQueryParams) { + return getJson('/meta-presets', { + enabledOnly: params?.enabledOnly, + scope: params?.scope, + }) +} + +export function getMetaPreset(id: string) { + return getJson(`/meta-presets/${id}`) +} + +export function createMetaPreset(data: CreateMetaPresetDto) { + return postJson('/meta-presets', data) +} + +export function updateMetaPreset(id: string, data: UpdateMetaPresetDto) { + return patchJson( + `/meta-presets/${id}`, + data, + ) +} + +export function deleteMetaPreset(id: string) { + return deleteJson(`/meta-presets/${id}`) +} + +export function updateMetaPresetOrder(ids: string[]) { + return putJson('/meta-presets/order', { + ids, + }) +} diff --git a/apps/admin/src/app/api/notes.ts b/apps/admin/src/app/api/notes.ts new file mode 100644 index 000000000..2ca38a1ca --- /dev/null +++ b/apps/admin/src/app/api/notes.ts @@ -0,0 +1,105 @@ +import type { PaginateResult } from '~/app/models/base' +import type { NoteModel } from '~/app/models/note' + +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' + +export type NoteSortKey = + | 'createdAt' + | 'modifiedAt' + | 'mood' + | 'title' + | 'weather' +export type SortOrder = 'asc' | 'desc' + +export interface GetNotesParams { + db_query?: Record + page?: number + size?: number + sort_by?: NoteSortKey + sort_order?: SortOrder + topicId?: null | string +} + +export interface SearchNotesParams { + keyword: string + page: number + size: number +} + +export interface CreateNoteData { + bookmark?: boolean + content?: string + contentFormat?: 'lexical' | 'markdown' + coordinates?: null | { + latitude: number + longitude: number + } + draftId?: string + isPublished?: boolean + location?: null | string + meta?: Record + mood?: string + password?: null | string + publicAt?: Date | null | string + slug?: string + text: string + title: string + topicId?: null | string + weather?: string +} + +export interface PatchNoteData { + [key: string]: unknown + slug?: null | string + topicId?: null | string +} + +export function getNotes(params: GetNotesParams = {}) { + return getJson>('/notes', { + db_query: params.db_query, + page: params.page, + size: params.size, + sort_by: params.sort_by, + sort_order: params.sort_order, + topicId: params.topicId ?? undefined, + }) +} + +export function searchNotes(params: SearchNotesParams) { + return getJson>('/search/note', { + keyword: params.keyword, + page: params.page, + size: params.size, + }) +} + +export function getNoteById(id: string, params?: { single?: boolean }) { + return getJson(`/notes/${id}`, { + single: params?.single ? 'true' : undefined, + }) +} + +export function createNote(data: CreateNoteData) { + return postJson('/notes', data) +} + +export function updateNote(id: string, data: Partial) { + return putJson>(`/notes/${id}`, data) +} + +export function patchNote(id: string, data: PatchNoteData) { + return patchJson(`/notes/${id}`, data) +} + +export function patchNotePublish(id: string, isPublished: boolean) { + return patchJson( + `/notes/${id}/publish`, + { + isPublished, + }, + ) +} + +export function deleteNote(id: string) { + return deleteJson(`/notes/${id}`) +} diff --git a/apps/admin/src/app/api/options.ts b/apps/admin/src/app/api/options.ts new file mode 100644 index 000000000..3ea53dfff --- /dev/null +++ b/apps/admin/src/app/api/options.ts @@ -0,0 +1,136 @@ +import type { UserModel } from '~/app/models/user' + +import { deleteJson, getJson, patchJson, putJson } from './http' + +export interface SystemOptions { + [key: string]: unknown +} + +export interface UrlOptions { + adminUrl: string + serverUrl: string + webUrl: string + wsUrl: string +} + +export interface EmailTemplateResponse { + props: unknown + template: string +} + +export type ConfigFieldComponent = + | 'action' + | 'input' + | 'number' + | 'password' + | 'select' + | 'switch' + | 'tags' + | 'textarea' + +export interface ConfigFieldUi { + actionId?: string + actionLabel?: string + component: ConfigFieldComponent + halfGrid?: boolean + hidden?: boolean + options?: Array<{ label: string; value: number | string }> + placeholder?: string + showWhen?: Record< + string, + boolean | number | string | Array + > +} + +export interface ConfigFormField { + description?: string + fields?: ConfigFormField[] + key: string + required?: boolean + subsection?: { + description?: string + title: string + } + title: string + ui: ConfigFieldUi +} + +export interface ConfigFormSection { + description?: string + fields: ConfigFormField[] + hidden?: boolean + key: string + title: string +} + +export interface ConfigFormGroup { + description: string + icon: string + key: string + sections: ConfigFormSection[] + title: string +} + +export interface ConfigFormSchema { + defaults: Record + description?: string + groups: ConfigFormGroup[] + title: string +} + +export interface UpdateOwnerData { + avatar?: string + introduce?: string + mail?: string + name?: string + socialIds?: Record + url?: string + username?: string +} + +export function getAllOptions() { + return getJson('/options') +} + +export function getFormSchema() { + return getJson('/config/form-schema') +} + +export function getOption(key: string) { + return getJson(`/options/${key}`) +} + +export function getUrlOptions() { + return getJson('/options/url') +} + +export function patchOption(key: string, data: unknown) { + return patchJson(`/options/${key}`, data) +} + +export function getEmailTemplate(type: string) { + return getJson('/options/email/template', { type }) +} + +export function updateEmailTemplate(type: string, source: string) { + return putJson( + `/options/email/template?type=${encodeURIComponent(type)}`, + { + source, + }, + ) +} + +export function deleteEmailTemplate(type: string) { + return deleteJson( + `/options/email/template?type=${encodeURIComponent(type)}`, + ) +} + +export function getOwner() { + return getJson('/owner') +} + +export function updateOwner(data: UpdateOwnerData) { + return patchJson('/owner', data) +} diff --git a/apps/admin/src/app/api/pages.ts b/apps/admin/src/app/api/pages.ts new file mode 100644 index 000000000..9442c45e8 --- /dev/null +++ b/apps/admin/src/app/api/pages.ts @@ -0,0 +1,51 @@ +import type { PaginateResult } from '~/app/models/base' +import type { PageModel } from '~/app/models/page' + +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' + +export interface GetPagesParams { + page?: number + size?: number +} + +export interface CreatePageData { + content?: string + contentFormat?: 'lexical' | 'markdown' + draftId?: string + meta?: Record + order?: number + slug: string + subtitle?: string + text: string + title: string +} + +export function getPages(params: GetPagesParams = {}) { + return getJson>('/pages', { + page: params.page, + size: params.size, + }) +} + +export function getPageById(id: string) { + return getJson(`/pages/${id}`) +} + +export function createPage(data: CreatePageData) { + return postJson('/pages', data) +} + +export function updatePage(id: string, data: Partial) { + return putJson>(`/pages/${id}`, data) +} + +export function deletePage(id: string) { + return deleteJson(`/pages/${id}`) +} + +export function reorderPages(seq: Array<{ id: string; order: number }>) { + return patchJson }>( + '/pages/reorder', + { seq }, + ) +} diff --git a/apps/admin/src/app/api/posts.ts b/apps/admin/src/app/api/posts.ts new file mode 100644 index 000000000..55b859f10 --- /dev/null +++ b/apps/admin/src/app/api/posts.ts @@ -0,0 +1,77 @@ +import type { PaginateResult } from '~/app/models/base' +import type { PostModel } from '~/app/models/post' + +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' + +export type PostSortKey = 'createdAt' | 'modifiedAt' | 'pinAt' +export type PostSortOrder = 'asc' | 'desc' + +export interface GetPostsParams { + categoryIds?: string[] + page: number + size: number + sort_by?: PostSortKey + sort_order?: PostSortOrder +} + +export interface SearchPostsParams { + keyword: string + page: number + size: number +} + +export interface CreatePostData { + categoryId: string + content?: string + contentFormat?: 'lexical' | 'markdown' + copyright?: boolean + draftId?: string + isPublished?: boolean + meta?: Record + pin?: null | string + pinOrder?: null | number + relatedId?: string[] + slug?: string + summary?: null | string + tags?: string[] + text: string + title: string +} + +export function getPosts(params: GetPostsParams) { + return getJson>('/posts', { + categoryIds: params.categoryIds, + page: params.page, + size: params.size, + sort_by: params.sort_by, + sort_order: params.sort_order, + }) +} + +export function searchPosts(params: SearchPostsParams) { + return getJson>('/search/post', { + keyword: params.keyword, + page: params.page, + size: params.size, + }) +} + +export function getPostById(id: string) { + return getJson(`/posts/${id}`) +} + +export function createPost(data: CreatePostData) { + return postJson('/posts', data) +} + +export function updatePost(id: string, data: Partial) { + return putJson>(`/posts/${id}`, data) +} + +export function patchPost(id: string, data: Partial) { + return patchJson>(`/posts/${id}`, data) +} + +export function deletePost(id: string) { + return deleteJson(`/posts/${id}`) +} diff --git a/apps/admin/src/app/api/projects.ts b/apps/admin/src/app/api/projects.ts new file mode 100644 index 000000000..afb820771 --- /dev/null +++ b/apps/admin/src/app/api/projects.ts @@ -0,0 +1,34 @@ +import type { ProjectModel, ProjectResponse } from '~/app/models/project' + +import { deleteJson, getJson, postJson, putJson } from './http' + +export interface ProjectInput { + avatar?: string + description: string + docUrl?: string + images?: string[] + name: string + previewUrl?: string + projectUrl?: string + text: string +} + +export function getProjects(params: { page: number; size: number }) { + return getJson('/projects', params) +} + +export function getProject(id: string) { + return getJson(`/projects/${id}`) +} + +export function createProject(data: ProjectInput) { + return postJson('/projects', data) +} + +export function updateProject(id: string, data: Partial) { + return putJson>(`/projects/${id}`, data) +} + +export function deleteProject(id: string) { + return deleteJson(`/projects/${id}`) +} diff --git a/apps/admin/src/app/api/readers.ts b/apps/admin/src/app/api/readers.ts new file mode 100644 index 000000000..6fc67bbe8 --- /dev/null +++ b/apps/admin/src/app/api/readers.ts @@ -0,0 +1,18 @@ +import type { PaginateResult } from '~/app/models/base' + +import { getJson } from './http' + +export interface ReaderModel { + id: string + provider?: string + type?: string + name: string + email: string + image: string + handle?: string + role: 'reader' | 'owner' +} + +export function getReaders(params: { page: number; size: number }) { + return getJson>('/readers', params) +} diff --git a/apps/admin/src/app/api/recently.ts b/apps/admin/src/app/api/recently.ts new file mode 100644 index 000000000..7d75e050b --- /dev/null +++ b/apps/admin/src/app/api/recently.ts @@ -0,0 +1,23 @@ +import type { RecentlyModel } from '~/app/models/recently' + +import { deleteJson, getJson, postJson, putJson } from './http' + +export interface RecentlyInput { + content: string +} + +export function getRecentlyList() { + return getJson('/recently/all') +} + +export function createRecently(data: RecentlyInput) { + return postJson('/recently', data) +} + +export function updateRecently(id: string, data: RecentlyInput) { + return putJson(`/recently/${id}`, data) +} + +export function deleteRecently(id: string) { + return deleteJson(`/recently/${id}`) +} diff --git a/apps/admin/src/app/api/says.ts b/apps/admin/src/app/api/says.ts new file mode 100644 index 000000000..d35faedfe --- /dev/null +++ b/apps/admin/src/app/api/says.ts @@ -0,0 +1,25 @@ +import type { SayModel, SayResponse } from '~/app/models/say' + +import { deleteJson, getJson, postJson, putJson } from './http' + +export interface SayInput { + author?: string + source?: string + text: string +} + +export function getSays(params: { page: number; size: number }) { + return getJson('/says', params) +} + +export function createSay(data: SayInput) { + return postJson('/says', data) +} + +export function updateSay(id: string, data: Partial) { + return putJson>(`/says/${id}`, data) +} + +export function deleteSay(id: string) { + return deleteJson(`/says/${id}`) +} diff --git a/apps/admin/src/app/api/search-index.ts b/apps/admin/src/app/api/search-index.ts new file mode 100644 index 000000000..4037d680a --- /dev/null +++ b/apps/admin/src/app/api/search-index.ts @@ -0,0 +1,83 @@ +import { getJson, requestJson } from './http' + +export interface SearchIndexLegacyPager { + currentPage: number + hasNextPage: boolean + hasPrevPage: boolean + size: number + total: number + totalPage: number +} + +export type SearchIndexRefType = 'note' | 'page' | 'post' + +export interface SearchIndexRebuildResult { + created: number + deleted: number + skipped: number + total: number + updated: number +} + +export interface SearchIndexRebuildOneResult { + rebuilt: number +} + +export interface SearchDocumentAdminRow { + bodyLength: number + createdAt: string + hasPassword: boolean + id: string + isPublished: boolean + lang: string + modifiedAt: string + publicAt: string | null + refId: string + refType: SearchIndexRefType | string + sourceHash: string + title: string + titleLength: number +} + +export interface SearchDocumentAdminListResponse { + data: SearchDocumentAdminRow[] + pagination: SearchIndexLegacyPager +} + +export interface SearchDocumentAdminListParams { + keyword?: string + lang?: string + page?: number + refType?: SearchIndexRefType | string + size?: number +} + +export function rebuildSearchIndex(force = false) { + return requestJson( + force ? '/search/rebuild?force=true' : '/search/rebuild', + { + method: 'POST', + }, + ) +} + +export function rebuildSearchIndexDocument(refType: string, refId: string) { + return requestJson( + `/search/rebuild/${encodeURIComponent(refType)}/${encodeURIComponent(refId)}`, + { + method: 'POST', + }, + ) +} + +export function getSearchIndexDocuments( + params: SearchDocumentAdminListParams = {}, +) { + return getJson('/search/admin/documents', { + keyword: params.keyword, + lang: params.lang, + page: params.page, + refType: params.refType, + size: params.size, + }) +} diff --git a/apps/admin/src/app/api/serverless.ts b/apps/admin/src/app/api/serverless.ts new file mode 100644 index 000000000..209c1f7f7 --- /dev/null +++ b/apps/admin/src/app/api/serverless.ts @@ -0,0 +1,59 @@ +import { getJson } from './http' + +export interface ServerlessLogEntry { + createdAt: string + error?: { message: string; name: string; stack?: string } + executionTime: number + functionId: string + id: string + ip: string + logs?: Array<{ args: unknown[]; level: string; timestamp: number }> + method: string + name: string + reference: string + status: 'error' | 'success' +} + +export interface ServerlessLogPagination { + currentPage: number + hasNextPage: boolean + hasPrevPage: boolean + size: number + total: number + totalPage: number +} + +export interface ServerlessLogListResponse { + data: ServerlessLogEntry[] + pagination: ServerlessLogPagination +} + +export interface GetServerlessLogsParams { + page?: number + size?: number + status?: 'error' | 'success' +} + +export function getInvocationLogs( + id: string, + params?: GetServerlessLogsParams, +) { + return getJson( + `/fn/logs/${id}`, + params + ? { + page: params.page, + size: params.size, + status: params.status, + } + : undefined, + ) +} + +export function getInvocationLogDetail(id: string) { + return getJson(`/fn/log/${id}`) +} + +export function getCompiledCode(id: string) { + return getJson(`/fn/compiled/${id}`) +} diff --git a/apps/admin/src/app/api/snippets.ts b/apps/admin/src/app/api/snippets.ts new file mode 100644 index 000000000..7fa15d495 --- /dev/null +++ b/apps/admin/src/app/api/snippets.ts @@ -0,0 +1,85 @@ +import type { PaginateResult } from '~/app/models/base' +import type { SnippetModel, SnippetType } from '~/app/models/snippet' + +import { deleteJson, getJson, postJson, putJson } from './http' + +export interface GetSnippetsParams { + page?: number + reference?: string + size?: number + type?: SnippetType +} + +export interface CreateSnippetData { + comment?: string + customPath?: string + enable?: boolean + metatype?: string + method?: string + name: string + private?: boolean + raw: string + reference?: string + schema?: string + secret?: Record | string | null + type: SnippetType +} + +export interface SnippetGroup { + count: number + reference: string +} + +export interface ImportSnippetsData { + packages?: string[] + snippets: Array +} + +export function getSnippets(params: GetSnippetsParams = {}) { + return getJson>('/snippets', { + page: params.page, + reference: params.reference, + size: params.size, + type: params.type, + }) +} + +export function getSnippetById(id: string) { + return getJson(`/snippets/${id}`) +} + +export function createSnippet(data: CreateSnippetData) { + return postJson('/snippets', data) +} + +export function updateSnippet(id: string, data: Partial) { + return putJson>( + `/snippets/${id}`, + data, + ) +} + +export function deleteSnippet(id: string) { + return deleteJson(`/snippets/${id}`) +} + +export function getSnippetGroups(params?: { page?: number; size?: number }) { + return getJson>('/snippets/group', { + page: params?.page, + size: params?.size, + }) +} + +export function getGroupSnippets(reference: string) { + return getJson( + `/snippets/group/${encodeURIComponent(reference)}`, + ) +} + +export function resetFunctionSnippet(id: string) { + return deleteJson(`/fn/reset/${id}`) +} + +export function importSnippets(data: ImportSnippetsData) { + return postJson('/snippets/import', data) +} diff --git a/apps/admin/src/app/api/subscribe.ts b/apps/admin/src/app/api/subscribe.ts new file mode 100644 index 000000000..d8ef6a9b7 --- /dev/null +++ b/apps/admin/src/app/api/subscribe.ts @@ -0,0 +1,51 @@ +import { deleteJson, getJson, patchJson } from './http' + +export const SubscribePostCreateBit = 1 +export const SubscribeNoteCreateBit = 2 +export const SubscribeSayCreateBit = 4 +export const SubscribeRecentCreateBit = 8 + +export interface Subscriber { + cancelToken: string + createdAt: string + email: string + id: string + subscribe: number + verified: boolean +} + +export interface SubscribeResponse { + data: Subscriber[] + pagination: { + currentPage: number + hasNextPage: boolean + hasPrevPage: boolean + size: number + total: number + totalPage: number + } +} + +export function getSubscribeStatus() { + return getJson<{ enable: boolean }>('/subscribe/status') +} + +export function getSubscribers(params: { page: number; size: number }) { + return getJson('/subscribe', { + page: params.page, + size: params.size, + }) +} + +export function updateSubscribeEnabled(enabled: boolean) { + return patchJson('/options/featureList', { + emailSubscribe: enabled, + }) +} + +export function unsubscribeBatch(params: { all: true } | { emails: string[] }) { + return deleteJson<{ deletedCount: number }, typeof params>( + '/subscribe/unsubscribe/batch', + params, + ) +} diff --git a/apps/admin/src/app/api/system.ts b/apps/admin/src/app/api/system.ts new file mode 100644 index 000000000..cbeb1932a --- /dev/null +++ b/apps/admin/src/app/api/system.ts @@ -0,0 +1,76 @@ +import type { AppInfo } from '~/app/models/system' + +import { API_URL } from '~/app/constants/env' + +import { getJson, patchJson, postJson, requestJson } from './http' + +export interface CreateOwnerData { + avatar?: string + introduce?: string + mail: string + name?: string + password: string + url?: string + username: string +} + +export interface InitDefaultConfigs { + seo?: { + description?: string + keywords?: string[] + title?: string + } +} + +export async function checkInit() { + try { + const response = await fetch(`${API_URL}/init`, { + credentials: 'include', + headers: { + 'x-skip-translation': '1', + }, + }) + + if (response.status === 404 || response.status === 403) { + return { isInit: true } + } + + if (!response.ok) + throw new Error(response.statusText || 'Init check failed') + + return (await response.json()) as { isInit: boolean } + } catch (error) { + if (error instanceof Error) throw error + throw new Error('Init check failed') + } +} + +export function getAppInfo() { + return getJson('/') +} + +export function getInitDefaultConfigs() { + return getJson('/init/configs/default') +} + +export function patchInitConfig(key: string, data: TData) { + return patchJson(`/init/configs/${key}`, data) +} + +export function restoreFromBackup(formData: FormData) { + return requestJson('/init/restore', { + body: formData, + method: 'POST', + }) +} + +export function createOwner(data: CreateOwnerData) { + return postJson('/init/owner', data) +} + +export function callBuiltInFunction( + name: string, + params?: Record, +) { + return getJson(`/fn/built-in/${name}`, params) +} diff --git a/apps/admin/src/app/api/topics.ts b/apps/admin/src/app/api/topics.ts new file mode 100644 index 000000000..f11b1b298 --- /dev/null +++ b/apps/admin/src/app/api/topics.ts @@ -0,0 +1,60 @@ +import type { PaginateResult } from '~/app/models/base' +import type { NoteModel } from '~/app/models/note' +import type { TopicModel } from '~/app/models/topic' + +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' + +export interface GetTopicsParams { + page?: number + size?: number +} + +export interface CreateTopicData { + description?: string + icon?: string + introduce: string + name: string + slug: string +} + +export type UpdateTopicData = Partial + +export function getTopics(params: GetTopicsParams = {}) { + return getJson>('/topics', { + page: params.page, + size: params.size, + }) +} + +export function getTopic(id: string) { + return getJson(`/topics/${id}`) +} + +export function createTopic(data: CreateTopicData) { + return postJson('/topics', data) +} + +export function updateTopic(id: string, data: UpdateTopicData) { + return putJson(`/topics/${id}`, data) +} + +export function patchTopic(id: string, data: Partial) { + return patchJson>(`/topics/${id}`, data) +} + +export function deleteTopic(id: string) { + return deleteJson(`/topics/${id}`) +} + +export function getNotesByTopic( + topicId: string, + params: { page?: number; size?: number } = {}, +) { + return getJson>>( + `/notes/topics/${topicId}`, + { + page: params.page, + size: params.size, + }, + ) +} diff --git a/apps/admin/src/app/api/webhooks.ts b/apps/admin/src/app/api/webhooks.ts new file mode 100644 index 000000000..63898575b --- /dev/null +++ b/apps/admin/src/app/api/webhooks.ts @@ -0,0 +1,84 @@ +import type { PaginateResult } from '~/app/models/base' + +import { deleteJson, getJson, patchJson, postJson } from './http' + +export interface WebhookModel { + created: string + enabled: boolean + events: string[] + id: string + payloadUrl: string + scope: number + secret?: string + updated: string + url: string +} + +export interface WebhookInput { + enabled?: boolean + events: string[] + payloadUrl?: string + scope?: number + secret?: string + url?: string +} + +export interface WebhookEventRecord { + event: string + headers: Record + hookId: string + id: string + payload: unknown + response: unknown + status: number + success: boolean + timestamp: string +} + +export const EventScope = { + ALL: (1 << 0) | (1 << 1) | (1 << 2), + TO_ADMIN: 1 << 1, + TO_SYSTEM: 1 << 2, + TO_VISITOR: 1 << 0, +} as const + +export function getWebhooks() { + return getJson('/webhooks') +} + +export function getWebhookEvents() { + return getJson('/webhooks/events') +} + +export function createWebhook(data: WebhookInput) { + return postJson('/webhooks', data) +} + +export function updateWebhook(id: string, data: Partial) { + return patchJson>(`/webhooks/${id}`, data) +} + +export function deleteWebhook(id: string) { + return deleteJson(`/webhooks/${id}`) +} + +export function testWebhook(id: string, event: string) { + return postJson(`/webhooks/${id}/test`, { event }) +} + +export function getWebhookDispatches( + id: string, + params: { page: number; size: number }, +) { + return getJson>(`/webhooks/${id}`, { + page: params.page, + size: params.size, + }) +} + +export function redispatchWebhook(hookId: string, eventId: string) { + return postJson>( + `/webhooks/${hookId}/redispatch/${eventId}`, + {}, + ) +} diff --git a/apps/admin/src/constants/env.ts b/apps/admin/src/app/constants/env.ts similarity index 100% rename from apps/admin/src/constants/env.ts rename to apps/admin/src/app/constants/env.ts diff --git a/apps/admin/src/constants/keys.ts b/apps/admin/src/app/constants/keys.ts similarity index 68% rename from apps/admin/src/constants/keys.ts rename to apps/admin/src/app/constants/keys.ts index f89f79fff..879e6cfc6 100644 --- a/apps/admin/src/constants/keys.ts +++ b/apps/admin/src/app/constants/keys.ts @@ -3,3 +3,5 @@ export const enum EmitKeyMap { } export const SESSION_WITH_LOGIN = 'session-with-login' + +export const UI_LOCALE_STORAGE_KEY = 'mx-admin-ui-locale' diff --git a/apps/admin/src/app/hooks/use-local-storage-state.ts b/apps/admin/src/app/hooks/use-local-storage-state.ts new file mode 100644 index 000000000..c618f57d6 --- /dev/null +++ b/apps/admin/src/app/hooks/use-local-storage-state.ts @@ -0,0 +1,22 @@ +import { useState } from 'react' + +export function useLocalStorageState(key: string, initialValue: T) { + const [state, setState] = useState(() => { + const rawValue = window.localStorage.getItem(key) + + if (!rawValue) return initialValue + + try { + return JSON.parse(rawValue) as T + } catch { + return rawValue as T + } + }) + + const setStoredState = (nextState: T) => { + setState(nextState) + window.localStorage.setItem(key, JSON.stringify(nextState)) + } + + return [state, setStoredState] as const +} diff --git a/apps/admin/src/app/i18n/formatters.ts b/apps/admin/src/app/i18n/formatters.ts new file mode 100644 index 000000000..ab70930cd --- /dev/null +++ b/apps/admin/src/app/i18n/formatters.ts @@ -0,0 +1,60 @@ +import type { Locale } from './types' + +export function formatDateTime( + value: Date | number | string, + locale: Locale, + options?: Intl.DateTimeFormatOptions, +) { + const date = value instanceof Date ? value : new Date(value) + + if (Number.isNaN(date.getTime())) return 'N/A' + + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + ...options, + }).format(date) +} + +export function formatNumber( + value: number, + locale: Locale, + options?: Intl.NumberFormatOptions, +) { + return new Intl.NumberFormat(locale, options).format(value) +} + +export function formatRelativeTime( + value: Date | number | string, + locale: Locale, + current = new Date(), +) { + const date = value instanceof Date ? value : new Date(value) + + if (Number.isNaN(date.getTime())) return '-' + + const elapsedSeconds = Math.round((date.getTime() - current.getTime()) / 1000) + const divisions = [ + { amount: 60, unit: 'second' }, + { amount: 60, unit: 'minute' }, + { amount: 24, unit: 'hour' }, + { amount: 30, unit: 'day' }, + { amount: 12, unit: 'month' }, + ] as const + + let duration = elapsedSeconds + for (const division of divisions) { + if (Math.abs(duration) < division.amount) { + return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format( + duration, + division.unit, + ) + } + duration = Math.round(duration / division.amount) + } + + return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format( + duration, + 'year', + ) +} diff --git a/apps/admin/src/app/i18n/index.tsx b/apps/admin/src/app/i18n/index.tsx new file mode 100644 index 000000000..9d9974c72 --- /dev/null +++ b/apps/admin/src/app/i18n/index.tsx @@ -0,0 +1,108 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' +import type { PropsWithChildren } from 'react' +import type { Locale, TranslationKey, TranslationValues } from './types' + +import { UI_LOCALE_STORAGE_KEY } from '../constants/keys' +import { formatDateTime, formatNumber, formatRelativeTime } from './formatters' +import { DEFAULT_LOCALE, messages, SUPPORTED_LOCALES } from './resources' + +interface I18nContextValue { + format: { + dateTime: ( + value: Date | number | string, + options?: Intl.DateTimeFormatOptions, + ) => string + number: (value: number, options?: Intl.NumberFormatOptions) => string + relativeTime: (value: Date | number | string, current?: Date) => string + } + locale: Locale + setLocale: (locale: Locale) => void + t: (key: TranslationKey, values?: TranslationValues) => string +} + +const I18nContext = createContext(null) + +export function I18nProvider(props: PropsWithChildren) { + const [locale, setLocaleState] = useState(readInitialLocale) + + const setLocale = useCallback((nextLocale: Locale) => { + setLocaleState(nextLocale) + localStorage.setItem(UI_LOCALE_STORAGE_KEY, nextLocale) + document.documentElement.lang = nextLocale + }, []) + + const t = useCallback( + (key: TranslationKey, values?: TranslationValues) => { + const template = + messages[locale][key] ?? messages[DEFAULT_LOCALE][key] ?? key + + if (!values) return template + + return template.replace(/\{(\w+)\}/g, (match, name: string) => + Object.hasOwn(values, name) ? String(values[name]) : match, + ) + }, + [locale], + ) + + const format = useMemo( + () => ({ + dateTime: ( + value: Date | number | string, + options?: Intl.DateTimeFormatOptions, + ) => formatDateTime(value, locale, options), + number: (value: number, options?: Intl.NumberFormatOptions) => + formatNumber(value, locale, options), + relativeTime: (value: Date | number | string, current?: Date) => + formatRelativeTime(value, locale, current), + }), + [locale], + ) + + const value = useMemo( + () => ({ format, locale, setLocale, t }), + [format, locale, setLocale, t], + ) + + return ( + {props.children} + ) +} + +export function useI18n() { + const context = useContext(I18nContext) + + if (!context) { + throw new Error('useI18n must be used inside I18nProvider') + } + + return context +} + +export function isSupportedLocale(value: string): value is Locale { + return SUPPORTED_LOCALES.includes(value as Locale) +} + +function readInitialLocale(): Locale { + const storedLocale = localStorage.getItem(UI_LOCALE_STORAGE_KEY) + if (storedLocale && isSupportedLocale(storedLocale)) { + document.documentElement.lang = storedLocale + return storedLocale + } + + const browserLocale = + navigator.languages.find(isSupportedLocale) || + navigator.languages.find((locale) => locale.startsWith('zh')) || + navigator.language + + const locale = browserLocale?.startsWith('zh') ? 'zh-CN' : DEFAULT_LOCALE + document.documentElement.lang = locale + + return locale +} diff --git a/apps/admin/src/app/i18n/resources.ts b/apps/admin/src/app/i18n/resources.ts new file mode 100644 index 000000000..664321eb3 --- /dev/null +++ b/apps/admin/src/app/i18n/resources.ts @@ -0,0 +1,13 @@ +import type { Locale, TranslationKey } from './types' + +import { enUS } from './resources/en-US' +import { zhCN } from './resources/zh-CN' + +export const DEFAULT_LOCALE: Locale = 'zh-CN' + +export const SUPPORTED_LOCALES = ['zh-CN', 'en-US'] as const + +export const messages: Record> = { + 'en-US': enUS, + 'zh-CN': zhCN, +} diff --git a/apps/admin/src/app/i18n/resources/en-US.ts b/apps/admin/src/app/i18n/resources/en-US.ts new file mode 100644 index 000000000..9c183e985 --- /dev/null +++ b/apps/admin/src/app/i18n/resources/en-US.ts @@ -0,0 +1,188 @@ +import type { TranslationKey } from '../types' + +export const enUS = { + 'app.loading.auth': 'Checking sign-in state...', + + 'auth.login.failed': 'Sign-in failed', + 'auth.login.github': 'Sign in with GitHub', + 'auth.login.google': 'Sign in with Google', + 'auth.login.loadingProfile': 'Loading authentication profile', + 'auth.login.ownerUsernameMissing': 'Owner username is unavailable', + 'auth.login.passkey': 'Sign in with Passkey', + 'auth.login.passkeyFailed': 'Passkey verification failed', + 'auth.login.passkeySucceeded': 'Passkey verification succeeded', + 'auth.login.passwordLabel': 'Password', + 'auth.login.passwordPlaceholder': 'Enter password', + 'auth.login.submit': 'Sign in', + 'auth.login.welcomeBack': 'Welcome back', + + 'common.locale.en-US': 'English', + 'common.locale.zh-CN': 'Simplified Chinese', + 'common.openMainSite': 'Open main site', + 'common.pagination.nextPage': 'Next page', + 'common.pagination.pageSize': '{count} / page', + 'common.pagination.previousPage': 'Previous page', + 'common.primaryNavigation': 'Primary navigation', + + 'routes.ai.description': 'AI task surfaces and enrichment workflows.', + 'routes.ai.title': 'AI', + 'routes.aiInsights.description': + 'AI insights, insight translations, and generation tasks.', + 'routes.aiInsights.title': 'Insights', + 'routes.aiSlugBackfill.description': + 'Backfill AI-generated slugs for existing content.', + 'routes.aiSlugBackfill.title': 'Slug Backfill', + 'routes.aiSummary.description': + 'AI summary results and batch generation status.', + 'routes.aiSummary.title': 'Summary', + 'routes.aiTasks.description': + 'AI background task queue, retry, and cancellation controls.', + 'routes.aiTasks.title': 'Task Queue', + 'routes.aiTranslation.description': + 'AI translation results and batch translation workflows.', + 'routes.aiTranslation.title': 'Translation', + 'routes.aiTranslationEntries.description': + 'AI translation glossary and terminology maintenance.', + 'routes.aiTranslationEntries.title': 'Glossary', + 'routes.analyze.description': + 'Traffic metrics, paths, IP records, and visitor analysis.', + 'routes.analyze.title': 'Analyze', + 'routes.backups.description': + 'Database backup archives and restore operations.', + 'routes.backups.title': 'Backups', + 'routes.categories.description': + 'Posts categories, tags, and their associated articles.', + 'routes.categories.title': 'Categories', + 'routes.commentImages.description': + 'Reader comment image uploads and binding status.', + 'routes.commentImages.title': 'Comment Images', + 'routes.comments.description': + 'Comments, moderation, and reader-facing feedback.', + 'routes.comments.title': 'Comments', + 'routes.cron.description': + 'Scheduled task definitions, execution status, and logs.', + 'routes.cron.title': 'Cron', + 'routes.dashboard.description': 'Runtime status and environment data.', + 'routes.dashboard.title': 'Dashboard', + 'routes.debug.description': 'Development and diagnostic tools.', + 'routes.drafts.description': + 'Autosaved drafts, versions, and content recovery.', + 'routes.drafts.title': 'Drafts', + 'routes.enrichment.description': + 'Cache, screenshots, probes, and derived content assets.', + 'routes.enrichment.title': 'Enrichment', + 'routes.eventLab.description': + 'Synthetic socket event payloads and dispatch checks.', + 'routes.eventLab.title': 'Event Lab', + 'routes.extraFeatures.description': + 'Subscription, webhook, template, and import/export tools.', + 'routes.files.description': + 'Uploaded files, orphan images, and comment image uploads.', + 'routes.files.title': 'Files', + 'routes.friends.description': + 'Friend links, link review, and site health checks.', + 'routes.friends.title': 'Friends', + 'routes.functionLab.description': + 'Serverless function execution and response diagnostics.', + 'routes.functionLab.title': 'Function Lab', + 'routes.markdown.description': + 'Markdown import, parsing preview, and archive export.', + 'routes.markdown.title': 'Markdown', + 'routes.maintenance.description': + 'Background maintenance tasks, backups, and search indexing.', + 'routes.manageFiles.description': 'Uploaded file list and file operations.', + 'routes.manageFiles.title': 'File Manager', + 'routes.manageNotes.description': + 'Note list, state filters, and batch operations.', + 'routes.manageNotes.title': 'Manage', + 'routes.managePages.description': + 'Static page list, ordering metadata, and page operations.', + 'routes.managePages.title': 'Manage', + 'routes.managePosts.description': + 'Post list, state filters, and batch operations.', + 'routes.managePosts.title': 'Manage', + 'routes.notes.description': + 'Notes, publication state, and public note links.', + 'routes.notes.title': 'Notes', + 'routes.orphanImages.description': + 'Uploaded images that are no longer attached to content.', + 'routes.orphanImages.title': 'Orphan Images', + 'routes.pages.description': + 'Static pages, ordering metadata, and public page links.', + 'routes.pages.title': 'Pages', + 'routes.passkeyLab.description': + 'Passkey registration and authentication diagnostics.', + 'routes.passkeyLab.title': 'Passkey Lab', + 'routes.posts.description': + 'Posts, publishing state, search, and article operations.', + 'routes.posts.title': 'Posts', + 'routes.projects.description': + 'Project portfolio entries and publication metadata.', + 'routes.projects.title': 'Projects', + 'routes.readers.description': + 'Authenticated readers and provider identities.', + 'routes.readers.title': 'Readers', + 'routes.recently.description': + 'Short-form thoughts, links, and lightweight references.', + 'routes.recently.title': 'Recently', + 'routes.richLab.description': + 'Rich editor mounting, upload, and serialized content diagnostics.', + 'routes.richLab.title': 'Rich Lab', + 'routes.says.description': 'Short quotes, sayings, and source metadata.', + 'routes.says.title': 'Says', + 'routes.searchIndex.description': + 'Search document rows, rebuild controls, and index metadata.', + 'routes.searchIndex.title': 'Search Index', + 'routes.settings.description': 'Owner profile, URL data, and system options.', + 'routes.settings.title': 'Settings', + 'routes.snippets.description': + 'Configuration snippets and serverless function source.', + 'routes.snippets.title': 'Snippets', + 'routes.subscribe.description': + 'Email subscription status and subscriber management.', + 'routes.subscribe.title': 'Subscribe', + 'routes.templates.description': 'Email template source and sample payloads.', + 'routes.templates.title': 'Templates', + 'routes.toastLab.description': + 'React Sonner scenarios for status, loading, and actions.', + 'routes.toastLab.title': 'Toast Lab', + 'routes.topics.description': + 'Note topics, metadata, and associated note references.', + 'routes.topics.title': 'Topics', + 'routes.webhooks.description': + 'Outbound webhook endpoints and dispatch history.', + 'routes.webhooks.title': 'Webhooks', + 'routes.writeNote.description': + 'Create and edit notes in the React markdown writing surface.', + 'routes.writeNote.title': 'Write Note', + 'routes.writePage.description': + 'Create and edit static pages in the React writing surface.', + 'routes.writePage.title': 'Write Page', + 'routes.writePost.description': + 'Create and edit posts in the React markdown writing surface.', + 'routes.writePost.title': 'Write Post', + + 'shell.footer.runtime': 'React admin runtime.', + 'shell.locale.label': 'Interface language', + 'shell.logout': 'Sign out', + 'shell.nav.assets': 'Assets', + 'shell.nav.assets.description': + 'Files, templates, and import/export resources.', + 'shell.nav.community': 'Community', + 'shell.nav.community.description': + 'Comments, readers, friend links, and subscriptions.', + 'shell.nav.content': 'Content', + 'shell.nav.content.description': + 'Posts, notes, pages, and editorial entries.', + 'shell.nav.debug': 'Debug', + 'shell.nav.extra': 'Extensions', + 'shell.nav.maintenance': 'Maintenance', + 'shell.nav.system': 'System', + 'shell.nav.system.description': + 'AI, analytics, settings, and background maintenance.', + 'shell.owner.fallback': 'User', + 'shell.theme.dark': 'Dark', + 'shell.theme.label': 'Theme', + 'shell.theme.light': 'Light', + 'shell.theme.system': 'System', +} satisfies Record diff --git a/apps/admin/src/app/i18n/resources/zh-CN.ts b/apps/admin/src/app/i18n/resources/zh-CN.ts new file mode 100644 index 000000000..d3b677859 --- /dev/null +++ b/apps/admin/src/app/i18n/resources/zh-CN.ts @@ -0,0 +1,140 @@ +export const zhCN = { + 'app.loading.auth': '检查登录状态...', + + 'auth.login.failed': '登录失败', + 'auth.login.github': '使用 GitHub 登录', + 'auth.login.google': '使用 Google 登录', + 'auth.login.loadingProfile': '正在加载认证资料', + 'auth.login.ownerUsernameMissing': '主人用户名无法获取', + 'auth.login.passkey': '使用 Passkey 登录', + 'auth.login.passkeyFailed': 'Passkey 验证失败', + 'auth.login.passkeySucceeded': 'Passkey 验证成功', + 'auth.login.passwordLabel': '密码', + 'auth.login.passwordPlaceholder': '输入密码', + 'auth.login.submit': '登录', + 'auth.login.welcomeBack': '欢迎回来', + + 'common.locale.en-US': 'English', + 'common.locale.zh-CN': '简体中文', + 'common.openMainSite': '前往主站', + 'common.pagination.nextPage': '下一页', + 'common.pagination.pageSize': '{count} / 页', + 'common.pagination.previousPage': '上一页', + 'common.primaryNavigation': '主导航', + + 'routes.ai.description': 'AI 任务面板与内容增强工作流。', + 'routes.ai.title': 'AI', + 'routes.aiInsights.description': 'AI 精读结果、翻译与生成任务。', + 'routes.aiInsights.title': '精读', + 'routes.aiSlugBackfill.description': '为历史内容补全 AI 生成的 slug。', + 'routes.aiSlugBackfill.title': 'Slug 回填', + 'routes.aiSummary.description': 'AI 摘要结果与批量生成状态。', + 'routes.aiSummary.title': '摘要', + 'routes.aiTasks.description': 'AI 后台任务队列、重试与取消操作。', + 'routes.aiTasks.title': '任务队列', + 'routes.aiTranslation.description': 'AI 翻译结果与批量翻译流程。', + 'routes.aiTranslation.title': '翻译', + 'routes.aiTranslationEntries.description': 'AI 翻译词表与术语维护。', + 'routes.aiTranslationEntries.title': '翻译词表', + 'routes.analyze.description': '流量指标、访问路径、IP 记录与访客分析。', + 'routes.analyze.title': '分析', + 'routes.backups.description': '数据库备份归档与恢复操作。', + 'routes.backups.title': '备份', + 'routes.categories.description': '博文分类、标签与关联文章。', + 'routes.categories.title': '分类', + 'routes.commentImages.description': '读者评论图片上传与绑定状态。', + 'routes.commentImages.title': '评论图片', + 'routes.comments.description': '评论、审核与读者反馈。', + 'routes.comments.title': '评论', + 'routes.cron.description': '计划任务定义、执行状态与日志。', + 'routes.cron.title': '计划任务', + 'routes.dashboard.description': '运行状态与环境数据。', + 'routes.dashboard.title': '仪表盘', + 'routes.debug.description': '开发与诊断工具集合。', + 'routes.drafts.description': '自动保存草稿、版本与内容恢复。', + 'routes.drafts.title': '草稿', + 'routes.enrichment.description': '缓存、截图、探针与派生内容资产。', + 'routes.enrichment.title': '内容增强', + 'routes.eventLab.description': '模拟 socket 事件载荷与派发检查。', + 'routes.eventLab.title': '事件实验室', + 'routes.extraFeatures.description': '订阅、Webhook、模板与导入导出工具。', + 'routes.files.description': '上传文件、孤儿图片与评论图片审计。', + 'routes.files.title': '文件', + 'routes.friends.description': '友链、链接审核与站点健康检查。', + 'routes.friends.title': '友链', + 'routes.functionLab.description': 'Serverless 函数执行与响应诊断。', + 'routes.functionLab.title': '函数实验室', + 'routes.markdown.description': 'Markdown 导入、解析预览与归档导出。', + 'routes.markdown.title': 'Markdown', + 'routes.maintenance.description': '后台维护任务、备份与搜索索引。', + 'routes.manageFiles.description': '上传文件列表与文件操作。', + 'routes.manageFiles.title': '文件管理', + 'routes.manageNotes.description': '手记列表、状态筛选与批量操作。', + 'routes.manageNotes.title': '管理', + 'routes.managePages.description': '静态页面列表、排序与操作。', + 'routes.managePages.title': '管理', + 'routes.managePosts.description': '博文列表、状态筛选与批量操作。', + 'routes.managePosts.title': '管理', + 'routes.notes.description': '手记、发布状态与公开链接。', + 'routes.notes.title': '手记', + 'routes.orphanImages.description': '已上传但不再附着于内容的图片。', + 'routes.orphanImages.title': '孤儿图片', + 'routes.pages.description': '静态页面、排序元数据与公开链接。', + 'routes.pages.title': '页面', + 'routes.passkeyLab.description': 'Passkey 注册与认证诊断。', + 'routes.passkeyLab.title': 'Passkey 实验室', + 'routes.posts.description': '博文、发布状态、搜索与文章操作。', + 'routes.posts.title': '博文', + 'routes.projects.description': '项目作品集条目与发布元数据。', + 'routes.projects.title': '项目', + 'routes.readers.description': '认证读者与身份提供方。', + 'routes.readers.title': '读者', + 'routes.recently.description': '短想法、链接与轻量引用。', + 'routes.recently.title': '速记', + 'routes.richLab.description': '富文本编辑器挂载、上传与内容序列化调试。', + 'routes.richLab.title': '富文本实验室', + 'routes.says.description': '短句、说说与来源元数据。', + 'routes.says.title': '说说', + 'routes.searchIndex.description': '搜索文档行、重建控制与索引元数据。', + 'routes.searchIndex.title': '搜索索引', + 'routes.settings.description': '所有者资料、URL 数据与系统选项。', + 'routes.settings.title': '设置', + 'routes.snippets.description': '配置片段与 serverless 函数源码。', + 'routes.snippets.title': '代码片段', + 'routes.subscribe.description': '邮件订阅状态与订阅者管理。', + 'routes.subscribe.title': '订阅', + 'routes.templates.description': '邮件模板源码与示例载荷。', + 'routes.templates.title': '模板', + 'routes.toastLab.description': 'React Sonner 的状态、加载与操作场景。', + 'routes.toastLab.title': 'Toast 实验室', + 'routes.topics.description': '手记专栏、元数据与关联引用。', + 'routes.topics.title': '专栏', + 'routes.webhooks.description': '出站 Webhook 端点与派发历史。', + 'routes.webhooks.title': 'Webhooks', + 'routes.writeNote.description': '在 React markdown 写作界面创建与编辑手记。', + 'routes.writeNote.title': '写手记', + 'routes.writePage.description': '在 React 写作界面创建与编辑静态页面。', + 'routes.writePage.title': '写页面', + 'routes.writePost.description': '在 React markdown 写作界面创建与编辑博文。', + 'routes.writePost.title': '写博文', + + 'shell.footer.runtime': 'React 管理端运行时。', + 'shell.locale.label': '界面语言', + 'shell.logout': '登出', + 'shell.nav.assets': '资产', + 'shell.nav.assets.description': '文件、模板与导入导出资源。', + 'shell.nav.community': '互动', + 'shell.nav.community.description': '评论、读者、友链与订阅关系。', + 'shell.nav.content': '内容', + 'shell.nav.content.description': '文章、手记、页面与内容条目。', + 'shell.nav.debug': '调试', + 'shell.nav.extra': '扩展', + 'shell.nav.maintenance': '维护', + 'shell.nav.system': '系统', + 'shell.nav.system.description': 'AI、数据分析、设置与后台维护。', + 'shell.owner.fallback': '用户', + 'shell.theme.dark': '深色', + 'shell.theme.label': '主题', + 'shell.theme.light': '浅色', + 'shell.theme.system': '跟随系统', +} as const diff --git a/apps/admin/src/app/i18n/types.ts b/apps/admin/src/app/i18n/types.ts new file mode 100644 index 000000000..814d956d2 --- /dev/null +++ b/apps/admin/src/app/i18n/types.ts @@ -0,0 +1,7 @@ +import type { zhCN } from './resources/zh-CN' + +export type Locale = 'en-US' | 'zh-CN' + +export type TranslationKey = keyof typeof zhCN + +export type TranslationValues = Record diff --git a/apps/admin/src/models/activity.ts b/apps/admin/src/app/models/activity.ts similarity index 100% rename from apps/admin/src/models/activity.ts rename to apps/admin/src/app/models/activity.ts diff --git a/apps/admin/src/models/ai.ts b/apps/admin/src/app/models/ai.ts similarity index 100% rename from apps/admin/src/models/ai.ts rename to apps/admin/src/app/models/ai.ts diff --git a/apps/admin/src/models/amap.ts b/apps/admin/src/app/models/amap.ts similarity index 100% rename from apps/admin/src/models/amap.ts rename to apps/admin/src/app/models/amap.ts diff --git a/apps/admin/src/models/analyze.ts b/apps/admin/src/app/models/analyze.ts similarity index 100% rename from apps/admin/src/models/analyze.ts rename to apps/admin/src/app/models/analyze.ts diff --git a/apps/admin/src/models/base.ts b/apps/admin/src/app/models/base.ts similarity index 100% rename from apps/admin/src/models/base.ts rename to apps/admin/src/app/models/base.ts diff --git a/apps/admin/src/models/category.ts b/apps/admin/src/app/models/category.ts similarity index 100% rename from apps/admin/src/models/category.ts rename to apps/admin/src/app/models/category.ts diff --git a/apps/admin/src/models/comment.ts b/apps/admin/src/app/models/comment.ts similarity index 100% rename from apps/admin/src/models/comment.ts rename to apps/admin/src/app/models/comment.ts diff --git a/apps/admin/src/models/draft.ts b/apps/admin/src/app/models/draft.ts similarity index 100% rename from apps/admin/src/models/draft.ts rename to apps/admin/src/app/models/draft.ts diff --git a/apps/admin/src/models/enrichment.ts b/apps/admin/src/app/models/enrichment.ts similarity index 100% rename from apps/admin/src/models/enrichment.ts rename to apps/admin/src/app/models/enrichment.ts diff --git a/apps/admin/src/models/link.ts b/apps/admin/src/app/models/link.ts similarity index 100% rename from apps/admin/src/models/link.ts rename to apps/admin/src/app/models/link.ts diff --git a/apps/admin/src/models/meta-preset.ts b/apps/admin/src/app/models/meta-preset.ts similarity index 100% rename from apps/admin/src/models/meta-preset.ts rename to apps/admin/src/app/models/meta-preset.ts diff --git a/apps/admin/src/models/note.ts b/apps/admin/src/app/models/note.ts similarity index 100% rename from apps/admin/src/models/note.ts rename to apps/admin/src/app/models/note.ts diff --git a/apps/admin/src/models/options.ts b/apps/admin/src/app/models/options.ts similarity index 100% rename from apps/admin/src/models/options.ts rename to apps/admin/src/app/models/options.ts diff --git a/apps/admin/src/models/page.ts b/apps/admin/src/app/models/page.ts similarity index 100% rename from apps/admin/src/models/page.ts rename to apps/admin/src/app/models/page.ts diff --git a/apps/admin/src/models/post.ts b/apps/admin/src/app/models/post.ts similarity index 100% rename from apps/admin/src/models/post.ts rename to apps/admin/src/app/models/post.ts diff --git a/apps/admin/src/models/project.ts b/apps/admin/src/app/models/project.ts similarity index 100% rename from apps/admin/src/models/project.ts rename to apps/admin/src/app/models/project.ts diff --git a/apps/admin/src/models/recently.ts b/apps/admin/src/app/models/recently.ts similarity index 100% rename from apps/admin/src/models/recently.ts rename to apps/admin/src/app/models/recently.ts diff --git a/apps/admin/src/models/say.ts b/apps/admin/src/app/models/say.ts similarity index 100% rename from apps/admin/src/models/say.ts rename to apps/admin/src/app/models/say.ts diff --git a/apps/admin/src/models/search-index.ts b/apps/admin/src/app/models/search-index.ts similarity index 100% rename from apps/admin/src/models/search-index.ts rename to apps/admin/src/app/models/search-index.ts diff --git a/apps/admin/src/models/snippet.ts b/apps/admin/src/app/models/snippet.ts similarity index 100% rename from apps/admin/src/models/snippet.ts rename to apps/admin/src/app/models/snippet.ts diff --git a/apps/admin/src/models/stat.ts b/apps/admin/src/app/models/stat.ts similarity index 100% rename from apps/admin/src/models/stat.ts rename to apps/admin/src/app/models/stat.ts diff --git a/apps/admin/src/models/system.ts b/apps/admin/src/app/models/system.ts similarity index 79% rename from apps/admin/src/models/system.ts rename to apps/admin/src/app/models/system.ts index 1fd06754c..aec141782 100644 --- a/apps/admin/src/models/system.ts +++ b/apps/admin/src/app/models/system.ts @@ -1,4 +1,5 @@ export interface AppInfo { + hash?: string name: string version: string } diff --git a/apps/admin/src/models/token.ts b/apps/admin/src/app/models/token.ts similarity index 100% rename from apps/admin/src/models/token.ts rename to apps/admin/src/app/models/token.ts diff --git a/apps/admin/src/models/topic.ts b/apps/admin/src/app/models/topic.ts similarity index 100% rename from apps/admin/src/models/topic.ts rename to apps/admin/src/app/models/topic.ts diff --git a/apps/admin/src/models/user.ts b/apps/admin/src/app/models/user.ts similarity index 100% rename from apps/admin/src/models/user.ts rename to apps/admin/src/app/models/user.ts diff --git a/apps/admin/src/app/providers.tsx b/apps/admin/src/app/providers.tsx new file mode 100644 index 000000000..a93c0cfea --- /dev/null +++ b/apps/admin/src/app/providers.tsx @@ -0,0 +1,33 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { Toaster } from 'sonner' +import type { PropsWithChildren } from 'react' + +import { I18nProvider } from './i18n' +import { queryClient } from './query-client' +import { useThemeMode } from './theme' + +export function AppProviders(props: PropsWithChildren) { + const { isDark } = useThemeMode() + + return ( + + {props.children} + + + ) +} diff --git a/apps/admin/src/app/query-client.ts b/apps/admin/src/app/query-client.ts new file mode 100644 index 000000000..5a21aad46 --- /dev/null +++ b/apps/admin/src/app/query-client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 1000 * 30, + }, + }, +}) diff --git a/apps/admin/src/app/routes.tsx b/apps/admin/src/app/routes.tsx new file mode 100644 index 000000000..b45aaac61 --- /dev/null +++ b/apps/admin/src/app/routes.tsx @@ -0,0 +1,628 @@ +import { + BellOff, + BellRing, + BookOpen, + BookOpenText, + ChartLine, + Clock, + DatabaseZap, + Eye, + File, + FileClock, + FileCode2, + FileDown, + Files, + FileText, + Folder, + FolderOpen, + Gauge, + Hash, + Image, + KeyRound, + Languages, + Link, + ListTodo, + MessageSquare, + Pencil, + Quote, + RadioTower, + SearchCheck, + Settings, + Sparkles, + SquareFunction, + Telescope, + Terminal, + Undo2, + UserRound, + Users, + Webhook, +} from 'lucide-react' +import { Navigate, Route, Routes, useLocation } from 'react-router' +import type { LucideIcon } from 'lucide-react' +import type { ComponentType } from 'react' +import type { TranslationKey } from './i18n/types' + +import { AiPage } from './views/ai-page' +import { AnalyzePage } from './views/analyze-page' +import { AuthnDebugPage } from './views/authn-debug-page' +import { BackupPage } from './views/backup-page' +import { CategoriesPage } from './views/categories-page' +import { CommentsPage } from './views/comments-page' +import { CronPage } from './views/cron-page' +import { DashboardPage } from './views/dashboard-page' +import { DraftsPage } from './views/drafts-page' +import { EnrichmentPage } from './views/enrichment-page' +import { EventsDebugPage } from './views/events-debug-page' +import { + CommentImagesPage, + FilesPage, + OrphanFilesPage, +} from './views/files-page' +import { FriendsPage } from './views/friends-page' +import { LoginPage } from './views/login-page' +import { MarkdownPage } from './views/markdown-page' +import { NotesPage } from './views/notes-page' +import { PagesPage } from './views/pages-page' +import { PostsPage } from './views/posts-page' +import { ProjectsPage } from './views/projects-page' +import { ReadersPage } from './views/readers-page' +import { RecentlyPage } from './views/recently-page' +import { RichDebugPage } from './views/rich-debug-page' +import { SaysPage } from './views/says-page' +import { SearchIndexPage } from './views/search-index-page' +import { ServerlessDebugPage } from './views/serverless-debug-page' +import { SettingsPage } from './views/settings-page' +import { SetupApiPage } from './views/setup-api-page' +import { SetupPage } from './views/setup-page' +import { SnippetsPage } from './views/snippets-page' +import { SubscribePage } from './views/subscribe-page' +import { TemplatePage } from './views/template-page' +import { ToastDebugPage } from './views/toast-debug-page' +import { TopicsPage } from './views/topics-page' +import { WebhooksPage } from './views/webhooks-page' +import { NoteWritePage, PageWritePage, PostWritePage } from './views/write-page' + +export interface SidebarNavRoute { + descriptionKey: TranslationKey + icon: LucideIcon + matchPaths?: string[] + path: string + titleKey: TranslationKey +} + +export interface AppRoute extends SidebarNavRoute { + element: ComponentType +} + +export interface SidebarNavNode { + children?: SidebarNavNode[] + route: SidebarNavRoute +} + +export interface SidebarNavSection { + items: SidebarNavNode[] + titleKey?: TranslationKey +} + +export const appRoutes: AppRoute[] = [ + { + descriptionKey: 'routes.dashboard.description', + element: DashboardPage, + icon: Gauge, + path: '/dashboard', + titleKey: 'routes.dashboard.title', + }, + { + descriptionKey: 'routes.posts.description', + element: PostsPage, + icon: FileText, + path: '/posts', + titleKey: 'routes.posts.title', + }, + { + descriptionKey: 'routes.writePost.description', + element: PostWritePage, + icon: Pencil, + path: '/posts/edit', + titleKey: 'routes.writePost.title', + }, + { + descriptionKey: 'routes.categories.description', + element: CategoriesPage, + icon: FolderOpen, + path: '/posts/category', + titleKey: 'routes.categories.title', + }, + { + descriptionKey: 'routes.notes.description', + element: NotesPage, + icon: BookOpen, + path: '/notes', + titleKey: 'routes.notes.title', + }, + { + descriptionKey: 'routes.writeNote.description', + element: NoteWritePage, + icon: Pencil, + path: '/notes/edit', + titleKey: 'routes.writeNote.title', + }, + { + descriptionKey: 'routes.topics.description', + element: TopicsPage, + icon: Hash, + path: '/notes/topic', + titleKey: 'routes.topics.title', + }, + { + descriptionKey: 'routes.pages.description', + element: PagesPage, + icon: File, + path: '/pages', + titleKey: 'routes.pages.title', + }, + { + descriptionKey: 'routes.writePage.description', + element: PageWritePage, + icon: Pencil, + path: '/pages/edit', + titleKey: 'routes.writePage.title', + }, + { + descriptionKey: 'routes.drafts.description', + element: DraftsPage, + icon: FileClock, + path: '/drafts', + titleKey: 'routes.drafts.title', + }, + { + descriptionKey: 'routes.comments.description', + element: CommentsPage, + icon: MessageSquare, + path: '/comments', + titleKey: 'routes.comments.title', + }, + { + descriptionKey: 'routes.readers.description', + element: ReadersPage, + icon: Users, + path: '/readers', + titleKey: 'routes.readers.title', + }, + { + descriptionKey: 'routes.says.description', + element: SaysPage, + icon: Quote, + path: '/says', + titleKey: 'routes.says.title', + }, + { + descriptionKey: 'routes.recently.description', + element: RecentlyPage, + icon: Clock, + path: '/recently', + titleKey: 'routes.recently.title', + }, + { + descriptionKey: 'routes.projects.description', + element: ProjectsPage, + icon: Folder, + path: '/projects', + titleKey: 'routes.projects.title', + }, + { + descriptionKey: 'routes.friends.description', + element: FriendsPage, + icon: UserRound, + path: '/friends', + titleKey: 'routes.friends.title', + }, + { + descriptionKey: 'routes.files.description', + element: FilesPage, + icon: Files, + path: '/files', + titleKey: 'routes.files.title', + }, + { + descriptionKey: 'routes.orphanImages.description', + element: OrphanFilesPage, + icon: Image, + path: '/files/orphans', + titleKey: 'routes.orphanImages.title', + }, + { + descriptionKey: 'routes.commentImages.description', + element: CommentImagesPage, + icon: Image, + path: '/files/comment-images', + titleKey: 'routes.commentImages.title', + }, + { + descriptionKey: 'routes.analyze.description', + element: AnalyzePage, + icon: ChartLine, + path: '/analyze', + titleKey: 'routes.analyze.title', + }, + { + descriptionKey: 'routes.ai.description', + element: AiPage, + icon: Sparkles, + path: '/ai', + titleKey: 'routes.ai.title', + }, + { + descriptionKey: 'routes.aiSummary.description', + element: AiPage, + icon: FileText, + path: '/ai/summary', + titleKey: 'routes.aiSummary.title', + }, + { + descriptionKey: 'routes.aiInsights.description', + element: AiPage, + icon: Telescope, + path: '/ai/insights', + titleKey: 'routes.aiInsights.title', + }, + { + descriptionKey: 'routes.aiTranslation.description', + element: AiPage, + icon: Languages, + path: '/ai/translation', + titleKey: 'routes.aiTranslation.title', + }, + { + descriptionKey: 'routes.aiTranslationEntries.description', + element: AiPage, + icon: BookOpenText, + path: '/ai/translation-entries', + titleKey: 'routes.aiTranslationEntries.title', + }, + { + descriptionKey: 'routes.aiTasks.description', + element: AiPage, + icon: ListTodo, + path: '/ai/tasks', + titleKey: 'routes.aiTasks.title', + }, + { + descriptionKey: 'routes.aiSlugBackfill.description', + element: AiPage, + icon: Link, + path: '/ai/slug-backfill', + titleKey: 'routes.aiSlugBackfill.title', + }, + { + descriptionKey: 'routes.settings.description', + element: SettingsPage, + icon: Settings, + path: '/setting', + titleKey: 'routes.settings.title', + }, + { + descriptionKey: 'routes.subscribe.description', + element: SubscribePage, + icon: BellOff, + path: '/extra-features/subscribe', + titleKey: 'routes.subscribe.title', + }, + { + descriptionKey: 'routes.snippets.description', + element: SnippetsPage, + icon: SquareFunction, + path: '/extra-features/snippets', + titleKey: 'routes.snippets.title', + }, + { + descriptionKey: 'routes.webhooks.description', + element: WebhooksPage, + icon: Webhook, + path: '/extra-features/webhooks', + titleKey: 'routes.webhooks.title', + }, + { + descriptionKey: 'routes.markdown.description', + element: MarkdownPage, + icon: FileDown, + path: '/extra-features/markdown', + titleKey: 'routes.markdown.title', + }, + { + descriptionKey: 'routes.templates.description', + element: TemplatePage, + icon: FileCode2, + path: '/extra-features/assets/template', + titleKey: 'routes.templates.title', + }, + { + descriptionKey: 'routes.backups.description', + element: BackupPage, + icon: Undo2, + path: '/maintenance/backup', + titleKey: 'routes.backups.title', + }, + { + descriptionKey: 'routes.cron.description', + element: CronPage, + icon: ListTodo, + path: '/maintenance/cron', + titleKey: 'routes.cron.title', + }, + { + descriptionKey: 'routes.searchIndex.description', + element: SearchIndexPage, + icon: SearchCheck, + path: '/maintenance/search-index', + titleKey: 'routes.searchIndex.title', + }, + { + descriptionKey: 'routes.enrichment.description', + element: EnrichmentPage, + icon: DatabaseZap, + path: '/enrichment', + titleKey: 'routes.enrichment.title', + }, + { + descriptionKey: 'routes.toastLab.description', + element: ToastDebugPage, + icon: BellRing, + path: '/debug/toast', + titleKey: 'routes.toastLab.title', + }, + { + descriptionKey: 'routes.passkeyLab.description', + element: AuthnDebugPage, + icon: KeyRound, + path: '/debug/authn', + titleKey: 'routes.passkeyLab.title', + }, + { + descriptionKey: 'routes.eventLab.description', + element: EventsDebugPage, + icon: RadioTower, + path: '/debug/events', + titleKey: 'routes.eventLab.title', + }, + { + descriptionKey: 'routes.functionLab.description', + element: ServerlessDebugPage, + icon: Terminal, + path: '/debug/serverless', + titleKey: 'routes.functionLab.title', + }, + { + descriptionKey: 'routes.richLab.description', + element: RichDebugPage, + icon: FileCode2, + path: '/debug/rich', + titleKey: 'routes.richLab.title', + }, +] + +function routeByPath(path: string) { + const route = appRoutes.find((item) => item.path === path) + if (!route) { + throw new Error(`Missing admin route: ${path}`) + } + + return route +} + +function sidebarRoute( + path: string, + overrides: Partial> = {}, +): SidebarNavRoute { + return { + ...routeByPath(path), + ...overrides, + } +} + +function sidebarOnlyRoute(route: SidebarNavRoute): SidebarNavRoute { + return route +} + +function sidebarNode( + path: string, + overrides?: Partial>, +): SidebarNavNode { + return { route: sidebarRoute(path, overrides) } +} + +function sidebarAliasNode( + path: string, + targetPath: string, + overrides?: Partial>, +): SidebarNavNode { + const route = sidebarRoute(targetPath, overrides) + + return { + route: { + ...route, + matchPaths: [...(route.matchPaths ?? []), targetPath], + path, + }, + } +} + +function sidebarGroupNode(route: SidebarNavRoute, children: SidebarNavNode[]) { + return { + children, + route: sidebarOnlyRoute(route), + } satisfies SidebarNavNode +} + +export const sidebarNavigation: SidebarNavSection[] = [ + { + items: [{ route: routeByPath('/dashboard') }], + }, + { + titleKey: 'shell.nav.content', + items: [ + sidebarGroupNode(routeByPath('/posts'), [ + sidebarAliasNode('/posts/view', '/posts', { + descriptionKey: 'routes.managePosts.description', + icon: Eye, + titleKey: 'routes.managePosts.title', + }), + sidebarNode('/posts/edit'), + sidebarNode('/posts/category'), + ]), + sidebarGroupNode(routeByPath('/notes'), [ + sidebarAliasNode('/notes/view', '/notes', { + descriptionKey: 'routes.manageNotes.description', + icon: Eye, + titleKey: 'routes.manageNotes.title', + }), + sidebarNode('/notes/edit'), + sidebarNode('/notes/topic'), + ]), + { route: routeByPath('/drafts') }, + sidebarGroupNode(routeByPath('/pages'), [ + sidebarAliasNode('/pages/list', '/pages', { + descriptionKey: 'routes.managePages.description', + icon: Eye, + titleKey: 'routes.managePages.title', + }), + sidebarNode('/pages/edit'), + ]), + { route: routeByPath('/says') }, + { route: routeByPath('/recently') }, + { route: routeByPath('/projects') }, + ], + }, + { + titleKey: 'shell.nav.community', + items: [ + { route: routeByPath('/comments') }, + { route: routeByPath('/readers') }, + { route: routeByPath('/friends') }, + sidebarNode('/extra-features/subscribe'), + ], + }, + { + titleKey: 'shell.nav.assets', + items: [ + sidebarGroupNode(routeByPath('/files'), [ + sidebarAliasNode('/files/list', '/files', { + descriptionKey: 'routes.manageFiles.description', + icon: Eye, + titleKey: 'routes.manageFiles.title', + }), + sidebarNode('/files/orphans'), + sidebarNode('/files/comment-images'), + ]), + sidebarNode('/extra-features/assets/template'), + sidebarNode('/extra-features/markdown'), + ], + }, + { + titleKey: 'shell.nav.system', + items: [ + sidebarGroupNode(routeByPath('/ai'), [ + sidebarNode('/ai/summary'), + sidebarNode('/ai/insights'), + sidebarNode('/ai/translation'), + sidebarNode('/ai/translation-entries'), + sidebarNode('/ai/tasks'), + sidebarNode('/ai/slug-backfill'), + ]), + { route: routeByPath('/analyze') }, + { route: routeByPath('/setting') }, + ], + }, + { + titleKey: 'shell.nav.extra', + items: [ + sidebarNode('/extra-features/snippets'), + sidebarNode('/extra-features/webhooks'), + ], + }, + { + titleKey: 'shell.nav.maintenance', + items: [ + sidebarNode('/maintenance/cron'), + sidebarNode('/maintenance/backup'), + sidebarAliasNode('/maintenance/enrichment', '/enrichment'), + sidebarNode('/maintenance/search-index'), + ], + }, + { + titleKey: 'shell.nav.debug', + items: [ + sidebarNode('/debug/toast'), + sidebarNode('/debug/authn'), + sidebarNode('/debug/events'), + sidebarNode('/debug/serverless'), + sidebarNode('/debug/rich'), + ], + }, +] + +const legacyRouteAliases: Array<{ + element?: ComponentType + from: string + to?: string +}> = [ + { from: '/posts/view', to: '/posts' }, + { from: '/notes/view', to: '/notes' }, + { from: '/pages/list', to: '/pages' }, + { from: '/files/list', to: '/files' }, + { from: '/maintenance/enrichment', to: '/enrichment' }, + { element: LegacyPageRedirect, from: '/page/*' }, + { element: LegacyExtraRedirect, from: '/extra/*' }, +] + +function LegacyPageRedirect() { + const location = useLocation() + const nextPath = location.pathname.replace(/^\/page(?=\/|$)/, '/pages') + + return ( + + ) +} + +function LegacyExtraRedirect() { + const location = useLocation() + const nextPath = location.pathname.replace(/^\/extra(?=\/|$)/, '') + + return ( + + ) +} + +export function AppRoutes() { + return ( + + } path="/" /> + } path="/login" /> + } path="/setup" /> + } path="/setup-api" /> + {legacyRouteAliases.map((route) => { + if (route.to) { + return ( + } + key={route.from} + path={route.from} + /> + ) + } + + const Element = route.element + return Element ? ( + } key={route.from} path={route.from} /> + ) : null + })} + {appRoutes.map((route) => ( + } key={route.path} path={route.path} /> + ))} + } path="*" /> + + ) +} diff --git a/apps/admin/src/app/shell.tsx b/apps/admin/src/app/shell.tsx new file mode 100644 index 000000000..ec95cacc0 --- /dev/null +++ b/apps/admin/src/app/shell.tsx @@ -0,0 +1,469 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + ChevronDown, + ExternalLink, + LogOut, + Monitor, + Moon, + Sun, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { NavLink, useLocation, useNavigate } from 'react-router' +import type { PropsWithChildren } from 'react' +import type { Locale, TranslationKey } from './i18n/types' +import type { SidebarNavNode, SidebarNavRoute } from './routes' +import type { ThemeMode } from './theme' + +import { getOwner } from './api/options' +import { getAppInfo } from './api/system' +import { API_URL, GATEWAY_URL, WEB_URL } from './constants/env' +import { SESSION_WITH_LOGIN } from './constants/keys' +import { useI18n } from './i18n' +import { SUPPORTED_LOCALES } from './i18n/resources' +import { appRoutes, sidebarNavigation } from './routes' +import { useThemeMode } from './theme' +import { cn } from './ui/cn' +import { APP_SHELL_HEADER_HEIGHT_CLASS } from './ui/layout' +import { SelectField } from './ui/select' +import { authClient } from './utils/authjs/auth' + +const activeLinkClassName = + 'bg-neutral-950 text-white shadow-sm dark:bg-neutral-50 dark:text-neutral-950' +const inactiveLinkClassName = + 'text-neutral-600 hover:bg-neutral-100 hover:text-neutral-950 dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50' +const localeLabelKeys = { + 'en-US': 'common.locale.en-US', + 'zh-CN': 'common.locale.zh-CN', +} satisfies Record +const themeModeLabelKeys = { + dark: 'shell.theme.dark', + light: 'shell.theme.light', + system: 'shell.theme.system', +} satisfies Record +const themeModeOptions: Array<{ + icon: typeof Sun + value: ThemeMode +}> = [ + { icon: Sun, value: 'light' }, + { icon: Moon, value: 'dark' }, + { icon: Monitor, value: 'system' }, +] + +export function AdminShell(props: PropsWithChildren) { + const location = useLocation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { locale, setLocale, t } = useI18n() + const { setThemeMode, themeMode } = useThemeMode() + const ownerQuery = useQuery({ + queryFn: getOwner, + queryKey: ['shell', 'owner'], + retry: false, + }) + const appInfoQuery = useQuery({ + queryFn: getAppInfo, + queryKey: ['shell', 'app-info'], + retry: false, + }) + const activeRoute = + [...appRoutes] + .sort((a, b) => b.path.length - a.path.length) + .find((route) => location.pathname.startsWith(route.path)) ?? appRoutes[0] + const owner = ownerQuery.data + const ownerName = + owner?.name || owner?.username || owner?.handle || t('shell.owner.fallback') + const shouldShowDebugMenu = + window.injectData.PAGE_PROXY || appInfoQuery.data?.version === 'dev' + const isInApiDebugMode = Boolean( + localStorage.getItem('__api') || + localStorage.getItem('__gateway') || + sessionStorage.getItem('__api') || + sessionStorage.getItem('__gateway') || + window.injectData.PAGE_PROXY, + ) + const visibleSidebarNavigation = useMemo( + () => + shouldShowDebugMenu + ? sidebarNavigation + : sidebarNavigation + .map((section) => ({ + ...section, + items: section.items.flatMap((node) => { + const visibleNode = filterSidebarNode(node) + + return visibleNode ? [visibleNode] : [] + }), + })) + .filter((section) => section.items.length > 0), + [shouldShowDebugMenu], + ) + const isRouteActive = (route: SidebarNavRoute) => + doesRouteMatch(route, activeRoute.path, location.pathname) + const isNodeActive = (node: SidebarNavNode): boolean => + isRouteActive(node.route) || + (node.children ?? []).some((child) => isNodeActive(child)) + const [expandedPaths, setExpandedPaths] = useState>( + () => new Set(), + ) + + useEffect(() => { + const activeNodePaths = visibleSidebarNavigation + .flatMap((section) => section.items) + .flatMap((node) => + collectActiveParentPaths(node, activeRoute.path, location.pathname), + ) + + if (!activeNodePaths.length) { + return + } + + setExpandedPaths((previous) => { + const next = new Set(previous) + let changed = false + + activeNodePaths.forEach((path) => { + if (!next.has(path)) { + next.add(path) + changed = true + } + }) + + return changed ? next : previous + }) + }, [activeRoute.path, location.pathname, visibleSidebarNavigation]) + + const toggleExpandedPath = (path: string) => { + setExpandedPaths((previous) => { + const next = new Set(previous) + + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + + return next + }) + } + const handleLogout = async () => { + await authClient.signOut() + sessionStorage.removeItem(SESSION_WITH_LOGIN) + queryClient.clear() + navigate('/login', { replace: true }) + } + + return ( +
+ + +
+ {isInApiDebugMode ? ( +
+ API endpoint mode: + + setup-api + + + | Endpoint: {API_URL || '-'} | Gateway: {GATEWAY_URL || '-'} + {window.injectData.PAGE_PROXY ? ' | Local dev mode' : ''} + +
+ ) : null} +
{props.children}
+
+
+ ) +} + +function SidebarNavItem(props: { + active: boolean + depth: number + isExpanded: (path: string) => boolean + isRouteActive: (route: SidebarNavRoute) => boolean + isNodeActive: (node: SidebarNavNode) => boolean + node: SidebarNavNode + onExpandedChange: (path: string) => void + t: (key: TranslationKey) => string +}) { + const Icon = props.node.route.icon + const hasChildren = !!props.node.children?.length + const expanded = props.isExpanded(props.node.route.path) + const parentClassName = cn( + 'grid w-full grid-cols-[1rem_minmax(0,1fr)_1rem] items-center gap-2 rounded text-left transition-colors', + props.depth === 0 ? 'h-9 px-3 text-sm' : 'h-8 px-2 text-[13px]', + hasChildren + ? props.active + ? 'bg-neutral-100 font-medium text-neutral-950 dark:bg-neutral-900 dark:text-neutral-50' + : inactiveLinkClassName + : props.isRouteActive(props.node.route) + ? activeLinkClassName + : props.active + ? 'bg-neutral-100 text-neutral-950 dark:bg-neutral-900 dark:text-neutral-50' + : inactiveLinkClassName, + ) + + return ( +
+ {hasChildren ? ( + + ) : ( + + + )} + + {hasChildren ? ( +
+
+
+ {props.node.children?.map((child) => ( + + ))} +
+
+
+ ) : null} +
+ ) +} + +function collectActiveParentPaths( + node: SidebarNavNode, + activePath: string, + currentPath: string, +): string[] { + if (!node.children?.length) { + return [] + } + + const activeChildPaths = node.children.flatMap((child) => + collectActiveParentPaths(child, activePath, currentPath), + ) + const hasActiveDescendant = + doesRouteMatch(node.route, activePath, currentPath) || + activeChildPaths.length > 0 || + node.children.some((child) => + doesRouteMatch(child.route, activePath, currentPath), + ) + + return hasActiveDescendant + ? [node.route.path, ...activeChildPaths] + : activeChildPaths +} + +function doesRouteMatch( + route: SidebarNavRoute, + activePath: string, + currentPath: string, +) { + return ( + route.path === activePath || + route.path === currentPath || + (route.matchPaths ?? []).some( + (path) => path === activePath || path === currentPath, + ) + ) +} + +function filterSidebarNode(node: SidebarNavNode): SidebarNavNode | null { + if (node.route.path === '/debug') { + return null + } + + if (!node.children?.length) { + return node + } + + const children = node.children.flatMap((child) => { + const visibleChild = filterSidebarNode(child) + + return visibleChild ? [visibleChild] : [] + }) + + return { + ...node, + children, + } +} diff --git a/apps/admin/src/socket/types.ts b/apps/admin/src/app/socket/types.ts similarity index 80% rename from apps/admin/src/socket/types.ts rename to apps/admin/src/app/socket/types.ts index 803ec331e..0e2f29293 100644 --- a/apps/admin/src/socket/types.ts +++ b/apps/admin/src/app/socket/types.ts @@ -26,13 +26,12 @@ export enum EventTypes { LINK_APPLY = 'LINK_APPLY', DANMAKU_CREATE = 'DANMAKU_CREATE', - // util - CONTENT_REFRESH = 'CONTENT_REFRESH', // 内容更新或重置 页面需要重载 - // for admin + CONTENT_REFRESH = 'CONTENT_REFRESH', + IMAGE_REFRESH = 'IMAGE_REFRESH', IMAGE_FETCH = 'IMAGE_FETCH', ADMIN_NOTIFICATION = 'ADMIN_NOTIFICATION', } -export type NotificationTypes = 'error' | 'warn' | 'success' | 'info' +export type NotificationTypes = 'error' | 'info' | 'success' | 'warn' diff --git a/apps/admin/src/app/theme.ts b/apps/admin/src/app/theme.ts new file mode 100644 index 000000000..595d7c805 --- /dev/null +++ b/apps/admin/src/app/theme.ts @@ -0,0 +1,84 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' + +export const themeColors = { + primary: '#1a9cf3', + primaryDeep: '#0f7ec4', + primaryShallow: '#4fb5f7', +} as const + +export type ThemeMode = 'dark' | 'light' | 'system' + +const themeModeChangeEvent = 'mx-admin-theme-mode-change' + +export function useThemeMode() { + const query = useMemo( + () => window.matchMedia('(prefers-color-scheme: dark)'), + [], + ) + + const snapshot = useSyncExternalStore( + (onStoreChange) => { + query.addEventListener('change', onStoreChange) + window.addEventListener(themeModeChangeEvent, onStoreChange) + + return () => { + query.removeEventListener('change', onStoreChange) + window.removeEventListener(themeModeChangeEvent, onStoreChange) + } + }, + () => getThemeSnapshot(query), + () => 'system:light', + ) + const [themeMode, resolvedTheme] = snapshot.split(':') as [ + ThemeMode, + 'dark' | 'light', + ] + const isDark = resolvedTheme === 'dark' + + useEffect(() => { + document.documentElement.classList.toggle('dark', isDark) + }, [isDark]) + + return { isDark, setThemeMode, themeMode } +} + +export function installThemeTokens() { + document.documentElement.style.setProperty( + '--color-primary', + themeColors.primary, + ) + document.documentElement.style.setProperty( + '--color-primary-shallow', + themeColors.primaryShallow, + ) + document.documentElement.style.setProperty( + '--color-primary-deep', + themeColors.primaryDeep, + ) +} + +export function setThemeMode(themeMode: ThemeMode) { + if (themeMode === 'system') { + localStorage.removeItem('theme-mode') + } else { + localStorage.setItem('theme-mode', themeMode) + } + + window.dispatchEvent(new Event(themeModeChangeEvent)) +} + +function getThemeSnapshot(query: MediaQueryList) { + const themeMode = readThemeMode() + const resolvedTheme = + themeMode === 'system' ? (query.matches ? 'dark' : 'light') : themeMode + + return `${themeMode}:${resolvedTheme}` as const +} + +function readThemeMode(): ThemeMode { + const storedTheme = localStorage.getItem('theme-mode')?.replace(/^"|"$/g, '') + + if (storedTheme === 'dark' || storedTheme === 'light') return storedTheme + + return 'system' +} diff --git a/apps/admin/src/app/ui/button.tsx b/apps/admin/src/app/ui/button.tsx new file mode 100644 index 000000000..2b2904f6d --- /dev/null +++ b/apps/admin/src/app/ui/button.tsx @@ -0,0 +1,54 @@ +import { Button as BaseButton } from '@base-ui/react/button' +import { Link } from 'react-router' +import type { ButtonProps as BaseButtonProps } from '@base-ui/react/button' +import type { LinkProps } from 'react-router' + +import { cn } from './cn' + +type ButtonVariant = 'primary' | 'subtle' + +export interface ButtonProps extends Omit { + className?: string + variant?: ButtonVariant +} + +export interface ButtonLinkProps extends Omit { + className?: string + variant?: ButtonVariant +} + +const variantClassNames: Record = { + primary: + 'bg-neutral-950 text-white hover:bg-neutral-800 dark:bg-neutral-50 dark:text-neutral-950 dark:hover:bg-neutral-200', + subtle: + 'border border-neutral-200 bg-white text-neutral-700 hover:bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-200 dark:hover:bg-neutral-900', +} + +const buttonClassName = + 'inline-flex h-9 items-center justify-center gap-2 rounded px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-primary-shallow)] disabled:pointer-events-none disabled:opacity-50' + +export function Button({ + className, + variant = 'primary', + ...props +}: ButtonProps) { + return ( + + ) +} + +export function ButtonLink({ + className, + variant = 'primary', + ...props +}: ButtonLinkProps) { + return ( + + ) +} diff --git a/apps/admin/src/app/ui/checkbox.tsx b/apps/admin/src/app/ui/checkbox.tsx new file mode 100644 index 000000000..9f4573113 --- /dev/null +++ b/apps/admin/src/app/ui/checkbox.tsx @@ -0,0 +1,50 @@ +import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox' +import { Check, Minus } from 'lucide-react' +import type { MouseEventHandler, ReactNode } from 'react' + +import { cn } from './cn' + +interface CheckboxProps { + 'aria-label'?: string + checked: boolean + className?: string + disabled?: boolean + indeterminate?: boolean + label?: ReactNode + onCheckedChange: (checked: boolean) => void + onClick?: MouseEventHandler +} + +export function Checkbox(props: CheckboxProps) { + const control = ( + + + {props.indeterminate ? ( + + + ) + + if (!props.label) return control + + return ( + + ) +} diff --git a/apps/admin/src/app/ui/cn.ts b/apps/admin/src/app/ui/cn.ts new file mode 100644 index 000000000..bcb0f5397 --- /dev/null +++ b/apps/admin/src/app/ui/cn.ts @@ -0,0 +1,5 @@ +type ClassValue = false | null | string | undefined + +export function cn(...values: ClassValue[]) { + return values.filter(Boolean).join(' ') +} diff --git a/apps/admin/src/app/ui/compact-pagination.tsx b/apps/admin/src/app/ui/compact-pagination.tsx new file mode 100644 index 000000000..8ca07a19f --- /dev/null +++ b/apps/admin/src/app/ui/compact-pagination.tsx @@ -0,0 +1,68 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' + +import { useI18n } from '../i18n' +import { SelectField } from './select' + +export interface CompactPaginationProps { + onPageChange: (page: number) => void + onPageSizeChange: (pageSize: number) => void + page: number + pageCount: number + pageSize: number + pageSizes?: number[] +} + +export function CompactPagination(props: CompactPaginationProps) { + const { t } = useI18n() + const pageSizes = props.pageSizes ?? [10, 20, 50, 100] + const canPrev = props.page > 1 + const canNext = props.page < props.pageCount + + return ( +
+ + + + + {props.page} + + / + {props.pageCount} + + + + + ({ + label: t('common.pagination.pageSize', { count: size }), + value: size, + }))} + popupClassName="text-xs" + triggerClassName="ml-1 h-auto w-auto border-0 bg-transparent px-1.5 py-0.5 text-xs tabular-nums hover:bg-neutral-100 dark:bg-transparent dark:hover:bg-neutral-800" + value={props.pageSize} + /> +
+ ) +} diff --git a/apps/admin/src/app/ui/content-list-toolbar.tsx b/apps/admin/src/app/ui/content-list-toolbar.tsx new file mode 100644 index 000000000..0ac44ef1e --- /dev/null +++ b/apps/admin/src/app/ui/content-list-toolbar.tsx @@ -0,0 +1,134 @@ +import { Search } from 'lucide-react' +import type { FormEventHandler, ReactNode } from 'react' + +import { Button } from './button' +import { Checkbox } from './checkbox' +import { cn } from './cn' +import { TextInput } from './text-field' + +interface ContentListToolbarSelection { + allVisibleSelected: boolean + bulkActionDisabled?: boolean + bulkActionIcon?: ReactNode + bulkActionLabel: string + hasVisibleItems: boolean + indeterminate: boolean + onBulkAction: () => void + onToggleAllVisible: (checked: boolean) => void + selectAllLabel: string + selectedLabel: string +} + +interface ContentListToolbarProps { + className?: string + filters?: ReactNode + hasSearch: boolean + onClearSearch: () => void + onSearch: FormEventHandler + onSearchValueChange: (value: string) => void + searchPlaceholder: string + searchValue: string + selection?: ContentListToolbarSelection + summary?: ReactNode +} + +const toolbarControlClassName = + 'h-8 text-xs transition-transform active:scale-[0.96]' + +export function ContentListToolbar(props: ContentListToolbarProps) { + return ( +
+
+
+ + + {props.hasSearch ? ( + + ) : null} +
+ + {props.filters ? ( +
+ {props.filters} +
+ ) : null} + + {props.selection ? ( + + ) : null} +
+ {props.summary ? ( + + {props.summary} + + ) : null} +
+ ) +} + +function ContentListToolbarSelectionControls(props: { + selection: ContentListToolbarSelection +}) { + const selection = props.selection + + return ( +
+ {selection.hasVisibleItems ? ( +
+ + {selection.selectAllLabel} +
+ ) : null} + + {selection.selectedLabel} + + +
+ ) +} diff --git a/apps/admin/src/app/ui/data-table.tsx b/apps/admin/src/app/ui/data-table.tsx new file mode 100644 index 000000000..72ac51722 --- /dev/null +++ b/apps/admin/src/app/ui/data-table.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react' + +interface DataTableColumn { + key: keyof T + label: string + render?: (value: T[keyof T], row: T) => ReactNode +} + +interface DataTableProps> { + columns: Array> + rows: T[] +} + +export function DataTable>( + props: DataTableProps, +) { + return ( +
+ + + + {props.columns.map((column) => ( + + ))} + + + + {props.rows.map((row, index) => ( + + {props.columns.map((column) => { + const value = row[column.key] + + return ( + + ) + })} + + ))} + +
+ {column.label} +
+ {column.render ? column.render(value, row) : value} +
+
+ ) +} diff --git a/apps/admin/src/app/ui/layout.ts b/apps/admin/src/app/ui/layout.ts new file mode 100644 index 000000000..5a26480b2 --- /dev/null +++ b/apps/admin/src/app/ui/layout.ts @@ -0,0 +1 @@ +export const APP_SHELL_HEADER_HEIGHT_CLASS = 'h-12' diff --git a/apps/admin/src/app/ui/markdown-render.tsx b/apps/admin/src/app/ui/markdown-render.tsx new file mode 100644 index 000000000..11cad848e --- /dev/null +++ b/apps/admin/src/app/ui/markdown-render.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import { marked } from 'marked' +import xss from 'xss' + +import { cn } from './cn' + +interface MarkdownRenderProps { + className?: string + text: string +} + +export function MarkdownRender(props: MarkdownRenderProps) { + const [html, setHtml] = useState('') + + useEffect(() => { + let cancelled = false + + async function render() { + if (!props.text) { + setHtml('') + return + } + + const parsed = await marked.parse(props.text, { + breaks: true, + gfm: true, + }) + + if (!cancelled) setHtml(xss(parsed)) + } + + void render() + + return () => { + cancelled = true + } + }, [props.text]) + + return ( +
+ ) +} diff --git a/apps/admin/src/app/ui/metric-card.tsx b/apps/admin/src/app/ui/metric-card.tsx new file mode 100644 index 000000000..cbdea5adb --- /dev/null +++ b/apps/admin/src/app/ui/metric-card.tsx @@ -0,0 +1,24 @@ +import type { LucideIcon } from 'lucide-react' + +interface MetricCardProps { + icon: LucideIcon + label: string + value: string +} + +export function MetricCard(props: MetricCardProps) { + const Icon = props.icon + + return ( +
+
+ ) +} diff --git a/apps/admin/src/app/ui/page-layout.tsx b/apps/admin/src/app/ui/page-layout.tsx new file mode 100644 index 000000000..60174c344 --- /dev/null +++ b/apps/admin/src/app/ui/page-layout.tsx @@ -0,0 +1,134 @@ +import { + Group as PanelGroup, + Separator as PanelResizeHandle, + Panel as ResizablePanel, +} from 'react-resizable-panels' +import type { ReactNode } from 'react' + +import { cn } from './cn' + +export function AppPage(props: { children: ReactNode; className?: string }) { + return ( +
+ {props.children} +
+ ) +} + +export function PageHeader(props: { + actions?: ReactNode + className?: string + description?: ReactNode + title: ReactNode +}) { + return ( +
+
+

+ {props.title} +

+ {props.description ? ( +

+ {props.description} +

+ ) : null} +
+ {props.actions ? ( +
+ {props.actions} +
+ ) : null} +
+ ) +} + +export function MasterDetailLayout(props: { + className?: string + children?: ReactNode + defaultSize?: number + detail: ReactNode + detailClassName?: string + list: ReactNode + listClassName?: string + maxSize?: number + minSize?: number + showDetailOnMobile?: boolean +}) { + const defaultSize = props.defaultSize ?? 0.38 + const minSize = props.minSize ?? 0.25 + const maxSize = props.maxSize ?? 0.5 + const detailMinSize = 1 - maxSize + + return ( +
+
+
+ {props.list} +
+
+ {props.detail} +
+
+ + + + {props.list} + + + + + + {props.detail} + + + + {props.children} +
+ ) +} + +function toPanelPercent(value: number) { + const percentage = value <= 1 ? value * 100 : value + return `${percentage}%` +} diff --git a/apps/admin/src/app/ui/panel.tsx b/apps/admin/src/app/ui/panel.tsx new file mode 100644 index 000000000..d8f73851a --- /dev/null +++ b/apps/admin/src/app/ui/panel.tsx @@ -0,0 +1,25 @@ +import type { PropsWithChildren, ReactNode } from 'react' + +import { cn } from './cn' + +interface PanelProps extends PropsWithChildren { + className?: string + description?: ReactNode + title: ReactNode +} + +export function Panel(props: PanelProps) { + return ( +
+
+

{props.title}

+ {props.description ? ( +

+ {props.description} +

+ ) : null} +
+ {props.children} +
+ ) +} diff --git a/apps/admin/src/app/ui/select.tsx b/apps/admin/src/app/ui/select.tsx new file mode 100644 index 000000000..8ea21150e --- /dev/null +++ b/apps/admin/src/app/ui/select.tsx @@ -0,0 +1,75 @@ +import { Select as BaseSelect } from '@base-ui/react/select' +import { ChevronDown } from 'lucide-react' +import type { ReactNode } from 'react' + +import { cn } from './cn' + +type SelectValue = number | string + +export interface SelectOption { + label: ReactNode + value: TValue +} + +interface SelectFieldProps { + 'aria-label'?: string + className?: string + disabled?: boolean + id?: string + onValueChange: (value: TValue) => void + options: SelectOption[] + popupClassName?: string + triggerClassName?: string + value: TValue +} + +export function SelectField( + props: SelectFieldProps, +) { + return ( + + disabled={props.disabled} + id={props.id} + items={props.options} + onValueChange={(value) => { + if (value !== null) props.onValueChange(value) + }} + value={props.value} + > + + + + + + + {props.options.map((option) => ( + + {option.label} + + ))} + + + + + ) +} diff --git a/apps/admin/src/app/ui/switch.tsx b/apps/admin/src/app/ui/switch.tsx new file mode 100644 index 000000000..94aa624ce --- /dev/null +++ b/apps/admin/src/app/ui/switch.tsx @@ -0,0 +1,44 @@ +import { Switch as BaseSwitch } from '@base-ui/react/switch' +import type { ReactNode } from 'react' + +import { cn } from './cn' + +interface SwitchProps { + checked: boolean + className?: string + description?: ReactNode + disabled?: boolean + label: ReactNode + onCheckedChange: (checked: boolean) => void +} + +export function Switch(props: SwitchProps) { + return ( + + ) +} diff --git a/apps/admin/src/app/ui/text-field.tsx b/apps/admin/src/app/ui/text-field.tsx new file mode 100644 index 000000000..9ad52d240 --- /dev/null +++ b/apps/admin/src/app/ui/text-field.tsx @@ -0,0 +1,147 @@ +import { Field } from '@base-ui/react/field' +import { Input as BaseInput } from '@base-ui/react/input' +import { forwardRef } from 'react' +import type { + ComponentPropsWithoutRef, + FocusEventHandler, + KeyboardEventHandler, + ReactNode, +} from 'react' + +import { cn } from './cn' + +type TextInputType = ComponentPropsWithoutRef<'input'>['type'] + +interface TextInputProps { + autoComplete?: string + autoFocus?: boolean + className?: string + controlClassName?: string + disabled?: boolean + id?: string + inputMode?: ComponentPropsWithoutRef<'input'>['inputMode'] + label?: ReactNode + labelClassName?: string + list?: string + maxLength?: number + name?: string + onBlur?: FocusEventHandler + onChange: (value: string) => void + onKeyDown?: KeyboardEventHandler + placeholder?: string + required?: boolean + spellCheck?: boolean + type?: TextInputType + value: string +} + +export const TextInput = forwardRef( + function TextInput(props, ref) { + const control = ( + + ) + + if (!props.label) { + return ( + + {control} + + ) + } + + return ( + + + {props.label} + {props.required ? * : null} + + {control} + + ) + }, +) + +interface TextAreaProps { + autoFocus?: boolean + className?: string + controlClassName?: string + disabled?: boolean + label?: ReactNode + labelClassName?: string + maxLength?: number + name?: string + onChange: (value: string) => void + onKeyDown?: KeyboardEventHandler + placeholder?: string + required?: boolean + spellCheck?: boolean + value: string +} + +export const TextArea = forwardRef( + function TextArea(props, ref) { + const control = ( +