diff --git a/.claude/skills/master-detail-list-keyboard/SKILL.md b/.claude/skills/master-detail-list-keyboard/SKILL.md new file mode 100644 index 000000000..dbb7de12d --- /dev/null +++ b/.claude/skills/master-detail-list-keyboard/SKILL.md @@ -0,0 +1,174 @@ +--- +name: master-detail-list-keyboard +description: Mandatory recipe for wiring j/k/↑/↓ keyboard navigation in admin-vue3 master-detail list panes. Apply when adding `useListKeyboard` to any list, building a new master-detail view, refactoring `ListRow` consumers, or whenever the user complains that "hjkl moves but the selected row doesn't update" / "键盘移动没有高亮" / "j/k 没反应" / "detail 不跟着键盘走" / "panel 打开后 j/k 切 item 右侧不刷新" / "edit drawer 不随键盘切换". Triggers on `useListKeyboard`, `FocusScope`, `ListRow`, `selection.selectOne`, `onItemFocus`, master-detail keyboard nav, drawer/panel-sync-with-keyboard. +--- + +# master-detail list keyboard wiring (MUST) + +## The trap + +`useListKeyboard` exposes a `selection` model intended for **multi-select**. By default it calls `selection.selectOne(focusedId)` on every j/k tick. But the row's visual `selected` prop is **NOT auto-wired** — you must read `selection.isSelected(id)` yourself, otherwise pressing j/k changes nothing visible and the user thinks the keyboard is dead. + +This bug bites every new master-detail page. Always recur. Hence this skill. + +## The contract + +Two distinct cases. **Pick one per scope** and wire it end-to-end. + +### Case A — Outer master list (j/k drives the detail target) + +This is the mail-client feel: arrow keys preview the next item by opening it in the detail pane. + +```tsx +useListKeyboard
({ + scopeId: 'foo-articles', + items: articles, + getId: (a) => a.id, + resetOn: [search], + // 1. override default: drive the detail target, not the internal selection + onItemFocus: (id) => { + const article = articles.find((a) => a.id === id) + if (article) setSelectedArticleId(article.id) + }, + actions: [ + { + key: 'open', + label: 'Open', + shortcut: 'Enter', + run: (targets) => { + const t = targets[0] + if (t) setSelectedArticleId(t.id) + }, + }, + ], +}) + +// row: + setSelectedArticleId(article.id)} +/> +``` + +**Both `onItemFocus` and `selected={... === selectedDetailId}` are required.** Without the override, j/k only mutates an internal selection model that nothing renders. + +### Case B — Inner detail list (j/k highlights, Enter opens edit) + +For a list inside the detail pane — e.g. summary rows under an article — j/k should highlight the focused row, NOT auto-open the editor. Use the hook's selection model directly: + +```tsx +const { selection } = useListKeyboard({ + scopeId: 'foo-items', + items, + getId: (it) => it.id, + resetOn: [parentArticleId], + // no onItemFocus — default `selection.selectOne(id)` is correct + actions: [ + { shortcut: 'Enter', run: (t) => openEditDrawer(t[0]) }, + { shortcut: 'Backspace', run: (t) => confirmDelete(t[0]) }, + ], +}) + +// row: + openEditDrawer(item)} + onDelete={() => confirmDelete(item)} +/> +``` + +**Reading `selection.isSelected(id)` is required** — it's the only place the j/k focus state surfaces. + +#### Click → must also call selection.selectOne + +Mouse clicks do NOT update the selection model automatically. ListRow's click handler only fires `onSelect(mode)`. If your row's visual `selected` is bound to `selection.isSelected(id)` (per Case B), clicking won't highlight the row — only j/k will. To make clicks behave the same as j/k, call `selection.selectOne(id)` in the click handler: + +```tsx + { + if (mode === 'toggle') selection.toggle(item.id) + else if (mode === 'range') selection.selectRange(item.id) + else { + selection.selectOne(item.id) // ← required for click highlight + openEditDrawer(item) + } + }} +/> +``` + +(Drafts' `DraftsRouteViewContent.tsx` is the canonical example.) + +#### When an external panel/drawer is open, sync target on j/k + +If clicking an item opens an edit drawer / right-side panel, the user expects j/k to **also** swap the drawer's target while the drawer is open. Without this, the drawer is stuck on the originally-clicked item even though the highlight has moved. + +Override `onItemFocus` to call `selection.selectOne` (preserve the highlight) **and** the external sync callback. Gate the sync on whether the drawer is currently open so j/k never opens a closed drawer from scratch: + +```tsx +const { selection } = useListKeyboard({ + scopeId: 'foo-items', + items, + getId: (it) => it.id, + resetOn: [parentArticleId], + onItemFocus: (id) => { + selection.selectOne(id) // ← preserve default highlight + const item = items.find((it) => it.id === id) + if (item) onItemFocus?.(item) // ← inform parent + }, + actions: [/* Enter, Backspace */], +}) +``` + +…and at the parent, gate the sync: + +```tsx + { + if (editingItemId !== null) { + setEditingItemId(item.id) // ← only when drawer is already open + } + }} +/> +``` + +Also: when an edit drawer's body owns local state (`useState` initialized from props), give it `key={editingItem.id}` so it remounts on item switch — otherwise the form keeps the old item's values. + +```tsx + +``` + +## Forbidden + +- `selected={false}` constant on a `ListRow` inside a `FocusScope`. Means j/k is dead. +- Wiring `selected={detailId === id}` while not passing `onItemFocus`. j/k advances the internal selection model invisibly; the user sees no movement. +- Two scopes using the same `scopeId` on one page — `FocusScope` ids must be unique per page (use a `scopeIdPrefix` and append `-articles` / `-items`). +- Auto-opening an edit drawer / modal from `onItemFocus`. That maps Up/Down to "open editor", which feels broken. Use `onItemFocus` only to drive **already-visible** state (the detail target, a preview, a highlight). +- Forgetting `` around the rows. `useListKeyboard` is scope-gated; without the wrapper, nothing fires. + +## Checklist when reviewing a list pane + +1. Is there a `` around the row list? If no — keyboard nav is silently broken. +2. Does `useListKeyboard` get the same `scopeId`? Mismatch → silent breakage. +3. What drives `selected` on the row? + - If `selection.isSelected(id)` → Case B. If you override `onItemFocus`, call `selection.selectOne(id)` yourself first. + - If `someExternalId === id` → Case A. Must pass `onItemFocus` that updates `someExternalId`. + - If literal `false` → bug. +4. Does `resetOn` include the inputs that should clear focus (filter changes, search changes, parent article switch)? Without it, j/k can land on a stale id after list reshape. +5. Does **clicking** a row update `selected` visually? If `selected` reads from `selection.isSelected(id)`, the row's `onSelect` handler must call `selection.selectOne(id)` — mouse clicks do not auto-update the selection model. +6. If an edit drawer / right-side panel opens from the row, does j/k update it while it's open? Override `onItemFocus` to fire an external sync (gated on drawer open) AND keep `selection.selectOne(id)`. Pass `key={item.id}` to the drawer body so its local state resets on item switch. + +## Reference views to copy + +- `apps/admin/src/features/drafts/components/DraftsRouteViewContent.tsx` — Case B canonical (selection drives `selected`, Enter opens detail through the action registry). +- `apps/admin/src/features/ai/components/article-grouped/ArticleListPane.tsx` — Case A canonical (j/k drives the article-detail target). +- `apps/admin/src/features/ai/components/article-grouped/ArticleDetailPane.tsx` — Case B canonical (inner item list). + +## Why a hook exposes both + +`useListKeyboard` was built for multi-select lists (posts, notes, comments) where the selection model owns checkbox state AND focus visuals. For master-detail single-target, the selection model is the wrong source of truth — the URL-synced detail id is. Don't fight the hook; just pick the right case above. If a project-wide `useMasterDetailKeyboard` wrapper materializes, prefer it. diff --git a/.gitignore b/.gitignore index 59e5a1f13..e9ec3efab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ bundle-analyze stats.html .env.development .idea +.superpowers /src/views/dev g.d.ts -.turbo/ diff --git a/CLAUDE.md b/CLAUDE.md index f2165091b..46937b554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,40 +4,51 @@ 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 repo is a pnpm workspace whose only active app is `apps/admin` — a React 19 SPA built with Base UI primitives, React Router (HashRouter), TanStack Query, Sonner, UnoCSS, and a Tailwind v4 layer. Despite the repo name `admin-vue3`, the Vue codebase has been retired; React is the sole runtime. ## Development Commands +Root scripts proxy to `apps/admin` via `pnpm -C apps/admin `: + ```bash 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:fix # Lint and auto-fix -npx tsc --noEmit # Type check (use this instead of build for validation) +pnpm dev # Start Vite dev server (opens browser automatically) +pnpm build # Production build +pnpm lint # oxlint +pnpm lint:fix # oxlint --fix +pnpm typecheck # tsc --noEmit (per-package) ``` +Scope checks to changed files only — never run lint/typecheck/build over the whole tree just to verify a small edit. For a one-off file typecheck inside the app: `pnpm -C apps/admin exec tsc --noEmit --pretty false`. + +Local API endpoint lives in `apps/admin/.env` as `VITE_APP_BASE_API`. + ## 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 -- **UnoCSS** (preset-wind4) - Tailwind-compatible utility classes -- **Pinia** - State management -- **TanStack Query** (`@tanstack/vue-query`) - Server state management with localStorage persistence -- **Socket.IO** - Real-time WebSocket updates -- **CodeMirror/Monaco** - Code editors +- **React 19** + TSX, react-compiler enabled via Babel +- **Base UI** (`@base-ui/react`) — headless primitives; UI wrappers live in `apps/admin/src/ui/` +- **React Router 7** (`HashRouter`) — `apps/admin/src/routes.tsx` is the single source of route → lazy-view mapping +- **UnoCSS** (preset-wind4) + **Tailwind v4** layer via `@tailwindcss/vite` +- **TanStack Query** — created in `apps/admin/src/query-client.ts`, mounted in `providers.tsx` +- **Sonner** — toast layer mounted alongside the query provider +- **Socket.IO** — `src/socket/SocketBridge` hangs off the authenticated shell +- **better-auth** + passkey for login; auth gate in `App.tsx` (`checkLogged` query) wraps everything except `/setup`, `/setup-api`, `/login` + +### Entry & Shell + +`main.tsx` → `App.tsx` (mounts providers, HashRouter, auth gate, installs theme tokens via `installThemeTokens`) → `AdminShell` (nav chrome + `SocketBridge` + `AppRoutes`). All views in `routes.tsx` are `lazy()`-loaded and wrapped in ``; add new pages by registering a lazy import there. ### Path Aliases ```typescript -import { something } from '~/utils/...' // ~ maps to ./src +import { something } from '~/utils/...' // ~ → apps/admin/src ``` -### API Layer (`src/api/`) +### API Layer (`apps/admin/src/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. +API services use the fetch-based helpers in `apps/admin/src/api/http.ts`. When using TanStack Query, extract arrays with: ```typescript @@ -48,14 +59,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 +69,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 @@ -89,12 +92,20 @@ Do NOT use arbitrary font sizes (e.g., `text-[11px]`, `text-[13px]`). Use standa See `docs/typography.md` for full guidelines. +## Layout Conventions + +New admin views must follow the master-detail / content-layout convention. See: + +- `docs/master-detail-layout.md` — list+detail pages (comments, drafts, topics) +- `docs/typography.md` — full typography rules +- `apps/admin/src/ui/content-layout.tsx` and `page-layout.tsx` — reusable shells + ## 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 -- `.env` - Local dev API endpoint (`VITE_APP_BASE_API`) +- `apps/admin/vite.config.mts` — Vite + react-compiler + Tailwind + mkcert + checker +- `apps/admin/uno.config.ts` — UnoCSS breakpoints (`phone:`, `tablet:`, `desktop:`) and theme colors (if present) +- `apps/admin/src/theme.ts` — CSS token installation for the shell +- `apps/admin/src/index.css` — global stylesheet + Tailwind layer ## Related Projects @@ -102,9 +113,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..f5e01cb4b 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -12,8 +12,8 @@ }, "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", @@ -21,103 +21,114 @@ "@codemirror/search": "6.7.0", "@codemirror/state": "6.6.0", "@codemirror/theme-one-dark": "6.1.3", - "@codemirror/view": "6.42.1", + "@codemirror/view": "^6.43.0", "@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", + "@haklex/rich-agent-core": "0.15.4", + "@haklex/rich-compose": "0.15.4", + "@haklex/rich-diff": "0.15.4", + "@haklex/rich-editor": "0.15.4", + "@haklex/rich-editor-ui": "0.15.4", + "@haklex/rich-ext-ai-agent": "0.15.4", + "@haklex/rich-ext-chat": "0.15.4", + "@haklex/rich-ext-code-snippet": "0.15.4", + "@haklex/rich-ext-embed": "0.15.4", + "@haklex/rich-ext-excalidraw": "0.15.4", + "@haklex/rich-ext-gallery": "0.15.4", + "@haklex/rich-ext-nested-doc": "0.15.4", + "@haklex/rich-plugin-block-handle": "0.15.4", + "@haklex/rich-plugin-floating-toolbar": "0.15.4", + "@haklex/rich-plugin-link-edit": "0.15.4", + "@haklex/rich-plugin-litexml-paste": "0.15.4", + "@haklex/rich-plugin-mention": "0.15.4", + "@haklex/rich-plugin-slash-menu": "0.15.4", + "@haklex/rich-plugin-table": "0.15.4", + "@haklex/rich-plugin-toolbar": "0.16.0", + "@haklex/rich-renderer-alert": "0.15.4", + "@haklex/rich-renderer-banner": "0.15.4", + "@haklex/rich-renderer-codeblock": "0.15.4", + "@haklex/rich-renderer-image": "0.15.4", + "@haklex/rich-renderer-katex": "0.15.4", + "@haklex/rich-renderer-linkcard": "0.15.4", + "@haklex/rich-renderer-mention": "0.15.4", + "@haklex/rich-renderer-mermaid": "0.15.4", + "@haklex/rich-renderer-ruby": "0.15.4", + "@haklex/rich-renderer-video": "0.15.4", "@lexical/markdown": "^0.44.0", + "@lexical/react": "^0.44.0", "@lezer/highlight": "1.2.3", - "@mx-admin/rich-react": "workspace:*", + "@monaco-editor/react": "4.7.0", "@pierre/diffs": "1.1.3", + "@rehookify/datepicker": "6.6.8", "@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", + "katex": "^0.16.45", "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", + "motion": "12.40.0", "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", + "jotai": "2.20.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-resizable-panels": "4.11.1", + "react-router": "7.15.1", "shiki": "3.21.0", "socket.io-client": "4.8.3", - "sortablejs": "1.15.7", - "umi-request": "1.4.0", + "sonner": "2.0.7", + "tinykeys": "4.0.0", "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" + "zod": "4.3.6", + "zustand": "5.0.13" }, "devDependencies": { - "@types/ejs": "3.1.5", + "@babel/core": "7.29.0", + "@babel/parser": "^7.29.3", + "@rolldown/plugin-babel": "0.2.3", + "@tailwindcss/vite": "^4.1.13", + "@types/babel__core": "7.20.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", + "babel-plugin-react-compiler": "1.0.0", + "code-inspector-plugin": "1.5.1", "cors": "2.8.6", "happy-dom": "^15.11.0", - "postcss": "8.5.8", - "postcss-nested": "7.0.2", - "postcss-preset-env": "11.2.0", "rollup": "^4.60.1", + "tailwindcss": "^4.1.13", "typescript": "5.9.3", - "unocss": "^66.6.8", "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/postcss.config.js b/apps/admin/postcss.config.js deleted file mode 100644 index 3cb05b14c..000000000 --- a/apps/admin/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - 'postcss-nested': {}, - 'postcss-preset-env': {}, - }, -} diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 4a5f783cb..58ac24918 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -1,132 +1,78 @@ -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 { publicRoutes } from 'virtual:admin-routes' -import { AiTaskQueue } from '~/components/ai-task-queue' -import { PortalInjectKey } from '~/hooks/use-portal-element' +import { checkLogged } from './api/auth' +import { useI18n } from './i18n' +import { AppProviders } from './providers' +import { AppRoutes } from './routes' +import { AdminShell } from './shell' +import { SocketBridge } from './socket/SocketBridge' +import { installThemeTokens } from './theme' -import { useUIStore } from './stores/ui' -import { - commonThemeVars, - componentThemeOverrides, - darkThemeColors, - lightThemeColors, -} from './utils/color' +const publicPathSet = new Set(publicRoutes.map((route) => route.path)) -const Root = defineComponent({ - name: 'RootView', +function App() { + useEffect(() => { + document.title = 'Mx Space Admin' + installThemeTokens() + }, []) - setup() { - onMounted(() => { - window.dialog = useDialog() - }) - const $portalElement = ref(null) + return ( + + + + + + ) +} - provide(PortalInjectKey, { - setElement(el) { - $portalElement.value = el - return () => { - $portalElement.value = null - } - }, - }) +function AppContent() { + const location = useLocation() - return () => { - return ( - <> - - {$portalElement.value ?? <>} - - ) - } - }, -}) + if (publicPathSet.has(location.pathname)) { + return + } -const App = defineComponent({ - setup() { - const uiStore = useUIStore() - return () => { - const { isDark, naiveUIDark } = uiStore - const isCurrentDark = naiveUIDark || isDark + return +} - return ( - - - - - - - - - - - ) - } - }, -}) +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}` -const AccentColorInjector = defineComponent({ - setup() { - const vars = useThemeVars() - watchEffect(() => { - const { primaryColor, primaryColorHover, primaryColorSuppl } = vars.value + if (loggedQuery.isLoading) { + return ( +
+ {t('app.loading.auth')} +
+ ) + } - document.documentElement.style.setProperty( - '--color-primary', - primaryColor, - ) - document.documentElement.style.setProperty( - '--color-primary-shallow', - primaryColorHover, - ) - document.documentElement.style.setProperty( - '--color-primary-deep', - primaryColorSuppl, - ) - }) + if (!loggedQuery.data?.ok) { + return ( + + ) + } + + 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 index 3e47f1ebb..2fbb2ee42 100644 --- a/apps/admin/src/api/activity.ts +++ b/apps/admin/src/api/activity.ts @@ -1,84 +1,72 @@ -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 type { ActivityReadDurationType } from '../models/activity' +import type { PaginateResult } from '../models/base' -import { request } from '~/utils/request' +import { getJson } from './http' -export interface ActivityPresence { - operationTime: number - updatedAt: number - connectedAt: number - identity: string - roomName: string - position: number - sid: string - displayName?: string - ts?: number +export enum ActivityType { + Like = 0, + ReadDuration = 1, } export interface ActivityItem { + createdAt: string id: string - created: string - payload: any - type: number + payload: { + id?: string + ip?: string + [key: string]: unknown + } + ref?: { + id?: string + slug?: string + title?: string + } + refId?: string + type: ActivityType } -export interface ActivityListResponse extends PaginateResult { +export interface ActivityListResponse extends PaginateResult< + ActivityItem | ActivityReadDurationType +> { objects?: { - posts?: PostModel[] - notes?: NoteModel[] - pages?: PageModel[] - recentlies?: RecentlyModel[] + 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 { - refId: string count: number ref: { id?: string - title?: string - slug?: string nid?: number + slug?: string + title?: string } + refId: string } -export interface GetActivityParams { +export function getActivityList(params: { page?: number size?: number - type?: number - before?: string - after?: string + type?: ActivityType +}) { + return getJson('/activity', params) } -export interface OnlineCountResponse { - total: number - rooms: Record +export function getReadingRank(params?: { + end?: number + limit?: number + start?: number +}) { + return getJson('/activity/reading/rank', params) } -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}`), +export function getTopReadings(params?: { days?: number; top?: number }) { + return getJson('/activity/reading/top', params) +} - // 获取在线人数 - getOnlineCount: () => - request.get('/activity/online-count'), +export function getReferenceUrl(id: string) { + return getJson(`/helper/url-builder/${id}`) } diff --git a/apps/admin/src/api/aggregate.ts b/apps/admin/src/api/aggregate.ts index 27854b180..a1f7cc1f3 100644 --- a/apps/admin/src/api/aggregate.ts +++ b/apps/admin/src/api/aggregate.ts @@ -1,115 +1,111 @@ -import { request } from '~/utils/request' +import { getJson } from './http' export interface StatCount { - posts: number - notes: number - pages: number + allComments?: number + callTime: number categories: number - tags: number comments: number + linkApply?: number links: number - says: number - recently: number - unreadComments: number + notes: number online: number + pages: number + posts: number + recently: number + says: number + tags?: number + todayIpAccessCount: number todayMaxOnline: number todayOnlineTotal: number - callTime: number + unreadComments: number uv: number - todayIpAccessCount: number } export interface CategoryDistribution { + count: number id: string name: string slug: string - count: number } -export interface PublicationTrend { +export interface PublicationTrendItem { date: string - posts: number notes: number + posts: number } export interface TagCloudItem { - tag: string count: number + tag: string } export interface TopArticle { + category: { name: string; slug: string } | null id: string - title: string - slug: string - reads: number likes: number - category: { - name: string - slug: string - } | null + reads: number + slug: string + title: string } export interface CommentActivityItem { - date: string count: number + date: string } export interface TrafficSourceData { - os: Array<{ name: string; count: number }> - browser: Array<{ name: string; count: number }> + browser: Array<{ count: number; name: string }> + os: Array<{ count: number; name: string }> } -export interface WordCount { - count: number +export function getAggregateStat() { + return getJson('/aggregate/stat') } -export interface ReadAndLikeCount { - totalLikes: number - totalReads: number +export function getCategoryDistribution() { + return getJson( + '/aggregate/stat/category-distribution', + ) } -export const aggregateApi = { - // 获取统计数据 - getStat: () => request.get('/aggregate/stat'), - - // 获取分类分布 - getCategoryDistribution: () => - request.get( - '/aggregate/stat/category-distribution', - ), - - // 获取发布趋势 - getPublicationTrend: () => - request.get('/aggregate/stat/publication-trend'), +export function getPublicationTrend() { + return getJson('/aggregate/stat/publication-trend') +} - // 获取标签云 - getTagCloud: () => request.get('/aggregate/stat/tag-cloud'), +export function getTagCloud() { + return getJson('/aggregate/stat/tag-cloud') +} - // 获取热门文章 - getTopArticles: () => - request.get('/aggregate/stat/top-articles'), +export function getTopArticles() { + return getJson('/aggregate/stat/top-articles') +} - // 获取评论活动 - getCommentActivity: () => - request.get('/aggregate/stat/comment-activity'), +export function getCommentActivity() { + return getJson('/aggregate/stat/comment-activity') +} - // 获取流量来源 - getTrafficSource: () => - request.get('/aggregate/stat/traffic-source'), +export function getTrafficSource() { + return getJson('/aggregate/stat/traffic-source') +} - // 获取站点字数统计 - countSiteWords: () => request.get('/aggregate/count_site_words'), +export function countSiteWords() { + return getJson<{ count: number }>('/aggregate/count_site_words') +} - // 获取阅读和点赞统计 - countReadAndLike: () => - request.get('/aggregate/count_read_and_like'), +export function countReadAndLike() { + return getJson<{ totalLikes: number; totalReads: number }>( + '/aggregate/count_read_and_like', + ) +} - // 获取站点点赞数 - getSiteLikeCount: () => request.get('/like_this'), +export function getSiteLikeCount() { + return getJson('/like_this') +} - // 清理缓存 - cleanCache: () => request.get('/clean_catch'), +export function cleanCache() { + return getJson('/clean_catch') +} - // 清理 Redis - cleanRedis: () => request.get('/clean_redis'), +export function cleanRedis() { + return getJson('/clean_redis') } diff --git a/apps/admin/src/api/ai-agent.ts b/apps/admin/src/api/ai-agent.ts index b25f2332d..d639b6ce3 100644 --- a/apps/admin/src/api/ai-agent.ts +++ b/apps/admin/src/api/ai-agent.ts @@ -1,58 +1,72 @@ -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' export interface AgentConversation { + createdAt: string + diffState?: Record id: string + messageCount: number + messages?: Record[] + model: string + providerId: string refId: string refType: string + reviewState?: Record 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 }, - }), +export function createAgentConversation(data: { + messages?: Record[] + model: string + providerId: string + refId: string + refType: 'note' | 'page' | 'post' + title?: string +}) { + return postJson( + '/ai/agent/conversations', + data, + ) +} - getConversation: (id: string) => - request.get(`/ai/agent/conversations/${id}`), +export function getAgentConversations( + refId: string, + refType: 'note' | 'page' | 'post', +) { + return getJson('/ai/agent/conversations', { + refId, + refType, + }) +} - appendMessages: (id: string, messages: Record[]) => - request.patch(`/ai/agent/conversations/${id}/messages`, { - data: { messages }, - }), +export function getAgentConversation(id: string) { + return getJson(`/ai/agent/conversations/${id}`) +} - replaceMessages: (id: string, messages: Record[]) => - request.put(`/ai/agent/conversations/${id}/messages`, { - data: { messages }, - }), +export function replaceAgentConversationMessages( + id: string, + messages: Record[], +) { + return putJson[] }>( + `/ai/agent/conversations/${id}/messages`, + { messages }, + ) +} - updateConversation: ( - id: string, - data: { - title?: string - reviewState?: Record | null - diffState?: Record | null - }, - ) => - request.patch(`/ai/agent/conversations/${id}`, { data }), +export function updateAgentConversation( + id: string, + data: { + diffState?: Record | null + reviewState?: Record | null + title?: string + }, +) { + return patchJson( + `/ai/agent/conversations/${id}`, + data, + ) +} - deleteConversation: (id: string) => - request.delete(`/ai/agent/conversations/${id}`), +export function deleteAgentConversation(id: string) { + return deleteJson(`/ai/agent/conversations/${id}`) } diff --git a/apps/admin/src/api/ai.ts b/apps/admin/src/api/ai.ts index a892a2b3f..b26563069 100644 --- a/apps/admin/src/api/ai.ts +++ b/apps/admin/src/api/ai.ts @@ -1,43 +1,44 @@ -import type { ContentFormat } from '~/shared/types/base' +import { deleteJson, getJson, patchJson, postJson, requestJson } from './http' -import { request } from '~/utils/request' - -// AI Writer 类型 export enum AiQueryType { - TitleSlug = 'title-slug', Slug = 'slug', + TitleSlug = 'title-slug', } export interface AIWriterGenerateData { + text?: string + title?: string type: AiQueryType - text?: string // 当 type 为 title-slug 时需要 - title?: string // 当 type 为 slug 时需要 } export interface AIWriterGenerateResponse { - title?: string slug?: string + title?: string } -// AI Summary 类型 -export interface AISummary { +export interface ArticleInfo { id: string - createdAt: string - summary: string - hash: string - refId: string - lang: string + title: string + type: 'Note' | 'Page' | 'Post' | 'Recently' } -export interface GroupedSummary { - type: string - items: AISummary[] +export interface PaginationInfo { + currentPage?: number + hasNextPage?: boolean + hasPrevPage?: boolean + page?: number + size: number + total: number + totalPage?: number } -export interface ArticleInfo { - type: 'Post' | 'Note' | 'Page' | 'Recently' - title: string +export interface AISummary { + createdAt: string + hash: string id: string + lang: string + refId: string + summary: string } export interface GroupedSummaryData { @@ -47,33 +48,25 @@ export interface GroupedSummaryData { export interface GroupedSummaryResponse { data: GroupedSummaryData[] - pagination: { - total: number - currentPage: number - totalPage: number - size: number - hasNextPage: boolean - hasPrevPage: boolean - } + pagination: PaginationInfo } export interface SummaryByRefResponse { - summaries: AISummary[] article: { - type: 'Post' | 'Note' | 'Page' | 'Recently' document: { title: string } + type: 'Note' | 'Page' | 'Post' | 'Recently' } + summaries: AISummary[] } -// AI Insights 类型 export interface AIInsights { - id: string + content: string createdAt: string - refId: string - lang: string hash: string - content: string + id: string isTranslation: boolean + lang: string + refId: string sourceInsightsId?: string sourceLang?: string } @@ -85,42 +78,36 @@ export interface GroupedInsightsData { export interface GroupedInsightsResponse { data: GroupedInsightsData[] - pagination: { - total: number - currentPage: number - totalPage: number - size: number - hasNextPage: boolean - hasPrevPage: boolean - } + pagination: PaginationInfo } export interface InsightsByRefResponse { - insights: AIInsights[] article: { - type: 'Post' | 'Note' | 'Page' | 'Recently' document: { title: string } + type: 'Note' | 'Page' | 'Post' | 'Recently' } | null + insights: AIInsights[] } -// AI Translation 类型 +export type AIContentFormat = 'lexical' | 'markdown' | string + export interface AITranslation { - id: string + aiModel?: string + aiProvider?: string + content?: string + contentFormat?: AIContentFormat createdAt: string hash: string + id: string + lang: 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 + text: string + title: string } export interface GroupedTranslationData { @@ -130,22 +117,15 @@ export interface GroupedTranslationData { export interface GroupedTranslationResponse { data: GroupedTranslationData[] - pagination: { - total: number - currentPage: number - totalPage: number - size: number - hasNextPage: boolean - hasPrevPage: boolean - } + pagination: PaginationInfo } export interface TranslationByRefResponse { - translations: AITranslation[] article: { - type: 'Post' | 'Note' | 'Page' | 'Recently' document: { title: string } + type: 'Note' | 'Page' | 'Post' | 'Recently' } + translations: AITranslation[] } export interface ProviderModel { @@ -154,29 +134,72 @@ export interface ProviderModel { } export interface ProviderModelsResponse { + error?: string + models: ProviderModel[] providerId: string providerName: string providerType: string - models: ProviderModel[] - error?: string } export interface AITestData { - providerId: string - type: string apiKey?: string endpoint?: string model?: string + providerId: string + type: string } export interface AIModelListData { - providerId: string - type: string 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 } -// AI Task 类型 export enum AITaskType { Summary = 'ai:summary', Translation = 'ai:translation', @@ -197,45 +220,39 @@ export enum AITaskStatus { } export interface AITaskLog { - timestamp: number - level: 'info' | 'warn' | 'error' + level: 'error' | 'info' | 'warn' message: string + timestamp: number } export interface SubTaskStats { - total: number completed: number failed: number - running: number pending: number + running: number + total: number } export interface AITask { + completedAt?: number + completedItems?: number + createdAt: number + error?: string + groupId?: string id: string - type: AITaskType - status: AITaskStatus + logs: AITaskLog[] 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 + startedAt?: number + status: AITaskStatus subTaskStats?: SubTaskStats + tokensGenerated?: number + totalItems?: number + type: AITaskType + workerId?: string } export interface AITasksResponse { @@ -244,258 +261,261 @@ export interface AITasksResponse { } export interface CreateTaskResponse { - taskId: string created: boolean + taskId: string } -export interface AICommentReviewTestData { - text: string - author?: string +export function testCommentReview(data: AICommentReviewTestData) { + return postJson( + '/ai/comment-review/test', + data, + ) } -export interface AICommentReviewTestResponse { - isSpam: boolean - score?: number - reason?: string +export function writerGenerate(data: AIWriterGenerateData) { + return postJson( + '/ai/writer/generate', + data, + ) } -// Translation Entry (词表) 类型 -export type TranslationEntryKeyPath = - | 'category.name' - | 'topic.name' - | 'topic.introduce' - | 'note.mood' - | 'note.weather' +export function getSummariesGrouped(params?: { + page?: number + search?: string + size?: number +}) { + return getJson('/ai/summaries/grouped', params) +} -export interface TranslationEntry { - id: string - createdAt: string - keyPath: TranslationEntryKeyPath - lang: string - keyType: 'entity' | 'dict' - lookupKey: string - sourceText: string - translatedText: string - sourceUpdatedAt?: string +export function getSummaryByRef(refId: string) { + return getJson(`/ai/summaries/ref/${refId}`) } -export interface TranslationEntriesResponse { - data: TranslationEntry[] - pagination: { - total: number - page: number - size: number - } +export function deleteSummary(id: string) { + return deleteJson(`/ai/summaries/${id}`) } -export interface GenerateEntriesResponse { - created: number - skipped: number +export function updateSummary(id: string, data: { summary: string }) { + return patchJson(`/ai/summaries/${id}`, data) } -export const aiApi = { - // AI 评论审核测试 - testCommentReview: (data: AICommentReviewTestData) => - request.post('/ai/comment-review/test', { - 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) +} - // AI 写作生成标题/Slug - writerGenerate: (data: AIWriterGenerateData) => - request.post('/ai/writer/generate', { data }), +export function getInsightsByRef(refId: string) { + return getJson(`/ai/insights/ref/${refId}`) +} - // 获取摘要列表(分组) - getSummariesGrouped: (params?: { - page?: number - size?: number - search?: string - }) => - request.get('/ai/summaries/grouped', { params }), +export function deleteInsights(id: string) { + return deleteJson(`/ai/insights/${id}`) +} - // 根据引用获取摘要 - getSummaryByRef: (refId: string) => - request.get(`/ai/summaries/ref/${refId}`), +export function updateInsights(id: string, data: { content: string }) { + return patchJson(`/ai/insights/${id}`, data) +} - // 删除摘要 - deleteSummary: (id: string) => request.delete(`/ai/summaries/${id}`), +export function createInsightsTask(data: { refId: string }) { + return postJson( + '/ai/insights/task', + data, + ) +} - // 更新摘要 - updateSummary: (id: string, data: { summary: string }) => - request.patch(`/ai/summaries/${id}`, { data }), +export function createInsightsTranslationTask(data: { + refId: string + targetLang: string +}) { + return postJson( + '/ai/insights/task/translate', + data, + ) +} - // 生成摘要(创建任务) - createSummaryTask: (data: { refId: string; lang?: string }) => - request.post('/ai/summaries/task', { data }), +export function getModels() { + return getJson('/ai/models') +} - // === AI Insights === +export function getModelList(data: AIModelListData) { + return postJson<{ error?: string; models: ProviderModel[] }, AIModelListData>( + '/ai/models/list', + data, + ) +} - // 获取精读列表(分组) - 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'), +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}`) +} + +function normalizeTasksResponse( + response: AITasksResponse | AITask[], +): AITasksResponse { + if (Array.isArray(response)) { + return { + data: response, + total: response.length, + } + } + + return response } diff --git a/apps/admin/src/api/analyze.ts b/apps/admin/src/api/analyze.ts index 1c04e45d7..9be4cf174 100644 --- a/apps/admin/src/api/analyze.ts +++ b/apps/admin/src/api/analyze.ts @@ -1,24 +1,14 @@ import type { UA } from '~/models/analyze' import type { PaginateResult } from '~/models/base' -import { request } from '~/utils/request' +import { deleteJson, getJson } from './http' export type AnalyzeRecord = UA.Root & { - country?: string | null - referer?: string | null + country?: null | string + referer?: null | string } 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' @@ -28,47 +18,68 @@ export interface IPAggregate { count: number path: string }> + today: Array<{ + hour: string + key: 'ip' | 'pv' + value: number + }> + todayIps: string[] total: { callTime: number uv: number } - todayIps: string[] + weeks: Array<{ + day: string + key: 'ip' | 'pv' + value: number + }> } export interface GetAnalyzeParams { + from?: string page?: number size?: number - from?: string to?: string } export interface TrafficSourceResponse { categories: Array<{ name: string; value: number }> - details: Array<{ source: string; count: number }> + details: Array<{ count: number; source: string }> } export interface DeviceDistributionResponse { browsers: Array<{ name: string; value: number }> - os: Array<{ name: string; value: number }> devices: Array<{ name: string; value: number }> + os: Array<{ name: string; value: number }> } -export const analyzeApi = { - // 获取分析列表 - getList: (params?: GetAnalyzeParams) => - request.get>('/analyze', { params }), +export function getAnalyzeList(params: GetAnalyzeParams = {}) { + return getJson>('/analyze', { + from: params.from, + page: params.page, + size: params.size, + to: params.to, + }) +} - // 获取聚合数据 - getAggregate: () => request.get('/analyze/aggregate'), +export function getAnalyzeAggregate() { + return getJson('/analyze/aggregate') +} - // 获取流量来源 - getTrafficSource: (params?: { from?: string; to?: string }) => - request.get('/analyze/traffic-source', { params }), +export function getTrafficSource(params?: { from?: string; to?: string }) { + return getJson('/analyze/traffic-source', { + from: params?.from, + to: params?.to, + }) +} - // 获取设备分布 - getDeviceDistribution: (params?: { from?: string; to?: string }) => - request.get('/analyze/device', { params }), +export function getDeviceDistribution(params?: { from?: string; to?: string }) { + return getJson('/analyze/device', { + from: params?.from, + to: params?.to, + }) +} - // 清空分析数据 - deleteAll: () => request.delete('/analyze'), +export function deleteAllAnalyzeRecords() { + return deleteJson('/analyze') } diff --git a/apps/admin/src/api/auth.ts b/apps/admin/src/api/auth.ts index f377a5fc5..18492dc23 100644 --- a/apps/admin/src/api/auth.ts +++ b/apps/admin/src/api/auth.ts @@ -1,69 +1,74 @@ import type { TokenModel } from '~/models/token' +import { translate } from '~/i18n/translate' import { authClient } from '~/utils/authjs/auth' -import { request } from '~/utils/request' + +import { getJson, postJson, requestJson } from './http' export interface CreateTokenData { - name: string expired?: Date | string + name: string } export interface PasskeyItem { + createdAt: string + credentialID: string id: string name?: string - credentialID: string - publicKey: string - createdAt: string + publicKey?: string } -export const authApi = { - // === Token 管理 === - - // 获取 Token 列表 - getTokens: () => request.get('/auth/token'), +export interface LoggedStatus { + isGuest?: boolean + ok: boolean | number +} - // 获取单个 Token - getToken: (id: string) => - request.get('/auth/token', { params: { id } }), +export function checkLogged() { + return getJson('/owner/check_logged') +} - // 创建 Token - createToken: (data: CreateTokenData) => - request.post('/auth/token', { data }), +export function getTokens() { + return getJson('/auth/token') +} - // 删除 Token - deleteToken: (id: string) => - request.delete('/auth/token', { params: { id } }), +export function getToken(id: string) { + return getJson('/auth/token', { id }) +} - // === Passkey 管理(使用 Better Auth 客户端)=== +export function createToken(data: CreateTokenData) { + return postJson('/auth/token', data) +} - // 获取 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, - })) - }, +export function deleteToken(id: string) { + return requestJson(`/auth/token?id=${encodeURIComponent(id)}`, { + method: 'DELETE', + }) +} - // 删除 Passkey - deletePasskey: async (id: string) => { - const result = await authClient.passkey.deletePasskey({ id }) - if (result.error) { - throw new Error(result.error.message) - } - }, +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 || translate('api.error.passkeyFetch')) - // 获取 Session - getSession: () => request.get('/auth/session'), + 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[] +} - // 作为 Owner 认证 - authAsOwner: () => request.patch('/auth/as-owner'), +export async function deletePasskey(id: string) { + const result = await authClient.passkey.deletePasskey({ id }) + if (result.error) + throw new Error( + result.error.message || translate('api.error.passkeyDelete'), + ) } 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/backups.ts b/apps/admin/src/api/backups.ts new file mode 100644 index 000000000..a8681516b --- /dev/null +++ b/apps/admin/src/api/backups.ts @@ -0,0 +1,46 @@ +import { + deleteJson, + getJson, + patchJson, + requestBlob, + requestJson, +} from './http' + +export interface BackupFile { + createdAt: string + filename: string + size: string +} + +export async function getBackups() { + return getJson('/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) + + await requestJson('/backups/rollback', { + body: formData, + method: 'POST', + }) +} diff --git a/apps/admin/src/api/categories.ts b/apps/admin/src/api/categories.ts index b309a6050..9a90a4a5a 100644 --- a/apps/admin/src/api/categories.ts +++ b/apps/admin/src/api/categories.ts @@ -1,7 +1,7 @@ import type { CategoryModel, TagModel } from '~/models/category' import type { PostModel } from '~/models/post' -import { request } from '~/utils/request' +import { deleteJson, getJson, postJson, putJson } from './http' export interface GetCategoriesParams { type?: 'Category' | 'Tag' | 'tag' @@ -13,34 +13,42 @@ export interface CreateCategoryData { type?: number } -export interface UpdateCategoryData extends Partial {} +export type UpdateCategoryData = Partial -export const categoriesApi = { - // 获取分类列表(响应会被自动解包) - getList: (params?: GetCategoriesParams) => - request.get('/categories', { params }), +export function getCategories(params?: GetCategoriesParams) { + return getJson('/categories', { type: params?.type }) +} - // 获取单个分类(响应会被自动解包) - getById: (id: string) => request.get(`/categories/${id}`), +export function getCategory(id: string) { + return getJson(`/categories/${id}`) +} - // 创建分类(响应会被自动解包) - create: (data: CreateCategoryData) => - request.post('/categories', { data }), +export function createCategory(data: CreateCategoryData) { + return postJson('/categories', data) +} - // 更新分类 - update: (id: string, data: UpdateCategoryData) => - request.put(`/categories/${id}`, { data }), +export function updateCategory(id: string, data: UpdateCategoryData) { + return putJson(`/categories/${id}`, data) +} - // 删除分类 - delete: (id: string) => request.delete(`/categories/${id}`), +export function deleteCategory(id: string) { + return deleteJson(`/categories/${id}`) +} + +export function getTags() { + return getJson('/categories', { type: 'tag' }) +} + +interface PostsByTagResponse { + data: PostModel[] + tag: string +} - // 获取标签列表(响应会被自动解包) - getTags: () => - request.get('/categories', { params: { type: 'tag' } }), +export async function getPostsByTag(tagName: string) { + const result = await getJson( + `/categories/${tagName}`, + { tag: 'true' }, + ) - // 获取标签关联的文章(响应会被自动解包) - getPostsByTag: (tagName: string) => - request.get(`/categories/${tagName}`, { - params: { tag: 'true' }, - }), + return Array.isArray(result) ? result : result.data } diff --git a/apps/admin/src/api/comments.ts b/apps/admin/src/api/comments.ts index 7b15c2775..c22c2d144 100644 --- a/apps/admin/src/api/comments.ts +++ b/apps/admin/src/api/comments.ts @@ -1,53 +1,52 @@ import type { CommentModel, CommentsResponse } from '~/models/comment' -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, postJson } from './http' export interface GetCommentsParams { - page?: number - size?: number - state?: number + 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 }), +} + +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/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/cron-tasks.ts b/apps/admin/src/api/cron-tasks.ts new file mode 100644 index 000000000..b775edb1c --- /dev/null +++ b/apps/admin/src/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/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 index d5df78c94..218ef43ef 100644 --- a/apps/admin/src/api/dependencies.ts +++ b/apps/admin/src/api/dependencies.ts @@ -1,10 +1,44 @@ -import { request } from '~/utils/request' +import { API_URL } from '../constants/env' +import { translate } from '../i18n/translate' +import { getJson } from './http' export interface DependencyGraph { dependencies: Record } -export const dependenciesApi = { - // 获取依赖图 - getGraph: () => request.get('/dependencies/graph'), +export interface NpmPackageLatest { + name: string + version: string +} + +export function getDependencyGraph() { + return getJson('/dependencies/graph') +} + +export function getDependencyInstallUrl(packageNames: string | string[]) { + return `${API_URL}${getDependencyInstallPath(packageNames)}` +} + +function getDependencyInstallPath(packageNames: string | string[]) { + const names = Array.isArray(packageNames) + ? packageNames.join(',') + : packageNames + + const searchParams = new URLSearchParams({ + packageNames: names, + }) + + return `/dependencies/install_deps?${searchParams}` +} + +export async function getNpmPackageLatest(name: string) { + const response = await fetch( + `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, + ) + + if (!response.ok) { + throw new Error(translate('api.error.npmLatest', { name })) + } + + return (await response.json()) as NpmPackageLatest } diff --git a/apps/admin/src/api/drafts.ts b/apps/admin/src/api/drafts.ts index c8a26ab4f..92f9cfebc 100644 --- a/apps/admin/src/api/drafts.ts +++ b/apps/admin/src/api/drafts.ts @@ -6,69 +6,77 @@ import type { TypeSpecificData, } from '~/models/draft' -import { request } from '~/utils/request' +import { deleteJson, getJson, postJson, putJson } from './http' export type DraftSortOrder = 'asc' | 'desc' export interface GetDraftsParams { + hasRef?: boolean page?: number - size?: number refType?: DraftRefType - hasRef?: boolean + size?: number sort_by?: string sort_order?: DraftSortOrder } export interface CreateDraftData { - refType: DraftRefType - refId?: string - title?: string - text?: string - contentFormat?: 'markdown' | 'lexical' content?: string + contentFormat?: 'lexical' | 'markdown' images?: Image[] - meta?: Record + meta?: Record + refId?: string + refType: DraftRefType + text?: string + title?: string typeSpecificData?: TypeSpecificData } -export interface UpdateDraftData extends Partial {} - -export const draftsApi = { - // 获取草稿列表 - getList: (params?: GetDraftsParams) => - request.get>('/drafts', { params }), +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, + }) +} - // 获取单个草稿 - getById: (id: string) => request.get(`/drafts/${id}`), +export function getDraftById(id: string) { + return getJson(`/drafts/${id}`) +} - // 根据引用获取草稿 - getByRef: (refType: DraftRefType, refId: string) => - request.get(`/drafts/by-ref/${refType}/${refId}`), +export function getDraftByRef(refType: DraftRefType, refId: string) { + return getJson(`/drafts/by-ref/${refType}/${refId}`) +} - // 获取新草稿列表(无关联的草稿) - getNewDrafts: (refType: DraftRefType) => - request.get(`/drafts/by-ref/${refType}/new`), +export function getNewDrafts(refType: DraftRefType) { + return getJson(`/drafts/by-ref/${refType}/new`) +} - // 获取历史版本列表 - getHistory: (id: string) => - request.get(`/drafts/${id}/history`), +export function getDraftHistory(id: string) { + return getJson(`/drafts/${id}/history`) +} - // 获取特定历史版本 - getHistoryVersion: (id: string, version: number) => - request.get(`/drafts/${id}/history/${version}`), +export function getDraftHistoryVersion(id: string, version: number) { + return getJson(`/drafts/${id}/history/${version}`) +} - // 创建草稿 - create: (data: CreateDraftData) => - request.post('/drafts', { data }), +export function createDraft(data: CreateDraftData) { + return postJson('/drafts', data) +} - // 更新草稿 - update: (id: string, data: UpdateDraftData) => - request.put(`/drafts/${id}`, { data }), +export function updateDraft(id: string, data: Partial) { + return putJson>(`/drafts/${id}`, data) +} - // 删除草稿 - delete: (id: string) => request.delete<{ success: boolean }>(`/drafts/${id}`), +export function deleteDraft(id: string) { + return deleteJson<{ success: boolean }>(`/drafts/${id}`) +} - // 恢复到特定版本 - restoreVersion: (id: string, version: number) => - request.post(`/drafts/${id}/restore/${version}`), +export function restoreDraftVersion(id: string, version: number) { + return postJson>( + `/drafts/${id}/restore/${version}`, + {}, + ) } diff --git a/apps/admin/src/api/enrichment.ts b/apps/admin/src/api/enrichment.ts index 744f4dac4..9dbd921c5 100644 --- a/apps/admin/src/api/enrichment.ts +++ b/apps/admin/src/api/enrichment.ts @@ -1,4 +1,5 @@ import type { + EnrichmentCaptureJoinedRow, EnrichmentCaptureListResponse, EnrichmentCaptureQuota, EnrichmentImage, @@ -6,104 +7,169 @@ import type { EnrichmentProbeResult, EnrichmentProviderMeta, EnrichmentResult, + EnrichmentRow, EnrichmentRowDetail, + LegacyPager, } from '~/models/enrichment' -import { request } from '~/utils/request' +import { deleteJson, getJson, requestJson } from './http' const encodeId = (id: string) => encodeURIComponent(id) -export const enrichmentApi = { - resolve: (url: string, lang?: string) => - request.get('/enrichment/resolve', { - params: { url, ...(lang ? { lang } : {}) }, - }), +export interface GetEnrichmentListParams { + locale?: string + onlyFailed?: boolean + page?: number + size?: number +} - 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 } : {}), - }, - }), +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}`, + ) +} - 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, - }, - }, - ) +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), + ) +} - quota: () => - request.get('/enrichment/admin/captures/quota'), +export function getEnrichmentCaptureQuota() { + return getJson('/enrichment/admin/captures/quota') +} - delete: (enrichmentId: string) => - request.delete( - `/enrichment/admin/captures/${encodeId(enrichmentId)}`, - ), +export function deleteEnrichmentCapture(enrichmentId: string) { + return deleteJson( + `/enrichment/admin/captures/${encodeId(enrichmentId)}`, + ) +} - recapture: (enrichmentId: string) => - request.post( - `/enrichment/admin/captures/${encodeId(enrichmentId)}/recapture`, - ), - }, +export function recaptureEnrichment(enrichmentId: string) { + return requestJson( + `/enrichment/admin/captures/${encodeId(enrichmentId)}/recapture`, + { method: 'POST' }, + ) +} - probe: (url: string, useCache?: boolean) => - request.post('/enrichment/admin/probe', { - data: { url, ...(useCache !== undefined ? { useCache } : {}) }, +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/api/files.ts b/apps/admin/src/api/files.ts index 561f7833b..8068c598a 100644 --- a/apps/admin/src/api/files.ts +++ b/apps/admin/src/api/files.ts @@ -1,41 +1,52 @@ -import { request } from '~/utils/request' +import { API_URL } from '~/constants/env' +import { translate } from '~/i18n/translate' + +import { deleteJson, getJson, patchJson, requestJson } from './http' export interface FileItem { + blurhash?: null | string + created?: number name: string + palette?: { dominant?: string; swatches?: string[] } | null url: string - created?: number } export interface UploadResponse { - url: string + blurhash?: null | string name: string + palette?: { dominant?: string; swatches?: string[] } | null + url: string } export interface OrphanFile { - id: string + blurhash?: null | string + byteSize?: null | number + createdAt: string + detachedAt?: null | 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 + id: string + mimeType?: null | string + readerId?: null | string + refId?: null | string + refType?: null | string + palette?: { dominant?: string; swatches?: string[] } | null + 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: { - currentPage: number - totalPage: number - size: number - total: number - hasNextPage: boolean - hasPrevPage: boolean - } + pagination: FileListPagination } export interface CleanupResult { @@ -43,114 +54,185 @@ export interface CleanupResult { totalOrphan: number } -export interface ImageStorageOptions { - enable?: boolean - endpoint?: string - bucket?: string - region?: string - customDomain?: string - prefix?: string +export interface CommentUploadFile { + blurhash?: null | string + byteSize?: number + createdAt: string + detachedAt?: string + fileName: string + fileUrl: string + id: string + mimeType?: string + readerId?: string + refId?: string + refType?: string + palette?: { dominant?: string; swatches?: string[] } | null + status: 'active' | 'detached' | 'pending' +} + +export interface CommentUploadListResponse { + data: CommentUploadFile[] + pagination: FileListPagination } -export const filesApi = { - // 按类型获取文件列表 - getByType: (type: string) => request.get(`/files/${type}`), +export type FileType = 'avatar' | 'file' | 'icon' | 'image' +export type CommentUploadStatus = '' | 'active' | 'detached' | 'pending' - // 上传文件 - upload: (file: File, type?: string) => { - const formData = new FormData() - formData.append('file', file) - return request.post('/files/upload', { - data: formData, - params: type ? { type } : undefined, - }) - }, +export function getFilesByType(type: FileType) { + return getJson(`/files/${type}`) +} - // 更新已有文件(覆盖内容,保持文件名不变) - update: (type: string, name: string, file: File) => { - const formData = new FormData() - formData.append('file', file) - return request.put(`/files/${type}/${name}`, { - data: formData, - }) - }, +export function uploadFile(file: File, type?: FileType) { + const formData = new FormData() + formData.append('file', file) - // 按类型和名称删除文件 - 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, - }), - }, + const query = type ? `?type=${encodeURIComponent(type)}` : '' - // 评论图片管理(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}`, - ), + 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(translate('api.error.uploadFailed'))) + xhr.send(formData) + }) } -export interface CommentUploadFile { - id: string - fileName: string - fileUrl: string - status: 'pending' | 'active' | 'detached' +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 - mimeType?: string - byteSize?: number - refType?: string refId?: string - detachedAt?: string - createdAt: 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 interface CommentUploadListResponse { - data: CommentUploadFile[] - pagination: { - currentPage: number - totalPage: number - size: number - total: number - hasNextPage: boolean - hasPrevPage: boolean +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/api/github-repo.ts b/apps/admin/src/api/github-repo.ts new file mode 100644 index 000000000..a4c6a639c --- /dev/null +++ b/apps/admin/src/api/github-repo.ts @@ -0,0 +1,40 @@ +import { translate } from '../i18n/translate' + +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(translate('api.error.githubRepo')) + + 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/api/github-snippets.ts b/apps/admin/src/api/github-snippets.ts new file mode 100644 index 000000000..16c28f42d --- /dev/null +++ b/apps/admin/src/api/github-snippets.ts @@ -0,0 +1,34 @@ +import { translate } from '../i18n/translate' + +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(translate('api.error.githubSnippets')) + } + + return (await response.json()) as GitHubContentItem[] | GitHubContentItem +} + +export async function fetchGitHubText(downloadUrl: string) { + const response = await fetch(downloadUrl) + + if (!response.ok) { + throw new Error(translate('api.error.fetchFile')) + } + + return response.text() +} diff --git a/apps/admin/src/api/github-update.ts b/apps/admin/src/api/github-update.ts new file mode 100644 index 000000000..3fd465fdd --- /dev/null +++ b/apps/admin/src/api/github-update.ts @@ -0,0 +1,73 @@ +export type UpdateRepo = 'mx-admin' | 'mx-server' + +export interface GitHubReleaseDetails { + body: string | null + htmlUrl: string + name: string | null + publishedAt: string | null + tagName: string +} + +interface GitHubReleaseResponse { + body: string | null + html_url: string + name: string | null + published_at: string | null + tag_name: string +} + +export interface GitHubUpdateVersions { + dashboard: string + dashboardRelease: GitHubReleaseDetails + system: string + systemRelease: GitHubReleaseDetails +} + +export async function checkUpdateFromGitHub(): Promise { + const [systemRelease, dashboardRelease] = await Promise.all([ + fetchRelease('mx-server', 'latest'), + fetchRelease('mx-admin', 'latest'), + ]) + + return { + dashboard: normalizeVersion(dashboardRelease.tagName), + dashboardRelease, + system: normalizeVersion(systemRelease.tagName), + systemRelease, + } +} + +export function getReleaseDetails(repo: UpdateRepo, version: string) { + return fetchRelease(repo, `tags/v${normalizeVersion(version)}`) +} + +async function fetchRelease(repo: UpdateRepo, release: 'latest' | string) { + const response = await fetch( + `https://api.github.com/repos/mx-space/${repo}/releases/${release}`, + { + headers: { + accept: 'application/vnd.github+json', + }, + }, + ) + + if (!response.ok) { + throw new Error(`GitHub release request failed: ${response.status}`) + } + + return mapRelease((await response.json()) as GitHubReleaseResponse) +} + +function mapRelease(release: GitHubReleaseResponse): GitHubReleaseDetails { + return { + body: release.body, + htmlUrl: release.html_url, + name: release.name, + publishedAt: release.published_at, + tagName: release.tag_name, + } +} + +function normalizeVersion(version: string) { + return version.replace(/^v/, '') +} diff --git a/apps/admin/src/api/health.ts b/apps/admin/src/api/health.ts index 40c84dfc4..6c2f4946b 100644 --- a/apps/admin/src/api/health.ts +++ b/apps/admin/src/api/health.ts @@ -1,7 +1,5 @@ -import { request } from '~/utils/request' +import { getJson } from './http' -export const healthApi = { - // 发送测试邮件 - sendTestEmail: () => - request.get<{ message?: string; trace?: string }>('/health/email/test'), +export function sendTestEmail() { + return getJson<{ message?: string; trace?: string }>('/health/email/test') } diff --git a/apps/admin/src/api/http.ts b/apps/admin/src/api/http.ts new file mode 100644 index 000000000..a2a6d5e46 --- /dev/null +++ b/apps/admin/src/api/http.ts @@ -0,0 +1,303 @@ +import { API_URL } from '~/constants/env' +import { SESSION_WITH_LOGIN } from '~/constants/keys' + +const requestUuid = createRequestUuid() + +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: buildAdminRequestHeaders(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 +} + +export async function requestBlob( + path: string, + init: RequestInit = {}, +): Promise { + const response = await fetch(`${API_URL}${path}`, { + credentials: 'include', + ...init, + headers: buildAdminRequestHeaders(init.headers), + }) + + if (!response.ok) { + const responseData = normalizeResponseData( + camelcaseKeys(await readResponseData(response.clone())), + ) + + if (isUnauthorizedResponse(response, responseData)) { + handleUnauthorized() + } + + const message = + responseData?.error?.message || + responseData?.message || + response.statusText + + throw new Error( + Array.isArray(message) ? message.join(', ') : message || 'Request failed', + ) + } + + return response.blob() +} + +export function buildAdminRequestHeaders(headers?: HeadersInit) { + const next = new Headers(headers) + next.set('x-skip-translation', '1') + next.set('x-uuid', requestUuid) + + return next +} + +function createRequestUuid() { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAll(/[xy]/g, (char) => { + const value = (Math.random() * 16) | 0 + const digit = char === 'x' ? value : (value & 0x3) | 0x8 + + return digit.toString(16) + }) +} + +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/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 index 89c271f97..d684d9861 100644 --- a/apps/admin/src/api/links.ts +++ b/apps/admin/src/api/links.ts @@ -1,65 +1,74 @@ import type { LinkModel, LinkResponse, LinkStateCount } from '~/models/link' -import { request } from '~/utils/request' +import { + deleteJson, + getJson, + patchJson, + postJson, + putJson, + requestJson, +} from './http' export interface GetLinksParams { - page?: number - size?: number - state?: number + page: number + size: number + state: number } -export interface CreateLinkData { - name: string - url: string +export interface LinkInput { avatar?: string description?: string - type?: number + name: string state?: number + type?: number + url: string } -export interface UpdateLinkData extends Partial {} - -export const linksApi = { - // 获取友链列表 - getList: (params?: GetLinksParams) => - request.get('/links', { params }), - - // 获取状态计数 - getStateCount: () => request.get('/links/state'), +export function getLinks(params: GetLinksParams) { + return getJson('/links', { + page: params.page, + size: params.size, + state: params.state, + }) +} - // 获取单个友链 - getById: (id: string) => request.get(`/links/${id}`), +export function getLinkStateCount() { + return getJson('/links/state') +} - // 创建友链 - create: (data: CreateLinkData) => request.post('/links', { data }), +export function createLink(data: LinkInput) { + return postJson('/links', data) +} - // 更新友链 - update: (id: string, data: UpdateLinkData) => - request.put(`/links/${id}`, { data }), +export function updateLink(id: string, data: Partial) { + return putJson>(`/links/${id}`, data) +} - // 删除友链 - delete: (id: string) => request.delete(`/links/${id}`), +export function deleteLink(id: string) { + return deleteJson(`/links/${id}`) +} - // 更新友链状态 - updateState: (id: string, state: number) => - request.patch(`/links/${id}`, { data: { state } }), +export function auditPassLink(id: string) { + return requestJson(`/links/audit/${id}`, { method: 'PATCH' }) +} - // 检查友链健康状态 - checkHealth: (options?: { timeout?: number }) => - request.get< - Record - >('/links/health', { timeout: options?.timeout }), +export function auditLinkWithReason( + id: string, + data: { reason: string; state: number }, +) { + return postJson(`/links/audit/reason/${id}`, data) +} - // 审核通过友链 - auditPass: (id: string) => request.patch(`/links/audit/${id}`), +export function updateLinkState(id: string, state: number) { + return patchJson(`/links/${id}`, { state }) +} - // 审核友链并发送理由 - auditWithReason: (id: string, state: number, reason: string) => - request.post(`/links/audit/reason/${id}`, { - data: { state, reason }, - }), +export function checkLinksHealth() { + return getJson< + Record + >('/links/health') +} - // 迁移头像 - migrateAvatars: (options?: { timeout?: number }) => - request.post('/links/avatar/migrate', { timeout: options?.timeout }), +export function migrateLinkAvatars() { + return requestJson('/links/avatar/migrate', { method: 'POST' }) } diff --git a/apps/admin/src/api/markdown.ts b/apps/admin/src/api/markdown.ts index 3c3d17030..727af0b9a 100644 --- a/apps/admin/src/api/markdown.ts +++ b/apps/admin/src/api/markdown.ts @@ -1,32 +1,33 @@ -import { request } from '~/utils/request' +import { postJson, requestBlob } from './http' export interface MarkdownImportData { content?: string - type?: 'post' | 'note' | 'page' - data?: any[] + data?: unknown[] + type?: 'note' | 'page' | 'post' } export interface MarkdownExportParams { - type?: 'post' | 'note' | 'page' id?: string - slug?: boolean - yaml?: boolean show_title?: boolean + slug?: boolean + type?: 'note' | 'page' | 'post' with_meta_json?: boolean + yaml?: boolean } -export const markdownApi = { - // 导入 Markdown - import: (data: MarkdownImportData) => - request.post<{ id: string }>('/markdown/import', { data }), +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)) + } + } - // 导出 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 - }, + const query = searchParams.toString() + return requestBlob(`/markdown/export${query ? `?${query}` : ''}`) } diff --git a/apps/admin/src/api/meta-presets.ts b/apps/admin/src/api/meta-presets.ts index 7e17b48cf..a800412cb 100644 --- a/apps/admin/src/api/meta-presets.ts +++ b/apps/admin/src/api/meta-presets.ts @@ -5,45 +5,41 @@ import type { UpdateMetaPresetDto, } from '~/models/meta-preset' -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' export interface MetaPresetQueryParams { - scope?: MetaPresetScope 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 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 } }), +export function updateMetaPresetOrder(ids: string[]) { + return putJson('/meta-presets/order', { + ids, + }) } diff --git a/apps/admin/src/api/notes.ts b/apps/admin/src/api/notes.ts index b0b615b34..9e5a6aa37 100644 --- a/apps/admin/src/api/notes.ts +++ b/apps/admin/src/api/notes.ts @@ -1,14 +1,14 @@ -import type { PaginateResult } from '~/models/base' +import type { Image, PaginateResult } from '~/models/base' import type { NoteModel } from '~/models/note' -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' export type NoteSortKey = - | 'title' | 'createdAt' | 'modifiedAt' - | 'weather' | 'mood' + | 'title' + | 'weather' export type SortOrder = 'asc' | 'desc' export interface GetNotesParams { @@ -16,70 +16,89 @@ export interface GetNotesParams { 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 + topicId?: null | string +} + +export interface SearchNotesParams { + keyword: string + page: number + size: number } 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 + content?: string + contentFormat?: 'lexical' | 'markdown' + coordinates?: null | { + latitude: number + longitude: number + } + draftId?: string + images?: Image[] isPublished?: boolean + location?: null | string meta?: Record - /** 关联的草稿 ID,发布时传递以标记草稿为已发布 */ - draftId?: string + mood?: string + password?: null | string + publicAt?: Date | null | string + slug?: string + text: string + title: string + topicId?: null | string + weather?: string } -export interface UpdateNoteData extends Partial {} - -// 用于 patch 操作的数据类型,允许将某些字段设为 null export interface PatchNoteData { - topicId?: string | null - slug?: string | null [key: string]: unknown + slug?: null | string + topicId?: null | string } -export const notesApi = { - // 获取日记列表 - getList: (params?: GetNotesParams) => - request.get>('/notes', { params }), +export function getNotes(params: GetNotesParams = {}) { + return getJson>('/notes', { + page: params.page, + size: params.size, + sort_by: params.sort_by, + sort_order: params.sort_order, + topicId: params.topicId ?? undefined, + }) +} - // 获取单篇日记 - getById: (id: string, params?: { single?: boolean }) => - request.get(`/notes/${id}`, { params }), +export function searchNotes(params: SearchNotesParams) { + return getJson>('/search/note', { + keyword: params.keyword, + page: params.page, + size: params.size, + }) +} - // 创建日记 - create: (data: CreateNoteData) => request.post('/notes', { data }), +export function getNoteById(id: string, params?: { single?: boolean }) { + return getJson(`/notes/${id}`, { + single: params?.single ? 'true' : undefined, + }) +} - // 更新日记 - update: (id: string, data: UpdateNoteData) => - request.put(`/notes/${id}`, { data }), +export function createNote(data: CreateNoteData) { + return postJson('/notes', data) +} - // 删除日记 - delete: (id: string) => request.delete(`/notes/${id}`), +export function updateNote(id: string, data: Partial) { + return putJson>(`/notes/${id}`, data) +} - // 更新部分字段 - patch: (id: string, data: PatchNoteData) => - request.patch(`/notes/${id}`, { data }), +export function patchNote(id: string, data: PatchNoteData) { + return patchJson(`/notes/${id}`, data) +} - // 更新发布状态 - patchPublish: (id: string, isPublished: boolean) => - request.patch(`/notes/${id}/publish`, { data: { isPublished } }), +export function patchNotePublish(id: string, isPublished: boolean) { + return patchJson( + `/notes/${id}/publish`, + { + isPublished, + }, + ) +} - // 获取专栏下的日记列表 - getByTopic: (topicId: string, params?: { page?: number; size?: number }) => - request.get>>( - `/notes/topics/${topicId}`, - { params }, - ), +export function deleteNote(id: string) { + return deleteJson(`/notes/${id}`) } diff --git a/apps/admin/src/api/options.ts b/apps/admin/src/api/options.ts index bf5c83ca1..3a536b261 100644 --- a/apps/admin/src/api/options.ts +++ b/apps/admin/src/api/options.ts @@ -1,51 +1,136 @@ -import type { FormDSL } from '~/components/config-form/types' +import type { UserModel } from '~/models/user' -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, putJson } from './http' export interface SystemOptions { - [key: string]: any + [key: string]: unknown +} + +export interface UrlOptions { + adminUrl: string + serverUrl: string + webUrl: string + wsUrl: string } export interface EmailTemplateResponse { + props: unknown 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 }), +} + +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/api/pages.ts b/apps/admin/src/api/pages.ts index 014cf0dad..812a77b3a 100644 --- a/apps/admin/src/api/pages.ts +++ b/apps/admin/src/api/pages.ts @@ -1,7 +1,7 @@ -import type { PaginateResult } from '~/models/base' +import type { Image, PaginateResult } from '~/models/base' import type { PageModel } from '~/models/page' -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' export interface GetPagesParams { page?: number @@ -9,37 +9,44 @@ export interface GetPagesParams { } export interface CreatePageData { - title: string - text: string + content?: string + contentFormat?: 'lexical' | 'markdown' + draftId?: string + images?: Image[] + meta?: Record + order?: number slug: string subtitle?: string - order?: number - meta?: Record - /** 关联的草稿 ID,发布时传递以标记草稿为已发布 */ - draftId?: string + text: string + title: string } -export interface UpdatePageData extends Partial {} - -export const pagesApi = { - // 获取页面列表 - getList: (params?: GetPagesParams) => - request.get>('/pages', { params }), +export function getPages(params: GetPagesParams = {}) { + return getJson>('/pages', { + page: params.page, + size: params.size, + }) +} - // 获取单个页面 - getById: (id: string) => request.get(`/pages/${id}`), +export function getPageById(id: string) { + return getJson(`/pages/${id}`) +} - // 创建页面 - create: (data: CreatePageData) => request.post('/pages', { data }), +export function createPage(data: CreatePageData) { + return postJson('/pages', data) +} - // 更新页面 - update: (id: string, data: UpdatePageData) => - request.put(`/pages/${id}`, { data }), +export function updatePage(id: string, data: Partial) { + return putJson>(`/pages/${id}`, data) +} - // 删除页面 - delete: (id: string) => request.delete(`/pages/${id}`), +export function deletePage(id: string) { + return deleteJson(`/pages/${id}`) +} - // 重新排序 - reorder: (seq: Array<{ id: string; order: number }>) => - request.patch('/pages/reorder', { data: { seq } }), +export function reorderPages(seq: Array<{ id: string; order: number }>) { + return patchJson }>( + '/pages/reorder', + { seq }, + ) } diff --git a/apps/admin/src/api/posts.ts b/apps/admin/src/api/posts.ts index 4b80fa427..807d13f3b 100644 --- a/apps/admin/src/api/posts.ts +++ b/apps/admin/src/api/posts.ts @@ -1,57 +1,78 @@ -import type { PaginateResult } from '~/models/base' +import type { Image, PaginateResult } from '~/models/base' import type { PostModel } from '~/models/post' -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' export type PostSortKey = 'createdAt' | 'modifiedAt' | 'pinAt' -export type SortOrder = 'asc' | 'desc' +export type PostSortOrder = 'asc' | 'desc' export interface GetPostsParams { - page?: number - size?: number - sort_by?: PostSortKey - sort_order?: SortOrder categoryIds?: string[] + page: number + size: number + sort_by?: PostSortKey + sort_order?: PostSortOrder +} + +export interface SearchPostsParams { + keyword: string + page: number + size: number } export interface CreatePostData { - title: string - text: string categoryId: string - slug?: string - tags?: string[] - summary?: string | null + content?: string + contentFormat?: 'lexical' | 'markdown' copyright?: boolean + draftId?: string + images?: Image[] isPublished?: boolean - pin?: string | null - pinOrder?: number - relatedId?: string[] meta?: Record - /** 关联的草稿 ID,发布时传递以标记草稿为已发布 */ - draftId?: string + pin?: null | string + pinOrder?: null | number + relatedId?: string[] + slug?: string + summary?: null | string + tags?: string[] + text: string + title: string } -export interface UpdatePostData extends Partial {} +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 const postsApi = { - // 获取文章列表 - getList: (params?: GetPostsParams) => - request.get>('/posts', { params }), +export function searchPosts(params: SearchPostsParams) { + return getJson>('/search/post', { + keyword: params.keyword, + page: params.page, + size: params.size, + }) +} - // 获取单篇文章 - getById: (id: string) => request.get(`/posts/${id}`), +export function getPostById(id: string) { + return getJson(`/posts/${id}`) +} - // 创建文章 - create: (data: CreatePostData) => request.post('/posts', { data }), +export function createPost(data: CreatePostData) { + return postJson('/posts', data) +} - // 更新文章 - update: (id: string, data: UpdatePostData) => - request.put(`/posts/${id}`, { data }), +export function updatePost(id: string, data: Partial) { + return putJson>(`/posts/${id}`, data) +} - // 删除文章 - delete: (id: string) => request.delete(`/posts/${id}`), +export function patchPost(id: string, data: Partial) { + return patchJson>(`/posts/${id}`, data) +} - // 更新发布状态 - patch: (id: string, data: Partial) => - request.patch(`/posts/${id}`, { data }), +export function deletePost(id: string) { + return deleteJson(`/posts/${id}`) } diff --git a/apps/admin/src/api/projects.ts b/apps/admin/src/api/projects.ts index 33afa9aae..d7b6d1415 100644 --- a/apps/admin/src/api/projects.ts +++ b/apps/admin/src/api/projects.ts @@ -1,41 +1,34 @@ import type { ProjectModel, ProjectResponse } from '~/models/project' -import { request } from '~/utils/request' +import { deleteJson, getJson, postJson, putJson } from './http' -export interface GetProjectsParams { - page?: number - size?: number -} - -export interface CreateProjectData { - name: string +export interface ProjectInput { + avatar?: string description: string - text: string - previewUrl?: string docUrl?: string - projectUrl?: string images?: string[] - avatar?: string + name: string + previewUrl?: string + projectUrl?: string + text: string } -export interface UpdateProjectData extends Partial {} - -export const projectsApi = { - // 获取项目列表 - getList: (params?: GetProjectsParams) => - request.get('/projects', { params }), +export function getProjects(params: { page: number; size: number }) { + return getJson('/projects', params) +} - // 获取单个项目 - getById: (id: string) => request.get(`/projects/${id}`), +export function getProject(id: string) { + return getJson(`/projects/${id}`) +} - // 创建项目 - create: (data: CreateProjectData) => - request.post('/projects', { data }), +export function createProject(data: ProjectInput) { + return postJson('/projects', data) +} - // 更新项目 - update: (id: string, data: UpdateProjectData) => - request.put(`/projects/${id}`, { data }), +export function updateProject(id: string, data: Partial) { + return putJson>(`/projects/${id}`, data) +} - // 删除项目 - delete: (id: string) => request.delete(`/projects/${id}`), +export function deleteProject(id: string) { + return deleteJson(`/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 index b145fe6f9..3134b82b2 100644 --- a/apps/admin/src/api/readers.ts +++ b/apps/admin/src/api/readers.ts @@ -1,6 +1,6 @@ import type { PaginateResult } from '~/models/base' -import { request } from '~/utils/request' +import { getJson } from './http' export interface ReaderModel { id: string @@ -13,13 +13,6 @@ export interface ReaderModel { role: 'reader' | 'owner' } -export interface GetReadersParams { - page?: number - size?: number -} - -export const readersApi = { - // 获取读者列表 - getList: (params?: GetReadersParams) => - request.get>('/readers', { params }), +export function getReaders(params: { page: number; size: number }) { + return getJson>('/readers', params) } diff --git a/apps/admin/src/api/recently.ts b/apps/admin/src/api/recently.ts index 8f06895c7..9324b500c 100644 --- a/apps/admin/src/api/recently.ts +++ b/apps/admin/src/api/recently.ts @@ -1,28 +1,30 @@ import type { RecentlyModel } from '~/models/recently' -import { request } from '~/utils/request' +import { deleteJson, getJson, postJson, putJson } from './http' -export interface RecentlyCreatePayload { +export interface RecentlyInput { content: string } -export type RecentlyUpdatePayload = RecentlyCreatePayload - -export const recentlyApi = { - // 获取最近访问列表 - getAll: () => request.get('/recently/all'), +export interface RecentlyListParams { + [key: string]: boolean | number | string | undefined + after?: string + before?: string + size?: number +} - // 创建速记 - create: (data: RecentlyCreatePayload) => - request.post('/recently', { data }), +export function getRecentlyList(params: RecentlyListParams = {}) { + return getJson('/recently', params) +} - // 更新速记 - update: (id: string, data: RecentlyUpdatePayload) => - request.put(`/recently/${id}`, { data }), +export function createRecently(data: RecentlyInput) { + return postJson('/recently', data) +} - // 删除最近访问项 - delete: (id: string) => request.delete(`/recently/${id}`), +export function updateRecently(id: string, data: RecentlyInput) { + return putJson(`/recently/${id}`, data) +} - // 清空最近访问 - clear: () => request.delete('/recently/all'), +export function deleteRecently(id: string) { + return deleteJson(`/recently/${id}`) } diff --git a/apps/admin/src/api/says.ts b/apps/admin/src/api/says.ts index b6ccd1714..a73044c85 100644 --- a/apps/admin/src/api/says.ts +++ b/apps/admin/src/api/says.ts @@ -1,35 +1,25 @@ import type { SayModel, SayResponse } from '~/models/say' -import { request } from '~/utils/request' +import { deleteJson, getJson, postJson, putJson } from './http' -export interface GetSaysParams { - page?: number - size?: number -} - -export interface CreateSayData { - text: string - source?: string +export interface SayInput { author?: string + source?: string + text: string } -export interface UpdateSayData extends Partial {} - -export const saysApi = { - // 获取一言列表 - getList: (params?: GetSaysParams) => - request.get('/says', { params }), - - // 获取单个一言 - getById: (id: string) => request.get(`/says/${id}`), +export function getSays(params: { page: number; size: number }) { + return getJson('/says', params) +} - // 创建一言 - create: (data: CreateSayData) => request.post('/says', { data }), +export function createSay(data: SayInput) { + return postJson('/says', data) +} - // 更新一言 - update: (id: string, data: UpdateSayData) => - request.put(`/says/${id}`, { data }), +export function updateSay(id: string, data: Partial) { + return putJson>(`/says/${id}`, data) +} - // 删除一言 - delete: (id: string) => request.delete(`/says/${id}`), +export function deleteSay(id: string) { + return deleteJson(`/says/${id}`) } diff --git a/apps/admin/src/api/search-index.ts b/apps/admin/src/api/search-index.ts index e6235ce0b..4037d680a 100644 --- a/apps/admin/src/api/search-index.ts +++ b/apps/admin/src/api/search-index.ts @@ -1,38 +1,83 @@ -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 }, - ) - }, +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/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 index f307ee24c..209c1f7f7 100644 --- a/apps/admin/src/api/serverless.ts +++ b/apps/admin/src/api/serverless.ts @@ -1,26 +1,26 @@ -import { request } from '~/utils/request' +import { getJson } from './http' export interface ServerlessLogEntry { - id: string + createdAt: string + error?: { message: string; name: string; stack?: string } + executionTime: number functionId: string - reference: string - name: string - method: string + id: string ip: string - status: 'success' | 'error' - executionTime: number - createdAt: string - logs?: { level: string; timestamp: number; args: unknown[] }[] - error?: { name: string; message: string; stack?: string } + logs?: Array<{ args: unknown[]; level: string; timestamp: number }> + method: string + name: string + reference: string + status: 'error' | 'success' } export interface ServerlessLogPagination { - total: number - size: number currentPage: number - totalPage: number hasNextPage: boolean hasPrevPage: boolean + size: number + total: number + totalPage: number } export interface ServerlessLogListResponse { @@ -31,17 +31,29 @@ export interface ServerlessLogListResponse { export interface GetServerlessLogsParams { page?: number size?: number - status?: 'success' | 'error' + status?: 'error' | 'success' } -export const serverlessApi = { - getInvocationLogs: (id: string, params?: GetServerlessLogsParams) => - request.get(`/fn/logs/${id}`, { - params, - }), +export function getInvocationLogs( + id: string, + params?: GetServerlessLogsParams, +) { + return getJson( + `/fn/logs/${id}`, + params + ? { + page: params.page, + size: params.size, + status: params.status, + } + : undefined, + ) +} - getInvocationLogDetail: (id: string) => - request.get(`/fn/log/${id}`), +export function getInvocationLogDetail(id: string) { + return getJson(`/fn/log/${id}`) +} - getCompiledCode: (id: string) => request.get(`/fn/compiled/${id}`), +export function getCompiledCode(id: string) { + return getJson(`/fn/compiled/${id}`) } diff --git a/apps/admin/src/api/snippets.ts b/apps/admin/src/api/snippets.ts index 2e9796083..876c903f8 100644 --- a/apps/admin/src/api/snippets.ts +++ b/apps/admin/src/api/snippets.ts @@ -1,73 +1,85 @@ import type { PaginateResult } from '~/models/base' import type { SnippetModel, SnippetType } from '~/models/snippet' -import { request } from '~/utils/request' +import { deleteJson, getJson, postJson, putJson } from './http' export interface GetSnippetsParams { page?: number + reference?: string size?: number type?: SnippetType - reference?: string } export interface CreateSnippetData { + comment?: string + customPath?: string + enable?: boolean + metatype?: string + method?: string name: string - type: SnippetType + private?: boolean raw: string reference?: string - private?: boolean - comment?: string - metatype?: string schema?: string - enable?: boolean - method?: string - secret?: Record - customPath?: string + secret?: Record | string | null + type: SnippetType } -export interface UpdateSnippetData extends Partial {} - export interface SnippetGroup { - reference: string count: number + reference: string } export interface ImportSnippetsData { - snippets: SnippetModel[] packages?: string[] + snippets: Array } -export const snippetsApi = { - // 获取片段列表 - getList: (params?: GetSnippetsParams) => - request.get>('/snippets', { params }), +export function getSnippets(params: GetSnippetsParams = {}) { + return getJson>('/snippets', { + page: params.page, + reference: params.reference, + size: params.size, + type: params.type, + }) +} - // 获取单个片段 - getById: (id: string) => request.get(`/snippets/${id}`), +export function getSnippetById(id: string) { + return getJson(`/snippets/${id}`) +} - // 创建片段 - create: (data: CreateSnippetData) => - request.post('/snippets', { data }), +export function createSnippet(data: CreateSnippetData) { + return postJson('/snippets', data) +} - // 更新片段 - update: (id: string, data: UpdateSnippetData) => - request.put(`/snippets/${id}`, { data }), +export function updateSnippet(id: string, data: Partial) { + return putJson>( + `/snippets/${id}`, + data, + ) +} - // 删除片段 - delete: (id: string) => request.delete(`/snippets/${id}`), +export function deleteSnippet(id: string) { + return deleteJson(`/snippets/${id}`) +} - // 获取分组列表 - getGroups: (params?: { page?: number; size?: number }) => - request.get>('/snippets/group', { params }), +export function getSnippetGroups(params?: { page?: number; size?: number }) { + return getJson>('/snippets/group', { + page: params?.page, + size: params?.size, + }) +} - // 获取分组下的片段 - getGroupSnippets: (reference: string) => - request.get(`/snippets/group/${reference}`), +export function getGroupSnippets(reference: string) { + return getJson( + `/snippets/group/${encodeURIComponent(reference)}`, + ) +} - // 重置函数片段(内置函数) - resetFunction: (id: string) => request.delete(`/fn/reset/${id}`), +export function resetFunctionSnippet(id: string) { + return deleteJson(`/fn/reset/${id}`) +} - // 导入片段 - import: (data: ImportSnippetsData) => - request.post('/snippets/import', { data }), +export function importSnippets(data: ImportSnippetsData) { + return postJson('/snippets/import', data) } diff --git a/apps/admin/src/api/subscribe.ts b/apps/admin/src/api/subscribe.ts index 57ad6c947..d8ef6a9b7 100644 --- a/apps/admin/src/api/subscribe.ts +++ b/apps/admin/src/api/subscribe.ts @@ -1,41 +1,51 @@ -import { request } from '~/utils/request' +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 { - id: string - email: string cancelToken: string + createdAt: string + email: string + id: 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 + size: number + total: number + totalPage: number } } -export const subscribeApi = { - // 获取订阅状态 - getStatus: () => request.get<{ enable: boolean }>('/subscribe/status'), +export function getSubscribeStatus() { + return getJson<{ enable: boolean }>('/subscribe/status') +} - // 获取订阅列表 - getList: (params?: { page?: number; size?: number }) => - request.get('/subscribe', { params }), +export function getSubscribers(params: { page: number; size: number }) { + return getJson('/subscribe', { + page: params.page, + size: params.size, + }) +} - // 取消订阅 (单个,需 cancelToken) - unsubscribe: (params: { email: string; cancelToken: string }) => - request.get('/subscribe/unsubscribe', { params }), +export function updateSubscribeEnabled(enabled: boolean) { + return patchJson('/options/featureList', { + emailSubscribe: enabled, + }) +} - // 批量取消订阅 - unsubscribeBatch: (params: { emails?: string[]; all?: boolean }) => - request.delete<{ deletedCount: number }>('/subscribe/unsubscribe/batch', { - data: params, - }), +export function unsubscribeBatch(params: { all: true } | { emails: string[] }) { + return deleteJson<{ deletedCount: number }, typeof params>( + '/subscribe/unsubscribe/batch', + params, + ) } diff --git a/apps/admin/src/api/system.ts b/apps/admin/src/api/system.ts index dc1218543..c17a54a05 100644 --- a/apps/admin/src/api/system.ts +++ b/apps/admin/src/api/system.ts @@ -1,95 +1,80 @@ -import { request } from '~/utils/request' +import type { AppInfo } from '~/models/system' -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 -} +import { API_URL } from '~/constants/env' -export interface PtyRecord { - id: string - data: any -} +import { + buildAdminRequestHeaders, + getJson, + patchJson, + postJson, + requestJson, +} from './http' export interface CreateOwnerData { - username: string - password: string - name?: string - mail: string - url?: string avatar?: string introduce?: string + mail: string + name?: string + password: string + url?: string + username: 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 }), +export interface InitDefaultConfigs { + seo?: { + description?: string + keywords?: string[] + title?: string + } +} - // 从备份恢复 - restoreFromBackup: (formData: FormData, timeout?: number) => - request.post('/init/restore', { data: formData, timeout }), +export async function checkInit() { + try { + const response = await fetch(`${API_URL}/init`, { + credentials: 'include', + headers: buildAdminRequestHeaders(), + }) - // 创建站点主人 - createOwner: (data: CreateOwnerData) => - request.post('/init/owner', { data }), + if (response.status === 404 || response.status === 403) { + return { isInit: true } + } - // === Debug === + if (!response.ok) + throw new Error(response.statusText || 'Init check failed') - // 发送调试事件 - sendDebugEvent: (data: DebugEventData) => - request.post('/debug/events', { data }), + return (await response.json()) as { isInit: boolean } + } catch (error) { + if (error instanceof Error) throw error + throw new Error('Init check failed') + } +} - // 执行 Serverless 函数 - executeFunction: (data: { code: string; context?: any }) => - request.post('/debug/function', { data }), +export function getAppInfo() { + return getJson('/') +} - // === PTY === +export function getInitDefaultConfigs() { + return getJson('/init/configs/default') +} - // 获取 PTY 记录 - getPtyRecords: () => request.get('/pty/record'), +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', + }) +} - // 执行内置函数 - callBuiltInFunction: (name: string, params?: Record) => - request.get(`/fn/built-in/${name}`, { params }), +export function createOwner(data: CreateOwnerData) { + return postJson('/init/owner', data) +} - // 获取函数类型定义 - getFnTypes: () => request.get('/fn/types'), +export function callBuiltInFunction( + name: string, + params?: Record, +) { + return getJson(`/fn/built-in/${name}`, params) } 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 index 0bbe08627..1259017fd 100644 --- a/apps/admin/src/api/topics.ts +++ b/apps/admin/src/api/topics.ts @@ -1,7 +1,8 @@ import type { PaginateResult } from '~/models/base' +import type { NoteModel } from '~/models/note' import type { TopicModel } from '~/models/topic' -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, postJson, putJson } from './http' export interface GetTopicsParams { page?: number @@ -9,35 +10,51 @@ export interface GetTopicsParams { } export interface CreateTopicData { - name: string - slug: string - introduce: string description?: string icon?: string + introduce: string + name: string + slug: string } -export interface UpdateTopicData extends Partial {} +export type UpdateTopicData = Partial -export const topicsApi = { - // 获取专栏列表 - getList: (params?: GetTopicsParams) => - request.get>('/topics', { params }), +export function getTopics(params: GetTopicsParams = {}) { + return getJson>('/topics', { + page: params.page, + size: params.size, + }) +} - // 获取单个专栏 - getById: (id: string) => request.get(`/topics/${id}`), +export function getTopic(id: string) { + return getJson(`/topics/${id}`) +} + +export function createTopic(data: CreateTopicData) { + return postJson('/topics', data) +} - // 创建专栏 - create: (data: CreateTopicData) => - request.post('/topics', { data }), +export function updateTopic(id: string, data: UpdateTopicData) { + return putJson(`/topics/${id}`, data) +} - // 更新专栏 - update: (id: string, data: UpdateTopicData) => - request.put(`/topics/${id}`, { data }), +export function patchTopic(id: string, data: Partial) { + return patchJson>(`/topics/${id}`, data) +} - // 部分更新专栏 - patch: (id: string, data: Partial) => - request.patch(`/topics/${id}`, { data }), +export function deleteTopic(id: string) { + return deleteJson(`/topics/${id}`) +} - // 删除专栏 - delete: (id: string) => request.delete(`/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/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 index 523c2f017..872b173e7 100644 --- a/apps/admin/src/api/webhooks.ts +++ b/apps/admin/src/api/webhooks.ts @@ -1,71 +1,84 @@ import type { PaginateResult } from '~/models/base' -import { request } from '~/utils/request' +import { deleteJson, getJson, patchJson, postJson } from './http' export interface WebhookModel { + created: string + enabled: boolean + events: string[] id: string - url: string payloadUrl: string - events: string[] - secret?: string - enabled: boolean scope: number - created: string + secret?: string updated: string + url: string } -export interface CreateWebhookData { - url?: string - payloadUrl?: string - events: string[] - secret?: string +export interface WebhookInput { enabled?: boolean + events: string[] + payloadUrl?: string scope?: number + secret?: string + url?: string } -export interface UpdateWebhookData extends Partial {} - export interface WebhookEventRecord { - id: string event: string headers: Record + hookId: string + id: string payload: unknown response: unknown - success: boolean status: number - hookId: string + success: boolean timestamp: string } -export const webhooksApi = { - // 获取 Webhook 列表 - getList: () => request.get('/webhooks'), +export const EventScope = { + ALL: (1 << 0) | (1 << 1) | (1 << 2), + TO_ADMIN: 1 << 1, + TO_SYSTEM: 1 << 2, + TO_VISITOR: 1 << 0, +} as const - // 获取可用事件列表 - getEvents: () => request.get('/webhooks/events'), +export function getWebhooks() { + return getJson('/webhooks') +} + +export function getWebhookEvents() { + return getJson('/webhooks/events') +} - // 创建 Webhook - create: (data: CreateWebhookData) => - request.post('/webhooks', { data }), +export function createWebhook(data: WebhookInput) { + return postJson('/webhooks', data) +} - // 更新 Webhook - update: (id: string, data: UpdateWebhookData) => - request.patch(`/webhooks/${id}`, { data }), +export function updateWebhook(id: string, data: Partial) { + return patchJson>(`/webhooks/${id}`, data) +} - // 删除 Webhook - delete: (id: string) => request.delete(`/webhooks/${id}`), +export function deleteWebhook(id: string) { + return deleteJson(`/webhooks/${id}`) +} - // 测试 Webhook - test: (id: string, event: string) => - request.post(`/webhooks/${id}/test`, { data: { event } }), +export function testWebhook(id: string, event: string) { + return postJson(`/webhooks/${id}/test`, { event }) +} - // 获取 Webhook 推送记录 - getDispatches: (id: string, params?: { page?: number; size?: number }) => - request.get>(`/webhooks/${id}`, { - params: { page: params?.page ?? 1, size: params?.size ?? 20 }, - }), +export function getWebhookDispatches( + id: string, + params: { page: number; size: number }, +) { + return getJson>(`/webhooks/${id}`, { + page: params.page, + size: params.size, + }) +} - // 重新推送 - redispatch: (hookId: string, eventId: string) => - request.post(`/webhooks/${hookId}/redispatch/${eventId}`), +export function redispatchWebhook(hookId: string, eventId: string) { + return postJson>( + `/webhooks/${hookId}/redispatch/${eventId}`, + {}, + ) } diff --git a/apps/admin/src/components/ai-task-queue/AiTaskQueue.tsx b/apps/admin/src/components/ai-task-queue/AiTaskQueue.tsx deleted file mode 100644 index 4639423aa..000000000 --- a/apps/admin/src/components/ai-task-queue/AiTaskQueue.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import { - AlertCircle, - AlertTriangle, - CheckCircle2, - Clock, - Loader2, - RefreshCw, - Sparkles, -} from 'lucide-vue-next' -import { NProgress } from 'naive-ui' -import { defineComponent } from 'vue' -import type { TrackedTask } from './types' - -import { AITaskStatus, AITaskType } from '~/api/ai' -import { TaskQueuePanel } from '~/components/task-queue-panel' - -import { useAiTaskQueue } from './use-ai-task-queue' - -const TaskTypeLabels: Record = { - [AITaskType.Summary]: '摘要', - [AITaskType.Translation]: '翻译', - [AITaskType.TranslationBatch]: '批量翻译', - [AITaskType.TranslationAll]: '全量翻译', - [AITaskType.SlugBackfill]: 'Slug 回填', - [AITaskType.Insights]: '精读', - [AITaskType.InsightsTranslation]: '精读翻译', -} - -const ITEM_HEIGHT = 56 - -function isBatchTask(type: AITaskType): boolean { - return ( - type === AITaskType.TranslationBatch || type === AITaskType.TranslationAll - ) -} - -const TaskItem = defineComponent({ - props: { - task: { - type: Object as () => TrackedTask, - required: true, - }, - onRetry: { - type: Function as unknown as () => (taskId: string) => void, - }, - }, - setup(props) { - return () => { - const task = props.task - const stats = task.subTaskStats - const hasBatchSubTasks = isBatchTask(task.type) && stats - - // For batch tasks with active sub-tasks, show as running - const hasActiveSubTasks = - hasBatchSubTasks && (stats.pending > 0 || stats.running > 0) - - const isFailed = - task.status === AITaskStatus.Failed || - task.status === AITaskStatus.Cancelled - const isPartialFailed = task.status === AITaskStatus.PartialFailed - const isRunning = - task.status === AITaskStatus.Running || hasActiveSubTasks - const isCompleted = - (task.status === AITaskStatus.Completed || - task.status === AITaskStatus.PartialFailed) && - !hasActiveSubTasks - const canRetry = isFailed && task.retryFn - - const getStatusIcon = () => { - if (task.status === AITaskStatus.Pending) - return - if (isRunning) - return - if (isPartialFailed) - return - if (isCompleted) return - if (isFailed) return - return - } - - // Calculate progress for batch tasks - const progressInfo = hasBatchSubTasks - ? { - percent: Math.round( - ((stats.completed + stats.failed) / stats.total) * 100, - ), - text: `${stats.completed + stats.failed}/${stats.total}`, - } - : task.progress !== undefined - ? { percent: task.progress, text: `${task.progress}%` } - : null - - return ( -
- {/* Status Icon */} -
- {getStatusIcon()} -
- - {/* Content */} -
-
- - {task.label} - -
-
- - {TaskTypeLabels[task.type] || task.type} - - {isRunning && progressInfo && ( - <> - - · - - {progressInfo.text} - - )} - {isRunning && - !hasBatchSubTasks && - task.tokensGenerated !== undefined && - task.tokensGenerated > 0 && ( - <> - - · - - - {task.tokensGenerated} tokens - - - )} - {isFailed && task.error && ( - <> - - · - - - {task.error} - - - )} -
-
- - {/* Progress bar for running tasks */} - {isRunning && progressInfo && ( -
- -
- )} - - {/* Retry button */} - {canRetry && ( - - )} -
- ) - } - }, -}) - -// Mock data for preview -// const mockTasks: TrackedTask[] = [ -// { -// id: '1', -// type: AITaskType.Summary, -// status: AITaskStatus.Completed, -// label: '如何使用 Vue 3 构建现代化应用', -// createdAt: Date.now() - 60000, -// }, -// { -// id: '2', -// type: AITaskType.Translation, -// status: AITaskStatus.Running, -// label: 'TypeScript 高级类型系统详解', -// progress: 65, -// progressMessage: '正在翻译第 3/5 段...', -// createdAt: Date.now() - 30000, -// }, -// { -// id: '3', -// type: AITaskType.TranslationBatch, -// status: AITaskStatus.Pending, -// label: '批量翻译 (8 篇)', -// createdAt: Date.now() - 10000, -// }, -// { -// id: '4', -// type: AITaskType.Translation, -// status: AITaskStatus.Failed, -// label: 'React vs Vue 性能对比分析', -// error: 'API 请求超时,请稍后重试', -// createdAt: Date.now() - 120000, -// retryFn: async () => ({ taskId: '4-retry', created: true }), -// }, -// { -// id: '5', -// type: AITaskType.Summary, -// status: AITaskStatus.Completed, -// label: '前端工程化最佳实践', -// createdAt: Date.now() - 90000, -// }, -// ] - -export const AiTaskQueue = defineComponent({ - name: 'AiTaskQueue', - setup() { - const queue = useAiTaskQueue() - - const handleClose = () => { - if (!queue.isProcessing.value) { - queue.clearAll() - } else { - queue.hide() - } - } - - const handleRetry = (taskId: string) => { - queue.retryTask(taskId) - } - - return () => { - const tasks = queue.tasks.value - const isProcessing = queue.isProcessing.value - const progress = queue.progress.value - const completedCount = queue.completedCount.value - const failedCount = queue.failedCount.value - - return ( - - {{ - icon: () => ( - - ), - title: () => ( - <> - AI 任务 - - {progress.completed}/{progress.total} - - - ), - item: ({ task }: { task: TrackedTask }) => ( - - ), - footer: () => - !isProcessing && tasks.length > 0 ? ( -
-
- {completedCount > 0 && ( - - {completedCount} 成功 - - )} - {failedCount > 0 && ( - - {failedCount} 失败 - - )} -
- -
- ) : null, - }} -
- ) - } - }, -}) diff --git a/apps/admin/src/components/ai-task-queue/index.ts b/apps/admin/src/components/ai-task-queue/index.ts deleted file mode 100644 index 4d883cf3c..000000000 --- a/apps/admin/src/components/ai-task-queue/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AiTaskQueue } from './AiTaskQueue' -export type { AiTaskQueueState, TrackedTask } from './types' -export { useAiTaskQueue } from './use-ai-task-queue' diff --git a/apps/admin/src/components/ai-task-queue/types.ts b/apps/admin/src/components/ai-task-queue/types.ts deleted file mode 100644 index a5064d459..000000000 --- a/apps/admin/src/components/ai-task-queue/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AITaskStatus, AITaskType } from '~/api/ai' - -export interface SubTaskStats { - total: number - completed: number - failed: number - running: number - pending: number -} - -export interface TrackedTask { - id: string - type: AITaskType - status: AITaskStatus - label: string - progress?: number - progressMessage?: string - tokensGenerated?: number - error?: string - createdAt: number - onComplete?: () => void - retryFn?: () => Promise<{ taskId: string; created: boolean }> - // For batch tasks: track sub-task progress - subTaskStats?: SubTaskStats -} - -export interface AiTaskQueueState { - tasks: TrackedTask[] - visible: boolean -} diff --git a/apps/admin/src/components/ai-task-queue/use-ai-task-queue.ts b/apps/admin/src/components/ai-task-queue/use-ai-task-queue.ts deleted file mode 100644 index 354b2bdb3..000000000 --- a/apps/admin/src/components/ai-task-queue/use-ai-task-queue.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { computed, reactive, ref } from 'vue' -import type { SubTaskStats, TrackedTask } from './types' - -import { aiApi, AITaskStatus, AITaskType } from '~/api/ai' - -const state = reactive<{ - tasks: TrackedTask[] -}>({ - tasks: [], -}) - -const visible = ref(false) -let pollTimer: ReturnType | null = null - -const POLL_INTERVAL = 2000 - -function isBatchTask(type: AITaskType): boolean { - return ( - type === AITaskType.TranslationBatch || type === AITaskType.TranslationAll - ) -} - -async function fetchSubTaskStats(taskId: string): Promise { - try { - const subTasks = await aiApi.getTasksByGroupId(taskId) - if (!subTasks.length) return null - return { - total: subTasks.length, - completed: subTasks.filter((t) => t.status === AITaskStatus.Completed) - .length, - failed: subTasks.filter((t) => t.status === AITaskStatus.Failed).length, - running: subTasks.filter((t) => t.status === AITaskStatus.Running).length, - pending: subTasks.filter((t) => t.status === AITaskStatus.Pending).length, - } - } catch { - return null - } -} - -function startPolling() { - if (pollTimer) return - - pollTimer = setInterval(async () => { - // Check if there are any active tasks or batch tasks with incomplete sub-tasks - const hasActiveTasks = state.tasks.some( - (t) => - t.status === AITaskStatus.Pending || t.status === AITaskStatus.Running, - ) - - const hasActiveBatchSubTasks = state.tasks.some((t) => { - if (!isBatchTask(t.type)) return false - if (!t.subTaskStats) return t.status === AITaskStatus.Completed - return t.subTaskStats.pending > 0 || t.subTaskStats.running > 0 - }) - - if (!hasActiveTasks && !hasActiveBatchSubTasks) { - stopPolling() - return - } - - for (const task of state.tasks) { - // Poll active tasks - if ( - task.status === AITaskStatus.Pending || - task.status === AITaskStatus.Running - ) { - try { - const result = await aiApi.getTask(task.id) - const oldStatus = task.status - task.status = result.status - task.progress = result.progress - task.progressMessage = result.progressMessage - task.tokensGenerated = result.tokensGenerated - task.error = result.error - - if ( - oldStatus !== result.status && - (result.status === AITaskStatus.Completed || - result.status === AITaskStatus.Failed) - ) { - // For batch tasks, start tracking sub-tasks - if ( - isBatchTask(task.type) && - result.status === AITaskStatus.Completed - ) { - task.subTaskStats = - (await fetchSubTaskStats(task.id)) ?? undefined - } - task.onComplete?.() - } - } catch { - // Ignore individual task fetch errors - } - } - - // Poll sub-task stats for completed batch tasks - if ( - isBatchTask(task.type) && - task.status === AITaskStatus.Completed && - task.subTaskStats && - (task.subTaskStats.pending > 0 || task.subTaskStats.running > 0) - ) { - task.subTaskStats = (await fetchSubTaskStats(task.id)) ?? undefined - } - } - }, POLL_INTERVAL) -} - -function stopPolling() { - if (pollTimer) { - clearInterval(pollTimer) - pollTimer = null - } -} - -export function useAiTaskQueue() { - const trackTask = (taskInfo: { - taskId: string - type: AITaskType - label: string - onComplete?: () => void - retryFn?: () => Promise<{ taskId: string; created: boolean }> - }) => { - const existing = state.tasks.find((t) => t.id === taskInfo.taskId) - if (existing) return - - state.tasks.push({ - id: taskInfo.taskId, - type: taskInfo.type, - status: AITaskStatus.Pending, - label: taskInfo.label, - createdAt: Date.now(), - onComplete: taskInfo.onComplete, - retryFn: taskInfo.retryFn, - }) - visible.value = true - startPolling() - } - - const retryTask = async (taskId: string) => { - const task = state.tasks.find((t) => t.id === taskId) - if (!task || !task.retryFn) return - - if ( - task.status !== AITaskStatus.Failed && - task.status !== AITaskStatus.Cancelled - ) { - return - } - - try { - const result = await task.retryFn() - if (result.created) { - // Update the task with new ID and reset status - task.id = result.taskId - task.status = AITaskStatus.Pending - task.error = undefined - task.progress = undefined - task.progressMessage = undefined - startPolling() - } - } catch (error) { - task.error = - error instanceof Error ? error.message : 'Failed to retry task' - } - } - - const removeTask = (id: string) => { - const idx = state.tasks.findIndex((t) => t.id === id) - if (idx !== -1) { - state.tasks.splice(idx, 1) - } - if (state.tasks.length === 0) { - visible.value = false - } - } - - const clearCompleted = () => { - state.tasks = state.tasks.filter( - (t) => - t.status !== AITaskStatus.Completed && - t.status !== AITaskStatus.Failed && - t.status !== AITaskStatus.Cancelled, - ) - if (state.tasks.length === 0) { - visible.value = false - } - } - - const clearAll = () => { - stopPolling() - state.tasks = [] - visible.value = false - } - - const hide = () => { - visible.value = false - } - - const show = () => { - if (state.tasks.length > 0) { - visible.value = true - } - } - - const activeCount = computed( - () => - state.tasks.filter( - (t) => - t.status === AITaskStatus.Pending || - t.status === AITaskStatus.Running, - ).length, - ) - - const completedCount = computed( - () => state.tasks.filter((t) => t.status === AITaskStatus.Completed).length, - ) - - const failedCount = computed( - () => state.tasks.filter((t) => t.status === AITaskStatus.Failed).length, - ) - - // Consider batch tasks with active sub-tasks as processing - const isProcessing = computed(() => { - if (activeCount.value > 0) return true - // Check if any batch task has active sub-tasks - return state.tasks.some((t) => { - if (!isBatchTask(t.type) || !t.subTaskStats) return false - return t.subTaskStats.pending > 0 || t.subTaskStats.running > 0 - }) - }) - - const progress = computed(() => { - let total = 0 - let completed = 0 - - for (const task of state.tasks) { - // For batch tasks with sub-task stats, count sub-tasks - if (isBatchTask(task.type) && task.subTaskStats) { - total += task.subTaskStats.total - completed += task.subTaskStats.completed + task.subTaskStats.failed - } else { - total += 1 - if ( - task.status === AITaskStatus.Completed || - task.status === AITaskStatus.Failed || - task.status === AITaskStatus.Cancelled - ) { - completed += 1 - } - } - } - - return { completed, total } - }) - - return { - tasks: computed(() => state.tasks), - visible: computed(() => visible.value), - isProcessing, - progress, - activeCount, - completedCount, - failedCount, - trackTask, - retryTask, - removeTask, - clearCompleted, - clearAll, - hide, - show, - } -} diff --git a/apps/admin/src/components/ai/ai-helper.tsx b/apps/admin/src/components/ai/ai-helper.tsx deleted file mode 100644 index 4caf523b8..000000000 --- a/apps/admin/src/components/ai/ai-helper.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { SparklesIcon } from 'lucide-vue-next' -import { NButton, NTooltip } from 'naive-ui' -import { defineComponent, ref } from 'vue' -import type { PropType } from 'vue' - -import { aiApi, AiQueryType } from '~/api' - -const OpenAIIcon = () => ( - - - -) - -export const AiHelperButton = defineComponent({ - props: { - reactiveData: { - type: Object as PropType<{ - title: string - text: string - slug?: string - }>, - required: true, - }, - }, - setup(props) { - const loading = ref(false) - - const callApi = async () => { - const { title, text } = props.reactiveData - - if (!text && !title) { - return - } - - const hasSlug = 'slug' in props.reactiveData - - loading.value = true - if (title && hasSlug) { - // 有标题时,根据标题生成 slug - const result = await aiApi - .writerGenerate({ - type: AiQueryType.Slug, - title: title, - }) - .finally(() => { - loading.value = false - }) - - if (result.slug) { - props.reactiveData.slug = result.slug - } - } else if (text) { - // 有文本时,根据文本生成标题和 slug - const aiResult = await aiApi - .writerGenerate({ - type: AiQueryType.TitleSlug, - text: text, - }) - .finally(() => { - loading.value = false - }) - - if (aiResult.title) { - props.reactiveData.title = aiResult.title - } - - if (hasSlug && aiResult.slug) { - props.reactiveData.slug = aiResult.slug - } - } - } - return () => { - return ( - - {{ - default() { - return 'AI 生成标题或者 Slug' - }, - trigger() { - return ( - - - - ) - }, - }} - - ) - } - }, -}) diff --git a/apps/admin/src/components/avatar/Avatar.tsx b/apps/admin/src/components/avatar/Avatar.tsx deleted file mode 100644 index 72cf11379..000000000 --- a/apps/admin/src/components/avatar/Avatar.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { defineComponent, onMounted, ref, watch } from 'vue' - -import styles from './avatar.module.css' - -export const Avatar = defineComponent({ - name: 'Avatar', - props: { - size: { - type: Number, - default: 50, - }, - src: { - type: String, - }, - }, - setup(props) { - const loaded = ref(false) - - const preloadImage = () => { - if (!props.src) { - return - } - const img = new Image() - img.src = props.src - - img.addEventListener('load', () => { - loaded.value = true - }) - } - - onMounted(() => { - preloadImage() - }) - - watch( - () => props.src, - () => { - preloadImage() - }, - ) - - return () => ( -
- -
一个头像
-
- ) - }, -}) diff --git a/apps/admin/src/components/avatar/avatar.module.css b/apps/admin/src/components/avatar/avatar.module.css deleted file mode 100644 index e43bdd258..000000000 --- a/apps/admin/src/components/avatar/avatar.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.avatar { - @apply relative inline-block select-none overflow-hidden rounded-full; - background-color: #ddd; -} - -.avatar img { - @apply h-full max-w-full rounded-full; - animation: scale 0.5s ease-out; -} - -@keyframes scale { - 0% { - transform: scale(0); - } - to { - transform: scale(1); - } -} diff --git a/apps/admin/src/components/avatar/index.tsx b/apps/admin/src/components/avatar/index.tsx deleted file mode 100644 index 994517e23..000000000 --- a/apps/admin/src/components/avatar/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { Avatar } from './Avatar' - -// eslint-disable-next-line import/no-default-export -export { Avatar, Avatar as default } diff --git a/apps/admin/src/components/button/header-action-button.tsx b/apps/admin/src/components/button/header-action-button.tsx deleted file mode 100644 index 3c16dd1ea..000000000 --- a/apps/admin/src/components/button/header-action-button.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { NTooltip } from 'naive-ui' -import { defineComponent } from 'vue' -import { RouterLink } from 'vue-router' -import type { ButtonHTMLAttributes, PropType, VNode } from 'vue' -import type { RouteLocationRaw } from 'vue-router' - -export type ButtonType = PropType< - 'primary' | 'info' | 'success' | 'warning' | 'error' -> - -type HeaderButtonVariant = - | 'default' - | 'primary' - | 'info' - | 'success' - | 'warning' - | 'error' - -export const HeaderActionButton = defineComponent({ - props: { - to: { - type: [Object, String] as PropType, - }, - name: { - type: String, - }, - icon: { - type: Object as PropType, - required: true, - }, - rounded: { - type: Boolean, - default: false, - }, - variant: { - type: String as PropType, - default: 'default', - }, - onClick: { - type: Function as any as PropType, - }, - disabled: { - type: Boolean, - }, - }, - setup(props) { - const variantClasses: Record = { - default: [ - 'text-neutral-600 dark:text-neutral-400', - 'bg-neutral-100/80 dark:bg-neutral-800/50', - 'hover:bg-neutral-200 hover:text-neutral-900', - 'dark:hover:bg-neutral-700 dark:hover:text-neutral-100', - ], - primary: [ - 'text-white dark:text-neutral-900', - 'bg-neutral-900 dark:bg-white', - 'hover:bg-neutral-800 dark:hover:bg-neutral-100', - ], - info: [ - 'text-blue-600 dark:text-blue-400', - 'bg-blue-50 dark:bg-blue-950/50', - 'hover:bg-blue-100 dark:hover:bg-blue-900/50', - ], - success: [ - 'text-green-600 dark:text-green-400', - 'bg-green-50 dark:bg-green-950/50', - 'hover:bg-green-100 dark:hover:bg-green-900/50', - ], - warning: [ - 'text-amber-600 dark:text-amber-400', - 'bg-amber-50 dark:bg-amber-950/50', - 'hover:bg-amber-100 dark:hover:bg-amber-900/50', - ], - error: [ - 'text-red-600 dark:text-red-400', - 'bg-red-50 dark:bg-red-950/50', - 'hover:bg-red-100 dark:hover:bg-red-900/50', - ], - } - - const Button = () => ( - - ) - - const ButtonWithTooltip = () => { - const btn = props.to ? ( - - - - - {/* Body - Split View */} -
- {/* Left: Draft List */} -
- {props.drafts.map((draft, index) => ( -
handleSelectDraft(draft)} - > -
-
- handleSelectDraft(draft)} - /> -
- -
-

- {draft.title || '无标题'} -

-

- v{draft.version} · {formatWordCount(draft.text)} 字 -

-

- {new Date(draft.updatedAt).toLocaleString()} -

-
-
-
- ))} -
- - {/* Right: Content Preview */} -
- {selectedDraft.value ? ( - - ) : ( -
- 选择一个草稿查看内容 -
- )} -
-
- - {/* Footer */} -
- 创建新{props.draftLabel} - - 继续编辑选中的草稿 - -
- - - ) - }, -}) diff --git a/apps/admin/src/components/draft/draft-recovery-modal.tsx b/apps/admin/src/components/draft/draft-recovery-modal.tsx deleted file mode 100644 index 919b274aa..000000000 --- a/apps/admin/src/components/draft/draft-recovery-modal.tsx +++ /dev/null @@ -1,643 +0,0 @@ -import { GitCompare, X } from 'lucide-vue-next' -import { NButton, NModal, NScrollbar, NSpin } from 'naive-ui' -import { computed, defineComponent, onBeforeUnmount, ref, watch } from 'vue' -import type { DraftModel } from '~/models/draft' -import type { SerializedEditorState } from 'lexical' -import type { PropType } from 'vue' - -import { computeDeltaStats } from '@haklex/rich-diff' -import { useQuery } from '@tanstack/vue-query' - -import { draftsApi } from '~/api/drafts' -import { RichDiffBridge } from '~/components/editor/rich/RichDiffBridge' -import { SplitPanel } from '~/components/layout' - -import { DiffPreview } from './diff-preview' -import { VersionListItem } from './version-list-item' - -function tryParseLexicalState(raw: string): SerializedEditorState | null { - if (!raw?.trim()) return null - try { - const v = JSON.parse(raw) as unknown - if (!v || typeof v !== 'object') return null - const root = (v as { root?: unknown }).root - if (!root || typeof root !== 'object') return null - const children = (root as { children?: unknown }).children - if (!Array.isArray(children)) return null - return v as SerializedEditorState - } catch { - return null - } -} - -export interface PublishedContent { - title: string - text: string - contentFormat?: 'markdown' | 'lexical' - content?: string - updated: string -} - -export const DraftRecoveryModal = defineComponent({ - name: 'DraftRecoveryModal', - props: { - show: { - type: Boolean, - required: true, - }, - onClose: { - type: Function as PropType<() => void>, - required: true, - }, - draft: { - type: Object as PropType, - required: true, - }, - publishedContent: { - type: Object as PropType, - required: true, - }, - onRecover: { - type: Function as PropType< - (version: number | 'published', versionData?: DraftModel) => void - >, - required: true, - }, - }, - setup(props) { - const selectedVersion = ref('published') - const selectedVersionContent = ref<{ - title: string - text: string - contentFormat?: 'markdown' | 'lexical' - content?: string - } | null>(null) - - type VersionContent = { - text: string - content?: string - contentFormat?: 'markdown' | 'lexical' - } - const versionContentCache = ref( - new Map(), - ) - - type RowDiffStats = { added: number; removed: number; unit: '词' | '字' } - - // Pre-computed diff stats via frame-budgeted batch processing - const precomputedDiffStats = ref(new Map()) - let diffQueue: Array<{ version: number; content: VersionContent }> = [] - let rafId: number | null = null - let batchGeneration = 0 - - const flushDiffQueue = () => { - const BUDGET = 8 - const start = performance.now() - const updates = new Map(precomputedDiffStats.value) - let dirty = false - - while (diffQueue.length > 0 && performance.now() - start < BUDGET) { - const { version, content } = diffQueue.shift()! - const stats = calcDiffStats( - props.publishedContent.text ?? '', - content.text ?? '', - props.publishedContent.content, - content.content, - ) - updates.set(version, stats) - dirty = true - } - - if (dirty) precomputedDiffStats.value = updates - if (diffQueue.length > 0) { - rafId = requestAnimationFrame(flushDiffQueue) - } else { - rafId = null - } - } - - const enqueueDiff = (version: number, content: VersionContent) => { - diffQueue.push({ version, content }) - if (rafId === null) { - rafId = requestAnimationFrame(flushDiffQueue) - } - } - - const cancelBatch = () => { - diffQueue = [] - if (rafId !== null) { - cancelAnimationFrame(rafId) - rafId = null - } - } - - onBeforeUnmount(cancelBatch) - - // Get draft history - const { data: historyData, isLoading: historyLoading } = useQuery({ - queryKey: ['drafts', 'history', () => props.draft.id], - queryFn: () => draftsApi.getHistory(props.draft.id), - enabled: () => props.show, - select: (res: any) => - Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [], - }) - - // Compute version list: current draft + history + published - const versionList = computed(() => { - const list: Array<{ - version: number | 'published' | 'current' - title: string - savedAt: string - isCurrent?: boolean - isFullSnapshot?: boolean - }> = [] - - // Current draft (latest version) - list.push({ - version: 'current', - title: props.draft.title, - savedAt: props.draft.updatedAt, - isCurrent: true, - }) - - // History versions (excluding current) - if (historyData.value) { - const sortedHistory = [...historyData.value].sort( - (a, b) => b.version - a.version, - ) - for (const item of sortedHistory) { - if (item.version !== props.draft.version) { - list.push({ - version: item.version, - title: item.title, - savedAt: item.savedAt, - isFullSnapshot: item.isFullSnapshot, - }) - } - } - } - - // Published version as baseline - list.push({ - version: 'published', - title: props.publishedContent.title, - savedAt: props.publishedContent.updated, - }) - - return list - }) - - // Default select current draft - watch( - () => props.show, - (show) => { - if (show) { - selectedVersion.value = props.draft.version - selectedVersionContent.value = { - title: props.draft.title, - text: props.draft.text, - contentFormat: props.draft.contentFormat, - content: props.draft.content, - } - // Seed cache with current draft - versionContentCache.value.set(props.draft.version, { - text: props.draft.text, - content: props.draft.content, - contentFormat: props.draft.contentFormat, - }) - } else { - cancelBatch() - } - }, - { immediate: true }, - ) - - // Proactively fetch all version contents and batch-compute diffs - watch([() => historyData.value, () => props.show], ([history, show]) => { - batchGeneration++ - const gen = batchGeneration - cancelBatch() - precomputedDiffStats.value = new Map() - - if (!show || !history?.length) return - - // Current draft already available - enqueueDiff(props.draft.version, { - text: props.draft.text, - content: props.draft.content, - contentFormat: props.draft.contentFormat, - }) - - // Fetch history versions with concurrency limit - const versions = history - .filter((item: any) => item.version !== props.draft.version) - .sort((a: any, b: any) => b.version - a.version) - - const CONCURRENCY = 3 - let idx = 0 - const fetchNext = async (): Promise => { - while (idx < versions.length) { - if (gen !== batchGeneration) return - const item = versions[idx++] - try { - const data = await draftsApi.getHistoryVersion( - props.draft.id, - item.version, - ) - if (gen !== batchGeneration) return - const content: VersionContent = { - text: data.text, - content: data.content, - contentFormat: data.contentFormat, - } - const cache = new Map(versionContentCache.value) - cache.set(item.version, content) - versionContentCache.value = cache - enqueueDiff(item.version, content) - } catch { - // skip failed versions - } - } - } - - Promise.all( - Array.from({ length: Math.min(CONCURRENCY, versions.length) }, () => - fetchNext(), - ), - ) - }) - - // Load version content when selection changes - const handleSelectVersion = async ( - version: number | 'published' | 'current', - ) => { - const actualVersion = - version === 'current' ? props.draft.version : version - selectedVersion.value = actualVersion - - if (version === 'published') { - selectedVersionContent.value = { - title: props.publishedContent.title, - text: props.publishedContent.text, - contentFormat: props.publishedContent.contentFormat, - content: props.publishedContent.content, - } - } else if (version === 'current') { - selectedVersionContent.value = { - title: props.draft.title, - text: props.draft.text, - contentFormat: props.draft.contentFormat, - content: props.draft.content, - } - } else { - // Use cache if already fetched by batch loader - const cached = versionContentCache.value.get(version) - if (cached) { - const historyItem = historyData.value?.find( - (h: any) => h.version === version, - ) - selectedVersionContent.value = { - title: historyItem?.title ?? '', - text: cached.text, - contentFormat: cached.contentFormat, - content: cached.content, - } - return - } - try { - const versionData = await draftsApi.getHistoryVersion( - props.draft.id, - version, - ) - selectedVersionContent.value = { - title: versionData.title, - text: versionData.text, - contentFormat: versionData.contentFormat, - content: versionData.content, - } - const cache = new Map(versionContentCache.value) - cache.set(version, { - text: versionData.text, - content: versionData.content, - contentFormat: versionData.contentFormat, - }) - versionContentCache.value = cache - } catch (error) { - console.error('Failed to load version:', error) - } - } - } - - const calcDiffStats = ( - oldText: string, - newText: string, - oldContent?: string, - newContent?: string, - ): RowDiffStats => { - const oc = oldContent?.trim() - const nc = newContent?.trim() - if (oc && nc) { - const oldState = tryParseLexicalState(oc) - const newState = tryParseLexicalState(nc) - if (oldState && newState) { - const stats = computeDeltaStats(oldState, newState) - return { - added: stats.words.added, - removed: stats.words.removed, - unit: '词', - } - } - } - const oldLen = oldText.length - const newLen = newText.length - return { - added: Math.max(0, newLen - oldLen), - removed: Math.max(0, oldLen - newLen), - unit: '字', - } - } - - const isRichMode = computed(() => { - const pub = props.publishedContent.content - const sel = selectedVersionContent.value?.content - if (!pub?.trim() || !sel?.trim()) return false - return !!(tryParseLexicalState(pub) && tryParseLexicalState(sel)) - }) - - const diffStats = computed(() => { - if (!selectedVersionContent.value) return null - - if (isRichMode.value) { - const oldState = tryParseLexicalState(props.publishedContent.content!) - const newState = tryParseLexicalState( - selectedVersionContent.value.content!, - ) - if (oldState && newState) { - const stats = computeDeltaStats(oldState, newState) - const isSame = - stats.chars.added === 0 && - stats.chars.removed === 0 && - stats.words.added === 0 && - stats.words.removed === 0 - if (isSame) { - return { isSame: true, added: 0, removed: 0 } - } - return { - isSame: false, - added: stats.words.added, - removed: stats.words.removed, - } - } - return { isSame: true, added: 0, removed: 0 } - } - - const currentText = selectedVersionContent.value.text ?? '' - const publishedText = props.publishedContent.text ?? '' - const diff = currentText.length - publishedText.length - return { diff, isSame: currentText === publishedText } - }) - - // Selected version label - const selectedVersionLabel = computed(() => { - if (selectedVersion.value === 'published') return '已发布' - if (selectedVersion.value === props.draft.version) return '当前草稿' - return `v${selectedVersion.value}` - }) - - const handleUsePublished = () => { - props.onRecover('published') - props.onClose() - } - - const handleRecoverSelected = async () => { - if (selectedVersion.value === 'published') { - props.onRecover('published') - } else { - // Load the full version data and pass it - const version = - selectedVersion.value === props.draft.version - ? props.draft.version - : (selectedVersion.value as number) - try { - const versionData = await draftsApi.getHistoryVersion( - props.draft.id, - version, - ) - props.onRecover(version, versionData) - } catch { - // If loading fails, use current draft data - props.onRecover(version, props.draft) - } - } - props.onClose() - } - - return () => ( - { - if (!show) props.onClose() - }} - closeOnEsc - transformOrigin="center" - > - - - ) - }, -}) diff --git a/apps/admin/src/components/draft/draft-save-indicator.tsx b/apps/admin/src/components/draft/draft-save-indicator.tsx deleted file mode 100644 index 5498cdaf1..000000000 --- a/apps/admin/src/components/draft/draft-save-indicator.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { CloudIcon, LoaderIcon } from 'lucide-vue-next' -import { computed, defineComponent, onUnmounted, ref, watch } from 'vue' -import type { ComputedRef, PropType } from 'vue' - -/** - * 格式化相对时间 - */ -function formatRelativeTime(date: Date): string { - const now = Date.now() - const diff = now - date.getTime() - const seconds = Math.floor(diff / 1000) - - if (seconds < 5) { - return '刚刚' - } - if (seconds < 60) { - return `${seconds} 秒前` - } - - const minutes = Math.floor(seconds / 60) - if (minutes < 60) { - return `${minutes} 分钟前` - } - - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return `${hours} 小时前` - } - - const days = Math.floor(hours / 24) - return `${days} 天前` -} - -/** - * 草稿保存状态指示器 - * 显示"已保存草稿"以及动态更新的相对时间 - */ -export const DraftSaveIndicator = defineComponent({ - name: 'DraftSaveIndicator', - props: { - isSaving: { - type: Object as PropType>, - required: true, - }, - lastSavedTime: { - type: Object as PropType>, - required: true, - }, - }, - setup(props) { - const relativeTimeText = ref('') - let intervalId: ReturnType | null = null - - // 更新相对时间文本 - const updateRelativeTime = () => { - const time = props.lastSavedTime.value - if (time) { - relativeTimeText.value = formatRelativeTime(time) - } - } - - // 监听 lastSavedTime 变化,立即更新并启动定时器 - watch( - () => props.lastSavedTime.value, - (newTime) => { - if (newTime) { - updateRelativeTime() - - // 清除之前的定时器 - if (intervalId) { - clearInterval(intervalId) - } - - // 每秒更新一次相对时间(前60秒),之后每分钟更新 - intervalId = setInterval(() => { - updateRelativeTime() - }, 1000) - } - }, - { immediate: true }, - ) - - onUnmounted(() => { - if (intervalId) { - clearInterval(intervalId) - } - }) - - const showIndicator = computed(() => { - return props.isSaving.value || props.lastSavedTime.value - }) - - return () => { - if (!showIndicator.value) { - return null - } - - const isSaving = props.isSaving.value - - return ( -
- {isSaving ? ( - <> - - 保存中... - - ) : ( - <> - - 已保存草稿 - {relativeTimeText.value && ( - - · {relativeTimeText.value} - - )} - - )} -
- ) - } - }, -}) diff --git a/apps/admin/src/components/draft/file-preview.tsx b/apps/admin/src/components/draft/file-preview.tsx deleted file mode 100644 index da37ad514..000000000 --- a/apps/admin/src/components/draft/file-preview.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - defineComponent, - nextTick, - onMounted, - onUnmounted, - ref, - shallowRef, - watch, -} from 'vue' -import type { PropType } from 'vue' - -import { File as FileRenderer, preloadHighlighter } from '@pierre/diffs' - -export interface PreviewFile { - name: string - contents: string -} - -// Preload highlighter once -let highlighterReady = false -const ensureHighlighter = async () => { - if (highlighterReady) return - await preloadHighlighter({ - themes: ['github-dark', 'github-light'], - langs: ['markdown', 'typescript', 'javascript', 'json', 'html', 'css'], - }) - highlighterReady = true -} - -export const FilePreview = defineComponent({ - name: 'FilePreview', - props: { - file: { - type: Object as PropType, - required: true, - }, - }, - setup(props) { - const containerRef = ref() - const fileInstance = shallowRef(null) - const isLoading = ref(true) - - const createPreview = async () => { - if (!containerRef.value) return - - // Clean up existing instance - if (fileInstance.value) { - fileInstance.value.cleanUp() - fileInstance.value = null - } - - // Clear container - containerRef.value.innerHTML = '' - - try { - // Ensure highlighter is ready - await ensureHighlighter() - isLoading.value = false - - await nextTick() - - if (!containerRef.value) return - - fileInstance.value = new FileRenderer({ - themeType: 'system', - disableFileHeader: true, - }) - - // Use containerWrapper to attach the diffs-container element - fileInstance.value.render({ - file: { - name: props.file.name, - contents: props.file.contents, - }, - containerWrapper: containerRef.value, - }) - } catch (error) { - console.error('[FilePreview] Failed to create preview:', error) - isLoading.value = false - } - } - - onMounted(() => { - createPreview() - }) - - watch( - () => props.file.contents, - () => { - createPreview() - }, - ) - - onUnmounted(() => { - if (fileInstance.value) { - fileInstance.value.cleanUp() - fileInstance.value = null - } - }) - - return () => ( -
- {isLoading.value && ( -
- 加载中... -
- )} -
- ) - }, -}) diff --git a/apps/admin/src/components/draft/version-list-item.tsx b/apps/admin/src/components/draft/version-list-item.tsx deleted file mode 100644 index 24b56c69a..000000000 --- a/apps/admin/src/components/draft/version-list-item.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { defineComponent } from 'vue' -import type { PropType } from 'vue' - -import { RelativeTime } from '~/components/time/relative-time' - -export interface DiffStats { - added: number - removed: number - /** 与 `calcDiffStats` 一致:`词` 来自 Lexical,`字` 来自纯文本长度差 */ - unit: '词' | '字' -} - -export const VersionListItem = defineComponent({ - name: 'VersionListItem', - props: { - version: { - type: [Number, String] as PropType, - required: true, - }, - title: { - type: String, - required: true, - }, - savedAt: { - type: String, - required: true, - }, - isSelected: { - type: Boolean, - default: false, - }, - isCurrent: { - type: Boolean, - default: false, - }, - diffStats: { - type: Object as PropType, - default: undefined, - }, - isFullSnapshot: { - type: Boolean, - default: undefined, - }, - onClick: { - type: Function as PropType<() => void>, - required: true, - }, - }, - setup(props) { - const getVersionLabel = () => { - if (props.version === 'published') { - return '已发布版本' - } - if (props.version === 'current') { - return '当前草稿' - } - return `v${props.version}` - } - - return () => ( -
-
-
- {/* 版本标签行 */} -
- - {getVersionLabel()} - - {props.isCurrent && ( - - 当前 - - )} - {props.version === 'published' && ( - - 基准 - - )} - {typeof props.version === 'number' && - props.isFullSnapshot !== undefined && ( - - {props.isFullSnapshot ? '全量' : '增量'} - - )} - {props.diffStats && - (props.diffStats.added > 0 || props.diffStats.removed > 0) && ( - - - +{props.diffStats.added} - - - -{props.diffStats.removed} - - - {props.diffStats.unit} - - - )} -
- - {/* 标题 */} -

- {props.title || '无标题'} -

-
- - {/* 右侧:时间 */} -
- - - -
-
-
- ) - }, -}) diff --git a/apps/admin/src/components/drawer/components/image-detail-section.tsx b/apps/admin/src/components/drawer/components/image-detail-section.tsx deleted file mode 100644 index 63bb0a13b..000000000 --- a/apps/admin/src/components/drawer/components/image-detail-section.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import { decode } from 'blurhash' -import { uniqBy } from 'es-toolkit/compat' -import { ChevronDownIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-vue-next' -import { - NButton, - NCheckbox, - NColorPicker, - NInput, - NInputNumber, -} from 'naive-ui' -import { computed, defineComponent, onMounted, ref } from 'vue' -import { toast } from 'vue-sonner' -import type { Image as ImageModel } from '~/models/base' -import type { PropType } from 'vue' - -import { useStorage } from '@vueuse/core' - -import { - encodeImageToBlurhash, - encodeImageToBlurhashWebgl, - getDominantColor, -} from '~/utils/image' -import { isVideoExt, pickImagesFromMarkdown } from '~/utils/markdown' - -export const ImageDetailSection = defineComponent({ - props: { - images: { - type: Array as PropType, - required: true, - }, - onChange: { - type: Function as PropType<(images: ImageModel[]) => void>, - required: true, - }, - text: { - type: String, - required: true, - }, - extraImages: { - type: Array as PropType, - required: false, - }, - }, - setup(props) { - const loading = ref(false) - - const useWebglFlag = useStorage('useWebglFlag', true) - - const originImageMap = computed(() => { - const map = new Map() - props.images.forEach((image) => { - if (image?.src) { - map.set(image.src, image) - } - }) - return map - }) - - const images = computed(() => { - // 过滤掉 null/undefined 值,防止数据崩坏 - const validPropsImages = props.images.filter( - (img): img is ImageModel => img != null && !!img.src, - ) - const basedImages: ImageModel[] = props.text - ? uniqBy( - pickImagesFromMarkdown(props.text) - .map((src) => { - const existImageInfo = originImageMap.value.get(src) - return { - src, - height: existImageInfo?.height, - width: existImageInfo?.width, - type: existImageInfo?.type, - accent: existImageInfo?.accent, - blurHash: existImageInfo?.blurHash, - } as any - }) - .concat(validPropsImages), - 'src', - ) - : validPropsImages - const srcSet = new Set() - - for (const image of basedImages) { - srcSet.add(image.src) - } - const nextImages = basedImages.concat() - if (props.extraImages) { - // 需要过滤存在的图片 - props.extraImages.forEach((src) => { - if (!srcSet.has(src)) { - nextImages.push({ - src, - height: 0, - width: 0, - type: '', - accent: '', - }) - } - }) - } - - return nextImages - }) - const handleCorrectImageDimensions = async () => { - loading.value = true - - const fetchImageTasks = await Promise.allSettled( - images.value.map((item) => { - return new Promise((resolve, reject) => { - const ext = item.src.split('.').pop()! - const isVideo = isVideoExt(ext) - - if (isVideo) { - const video = document.createElement('video') - - video.src = item.src - - video.addEventListener('loadedmetadata', () => { - resolve({ - height: video.videoHeight, - type: ext, - src: item.src, - width: video.videoWidth, - accent: '#fff', - }) - }) - - video.addEventListener('error', (e) => { - reject({ - err: e, - src: item.src, - }) - }) - } else { - const $image = new Image() - $image.src = item.src - $image.crossOrigin = 'Anonymous' - $image.addEventListener('load', () => { - resolve({ - width: $image.naturalWidth, - height: $image.naturalHeight, - src: item.src, - type: ext, - accent: getDominantColor($image), - blurHash: useWebglFlag - ? encodeImageToBlurhashWebgl($image) - : encodeImageToBlurhash($image), - }) - }) - $image.onerror = (err) => { - reject({ - err, - src: item.src, - }) - } - } - }) - }), - ) - - loading.value = false - - const nextImageDimensions = [] as ImageModel[] - fetchImageTasks.forEach((task, index) => { - if (task.status === 'fulfilled') { - nextImageDimensions.push(task.value) - } else { - // 保留原始图片信息,避免丢失 - const originalImage = images.value[index] - if (originalImage) { - nextImageDimensions.push(originalImage) - } - toast.warning( - `获取图片信息失败:${task.reason.src}: ${task.reason.err}`, - ) - } - }) - - props.onChange(nextImageDimensions) - - loading.value = false - } - - // 展开状态管理 - const expandedIndex = ref(null) - - return () => ( -
- {/* 头部操作区 */} -
- - 调整 Markdown 中的图片信息 - - - 自动修正 - -
- - {/* WebGL 选项 */} - - - {/* 图片列表 */} - {images.value.length > 0 && ( -
- {images.value.map((image: ImageModel, index: number) => { - const isExpanded = expandedIndex.value === index - const fileName = image.src.split('/').pop() || image.src - - return ( -
- {/* 折叠头部 */} - - - {/* 展开内容 */} - {isExpanded && ( -
- {/* 尺寸 */} -
-
- - { - if (!n) return - props.images[index].width = n - }} - aria-label="图片宽度" - /> -
-
- - { - if (!n) return - props.images[index].height = n - }} - aria-label="图片高度" - /> -
-
- - {/* 类型和主色调 */} -
-
- - { - if (!n) return - props.images[index].type = n - }} - placeholder="jpg, png..." - aria-label="图片类型" - /> -
-
- - { - if (!n) return - props.images[index].accent = n - }} - aria-label="主色调" - /> -
-
- - {/* BlurHash 预览 */} - {image.blurHash && ( -
- - -
- )} - - {/* 操作按钮 */} -
- { - window.open(image.src) - }} - aria-label="在新窗口查看图片" - > - {{ - icon: () => , - default: () => '查看', - }} - - - { - props.images.splice(index, 1) - if (expandedIndex.value === index) { - expandedIndex.value = null - } - }} - aria-label="删除图片" - > - {{ - icon: () => , - default: () => '删除', - }} - -
-
- )} -
- ) - })} -
- )} - - {/* 空状态 */} - {images.value.length === 0 && ( -
- 文章中暂无图片 -
- )} -
- ) - }, -}) - -const BlurHashPreview = defineComponent({ - props: { - hash: { - type: String, - required: true, - }, - }, - setup(props) { - const canvasRef = ref(null) - - onMounted(() => { - const canvas = canvasRef.value! - const ctx = canvas.getContext('2d')! - const pixels = decode(props.hash, 32, 32) - const imageData = ctx.createImageData(32, 32) - imageData.data.set(pixels) - ctx.putImageData(imageData, 0, 0) - }) - - return () => ( - - ) - }, -}) diff --git a/apps/admin/src/components/drawer/components/json-editor.tsx b/apps/admin/src/components/drawer/components/json-editor.tsx deleted file mode 100644 index af658f359..000000000 --- a/apps/admin/src/components/drawer/components/json-editor.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { NButton } from 'naive-ui' -import { defineComponent, ref } from 'vue' -import type { PropType } from 'vue' - -import { useAsyncLoadMonaco } from '~/components/monaco-editor' - -const JSONEditorProps = { - value: { - type: String, - required: true, - }, - - onFinish: { - type: Function as PropType<(s: string) => void>, - required: true, - }, -} as const -export const JSONEditor = defineComponent({ - props: JSONEditorProps, - - setup(props) { - const htmlRef = ref() - const refValue = ref(props.value) - const editor = useAsyncLoadMonaco( - htmlRef, - refValue, - (val) => { - refValue.value = val - }, - { - language: 'json', - }, - ) - const handleFinish = () => { - props.onFinish(refValue.value) - } - return () => { - const { Snip } = editor - return ( -
-
- -
- -
- - 提交 - -
-
- ) - } - }, -}) diff --git a/apps/admin/src/components/drawer/components/lexical-image-detail-section.tsx b/apps/admin/src/components/drawer/components/lexical-image-detail-section.tsx deleted file mode 100644 index 24746cf76..000000000 --- a/apps/admin/src/components/drawer/components/lexical-image-detail-section.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { uniqBy } from 'es-toolkit/compat' -import { $nodesOfType } from 'lexical' -import { ImageIcon } from 'lucide-vue-next' -import { NButton } from 'naive-ui' -import { computed, defineComponent, ref } from 'vue' -import { toast } from 'vue-sonner' -import type { LexicalEditor } from 'lexical' -import type { PropType } from 'vue' - -import { ImageNode } from '@haklex/rich-editor/nodes' - -import { encodeImageToThumbhash, getDominantColor } from '~/utils/image' - -type SerializedImageNode = { - type: string - src?: string - width?: number - height?: number - accent?: string - thumbhash?: string - children?: SerializedImageNode[] -} - -type LexicalImageMeta = { - src: string - width?: number - height?: number - accent?: string - thumbhash?: string -} - -const collectImageNodes = (nodes: SerializedImageNode[] = []) => { - const images: LexicalImageMeta[] = [] - - nodes.forEach((node) => { - if (node.type === 'image' && node.src) { - images.push({ - src: node.src, - width: node.width, - height: node.height, - accent: node.accent, - thumbhash: node.thumbhash, - }) - } - - if (node.children?.length) { - images.push(...collectImageNodes(node.children)) - } - }) - - return images -} - -export const LexicalImageDetailSection = defineComponent({ - name: 'LexicalImageDetailSection', - props: { - content: { - type: String, - required: true, - }, - editor: { - type: Object as PropType, - required: false, - default: null, - }, - }, - setup(props) { - const loading = ref(false) - - const images = computed(() => { - if (!props.content) { - return [] as LexicalImageMeta[] - } - - try { - const parsed = JSON.parse(props.content) as { - root?: { children?: SerializedImageNode[] } - } - return uniqBy(collectImageNodes(parsed.root?.children), 'src') - } catch { - return [] as LexicalImageMeta[] - } - }) - - const handleCorrectImageDimensions = async () => { - if (!props.editor) { - toast.warning('Lexical 编辑器尚未就绪') - return - } - - loading.value = true - - const fetchImageTasks = await Promise.allSettled( - images.value.map((item) => { - return new Promise((resolve, reject) => { - const image = new Image() - image.crossOrigin = 'Anonymous' - image.src = item.src - - image.addEventListener('load', async () => { - try { - let accent = item.accent - let thumbhash = item.thumbhash - - try { - accent = getDominantColor(image) - } catch { - // Cross-origin images may block canvas reads. - } - - try { - thumbhash = await encodeImageToThumbhash(image) - } catch { - // Keep existing thumbhash when recomputing is not possible. - } - - resolve({ - src: item.src, - width: image.naturalWidth, - height: image.naturalHeight, - accent, - thumbhash, - }) - } catch (error) { - reject({ - err: error, - src: item.src, - }) - } - }) - - image.onerror = (err) => { - reject({ - err, - src: item.src, - }) - } - }) - }), - ) - - const nextImageMetaMap = new Map() - - fetchImageTasks.forEach((task) => { - if (task.status === 'fulfilled') { - nextImageMetaMap.set(task.value.src, task.value) - return - } - - toast.warning(`获取图片信息失败:${task.reason.src}`) - }) - - props.editor.update(() => { - const imageNodes = $nodesOfType(ImageNode) - imageNodes.forEach((node) => { - const nextMeta = nextImageMetaMap.get(node.getSrc()) - if (!nextMeta) return - - node.setDimensions(nextMeta.width, nextMeta.height) - node.setAccent(nextMeta.accent) - node.setThumbhash(nextMeta.thumbhash) - }) - }) - - loading.value = false - } - - return () => ( -
-
- - 调整 Lexical 中的图片信息 - - - 自动修正 - -
- - {images.value.length > 0 ? ( -
- {images.value.map((image) => { - const fileName = image.src.split('/').pop() || image.src - return ( -
-
- - {fileName} -
-
- {image.width && image.height - ? `${image.width}×${image.height}` - : '未写入尺寸'} - {image.accent ? ' · 已有 accent' : ''} - {image.thumbhash ? ' · 已有 thumbhash' : ''} -
-
- ) - })} -
- ) : ( -
- 当前 Lexical 内容中没有图片节点 -
- )} -
- ) - }, -}) diff --git a/apps/admin/src/components/drawer/components/meta-preset-section.tsx b/apps/admin/src/components/drawer/components/meta-preset-section.tsx deleted file mode 100644 index e5bcd62e5..000000000 --- a/apps/admin/src/components/drawer/components/meta-preset-section.tsx +++ /dev/null @@ -1,298 +0,0 @@ -/** - * 元数据预设字段区块 - * 提供预设字段表单和自定义 JSON 编辑的标签页切换 - */ -import { isObject } from 'es-toolkit/compat' -import { ChevronDownIcon } from 'lucide-vue-next' -import { - NButton, - NCollapse, - NCollapseItem, - NDynamicInput, - NModal, - NSkeleton, - NTabPane, - NTabs, -} from 'naive-ui' -import { defineComponent, onMounted, ref, watch } from 'vue' -import { toast } from 'vue-sonner' -import type { MetaPresetField, MetaPresetScope } from '~/models/meta-preset' -import type { PropType } from 'vue' - -import { metaPresetsApi } from '~/api/meta-presets' -import { JSONHighlight } from '~/components/json-highlight' -import { JSONParseReturnOriginal } from '~/utils/json' - -import { JSONEditor } from './json-editor' -import { PresetFieldRenderer } from './preset-field-renderer' - -/** - * 元数据预设字段区块组件 - */ -export const MetaPresetSection = defineComponent({ - props: { - /** - * 元数据对象 - */ - meta: { - type: Object as PropType | undefined>, - required: false, - }, - /** - * 更新元数据 - */ - onUpdateMeta: { - type: Function as PropType< - (meta: Record | undefined) => void - >, - required: true, - }, - /** - * 适用范围 - */ - scope: { - type: String as PropType, - default: 'both', - }, - }, - setup(props) { - const activeTab = ref<'preset' | 'json'>('preset') - - const presets = ref([]) - const loading = ref(true) - const error = ref(null) - - const showJSONEditorModal = ref(false) - - const keyValuePairs = ref<{ key: string; value: string }[]>([]) - let inUpdatedKeyValue = false - - const loadPresets = async () => { - try { - loading.value = true - error.value = null - const data = await metaPresetsApi.getAll({ - scope: props.scope, - enabledOnly: true, - }) - presets.value = Array.isArray(data) ? data : ((data as any)?.data ?? []) - } catch (e: any) { - error.value = e.message || '加载预设字段失败' - presets.value = [] - } finally { - loading.value = false - } - } - - onMounted(loadPresets) - - watch(() => props.scope, loadPresets) - - watch( - () => props.meta, - () => { - if (inUpdatedKeyValue) { - inUpdatedKeyValue = false - return - } - - if (props.meta && isObject(props.meta)) { - keyValuePairs.value = Object.entries(props.meta).reduce( - (acc, [key, value]): { key: string; value: string }[] => { - return [ - ...acc, - { - key, - value: JSON.stringify(value), - }, - ] - }, - [] as { key: string; value: string }[], - ) - } else { - keyValuePairs.value = [] - } - }, - { flush: 'post', deep: true, immediate: true }, - ) - - watch( - () => keyValuePairs.value, - () => { - inUpdatedKeyValue = true - const newMeta = keyValuePairs.value.reduce( - (acc, { key, value }) => { - if (!key || value === undefined || value === '') return acc - return { ...acc, [key]: JSONParseReturnOriginal(value) } - }, - {} as Record, - ) - props.onUpdateMeta( - Object.keys(newMeta).length > 0 ? newMeta : undefined, - ) - }, - ) - - const isEmptyValue = (val: any): boolean => { - if (val === undefined || val === null || val === '') return true - if ( - typeof val === 'object' && - !Array.isArray(val) && - Object.keys(val).length === 0 - ) - return true - return false - } - - const updateFieldValue = (key: string, value: any) => { - const currentMeta = props.meta ?? {} - if (isEmptyValue(value)) { - const { [key]: _, ...rest } = currentMeta - props.onUpdateMeta(Object.keys(rest).length > 0 ? rest : undefined) - } else { - props.onUpdateMeta({ ...currentMeta, [key]: value }) - } - } - - const expanded = ref(false) - - return () => ( -
- - - {expanded.value && ( -
- {loading.value ? ( -
- - - -
- ) : error.value ? ( -
{error.value}
- ) : ( - - (activeTab.value = v as 'preset' | 'json') - } - type="segment" - animated - > - -
- {presets.value.length === 0 ? ( -
- 暂无可用的预设字段 -
- ) : ( - presets.value.map((field) => ( - updateFieldValue(field.key, v)} - /> - )) - )} -
-
- - -
- { - keyValuePairs.value = value - }} - /> - - {props.meta && Object.keys(props.meta).length > 0 && ( - - - - - - )} -
-
-
- )} -
- )} - - { - showJSONEditorModal.value = show - }} - zIndex={2222} - preset="card" - closable - closeOnEsc={false} - title="编辑附加字段" - onClose={() => { - showJSONEditorModal.value = false - }} - class="w-[unset]" - > - { - try { - inUpdatedKeyValue = false - const parsed = JSON.parse(jsonString || '{}') - props.onUpdateMeta( - Object.keys(parsed).length > 0 ? parsed : undefined, - ) - showJSONEditorModal.value = false - } catch (error: any) { - toast.error(error.message) - } - }} - /> - -
- ) - }, -}) diff --git a/apps/admin/src/components/drawer/components/preset-field-renderer.tsx b/apps/admin/src/components/drawer/components/preset-field-renderer.tsx deleted file mode 100644 index f5c3cc5b8..000000000 --- a/apps/admin/src/components/drawer/components/preset-field-renderer.tsx +++ /dev/null @@ -1,555 +0,0 @@ -/** - * 预设字段渲染器 - * 根据字段类型动态渲染对应的表单控件 - */ -import { PlusIcon, XIcon } from 'lucide-vue-next' -import { - NButton, - NDynamicTags, - NInput, - NInputNumber, - NSelect, - NSwitch, -} from 'naive-ui' -import { computed, defineComponent, ref, toRefs, watch } from 'vue' -import type { - MetaFieldOption, - MetaPresetChild, - MetaPresetField, -} from '~/models/meta-preset' -import type { PropType } from 'vue' - -import { FormField, SwitchRow } from './ui' - -/** - * 处理 Select 互斥逻辑 - * - 选择 exclusive 选项时,清除其他所有选项 - * - 选择非 exclusive 选项时,清除所有 exclusive 选项 - */ -function handleSelectExclusive( - newValues: any[], - oldValues: any[], - options: MetaFieldOption[], -): any[] { - if (!options || options.length === 0) return newValues - - const exclusiveValues = new Set( - options.filter((o) => o.exclusive).map((o) => o.value), - ) - - // 找出新增的值 - const addedValues = newValues.filter((v) => !oldValues.includes(v)) - - if (addedValues.length === 0) return newValues - - const addedValue = addedValues[0] - - // 如果新增的是互斥选项,清除其他所有 - if (exclusiveValues.has(addedValue)) { - return [addedValue] - } - - // 如果新增的不是互斥选项,清除所有互斥选项 - return newValues.filter((v) => !exclusiveValues.has(v)) -} - -/** - * 将 meta 值标准化为数组 - */ -function normalizeToArray(value: any): any[] { - if (value === undefined || value === null) return [] - if (Array.isArray(value)) return value - return [value] -} - -/** - * 将数组值标准化为存储格式 - * 单个值时返回该值,多个值时返回数组 - */ -function normalizeFromArray(arr: any[]): any { - if (arr.length === 0) return undefined - if (arr.length === 1) return arr[0] - return arr -} - -/** - * 子字段渲染器(用于 object 类型) - */ -const ChildFieldRenderer = defineComponent({ - props: { - child: { - type: Object as PropType, - required: true, - }, - value: { - type: null as unknown as PropType, - required: false, - }, - onChange: { - type: Function as PropType<(value: any) => void>, - required: true, - }, - }, - setup(props) { - const { child, value, onChange } = toRefs(props) - - return () => { - const field = child.value - - switch (field.type) { - case 'text': - return ( - - ) - - case 'textarea': - return ( - - ) - - case 'select': - return ( - ({ - label: o.label, - value: o.value, - })) ?? [] - } - placeholder={field.placeholder || '请选择'} - clearable - size="small" - /> - ) - - default: - return ( - - ) - } - } - }, -}) - -/** - * 多选字段渲染器(支持互斥逻辑和自定义选项) - * 使用 Select 多选组件替代 Checkbox - */ -const MultiSelectFieldRenderer = defineComponent({ - props: { - field: { - type: Object as PropType, - required: true, - }, - value: { - type: null as unknown as PropType, - required: false, - }, - onChange: { - type: Function as PropType<(value: any) => void>, - required: true, - }, - }, - setup(props) { - const { field, value, onChange } = toRefs(props) - - const currentValues = computed(() => normalizeToArray(value.value)) - - const predefinedValues = computed(() => { - const optionValues = new Set( - field.value.options?.map((o) => o.value) ?? [], - ) - return currentValues.value.filter( - (v) => optionValues.has(v) || typeof v === 'number', - ) - }) - - const customValues = computed(() => { - const optionValues = new Set( - field.value.options?.map((o) => o.value) ?? [], - ) - return currentValues.value.filter( - (v) => !optionValues.has(v) && typeof v === 'string', - ) - }) - - const customInput = ref('') - - const handlePredefinedChange = (newValues: any[]) => { - const processed = handleSelectExclusive( - newValues, - predefinedValues.value, - field.value.options ?? [], - ) - const merged = [...processed, ...customValues.value] - onChange.value(normalizeFromArray(merged)) - } - - const addCustomValue = () => { - const trimmed = customInput.value.trim() - if (!trimmed) return - if (currentValues.value.includes(trimmed)) { - customInput.value = '' - return - } - - // 添加自定义值时清除互斥选项 - const exclusiveValues = new Set( - (field.value.options ?? []) - .filter((o) => o.exclusive) - .map((o) => o.value), - ) - const newPredefined = predefinedValues.value.filter( - (v) => !exclusiveValues.has(v), - ) - const merged = [...newPredefined, ...customValues.value, trimmed] - onChange.value(normalizeFromArray(merged)) - customInput.value = '' - } - - const removeCustomValue = (val: string) => { - const newCustom = customValues.value.filter((v) => v !== val) - const merged = [...predefinedValues.value, ...newCustom] - onChange.value(normalizeFromArray(merged)) - } - - const selectOptions = computed(() => { - return ( - field.value.options?.map((o) => ({ - label: o.label, - value: o.value, - })) ?? [] - ) - }) - - return () => ( -
- - - {customValues.value.length > 0 && ( -
- {customValues.value.map((val) => ( - - {val} - - - ))} -
- )} - - {field.value.allowCustomOption && ( -
- (customInput.value = v)} - placeholder="自定义声明..." - size="small" - onKeydown={(e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - addCustomValue() - } - }} - /> - - - -
- )} -
- ) - }, -}) - -/** - * Object 字段渲染器(带开关控制) - */ -const ObjectFieldRenderer = defineComponent({ - props: { - field: { - type: Object as PropType, - required: true, - }, - value: { - type: null as unknown as PropType | undefined>, - required: false, - }, - onChange: { - type: Function as PropType< - (value: Record | undefined) => void - >, - required: true, - }, - }, - setup(props) { - const { field, value, onChange } = toRefs(props) - - // 使用独立的启用状态,而不是依赖值的存在 - // 当 value 存在时(包括空对象)认为是启用状态 - const isEnabled = ref(value.value !== undefined) - - watch( - () => value.value, - (newVal) => { - isEnabled.value = newVal !== undefined - }, - ) - - const toggleEnabled = (enabled: boolean) => { - isEnabled.value = enabled - if (enabled) { - onChange.value({}) - } else { - onChange.value(undefined) - } - } - - const updateChildValue = (key: string, childValue: any) => { - const current = value.value ?? {} - if ( - childValue === undefined || - childValue === null || - childValue === '' - ) { - const { [key]: _, ...rest } = current - onChange.value(rest) - } else { - onChange.value({ ...current, [key]: childValue }) - } - } - - return () => ( -
- - - {isEnabled.value && field.value.children && ( -
- {field.value.children.map((child) => ( -
- - updateChildValue(child.key, v)} - /> -
- ))} -
- )} -
- ) - }, -}) - -/** - * 主字段渲染器 - */ -export const PresetFieldRenderer = defineComponent({ - props: { - field: { - type: Object as PropType, - required: true, - }, - value: { - type: null as unknown as PropType, - required: false, - }, - onChange: { - type: Function as PropType<(value: any) => void>, - required: true, - }, - }, - setup(props) { - const { field, value, onChange } = toRefs(props) - - return () => { - const f = field.value - - // Object 类型特殊处理(自带开关) - if (f.type === 'object') { - return ( - - ) - } - - // 其他类型包装在 FormField 中 - return ( - - {renderField()} - - ) - - function renderField() { - switch (f.type) { - case 'text': - return ( - - ) - - case 'textarea': - return ( - - ) - - case 'number': - return ( - - ) - - case 'url': - return ( - - ) - - case 'select': - return ( - ({ - label: o.label, - value: o.value, - })) ?? [] - } - placeholder={f.placeholder || '请选择'} - clearable - size="small" - /> - ) - - case 'multi-select': - return ( - ({ - label: o.label, - value: o.value, - })) ?? [] - } - placeholder={f.placeholder || '请选择'} - multiple - clearable - size="small" - /> - ) - - case 'checkbox': - // 使用多选 Select 替代 Checkbox - return ( - - ) - - case 'tags': - return ( - - ) - - case 'boolean': - return ( - - ) - - default: - return ( - - ) - } - } - } - }, -}) diff --git a/apps/admin/src/components/drawer/components/ui.tsx b/apps/admin/src/components/drawer/components/ui.tsx deleted file mode 100644 index 7ec00159b..000000000 --- a/apps/admin/src/components/drawer/components/ui.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Drawer 统一 UI 组件系统 - * 提供一致的视觉语言和交互模式 - */ -import { NSwitch } from 'naive-ui' -import { defineComponent, h } from 'vue' -import type { Component, PropType } from 'vue' - -/** - * 分组标题 - */ -export const SectionTitle = defineComponent({ - props: { - icon: { - type: Object as PropType, - required: false, - }, - }, - setup(props, { slots }) { - return () => ( -
- {props.icon && - h(props.icon, { - class: 'size-4 text-neutral-400', - 'aria-hidden': 'true', - })} - - {slots.default?.()} - -
- ) - }, -}) - -/** - * 表单字段 - Label 在上方 - */ -export const FormField = defineComponent({ - props: { - label: { - type: String, - required: true, - }, - required: { - type: Boolean, - default: false, - }, - description: { - type: String, - required: false, - }, - }, - setup(props, { slots }) { - return () => ( -
- - {props.description && ( -

{props.description}

- )} -
{slots.default?.()}
-
- ) - }, -}) - -/** - * Switch 行 - 用于开关类设置项,label 和 switch 两端对齐 - */ -export const SwitchRow = defineComponent({ - props: { - label: { - type: String, - required: true, - }, - description: { - type: String, - required: false, - }, - modelValue: { - type: Boolean, - required: true, - }, - onUpdate: { - type: Function as PropType<(value: boolean) => void>, - required: true, - }, - checkedText: { - type: String, - required: false, - }, - uncheckedText: { - type: String, - required: false, - }, - }, - setup(props) { - return () => ( -
props.onUpdate(!props.modelValue)} - role="switch" - aria-checked={props.modelValue} - aria-label={props.label} - tabindex={0} - onKeydown={(e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - props.onUpdate(!props.modelValue) - } - }} - > -
- - {props.label} - - {props.description && ( - {props.description} - )} -
-
e.stopPropagation()}> - - {props.checkedText || props.uncheckedText - ? { - checked: () => props.checkedText, - unchecked: () => props.uncheckedText, - } - : undefined} - -
-
- ) - }, -}) - -/** - * 内联字段行 - Label 和控件在同一行 - */ -export const InlineField = defineComponent({ - props: { - label: { - type: String, - required: true, - }, - description: { - type: String, - required: false, - }, - }, - setup(props, { slots }) { - return () => ( -
-
- - {props.label} - - {props.description && ( - {props.description} - )} -
-
{slots.default?.()}
-
- ) - }, -}) - -/** - * 字段组 - 用于将多个相关字段组合在一起 - */ -export const FieldGroup = defineComponent({ - props: { - label: { - type: String, - required: false, - }, - }, - setup(props, { slots }) { - return () => ( -
- {props.label && ( -
- {props.label} -
- )} - {slots.default?.()} -
- ) - }, -}) - -/** - * 操作按钮组 - */ -export const ActionRow = defineComponent({ - props: { - label: { - type: String, - required: false, - }, - }, - setup(props, { slots }) { - return () => ( -
- {props.label && ( - - {props.label} - - )} -
{slots.default?.()}
-
- ) - }, -}) - -/** - * 分隔线 - */ -export const Divider = defineComponent({ - setup() { - return () =>
- }, -}) - -/** - * 信息展示 - */ -export const InfoDisplay = defineComponent({ - props: { - label: { - type: String, - required: true, - }, - }, - setup(props, { slots }) { - return () => ( -
- {props.label} - - {slots.default?.()} - -
- ) - }, -}) diff --git a/apps/admin/src/components/drawer/lexical-debug-drawer.tsx b/apps/admin/src/components/drawer/lexical-debug-drawer.tsx deleted file mode 100644 index 5f8cdf911..000000000 --- a/apps/admin/src/components/drawer/lexical-debug-drawer.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { BugIcon } from 'lucide-vue-next' -import { NDrawer, NDrawerContent } from 'naive-ui' -import { computed, defineComponent, ref } from 'vue' -import type { PropType } from 'vue' - -import { HeaderActionButton } from '~/components/button/header-action-button' -import { useAsyncLoadMonaco } from '~/components/monaco-editor' - -export const LexicalDebugButton = defineComponent({ - props: { - content: { - type: String, - required: true, - }, - }, - setup(props) { - const show = ref(false) - - return () => ( - <> - } - variant="default" - name="Lexical Debug" - onClick={() => (show.value = true)} - /> - - (show.value = s)} - content={props.content} - /> - - ) - }, -}) - -const LexicalDebugDrawerContent = defineComponent({ - props: { - content: { - type: String, - required: true, - }, - }, - setup(props) { - const htmlRef = ref() - const formatted = computed(() => { - try { - return JSON.stringify(JSON.parse(props.content), null, 2) - } catch { - return props.content || '{}' - } - }) - - const editor = useAsyncLoadMonaco(htmlRef, formatted, () => {}, { - language: 'json', - readOnly: true, - unSaveConfirm: false, - }) - - return () => ( -
- -
- ) - }, -}) - -const LexicalDebugDrawer = defineComponent({ - props: { - show: { - type: Boolean, - required: true, - }, - onUpdateShow: { - type: Function as PropType<(s: boolean) => void>, - required: true, - }, - content: { - type: String, - required: true, - }, - }, - setup(props) { - return () => ( - - - {props.show && } - - - ) - }, -}) diff --git a/apps/admin/src/components/drawer/text-base-drawer.tsx b/apps/admin/src/components/drawer/text-base-drawer.tsx deleted file mode 100644 index 322a6eeca..000000000 --- a/apps/admin/src/components/drawer/text-base-drawer.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import { BracesIcon, ImageIcon, SettingsIcon } from 'lucide-vue-next' -import { - NDatePicker, - NDrawer, - NDrawerContent, - NImage, - NInput, - NPopover, - NSelect, - NTooltip, -} from 'naive-ui' -import isURL from 'validator/lib/isURL' -import { defineComponent, h, ref } from 'vue' -import type { Image } from '~/models/base' -import type { MetaPresetScope } from '~/models/meta-preset' -import type { LexicalEditor } from 'lexical' -import type { SelectOption } from 'naive-ui' -import type { PropType, VNode } from 'vue' - -import { ImageDetailSection } from './components/image-detail-section' -import { LexicalImageDetailSection } from './components/lexical-image-detail-section' -import { MetaPresetSection } from './components/meta-preset-section' -import { FormField, SectionTitle, SwitchRow } from './components/ui' - -// 重新导出 UI 组件,方便外部使用 -export { - ActionRow, - Divider, - FieldGroup, - FormField, - InlineField, - SectionTitle, - SwitchRow, -} from './components/ui' - -type ItemType = 'date-picker' -export const TextBaseDrawer = defineComponent({ - props: { - show: { - type: Boolean, - required: true, - }, - onUpdateShow: { - type: Function as PropType<(s: boolean) => void>, - required: true, - }, - data: { - type: Object as PropType, - required: true, - }, - - title: { - type: String, - default: '文章设定', - }, - - disabledItem: { - type: Array as PropType, - required: false, - }, - - /** - * 元数据预设字段的适用范围 - */ - scope: { - type: String as PropType, - default: 'both', - }, - lexicalEditor: { - type: Object as PropType, - required: false, - default: null, - }, - }, - setup(props, { slots }) { - const disabledItem = new Set(props.disabledItem || []) - - // 更新 meta 数据 - const handleUpdateMeta = (meta: Record | undefined) => { - props.data.meta = meta - } - - return () => ( - - -
- {/* 外部传入的内容 (特定设置) */} - {slots.default?.()} - - {/* 基础设置 */} - 基础设置 - - {!disabledItem.has('date-picker') && ( - - { - return ts > Date.now() - }} - type="datetime" - value={ - props.data.createdAt - ? new Date(props.data.createdAt).getTime() - : undefined - } - onUpdateValue={(e) => { - const value = e ? new Date(e).toISOString() : undefined - props.data.createdAt = value - }} - /> - - )} - - {/* 图片设置 */} - 图片设置 - - - { - if (!props.data.meta) props.data.meta = {} - if (src === null) { - delete props.data.meta.cover - // 如果 meta 为空对象,设为 undefined - if (Object.keys(props.data.meta).length === 0) { - props.data.meta = undefined - } - return - } - props.data.meta.cover = src - }} - value={props.data.meta?.cover} - /> - - - {props.data.contentFormat !== 'lexical' ? ( - { - props.data.images = images - }} - /> - ) : ( - - )} - - {/* 附加字段 */} - 附加字段 - - -
-
-
- ) - }, -}) - -/** - * 图片封面输入组件 - */ -const ImageCoverInput = defineComponent({ - props: { - images: { - type: Array as PropType, - required: true, - }, - onChange: { - type: Function as PropType<(image: string | null) => void>, - required: true, - }, - value: { - type: String, - required: false, - }, - }, - setup(props) { - const isValidated = ref(true) - const validateAndCallback = (value: string) => { - if (!value) { - isValidated.value = true - props.onChange(null) - return - } - if (isURL(value)) isValidated.value = true - else isValidated.value = false - - props.onChange(value) - } - const show = ref(false) - return () => ( - { - if (newValue && !props.value) return - show.value = newValue - }} - > - {{ - trigger() { - const validImages = (props.images as Image[]).filter( - (img) => img?.src, - ) - return validImages.length > 0 ? ( - ({ - label: image.src, - value: image.src, - }))} - filterable - tag - clearable - maxTagCount={1} - placeholder="选择或输入图片 URL" - renderOption={({ - node, - option, - }: { - node: VNode - option: SelectOption - }) => - h( - NTooltip, - { placement: 'left' }, - { - trigger: () => node, - default: () => ( - - ), - }, - ) - } - /> - ) : ( - - ) - }, - default() { - if (!props.value) return null - return - }, - }} - - ) - }, -}) diff --git a/apps/admin/src/components/editor/codemirror/ImageDropZone.tsx b/apps/admin/src/components/editor/codemirror/ImageDropZone.tsx deleted file mode 100644 index b2829cf6e..000000000 --- a/apps/admin/src/components/editor/codemirror/ImageDropZone.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { Upload } from 'lucide-vue-next' -import { defineComponent, onBeforeUnmount, ref, Teleport, watch } from 'vue' -import type { EditorView } from '@codemirror/view' - -import { useEditorStore } from './editor-store' - -const findScrollableParent = (el: HTMLElement | null): HTMLElement | null => { - while (el) { - if (el.classList.contains('n-scrollbar-container')) { - return el - } - el = el.parentElement - } - return null -} - -export const ImageDropZone = defineComponent({ - name: 'ImageDropZone', - setup() { - const editorStore = useEditorStore() - const isDragging = ref(false) - const teleportTarget = ref(null) - let dragCounter = 0 - - const hasImageFile = (dataTransfer: DataTransfer | null) => { - if (!dataTransfer?.items) return false - for (const item of dataTransfer.items) { - if (item.type.startsWith('image/')) return true - } - return false - } - - const handleDrop = (event: DragEvent) => { - dragCounter = 0 - isDragging.value = false - - const files = event.dataTransfer?.files - if (!files || files.length === 0) return - - const imageFiles = Array.from(files).filter((file) => - file.type.startsWith('image/'), - ) - - if (imageFiles.length > 0) { - event.preventDefault() - event.stopPropagation() - imageFiles.forEach((file) => editorStore.uploadImageFile(file)) - } - } - - const handleDragOver = (event: DragEvent) => { - if (hasImageFile(event.dataTransfer)) { - event.preventDefault() - event.stopPropagation() - } - } - - const handleDragEnter = (event: DragEvent) => { - if (hasImageFile(event.dataTransfer)) { - event.preventDefault() - dragCounter++ - isDragging.value = true - } - } - - const handleDragLeave = () => { - dragCounter-- - if (dragCounter === 0) { - isDragging.value = false - } - } - - const bindEvents = (view: EditorView) => { - view.dom.addEventListener('drop', handleDrop) - view.dom.addEventListener('dragover', handleDragOver) - view.dom.addEventListener('dragenter', handleDragEnter) - view.dom.addEventListener('dragleave', handleDragLeave) - } - - const unbindEvents = (view: EditorView) => { - view.dom.removeEventListener('drop', handleDrop) - view.dom.removeEventListener('dragover', handleDragOver) - view.dom.removeEventListener('dragenter', handleDragEnter) - view.dom.removeEventListener('dragleave', handleDragLeave) - } - - let boundView: EditorView | null = null - - watch( - () => editorStore.editorView, - (newView, oldView) => { - if (oldView) unbindEvents(oldView) - if (newView) { - bindEvents(newView) - boundView = newView - // 查找滚动容器作为 Teleport 目标 - const scrollContainer = findScrollableParent( - newView.dom.closest( - '.write-editor-scroll-container', - ) as HTMLElement, - ) - teleportTarget.value = scrollContainer - } - }, - { immediate: true }, - ) - - onBeforeUnmount(() => { - if (boundView) unbindEvents(boundView) - }) - - return () => { - if (!isDragging.value || !teleportTarget.value) return null - - return ( - -
-
- - - 松开以上传图片 - -
-
-
- ) - } - }, -}) diff --git a/apps/admin/src/components/editor/codemirror/ImageEditPopover.tsx b/apps/admin/src/components/editor/codemirror/ImageEditPopover.tsx deleted file mode 100644 index 141913c43..000000000 --- a/apps/admin/src/components/editor/codemirror/ImageEditPopover.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { NButton, NInput, NSpace } from 'naive-ui' -import { - computed, - defineComponent, - nextTick, - onUnmounted, - ref, - Teleport, - watch, -} from 'vue' - -import { hideImagePopover, imagePopoverState } from './image-popover-state' - -export const ImageEditPopover = defineComponent({ - name: 'ImageEditPopover', - setup() { - const altValue = ref('') - const urlValue = ref('') - const popoverRef = ref() - const altInputRef = ref>() - const popoverStyle = ref({ left: '0px', top: '0px' }) - - // 从 data-* 属性读取数据 - const popoverData = computed(() => { - const el = imagePopoverState.targetEl - if (!el) return null - return { - alt: el.dataset.alt || '', - url: el.dataset.url || '', - matchStart: Number(el.dataset.matchStart), - matchEnd: Number(el.dataset.matchEnd), - } - }) - - // 控制 body 的 pointer-events 和滚动 - const disableBodyInteraction = () => { - document.body.style.pointerEvents = 'none' - document.body.style.overflow = 'hidden' - } - - const enableBodyInteraction = () => { - document.body.style.pointerEvents = '' - document.body.style.overflow = '' - } - - // 监听 state 变化,初始化表单值并控制交互 - watch( - () => imagePopoverState.visible, - (visible) => { - if (visible && popoverData.value) { - altValue.value = popoverData.value.alt - urlValue.value = popoverData.value.url - disableBodyInteraction() - nextTick(() => { - updatePosition() - altInputRef.value?.focus() - }) - } else { - enableBodyInteraction() - } - }, - ) - - // 组件卸载时确保恢复 body 交互 - onUnmounted(() => { - enableBodyInteraction() - }) - - const updatePosition = () => { - const el = imagePopoverState.targetEl - if (!el || !popoverRef.value) return - - const rect = el.getBoundingClientRect() - const popoverRect = popoverRef.value.getBoundingClientRect() - const viewportWidth = window.innerWidth - const viewportHeight = window.innerHeight - - let left = rect.left - let top = rect.bottom + 8 - - // Horizontal adjustment - if (left + popoverRect.width > viewportWidth - 16) { - left = viewportWidth - popoverRect.width - 16 - } - if (left < 16) { - left = 16 - } - - // Vertical adjustment - show above if not enough space below - if (top + popoverRect.height > viewportHeight - 16) { - top = rect.top - popoverRect.height - 8 - } - - popoverStyle.value = { - left: `${left}px`, - top: `${top}px`, - } - } - - const handleSave = () => { - const data = popoverData.value - const view = imagePopoverState.view - if (!data || !view) return - - const newMarkdown = `![${altValue.value}](${urlValue.value})` - view.dispatch({ - changes: { - from: data.matchStart, - to: data.matchEnd, - insert: newMarkdown, - }, - }) - hideImagePopover() - } - - const handleCancel = () => { - hideImagePopover() - } - - const handleDelete = () => { - const data = popoverData.value - const view = imagePopoverState.view - if (!data || !view) return - - // Find the line containing the image and check if it's the only content - const doc = view.state.doc - const line = doc.lineAt(data.matchStart) - const lineText = line.text.trim() - const imageMarkdown = doc.sliceString(data.matchStart, data.matchEnd) - - // If the line only contains this image, delete the entire line - const isOnlyContentOnLine = lineText === imageMarkdown.trim() - - if (isOnlyContentOnLine) { - // Delete the entire line including the newline - const deleteFrom = line.from - const deleteTo = Math.min(line.to + 1, doc.length) - view.dispatch({ - changes: { - from: deleteFrom, - to: deleteTo, - insert: '', - }, - }) - } else { - // Just delete the image markdown - view.dispatch({ - changes: { - from: data.matchStart, - to: data.matchEnd, - insert: '', - }, - }) - } - - hideImagePopover() - } - - const handleKeydown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSave() - } - if (e.key === 'Escape') { - handleCancel() - } - } - - return () => { - if (!imagePopoverState.visible || !imagePopoverState.targetEl) { - return null - } - - return ( - - {/* 透明遮罩层 - 点击关闭 */} -
-
e.stopPropagation()} - onKeydown={handleKeydown} - > -
-
- - -
-
- - -
-
- - 删除 - - - - 取消 - - - 保存 - - -
-
-
- - ) - } - }, -}) diff --git a/apps/admin/src/components/editor/codemirror/codemirror.tsx b/apps/admin/src/components/editor/codemirror/codemirror.tsx deleted file mode 100644 index 003c2164c..000000000 --- a/apps/admin/src/components/editor/codemirror/codemirror.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/* eslint-disable vue/no-setup-props-destructure */ -import { defineComponent, watch } from 'vue' -import type { EditorState } from '@codemirror/state' -import type { PropType } from 'vue' - -import { EditorView } from '@codemirror/view' - -import { useSaveConfirm } from '~/hooks/use-save-confirm' - -import { SlashMenu, slashMenuExtension } from '../slash-menu' -import { FloatingToolbar } from '../toolbar/floating-toolbar' -import { useSelectionPosition } from '../toolbar/use-selection-position' -import styles from '../universal/editor.module.css' -import { editorBaseProps } from '../universal/props' - -import 'katex/dist/katex.min.css' -import './codemirror.css' - -import { - codemirrorReconfigureExtensionMap, - wysiwygModeExtension, -} from './extension' -import { ImageEditPopover } from './ImageEditPopover' -import { useCodeMirror } from './use-codemirror' -import { wysiwygExtensions } from './wysiwyg' - -export const CodemirrorEditor = defineComponent({ - name: 'CodemirrorEditor', - props: { - ...editorBaseProps, - onStateChange: { - type: Function as PropType<(state: EditorState) => void>, - required: false, - }, - onArrowUpAtFirstLine: { - type: Function as PropType<() => void>, - required: false, - }, - className: { - type: String, - }, - embedded: { - type: Boolean, - default: false, - }, - }, - setup(props, { expose }) { - const [refContainer, editorView] = useCodeMirror({ - initialDoc: props.text, - onChange: (state) => { - props.onChange(state.doc.toString()) - props.onStateChange?.(state) - }, - onArrowUpAtFirstLine: props.onArrowUpAtFirstLine, - enableEditorStore: !props.embedded, - }) - - watch( - () => props.text, - (n) => { - const editor = editorView.value - - if (editor && n != editor.state.doc.toString()) { - editor.dispatch({ - changes: { from: 0, to: editor.state.doc.length, insert: n }, - }) - } - }, - ) - - watch( - () => [props.renderMode, editorView.value], - ([renderMode]) => { - const view = editorView.value - if (!view) return - - const hadFocus = view.hasFocus - const isWysiwyg = (renderMode ?? 'plain') === 'wysiwyg' - const extensions = isWysiwyg ? wysiwygExtensions : [] - - const selectionHead = view.state.selection.main.head - - view.dispatch({ - effects: [ - codemirrorReconfigureExtensionMap.wysiwyg.reconfigure(extensions), - codemirrorReconfigureExtensionMap.wysiwygMode.reconfigure( - isWysiwyg ? [wysiwygModeExtension] : [], - ), - codemirrorReconfigureExtensionMap.slashMenu.reconfigure( - isWysiwyg ? slashMenuExtension : [], - ), - ], - }) - view.requestMeasure() - - if (!props.embedded) { - requestAnimationFrame(() => { - view.dispatch({ - effects: EditorView.scrollIntoView(selectionHead, { - y: 'center', - }), - }) - }) - } - - if (hadFocus) { - requestAnimationFrame(() => view.focus()) - } - }, - { - immediate: true, - flush: 'post', - }, - ) - - expose({ - setValue: (value: string) => { - const editor = editorView.value - if (editor) { - editor.dispatch({ - changes: { from: 0, to: editor.state.doc.length, insert: value }, - }) - } - }, - focus: () => { - editorView.value?.focus() - }, - }) - - const memoedText = props.text - - useSaveConfirm( - props.unSaveConfirm && !props.embedded, - () => - props.saveConfirmFn?.() ?? - memoedText === editorView.value?.state.doc.toString(), - ) - - // 浮动工具栏选区位置追踪 - const { position, hasSelection } = useSelectionPosition(editorView) - - // 点击空白区域聚焦编辑器并将光标移到对应位置 (WYSIWYG 模式) - const handleContainerPointerDown = (e: PointerEvent) => { - const view = editorView.value - if (!view) return - if (props.embedded) return - - const isWysiwyg = (props.renderMode ?? 'plain') === 'wysiwyg' - if (!isWysiwyg) return - - if (e.button !== 0) return - - const path = e.composedPath() - if (path.includes(view.contentDOM)) return - - const target = e.target - if (target instanceof Node && view.contentDOM.contains(target)) return - - const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }) - if (pos == null) return - - e.preventDefault() - view.focus() - view.dispatch({ - selection: { anchor: pos }, - }) - } - - return () => ( -
-
- {!props.embedded && ( - - )} - {!props.embedded && (props.renderMode ?? 'plain') === 'wysiwyg' && ( - - )} - {!props.embedded && } -
- ) - }, -}) diff --git a/apps/admin/src/components/editor/codemirror/editor-store.ts b/apps/admin/src/components/editor/codemirror/editor-store.ts deleted file mode 100644 index a66fc4431..000000000 --- a/apps/admin/src/components/editor/codemirror/editor-store.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { defineStore } from 'pinia' -import { shallowRef } from 'vue' -import { toast } from 'vue-sonner' -import type { EditorView } from '@codemirror/view' - -import { filesApi } from '~/api/files' - -import { - addPendingUpload, - removePendingUpload, - setPendingUploadError, -} from './upload-store' - -export const useEditorStore = defineStore('codemirror-editor', () => { - const editorView = shallowRef() - - let uploadIdCounter = 0 - const generateUploadId = () => `__upload_${Date.now()}_${++uploadIdCounter}__` - - const readFileAsBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = reject - reader.readAsDataURL(file) - }) - } - - const uploadImageFile = async (file: File) => { - const view = editorView.value - if (!view) return - - const uploadId = generateUploadId() - const placeholder = `![上传中...](${uploadId})` - - // Read file as base64 for preview - let base64: string | null = null - try { - base64 = await readFileAsBase64(file) - addPendingUpload(uploadId, base64, file.name) - } catch { - // Failed to read base64, continue without preview - } - - const { from: cursorPos } = view.state.selection.main - const currentLine = view.state.doc.lineAt(cursorPos) - const isLineEmpty = currentLine.text.trim() === '' - - const insertPos = isLineEmpty ? cursorPos : currentLine.to - const prefix = isLineEmpty ? '' : '\n\n' - const insertText = `${prefix}${placeholder}` - - view.dispatch({ - changes: { from: insertPos, insert: insertText }, - selection: { anchor: insertPos + insertText.length }, - }) - - try { - const result = await filesApi.upload(file, 'image') - - const currentDoc = view.state.doc.toString() - const placeholderIndex = currentDoc.indexOf(placeholder) - - if (placeholderIndex !== -1) { - const imageMarkdown = `![](${result.url})` - view.dispatch({ - changes: { - from: placeholderIndex, - to: placeholderIndex + placeholder.length, - insert: imageMarkdown, - }, - }) - } - // Clean up pending upload - removePendingUpload(uploadId) - } catch { - toast.error('图片上传失败') - setPendingUploadError(uploadId) - - const currentDoc = view.state.doc.toString() - const placeholderIndex = currentDoc.indexOf(placeholder) - if (placeholderIndex !== -1) { - const placeholderLine = view.state.doc.lineAt(placeholderIndex) - const isOnlyPlaceholder = placeholderLine.text.trim() === placeholder - view.dispatch({ - changes: { - from: isOnlyPlaceholder ? placeholderLine.from : placeholderIndex, - to: isOnlyPlaceholder - ? Math.min(placeholderLine.to + 1, view.state.doc.length) - : placeholderIndex + placeholder.length, - insert: '', - }, - }) - } - // Clean up pending upload after removing placeholder - removePendingUpload(uploadId) - } - } - - const setEditorView = (view: EditorView | undefined) => { - editorView.value = view - } - - const setValue = (value: string) => { - const view = editorView.value - if (view) { - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: value }, - }) - } - } - - const focus = () => { - editorView.value?.focus() - } - - return { - editorView, - uploadImageFile, - setEditorView, - setValue, - focus, - } -}) diff --git a/apps/admin/src/components/editor/codemirror/image-popover-state.ts b/apps/admin/src/components/editor/codemirror/image-popover-state.ts deleted file mode 100644 index dfabcce6b..000000000 --- a/apps/admin/src/components/editor/codemirror/image-popover-state.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { reactive } from 'vue' -import type { EditorView } from '@codemirror/view' - -interface ImagePopoverState { - visible: boolean - targetEl: HTMLElement | null // Teleport 的目标元素 - view: EditorView | null // 编辑器实例,用于 dispatch 更新 -} - -export const imagePopoverState = reactive({ - visible: false, - targetEl: null, - view: null, -}) - -export const showImagePopover = (targetEl: HTMLElement, view: EditorView) => { - imagePopoverState.visible = true - imagePopoverState.targetEl = targetEl - imagePopoverState.view = view -} - -export const hideImagePopover = () => { - imagePopoverState.visible = false - imagePopoverState.targetEl = null - imagePopoverState.view = null -} diff --git a/apps/admin/src/components/editor/codemirror/use-auto-fonts.ts b/apps/admin/src/components/editor/codemirror/use-auto-fonts.ts deleted file mode 100644 index edd725d72..000000000 --- a/apps/admin/src/components/editor/codemirror/use-auto-fonts.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { watch } from 'vue' -import type { EditorView } from '@codemirror/view' -import type { Ref } from 'vue' - -import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' -import { tags } from '@lezer/highlight' - -import { useEditorConfig } from '../universal/use-editor-setting' -import { codemirrorReconfigureExtensionMap } from './extension' - -export const monospaceFonts = `"OperatorMonoSSmLig Nerd Font","Cascadia Code PL","FantasqueSansMono Nerd Font","operator mono","Fira code Retina","Fira code","Consolas", Monaco, "Hannotate SC", monospace, -apple-system` - -const markdownTags = [ - tags.heading1, - tags.heading2, - tags.heading3, - tags.heading4, - tags.heading5, - tags.heading6, - tags.strong, - tags.emphasis, - tags.deleted, - tags.content, - tags.url, - tags.link, -] - -export const useCodeMirrorConfigureFonts = ( - editorView: Ref, -) => { - const { general } = useEditorConfig() - - watch( - () => [general.setting.fontFamily, editorView.value], - ([fontFamily]) => { - if (!editorView.value) return - const sansFonts = fontFamily || 'var(--sans-font)' - - const fontStyles = HighlightStyle.define([ - { - tag: [tags.processingInstruction, tags.monospace], - fontFamily: monospaceFonts, - }, - { tag: markdownTags, fontFamily: sansFonts }, - ]) - - editorView.value.dispatch({ - effects: [ - codemirrorReconfigureExtensionMap.fonts.reconfigure([ - syntaxHighlighting(fontStyles), - ]), - ], - }) - }, - { - immediate: true, - flush: 'post', - }, - ) -} diff --git a/apps/admin/src/components/editor/codemirror/use-auto-theme.ts b/apps/admin/src/components/editor/codemirror/use-auto-theme.ts deleted file mode 100644 index b1e0d82f4..000000000 --- a/apps/admin/src/components/editor/codemirror/use-auto-theme.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { watch } from 'vue' -import type { EditorView } from '@codemirror/view' -import type { Ref } from 'vue' - -import { oneDark } from '@codemirror/theme-one-dark' -import { githubLight } from '@ddietr/codemirror-themes/theme/github-light' - -import { useStoreRef } from '~/hooks/use-store-ref' -import { UIStore } from '~/stores/ui' - -import { codemirrorReconfigureExtensionMap } from './extension' - -export const useCodeMirrorAutoToggleTheme = ( - view: Ref, -) => { - const { isDark } = useStoreRef(UIStore) - watch( - [isDark, view], - ([isDark]) => { - if (!view.value) { - return - } - - if (isDark) { - view.value.dispatch({ - effects: [ - codemirrorReconfigureExtensionMap.theme.reconfigure(oneDark), - ], - }) - } else { - view.value.dispatch({ - effects: [ - codemirrorReconfigureExtensionMap.theme.reconfigure(githubLight), - ], - }) - } - }, - - { immediate: true, flush: 'post' }, - ) -} diff --git a/apps/admin/src/components/editor/codemirror/use-codemirror.ts b/apps/admin/src/components/editor/codemirror/use-codemirror.ts deleted file mode 100644 index 5373d62ae..000000000 --- a/apps/admin/src/components/editor/codemirror/use-codemirror.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { onBeforeUnmount, onMounted, ref } from 'vue' -import type { Ref } from 'vue' - -import { - defaultKeymap, - history, - historyKeymap, - indentWithTab, -} from '@codemirror/commands' -import { markdownKeymap } from '@codemirror/lang-markdown' -import { bracketMatching, indentOnInput } from '@codemirror/language' -import { search, searchKeymap } from '@codemirror/search' -import { EditorState } from '@codemirror/state' -import { - EditorView, - highlightActiveLine, - highlightActiveLineGutter, - keymap, - lineNumbers, -} from '@codemirror/view' - -import { createToolbarKeymapExtension } from '../toolbar' -import { useEditorConfig } from '../universal/use-editor-setting' -import { useEditorStore } from './editor-store' -import { codemirrorReconfigureExtension } from './extension' -import { syntaxTheme } from './syntax-highlight' -import { useCodeMirrorConfigureFonts } from './use-auto-fonts' -import { useCodeMirrorAutoToggleTheme } from './use-auto-theme' - -interface Props { - initialDoc: string - onChange?: (state: EditorState) => void - onArrowUpAtFirstLine?: () => void - enableEditorStore?: boolean -} - -export const useCodeMirror = ( - props: Props, -): [Ref, Ref] => { - const refContainer = ref() - const editorView = ref() - const editorStore = - props.enableEditorStore === false ? null : useEditorStore() - const { general } = useEditorConfig() - const { onChange, onArrowUpAtFirstLine } = props - let cleanupDebugListeners: (() => void) | null = null - - onMounted(() => { - if (!refContainer.value) return - - const startState = EditorState.create({ - doc: props.initialDoc, - extensions: [ - keymap.of([ - { - key: 'Mod-s', - run() { - return false - }, - preventDefault: true, - }, - { - key: 'Enter', - run(view) { - // In WYSIWYG mode, insert double newline for paragraph separation - if (general.setting.renderMode === 'wysiwyg') { - const { state } = view - const { from, to } = state.selection.main - - // Insert two newlines (empty line + new line position) - view.dispatch({ - changes: { from, to, insert: '\n\n' }, - selection: { anchor: from + 2 }, - }) - - return true // Prevent default Enter behavior - } - - return false // Use default Enter behavior in plain mode - }, - }, - { - key: 'Mod-/', - run() { - general.setting.renderMode = - general.setting.renderMode === 'wysiwyg' ? 'plain' : 'wysiwyg' - return true - }, - }, - { - key: 'ArrowUp', - run(view) { - // In WYSIWYG mode, if cursor is at first line, jump to title input - if ( - general.setting.renderMode === 'wysiwyg' && - onArrowUpAtFirstLine - ) { - const { state } = view - const cursorPos = state.selection.main.head - const firstLine = state.doc.line(1) - - // Check if cursor is on the first line - if (cursorPos <= firstLine.to) { - onArrowUpAtFirstLine() - return true - } - } - return false // Use default ArrowUp behavior - }, - }, - ]), - createToolbarKeymapExtension(), - keymap.of([ - ...defaultKeymap, - ...historyKeymap, - ...markdownKeymap, - ...searchKeymap, - indentWithTab, - ]), - - lineNumbers(), - highlightActiveLineGutter(), - history(), - indentOnInput(), - bracketMatching(), - highlightActiveLine(), - EditorState.tabSize.of(2), - search({ - top: true, - }), - - syntaxTheme, - - ...codemirrorReconfigureExtension, - - EditorView.lineWrapping, - EditorView.updateListener.of((update) => { - if (update.changes) { - onChange && onChange(update.state) - } - }), - ], - }) - - const view = new EditorView({ - state: startState, - parent: refContainer.value, - }) - - editorView.value = view - - const shouldDebugWysiwyg = () => { - if (general.setting.renderMode !== 'wysiwyg') return false - if (typeof window === 'undefined') return false - const enabled = - (window as unknown as { __CM_WYSIWYG_DEBUG__?: boolean }) - .__CM_WYSIWYG_DEBUG__ === true - return enabled || window.localStorage?.getItem('cm-wysiwyg-debug') === '1' - } - - const shorten = (text: string, max = 120) => - text.length > max ? `${text.slice(0, max - 1)}…` : text - - const describeNode = (node: EventTarget | null): string => { - if (!node) return 'null' - if (node === window) return 'window' - if (node === document) return 'document' - if (node instanceof Text) { - return `#text("${shorten(node.data)}")` - } - if (node instanceof HTMLElement) { - const classes = typeof node.className === 'string' ? node.className : '' - const classHint = classes - ? `.${classes.split(/\s+/).filter(Boolean).slice(0, 4).join('.')}` - : '' - const dataHint = node.dataset?.enterPos - ? `[data-enter-pos=${node.dataset.enterPos}]` - : node.dataset?.matchStart - ? `[data-match-start=${node.dataset.matchStart}]` - : '' - return `<${node.tagName.toLowerCase()}${classHint}${dataHint}>` - } - return Object.prototype.toString.call(node) - } - - const describePath = (path: EventTarget[]) => - path.slice(0, 6).map(describeNode).join(' > ') - - const getCaretInfo = (x: number, y: number) => { - try { - if (document.caretPositionFromPoint) { - const caret = document.caretPositionFromPoint(x, y) - if (!caret) return null - return { node: caret.offsetNode, offset: caret.offset } - } - if (document.caretRangeFromPoint) { - const range = document.caretRangeFromPoint(x, y) - if (!range) return null - return { node: range.startContainer, offset: range.startOffset } - } - } catch { - return null - } - return null - } - - const logSelection = (label: string) => { - const selection = view.state.selection.main - const line = view.state.doc.lineAt(selection.head) - console.log(`[CM WYSIWYG] ${label}`, { - selection: { - from: selection.from, - to: selection.to, - head: selection.head, - anchor: selection.anchor, - empty: selection.empty, - }, - line: { - number: line.number, - from: line.from, - to: line.to, - text: shorten(line.text), - }, - }) - } - - const debugPointerDown = (event: PointerEvent) => { - if (!shouldDebugWysiwyg()) return - const coords = { x: event.clientX, y: event.clientY } - const pos = view.posAtCoords(coords) - const posLeft = view.posAtCoords(coords, false) - const posRight = view.posAtCoords(coords) - const line = pos == null ? null : view.state.doc.lineAt(pos) - const domAtPos = pos == null ? null : view.domAtPos(pos).node - const caretInfo = getCaretInfo(coords.x, coords.y) - const caretNode = caretInfo?.node ?? null - const caretPos = - caretNode && view.dom.contains(caretNode) - ? view.posAtDOM(caretNode, caretInfo?.offset ?? 0) - : null - - console.log('[CM WYSIWYG] pointerdown', { - coords, - button: event.button, - pos, - posLeft, - posRight, - line: line - ? { - number: line.number, - from: line.from, - to: line.to, - text: shorten(line.text), - } - : null, - selection: { - from: view.state.selection.main.from, - to: view.state.selection.main.to, - head: view.state.selection.main.head, - anchor: view.state.selection.main.anchor, - empty: view.state.selection.main.empty, - }, - target: describeNode(event.target), - path: - typeof event.composedPath === 'function' - ? describePath(event.composedPath()) - : 'n/a', - domAtPos: describeNode(domAtPos), - caret: caretInfo - ? { - node: describeNode(caretInfo.node as EventTarget), - offset: caretInfo.offset, - posAtDOM: caretPos, - } - : null, - }) - } - - const debugPointerUp = () => { - if (!shouldDebugWysiwyg()) return - setTimeout(() => logSelection('pointerup'), 0) - } - - view.dom.addEventListener('pointerdown', debugPointerDown, true) - view.dom.addEventListener('pointerup', debugPointerUp, true) - - const handlePaste = (event: ClipboardEvent) => { - const items = event.clipboardData?.items - if (!items) return - - const imageFiles: File[] = [] - for (const item of items) { - if (item.type.startsWith('image/')) { - const file = item.getAsFile() - if (file) imageFiles.push(file) - } - } - - if (imageFiles.length > 0 && editorStore) { - event.preventDefault() - imageFiles.forEach((file) => editorStore.uploadImageFile(file)) - } - } - - // 设置 store - editorStore?.setEditorView(view) - - view.dom.addEventListener('paste', handlePaste) - - cleanupDebugListeners = () => { - view.dom.removeEventListener('pointerdown', debugPointerDown, true) - view.dom.removeEventListener('pointerup', debugPointerUp, true) - view.dom.removeEventListener('paste', handlePaste) - } - }) - - useCodeMirrorAutoToggleTheme(editorView) - useCodeMirrorConfigureFonts(editorView) - - onBeforeUnmount(() => { - cleanupDebugListeners?.() - editorView.value?.destroy() - // 清理 store - editorStore?.setEditorView(undefined) - }) - - return [refContainer, editorView] -} diff --git a/apps/admin/src/components/editor/plain/plain.tsx b/apps/admin/src/components/editor/plain/plain.tsx deleted file mode 100644 index f1c33a898..000000000 --- a/apps/admin/src/components/editor/plain/plain.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { NInput } from 'naive-ui' -import { defineComponent, ref, toRaw, watch } from 'vue' -import type { HTMLAttributes, PropType } from 'vue' - -import { useSaveConfirm } from '~/hooks/use-save-confirm' - -import { editorBaseProps } from '../universal/props' - -export const PlainEditor = defineComponent({ - props: { - ...editorBaseProps, - wrapperProps: { - type: Object as PropType, - required: false, - }, - }, - setup(props) { - const textRef = ref() - - let memoInitialValue: string = toRaw(props.text) - - watch( - () => props.text, - (n) => { - if (!memoInitialValue && n) { - memoInitialValue = n - } - }, - ) - - useSaveConfirm( - props.unSaveConfirm, - () => props.saveConfirmFn?.() ?? memoInitialValue === props.text, - ) - - return () => ( -
- void props.onChange(e)} - value={props.text} - class="h-full" - /> -
- ) - }, -}) diff --git a/apps/admin/src/components/editor/rich/RichDiffBridge.tsx b/apps/admin/src/components/editor/rich/RichDiffBridge.tsx deleted file mode 100644 index f53788f72..000000000 --- a/apps/admin/src/components/editor/rich/RichDiffBridge.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { mountRichDiff } from '@mx-admin/rich-react' -import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import type { RichDiffHandle } from '@mx-admin/rich-react' -import type { SerializedEditorState } from 'lexical' -import type { PropType } from 'vue' - -import { enrichmentApi } from '~/api/enrichment' -import { useUIStore } from '~/stores/ui' - -const fetchEnrichment = (url: string) => - enrichmentApi.resolve(url).catch(() => null) - -export const RichDiffBridge = defineComponent({ - props: { - oldValue: { - type: Object as PropType, - required: true, - }, - newValue: { - type: Object as PropType, - required: true, - }, - variant: String as PropType<'article' | 'comment' | 'note'>, - className: String, - }, - setup(props) { - const containerRef = ref(null) - let handle: RichDiffHandle | null = null - - onMounted(() => { - if (!containerRef.value) return - const uiStore = useUIStore() - const resolveTheme = () => (uiStore.isDark ? 'dark' : 'light') - - handle = mountRichDiff(containerRef.value, { - oldValue: props.oldValue, - newValue: props.newValue, - variant: props.variant, - className: props.className, - theme: resolveTheme(), - fetchEnrichment, - }) - - watch( - () => [ - props.oldValue, - props.newValue, - props.variant, - props.className, - uiStore.isDark, - ], - () => - handle?.update({ - oldValue: props.oldValue, - newValue: props.newValue, - variant: props.variant, - className: props.className, - theme: resolveTheme(), - fetchEnrichment, - }), - ) - }) - - onBeforeUnmount(() => { - handle?.unmount() - handle = null - }) - - return () =>
- }, -}) diff --git a/apps/admin/src/components/editor/rich/RichEditor.tsx b/apps/admin/src/components/editor/rich/RichEditor.tsx deleted file mode 100644 index 02981263b..000000000 --- a/apps/admin/src/components/editor/rich/RichEditor.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { mountRichEditor } from '@mx-admin/rich-react' -import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import type { ProviderGroup, SelectedModel } from '@haklex/rich-agent-chat' -import type { ChatBubble } from '@haklex/rich-agent-core' -import type { RichEditorVariant } from '@haklex/rich-editor' -import type { ImageUpload, RichEditorHandle } from '@mx-admin/rich-react' -import type { - Klass, - LexicalEditor, - LexicalNode, - SerializedEditorState, -} from 'lexical' -import type { PropType } from 'vue' -import type { MetaFieldsSchema } from './agent-chat/composables/use-meta-tools' - -import { enrichmentApi } from '~/api/enrichment' -import { filesApi } from '~/api/files' -import { API_URL } from '~/constants/env' -import { useUIStore } from '~/stores/ui' - -import { RichEditorWithAgent } from './RichEditorWithAgent' - -const fetchEnrichment = (url: string) => - enrichmentApi.resolve(url).catch(() => null) - -async function saveExcalidrawSnapshot( - snapshot: object, - existingRef?: string, -): Promise { - const blob = new Blob([JSON.stringify(snapshot)], { - type: 'application/json', - }) - const file = new File([blob], 'snapshot.excalidraw', { - type: 'application/json', - }) - - if (existingRef?.startsWith('ref:file/')) { - const name = existingRef.slice(9) - const result = await filesApi.update('file', name, file) - return `ref:file/${result.name}` - } - - const result = await filesApi.upload(file, 'file') - return `ref:file/${result.name}` -} - -type FocusableEditorHandle = { focus: () => void } - -export const RichEditor = defineComponent({ - props: { - initialValue: Object as PropType, - theme: String as PropType<'dark' | 'light'>, - placeholder: String, - variant: String as PropType, - autoFocus: { type: Boolean, default: undefined }, - className: String, - contentClassName: String, - debounceMs: Number, - selfHostnames: Array as PropType, - extraNodes: Array as PropType>>, - editorStyle: Object as PropType>, - imageUpload: Function as PropType, - agentEnabled: { type: Boolean, default: false }, - agentVisible: { type: Boolean, default: false }, - providerGroups: Array as PropType, - selectedModel: Object as PropType, - onSelectModel: Function as PropType<(model: SelectedModel) => void>, - initialBubbles: Array as PropType, - refId: String, - refType: String as PropType<'post' | 'note' | 'page'>, - metaFieldsSchema: Object as PropType, - getMetaFields: Function as PropType<() => Record>, - onMetaFieldsUpdate: Function as PropType< - (updates: Record) => void | Promise - >, - }, - emits: { - change: (_value: SerializedEditorState) => true, - textChange: (_text: string) => true, - submit: () => true, - editorReady: (_editor: LexicalEditor | null) => true, - }, - setup(props, { emit, expose }) { - const containerRef = ref(null) - const agentRef = ref(null) - let handle: RichEditorHandle | null = null - let editorInstance: LexicalEditor | null = null - - const buildOptions = (resolvedTheme: 'dark' | 'light') => ({ - theme: resolvedTheme, - initialValue: props.initialValue, - placeholder: props.placeholder, - variant: props.variant, - autoFocus: props.autoFocus, - className: props.className, - contentClassName: props.contentClassName, - debounceMs: props.debounceMs, - selfHostnames: props.selfHostnames, - extraNodes: props.extraNodes, - editorStyle: props.editorStyle, - imageUpload: props.imageUpload, - saveExcalidrawSnapshot, - apiUrl: API_URL, - fetchEnrichment, - onChange: (v: SerializedEditorState) => emit('change', v), - onSubmit: () => emit('submit'), - onEditorReady: (editor: LexicalEditor | null) => { - editorInstance = editor - emit('editorReady', editor) - }, - onTextChange: (text: string) => emit('textChange', text), - }) - - onMounted(() => { - if (props.agentEnabled || !containerRef.value) return - - const uiStore = useUIStore() - const resolveTheme = () => - props.theme ?? (uiStore.isDark ? 'dark' : 'light') - - handle = mountRichEditor(containerRef.value, buildOptions(resolveTheme())) - - watch( - () => [ - props.theme, - uiStore.isDark, - props.placeholder, - props.variant, - props.autoFocus, - props.className, - props.contentClassName, - props.debounceMs, - props.selfHostnames, - props.extraNodes, - props.editorStyle, - props.imageUpload, - ], - () => handle?.update(buildOptions(resolveTheme())), - ) - }) - - onBeforeUnmount(() => { - handle?.unmount() - handle = null - editorInstance = null - }) - - expose({ - focus: () => { - if (props.agentEnabled) { - agentRef.value?.focus() - } else { - editorInstance?.focus() - } - }, - }) - - if (props.agentEnabled) { - return () => ( - emit('change', v)} - onSubmit={() => emit('submit')} - onEditorReady={(e: LexicalEditor | null) => { - editorInstance = e - emit('editorReady', e) - }} - onTextChange={(text: string) => emit('textChange', text)} - /> - ) - } - - return () =>
- }, -}) diff --git a/apps/admin/src/components/editor/rich/RichEditorWithAgent.tsx b/apps/admin/src/components/editor/rich/RichEditorWithAgent.tsx deleted file mode 100644 index faa22422d..000000000 --- a/apps/admin/src/components/editor/rich/RichEditorWithAgent.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { - applyAgentReviewBatch, - mountRichEditorWithAgent, -} from '@mx-admin/rich-react' -import { - computed, - defineComponent, - onBeforeUnmount, - onMounted, - ref, - Teleport, - toRef, - watch, -} from 'vue' -import type { ProviderGroup, SelectedModel } from '@haklex/rich-agent-chat' -import type { - AgentToolConfig, - ChatBubble, - ChatMessage, -} from '@haklex/rich-agent-core' -import type { RichEditorVariant } from '@haklex/rich-editor' -import type { - AgentLoopHandle, - ImageUpload, - RichEditorWithAgentHandle, -} from '@mx-admin/rich-react' -import type { - Klass, - LexicalEditor, - LexicalNode, - SerializedEditorState, -} from 'lexical' -import type { PropType, Ref } from 'vue' -import type { MetaFieldsSchema } from './agent-chat/composables/use-meta-tools' - -import { enrichmentApi } from '~/api/enrichment' -import { filesApi } from '~/api/files' -import { API_URL } from '~/constants/env' -import { useUIStore } from '~/stores/ui' - -import { AgentChatPanel } from './agent-chat/AgentChatPanel' -import { useAgentSetup } from './agent-chat/composables/use-agent-loop' -import { useReapply } from './agent-chat/composables/use-agent-reapply' -import { provideAgentStore } from './agent-chat/composables/use-agent-store' -import { - buildMetaSystemMessages, - buildMetaTools, -} from './agent-chat/composables/use-meta-tools' -import { useSessionManager } from './agent-chat/composables/use-session-manager' - -const fetchEnrichment = (url: string) => - enrichmentApi.resolve(url).catch(() => null) - -async function saveExcalidrawSnapshot( - snapshot: object, - existingRef?: string, -): Promise { - const blob = new Blob([JSON.stringify(snapshot)], { - type: 'application/json', - }) - const file = new File([blob], 'snapshot.excalidraw', { - type: 'application/json', - }) - - if (existingRef?.startsWith('ref:file/')) { - const name = existingRef.slice(9) - const result = await filesApi.update('file', name, file) - return `ref:file/${result.name}` - } - - const result = await filesApi.upload(file, 'file') - return `ref:file/${result.name}` -} - -export const RichEditorWithAgent = defineComponent({ - name: 'RichEditorWithAgent', - props: { - initialValue: Object as PropType, - theme: String as PropType<'dark' | 'light'>, - placeholder: String, - variant: String as PropType, - autoFocus: { type: Boolean, default: undefined }, - className: String, - contentClassName: String, - debounceMs: Number, - selfHostnames: Array as PropType, - extraNodes: Array as PropType>>, - editorStyle: Object as PropType>, - imageUpload: Function as PropType, - agentVisible: { type: Boolean, default: false }, - providerGroups: Array as PropType, - selectedModel: Object as PropType, - onSelectModel: Function as PropType<(model: SelectedModel) => void>, - initialBubbles: Array as PropType, - refId: String, - refType: String as PropType<'post' | 'note' | 'page'>, - metaFieldsSchema: Object as PropType, - getMetaFields: Function as PropType<() => Record>, - onMetaFieldsUpdate: Function as PropType< - (updates: Record) => void | Promise - >, - }, - emits: { - change: (_value: SerializedEditorState) => true, - textChange: (_text: string) => true, - submit: () => true, - editorReady: (_editor: LexicalEditor | null) => true, - }, - setup(props, { emit, expose }) { - const editorContainerRef = ref() - let handle: RichEditorWithAgentHandle | null = null - let editorInstance: LexicalEditor | null = null - let agentLoop: AgentLoopHandle | null = null - - const { store, provider, abort, retry } = useAgentSetup({ - providerGroups: toRef(props, 'providerGroups') as any, - selectedModel: toRef(props, 'selectedModel') as any, - initialBubbles: props.initialBubbles, - }) - provideAgentStore(store) - - function realAbort() { - if (agentLoop) { - agentLoop.abort() - } - abort() - } - - const reapply = useReapply({ - getEditor: () => editorInstance, - getReviewBatch: (batchId: string) => { - const reviewState = store.getState().reviewState - return reviewState?.batches.find( - (b: { id: string }) => b.id === batchId, - ) - }, - }) - - const metaTools = computed(() => { - if (!props.metaFieldsSchema) return undefined - return buildMetaTools({ - schema: props.metaFieldsSchema, - getFields: () => props.getMetaFields?.() ?? {}, - setFields: (updates) => props.onMetaFieldsUpdate?.(updates), - }) - }) - - const metaSystemMessages = computed(() => { - if (!props.metaFieldsSchema) return undefined - return buildMetaSystemMessages(props.metaFieldsSchema) - }) - - const sessionManager = useSessionManager({ - store, - refId: toRef(props, 'refId') as Ref, - refType: props.refType ?? 'post', - getModel: () => props.selectedModel?.modelId ?? '', - getProviderId: () => props.selectedModel?.providerId ?? '', - abortFn: realAbort, - }) - - const buildOptions = (resolvedTheme: 'dark' | 'light') => ({ - theme: resolvedTheme, - initialValue: props.initialValue, - placeholder: props.placeholder, - variant: props.variant, - autoFocus: props.autoFocus, - className: props.className, - contentClassName: props.contentClassName, - debounceMs: props.debounceMs, - selfHostnames: props.selfHostnames, - extraNodes: props.extraNodes, - editorStyle: props.editorStyle, - imageUpload: props.imageUpload, - store, - provider: provider.value, - tools: metaTools.value, - systemMessages: metaSystemMessages.value, - saveExcalidrawSnapshot, - apiUrl: API_URL, - fetchEnrichment, - onChange: (v: SerializedEditorState) => emit('change', v), - onSubmit: () => emit('submit'), - onEditorReady: (editor: LexicalEditor | null) => { - editorInstance = editor - emit('editorReady', editor) - }, - onAgentLoopReady: (loop: AgentLoopHandle | null) => { - agentLoop = loop - }, - onTextChange: (text: string) => emit('textChange', text), - }) - - const handleSend = (message: string) => { - if (!agentLoop) return - agentLoop.run(message).catch((err: unknown) => { - if ((err as Error).name === 'AbortError') return - const msg = err instanceof Error ? err.message : String(err) - store.getState().addBubble({ type: 'error', message: msg }) - store.getState().setStatus('idle') - }) - } - - const handleAbort = () => realAbort() - - const handleRetry = () => { - const msg = retry() - if (msg) handleSend(msg) - } - - const handleAcceptBatch = (batchId: string) => { - store.getState().acceptReviewBatch(batchId) - const reviewState = store.getState().reviewState - const batch = reviewState?.batches.find( - (b: { id: string }) => b.id === batchId, - ) - if (!batch || !editorInstance) return - applyAgentReviewBatch(editorInstance, batch) - } - - const handleRejectBatch = (batchId: string) => { - store.getState().rejectReviewBatch(batchId) - } - - onMounted(() => { - if (!editorContainerRef.value) return - const uiStore = useUIStore() - const resolveTheme = () => - props.theme ?? (uiStore.isDark ? 'dark' : 'light') - - handle = mountRichEditorWithAgent( - editorContainerRef.value, - buildOptions(resolveTheme()), - ) - - watch( - () => [ - props.theme, - uiStore.isDark, - props.placeholder, - props.variant, - props.autoFocus, - props.className, - props.contentClassName, - props.debounceMs, - props.selfHostnames, - props.extraNodes, - props.editorStyle, - props.imageUpload, - provider.value, - props.providerGroups, - props.selectedModel, - props.metaFieldsSchema, - props.getMetaFields, - props.onMetaFieldsUpdate, - ], - () => handle?.update(buildOptions(resolveTheme())), - ) - }) - - onBeforeUnmount(() => { - handle?.unmount() - handle = null - editorInstance = null - agentLoop = null - }) - - expose({ - focus: () => editorInstance?.focus(), - }) - - return () => ( - <> -
- {props.agentVisible && ( - - - reapply.applyReplayItem(item) - } - onReapplyGroup={(groupId: string, items: any[]) => - reapply.applyReplayGroup(groupId, items) - } - onReapplyBatch={(batchId: string) => - reapply.applyReplayBatch(batchId) - } - onSelectModel={(model: SelectedModel) => - props.onSelectModel?.(model) - } - onSwitchSession={(id: string) => sessionManager.switchSession(id)} - onCreateSession={() => sessionManager.createSession()} - onDeleteSession={(id: string) => sessionManager.deleteSession(id)} - onRenameSession={(id: string, title: string) => - sessionManager.renameSession(id, title) - } - onRetryLoad={() => sessionManager.loadSessions()} - /> - - )} - - ) - }, -}) diff --git a/apps/admin/src/components/editor/rich/agent-chat/AgentChatPanel.tsx b/apps/admin/src/components/editor/rich/agent-chat/AgentChatPanel.tsx deleted file mode 100644 index 68326649a..000000000 --- a/apps/admin/src/components/editor/rich/agent-chat/AgentChatPanel.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { NSpin } from 'naive-ui' -import { computed, defineComponent } from 'vue' -import type { ReviewBatch, ToolCallGroupItem } from '@haklex/rich-agent-core' -import type { PropType } from 'vue' -import type { ReplayStateMap } from './composables/use-agent-reapply' -import type { SessionMeta } from './composables/use-session-manager' -import type { ProviderGroup, SelectedModel } from './ModelSelector' - -import { agentStoreSelectors } from '@haklex/rich-agent-core' - -import { ChatInput } from './ChatInput' -import { ChatMessageList } from './ChatMessageList' -import { - useAgentStore, - useAgentStoreSelector, -} from './composables/use-agent-store' -import { ModelSelector } from './ModelSelector' -import { SessionHeader } from './SessionHeader' - -export const AgentChatPanel = defineComponent({ - name: 'AgentChatPanel', - props: { - providerGroups: { - type: Array as PropType, - required: true, - }, - selectedModel: { - type: Object as PropType, - default: null, - }, - replayState: { - type: Object as PropType, - default: () => ({}), - }, - isReplayableItem: { - type: Function as PropType<(item: ToolCallGroupItem) => boolean>, - default: undefined, - }, - sessions: { - type: Array as PropType, - default: () => [], - }, - activeSessionId: { - type: String as PropType, - default: null, - }, - isSessionLoading: { - type: Boolean, - default: false, - }, - isHydrating: { - type: Boolean, - default: false, - }, - loadError: { - type: Boolean, - default: false, - }, - }, - emits: [ - 'send', - 'abort', - 'selectModel', - 'acceptBatch', - 'rejectBatch', - 'retry', - 'reapplyItem', - 'reapplyGroup', - 'reapplyBatch', - 'switchSession', - 'createSession', - 'deleteSession', - 'renameSession', - 'retryLoad', - ], - setup(props, { emit }) { - const store = useAgentStore() - const bubbles = useAgentStoreSelector(agentStoreSelectors.bubbles) - const status = useAgentStoreSelector(agentStoreSelectors.status) - const reviewState = useAgentStoreSelector(agentStoreSelectors.reviewState) - const pinnedSelection = useAgentStoreSelector( - agentStoreSelectors.pinnedSelection, - ) - - const isRunning = computed( - () => status.value !== 'idle' && status.value !== 'done', - ) - const hasModel = computed(() => props.selectedModel !== null) - - function getBatch(batchId: string): ReviewBatch | undefined { - return reviewState.value?.batches.find( - (b: ReviewBatch) => b.id === batchId, - ) - } - - function handleSend(message: string) { - store.getState().addBubble({ type: 'user', content: message }) - emit('send', message) - } - - return () => ( -
- emit('switchSession', id)} - onCreateSession={() => emit('createSession')} - onDeleteSession={(id: string) => emit('deleteSession', id)} - onRenameSession={(id: string, title: string) => - emit('renameSession', id, title) - } - onRetry={() => emit('retryLoad')} - /> - {props.isHydrating ? ( -
- -
- ) : ( - <> - emit('acceptBatch', id)} - onRejectBatch={(id: string) => emit('rejectBatch', id)} - onReapplyItem={(itemId: string, item: ToolCallGroupItem) => - emit('reapplyItem', itemId, item) - } - onReapplyGroup={(groupId: string, items: ToolCallGroupItem[]) => - emit('reapplyGroup', groupId, items) - } - onReapplyBatch={(batchId: string) => - emit('reapplyBatch', batchId) - } - onRetry={() => emit('retry')} - /> - emit('abort')} - onDismissSelection={() => store.getState().clearPinnedSelection()} - > - {{ - modelSelector: () => ( - - emit('selectModel', model) - } - /> - ), - }} - - - )} -
- ) - }, -}) diff --git a/apps/admin/src/components/editor/rich/agent-chat/ChatInput.tsx b/apps/admin/src/components/editor/rich/agent-chat/ChatInput.tsx deleted file mode 100644 index 21db56178..000000000 --- a/apps/admin/src/components/editor/rich/agent-chat/ChatInput.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { ArrowUp, Square, Type, X } from 'lucide-vue-next' -import { defineComponent, ref } from 'vue' -import type { - AgentStoreStatus, - CapturedSelection, -} from '@haklex/rich-agent-core' -import type { PropType } from 'vue' - -const STATUS_LABELS: Partial> = { - thinking: 'Thinking...', - writing: 'Writing...', - running: 'Processing...', - calling_tool: 'Calling tool...', -} - -export const ChatInput = defineComponent({ - name: 'ChatInput', - props: { - disabled: { type: Boolean, default: false }, - isRunning: { type: Boolean, default: false }, - pinnedSelection: { - type: Object as PropType, - default: null, - }, - status: { type: String as PropType, default: 'idle' }, - }, - emits: ['send', 'abort', 'dismissSelection'], - setup(props, { emit, slots }) { - const input = ref('') - const textareaRef = ref() - const isComposing = ref(false) - - function handleSend() { - const trimmed = input.value.trim() - if (!trimmed) return - emit('send', trimmed) - input.value = '' - if (textareaRef.value) textareaRef.value.style.height = 'auto' - } - - function handleKeyDown(e: KeyboardEvent) { - if (isComposing.value) return - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - if (!props.disabled && !props.isRunning) handleSend() - } - } - - function handleInput(e: Event) { - const ta = e.target as HTMLTextAreaElement - input.value = ta.value - ta.style.height = 'auto' - ta.style.height = `${Math.min(ta.scrollHeight, 240)}px` - } - - return () => { - const isAbortMode = Boolean(props.isRunning) - const statusLabel = props.status ? STATUS_LABELS[props.status] : undefined - - return ( -
- {props.pinnedSelection && ( -
- - - {props.pinnedSelection.type === 'text' - ? `"${props.pinnedSelection.text.length > 60 ? `${props.pinnedSelection.text.slice(0, 60)}…` : props.pinnedSelection.text}"` - : `${props.pinnedSelection.blockIds.length} block${props.pinnedSelection.blockIds.length > 1 ? 's' : ''} selected`} - - -
- )} - {props.isRunning && statusLabel && ( -
- - - - - {statusLabel} -
- )} -
-