├── public
├── favicon.png
└── vite.svg
├── postcss.config.js
├── tsconfig.json
├── src
├── main.tsx
├── components
│ ├── PromptEditor
│ │ ├── types.ts
│ │ ├── MessageIcon.tsx
│ │ ├── TabButton.tsx
│ │ ├── EditorSkeleton.tsx
│ │ ├── APITab.tsx
│ │ ├── DefinitionTab.tsx
│ │ └── ChatTestingContent.tsx
│ ├── ErrorBoundary.tsx
│ ├── Toast.tsx
│ ├── MyPromptItemSkeleton.tsx
│ ├── EditorHeader.tsx
│ └── ConfirmDialog.tsx
├── App.css
├── App.tsx
├── contexts
│ └── ToastContext.tsx
├── index.css
├── assets
│ └── react.svg
├── utils
│ └── encryption.ts
├── services
│ └── localStorage.ts
└── pages
│ ├── PromptEditor.tsx
│ └── MyPrompts.tsx
├── vite.config.ts
├── .gitignore
├── eslint.config.js
├── tsconfig.node.json
├── index.html
├── tsconfig.app.json
├── .env.example
├── .env.README.md
├── package.json
├── README.md
└── tailwind.config.js
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChinaSiro/open-prompt-manager-for-prompub/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import "./index.css";
4 | import App from "./App.tsx";
5 |
6 | createRoot(document.getElementById("root")!).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path";
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | "@": path.resolve(__dirname, "./src"),
11 | },
12 | },
13 | server: {
14 | host: "0.0.0.0",
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Environment variables
16 | .env
17 | .env.local
18 |
19 | # Editor directories and files
20 | .vscode/*
21 | !.vscode/extensions.json
22 | .idea
23 | .DS_Store
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 |
30 | .claude
31 | CLAUDE.md
32 |
--------------------------------------------------------------------------------
/src/components/PromptEditor/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * PromptEditor 共享类型定义
3 | */
4 |
5 | export interface PromptFormData {
6 | title: string;
7 | description: string;
8 | content: string;
9 | apiUrl: string;
10 | apiKey: string;
11 | }
12 |
13 | export interface ChatMessage {
14 | role: "user" | "assistant" | "system";
15 | content: string;
16 | }
17 |
18 | export interface Model {
19 | id: string;
20 | object: string;
21 | created?: number;
22 | owned_by?: string;
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/PromptEditor/MessageIcon.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * MessageIcon 组件
3 | */
4 |
5 | export function MessageIcon() {
6 | return (
7 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import globals from "globals";
3 | import reactHooks from "eslint-plugin-react-hooks";
4 | import reactRefresh from "eslint-plugin-react-refresh";
5 | import tseslint from "typescript-eslint";
6 | import { defineConfig, globalIgnores } from "eslint/config";
7 |
8 | export default defineConfig([
9 | globalIgnores(["dist"]),
10 | {
11 | files: ["**/*.{ts,tsx}"],
12 | extends: [
13 | js.configs.recommended,
14 | tseslint.configs.recommended,
15 | reactHooks.configs["recommended-latest"],
16 | reactRefresh.configs.vite,
17 | ],
18 | languageOptions: {
19 | ecmaVersion: 2020,
20 | globals: globals.browser,
21 | },
22 | },
23 | ]);
24 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2023",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "types": ["node"],
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "erasableSyntaxOnly": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["vite.config.ts"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/PromptEditor/TabButton.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * TabButton 组件
3 | */
4 |
5 | interface TabButtonProps {
6 | icon: React.ReactNode;
7 | label: string;
8 | active: boolean;
9 | onClick: () => void;
10 | }
11 |
12 | export function TabButton({ icon, label, active, onClick }: TabButtonProps) {
13 | return (
14 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
2 | import { ToastProvider } from "./contexts/ToastContext";
3 | import { ErrorBoundary } from "./components/ErrorBoundary";
4 | import { MyPrompts } from "./pages/MyPrompts";
5 | import { PromptEditor } from "./pages/PromptEditor";
6 | import "./index.css";
7 |
8 | function App() {
9 | return (
10 |
11 |
12 |
13 |
14 | } />
15 | } />
16 | } />
17 | } />
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default App;
26 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | PromPub - 发现和管理 AI 提示词
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2022",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "types": ["vite/client"],
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "verbatimModuleSyntax": true,
15 | "moduleDetection": "force",
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "erasableSyntaxOnly": true,
24 | "noFallthroughCasesInSwitch": true,
25 | "noUncheckedSideEffectImports": true,
26 |
27 | /* Path Aliases */
28 | "baseUrl": ".",
29 | "paths": {
30 | "@/*": ["./src/*"]
31 | }
32 | },
33 | "include": ["src"]
34 | }
35 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # 分类配置(JSON格式)
2 | # 你可以修改这些分类来自定义你的提示词管理器
3 | VITE_CATEGORIES='[
4 | {"id": 1, "name": "写作", "slug": "writing", "description": "写作相关提示词"},
5 | {"id": 2, "name": "编程", "slug": "coding", "description": "编程相关提示词"},
6 | {"id": 3, "name": "创意", "slug": "creative", "description": "创意相关提示词"},
7 | {"id": 4, "name": "商务", "slug": "business", "description": "商务相关提示词"},
8 | {"id": 5, "name": "教育", "slug": "education", "description": "教育相关提示词"},
9 | {"id": 6, "name": "其他", "slug": "other", "description": "其他提示词"}
10 | ]'
11 |
12 | # AI 模型配置(JSON格式)
13 | # 你可以修改这些模型来自定义你的提示词管理器
14 | VITE_AI_MODELS='[
15 | {"id": 1, "slug": "gpt-4", "name": "GPT-4", "provider": "OpenAI"},
16 | {"id": 2, "slug": "gpt-3.5-turbo", "name": "GPT-3.5 Turbo", "provider": "OpenAI"},
17 | {"id": 3, "slug": "claude-3-opus", "name": "Claude 3 Opus", "provider": "Anthropic"},
18 | {"id": 4, "slug": "claude-3-sonnet", "name": "Claude 3 Sonnet", "provider": "Anthropic"},
19 | {"id": 5, "slug": "gemini-pro", "name": "Gemini Pro", "provider": "Google"}
20 | ]'
21 |
--------------------------------------------------------------------------------
/.env.README.md:
--------------------------------------------------------------------------------
1 | # 环境变量配置说明
2 |
3 | ## 如何自定义分类和 AI 模型
4 |
5 | ### 1. 分类配置 (VITE_CATEGORIES)
6 |
7 | 在 `.env` 文件中修改 `VITE_CATEGORIES` 变量来自定义分类:
8 |
9 | ```bash
10 | VITE_CATEGORIES='[
11 | {"id": 1, "name": "写作", "slug": "writing", "description": "写作相关提示词"},
12 | {"id": 2, "name": "编程", "slug": "coding", "description": "编程相关提示词"}
13 | ]'
14 | ```
15 |
16 | **字段说明:**
17 | - `id`: 唯一标识符(数字)
18 | - `name`: 分类名称(显示给用户)
19 | - `slug`: URL 友好的标识符(英文,用于筛选)
20 | - `description`: 分类描述
21 |
22 | ### 2. AI 模型配置 (VITE_AI_MODELS)
23 |
24 | 在 `.env` 文件中修改 `VITE_AI_MODELS` 变量来自定义 AI 模型:
25 |
26 | ```bash
27 | VITE_AI_MODELS='[
28 | {"id": 1, "slug": "gpt-4", "name": "GPT-4", "provider": "OpenAI"},
29 | {"id": 2, "slug": "claude-3-opus", "name": "Claude 3 Opus", "provider": "Anthropic"}
30 | ]'
31 | ```
32 |
33 | **字段说明:**
34 | - `id`: 唯一标识符(数字)
35 | - `slug`: URL 友好的标识符(英文,用于标识模型)
36 | - `name`: 模型名称(显示给用户)
37 | - `provider`: 提供商名称(如 OpenAI、Anthropic、Google)
38 |
39 | ### 3. 使用步骤
40 |
41 | 1. 复制 `.env.example` 为 `.env`
42 | 2. 根据需要修改 `VITE_CATEGORIES` 和 `VITE_AI_MODELS`
43 | 3. 重启开发服务器 `npm run dev`
44 |
45 | ### 4. 注意事项
46 |
47 | - JSON 格式必须正确,否则会使用默认配置
48 | - 修改后需要重启开发服务器
49 | - `id` 必须唯一
50 | - `slug` 建议使用英文,用于 URL 和数据存储
51 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "flowgpt-clone",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite --host 0.0.0.0",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "class-variance-authority": "^0.7.1",
14 | "clsx": "^2.1.1",
15 | "dompurify": "^3.3.0",
16 | "lucide-react": "^0.546.0",
17 | "react": "^19.1.1",
18 | "react-dom": "^19.1.1",
19 | "react-router-dom": "^7.9.4",
20 | "tailwind-merge": "^3.3.1"
21 | },
22 | "devDependencies": {
23 | "@eslint/js": "^9.36.0",
24 | "@tailwindcss/postcss": "^4.1.14",
25 | "@types/dompurify": "^3.2.0",
26 | "@types/node": "^24.8.1",
27 | "@types/react": "^19.1.16",
28 | "@types/react-dom": "^19.1.9",
29 | "@vitejs/plugin-react": "^5.0.4",
30 | "autoprefixer": "^10.4.21",
31 | "eslint": "^9.36.0",
32 | "eslint-plugin-react-hooks": "^5.2.0",
33 | "eslint-plugin-react-refresh": "^0.4.22",
34 | "globals": "^16.4.0",
35 | "postcss": "^8.5.6",
36 | "prettier": "^3.6.2",
37 | "puppeteer": "^24.25.0",
38 | "tailwindcss": "3.4.17",
39 | "typescript": "~5.9.3",
40 | "typescript-eslint": "^8.45.0",
41 | "vite": "^7.1.7"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/contexts/ToastContext.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-refresh/only-export-components */
2 | import React, { createContext, useContext, useState, useCallback } from "react";
3 | import { Toast } from "../components/Toast";
4 |
5 | interface ToastContextType {
6 | showToast: (message: string, type: "success" | "error") => void;
7 | }
8 |
9 | const ToastContext = createContext(undefined);
10 |
11 | export function ToastProvider({ children }: { children: React.ReactNode }) {
12 | const [toast, setToast] = useState<{
13 | message: string;
14 | type: "success" | "error";
15 | } | null>(null);
16 |
17 | const showToast = useCallback(
18 | (message: string, type: "success" | "error") => {
19 | setToast({ message, type });
20 | },
21 | [],
22 | );
23 |
24 | const handleClose = useCallback(() => {
25 | setToast(null);
26 | }, []);
27 |
28 | return (
29 |
30 | {children}
31 | {toast && (
32 |
37 | )}
38 |
39 | );
40 | }
41 |
42 | export function useToast() {
43 | const context = useContext(ToastContext);
44 | if (context === undefined) {
45 | throw new Error("useToast must be used within a ToastProvider");
46 | }
47 | return context;
48 | }
49 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer utilities {
6 | .no-scrollbar::-webkit-scrollbar {
7 | display: none;
8 | }
9 | .no-scrollbar {
10 | -ms-overflow-style: none;
11 | scrollbar-width: none;
12 | }
13 | }
14 |
15 | @layer base {
16 | :root {
17 | --color-base-main-950: 10 10 10;
18 | --background: 0 0% 4%;
19 | --foreground: 0 0% 95%;
20 | --card: 0 0% 12%;
21 | --card-foreground: 0 0% 95%;
22 | --popover: 0 0% 12%;
23 | --popover-foreground: 0 0% 95%;
24 | --primary: 48 100% 50%;
25 | --primary-foreground: 0 0% 0%;
26 | --secondary: 0 0% 15%;
27 | --secondary-foreground: 0 0% 95%;
28 | --muted: 0 0% 15%;
29 | --muted-foreground: 0 0% 60%;
30 | --accent: 48 100% 50%;
31 | --accent-foreground: 0 0% 0%;
32 | --destructive: 0 84% 60%;
33 | --destructive-foreground: 0 0% 95%;
34 | --border: 0 0% 20%;
35 | --input: 0 0% 20%;
36 | --ring: 48 100% 50%;
37 | --radius: 0.5rem;
38 | }
39 | }
40 |
41 | @layer base {
42 | * {
43 | @apply border-border;
44 | }
45 | body {
46 | @apply bg-background text-foreground;
47 | font-feature-settings:
48 | "rlig" 1,
49 | "calt" 1;
50 | }
51 | }
52 |
53 | @layer utilities {
54 | .scrollbar-hide::-webkit-scrollbar {
55 | display: none;
56 | }
57 | .scrollbar-hide {
58 | -ms-overflow-style: none;
59 | scrollbar-width: none;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PromPub 开源提示词管理器
2 |
3 | > Prompt 的简写 + Pub 酒馆
4 |
5 | 有太多的优质提示词不知道安放在什么地方,所以诞生了这个提示词管理工具。
6 |
7 | ## 项目地址
8 |
9 | - **线上 Demo**: [https://prompub.com](https://prompub.com)
10 | - **开源仓库**: [https://github.com/ChinaSiro/open-prompt-manager-for-prompub](https://github.com/ChinaSiro/open-prompt-manager-for-prompub)
11 |
12 | ## 架构说明
13 |
14 | - **线上版本**: 后端使用 WP API(过于复杂,未开源)
15 | - **开源版本**: 使用 LocalStorage 本地存储(简单易用)
16 |
17 | ## 基本功能
18 |
19 | ### 私人仓库 - 管理你的海量提示词
20 | - 搜索功能 - 快速找到你需要的提示词
21 | - 分类筛选 - 写作/编程/创意/商务/教育等
22 | - 模型筛选 - 支持 GPT/Claude/Gemini 等主流模型
23 | - 状态管理 - 草稿/已发布状态切换
24 |
25 | ### 创作中心 - 实时调试提示词
26 | - 实时预览 - 编写提示词的同时立即测试效果
27 | - 对话调试 - 系统提示词实时更新到对话中
28 | - 第三方 API - 支持任意兼容 OpenAI 格式的中转 API
29 | - 安全保障 - **API KEY 不上传服务器,仅存储在浏览器本地**
30 |
31 | ## 技术栈
32 |
33 | - **框架**: React 19.1 + TypeScript
34 | - **构建工具**: Vite 7.1
35 | - **样式**: Tailwind CSS 4.1
36 | - **路由**: React Router 7.9
37 | - **存储**: LocalStorage (浏览器本地存储)
38 |
39 |
40 | ## 快速开始
41 |
42 | ### 安装依赖
43 |
44 | ```bash
45 | npm install
46 | ```
47 |
48 | ### 配置分类和模型(可选)
49 |
50 | 复制 `.env.example` 为 `.env`,根据需要自定义分类和 AI 模型:
51 |
52 | ```bash
53 | # 查看配置说明
54 | cat .env.README.md
55 | ```
56 |
57 | ### 启动开发服务器
58 |
59 | ```bash
60 | npm run dev
61 | ```
62 |
63 | 服务器将运行在 `http://localhost:5173`
64 |
65 | ### 构建生产版本
66 |
67 | ```bash
68 | npm run build
69 | ```
70 |
71 | ## 注意事项
72 |
73 | - **隐私安全**: 所有数据(提示词、API KEY)仅存储在本地浏览器,不会上传到任何服务器
74 | - **自定义配置**: 可通过 `.env` 文件自定义分类和 AI 模型列表
75 |
76 | ## 开发计划 v1.1
77 |
78 | - [ ] 数据导入/导出
79 | - [ ] JSON 格式导入/导出
80 | - [ ] 单个提示词导出
81 | - [ ] 批量导入/导出
82 | - [ ] 更多功能待定...
83 |
84 | ## 贡献与反馈
85 |
86 | 欢迎提交 Issue 或 Pull Request!
87 |
88 | ## 许可证
89 |
90 | MIT License - 本项目开源免费使用
91 |
--------------------------------------------------------------------------------
/src/components/PromptEditor/EditorSkeleton.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * EditorSkeleton 组件 - 加载骨架屏
3 | */
4 |
5 | export function EditorSkeleton() {
6 | return (
7 |
8 |
9 |
10 | {/* Avatar Skeleton */}
11 |
21 |
22 | {/* Name Skeleton */}
23 |
28 |
29 | {/* Description Skeleton */}
30 |
35 |
36 | {/* Content Skeleton */}
37 |
42 |
43 | {/* Welcome Message Skeleton */}
44 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | darkMode: ["class"],
4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
5 | theme: {
6 | extend: {
7 | screens: {
8 | xs: "480px",
9 | },
10 | fontSize: {
11 | "2xs": "0.625rem", // 10px
12 | "1.5xs": "0.6875rem", // 11px
13 | "2sm": "0.8125rem", // 13px
14 | },
15 | borderRadius: {
16 | lg: "var(--radius)",
17 | md: "calc(var(--radius) - 2px)",
18 | sm: "calc(var(--radius) - 4px)",
19 | },
20 | colors: {
21 | fgMain: {
22 | 950: "rgb(var(--color-base-main-950) / )",
23 | },
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | card: {
27 | DEFAULT: "hsl(var(--card))",
28 | foreground: "hsl(var(--card-foreground))",
29 | },
30 | popover: {
31 | DEFAULT: "hsl(var(--popover))",
32 | foreground: "hsl(var(--popover-foreground))",
33 | },
34 | primary: {
35 | DEFAULT: "hsl(var(--primary))",
36 | foreground: "hsl(var(--primary-foreground))",
37 | },
38 | secondary: {
39 | DEFAULT: "hsl(var(--secondary))",
40 | foreground: "hsl(var(--secondary-foreground))",
41 | },
42 | muted: {
43 | DEFAULT: "hsl(var(--muted))",
44 | foreground: "hsl(var(--muted-foreground))",
45 | },
46 | accent: {
47 | DEFAULT: "hsl(var(--accent))",
48 | foreground: "hsl(var(--accent-foreground))",
49 | },
50 | destructive: {
51 | DEFAULT: "hsl(var(--destructive))",
52 | foreground: "hsl(var(--destructive-foreground))",
53 | },
54 | border: "hsl(var(--border))",
55 | input: "hsl(var(--input))",
56 | ring: "hsl(var(--ring))",
57 | chart: {
58 | 1: "hsl(var(--chart-1))",
59 | 2: "hsl(var(--chart-2))",
60 | 3: "hsl(var(--chart-3))",
61 | 4: "hsl(var(--chart-4))",
62 | 5: "hsl(var(--chart-5))",
63 | },
64 | },
65 | },
66 | },
67 | plugins: [],
68 | };
69 |
--------------------------------------------------------------------------------
/src/components/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, type ReactNode } from "react";
2 |
3 | interface Props {
4 | children: ReactNode;
5 | }
6 |
7 | interface State {
8 | hasError: boolean;
9 | error: Error | null;
10 | errorInfo: React.ErrorInfo | null;
11 | }
12 |
13 | export class ErrorBoundary extends Component {
14 | constructor(props: Props) {
15 | super(props);
16 | this.state = {
17 | hasError: false,
18 | error: null,
19 | errorInfo: null,
20 | };
21 | }
22 |
23 | static getDerivedStateFromError(error: Error): State {
24 | return {
25 | hasError: true,
26 | error,
27 | errorInfo: null,
28 | };
29 | }
30 |
31 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
32 | console.error("ErrorBoundary caught an error:", error, errorInfo);
33 | this.setState({
34 | error,
35 | errorInfo,
36 | });
37 | }
38 |
39 | render() {
40 | if (this.state.hasError) {
41 | return (
42 |
43 |
44 |
出错了
45 |
页面渲染时发生错误:
46 |
47 |
48 | {this.state.error?.toString()}
49 |
50 |
51 | {this.state.errorInfo && (
52 |
53 |
54 | 详细信息
55 |
56 |
57 |
58 | {this.state.errorInfo.componentStack}
59 |
60 |
61 |
62 | )}
63 |
69 |
70 |
71 | );
72 | }
73 |
74 | return this.props.children;
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/Toast.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { X, CheckCircle, AlertCircle } from "lucide-react";
3 |
4 | interface ToastProps {
5 | message: string;
6 | type: "success" | "error";
7 | onClose: () => void;
8 | duration?: number;
9 | }
10 |
11 | export function Toast({ message, type, onClose, duration = 3000 }: ToastProps) {
12 | const [progress, setProgress] = useState(100);
13 |
14 | useEffect(() => {
15 | const timer = setTimeout(() => {
16 | onClose();
17 | }, duration);
18 |
19 | // 进度条动画
20 | const interval = setInterval(() => {
21 | setProgress((prev) => {
22 | const newProgress = prev - 100 / (duration / 50);
23 | return newProgress > 0 ? newProgress : 0;
24 | });
25 | }, 50);
26 |
27 | return () => {
28 | clearTimeout(timer);
29 | clearInterval(interval);
30 | };
31 | }, [duration, onClose]);
32 |
33 | return (
34 |
35 |
42 | {/* 进度条 */}
43 |
51 |
52 |
53 | {type === "success" ? (
54 |
59 | ) : (
60 |
61 | )}
62 |
63 |
64 |
65 | {message}
66 |
67 |
68 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/MyPromptItemSkeleton.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 我的提示词列表项骨架屏组件
3 | */
4 | export function MyPromptItemSkeleton() {
5 | return (
6 |
7 |
8 | {/* Left: Thumbnail Skeleton */}
9 |
12 |
13 | {/* Middle: Content Skeleton */}
14 |
15 | {/* Title and Visibility */}
16 |
17 |
18 | {/* Title skeleton */}
19 |
20 | {/* Visibility badge skeleton */}
21 |
22 |
23 | {/* Tags skeleton */}
24 |
28 |
29 |
30 | {/* Actions skeleton */}
31 |
36 |
37 |
38 | {/* Right: Stats Skeleton */}
39 |
55 |
56 |
57 | );
58 | }
59 |
60 | /**
61 | * 我的提示词列表骨架屏
62 | * @param count 骨架屏数量,默认5个
63 | */
64 | export function MyPromptsListSkeleton({ count = 5 }: { count?: number }) {
65 | return (
66 |
67 | {Array.from({ length: count }).map((_, index) => (
68 |
69 | ))}
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/EditorHeader.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "react-router-dom";
2 | import { Github } from "lucide-react";
3 |
4 | export function EditorHeader() {
5 | const navigate = useNavigate();
6 |
7 | const handleNewPrompt = () => {
8 | navigate("/prompt-editor");
9 | };
10 |
11 | const handleGoToRepository = () => {
12 | navigate("/my-prompts");
13 | };
14 |
15 | const handleLogoClick = () => {
16 | navigate("/my-prompts");
17 | };
18 |
19 | return (
20 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/utils/encryption.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 前端加密/解密工具
3 | * 使用 AES-GCM 加密算法
4 | */
5 |
6 | // 固定的应用级盐值
7 | const APP_SALT = "flowgpt-encryption-salt-v1";
8 |
9 | // 获取或生成设备专用密钥
10 | async function getDeviceKey(): Promise {
11 | const DEVICE_KEY_STORAGE = "flowgpt_device_key";
12 |
13 | // 尝试从 localStorage 获取现有密钥
14 | let deviceKey = localStorage.getItem(DEVICE_KEY_STORAGE);
15 |
16 | if (!deviceKey) {
17 | // 如果不存在,生成一个新的随机密钥
18 | const randomBytes = window.crypto.getRandomValues(new Uint8Array(32));
19 | deviceKey = Array.from(randomBytes)
20 | .map((b) => b.toString(16).padStart(2, "0"))
21 | .join("");
22 |
23 | // 保存到 localStorage
24 | localStorage.setItem(DEVICE_KEY_STORAGE, deviceKey);
25 | }
26 |
27 | return deviceKey;
28 | }
29 |
30 | // 生成或获取加密密钥
31 | async function getEncryptionKey(): Promise {
32 | // 使用设备专用密钥 + 应用盐值
33 | const deviceKey = await getDeviceKey();
34 | const keyMaterial = await window.crypto.subtle.importKey(
35 | "raw",
36 | new TextEncoder().encode(`${deviceKey}-${APP_SALT}`),
37 | { name: "PBKDF2" },
38 | false,
39 | ["deriveBits", "deriveKey"],
40 | );
41 |
42 | // 派生真正的加密密钥
43 | return window.crypto.subtle.deriveKey(
44 | {
45 | name: "PBKDF2",
46 | salt: new TextEncoder().encode(APP_SALT),
47 | iterations: 100000,
48 | hash: "SHA-256",
49 | },
50 | keyMaterial,
51 | { name: "AES-GCM", length: 256 },
52 | false,
53 | ["encrypt", "decrypt"],
54 | );
55 | }
56 |
57 | /**
58 | * 加密字符串
59 | */
60 | export async function encryptString(text: string): Promise {
61 | try {
62 | const key = await getEncryptionKey();
63 | const iv = window.crypto.getRandomValues(new Uint8Array(12));
64 | const encodedText = new TextEncoder().encode(text);
65 |
66 | const encryptedData = await window.crypto.subtle.encrypt(
67 | {
68 | name: "AES-GCM",
69 | iv: iv,
70 | },
71 | key,
72 | encodedText,
73 | );
74 |
75 | // 组合 IV 和加密数据
76 | const combined = new Uint8Array(iv.length + encryptedData.byteLength);
77 | combined.set(iv, 0);
78 | combined.set(new Uint8Array(encryptedData), iv.length);
79 |
80 | // 转换为 Base64
81 | return btoa(String.fromCharCode(...combined));
82 | } catch (error) {
83 | console.error("加密失败:", error);
84 | throw new Error("加密失败");
85 | }
86 | }
87 |
88 | /**
89 | * 解密字符串
90 | */
91 | export async function decryptString(encryptedText: string): Promise {
92 | try {
93 | const key = await getEncryptionKey();
94 |
95 | // 从 Base64 解码
96 | const combined = Uint8Array.from(atob(encryptedText), (c) =>
97 | c.charCodeAt(0),
98 | );
99 |
100 | // 分离 IV 和加密数据
101 | const iv = combined.slice(0, 12);
102 | const encryptedData = combined.slice(12);
103 |
104 | const decryptedData = await window.crypto.subtle.decrypt(
105 | {
106 | name: "AES-GCM",
107 | iv: iv,
108 | },
109 | key,
110 | encryptedData,
111 | );
112 |
113 | return new TextDecoder().decode(decryptedData);
114 | } catch (error) {
115 | console.error("解密失败:", error);
116 | throw new Error("解密失败");
117 | }
118 | }
119 |
120 | /**
121 | * 保存加密的配置到 localStorage
122 | */
123 | export async function saveEncryptedConfig(
124 | key: string,
125 | value: string,
126 | ): Promise {
127 | const encrypted = await encryptString(value);
128 | localStorage.setItem(key, encrypted);
129 | }
130 |
131 | /**
132 | * 从 localStorage 读取并解密配置
133 | */
134 | export async function getEncryptedConfig(key: string): Promise {
135 | const encrypted = localStorage.getItem(key);
136 | if (!encrypted) return null;
137 |
138 | try {
139 | return await decryptString(encrypted);
140 | } catch (error) {
141 | console.error(`解密配置 ${key} 失败:`, error);
142 | return null;
143 | }
144 | }
145 |
146 | /**
147 | * 删除加密的配置
148 | */
149 | export function removeEncryptedConfig(key: string): void {
150 | localStorage.removeItem(key);
151 | }
152 |
--------------------------------------------------------------------------------
/src/components/ConfirmDialog.tsx:
--------------------------------------------------------------------------------
1 | import { X } from "lucide-react";
2 |
3 | interface ConfirmDialogProps {
4 | isOpen: boolean;
5 | onClose: () => void;
6 | onConfirm: () => void;
7 | title?: string;
8 | message?: string;
9 | confirmText?: string;
10 | cancelText?: string;
11 | type?: "warning" | "delete" | "publish";
12 | }
13 |
14 | export function ConfirmDialog({
15 | isOpen,
16 | onClose,
17 | onConfirm,
18 | title = "Are you sure?",
19 | message = "You can update the prompt anytime, but careful, we have a auto-save feature that will overwrite your changes.",
20 | confirmText = "Edit",
21 | cancelText = "Exit",
22 | type = "warning",
23 | }: ConfirmDialogProps) {
24 | if (!isOpen) return null;
25 |
26 | const getIconConfig = () => {
27 | switch (type) {
28 | case "delete":
29 | return {
30 | bgColor: "#ef4444",
31 | icon: (
32 |
35 | ),
36 | emoji: "🗑️",
37 | };
38 | case "publish":
39 | return {
40 | bgColor: "#3fda8c",
41 | icon: (
42 |
45 | ),
46 | emoji: "🚀",
47 | };
48 | default: // warning
49 | return {
50 | bgColor: "#ffd700",
51 | icon: (
52 |
58 | ),
59 | emoji: "😟",
60 | };
61 | }
62 | };
63 |
64 | const iconConfig = getIconConfig();
65 |
66 | return (
67 |
68 | {/* Backdrop */}
69 |
73 |
74 | {/* Dialog */}
75 |
76 | {/* Close Button */}
77 |
83 |
84 | {/* Content */}
85 |
86 | {/* Title */}
87 |
88 | {title}
89 |
90 |
91 | {/* Icon and Message */}
92 |
93 | {/* Icon with Card */}
94 |
95 | {/* Card */}
96 |
97 | {/* Lines on card */}
98 |
103 |
104 |
105 | {/* Icon Circle */}
106 |
110 |
111 | {iconConfig.icon}
112 |
113 |
114 |
115 | {/* Emoji */}
116 |
117 | {iconConfig.emoji}
118 |
119 |
120 |
121 | {/* Message */}
122 |
123 | {message}
124 |
125 |
126 |
127 | {/* Buttons */}
128 |
129 |
135 |
148 |
149 |
150 |
151 |
152 | );
153 | }
154 |
--------------------------------------------------------------------------------
/src/services/localStorage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * LocalStorage 服务 - 管理 prompts 和 categories
3 | */
4 |
5 | // ============= 类型定义 =============
6 |
7 | export interface Category {
8 | id: number;
9 | name: string;
10 | slug: string;
11 | description: string;
12 | }
13 |
14 | export interface AIModel {
15 | id: number;
16 | slug: string;
17 | name: string;
18 | provider: string;
19 | }
20 |
21 | export interface Prompt {
22 | id: number;
23 | title: string;
24 | description: string;
25 | content: string;
26 | image?: string; // 图片 URL 或 base64
27 | categoryId?: number;
28 | aiModelSlug?: string;
29 | status: "draft" | "published";
30 | visibility: "public" | "private";
31 | statistics: {
32 | views: number;
33 | uses: number;
34 | favorites: number;
35 | };
36 | createdAt: string;
37 | updatedAt: string;
38 | }
39 |
40 | // ============= 存储键 =============
41 |
42 | const STORAGE_KEYS = {
43 | PROMPTS: "prompts",
44 | CATEGORIES: "categories",
45 | AI_MODELS: "ai_models",
46 | NEXT_ID: "next_prompt_id",
47 | };
48 |
49 | // ============= 默认数据(从环境变量读取) =============
50 |
51 | // 从环境变量读取分类配置
52 | function loadCategoriesFromEnv(): Category[] {
53 | try {
54 | const envCategories = import.meta.env.VITE_CATEGORIES;
55 | if (envCategories) {
56 | return JSON.parse(envCategories);
57 | }
58 | } catch (error) {
59 | console.error("Failed to parse VITE_CATEGORIES:", error);
60 | }
61 |
62 | // 默认分类(如果环境变量未设置)
63 | return [
64 | { id: 1, name: "写作", slug: "writing", description: "写作相关提示词" },
65 | { id: 2, name: "编程", slug: "coding", description: "编程相关提示词" },
66 | { id: 3, name: "创意", slug: "creative", description: "创意相关提示词" },
67 | { id: 4, name: "商务", slug: "business", description: "商务相关提示词" },
68 | { id: 5, name: "教育", slug: "education", description: "教育相关提示词" },
69 | { id: 6, name: "其他", slug: "other", description: "其他提示词" },
70 | ];
71 | }
72 |
73 | // 从环境变量读取 AI 模型配置
74 | function loadAIModelsFromEnv(): AIModel[] {
75 | try {
76 | const envModels = import.meta.env.VITE_AI_MODELS;
77 | if (envModels) {
78 | return JSON.parse(envModels);
79 | }
80 | } catch (error) {
81 | console.error("Failed to parse VITE_AI_MODELS:", error);
82 | }
83 |
84 | // 默认模型(如果环境变量未设置)
85 | return [
86 | { id: 1, slug: "gpt-4", name: "GPT-4", provider: "OpenAI" },
87 | { id: 2, slug: "gpt-3.5-turbo", name: "GPT-3.5 Turbo", provider: "OpenAI" },
88 | { id: 3, slug: "claude-3-opus", name: "Claude 3 Opus", provider: "Anthropic" },
89 | { id: 4, slug: "claude-3-sonnet", name: "Claude 3 Sonnet", provider: "Anthropic" },
90 | { id: 5, slug: "gemini-pro", name: "Gemini Pro", provider: "Google" },
91 | ];
92 | }
93 |
94 | const DEFAULT_CATEGORIES = loadCategoriesFromEnv();
95 | const DEFAULT_AI_MODELS = loadAIModelsFromEnv();
96 |
97 | // ============= 初始化 =============
98 |
99 | function initializeStorage() {
100 | // 始终从环境变量更新分类和 AI 模型配置
101 | localStorage.setItem(
102 | STORAGE_KEYS.CATEGORIES,
103 | JSON.stringify(DEFAULT_CATEGORIES)
104 | );
105 |
106 | localStorage.setItem(
107 | STORAGE_KEYS.AI_MODELS,
108 | JSON.stringify(DEFAULT_AI_MODELS)
109 | );
110 |
111 | // 初始化提示词列表(只在第一次时创建)
112 | if (!localStorage.getItem(STORAGE_KEYS.PROMPTS)) {
113 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify([]));
114 | }
115 |
116 | // 初始化 ID 计数器(只在第一次时创建)
117 | if (!localStorage.getItem(STORAGE_KEYS.NEXT_ID)) {
118 | localStorage.setItem(STORAGE_KEYS.NEXT_ID, "1");
119 | }
120 | }
121 |
122 | // 自动初始化
123 | initializeStorage();
124 |
125 | // ============= 辅助函数 =============
126 |
127 | function getNextId(): number {
128 | const nextId = parseInt(localStorage.getItem(STORAGE_KEYS.NEXT_ID) || "1");
129 | localStorage.setItem(STORAGE_KEYS.NEXT_ID, (nextId + 1).toString());
130 | return nextId;
131 | }
132 |
133 | // ============= Prompts 操作 =============
134 |
135 | export function getAllPrompts(): Prompt[] {
136 | try {
137 | const data = localStorage.getItem(STORAGE_KEYS.PROMPTS);
138 | return data ? JSON.parse(data) : [];
139 | } catch (error) {
140 | console.error("读取提示词失败:", error);
141 | return [];
142 | }
143 | }
144 |
145 | export function getPromptById(id: number): Prompt | null {
146 | const prompts = getAllPrompts();
147 | return prompts.find((p) => p.id === id) || null;
148 | }
149 |
150 | export function savePrompt(prompt: Omit & { id?: number }): Prompt {
151 | const prompts = getAllPrompts();
152 | const now = new Date().toISOString();
153 |
154 | if (prompt.id) {
155 | // 更新现有提示词
156 | const index = prompts.findIndex((p) => p.id === prompt.id);
157 | if (index !== -1) {
158 | const updatedPrompt: Prompt = {
159 | ...prompts[index],
160 | ...prompt,
161 | id: prompt.id,
162 | updatedAt: now,
163 | };
164 | prompts[index] = updatedPrompt;
165 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(prompts));
166 | return updatedPrompt;
167 | }
168 | }
169 |
170 | // 创建新提示词
171 | const newPrompt: Prompt = {
172 | ...prompt,
173 | id: prompt.id || getNextId(),
174 | createdAt: now,
175 | updatedAt: now,
176 | statistics: prompt.statistics || {
177 | views: 0,
178 | uses: 0,
179 | favorites: 0,
180 | },
181 | };
182 |
183 | prompts.push(newPrompt);
184 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(prompts));
185 | return newPrompt;
186 | }
187 |
188 | export function deletePrompt(id: number): boolean {
189 | const prompts = getAllPrompts();
190 | const filteredPrompts = prompts.filter((p) => p.id !== id);
191 |
192 | if (filteredPrompts.length === prompts.length) {
193 | return false; // 未找到要删除的提示词
194 | }
195 |
196 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(filteredPrompts));
197 | return true;
198 | }
199 |
200 | export function publishPrompt(id: number): Prompt | null {
201 | const prompts = getAllPrompts();
202 | const index = prompts.findIndex((p) => p.id === id);
203 |
204 | if (index === -1) return null;
205 |
206 | prompts[index].status = "published";
207 | prompts[index].visibility = "public";
208 | prompts[index].updatedAt = new Date().toISOString();
209 |
210 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(prompts));
211 | return prompts[index];
212 | }
213 |
214 | export function unpublishPrompt(id: number): Prompt | null {
215 | const prompts = getAllPrompts();
216 | const index = prompts.findIndex((p) => p.id === id);
217 |
218 | if (index === -1) return null;
219 |
220 | prompts[index].status = "draft";
221 | prompts[index].visibility = "private";
222 | prompts[index].updatedAt = new Date().toISOString();
223 |
224 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(prompts));
225 | return prompts[index];
226 | }
227 |
228 | // ============= 搜索和筛选 =============
229 |
230 | export interface FilterOptions {
231 | status?: "all" | "draft" | "published";
232 | search?: string;
233 | categorySlug?: string;
234 | aiModelSlug?: string;
235 | }
236 |
237 | export function filterPrompts(options: FilterOptions = {}): Prompt[] {
238 | let prompts = getAllPrompts();
239 |
240 | // 状态筛选
241 | if (options.status && options.status !== "all") {
242 | prompts = prompts.filter((p) => p.status === options.status);
243 | }
244 |
245 | // 搜索
246 | if (options.search) {
247 | const searchLower = options.search.toLowerCase();
248 | prompts = prompts.filter(
249 | (p) =>
250 | p.title.toLowerCase().includes(searchLower) ||
251 | p.description.toLowerCase().includes(searchLower) ||
252 | p.content.toLowerCase().includes(searchLower)
253 | );
254 | }
255 |
256 | // 分类筛选
257 | if (options.categorySlug) {
258 | const category = getCategoryBySlug(options.categorySlug);
259 | if (category) {
260 | prompts = prompts.filter((p) => p.categoryId === category.id);
261 | }
262 | }
263 |
264 | // AI 模型筛选
265 | if (options.aiModelSlug) {
266 | prompts = prompts.filter((p) => p.aiModelSlug === options.aiModelSlug);
267 | }
268 |
269 | // 按更新时间降序排序
270 | prompts.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
271 |
272 | return prompts;
273 | }
274 |
275 | // ============= Categories 操作 =============
276 |
277 | export function getAllCategories(): Category[] {
278 | try {
279 | const data = localStorage.getItem(STORAGE_KEYS.CATEGORIES);
280 | return data ? JSON.parse(data) : DEFAULT_CATEGORIES;
281 | } catch (error) {
282 | console.error("读取分类失败:", error);
283 | return DEFAULT_CATEGORIES;
284 | }
285 | }
286 |
287 | export function getCategoryById(id: number): Category | null {
288 | const categories = getAllCategories();
289 | return categories.find((c) => c.id === id) || null;
290 | }
291 |
292 | export function getCategoryBySlug(slug: string): Category | null {
293 | const categories = getAllCategories();
294 | return categories.find((c) => c.slug === slug) || null;
295 | }
296 |
297 | // ============= AI Models 操作 =============
298 |
299 | export function getAllAIModels(): AIModel[] {
300 | try {
301 | const data = localStorage.getItem(STORAGE_KEYS.AI_MODELS);
302 | return data ? JSON.parse(data) : DEFAULT_AI_MODELS;
303 | } catch (error) {
304 | console.error("读取 AI 模型失败:", error);
305 | return DEFAULT_AI_MODELS;
306 | }
307 | }
308 |
309 | export function getAIModelBySlug(slug: string): AIModel | null {
310 | const models = getAllAIModels();
311 | return models.find((m) => m.slug === slug) || null;
312 | }
313 |
314 | // ============= 统计 =============
315 |
316 | export function getPromptCounts() {
317 | const prompts = getAllPrompts();
318 | return {
319 | all: prompts.length,
320 | published: prompts.filter((p) => p.status === "published").length,
321 | draft: prompts.filter((p) => p.status === "draft").length,
322 | };
323 | }
324 |
--------------------------------------------------------------------------------
/src/components/PromptEditor/APITab.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * APITab 组件 - API 配置标签页
3 | */
4 |
5 | import { useState, useEffect } from "react";
6 | import { Loader2 } from "lucide-react";
7 | import { useToast } from "../../contexts/ToastContext";
8 | import {
9 | getEncryptedConfig,
10 | saveEncryptedConfig,
11 | removeEncryptedConfig,
12 | } from "../../utils/encryption";
13 | import type { PromptFormData } from "./types";
14 |
15 | interface APITabProps {
16 | formData: PromptFormData;
17 | setFormData: React.Dispatch>;
18 | onApiConfigDeleted?: () => void;
19 | }
20 |
21 | export function APITab({
22 | formData,
23 | setFormData,
24 | onApiConfigDeleted,
25 | }: APITabProps) {
26 | const { showToast } = useToast();
27 | const [isTesting, setIsTesting] = useState(false);
28 | const [isSaving, setIsSaving] = useState(false);
29 | const [isDeleting, setIsDeleting] = useState(false);
30 | const [testResult, setTestResult] = useState<{
31 | success: boolean;
32 | message: string;
33 | } | null>(null);
34 |
35 | // 页面加载时从加密存储读取配置
36 | useEffect(() => {
37 | const loadApiConfig = async () => {
38 | const apiUrl = (await getEncryptedConfig("encrypted_api_url")) || "";
39 | const apiKey = (await getEncryptedConfig("encrypted_api_key")) || "";
40 |
41 | if (apiUrl || apiKey) {
42 | setFormData((prev) => ({
43 | ...prev,
44 | apiUrl,
45 | apiKey,
46 | }));
47 | }
48 | };
49 |
50 | loadApiConfig();
51 | }, [setFormData]);
52 |
53 | const handleSaveApiSettings = async () => {
54 | if (!formData.apiUrl.trim() && !formData.apiKey.trim()) {
55 | showToast("请至少填写 API 地址或密钥", "error");
56 | return;
57 | }
58 |
59 | setIsSaving(true);
60 | try {
61 | // 移除尾部斜杠
62 | const cleanApiUrl = formData.apiUrl.trim()
63 | ? formData.apiUrl.trim().replace(/\/+$/, "")
64 | : "";
65 | const cleanApiKey = formData.apiKey.trim();
66 |
67 | // 加密保存到本地 localStorage
68 | if (cleanApiUrl) {
69 | await saveEncryptedConfig("encrypted_api_url", cleanApiUrl);
70 | // 同步更新表单数据
71 | setFormData((prev) => ({ ...prev, apiUrl: cleanApiUrl }));
72 | }
73 |
74 | if (cleanApiKey) {
75 | await saveEncryptedConfig("encrypted_api_key", cleanApiKey);
76 | }
77 |
78 | // 触发自定义事件通知其他组件刷新
79 | window.dispatchEvent(new CustomEvent("api-config-updated"));
80 |
81 | showToast("API 设置已加密保存到本地", "success");
82 | } catch (error) {
83 | console.error("保存 API 设置错误:", error);
84 | showToast("保存失败", "error");
85 | } finally {
86 | setIsSaving(false);
87 | }
88 | };
89 |
90 | const handleDeleteApiSettings = async () => {
91 | setIsDeleting(true);
92 | try {
93 | // 删除加密存储的配置
94 | removeEncryptedConfig("encrypted_api_url");
95 | removeEncryptedConfig("encrypted_api_key");
96 |
97 | // 清空表单
98 | setFormData({ ...formData, apiUrl: "", apiKey: "" });
99 |
100 | // 通知父组件
101 | onApiConfigDeleted?.();
102 |
103 | // 触发自定义事件通知其他组件刷新和清理状态
104 | window.dispatchEvent(new CustomEvent("api-config-deleted"));
105 | window.dispatchEvent(new CustomEvent("api-config-updated"));
106 |
107 | showToast("API 设置已删除", "success");
108 | } catch (error) {
109 | console.error("删除 API 设置错误:", error);
110 | showToast("删除失败", "error");
111 | } finally {
112 | setIsDeleting(false);
113 | }
114 | };
115 |
116 | const handleTestAPI = async () => {
117 | if (!formData.apiUrl.trim()) {
118 | showToast("请先填写 API 地址", "error");
119 | return;
120 | }
121 |
122 | if (!formData.apiKey.trim()) {
123 | showToast("请先填写 API 密钥", "error");
124 | return;
125 | }
126 |
127 | setIsTesting(true);
128 | setTestResult(null);
129 |
130 | try {
131 | // 移除尾部斜杠并拼接模型列表路径
132 | const apiBaseUrl = formData.apiUrl.trim().replace(/\/+$/, "");
133 | const modelsApiUrl = `${apiBaseUrl}/v1/models`;
134 |
135 | const response = await fetch(modelsApiUrl, {
136 | method: "GET",
137 | headers: {
138 | Authorization: `Bearer ${formData.apiKey}`,
139 | },
140 | });
141 |
142 | if (response.ok) {
143 | const data = await response.json();
144 | if (data.data && Array.isArray(data.data)) {
145 | const modelCount = data.data.length;
146 | setTestResult({
147 | success: true,
148 | message: `API 连接成功!检测到 ${modelCount} 个可用模型`,
149 | });
150 | showToast("API 连接成功!", "success");
151 |
152 | // 测试成功后自动保存配置
153 | const cleanApiUrl = formData.apiUrl.trim();
154 | const cleanApiKey = formData.apiKey.trim();
155 |
156 | if (cleanApiUrl) {
157 | await saveEncryptedConfig("encrypted_api_url", cleanApiUrl);
158 | setFormData((prev) => ({ ...prev, apiUrl: cleanApiUrl }));
159 | }
160 |
161 | if (cleanApiKey) {
162 | await saveEncryptedConfig("encrypted_api_key", cleanApiKey);
163 | }
164 |
165 | // 触发自定义事件通知对话测试刷新模型列表
166 | window.dispatchEvent(new CustomEvent("api-config-updated"));
167 | } else {
168 | setTestResult({
169 | success: false,
170 | message: "API 响应格式不正确",
171 | });
172 | showToast("API 响应格式不正确", "error");
173 | }
174 | } else {
175 | const errorText = await response.text();
176 | let errorMessage = `连接失败: ${response.status} ${response.statusText}`;
177 |
178 | // 尝试解析错误信息
179 | try {
180 | const errorData = JSON.parse(errorText);
181 | if (errorData.error?.message) {
182 | errorMessage = `${errorMessage} - ${errorData.error.message}`;
183 | }
184 | } catch {
185 | // 忽略 JSON 解析错误
186 | }
187 |
188 | setTestResult({
189 | success: false,
190 | message: errorMessage,
191 | });
192 | showToast(`连接失败: ${response.status}`, "error");
193 | console.error("API 测试失败:", errorText);
194 | }
195 | } catch (error) {
196 | console.error("API 测试错误:", error);
197 | setTestResult({
198 | success: false,
199 | message: `连接错误: ${error instanceof Error ? error.message : "未知错误"}`,
200 | });
201 | showToast("API 连接失败", "error");
202 | } finally {
203 | setIsTesting(false);
204 | }
205 | };
206 |
207 | return (
208 |
209 |
API 配置
210 |
211 | {/* 安全说明 */}
212 |
213 |
214 |
229 |
230 |
231 | 本地加密存储
232 |
233 |
234 | 您的 API 密钥使用{" "}
235 |
236 | AES-256-GCM 加密算法
237 |
238 | 存储在浏览器本地,不会上传到我们的服务器。所有 API
239 | 请求直接从您的浏览器发送到目标服务,保护您的隐私安全。
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 | 配置自定义 API 端点以连接外部 OpenAI 兼容服务。
248 |
249 |
250 | {/* API URL */}
251 |
252 |
255 |
260 | setFormData({ ...formData, apiUrl: e.target.value })
261 | }
262 | className="w-full bg-[#2C2A2F] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#3fda8c] transition-colors"
263 | />
264 |
265 | 只需输入基础域名,系统会自动拼接 /v1/chat/completions
266 |
267 |
268 |
269 | {/* API Key */}
270 |
271 |
272 |
277 | setFormData({ ...formData, apiKey: e.target.value })
278 | }
279 | className="w-full bg-[#2C2A2F] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#3fda8c] transition-colors"
280 | />
281 |
282 | 你的 API 密钥将被加密并安全存储
283 |
284 |
285 |
286 | {/* Test Result */}
287 | {testResult && (
288 |
295 |
300 | {testResult.message}
301 |
302 |
303 | )}
304 |
305 | {/* Action Buttons */}
306 |
307 |
315 |
323 | {(formData.apiUrl || formData.apiKey) && (
324 |
332 | )}
333 |
334 |
335 |
336 | );
337 | }
338 |
--------------------------------------------------------------------------------
/src/components/PromptEditor/DefinitionTab.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * DefinitionTab 组件 - 提示词定义标签页
3 | */
4 |
5 | import { useState } from "react";
6 | import { ChevronDown, Upload, Loader2, Send as SendIcon } from "lucide-react";
7 | import type { PromptFormData } from "./types";
8 |
9 | interface Metadata {
10 | categories: Array<{
11 | id: number;
12 | name: string;
13 | slug: string;
14 | description: string;
15 | count: number;
16 | }>;
17 | ai_models: Array<{
18 | id: number;
19 | slug: string;
20 | name: string;
21 | provider: string;
22 | description: string;
23 | supports_vision: boolean;
24 | context_length: number;
25 | }>;
26 | tags: string[];
27 | visibility_options: Array<{
28 | value: string;
29 | label: string;
30 | description: string;
31 | }>;
32 | }
33 |
34 | interface DefinitionTabProps {
35 | formData: PromptFormData;
36 | setFormData: React.Dispatch>;
37 | coverImage: { id?: number; url: string } | null;
38 | onImageSelect: () => void;
39 | isUploading: boolean;
40 | onSave?: () => void;
41 | onPublish?: () => void;
42 | isSaving: boolean;
43 | isPublishing: boolean;
44 | metadata: Metadata | null;
45 | selectedCategory: number | null;
46 | setSelectedCategory: (id: number | null) => void;
47 | selectedModel: {
48 | id: number;
49 | slug: string;
50 | name: string;
51 | provider: string;
52 | } | null;
53 | setSelectedModel: (
54 | model: { id: number; slug: string; name: string; provider: string } | null,
55 | ) => void;
56 | promptStatus: "draft" | "publish" | "published" | "archived";
57 | }
58 |
59 | export function DefinitionTab({
60 | formData,
61 | setFormData,
62 | coverImage,
63 | onImageSelect,
64 | isUploading,
65 | onSave,
66 | onPublish,
67 | isSaving,
68 | isPublishing,
69 | metadata,
70 | selectedCategory,
71 | setSelectedCategory,
72 | selectedModel,
73 | setSelectedModel,
74 | promptStatus,
75 | }: DefinitionTabProps) {
76 | const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
77 | const [showModelDropdown, setShowModelDropdown] = useState(false);
78 | return (
79 |
80 |
提示词
81 |
82 | {/* Name and Cover */}
83 |
84 |
85 | {/* Name */}
86 |
87 |
90 |
95 | setFormData({ ...formData, title: e.target.value })
96 | }
97 | maxLength={100}
98 | className="w-full bg-[#2C2A2F] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#3fda8c] transition-colors"
99 | />
100 |
101 | {formData.title.length} / 100 字符
102 |
103 |
104 |
105 | {/* Cover */}
106 |
107 |
108 |
109 |
110 | {coverImage ? (
111 |

116 | ) : (
117 |
128 | )}
129 |
130 |
131 |
143 |
JPG、PNG、WEBP
144 |
145 |
146 |
147 |
148 |
149 |
150 | {/* Category and AI Model */}
151 | {metadata && (
152 |
153 |
154 | {/* Category */}
155 |
156 |
157 |
158 |
175 | {showCategoryDropdown && (
176 |
177 | {metadata.categories.map((category: { id: number; name: string; description: string }) => (
178 |
199 | ))}
200 |
201 | )}
202 |
203 |
204 |
205 | {/* AI Model */}
206 |
207 |
210 |
211 |
234 | {showModelDropdown && (
235 |
236 | {metadata.ai_models.map((model: { id: number; slug: string; name: string; provider: string; description: string }) => (
237 |
259 | ))}
260 |
261 | )}
262 |
263 |
264 |
265 |
266 | )}
267 |
268 | {/* Bot Intro */}
269 |
285 |
286 | {/* Prompt */}
287 |
288 |
291 |
306 |
307 | {/* Mobile Action Buttons */}
308 |
309 |
317 |
339 |
340 |
341 | );
342 | }
343 |
--------------------------------------------------------------------------------
/src/components/PromptEditor/ChatTestingContent.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ChatTestingContent 组件 - 对话测试面板
3 | */
4 |
5 | import { useState, useEffect, useRef, useCallback } from "react";
6 | import { Send, ChevronDown, Loader2, Trash2 } from "lucide-react";
7 | import { useToast } from "../../contexts/ToastContext";
8 | import { getEncryptedConfig } from "../../utils/encryption";
9 | import { MessageIcon } from "./MessageIcon";
10 | import type { ChatMessage, Model } from "./types";
11 |
12 | interface ChatTestingContentProps {
13 | systemPrompt: string;
14 | }
15 |
16 | export function ChatTestingContent({ systemPrompt }: ChatTestingContentProps) {
17 | const { showToast } = useToast();
18 | const [messages, setMessages] = useState([]);
19 | const [input, setInput] = useState("");
20 | const [isLoading, setIsLoading] = useState(false);
21 | const [models, setModels] = useState([]);
22 | const [selectedModel, setSelectedModel] = useState("gpt-3.5-turbo");
23 | const [isLoadingModels, setIsLoadingModels] = useState(false);
24 | const [showModelDropdown, setShowModelDropdown] = useState(false);
25 | const messagesEndRef = useRef(null);
26 | const abortControllerRef = useRef(null);
27 | const inputRef = useRef(null);
28 |
29 | const scrollToBottom = () => {
30 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
31 | };
32 |
33 | useEffect(() => {
34 | scrollToBottom();
35 | }, [messages]);
36 |
37 | // 加载模型列表
38 | const loadModels = useCallback(
39 | async (cancelled: { current: boolean }) => {
40 | const apiBaseUrl = (await getEncryptedConfig("encrypted_api_url")) || "";
41 | const apiKey = (await getEncryptedConfig("encrypted_api_key")) || "";
42 |
43 | if (!apiBaseUrl || !apiKey) {
44 | return;
45 | }
46 |
47 | setIsLoadingModels(true);
48 | try {
49 | const cleanBaseUrl = apiBaseUrl.replace(/\/+$/, "");
50 | const modelsApiUrl = `${cleanBaseUrl}/v1/models`;
51 |
52 | const response = await fetch(modelsApiUrl, {
53 | method: "GET",
54 | headers: {
55 | Authorization: `Bearer ${apiKey}`,
56 | },
57 | });
58 |
59 | if (cancelled.current) return;
60 |
61 | if (response.ok) {
62 | const data = await response.json();
63 | if (cancelled.current) return;
64 |
65 | if (data.data && Array.isArray(data.data)) {
66 | setModels(data.data);
67 | // 如果当前选中的模型不在列表中,选择第一个
68 | if (data.data.length > 0) {
69 | const modelIds = data.data.map((m: Model) => m.id);
70 | if (!modelIds.includes(selectedModel)) {
71 | setSelectedModel(data.data[0].id);
72 | }
73 | }
74 | }
75 | }
76 | } catch (error) {
77 | if (!cancelled.current) {
78 | console.error("加载模型列表失败:", error);
79 | }
80 | } finally {
81 | if (!cancelled.current) {
82 | setIsLoadingModels(false);
83 | }
84 | }
85 | },
86 | [selectedModel],
87 | );
88 |
89 | // 页面加载时自动获取模型列表
90 | useEffect(() => {
91 | const cancelled = { current: false };
92 | loadModels(cancelled);
93 | return () => {
94 | cancelled.current = true;
95 | };
96 | }, [loadModels]);
97 |
98 | // 监听 API 配置更新事件
99 | useEffect(() => {
100 | const handleApiConfigUpdate = () => {
101 | const cancelled = { current: false };
102 | loadModels(cancelled);
103 | };
104 |
105 | window.addEventListener("api-config-updated", handleApiConfigUpdate);
106 | return () => {
107 | window.removeEventListener("api-config-updated", handleApiConfigUpdate);
108 | };
109 | // eslint-disable-next-line react-hooks/exhaustive-deps
110 | }, []);
111 |
112 | // 监听 API 配置删除事件 - 清理对话测试状态
113 | useEffect(() => {
114 | const handleApiConfigDeleted = () => {
115 | // 清空模型列表
116 | setModels([]);
117 | // 清空选中的模型
118 | setSelectedModel("");
119 | // 清空对话消息
120 | setMessages([]);
121 | // 清空输入框
122 | setInput("");
123 | };
124 |
125 | window.addEventListener("api-config-deleted", handleApiConfigDeleted);
126 | return () => {
127 | window.removeEventListener("api-config-deleted", handleApiConfigDeleted);
128 | };
129 | }, []);
130 |
131 | // 点击外部关闭下拉菜单
132 | useEffect(() => {
133 | const handleClickOutside = (e: MouseEvent) => {
134 | const target = e.target as HTMLElement;
135 | if (!target.closest(".model-dropdown")) {
136 | setShowModelDropdown(false);
137 | }
138 | };
139 |
140 | if (showModelDropdown) {
141 | document.addEventListener("click", handleClickOutside);
142 | }
143 |
144 | return () => {
145 | document.removeEventListener("click", handleClickOutside);
146 | };
147 | }, [showModelDropdown]);
148 |
149 | const handleSend = async () => {
150 | if (!input.trim() || isLoading) return;
151 |
152 | const userMessage: ChatMessage = {
153 | role: "user",
154 | content: input.trim(),
155 | };
156 |
157 | setMessages((prev) => [...prev, userMessage]);
158 | setInput("");
159 | setIsLoading(true);
160 |
161 | // 获取 API 配置(从加密存储)
162 | let apiBaseUrl = (await getEncryptedConfig("encrypted_api_url")) || "";
163 | const apiKey = (await getEncryptedConfig("encrypted_api_key")) || "";
164 |
165 | if (!apiBaseUrl || !apiKey) {
166 | showToast("请先在 API 配置页面设置 API 地址和密钥", "error");
167 | setIsLoading(false);
168 | return;
169 | }
170 |
171 | // 移除尾部斜杠并拼接完整路径
172 | apiBaseUrl = apiBaseUrl.replace(/\/+$/, "");
173 | const fullApiUrl = `${apiBaseUrl}/v1/chat/completions`;
174 |
175 | try {
176 | abortControllerRef.current = new AbortController();
177 |
178 | // 构建消息数组,如果有系统提示词则添加
179 | const apiMessages: Array<{ role: string; content: string }> = [];
180 |
181 | if (systemPrompt.trim()) {
182 | apiMessages.push({
183 | role: "system",
184 | content: systemPrompt,
185 | });
186 | }
187 |
188 | // 添加对话历史和当前用户消息
189 | apiMessages.push(
190 | ...messages.map((msg) => ({
191 | role: msg.role,
192 | content: msg.content,
193 | })),
194 | {
195 | role: "user",
196 | content: userMessage.content,
197 | },
198 | );
199 |
200 | const response = await fetch(fullApiUrl, {
201 | method: "POST",
202 | headers: {
203 | "Content-Type": "application/json",
204 | Authorization: `Bearer ${apiKey}`,
205 | },
206 | body: JSON.stringify({
207 | model: selectedModel,
208 | messages: apiMessages,
209 | stream: true,
210 | }),
211 | signal: abortControllerRef.current.signal,
212 | });
213 |
214 | if (!response.ok) {
215 | throw new Error(`API 请求失败: ${response.status}`);
216 | }
217 |
218 | const reader = response.body?.getReader();
219 | const decoder = new TextDecoder();
220 |
221 | if (!reader) {
222 | throw new Error("无法读取响应流");
223 | }
224 |
225 | let assistantMessage = "";
226 | setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
227 |
228 | while (true) {
229 | const { done, value } = await reader.read();
230 | if (done) break;
231 |
232 | const chunk = decoder.decode(value);
233 | const lines = chunk.split("\n");
234 |
235 | for (const line of lines) {
236 | if (line.startsWith("data: ")) {
237 | const data = line.slice(6);
238 | if (data === "[DONE]") continue;
239 |
240 | try {
241 | const parsed = JSON.parse(data);
242 | const content = parsed.choices?.[0]?.delta?.content;
243 | if (content) {
244 | assistantMessage += content;
245 | setMessages((prev) => {
246 | const newMessages = [...prev];
247 | newMessages[newMessages.length - 1] = {
248 | role: "assistant",
249 | content: assistantMessage,
250 | };
251 | return newMessages;
252 | });
253 | }
254 | } catch (e) {
255 | // 忽略非 JSON 行(如空行)
256 | if (data.trim()) {
257 | console.warn("Failed to parse SSE data:", data, e);
258 | }
259 | }
260 | }
261 | }
262 | }
263 | } catch (error: unknown) {
264 | if (error instanceof Error && error.name === "AbortError") {
265 | console.log("请求已取消");
266 | } else {
267 | console.error("对话错误:", error);
268 | showToast(
269 | `对话失败: ${error instanceof Error ? error.message : "未知错误"}`,
270 | "error",
271 | );
272 | // 移除最后一条空的助手消息
273 | setMessages((prev) => prev.slice(0, -1));
274 | }
275 | } finally {
276 | setIsLoading(false);
277 | abortControllerRef.current = null;
278 | // AI 回复完成后聚焦输入框
279 | requestAnimationFrame(() => {
280 | inputRef.current?.focus();
281 | });
282 | }
283 | };
284 |
285 | const handleKeyPress = (e: React.KeyboardEvent) => {
286 | if (e.key === "Enter" && !e.shiftKey) {
287 | e.preventDefault();
288 | handleSend();
289 | }
290 | };
291 |
292 | const handleClear = () => {
293 | setMessages([]);
294 | };
295 |
296 | return (
297 |
298 |
299 |
300 |
301 | 对话测试
302 |
303 |
304 | {/* 模型选择下拉菜单 */}
305 |
306 |
316 | {showModelDropdown && models.length > 0 && (
317 |
318 | {models.map((model) => (
319 |
333 | ))}
334 |
335 | )}
336 |
337 | {/* 清空按钮 - 始终显示 */}
338 |
346 |
347 |
348 |
349 |
350 | {messages.length === 0 ? (
351 |
352 |
353 | 在左侧填写提示词内容,并在 API
354 | 配置页面设置你的自定义接口,然后开始测试对话。
355 |
356 |
357 | ) : (
358 |
359 | {messages.map((message, index) => (
360 |
366 |
373 |
374 | {message.content}
375 |
376 |
377 |
378 | ))}
379 |
380 |
381 | )}
382 |
383 |
384 |
385 |
386 | setInput(e.target.value)}
392 | onKeyPress={handleKeyPress}
393 | disabled={isLoading}
394 | className="flex-1 bg-[#1f1f1f] border border-white/10 rounded-lg px-4 py-2 text-white placeholder-gray-600 focus:outline-none focus:border-[#3fda8c] transition-colors disabled:opacity-50"
395 | />
396 |
407 |
408 |
409 |
410 | );
411 | }
412 |
--------------------------------------------------------------------------------
/src/pages/PromptEditor.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from "react";
2 | import { FileCode, Send as SendIcon, Loader2 } from "lucide-react";
3 | import { useParams, useNavigate } from "react-router-dom";
4 | import { ConfirmDialog } from "../components/ConfirmDialog";
5 | import { EditorHeader } from "../components/EditorHeader";
6 | import { useToast } from "../contexts/ToastContext";
7 | import { TabButton } from "../components/PromptEditor/TabButton";
8 | import { MessageIcon } from "../components/PromptEditor/MessageIcon";
9 | import { EditorSkeleton } from "../components/PromptEditor/EditorSkeleton";
10 | import { ChatTestingContent } from "../components/PromptEditor/ChatTestingContent";
11 | import { DefinitionTab } from "../components/PromptEditor/DefinitionTab";
12 | import { APITab } from "../components/PromptEditor/APITab";
13 | import type { PromptFormData } from "../components/PromptEditor/types";
14 | import {
15 | getAllCategories,
16 | getAllAIModels,
17 | getPromptById,
18 | savePrompt,
19 | publishPrompt as publishPromptLocal,
20 | unpublishPrompt as unpublishPromptLocal,
21 | type Category,
22 | type AIModel,
23 | } from "../services/localStorage";
24 | import { getEncryptedConfig } from "../utils/encryption";
25 |
26 | export function PromptEditor() {
27 | const { id } = useParams();
28 | const promptId = id ? parseInt(id) : undefined;
29 | const navigate = useNavigate();
30 | const { showToast } = useToast();
31 |
32 | const [activeTab, setActiveTab] = useState<"definition" | "api" | "chat">(
33 | "definition",
34 | );
35 | const [showPublishDialog, setShowPublishDialog] = useState(false);
36 |
37 | // 元数据
38 | const [categories, setCategories] = useState([]);
39 | const [aiModels, setAIModels] = useState([]);
40 | const [selectedModel, setSelectedModel] = useState(null);
41 |
42 | // 表单数据
43 | const [currentPromptId, setCurrentPromptId] = useState(
44 | promptId,
45 | );
46 |
47 | const [formData, setFormData] = useState({
48 | title: "",
49 | description: "",
50 | content: "",
51 | apiUrl: "",
52 | apiKey: "",
53 | });
54 |
55 | // 图片相关
56 | const [coverImage, setCoverImage] = useState(null);
57 | const fileInputRef = useRef(null);
58 |
59 | // 分类
60 | const [selectedCategory, setSelectedCategory] = useState(null);
61 |
62 | // 保存状态
63 | const [isSaving, setIsSaving] = useState(false);
64 | const [isPublishing, setIsPublishing] = useState(false);
65 | const [isLoading, setIsLoading] = useState(!!promptId);
66 |
67 | // 文章状态
68 | const [promptStatus, setPromptStatus] = useState<"draft" | "published">(
69 | "draft",
70 | );
71 | const [promptVisibility, setPromptVisibility] = useState<
72 | "public" | "private"
73 | >("public");
74 |
75 | // 加载元数据
76 | useEffect(() => {
77 | setCategories(getAllCategories());
78 | setAIModels(getAllAIModels());
79 | }, []);
80 |
81 | // 加载用户 API 设置(从本地加密存储)
82 | const loadUserApiSettings = async () => {
83 | try {
84 | const apiUrl = (await getEncryptedConfig("encrypted_api_url")) || "";
85 | const apiKey = (await getEncryptedConfig("encrypted_api_key")) || "";
86 |
87 | setFormData((prev) => ({
88 | ...prev,
89 | apiUrl,
90 | apiKey,
91 | }));
92 | } catch (error) {
93 | console.error("加载 API 设置失败:", error);
94 | }
95 | };
96 |
97 | // 加载 API 设置
98 | useEffect(() => {
99 | loadUserApiSettings();
100 | }, []);
101 |
102 | // 监听路由参数变化
103 | useEffect(() => {
104 | const newId = promptId;
105 |
106 | // 如果从有 ID 变为无 ID(点击新建按钮)
107 | if (currentPromptId && !newId) {
108 | // 重置表单
109 | setFormData((prev) => ({
110 | title: "",
111 | description: "",
112 | content: "",
113 | apiUrl: prev.apiUrl, // 保留 API 配置
114 | apiKey: prev.apiKey,
115 | }));
116 | setCoverImage(null);
117 | setSelectedCategory(null);
118 | setSelectedModel(null);
119 | setCurrentPromptId(undefined);
120 | setPromptStatus("draft");
121 | setPromptVisibility("public");
122 | } else if (newId !== currentPromptId) {
123 | // ID 变化了,更新 currentPromptId
124 | setCurrentPromptId(newId);
125 | if (newId) {
126 | setIsLoading(true);
127 | }
128 | }
129 | }, [promptId, currentPromptId]);
130 |
131 | // 加载提示词(编辑模式)
132 | useEffect(() => {
133 | if (currentPromptId) {
134 | setIsLoading(true);
135 | try {
136 | const prompt = getPromptById(currentPromptId);
137 | if (prompt) {
138 | setFormData((prev) => ({
139 | ...prev,
140 | title: prompt.title,
141 | description: prompt.description,
142 | content: prompt.content,
143 | // apiUrl 和 apiKey 保持本地存储的值
144 | }));
145 |
146 | setPromptStatus(prompt.status);
147 | setPromptVisibility(prompt.visibility);
148 |
149 | if (prompt.image) {
150 | setCoverImage(prompt.image);
151 | }
152 |
153 | if (prompt.categoryId) {
154 | setSelectedCategory(prompt.categoryId);
155 | }
156 |
157 | if (prompt.aiModelSlug) {
158 | const model = aiModels.find((m) => m.slug === prompt.aiModelSlug);
159 | if (model) {
160 | setSelectedModel(model);
161 | }
162 | }
163 | } else {
164 | showToast("未找到该提示词", "error");
165 | navigate("/my-prompts");
166 | }
167 | } finally {
168 | setIsLoading(false);
169 | }
170 | }
171 | }, [currentPromptId, aiModels, navigate, showToast]);
172 |
173 | const handleImageSelect = (e: React.ChangeEvent) => {
174 | const file = e.target.files?.[0];
175 | if (!file) return;
176 |
177 | // 检查文件大小(5MB)
178 | if (file.size > 5 * 1024 * 1024) {
179 | showToast("图片大小不能超过 5MB", "error");
180 | return;
181 | }
182 |
183 | // 检查文件类型
184 | const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
185 | if (!allowedTypes.includes(file.type)) {
186 | showToast("仅支持 JPG、PNG、WEBP 格式的图片", "error");
187 | return;
188 | }
189 |
190 | // 预览图片(转为 base64)
191 | const reader = new FileReader();
192 | reader.onload = (e) => {
193 | setCoverImage(e.target?.result as string);
194 | showToast("图片已选择", "success");
195 | };
196 | reader.readAsDataURL(file);
197 | };
198 |
199 | const handleSave = async (): Promise => {
200 | // 草稿保存时只验证标题和内容
201 | if (!formData.title.trim()) {
202 | showToast("请填写提示词名称", "error");
203 | return null;
204 | }
205 |
206 | if (!formData.content.trim()) {
207 | showToast("请填写提示词内容", "error");
208 | return null;
209 | }
210 |
211 | setIsSaving(true);
212 | try {
213 | const savedPrompt = savePrompt({
214 | id: currentPromptId,
215 | title: formData.title,
216 | description: formData.description,
217 | content: formData.content,
218 | image: coverImage || undefined,
219 | categoryId: selectedCategory || undefined,
220 | aiModelSlug: selectedModel?.slug,
221 | status: promptStatus,
222 | visibility: promptVisibility,
223 | statistics: {
224 | views: 0,
225 | uses: 0,
226 | favorites: 0,
227 | },
228 | });
229 |
230 | // 更新当前 ID
231 | setCurrentPromptId(savedPrompt.id);
232 | setPromptStatus(savedPrompt.status);
233 | setPromptVisibility(savedPrompt.visibility);
234 |
235 | // 如果是新建的草稿,更新 URL
236 | if (!currentPromptId) {
237 | navigate(`/prompt-editor/${savedPrompt.id}`, { replace: true });
238 | }
239 |
240 | showToast("保存成功", "success");
241 | return savedPrompt.id;
242 | } finally {
243 | setIsSaving(false);
244 | }
245 | };
246 |
247 | const handlePublish = () => {
248 | const isPublished = promptStatus === "published";
249 |
250 | // 如果是已发布,点击后应该设为私人(变回草稿)
251 | if (isPublished) {
252 | setShowPublishDialog(true);
253 | return;
254 | }
255 |
256 | // 草稿状态,发布前需要检查必填项
257 | if (!formData.title.trim()) {
258 | showToast("请填写提示词名称", "error");
259 | return;
260 | }
261 | if (!formData.content.trim()) {
262 | showToast("请填写提示词内容", "error");
263 | return;
264 | }
265 | if (!selectedCategory) {
266 | showToast("请选择分类", "error");
267 | return;
268 | }
269 | if (!selectedModel) {
270 | showToast("请选择 AI 模型", "error");
271 | return;
272 | }
273 | if (!formData.description.trim()) {
274 | showToast("请填写介绍", "error");
275 | return;
276 | }
277 |
278 | setShowPublishDialog(true);
279 | };
280 |
281 | const confirmPublish = async () => {
282 | const isPublished = promptStatus === "published";
283 |
284 | // 如果是已发布,设为私人(变回草稿)
285 | if (isPublished) {
286 | if (!currentPromptId) {
287 | showToast("无效的提示词ID", "error");
288 | setShowPublishDialog(false);
289 | return;
290 | }
291 |
292 | setIsPublishing(true);
293 | try {
294 | const updated = unpublishPromptLocal(currentPromptId);
295 | setShowPublishDialog(false);
296 |
297 | if (updated) {
298 | showToast("已设为私人!", "success");
299 | setPromptStatus("draft");
300 | setPromptVisibility("private");
301 | } else {
302 | showToast("操作失败", "error");
303 | }
304 | } finally {
305 | setIsPublishing(false);
306 | }
307 | return;
308 | }
309 |
310 | // 草稿状态,先保存再发布
311 | setIsPublishing(true);
312 | try {
313 | // 先保存
314 | const savedPrompt = savePrompt({
315 | id: currentPromptId,
316 | title: formData.title,
317 | description: formData.description,
318 | content: formData.content,
319 | image: coverImage || undefined,
320 | categoryId: selectedCategory || undefined,
321 | aiModelSlug: selectedModel?.slug,
322 | status: "draft",
323 | visibility: promptVisibility,
324 | statistics: {
325 | views: 0,
326 | uses: 0,
327 | favorites: 0,
328 | },
329 | });
330 |
331 | // 然后发布
332 | const published = publishPromptLocal(savedPrompt.id);
333 |
334 | setShowPublishDialog(false);
335 |
336 | if (published) {
337 | showToast("发布成功!", "success");
338 | setPromptStatus("published");
339 | setPromptVisibility("public");
340 | setCurrentPromptId(published.id);
341 |
342 | // 如果是新建的,更新 URL
343 | if (!currentPromptId) {
344 | navigate(`/prompt-editor/${published.id}`, { replace: true });
345 | }
346 | } else {
347 | showToast("发布失败", "error");
348 | }
349 | } finally {
350 | setIsPublishing(false);
351 | }
352 | };
353 |
354 | return (
355 |
356 | {/* Header */}
357 |
358 |
359 | {/* Mobile Tabs */}
360 |
361 |
362 | }
364 | label="提示词"
365 | active={activeTab === "definition"}
366 | onClick={() => setActiveTab("definition")}
367 | />
368 | }
370 | label="对话"
371 | active={activeTab === "chat"}
372 | onClick={() => setActiveTab("chat")}
373 | />
374 | }
376 | label="调试API"
377 | active={activeTab === "api"}
378 | onClick={() => setActiveTab("api")}
379 | />
380 |
381 |
382 |
383 | {/* Main Content */}
384 |
385 | {/* Left Sidebar */}
386 |
436 |
437 | {/* Center Content */}
438 |
439 | {activeTab === "chat" ? (
440 |
441 |
442 |
443 | ) : (
444 |
445 | {activeTab === "definition" &&
446 | (isLoading ? (
447 |
448 | ) : (
449 |
fileInputRef.current?.click()}
454 | isUploading={false}
455 | onSave={handleSave}
456 | onPublish={handlePublish}
457 | isSaving={isSaving}
458 | isPublishing={isPublishing}
459 | metadata={{
460 | categories: categories.map((c) => ({
461 | ...c,
462 | count: 0,
463 | description: c.description,
464 | })),
465 | ai_models: aiModels.map((m) => ({
466 | ...m,
467 | description: "",
468 | supports_vision: false,
469 | context_length: 0,
470 | })),
471 | tags: [],
472 | visibility_options: [],
473 | }}
474 | selectedCategory={selectedCategory}
475 | setSelectedCategory={setSelectedCategory}
476 | selectedModel={selectedModel}
477 | setSelectedModel={(model) => {
478 | setSelectedModel(model);
479 | }}
480 | promptStatus={promptStatus}
481 | />
482 | ))}
483 | {activeTab === "api" && (
484 | {
488 | setFormData((prev) => ({
489 | ...prev,
490 | apiUrl: "",
491 | apiKey: "",
492 | }));
493 | }}
494 | />
495 | )}
496 |
497 | )}
498 |
499 |
500 | {/* Right Sidebar - Chat Testing */}
501 |
504 |
505 |
506 | {/* Hidden File Input */}
507 |
514 |
515 | {/* Publish Confirm Dialog */}
516 |
setShowPublishDialog(false)}
519 | onConfirm={confirmPublish}
520 | title={
521 | promptStatus === "published" ? "设为私人" : "发布提示词"
522 | }
523 | message={
524 | promptStatus === "published"
525 | ? "设为私人后将变为草稿状态,不再公开显示。您随时可以重新发布。"
526 | : "发布后任何人都可以看到您分享的提示词,您随时可以设为私人。"
527 | }
528 | confirmText={
529 | isPublishing
530 | ? promptStatus === "published"
531 | ? "处理中..."
532 | : "发布中..."
533 | : promptStatus === "published"
534 | ? "设为私人"
535 | : "发布"
536 | }
537 | cancelText="取消"
538 | type="publish"
539 | />
540 |
541 | );
542 | }
543 |
--------------------------------------------------------------------------------
/src/pages/MyPrompts.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Edit, Trash2, Lock, Search, X, EyeOff } from "lucide-react";
3 | import { ConfirmDialog } from "../components/ConfirmDialog";
4 | import { useNavigate } from "react-router-dom";
5 | import { EditorHeader } from "../components/EditorHeader";
6 | import { MyPromptsListSkeleton } from "../components/MyPromptItemSkeleton";
7 | import { useToast } from "../contexts/ToastContext";
8 | import {
9 | getAllCategories,
10 | getAllAIModels,
11 | deletePrompt,
12 | publishPrompt as publishPromptLocal,
13 | unpublishPrompt as unpublishPromptLocal,
14 | filterPrompts,
15 | getPromptCounts,
16 | getCategoryById,
17 | getAIModelBySlug,
18 | type Prompt,
19 | type Category,
20 | type AIModel,
21 | } from "../services/localStorage";
22 |
23 | const tabs = [
24 | { id: "all", label: "所有创作" },
25 | { id: "published", label: "已发布" },
26 | { id: "draft", label: "草稿" },
27 | ];
28 |
29 | interface DisplayPrompt extends Prompt {
30 | category: { id: number; name: string; slug: string } | null;
31 | ai_model?: { id: number; slug: string; name: string; provider: string } | null;
32 | }
33 |
34 | export function MyPrompts() {
35 | const [activeTab, setActiveTab] = useState("all");
36 | const [showDeleteDialog, setShowDeleteDialog] = useState(false);
37 | const [showPublishDialog, setShowPublishDialog] = useState(false);
38 | const [showUnpublishDialog, setShowUnpublishDialog] = useState(false);
39 | const [selectedPrompt, setSelectedPrompt] = useState(
40 | null,
41 | );
42 | const navigate = useNavigate();
43 | const { showToast } = useToast();
44 |
45 | // 数据状态
46 | const [prompts, setPrompts] = useState([]);
47 | const [isLoading, setIsLoading] = useState(true);
48 | const [isDeleting, setIsDeleting] = useState(false);
49 | const [isPublishing, setIsPublishing] = useState(false);
50 |
51 | // 统计数据
52 | const [counts, setCounts] = useState({
53 | all: 0,
54 | published: 0,
55 | draft: 0,
56 | });
57 |
58 | // 搜索和筛选状态
59 | const [searchInput, setSearchInput] = useState("");
60 | const [searchKeyword, setSearchKeyword] = useState("");
61 | const [selectedCategory, setSelectedCategory] = useState("所有提示词");
62 | const [selectedModel, setSelectedModel] = useState("所有模型");
63 |
64 | // 元数据(分类和模型)
65 | const [categories, setCategories] = useState([]);
66 | const [aiModels, setAIModels] = useState([]);
67 |
68 | // 加载元数据
69 | useEffect(() => {
70 | setCategories(getAllCategories());
71 | setAIModels(getAllAIModels());
72 | }, []);
73 |
74 | // 搜索防抖
75 | useEffect(() => {
76 | const timer = setTimeout(() => {
77 | setSearchKeyword(searchInput);
78 | }, 300);
79 |
80 | return () => clearTimeout(timer);
81 | }, [searchInput]);
82 |
83 | // 加载数据
84 | useEffect(() => {
85 | setIsLoading(true);
86 |
87 | try {
88 | const status =
89 | activeTab === "all"
90 | ? undefined
91 | : activeTab === "published"
92 | ? "published"
93 | : "draft";
94 |
95 | const filteredPrompts = filterPrompts({
96 | status: status as "all" | "draft" | "published" | undefined,
97 | search: searchKeyword.trim() || undefined,
98 | categorySlug:
99 | selectedCategory !== "所有提示词" ? selectedCategory : undefined,
100 | aiModelSlug: selectedModel !== "所有模型" ? selectedModel : undefined,
101 | });
102 |
103 | // 转换为 DisplayPrompt 格式
104 | const displayPrompts: DisplayPrompt[] = filteredPrompts.map((p) => ({
105 | ...p,
106 | category: p.categoryId ? getCategoryById(p.categoryId) : null,
107 | ai_model: p.aiModelSlug ? getAIModelBySlug(p.aiModelSlug) : undefined,
108 | }));
109 |
110 | setPrompts(displayPrompts);
111 | } catch (err) {
112 | console.error("加载提示词异常:", err);
113 | showToast("加载失败,请重试", "error");
114 | } finally {
115 | setIsLoading(false);
116 | }
117 | }, [activeTab, searchKeyword, selectedCategory, selectedModel, showToast]);
118 |
119 | // 加载统计数据
120 | useEffect(() => {
121 | const counts = getPromptCounts();
122 | setCounts(counts);
123 | }, [prompts]); // 当 prompts 变化时更新统计
124 |
125 | const handleDelete = (prompt: DisplayPrompt) => {
126 | setSelectedPrompt(prompt);
127 | setShowDeleteDialog(true);
128 | };
129 |
130 | const confirmDelete = async () => {
131 | if (!selectedPrompt) return;
132 |
133 | setIsDeleting(true);
134 | try {
135 | const success = deletePrompt(selectedPrompt.id);
136 |
137 | if (success) {
138 | // 删除成功,重新加载数据
139 | const filteredPrompts = filterPrompts({
140 | status:
141 | activeTab === "all"
142 | ? undefined
143 | : activeTab === "published"
144 | ? "published"
145 | : "draft",
146 | });
147 |
148 | const displayPrompts: DisplayPrompt[] = filteredPrompts.map((p) => ({
149 | ...p,
150 | category: p.categoryId ? getCategoryById(p.categoryId) : null,
151 | ai_model: p.aiModelSlug ? getAIModelBySlug(p.aiModelSlug) : undefined,
152 | }));
153 |
154 | setPrompts(displayPrompts);
155 | setShowDeleteDialog(false);
156 | setSelectedPrompt(null);
157 | showToast("删除成功", "success");
158 | } else {
159 | showToast("删除失败:未找到该提示词", "error");
160 | }
161 | } finally {
162 | setIsDeleting(false);
163 | }
164 | };
165 |
166 | const handlePublish = (prompt: DisplayPrompt) => {
167 | setSelectedPrompt(prompt);
168 | setShowPublishDialog(true);
169 | };
170 |
171 | const confirmPublish = async () => {
172 | if (!selectedPrompt) return;
173 |
174 | setIsPublishing(true);
175 | try {
176 | const updated = publishPromptLocal(selectedPrompt.id);
177 |
178 | if (updated) {
179 | // 重新加载数据
180 | const filteredPrompts = filterPrompts({
181 | status:
182 | activeTab === "all"
183 | ? undefined
184 | : activeTab === "published"
185 | ? "published"
186 | : "draft",
187 | });
188 |
189 | const displayPrompts: DisplayPrompt[] = filteredPrompts.map((p) => ({
190 | ...p,
191 | category: p.categoryId ? getCategoryById(p.categoryId) : null,
192 | ai_model: p.aiModelSlug ? getAIModelBySlug(p.aiModelSlug) : undefined,
193 | }));
194 |
195 | setPrompts(displayPrompts);
196 | setShowPublishDialog(false);
197 | setSelectedPrompt(null);
198 | showToast("发布成功", "success");
199 | } else {
200 | showToast("发布失败:未找到该提示词", "error");
201 | }
202 | } finally {
203 | setIsPublishing(false);
204 | }
205 | };
206 |
207 | const handleUnpublish = (prompt: DisplayPrompt) => {
208 | setSelectedPrompt(prompt);
209 | setShowUnpublishDialog(true);
210 | };
211 |
212 | const confirmUnpublish = async () => {
213 | if (!selectedPrompt) return;
214 |
215 | setIsPublishing(true);
216 | try {
217 | const updated = unpublishPromptLocal(selectedPrompt.id);
218 |
219 | if (updated) {
220 | // 重新加载数据
221 | const filteredPrompts = filterPrompts({
222 | status:
223 | activeTab === "all"
224 | ? undefined
225 | : activeTab === "published"
226 | ? "published"
227 | : "draft",
228 | });
229 |
230 | const displayPrompts: DisplayPrompt[] = filteredPrompts.map((p) => ({
231 | ...p,
232 | category: p.categoryId ? getCategoryById(p.categoryId) : null,
233 | ai_model: p.aiModelSlug ? getAIModelBySlug(p.aiModelSlug) : undefined,
234 | }));
235 |
236 | setPrompts(displayPrompts);
237 | setShowUnpublishDialog(false);
238 | setSelectedPrompt(null);
239 | showToast("已设为私人", "success");
240 | } else {
241 | showToast("设为私人失败:未找到该提示词", "error");
242 | }
243 | } finally {
244 | setIsPublishing(false);
245 | }
246 | };
247 |
248 | const handleEdit = (id: number) => {
249 | navigate(`/prompt-editor/${id}`);
250 | };
251 |
252 | const handleTabChange = (tabId: string) => {
253 | setActiveTab(tabId);
254 | };
255 |
256 | const handleSearchChange = (value: string) => {
257 | setSearchInput(value);
258 | if (value === "") {
259 | setSearchKeyword("");
260 | }
261 | };
262 |
263 | const handleCategoryChange = (category: string) => {
264 | setSelectedCategory(category);
265 | };
266 |
267 | const handleModelChange = (model: string) => {
268 | setSelectedModel(model);
269 | };
270 |
271 | const getVisibilityDisplay = (
272 | visibility: string,
273 | status: string,
274 | ): { label: string; color: string } => {
275 | if (status === "draft") {
276 | return { label: "私人", color: "gray" };
277 | }
278 |
279 | if (visibility === "public") {
280 | return { label: "公开", color: "green" };
281 | } else if (visibility === "private") {
282 | return { label: "私人", color: "gray" };
283 | } else {
284 | return { label: "不公开列出", color: "yellow" };
285 | }
286 | };
287 |
288 | return (
289 |
290 | {/* Header */}
291 |
292 |
293 | {/* Tabs */}
294 |
295 |
296 | {tabs.map((tab) => (
297 |
317 | ))}
318 |
319 |
320 |
321 | {/* Search and Filter */}
322 |
323 | {/* Search Bar */}
324 |
325 |
326 |
327 |
328 |
handleSearchChange(e.target.value)}
333 | className="block w-full border-none pl-11 pr-10 py-2.5 text-sm font-medium text-white placeholder-gray-400 outline-none ring-transparent backdrop-blur-[30px] rounded-full bg-[#3f3f3f] focus:outline-none focus:ring-2 focus:ring-[#3fda8c]/20 transition-all"
334 | />
335 | {searchInput && (
336 |
343 | )}
344 |
345 |
346 | {/* Category and Model Filters */}
347 |
348 | {/* Category Filter */}
349 |
350 |
351 | 分类:
352 |
353 |
354 |
364 | {categories.map((category) => (
365 |
376 | ))}
377 |
378 |
379 |
380 | {/* Model Filter */}
381 |
382 |
383 | 模型:
384 |
385 |
386 |
396 | {aiModels.map((model) => (
397 |
408 | ))}
409 |
410 |
411 |
412 |
413 |
414 | {/* Content */}
415 |
416 | {/* 首次加载骨架屏 */}
417 | {isLoading && prompts.length === 0 ? (
418 |
419 | ) : prompts.length === 0 ? (
420 |
421 |
422 | {activeTab === "all"
423 | ? "暂无提示词"
424 | : activeTab === "published"
425 | ? "暂无已发布的提示词"
426 | : "暂无草稿"}
427 |
428 |
434 |
435 | ) : (
436 | <>
437 |
438 | {prompts.map((prompt) => {
439 | const visibility = getVisibilityDisplay(
440 | prompt.visibility,
441 | prompt.status,
442 | );
443 | const isDraft = prompt.status === "draft";
444 |
445 | return (
446 |
451 |
452 | {/* Left: Thumbnail */}
453 |
454 |
455 | {prompt.image ? (
456 |

461 | ) : (
462 |
463 |
482 |
483 | )}
484 |
485 |
486 |
487 | {/* Middle: Title, Tags and Actions */}
488 |
489 | {/* Title and Visibility */}
490 |
491 |
492 |
493 | {prompt.title}
494 |
495 |
504 | {visibility.color === "gray" && (
505 |
506 | )}
507 | {visibility.label}
508 |
509 |
510 | {/* Category and AI Model */}
511 | {(prompt.category || prompt.ai_model) && (
512 |
513 | {prompt.category && (
514 |
515 | {prompt.category.name}
516 |
517 | )}
518 | {prompt.ai_model && (
519 |
520 | {prompt.ai_model.name}
521 |
522 | )}
523 |
524 | )}
525 |
526 |
527 | {/* Actions - aligned to bottom */}
528 |
529 | {isDraft ? (
530 |
552 | ) : (
553 |
560 | )}
561 |
562 |
569 |
576 |
577 |
578 |
579 | {/* Right side - Stats */}
580 |
581 | {/* Stats */}
582 |
583 |
584 |
585 | {prompt.statistics.views}
586 |
587 |
588 | 浏览量
589 |
590 |
591 |
592 |
593 | {prompt.statistics.uses}
594 |
595 |
596 | 使用次数
597 |
598 |
599 |
600 |
601 | {prompt.statistics.favorites}
602 |
603 |
604 | 收藏数
605 |
606 |
607 |
608 |
609 |
610 |
611 | );
612 | })}
613 |
614 | >
615 | )}
616 |
617 |
618 | {/* Delete Confirm Dialog */}
619 |
setShowDeleteDialog(false)}
622 | onConfirm={confirmDelete}
623 | title="删除提示词"
624 | message={`确定要删除「${selectedPrompt?.title || ""}」吗?此操作无法撤销。`}
625 | confirmText={isDeleting ? "删除中..." : "删除"}
626 | cancelText="取消"
627 | type="delete"
628 | />
629 |
630 | {/* Publish Confirm Dialog */}
631 | setShowPublishDialog(false)}
634 | onConfirm={confirmPublish}
635 | title="发布提示词"
636 | message={`确定要发布「${selectedPrompt?.title || ""}」吗?发布后任何人都可以看到您分享的提示词。`}
637 | confirmText={isPublishing ? "发布中..." : "发布"}
638 | cancelText="取消"
639 | type="publish"
640 | />
641 |
642 | {/* Unpublish Confirm Dialog */}
643 | setShowUnpublishDialog(false)}
646 | onConfirm={confirmUnpublish}
647 | title="设为私人"
648 | message={`确定要将「${selectedPrompt?.title || ""}」设为私人吗?设为私人后将变为草稿状态,不再公开显示。`}
649 | confirmText={isPublishing ? "设置中..." : "设为私人"}
650 | cancelText="取消"
651 | type="delete"
652 | />
653 |
654 | );
655 | }
656 |
--------------------------------------------------------------------------------