samlax12 commited on
Commit
e85fa50
·
verified ·
1 Parent(s): 36a2bf6

Upload 55 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. public/index.html +43 -0
  2. public/manifest.json +0 -0
  3. public/robots.txt +0 -0
  4. src/App.tsx +89 -0
  5. src/components/Category/CategoryBadge.tsx +26 -0
  6. src/components/Category/CategoryForm.tsx +99 -0
  7. src/components/Category/CategorySelector.tsx +96 -0
  8. src/components/DslFile/DslFileList.tsx +275 -0
  9. src/components/DslFile/DslFileUploader.tsx +238 -0
  10. src/components/Layout/Header.tsx +50 -0
  11. src/components/Layout/Layout.tsx +50 -0
  12. src/components/Layout/Navigation.tsx +74 -0
  13. src/components/Layout/Sidebar.tsx +102 -0
  14. src/components/Prompt/PromptCard.tsx +141 -0
  15. src/components/Prompt/PromptDetail.tsx +186 -0
  16. src/components/Prompt/PromptForm.tsx +166 -0
  17. src/components/Prompt/PromptList.tsx +275 -0
  18. src/components/PromptGroup/PromptGroupCard.tsx +88 -0
  19. src/components/PromptGroup/PromptGroupDetail.tsx +156 -0
  20. src/components/PromptGroup/PromptGroupForm.tsx +118 -0
  21. src/components/PromptGroup/PromptGroupList.tsx +100 -0
  22. src/components/ProtectedRoute.tsx +28 -0
  23. src/components/common/Button.tsx +75 -0
  24. src/components/common/Card.tsx +82 -0
  25. src/components/common/Dropdown.tsx +121 -0
  26. src/components/common/Input.tsx +44 -0
  27. src/components/common/Modal.tsx +163 -0
  28. src/components/common/TextArea.tsx +35 -0
  29. src/contexts/AppContext.tsx +264 -0
  30. src/contexts/AuthContext.tsx +88 -0
  31. src/contexts/ThemeContext.tsx +88 -0
  32. src/hooks/useLocalStorage.ts +92 -0
  33. src/hooks/usePromptGroups.ts +254 -0
  34. src/index.css +0 -0
  35. src/index.tsx +18 -0
  36. src/pages/CategoriesPage.tsx +195 -0
  37. src/pages/CreatePromptGroupPage.tsx +34 -0
  38. src/pages/CreatePromptPage.tsx +50 -0
  39. src/pages/EditPromptGroupPage.tsx +51 -0
  40. src/pages/EditPromptPage.tsx +64 -0
  41. src/pages/HomePage.tsx +17 -0
  42. src/pages/LoginPage.tsx +93 -0
  43. src/pages/PromptGroupDetailPage.tsx +169 -0
  44. src/pages/SettingsPage.tsx +157 -0
  45. src/react-app-env.d.ts +0 -0
  46. src/reportWebVitals.ts +15 -0
  47. src/services/api.ts +139 -0
  48. src/setupTests.ts +0 -0
  49. src/styles/global.css +71 -0
  50. src/styles/iosStyles.css +481 -0
public/index.html ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="theme-color" content="#000000" />
8
+ <meta
9
+ name="description"
10
+ content="提示词管理应用 - 用于管理提示词、工作流和DSL文件"
11
+ />
12
+ <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13
+ <!--
14
+ manifest.json provides metadata used when your web app is installed on a
15
+ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
16
+ -->
17
+ <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
18
+ <!--
19
+ Notice the use of %PUBLIC_URL% in the tags above.
20
+ It will be replaced with the URL of the `public` folder during the build.
21
+ Only files inside the `public` folder can be referenced from the HTML.
22
+
23
+ Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
24
+ work correctly both with client-side routing and a non-root public URL.
25
+ Learn how to configure a non-root public URL by running `npm run build`.
26
+ -->
27
+ <title>提示词管理应用</title>
28
+ </head>
29
+ <body>
30
+ <noscript>您需要启用 JavaScript 来运行此应用。</noscript>
31
+ <div id="root"></div>
32
+ <!--
33
+ This HTML file is a template.
34
+ If you open it directly in the browser, you will see an empty page.
35
+
36
+ You can add webfonts, meta tags, or analytics to this file.
37
+ The build step will place the bundled scripts into the <body> tag.
38
+
39
+ To begin the development, run `npm start` or `yarn start`.
40
+ To create a production bundle, use `npm run build` or `yarn build`.
41
+ -->
42
+ </body>
43
+ </html>
public/manifest.json ADDED
File without changes
public/robots.txt ADDED
File without changes
src/App.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
3
+ import { AuthProvider } from './contexts/AuthContext';
4
+ import { AppProvider } from './contexts/AppContext';
5
+ import ProtectedRoute from './components/ProtectedRoute';
6
+ import LoginPage from './pages/LoginPage';
7
+ import HomePage from './pages/HomePage';
8
+ import PromptGroupDetailPage from './pages/PromptGroupDetailPage';
9
+ import CreatePromptGroupPage from './pages/CreatePromptGroupPage';
10
+ import EditPromptGroupPage from './pages/EditPromptGroupPage';
11
+ import CategoriesPage from './pages/CategoriesPage';
12
+ import SettingsPage from './pages/SettingsPage';
13
+ import './styles/global.css';
14
+ import './styles/iosStyles.css';
15
+
16
+ function App() {
17
+ return (
18
+ <AuthProvider>
19
+ <AppProvider>
20
+ <Router>
21
+ <Routes>
22
+ {/* 公开路由 */}
23
+ <Route path="/login" element={<LoginPage />} />
24
+
25
+ {/* 受保护路由 */}
26
+ <Route
27
+ path="/"
28
+ element={
29
+ <ProtectedRoute>
30
+ <HomePage />
31
+ </ProtectedRoute>
32
+ }
33
+ />
34
+
35
+ <Route
36
+ path="/prompt-group/:id"
37
+ element={
38
+ <ProtectedRoute>
39
+ <PromptGroupDetailPage />
40
+ </ProtectedRoute>
41
+ }
42
+ />
43
+
44
+ <Route
45
+ path="/create"
46
+ element={
47
+ <ProtectedRoute>
48
+ <CreatePromptGroupPage />
49
+ </ProtectedRoute>
50
+ }
51
+ />
52
+
53
+ <Route
54
+ path="/edit-prompt-group/:id"
55
+ element={
56
+ <ProtectedRoute>
57
+ <EditPromptGroupPage />
58
+ </ProtectedRoute>
59
+ }
60
+ />
61
+
62
+ <Route
63
+ path="/categories"
64
+ element={
65
+ <ProtectedRoute>
66
+ <CategoriesPage />
67
+ </ProtectedRoute>
68
+ }
69
+ />
70
+
71
+ <Route
72
+ path="/settings"
73
+ element={
74
+ <ProtectedRoute>
75
+ <SettingsPage />
76
+ </ProtectedRoute>
77
+ }
78
+ />
79
+
80
+ {/* 默认重定向 */}
81
+ <Route path="*" element={<Navigate to="/" replace />} />
82
+ </Routes>
83
+ </Router>
84
+ </AppProvider>
85
+ </AuthProvider>
86
+ );
87
+ }
88
+
89
+ export default App;
src/components/Category/CategoryBadge.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Category } from '../../types';
3
+
4
+ interface CategoryBadgeProps {
5
+ category: Category;
6
+ onClick?: () => void;
7
+ className?: string;
8
+ }
9
+
10
+ const CategoryBadge: React.FC<CategoryBadgeProps> = ({
11
+ category,
12
+ onClick,
13
+ className = ''
14
+ }) => {
15
+ return (
16
+ <div
17
+ className={`ios-tag cursor-pointer ${className}`}
18
+ style={{ backgroundColor: `${category.color}20`, color: category.color }}
19
+ onClick={onClick}
20
+ >
21
+ {category.name}
22
+ </div>
23
+ );
24
+ };
25
+
26
+ export default CategoryBadge;
src/components/Category/CategoryForm.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import Input from '../common/Input';
3
+ import Button from '../common/Button';
4
+ import { Category } from '../../types';
5
+
6
+ interface CategoryFormProps {
7
+ initialCategory?: Partial<Category>;
8
+ onSubmit: (category: Omit<Category, '_id'>) => void;
9
+ onCancel: () => void;
10
+ }
11
+
12
+ const CategoryForm: React.FC<CategoryFormProps> = ({
13
+ initialCategory = {},
14
+ onSubmit,
15
+ onCancel
16
+ }) => {
17
+ const [name, setName] = useState(initialCategory.name || '');
18
+ const [color, setColor] = useState(initialCategory.color || '#007AFF');
19
+ const [error, setError] = useState('');
20
+
21
+ const colorOptions = [
22
+ { color: '#007AFF', name: '蓝色' },
23
+ { color: '#4CD964', name: '绿色' },
24
+ { color: '#FF3B30', name: '红色' },
25
+ { color: '#FF9500', name: '橙色' },
26
+ { color: '#FFCC00', name: '黄色' },
27
+ { color: '#5856D6', name: '紫色' },
28
+ { color: '#FF2D55', name: '粉色' },
29
+ { color: '#5AC8FA', name: '浅蓝色' },
30
+ ];
31
+
32
+ const handleSubmit = (e: React.FormEvent) => {
33
+ e.preventDefault();
34
+
35
+ if (!name.trim()) {
36
+ setError('请输入分类名称');
37
+ return;
38
+ }
39
+
40
+ onSubmit({
41
+ name: name.trim(),
42
+ color
43
+ });
44
+ };
45
+
46
+
47
+ return (
48
+ <form onSubmit={handleSubmit} className="space-y-4">
49
+ <Input
50
+ label="分类名称"
51
+ placeholder="输入分类名称"
52
+ value={name}
53
+ onChange={(e) => {
54
+ setName(e.target.value);
55
+ if (error) setError('');
56
+ }}
57
+ error={error}
58
+ required
59
+ />
60
+
61
+ <div>
62
+ <label className="block text-sm font-medium mb-1 text-gray-700">
63
+ 颜色
64
+ </label>
65
+ <div className="flex flex-wrap gap-2">
66
+ {colorOptions.map((option) => (
67
+ <div
68
+ key={option.color}
69
+ className={`
70
+ w-8 h-8 rounded-full cursor-pointer
71
+ ${color === option.color ? 'ring-2 ring-offset-2 ring-gray-400' : ''}
72
+ `}
73
+ style={{ backgroundColor: option.color }}
74
+ onClick={() => setColor(option.color)}
75
+ title={option.name}
76
+ />
77
+ ))}
78
+ </div>
79
+ </div>
80
+
81
+ <div className="flex justify-end space-x-2 mt-4">
82
+ <Button
83
+ variant="secondary"
84
+ onClick={onCancel}
85
+ >
86
+ 取消
87
+ </Button>
88
+ <Button
89
+ variant="primary"
90
+ type="submit"
91
+ >
92
+ 保存
93
+ </Button>
94
+ </div>
95
+ </form>
96
+ );
97
+ };
98
+
99
+ export default CategoryForm;
src/components/Category/CategorySelector.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Category } from '../../types';
3
+ import { useApp } from '../../contexts/AppContext';
4
+ import CategoryBadge from './CategoryBadge';
5
+
6
+ interface CategorySelectorProps {
7
+ selectedCategory: string | Category;
8
+ onChange: (categoryId: string) => void;
9
+ className?: string;
10
+ }
11
+
12
+
13
+ const CategorySelector: React.FC<CategorySelectorProps> = ({
14
+ selectedCategory,
15
+ onChange,
16
+ className = ''
17
+ }) => {
18
+ const { categories } = useApp();
19
+ const [showDropdown, setShowDropdown] = useState(false);
20
+
21
+ const selectedCategoryObj = categories.find(c => c._id === selectedCategory);
22
+
23
+ const handleCategorySelect = (categoryId: string) => {
24
+ onChange(categoryId);
25
+ setShowDropdown(false);
26
+ };
27
+
28
+ return (
29
+ <div className={`relative ${className}`}>
30
+ <div
31
+ className="flex items-center cursor-pointer"
32
+ onClick={() => setShowDropdown(!showDropdown)}
33
+ >
34
+ {selectedCategoryObj ? (
35
+ <CategoryBadge category={selectedCategoryObj} />
36
+ ) : (
37
+ <div className="ios-tag" style={{ backgroundColor: '#f0f0f0', color: '#666' }}>
38
+ 选择分类
39
+ </div>
40
+ )}
41
+ <svg
42
+ xmlns="http://www.w3.org/2000/svg"
43
+ width="16"
44
+ height="16"
45
+ viewBox="0 0 24 24"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ strokeWidth="2"
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ className="ml-1"
52
+ >
53
+ <polyline points="6 9 12 15 18 9"></polyline>
54
+ </svg>
55
+ </div>
56
+
57
+ {showDropdown && (
58
+ <div className="absolute z-10 mt-1 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
59
+ <div className="py-1" role="menu" aria-orientation="vertical">
60
+ {categories.map((category) => (
61
+ <div
62
+ key={category._id}
63
+ className="px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center"
64
+ onClick={() => handleCategorySelect(category._id)}
65
+ >
66
+ <div
67
+ className="w-3 h-3 rounded-full mr-2"
68
+ style={{ backgroundColor: category.color }}
69
+ ></div>
70
+ {category.name}
71
+ {selectedCategory === category._id && (
72
+ <svg
73
+ xmlns="http://www.w3.org/2000/svg"
74
+ width="16"
75
+ height="16"
76
+ viewBox="0 0 24 24"
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth="2"
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ className="ml-auto"
83
+ >
84
+ <polyline points="20 6 9 17 4 12"></polyline>
85
+ </svg>
86
+ )}
87
+ </div>
88
+ ))}
89
+ </div>
90
+ </div>
91
+ )}
92
+ </div>
93
+ );
94
+ };
95
+
96
+ export default CategorySelector;
src/components/DslFile/DslFileList.tsx ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { DslFile } from '../../types';
3
+ import { useApp } from '../../contexts/AppContext';
4
+ import Button from '../common/Button';
5
+ import TextArea from '../common/TextArea';
6
+ import Input from '../common/Input';
7
+ import Modal from '../common/Modal';
8
+
9
+ interface DslFileListProps {
10
+ groupId: string;
11
+ files: DslFile[];
12
+ }
13
+
14
+ const DslFileList: React.FC<DslFileListProps> = ({ groupId, files }) => {
15
+ const { deleteDslFile, updateDslFile } = useApp();
16
+ const [selectedFile, setSelectedFile] = useState<DslFile | null>(null);
17
+ const [showViewModal, setShowViewModal] = useState(false);
18
+ const [showEditModal, setShowEditModal] = useState(false);
19
+ const [editFileName, setEditFileName] = useState('');
20
+ const [editContent, setEditContent] = useState('');
21
+ const [error, setError] = useState('');
22
+
23
+ if (files.length === 0) {
24
+ return (
25
+ <div className="ios-empty-state">
26
+ <div className="ios-empty-state-icon">
27
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
28
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
29
+ <polyline points="14 2 14 8 20 8"></polyline>
30
+ <line x1="16" y1="13" x2="8" y2="13"></line>
31
+ <line x1="16" y1="17" x2="8" y2="17"></line>
32
+ <polyline points="10 9 9 9 8 9"></polyline>
33
+ </svg>
34
+ </div>
35
+ <h3 className="ios-empty-state-title">暂无 YAML 文件</h3>
36
+ <p className="ios-empty-state-text">上传或添加 YAML 文件以便于存储和管理</p>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ const formatDate = (date: string | Date) => {
42
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
43
+ return dateObj.toLocaleDateString('zh-CN', {
44
+ year: 'numeric',
45
+ month: 'long',
46
+ day: 'numeric',
47
+ hour: '2-digit',
48
+ minute: '2-digit'
49
+ });
50
+ };
51
+
52
+ const handleDelete = (fileId: string) => {
53
+ if (window.confirm('确定要删除此 YAML 文件吗?此操作不可撤销。')) {
54
+ deleteDslFile(groupId, fileId);
55
+ }
56
+ };
57
+
58
+ const handleViewFile = (file: DslFile) => {
59
+ setSelectedFile(file);
60
+ setShowViewModal(true);
61
+ };
62
+
63
+ const handleEditFile = (file: DslFile) => {
64
+ setSelectedFile(file);
65
+ setEditFileName(file.name);
66
+ setEditContent(file.content);
67
+ setShowEditModal(true);
68
+ };
69
+
70
+ const handleSaveEdit = async () => {
71
+ if (!selectedFile) return;
72
+ setError('');
73
+
74
+ if (!editFileName.trim()) {
75
+ setError('请输入文件名');
76
+ return;
77
+ }
78
+
79
+ if (!editContent.trim()) {
80
+ setError('请输入YAML内容');
81
+ return;
82
+ }
83
+
84
+ try {
85
+ await updateDslFile(groupId, selectedFile._id, {
86
+ name: editFileName,
87
+ content: editContent
88
+ });
89
+ setShowEditModal(false);
90
+ } catch (err: any) {
91
+ console.error('更新YAML文件失败:', err);
92
+ setError(err.message || '更新失败,请重试');
93
+ }
94
+ };
95
+
96
+ const handleExportFile = (file: DslFile) => {
97
+ // 创建 Blob 对象
98
+ const blob = new Blob([file.content], { type: 'text/yaml' });
99
+
100
+ // 创建下载链接
101
+ const url = URL.createObjectURL(blob);
102
+ const a = document.createElement('a');
103
+ a.href = url;
104
+ a.download = file.name;
105
+ document.body.appendChild(a);
106
+ a.click();
107
+
108
+ // 清理
109
+ setTimeout(() => {
110
+ document.body.removeChild(a);
111
+ URL.revokeObjectURL(url);
112
+ }, 0);
113
+ };
114
+
115
+ const getFileIcon = (fileName: string) => {
116
+ return (
117
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#5856D6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
118
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
119
+ <polyline points="14 2 14 8 20 8"></polyline>
120
+ <path d="M8 13h2"></path>
121
+ <path d="M8 17h2"></path>
122
+ <path d="M14 13h2"></path>
123
+ <path d="M14 17h2"></path>
124
+ </svg>
125
+ );
126
+ };
127
+
128
+ return (
129
+ <div className="ios-list">
130
+ {files.map((file) => (
131
+ <div key={file._id} className="ios-list-item">
132
+ <div className="mr-3">
133
+ {getFileIcon(file.name)}
134
+ </div>
135
+ <div className="ios-list-item-content">
136
+ <div className="ios-list-item-title">{file.name}</div>
137
+ <div className="ios-list-item-subtitle">
138
+ 上传于 {formatDate(file.uploadedAt)}
139
+ </div>
140
+ </div>
141
+ <div className="flex space-x-2">
142
+ <button
143
+ className="text-blue-500 p-2"
144
+ onClick={() => handleViewFile(file)}
145
+ title="查看"
146
+ >
147
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
148
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
149
+ <circle cx="12" cy="12" r="3"></circle>
150
+ </svg>
151
+ </button>
152
+ <button
153
+ className="text-green-500 p-2"
154
+ onClick={() => handleEditFile(file)}
155
+ title="编辑"
156
+ >
157
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
158
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
159
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
160
+ </svg>
161
+ </button>
162
+ <button
163
+ className="text-indigo-500 p-2"
164
+ onClick={() => handleExportFile(file)}
165
+ title="导出"
166
+ >
167
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
168
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
169
+ <polyline points="7 10 12 15 17 10"></polyline>
170
+ <line x1="12" y1="15" x2="12" y2="3"></line>
171
+ </svg>
172
+ </button>
173
+ <button
174
+ className="text-red-500 p-2"
175
+ onClick={() => handleDelete(file._id)}
176
+ title="删除"
177
+ >
178
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
179
+ <polyline points="3 6 5 6 21 6"></polyline>
180
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
181
+ <line x1="10" y1="11" x2="10" y2="17"></line>
182
+ <line x1="14" y1="11" x2="14" y2="17"></line>
183
+ </svg>
184
+ </button>
185
+ </div>
186
+ </div>
187
+ ))}
188
+
189
+ {/* 查看文件模态框 */}
190
+ <Modal
191
+ isOpen={showViewModal}
192
+ onClose={() => setShowViewModal(false)}
193
+ title={selectedFile?.name || "查看 YAML 文件"}
194
+ >
195
+ <div className="space-y-4">
196
+ {selectedFile && (
197
+ <div>
198
+ <div className="bg-gray-50 p-4 rounded-lg">
199
+ <pre className="whitespace-pre-wrap text-sm font-mono overflow-auto max-h-96">
200
+ {selectedFile.content}
201
+ </pre>
202
+ </div>
203
+
204
+ <div className="flex justify-end mt-4 space-x-2">
205
+ <Button
206
+ variant="secondary"
207
+ onClick={() => setShowViewModal(false)}
208
+ >
209
+ 关闭
210
+ </Button>
211
+ <Button
212
+ variant="primary"
213
+ onClick={() => {
214
+ if (selectedFile) {
215
+ handleExportFile(selectedFile);
216
+ }
217
+ }}
218
+ >
219
+ 导出
220
+ </Button>
221
+ </div>
222
+ </div>
223
+ )}
224
+ </div>
225
+ </Modal>
226
+
227
+ {/* 编辑文件模态框 */}
228
+ <Modal
229
+ isOpen={showEditModal}
230
+ onClose={() => setShowEditModal(false)}
231
+ title="编辑 YAML 文件"
232
+ >
233
+ <div className="space-y-4">
234
+ {error && (
235
+ <div className="bg-red-50 text-red-600 p-3 rounded-lg">
236
+ {error}
237
+ </div>
238
+ )}
239
+
240
+ <Input
241
+ label="文件名"
242
+ value={editFileName}
243
+ onChange={(e) => setEditFileName(e.target.value)}
244
+ required
245
+ />
246
+
247
+ <TextArea
248
+ label="YAML 内容"
249
+ value={editContent}
250
+ onChange={(e) => setEditContent(e.target.value)}
251
+ rows={12}
252
+ required
253
+ />
254
+
255
+ <div className="flex justify-end mt-4 space-x-2">
256
+ <Button
257
+ variant="secondary"
258
+ onClick={() => setShowEditModal(false)}
259
+ >
260
+ 取消
261
+ </Button>
262
+ <Button
263
+ variant="primary"
264
+ onClick={handleSaveEdit}
265
+ >
266
+ 保存
267
+ </Button>
268
+ </div>
269
+ </div>
270
+ </Modal>
271
+ </div>
272
+ );
273
+ };
274
+
275
+ export default DslFileList;
src/components/DslFile/DslFileUploader.tsx ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useState } from 'react';
2
+ import { useApp } from '../../contexts/AppContext';
3
+ import Button from '../common/Button';
4
+ import Modal, { ModalFooter, ModalButton } from '../common/Modal';
5
+ import TextArea from '../common/TextArea';
6
+ import Input from '../common/Input';
7
+
8
+ interface DslFileUploaderProps {
9
+ groupId: string;
10
+ onUploadComplete?: () => void;
11
+ }
12
+
13
+ const DslFileUploader: React.FC<DslFileUploaderProps> = ({
14
+ groupId,
15
+ onUploadComplete
16
+ }) => {
17
+ const { addDslFile } = useApp();
18
+ const fileInputRef = useRef<HTMLInputElement>(null);
19
+ const [isUploading, setIsUploading] = useState(false);
20
+ const [showModal, setShowModal] = useState(false);
21
+
22
+ // 用于直接输入 YAML 内容的状态
23
+ const [fileName, setFileName] = useState('');
24
+ const [content, setContent] = useState('');
25
+ const [error, setError] = useState('');
26
+
27
+ // 处理文件选择
28
+ const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
29
+ if (!e.target.files || e.target.files.length === 0) return;
30
+
31
+ setIsUploading(true);
32
+ setError('');
33
+
34
+ try {
35
+ const file = e.target.files[0];
36
+
37
+ // 验证文件类型
38
+ const validTypes = ['.yml', '.yaml', '.txt', '.json', '.dsl'];
39
+ const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
40
+
41
+ if (!validTypes.includes(fileExtension)) {
42
+ throw new Error('不支持的文件类型。请上传 YAML, JSON, TXT 或 DSL 文件');
43
+ }
44
+
45
+ // 读取文件内容为文本
46
+ const reader = new FileReader();
47
+
48
+ reader.onload = async (event) => {
49
+ if (!event.target || typeof event.target.result !== 'string') {
50
+ throw new Error('文件读取失败');
51
+ }
52
+
53
+ // 获取文件的文本内容
54
+ const fileContent = event.target.result;
55
+
56
+ // 修改文件名,确保扩展名为 .yml
57
+ let fileName = file.name;
58
+ if (!fileName.toLowerCase().endsWith('.yml')) {
59
+ fileName = fileName.replace(/\.[^/.]+$/, '') + '.yml';
60
+ }
61
+
62
+ await addDslFile(groupId, {
63
+ name: fileName,
64
+ content: fileContent
65
+ });
66
+
67
+ if (onUploadComplete) {
68
+ onUploadComplete();
69
+ }
70
+
71
+ // 重置 input
72
+ if (fileInputRef.current) {
73
+ fileInputRef.current.value = '';
74
+ }
75
+ };
76
+
77
+ reader.onerror = () => {
78
+ throw new Error('文件读取错误');
79
+ };
80
+
81
+ reader.readAsText(file); // 直接读取为文本
82
+ } catch (error: any) {
83
+ console.error('文件上传失败:', error);
84
+ setError(error.message || '上传失败,请重试');
85
+ } finally {
86
+ setIsUploading(false);
87
+ }
88
+ };
89
+
90
+ // 处理直接粘贴 YAML 内容的提交
91
+ const handleContentSubmit = async () => {
92
+ if (!fileName.trim()) {
93
+ setError('请输入文件名');
94
+ return;
95
+ }
96
+
97
+ if (!content.trim()) {
98
+ setError('请输入YAML内容');
99
+ return;
100
+ }
101
+
102
+ setIsUploading(true);
103
+ setError('');
104
+
105
+ try {
106
+ // 确保文件名以 .yml 结尾
107
+ let finalFileName = fileName;
108
+ if (!finalFileName.toLowerCase().endsWith('.yml')) {
109
+ finalFileName = finalFileName.replace(/\.[^/.]+$/, '') + '.yml';
110
+ }
111
+
112
+ await addDslFile(groupId, {
113
+ name: finalFileName,
114
+ content: content
115
+ });
116
+
117
+ if (onUploadComplete) {
118
+ onUploadComplete();
119
+ }
120
+
121
+ // 重置表单并关闭模态框
122
+ setFileName('');
123
+ setContent('');
124
+ setShowModal(false);
125
+ } catch (error: any) {
126
+ console.error('添加YAML失败:', error);
127
+ setError(error.message || '添加失败,请重试');
128
+ } finally {
129
+ setIsUploading(false);
130
+ }
131
+ };
132
+
133
+ const triggerFileInput = () => {
134
+ if (fileInputRef.current) {
135
+ fileInputRef.current.click();
136
+ }
137
+ };
138
+
139
+ return (
140
+ <div>
141
+ <div className="flex space-x-2">
142
+ {/* 文件上传按钮 */}
143
+ <input
144
+ type="file"
145
+ ref={fileInputRef}
146
+ onChange={handleFileChange}
147
+ style={{ display: 'none' }}
148
+ accept=".yml,.yaml,.txt,.json,.dsl"
149
+ />
150
+
151
+ <Button
152
+ variant="primary"
153
+ onClick={triggerFileInput}
154
+ disabled={isUploading}
155
+ icon={
156
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
157
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
158
+ <polyline points="17 8 12 3 7 8"></polyline>
159
+ <line x1="12" y1="3" x2="12" y2="15"></line>
160
+ </svg>
161
+ }
162
+ >
163
+ {isUploading ? '上传���...' : '上传 YAML 文件'}
164
+ </Button>
165
+
166
+ {/* 添加直接粘贴内容的按钮 */}
167
+ <Button
168
+ variant="secondary"
169
+ onClick={() => setShowModal(true)}
170
+ icon={
171
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
172
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
173
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
174
+ </svg>
175
+ }
176
+ >
177
+ 粘贴 YAML 内容
178
+ </Button>
179
+ </div>
180
+
181
+ {error && (
182
+ <div className="mt-2 text-sm text-red-600">
183
+ {error}
184
+ </div>
185
+ )}
186
+
187
+ {/* 粘贴 YAML 内容的模态框 */}
188
+ <Modal
189
+ isOpen={showModal}
190
+ onClose={() => setShowModal(false)}
191
+ title="添加 YAML 内容"
192
+ footer={
193
+ <ModalFooter>
194
+ <ModalButton
195
+ variant="secondary"
196
+ onClick={() => setShowModal(false)}
197
+ >
198
+ 取消
199
+ </ModalButton>
200
+ <ModalButton
201
+ variant="primary"
202
+ onClick={handleContentSubmit}
203
+ >
204
+ 添加
205
+ </ModalButton>
206
+ </ModalFooter>
207
+ }
208
+ >
209
+ <div className="space-y-4">
210
+ {error && (
211
+ <div className="bg-red-50 text-red-600 p-3 rounded-lg mb-4">
212
+ {error}
213
+ </div>
214
+ )}
215
+
216
+ <Input
217
+ label="文件名"
218
+ value={fileName}
219
+ onChange={(e) => setFileName(e.target.value)}
220
+ placeholder="例如: workflow.yml"
221
+ required
222
+ />
223
+
224
+ <TextArea
225
+ label="YAML 内容"
226
+ value={content}
227
+ onChange={(e) => setContent(e.target.value)}
228
+ placeholder="输入或粘贴 YAML 内容..."
229
+ rows={12}
230
+ required
231
+ />
232
+ </div>
233
+ </Modal>
234
+ </div>
235
+ );
236
+ };
237
+
238
+ export default DslFileUploader;
src/components/Layout/Header.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+
4
+ interface HeaderProps {
5
+ title?: string;
6
+ showBackButton?: boolean;
7
+ rightAction?: React.ReactNode;
8
+ }
9
+
10
+ const Header: React.FC<HeaderProps> = ({
11
+ title,
12
+ showBackButton = false,
13
+ rightAction
14
+ }) => {
15
+ const navigate = useNavigate();
16
+
17
+ const handleBack = () => {
18
+ navigate(-1);
19
+ };
20
+
21
+ return (
22
+ <header className="ios-navbar">
23
+ <div className="flex items-center justify-between w-full">
24
+ {showBackButton ? (
25
+ <button
26
+ className="ios-navbar-button flex items-center"
27
+ onClick={handleBack}
28
+ >
29
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
30
+ <path d="M15 18l-6-6 6-6" />
31
+ </svg>
32
+ <span>返回</span>
33
+ </button>
34
+ ) : (
35
+ <div className="w-20"></div>
36
+ )}
37
+
38
+ {title && <h1 className="ios-navbar-title">{title}</h1>}
39
+
40
+ {rightAction ? (
41
+ <div className="flex items-center">{rightAction}</div>
42
+ ) : (
43
+ <div className="w-20"></div>
44
+ )}
45
+ </div>
46
+ </header>
47
+ );
48
+ };
49
+
50
+ export default Header;
src/components/Layout/Layout.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import Header from './Header';
3
+ import Navigation from './Navigation';
4
+ import Sidebar from './Sidebar';
5
+
6
+ interface LayoutProps {
7
+ children: React.ReactNode;
8
+ title?: string;
9
+ showBackButton?: boolean;
10
+ rightAction?: React.ReactNode;
11
+ }
12
+
13
+ const Layout: React.FC<LayoutProps> = ({
14
+ children,
15
+ title,
16
+ showBackButton = false,
17
+ rightAction
18
+ }) => {
19
+ return (
20
+ <div className="flex min-h-screen bg-gray-100">
21
+ {/* 桌面侧边栏 - 仅在中等及更大屏幕显示 */}
22
+ <div className="hidden md:block w-64 border-r border-gray-200 bg-white">
23
+ <Sidebar />
24
+ </div>
25
+
26
+ {/* 主内容区 */}
27
+ <div className="flex flex-col flex-1 w-full">
28
+ <Header
29
+ title={title}
30
+ showBackButton={showBackButton}
31
+ rightAction={rightAction}
32
+ />
33
+
34
+ <main className="flex-1 p-4 md:p-6 pb-20 md:pb-6 overflow-auto">
35
+ {/* 包装器,提供合适的最大宽度 */}
36
+ <div className="max-w-7xl mx-auto">
37
+ {children}
38
+ </div>
39
+ </main>
40
+
41
+ {/* 移动导航 - 仅在小屏幕显示 */}
42
+ <div className="md:hidden">
43
+ <Navigation />
44
+ </div>
45
+ </div>
46
+ </div>
47
+ );
48
+ };
49
+
50
+ export default Layout;
src/components/Layout/Navigation.tsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useLocation, useNavigate } from 'react-router-dom';
3
+
4
+ const Navigation: React.FC = () => {
5
+ const location = useLocation();
6
+ const navigate = useNavigate();
7
+
8
+ const isActive = (path: string) => {
9
+ return location.pathname === path;
10
+ };
11
+
12
+ const navItems = [
13
+ {
14
+ name: '首页',
15
+ path: '/',
16
+ icon: (
17
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
18
+ <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
19
+ <polyline points="9 22 9 12 15 12 15 22" />
20
+ </svg>
21
+ )
22
+ },
23
+ {
24
+ name: '分类',
25
+ path: '/categories',
26
+ icon: (
27
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
28
+ <rect x="3" y="3" width="7" height="7" />
29
+ <rect x="14" y="3" width="7" height="7" />
30
+ <rect x="14" y="14" width="7" height="7" />
31
+ <rect x="3" y="14" width="7" height="7" />
32
+ </svg>
33
+ )
34
+ },
35
+ {
36
+ name: '新建',
37
+ path: '/create',
38
+ icon: (
39
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
40
+ <circle cx="12" cy="12" r="10" />
41
+ <line x1="12" y1="8" x2="12" y2="16" />
42
+ <line x1="8" y1="12" x2="16" y2="12" />
43
+ </svg>
44
+ )
45
+ },
46
+ {
47
+ name: '设置',
48
+ path: '/settings',
49
+ icon: (
50
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
51
+ <circle cx="12" cy="12" r="3" />
52
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
53
+ </svg>
54
+ )
55
+ }
56
+ ];
57
+
58
+ return (
59
+ <nav className="ios-toolbar">
60
+ {navItems.map((item) => (
61
+ <button
62
+ key={item.path}
63
+ className={`ios-toolbar-item ${isActive(item.path) ? 'active' : ''}`}
64
+ onClick={() => navigate(item.path)}
65
+ >
66
+ <div className="mb-1">{item.icon}</div>
67
+ <span>{item.name}</span>
68
+ </button>
69
+ ))}
70
+ </nav>
71
+ );
72
+ };
73
+
74
+ export default Navigation;
src/components/Layout/Sidebar.tsx ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useNavigate, useLocation } from 'react-router-dom';
3
+ import { useAuth } from '../../contexts/AuthContext';
4
+
5
+ const Sidebar: React.FC = () => {
6
+ const navigate = useNavigate();
7
+ const location = useLocation();
8
+ const { user, logout } = useAuth();
9
+
10
+ const navItems = [
11
+ {
12
+ name: '首页',
13
+ path: '/',
14
+ icon: (
15
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
16
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
17
+ </svg>
18
+ )
19
+ },
20
+ {
21
+ name: '分类',
22
+ path: '/categories',
23
+ icon: (
24
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
25
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
26
+ </svg>
27
+ )
28
+ },
29
+ {
30
+ name: '新建',
31
+ path: '/create',
32
+ icon: (
33
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
34
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
35
+ </svg>
36
+ )
37
+ },
38
+ {
39
+ name: '设置',
40
+ path: '/settings',
41
+ icon: (
42
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
43
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
44
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
45
+ </svg>
46
+ )
47
+ }
48
+ ];
49
+
50
+ const isActive = (path: string) => {
51
+ return location.pathname === path;
52
+ };
53
+
54
+ return (
55
+ <div className="h-full flex flex-col">
56
+ <div className="p-4 border-b border-gray-200">
57
+ <h1 className="text-xl font-bold text-gray-800">提示词管理</h1>
58
+ </div>
59
+
60
+ <div className="flex-1 overflow-y-auto p-4">
61
+ <nav className="space-y-2">
62
+ {navItems.map((item) => (
63
+ <button
64
+ key={item.path}
65
+ onClick={() => navigate(item.path)}
66
+ className={`w-full flex items-center space-x-3 p-3 rounded-lg transition-colors
67
+ ${isActive(item.path)
68
+ ? 'bg-blue-50 text-blue-600'
69
+ : 'text-gray-600 hover:bg-gray-100'
70
+ }`}
71
+ >
72
+ <span>{item.icon}</span>
73
+ <span>{item.name}</span>
74
+ </button>
75
+ ))}
76
+ </nav>
77
+ </div>
78
+
79
+ {user && (
80
+ <div className="p-4 border-t border-gray-200">
81
+ <div className="flex items-center">
82
+ <div className="flex-1">
83
+ <p className="text-sm font-medium">{user}</p>
84
+ <p className="text-xs text-gray-500">已登录</p>
85
+ </div>
86
+ <button
87
+ onClick={logout}
88
+ className="text-gray-600 hover:text-red-600 p-2"
89
+ title="登出"
90
+ >
91
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
92
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
93
+ </svg>
94
+ </button>
95
+ </div>
96
+ </div>
97
+ )}
98
+ </div>
99
+ );
100
+ };
101
+
102
+ export default Sidebar;
src/components/Prompt/PromptCard.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Prompt } from '../../types';
3
+ import Card, { CardHeader, CardContent, CardFooter } from '../common/Card';
4
+ import Button from '../common/Button';
5
+
6
+ interface PromptCardProps {
7
+ prompt: Prompt;
8
+ onEdit?: () => void;
9
+ onDelete?: () => void;
10
+ onExport?: () => void;
11
+ className?: string;
12
+ showActions?: boolean;
13
+ selected?: boolean;
14
+ onSelect?: () => void;
15
+ }
16
+
17
+ const PromptCard: React.FC<PromptCardProps> = ({
18
+ prompt,
19
+ onEdit,
20
+ onDelete,
21
+ onExport,
22
+ className = '',
23
+ showActions = true,
24
+ selected = false,
25
+ onSelect
26
+ }) => {
27
+ const formatDate = (date: string | Date) => {
28
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
29
+ return dateObj.toLocaleDateString('zh-CN', {
30
+ year: 'numeric',
31
+ month: 'long',
32
+ day: 'numeric'
33
+ });
34
+ };
35
+
36
+ // 截断长内容并添加"查看更多"
37
+ const truncateContent = (content: string, maxLength: number = 200) => {
38
+ if (content.length <= maxLength) return content;
39
+ return `${content.substring(0, maxLength)}...`;
40
+ };
41
+
42
+ return (
43
+ <Card className={`${className}`}>
44
+ <CardHeader
45
+ title={prompt.title}
46
+ subtitle={`更新于 ${formatDate(prompt.updatedAt)}`}
47
+ action={
48
+ onSelect && (
49
+ <div
50
+ className={`
51
+ w-6 h-6 rounded-md border border-gray-300 flex items-center justify-center cursor-pointer
52
+ ${selected ? 'bg-blue-50' : ''}
53
+ `}
54
+ onClick={(e: React.MouseEvent<HTMLDivElement>) => {
55
+ e.stopPropagation();
56
+ onSelect();
57
+ }}
58
+ >
59
+ {selected && (
60
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#007AFF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
61
+ <polyline points="20 6 9 17 4 12"></polyline>
62
+ </svg>
63
+ )}
64
+ </div>
65
+ )
66
+ }
67
+ />
68
+
69
+ <CardContent>
70
+ {/* 添加 max-h-48 (192px) 和 overflow-y-auto 来限制高度并启用滚动 */}
71
+ <pre className="whitespace-pre-wrap text-gray-700 mb-4 font-sans max-h-48 overflow-y-auto">
72
+ {truncateContent(prompt.content)}
73
+ </pre>
74
+
75
+ {prompt.tags.length > 0 && (
76
+ <div className="flex flex-wrap mb-3">
77
+ {prompt.tags.map((tag) => (
78
+ <div
79
+ key={tag}
80
+ className="ios-tag bg-blue-100 text-blue-800"
81
+ >
82
+ {tag}
83
+ </div>
84
+ ))}
85
+ </div>
86
+ )}
87
+
88
+ <div className="text-xs text-gray-500">
89
+ 创建于 {formatDate(prompt.createdAt)}
90
+ </div>
91
+ </CardContent>
92
+
93
+ {showActions && (
94
+ <CardFooter>
95
+ <div className="flex justify-end space-x-2">
96
+ {onExport && (
97
+ <Button
98
+ variant="secondary"
99
+ size="small"
100
+ onClick={(e) => {
101
+ if (e) e.stopPropagation();
102
+ onExport();
103
+ }}
104
+ >
105
+ 导出
106
+ </Button>
107
+ )}
108
+
109
+ {onEdit && (
110
+ <Button
111
+ variant="secondary"
112
+ size="small"
113
+ onClick={(e) => {
114
+ if (e) e.stopPropagation();
115
+ onEdit();
116
+ }}
117
+ >
118
+ 编辑
119
+ </Button>
120
+ )}
121
+
122
+ {onDelete && (
123
+ <Button
124
+ variant="danger"
125
+ size="small"
126
+ onClick={(e) => {
127
+ if (e) e.stopPropagation();
128
+ onDelete();
129
+ }}
130
+ >
131
+ 删除
132
+ </Button>
133
+ )}
134
+ </div>
135
+ </CardFooter>
136
+ )}
137
+ </Card>
138
+ );
139
+ };
140
+
141
+ export default PromptCard;
src/components/Prompt/PromptDetail.tsx ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Prompt } from '../../types';
3
+ import Card, { CardHeader, CardContent, CardFooter } from '../common/Card';
4
+ import Button from '../common/Button';
5
+ import Modal, { ModalFooter, ModalButton } from '../common/Modal';
6
+ import { exportPrompt } from '../../utils/exportUtils';
7
+
8
+ interface PromptDetailProps {
9
+ prompt: Prompt;
10
+ onEdit: () => void;
11
+ onDelete: () => void;
12
+ onBack: () => void;
13
+ }
14
+
15
+ const PromptDetail: React.FC<PromptDetailProps> = ({
16
+ prompt,
17
+ onEdit,
18
+ onDelete,
19
+ onBack
20
+ }) => {
21
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
22
+
23
+ const formatDate = (date: string | Date) => {
24
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
25
+ return dateObj.toLocaleDateString('zh-CN', {
26
+ year: 'numeric',
27
+ month: 'long',
28
+ day: 'numeric',
29
+ hour: '2-digit',
30
+ minute: '2-digit'
31
+ });
32
+ };
33
+
34
+ const handleExport = () => {
35
+ exportPrompt(prompt);
36
+ };
37
+
38
+ const handleDelete = () => {
39
+ setShowDeleteModal(true);
40
+ };
41
+
42
+ const confirmDelete = () => {
43
+ onDelete();
44
+ setShowDeleteModal(false);
45
+ };
46
+
47
+ const handleCopy = () => {
48
+ navigator.clipboard.writeText(prompt.content)
49
+ .then(() => {
50
+ alert('提示词内容已复制到剪贴板');
51
+ })
52
+ .catch(err => {
53
+ console.error('复制失败:', err);
54
+ alert('复制失败,请手动选择并复制');
55
+ });
56
+ };
57
+
58
+ return (
59
+ <div>
60
+ <Card>
61
+ <CardHeader
62
+ title={prompt.title}
63
+ action={
64
+ <Button
65
+ variant="secondary"
66
+ size="small"
67
+ onClick={onBack}
68
+ icon={
69
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
70
+ <line x1="19" y1="12" x2="5" y2="12"></line>
71
+ <polyline points="12 19 5 12 12 5"></polyline>
72
+ </svg>
73
+ }
74
+ >
75
+ 返回
76
+ </Button>
77
+ }
78
+ />
79
+
80
+ <CardContent>
81
+ <div className="mb-6">
82
+ <h3 className="text-sm font-medium text-gray-500 mb-2">提示词内容</h3>
83
+ <div className="bg-gray-50 p-4 rounded-lg">
84
+ {/* 添加最大高度和滚动 */}
85
+ <pre className="whitespace-pre-wrap text-gray-700 font-sans max-h-80 overflow-y-auto">
86
+ {prompt.content}
87
+ </pre>
88
+ </div>
89
+ </div>
90
+
91
+ {prompt.tags.length > 0 && (
92
+ <div className="mb-6">
93
+ <h3 className="text-sm font-medium text-gray-500 mb-2">标签</h3>
94
+ <div className="flex flex-wrap">
95
+ {prompt.tags.map((tag) => (
96
+ <div
97
+ key={tag}
98
+ className="ios-tag bg-blue-100 text-blue-800"
99
+ >
100
+ {tag}
101
+ </div>
102
+ ))}
103
+ </div>
104
+ </div>
105
+ )}
106
+
107
+ <div className="mb-6">
108
+ <h3 className="text-sm font-medium text-gray-500 mb-2">信息</h3>
109
+ <div className="grid grid-cols-2 gap-4">
110
+ <div>
111
+ <p className="text-xs text-gray-500">创建时间</p>
112
+ <p className="text-sm">{formatDate(prompt.createdAt)}</p>
113
+ </div>
114
+ <div>
115
+ <p className="text-xs text-gray-500">更新时间</p>
116
+ <p className="text-sm">{formatDate(prompt.updatedAt)}</p>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ </CardContent>
121
+
122
+ <CardFooter className="flex justify-between">
123
+ <div>
124
+ <Button
125
+ variant="secondary"
126
+ size="small"
127
+ onClick={handleCopy}
128
+ >
129
+ 复制内容
130
+ </Button>
131
+ </div>
132
+ <div className="flex space-x-2">
133
+ <Button
134
+ variant="secondary"
135
+ size="small"
136
+ onClick={handleExport}
137
+ >
138
+ 导出
139
+ </Button>
140
+ <Button
141
+ variant="secondary"
142
+ size="small"
143
+ onClick={onEdit}
144
+ >
145
+ 编辑
146
+ </Button>
147
+ <Button
148
+ variant="danger"
149
+ size="small"
150
+ onClick={handleDelete}
151
+ >
152
+ 删除
153
+ </Button>
154
+ </div>
155
+ </CardFooter>
156
+ </Card>
157
+
158
+ <Modal
159
+ isOpen={showDeleteModal}
160
+ onClose={() => setShowDeleteModal(false)}
161
+ title="删除提示词"
162
+ footer={
163
+ <ModalFooter>
164
+ <ModalButton
165
+ variant="secondary"
166
+ onClick={() => setShowDeleteModal(false)}
167
+ >
168
+ 取消
169
+ </ModalButton>
170
+ <ModalButton
171
+ variant="danger"
172
+ onClick={confirmDelete}
173
+ >
174
+ 删除
175
+ </ModalButton>
176
+ </ModalFooter>
177
+ }
178
+ >
179
+ <p className="text-center mb-4">您确定要删除这个提示词吗?</p>
180
+ <p className="text-center text-gray-500 text-sm">此操作不可撤销。</p>
181
+ </Modal>
182
+ </div>
183
+ );
184
+ };
185
+
186
+ export default PromptDetail;
src/components/Prompt/PromptForm.tsx ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Prompt } from '../../types';
3
+ import Input from '../common/Input';
4
+ import TextArea from '../common/TextArea';
5
+ import Button from '../common/Button';
6
+
7
+
8
+ interface PromptFormProps {
9
+ initialPrompt?: Partial<Prompt>;
10
+ onSubmit: (prompt: { title: string; content: string; tags: string[] }) => void;
11
+ onCancel: () => void;
12
+ }
13
+ const PromptForm: React.FC<PromptFormProps> = ({
14
+ initialPrompt = {},
15
+ onSubmit,
16
+ onCancel
17
+ }) => {
18
+ const [title, setTitle] = useState(initialPrompt.title || '');
19
+ const [content, setContent] = useState(initialPrompt.content || '');
20
+ const [tagInput, setTagInput] = useState('');
21
+ const [tags, setTags] = useState<string[]>(initialPrompt.tags || []);
22
+ const [errors, setErrors] = useState({
23
+ title: '',
24
+ content: ''
25
+ });
26
+
27
+ const handleAddTag = () => {
28
+ if (!tagInput.trim()) return;
29
+
30
+ // 如果标签已存在,则不添加
31
+ if (tags.includes(tagInput.trim())) {
32
+ setTagInput('');
33
+ return;
34
+ }
35
+
36
+ setTags([...tags, tagInput.trim()]);
37
+ setTagInput('');
38
+ };
39
+
40
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
41
+ if (e.key === 'Enter') {
42
+ e.preventDefault();
43
+ handleAddTag();
44
+ }
45
+ };
46
+
47
+ const handleRemoveTag = (tagToRemove: string) => {
48
+ setTags(tags.filter(tag => tag !== tagToRemove));
49
+ };
50
+
51
+ const validate = (): boolean => {
52
+ const newErrors = {
53
+ title: '',
54
+ content: ''
55
+ };
56
+
57
+ if (!title.trim()) {
58
+ newErrors.title = '请输入提示词标题';
59
+ }
60
+
61
+ if (!content.trim()) {
62
+ newErrors.content = '请输入提示词内容';
63
+ }
64
+
65
+ setErrors(newErrors);
66
+
67
+ return !newErrors.title && !newErrors.content;
68
+ };
69
+
70
+ const handleSubmit = (e: React.FormEvent) => {
71
+ e.preventDefault();
72
+
73
+ if (!validate()) return;
74
+
75
+ onSubmit({
76
+ title: title.trim(),
77
+ content: content.trim(),
78
+ tags
79
+ });
80
+ };
81
+
82
+ return (
83
+ <form onSubmit={handleSubmit} className="space-y-4">
84
+ <Input
85
+ label="标题"
86
+ placeholder="输入提示词标题"
87
+ value={title}
88
+ onChange={(e) => setTitle(e.target.value)}
89
+ error={errors.title}
90
+ required
91
+ />
92
+
93
+ <TextArea
94
+ label="内容"
95
+ placeholder="输入提示词内容..."
96
+ value={content}
97
+ onChange={(e) => setContent(e.target.value)}
98
+ error={errors.content}
99
+ rows={10}
100
+ className="max-h-80 overflow-y-auto" // 添加最大高度和滚动
101
+ required
102
+ />
103
+
104
+ <div>
105
+ <label className="block text-sm font-medium mb-1 text-gray-700">
106
+ 标签
107
+ </label>
108
+ <div className="flex flex-wrap mb-2">
109
+ {tags.map((tag) => (
110
+ <div
111
+ key={tag}
112
+ className="ios-tag bg-blue-100 text-blue-800 flex items-center"
113
+ >
114
+ {tag}
115
+ <button
116
+ type="button"
117
+ className="ml-1 text-blue-600 hover:text-blue-800"
118
+ onClick={() => handleRemoveTag(tag)}
119
+ >
120
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
121
+ <line x1="18" y1="6" x2="6" y2="18"></line>
122
+ <line x1="6" y1="6" x2="18" y2="18"></line>
123
+ </svg>
124
+ </button>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ <div className="flex">
129
+ <Input
130
+ placeholder="添加标签"
131
+ value={tagInput}
132
+ onChange={(e) => setTagInput(e.target.value)}
133
+ onKeyDown={handleKeyDown}
134
+ className="flex-1 mb-0"
135
+ />
136
+ <Button
137
+ type="button"
138
+ variant="secondary"
139
+ onClick={handleAddTag}
140
+ className="ml-2"
141
+ >
142
+ 添加
143
+ </Button>
144
+ </div>
145
+ </div>
146
+
147
+ <div className="flex justify-end space-x-3 mt-6">
148
+ <Button
149
+ type="button"
150
+ variant="secondary"
151
+ onClick={onCancel}
152
+ >
153
+ 取消
154
+ </Button>
155
+ <Button
156
+ type="submit"
157
+ variant="primary"
158
+ >
159
+ 保存
160
+ </Button>
161
+ </div>
162
+ </form>
163
+ );
164
+ };
165
+
166
+ export default PromptForm;
src/components/Prompt/PromptList.tsx ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Prompt } from '../../types';
3
+ import Card, { CardHeader, CardContent } from '../common/Card';
4
+ import Button from '../common/Button';
5
+ import PromptForm from './PromptForm';
6
+ import { exportPrompt, exportMultiplePrompts } from '../../utils/exportUtils';
7
+
8
+ interface PromptListProps {
9
+ groupId: string;
10
+ prompts: Prompt[];
11
+ onAddPrompt: (promptData: { title: string; content: string; tags: string[] }) => void;
12
+ onUpdatePrompt: (promptId: string, promptData: { title: string; content: string; tags: string[] }) => void;
13
+ onDeletePrompt: (promptId: string) => void;
14
+ }
15
+ const PromptList: React.FC<PromptListProps> = ({
16
+ groupId,
17
+ prompts,
18
+ onAddPrompt,
19
+ onUpdatePrompt,
20
+ onDeletePrompt
21
+ }) => {
22
+ const [showNewPromptForm, setShowNewPromptForm] = useState(false);
23
+ const [editingPromptId, setEditingPromptId] = useState<string | null>(null);
24
+ const [selectedPrompts, setSelectedPrompts] = useState<string[]>([]);
25
+ const [isSelectMode, setIsSelectMode] = useState(false);
26
+
27
+ const handleEditPrompt = (promptId: string) => {
28
+ setEditingPromptId(promptId);
29
+ };
30
+
31
+ const handleDeletePrompt = (promptId: string) => {
32
+ if (window.confirm('确定要删除此提示词吗?此操作不可撤销。')) {
33
+ onDeletePrompt(promptId);
34
+ }
35
+ };
36
+
37
+ const handleSavePrompt = (promptData: { title: string; content: string; tags: string[] }) => {
38
+ if (editingPromptId) {
39
+ // 如果正在编辑提示词,调用 onUpdatePrompt
40
+ onUpdatePrompt(editingPromptId, promptData);
41
+ setEditingPromptId(null);
42
+ } else {
43
+ // 如果正在创建新提示词,调用 onAddPrompt
44
+ onAddPrompt(promptData);
45
+ setShowNewPromptForm(false);
46
+ }
47
+ };
48
+
49
+ const handleExportPrompt = (prompt: Prompt) => {
50
+ exportPrompt(prompt);
51
+ };
52
+
53
+ const handleToggleSelectPrompt = (promptId: string) => {
54
+ setSelectedPrompts(prevSelected => {
55
+ if (prevSelected.includes(promptId)) {
56
+ return prevSelected.filter(id => id !== promptId);
57
+ } else {
58
+ return [...prevSelected, promptId];
59
+ }
60
+ });
61
+ };
62
+
63
+ const handleSelectAll = () => {
64
+ if (selectedPrompts.length === prompts.length) {
65
+ setSelectedPrompts([]);
66
+ } else {
67
+ setSelectedPrompts(prompts.map(p => p._id));
68
+ }
69
+ };
70
+
71
+ const handleExportSelected = () => {
72
+ const selectedPromptObjects = prompts.filter(p => selectedPrompts.includes(p._id));
73
+ exportMultiplePrompts(selectedPromptObjects);
74
+ };
75
+
76
+ const handleDeleteSelected = () => {
77
+ if (window.confirm(`确定要删除选中的 ${selectedPrompts.length} 个提示词吗?此操作不可撤销。`)) {
78
+ selectedPrompts.forEach(promptId => {
79
+ onDeletePrompt(promptId);
80
+ });
81
+ setSelectedPrompts([]);
82
+ setIsSelectMode(false);
83
+ }
84
+ };
85
+
86
+ if (prompts.length === 0 && !showNewPromptForm) {
87
+ return (
88
+ <div>
89
+ <div className="ios-empty-state">
90
+ <div className="ios-empty-state-icon">
91
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
92
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
93
+ <polyline points="14 2 14 8 20 8"></polyline>
94
+ <line x1="16" y1="13" x2="8" y2="13"></line>
95
+ <line x1="16" y1="17" x2="8" y2="17"></line>
96
+ <polyline points="10 9 9 9 8 9"></polyline>
97
+ </svg>
98
+ </div>
99
+ <h3 className="ios-empty-state-title">暂无提示词</h3>
100
+ <p className="ios-empty-state-text">添加提示词以便于管理和使用</p>
101
+ <Button
102
+ variant="primary"
103
+ className="mt-4"
104
+ onClick={() => setShowNewPromptForm(true)}
105
+ >
106
+ 添加提示词
107
+ </Button>
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <div>
115
+ <div className="flex justify-between items-center mb-4">
116
+ <h3 className="text-lg font-medium">提示词列表</h3>
117
+ <div className="flex space-x-2">
118
+ {isSelectMode ? (
119
+ <>
120
+ <Button
121
+ variant="secondary"
122
+ size="small"
123
+ onClick={handleSelectAll}
124
+ >
125
+ {selectedPrompts.length === prompts.length ? '取消全选' : '全选'}
126
+ </Button>
127
+ <Button
128
+ variant="primary"
129
+ size="small"
130
+ onClick={handleExportSelected}
131
+ disabled={selectedPrompts.length === 0}
132
+ >
133
+ 导出选中
134
+ </Button>
135
+ <Button
136
+ variant="danger"
137
+ size="small"
138
+ onClick={handleDeleteSelected}
139
+ disabled={selectedPrompts.length === 0}
140
+ >
141
+ 删除选中
142
+ </Button>
143
+ <Button
144
+ variant="secondary"
145
+ size="small"
146
+ onClick={() => {
147
+ setIsSelectMode(false);
148
+ setSelectedPrompts([]);
149
+ }}
150
+ >
151
+ 取消
152
+ </Button>
153
+ </>
154
+ ) : (
155
+ <>
156
+ <Button
157
+ variant="secondary"
158
+ size="small"
159
+ onClick={() => setIsSelectMode(true)}
160
+ >
161
+ 选择
162
+ </Button>
163
+ <Button
164
+ variant="primary"
165
+ size="small"
166
+ onClick={() => setShowNewPromptForm(true)}
167
+ >
168
+ 添加提示词
169
+ </Button>
170
+ </>
171
+ )}
172
+ </div>
173
+ </div>
174
+
175
+ {showNewPromptForm && !editingPromptId && (
176
+ <Card className="mb-4">
177
+ <CardHeader title="新增提示词" />
178
+ <CardContent>
179
+ <PromptForm
180
+ onSubmit={handleSavePrompt}
181
+ onCancel={() => setShowNewPromptForm(false)}
182
+ />
183
+ </CardContent>
184
+ </Card>
185
+ )}
186
+
187
+ <div className="space-y-4">
188
+ {prompts.map((prompt) => (
189
+ <Card key={prompt._id} className="mb-4">
190
+ {editingPromptId === prompt._id ? (
191
+ <CardContent>
192
+ <PromptForm
193
+ initialPrompt={prompt}
194
+ onSubmit={handleSavePrompt}
195
+ onCancel={() => setEditingPromptId(null)}
196
+ />
197
+ </CardContent>
198
+ ) : (
199
+ <>
200
+ <CardHeader
201
+ title={prompt.title}
202
+ subtitle={new Date(prompt.updatedAt).toLocaleDateString('zh-CN', {
203
+ year: 'numeric',
204
+ month: 'long',
205
+ day: 'numeric'
206
+ })}
207
+ action={
208
+ isSelectMode && (
209
+ <div
210
+ className="w-6 h-6 rounded-md border border-gray-300 flex items-center justify-center cursor-pointer"
211
+ onClick={() => handleToggleSelectPrompt(prompt._id)}
212
+ >
213
+ {selectedPrompts.includes(prompt._id) && (
214
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#007AFF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
215
+ <polyline points="20 6 9 17 4 12"></polyline>
216
+ </svg>
217
+ )}
218
+ </div>
219
+ )
220
+ }
221
+ />
222
+ <CardContent>
223
+ <pre className="whitespace-pre-wrap text-gray-700 mb-4 font-sans max-h-48 overflow-y-auto">
224
+ {prompt.content}
225
+ </pre>
226
+
227
+ {prompt.tags.length > 0 && (
228
+ <div className="flex flex-wrap mb-4">
229
+ {prompt.tags.map((tag) => (
230
+ <div
231
+ key={tag}
232
+ className="ios-tag bg-blue-100 text-blue-800"
233
+ >
234
+ {tag}
235
+ </div>
236
+ ))}
237
+ </div>
238
+ )}
239
+
240
+ {!isSelectMode && (
241
+ <div className="flex justify-end space-x-2">
242
+ <Button
243
+ variant="secondary"
244
+ size="small"
245
+ onClick={() => handleExportPrompt(prompt)}
246
+ >
247
+ 导出
248
+ </Button>
249
+ <Button
250
+ variant="secondary"
251
+ size="small"
252
+ onClick={() => handleEditPrompt(prompt._id)}
253
+ >
254
+ 编辑
255
+ </Button>
256
+ <Button
257
+ variant="danger"
258
+ size="small"
259
+ onClick={() => handleDeletePrompt(prompt._id)}
260
+ >
261
+ 删除
262
+ </Button>
263
+ </div>
264
+ )}
265
+ </CardContent>
266
+ </>
267
+ )}
268
+ </Card>
269
+ ))}
270
+ </div>
271
+ </div>
272
+ );
273
+ };
274
+
275
+ export default PromptList;
src/components/PromptGroup/PromptGroupCard.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { PromptGroup } from '../../types';
4
+ import Card, { CardHeader, CardContent, CardFooter } from '../common/Card';
5
+ import CategoryBadge from '../Category/CategoryBadge';
6
+ import { useApp } from '../../contexts/AppContext';
7
+
8
+ interface PromptGroupCardProps {
9
+ promptGroup: PromptGroup;
10
+ className?: string;
11
+ }
12
+
13
+ const PromptGroupCard: React.FC<PromptGroupCardProps> = ({
14
+ promptGroup,
15
+ className = ''
16
+ }) => {
17
+ const navigate = useNavigate();
18
+ const { categories } = useApp();
19
+
20
+ const category = categories.find(c => c._id === promptGroup.category);
21
+
22
+ const handleClick = () => {
23
+ navigate(`/prompt-group/${promptGroup._id}`);
24
+ };
25
+
26
+ const formatDate = (date: string | Date) => {
27
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
28
+ return dateObj.toLocaleDateString('zh-CN', {
29
+ year: 'numeric',
30
+ month: 'long',
31
+ day: 'numeric'
32
+ });
33
+ };
34
+
35
+ return (
36
+ <Card
37
+ className={`${className}`}
38
+ hoverable
39
+ onClick={handleClick}
40
+ >
41
+ <CardHeader
42
+ title={promptGroup.name}
43
+ subtitle={formatDate(promptGroup.updatedAt)}
44
+ action={category && <CategoryBadge category={category} />}
45
+ />
46
+ <CardContent>
47
+ <p className="text-gray-600 line-clamp-2">{promptGroup.description || '无描述'}</p>
48
+ <div className="mt-3 flex items-center text-sm text-gray-500">
49
+ <div className="flex items-center mr-4">
50
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
51
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
52
+ <polyline points="14 2 14 8 20 8"></polyline>
53
+ <line x1="16" y1="13" x2="8" y2="13"></line>
54
+ <line x1="16" y1="17" x2="8" y2="17"></line>
55
+ <polyline points="10 9 9 9 8 9"></polyline>
56
+ </svg>
57
+ {promptGroup.prompts.length} 个提示词
58
+ </div>
59
+ <div className="flex items-center">
60
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
61
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
62
+ </svg>
63
+ {promptGroup.workflows.length} 个工作流
64
+ </div>
65
+ </div>
66
+ </CardContent>
67
+ <CardFooter className="flex justify-between items-center">
68
+ <div className="text-xs text-gray-500">
69
+ 创建于 {formatDate(promptGroup.createdAt)}
70
+ </div>
71
+ <button
72
+ className="text-blue-500 flex items-center"
73
+ onClick={(e) => {
74
+ e.stopPropagation();
75
+ navigate(`/prompt-group/${promptGroup._id}`);
76
+ }}
77
+ >
78
+ 查看详情
79
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-1">
80
+ <polyline points="9 18 15 12 9 6"></polyline>
81
+ </svg>
82
+ </button>
83
+ </CardFooter>
84
+ </Card>
85
+ );
86
+ };
87
+
88
+ export default PromptGroupCard;
src/components/PromptGroup/PromptGroupDetail.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { PromptGroup } from '../../types';
3
+ import Card, { CardHeader, CardContent, CardFooter } from '../common/Card';
4
+ import Button from '../common/Button';
5
+ import CategoryBadge from '../Category/CategoryBadge';
6
+ import { useApp } from '../../contexts/AppContext';
7
+ import Modal, { ModalFooter, ModalButton } from '../common/Modal';
8
+ import { exportPromptGroupToZip } from '../../utils/exportUtils';
9
+
10
+ interface PromptGroupDetailProps {
11
+ promptGroup: PromptGroup;
12
+ onEdit: () => void;
13
+ onDelete: () => void;
14
+ className?: string;
15
+ }
16
+
17
+ const PromptGroupDetail: React.FC<PromptGroupDetailProps> = ({
18
+ promptGroup,
19
+ onEdit,
20
+ onDelete,
21
+ className = ''
22
+ }) => {
23
+ const { categories } = useApp();
24
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
25
+
26
+ const category = categories.find(c => c._id === promptGroup.category);
27
+
28
+ const formatDate = (date: string | Date) => {
29
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
30
+ return dateObj.toLocaleDateString('zh-CN', {
31
+ year: 'numeric',
32
+ month: 'long',
33
+ day: 'numeric'
34
+ });
35
+ };
36
+
37
+ const handleExport = () => {
38
+ exportPromptGroupToZip(promptGroup);
39
+ };
40
+
41
+ const handleDelete = () => {
42
+ setShowDeleteModal(true);
43
+ };
44
+
45
+ const confirmDelete = () => {
46
+ onDelete();
47
+ setShowDeleteModal(false);
48
+ };
49
+
50
+ return (
51
+ <div className={className}>
52
+ <Card>
53
+ <CardContent className="pt-4">
54
+ <div className="flex justify-between items-start">
55
+ <div>
56
+ <div className="flex items-center mb-2">
57
+ <h1 className="text-2xl font-bold mr-2">{promptGroup.name}</h1>
58
+ {category && <CategoryBadge category={category} />}
59
+ </div>
60
+
61
+ <p className="text-gray-600 mb-4">{promptGroup.description || '无描述'}</p>
62
+
63
+ <div className="flex items-center text-sm text-gray-500 mb-4">
64
+ <div className="flex items-center mr-6">
65
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
66
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
67
+ <polyline points="14 2 14 8 20 8"></polyline>
68
+ <line x1="16" y1="13" x2="8" y2="13"></line>
69
+ <line x1="16" y1="17" x2="8" y2="17"></line>
70
+ <polyline points="10 9 9 9 8 9"></polyline>
71
+ </svg>
72
+ {promptGroup.prompts.length} 个提示词
73
+ </div>
74
+ <div className="flex items-center mr-6">
75
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
76
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
77
+ </svg>
78
+ {promptGroup.workflows.length} 个工作流
79
+ </div>
80
+ <div className="flex items-center">
81
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
82
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
83
+ <polyline points="14 2 14 8 20 8"></polyline>
84
+ <line x1="16" y1="13" x2="8" y2="13"></line>
85
+ <line x1="16" y1="17" x2="8" y2="17"></line>
86
+ <polyline points="10 9 9 9 8 9"></polyline>
87
+ </svg>
88
+ {promptGroup.dslFiles.length} 个DSL文件
89
+ </div>
90
+ </div>
91
+
92
+ <div className="grid grid-cols-2 gap-4 text-sm">
93
+ <div>
94
+ <p className="text-xs text-gray-500">创建时间</p>
95
+ <p>{formatDate(promptGroup.createdAt)}</p>
96
+ </div>
97
+ <div>
98
+ <p className="text-xs text-gray-500">更新时间</p>
99
+ <p>{formatDate(promptGroup.updatedAt)}</p>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </CardContent>
105
+
106
+ <CardFooter className="flex justify-end space-x-2">
107
+ <Button
108
+ variant="secondary"
109
+ onClick={handleExport}
110
+ >
111
+ 导出
112
+ </Button>
113
+ <Button
114
+ variant="secondary"
115
+ onClick={onEdit}
116
+ >
117
+ 编辑
118
+ </Button>
119
+ <Button
120
+ variant="danger"
121
+ onClick={handleDelete}
122
+ >
123
+ 删除
124
+ </Button>
125
+ </CardFooter>
126
+ </Card>
127
+
128
+ <Modal
129
+ isOpen={showDeleteModal}
130
+ onClose={() => setShowDeleteModal(false)}
131
+ title="删除提示词组"
132
+ footer={
133
+ <ModalFooter>
134
+ <ModalButton
135
+ variant="secondary"
136
+ onClick={() => setShowDeleteModal(false)}
137
+ >
138
+ 取消
139
+ </ModalButton>
140
+ <ModalButton
141
+ variant="danger"
142
+ onClick={confirmDelete}
143
+ >
144
+ 删除
145
+ </ModalButton>
146
+ </ModalFooter>
147
+ }
148
+ >
149
+ <p className="text-center mb-4">您确定要删除这个提示词组吗?</p>
150
+ <p className="text-center text-gray-500 text-sm">此操作不可撤销。所有关联的提示词和DSL文件也将被删除。</p>
151
+ </Modal>
152
+ </div>
153
+ );
154
+ };
155
+
156
+ export default PromptGroupDetail;
src/components/PromptGroup/PromptGroupForm.tsx ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { PromptGroup } from '../../types';
3
+ import Input from '../common/Input';
4
+ import TextArea from '../common/TextArea';
5
+ import Button from '../common/Button';
6
+ import CategorySelector from '../Category/CategorySelector';
7
+ import { useApp } from '../../contexts/AppContext';
8
+
9
+ interface PromptGroupFormProps {
10
+ initialPromptGroup?: Partial<PromptGroup>;
11
+ onSubmit: (promptGroup: { name: string; description: string; category: string }) => void;
12
+ onCancel: () => void;
13
+ }
14
+
15
+ const PromptGroupForm: React.FC<PromptGroupFormProps> = ({
16
+ initialPromptGroup = {},
17
+ onSubmit,
18
+ onCancel
19
+ }) => {
20
+ const { categories } = useApp();
21
+ const [name, setName] = useState(initialPromptGroup.name || '');
22
+ const [description, setDescription] = useState(initialPromptGroup.description || '');
23
+ const [category, setCategory] = useState(initialPromptGroup.category || (categories.length > 0 ? categories[0]._id : ''));
24
+ const [errors, setErrors] = useState({
25
+ name: '',
26
+ category: ''
27
+ });
28
+
29
+ const validate = (): boolean => {
30
+ const newErrors = {
31
+ name: '',
32
+ category: ''
33
+ };
34
+
35
+ if (!name.trim()) {
36
+ newErrors.name = '请输入提示词组名称';
37
+ }
38
+
39
+ if (!category) {
40
+ newErrors.category = '请选择分类';
41
+ }
42
+
43
+ setErrors(newErrors);
44
+
45
+ return !newErrors.name && !newErrors.category;
46
+ };
47
+
48
+ const handleSubmit = (e: React.FormEvent) => {
49
+ e.preventDefault();
50
+
51
+ if (!validate()) return;
52
+
53
+ // 确保 category 是字符串类型
54
+ const categoryId = typeof category === 'object' ? category._id : category;
55
+
56
+ onSubmit({
57
+ name: name.trim(),
58
+ description: description.trim(),
59
+ category: categoryId
60
+ });
61
+ };
62
+
63
+ const handleCategoryChange = (categoryId: string) => {
64
+ setCategory(categoryId);
65
+ if (errors.category) {
66
+ setErrors(prev => ({ ...prev, category: '' }));
67
+ }
68
+ };
69
+
70
+ return (
71
+ <form onSubmit={handleSubmit} className="space-y-4">
72
+ <Input
73
+ label="名称"
74
+ placeholder="输入提示词组名称"
75
+ value={name}
76
+ onChange={(e) => setName(e.target.value)}
77
+ error={errors.name}
78
+ required
79
+ />
80
+
81
+ <TextArea
82
+ label="描述"
83
+ placeholder="输入提示词组描述..."
84
+ value={description}
85
+ onChange={(e) => setDescription(e.target.value)}
86
+ rows={4}
87
+ />
88
+
89
+ <div>
90
+ <label className="block text-sm font-medium mb-1 text-gray-700">
91
+ 分类 {errors.category && <span className="text-red-600">({errors.category})</span>}
92
+ </label>
93
+ <CategorySelector
94
+ selectedCategory={category}
95
+ onChange={handleCategoryChange}
96
+ />
97
+ </div>
98
+
99
+ <div className="flex justify-end space-x-3 mt-6">
100
+ <Button
101
+ type="button"
102
+ variant="secondary"
103
+ onClick={onCancel}
104
+ >
105
+ 取消
106
+ </Button>
107
+ <Button
108
+ type="submit"
109
+ variant="primary"
110
+ >
111
+ 保存
112
+ </Button>
113
+ </div>
114
+ </form>
115
+ );
116
+ };
117
+
118
+ export default PromptGroupForm;
src/components/PromptGroup/PromptGroupList.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ // eslint-disable-next-line
3
+ import { PromptGroup } from '../../types';
4
+ import PromptGroupCard from './PromptGroupCard';
5
+ import { useApp } from '../../contexts/AppContext';
6
+ import CategorySelector from '../Category/CategorySelector';
7
+ import Input from '../common/Input';
8
+
9
+ const PromptGroupList: React.FC = () => {
10
+ // eslint-disable-next-line
11
+ const { promptGroups, categories } = useApp();
12
+ const [searchTerm, setSearchTerm] = useState('');
13
+ const [selectedCategory, setSelectedCategory] = useState<string>('');
14
+
15
+ // 搜索和过滤提示词组
16
+ const filteredPromptGroups = promptGroups.filter((group) => {
17
+ const matchesSearch =
18
+ group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
19
+ group.description.toLowerCase().includes(searchTerm.toLowerCase());
20
+
21
+ const matchesCategory =
22
+ selectedCategory === '' || group.category === selectedCategory;
23
+
24
+ return matchesSearch && matchesCategory;
25
+ });
26
+
27
+ const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
28
+ setSearchTerm(e.target.value);
29
+ };
30
+
31
+ const handleCategoryChange = (categoryId: string) => {
32
+ setSelectedCategory(categoryId);
33
+ };
34
+
35
+ const resetFilters = () => {
36
+ setSearchTerm('');
37
+ setSelectedCategory('');
38
+ };
39
+
40
+ return (
41
+ <div>
42
+ <div className="mb-4 flex flex-col sm:flex-row gap-3">
43
+ <Input
44
+ placeholder="搜索提示词组..."
45
+ value={searchTerm}
46
+ onChange={handleSearch}
47
+ className="flex-1"
48
+ icon={
49
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
50
+ <circle cx="11" cy="11" r="8"></circle>
51
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
52
+ </svg>
53
+ }
54
+ />
55
+ <div className="flex gap-2">
56
+ <CategorySelector
57
+ selectedCategory={selectedCategory}
58
+ onChange={handleCategoryChange}
59
+ />
60
+ {(searchTerm || selectedCategory) && (
61
+ <button
62
+ className="ios-navbar-button"
63
+ onClick={resetFilters}
64
+ >
65
+ 清除筛选
66
+ </button>
67
+ )}
68
+ </div>
69
+ </div>
70
+
71
+ {filteredPromptGroups.length === 0 ? (
72
+ <div className="ios-empty-state">
73
+ <div className="ios-empty-state-icon">
74
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
75
+ <circle cx="11" cy="11" r="8"></circle>
76
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
77
+ </svg>
78
+ </div>
79
+ <h3 className="ios-empty-state-title">未找到提示词组</h3>
80
+ <p className="ios-empty-state-text">
81
+ {searchTerm || selectedCategory
82
+ ? '请尝试调整筛选条件'
83
+ : '点击底部的"新建"按钮创建您的第一个提示词组'}
84
+ </p>
85
+ </div>
86
+ ) : (
87
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
88
+ {filteredPromptGroups.map((group) => (
89
+ <PromptGroupCard
90
+ key={group._id}
91
+ promptGroup={group}
92
+ />
93
+ ))}
94
+ </div>
95
+ )}
96
+ </div>
97
+ );
98
+ };
99
+
100
+ export default PromptGroupList;
src/components/ProtectedRoute.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Navigate, useLocation } from 'react-router-dom';
3
+ import { useAuth } from '../contexts/AuthContext';
4
+
5
+ interface ProtectedRouteProps {
6
+ children: React.ReactNode;
7
+ }
8
+
9
+ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
10
+ const { isAuthenticated, loading } = useAuth();
11
+ const location = useLocation();
12
+
13
+ if (loading) {
14
+ return (
15
+ <div className="flex items-center justify-center min-h-screen">
16
+ <div className="ios-loader"></div>
17
+ </div>
18
+ );
19
+ }
20
+
21
+ if (!isAuthenticated) {
22
+ return <Navigate to="/login" state={{ from: location }} replace />;
23
+ }
24
+
25
+ return <>{children}</>;
26
+ };
27
+
28
+ export default ProtectedRoute;
src/components/common/Button.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'text';
4
+ export type ButtonSize = 'small' | 'medium' | 'large';
5
+
6
+ interface ButtonProps {
7
+ children: React.ReactNode;
8
+ onClick?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
9
+ variant?: ButtonVariant;
10
+ size?: ButtonSize;
11
+ disabled?: boolean;
12
+ fullWidth?: boolean;
13
+ className?: string;
14
+ type?: 'button' | 'submit' | 'reset';
15
+ icon?: React.ReactNode;
16
+ }
17
+
18
+ const Button: React.FC<ButtonProps> = ({
19
+ children,
20
+ onClick,
21
+ variant = 'primary',
22
+ size = 'medium',
23
+ disabled = false,
24
+ fullWidth = false,
25
+ className = '',
26
+ type = 'button',
27
+ icon
28
+ }) => {
29
+ const getVariantClass = () => {
30
+ switch (variant) {
31
+ case 'primary':
32
+ return 'ios-button-primary';
33
+ case 'secondary':
34
+ return 'ios-button-secondary';
35
+ case 'danger':
36
+ return 'ios-button-danger';
37
+ case 'text':
38
+ return 'ios-button-text';
39
+ default:
40
+ return 'ios-button-primary';
41
+ }
42
+ };
43
+
44
+ const getSizeClass = () => {
45
+ switch (size) {
46
+ case 'small':
47
+ return 'min-h-8 text-sm px-3';
48
+ case 'medium':
49
+ return 'min-h-11 text-base px-4';
50
+ case 'large':
51
+ return 'min-h-12 text-lg px-6';
52
+ default:
53
+ return 'min-h-11 text-base px-4';
54
+ }
55
+ };
56
+
57
+ return (
58
+ <button
59
+ type={type}
60
+ onClick={onClick}
61
+ disabled={disabled}
62
+ className={`
63
+ ios-button ${getVariantClass()} ${getSizeClass()}
64
+ ${fullWidth ? 'w-full' : ''}
65
+ ${disabled ? 'opacity-50 cursor-not-allowed' : ''}
66
+ ${className}
67
+ `}
68
+ >
69
+ {icon && <span className="mr-2">{icon}</span>}
70
+ {children}
71
+ </button>
72
+ );
73
+ };
74
+
75
+ export default Button;
src/components/common/Card.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ interface CardProps {
4
+ children: React.ReactNode;
5
+ className?: string;
6
+ onClick?: () => void;
7
+ hoverable?: boolean;
8
+ }
9
+
10
+ const Card: React.FC<CardProps> = ({
11
+ children,
12
+ className = '',
13
+ onClick,
14
+ hoverable = false
15
+ }) => {
16
+ return (
17
+ <div
18
+ className={`
19
+ ios-card
20
+ ${hoverable ? 'cursor-pointer' : ''}
21
+ ${className}
22
+ `}
23
+ onClick={onClick}
24
+ >
25
+ {children}
26
+ </div>
27
+ );
28
+ };
29
+
30
+ export interface CardHeaderProps {
31
+ title: string;
32
+ subtitle?: string;
33
+ action?: React.ReactNode;
34
+ className?: string;
35
+ }
36
+
37
+ export const CardHeader: React.FC<CardHeaderProps> = ({
38
+ title,
39
+ subtitle,
40
+ action,
41
+ className = ''
42
+ }) => {
43
+ return (
44
+ <div className={`p-4 flex items-center justify-between border-b border-gray-200 ${className}`}>
45
+ <div>
46
+ <h3 className="text-lg font-medium">{title}</h3>
47
+ {subtitle && <p className="text-sm text-gray-500 mt-1">{subtitle}</p>}
48
+ </div>
49
+ {action && <div>{action}</div>}
50
+ </div>
51
+ );
52
+ };
53
+
54
+ export interface CardContentProps {
55
+ children: React.ReactNode;
56
+ className?: string;
57
+ }
58
+
59
+ export const CardContent: React.FC<CardContentProps> = ({
60
+ children,
61
+ className = ''
62
+ }) => {
63
+ return <div className={`p-4 ${className}`}>{children}</div>;
64
+ };
65
+
66
+ export interface CardFooterProps {
67
+ children: React.ReactNode;
68
+ className?: string;
69
+ }
70
+
71
+ export const CardFooter: React.FC<CardFooterProps> = ({
72
+ children,
73
+ className = ''
74
+ }) => {
75
+ return (
76
+ <div className={`p-4 border-t border-gray-200 ${className}`}>
77
+ {children}
78
+ </div>
79
+ );
80
+ };
81
+
82
+ export default Card;
src/components/common/Dropdown.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+
3
+ interface DropdownOption {
4
+ id: string;
5
+ label: React.ReactNode;
6
+ value: string;
7
+ }
8
+
9
+ interface DropdownProps {
10
+ options: DropdownOption[];
11
+ value?: string;
12
+ onChange: (value: string) => void;
13
+ placeholder?: string;
14
+ label?: string;
15
+ error?: string;
16
+ className?: string;
17
+ disabled?: boolean;
18
+ }
19
+
20
+ const Dropdown: React.FC<DropdownProps> = ({
21
+ options,
22
+ value,
23
+ onChange,
24
+ placeholder = '请选择',
25
+ label,
26
+ error,
27
+ className = '',
28
+ disabled = false
29
+ }) => {
30
+ const [isOpen, setIsOpen] = useState(false);
31
+ const dropdownRef = useRef<HTMLDivElement>(null);
32
+
33
+ const selectedOption = options.find(option => option.value === value);
34
+
35
+ // 处理点击外部关闭下拉菜单
36
+ useEffect(() => {
37
+ const handleClickOutside = (event: MouseEvent) => {
38
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
39
+ setIsOpen(false);
40
+ }
41
+ };
42
+
43
+ document.addEventListener('mousedown', handleClickOutside);
44
+ return () => {
45
+ document.removeEventListener('mousedown', handleClickOutside);
46
+ };
47
+ }, []);
48
+
49
+ const toggleDropdown = () => {
50
+ if (!disabled) {
51
+ setIsOpen(!isOpen);
52
+ }
53
+ };
54
+
55
+ const handleOptionSelect = (optionValue: string) => {
56
+ onChange(optionValue);
57
+ setIsOpen(false);
58
+ };
59
+
60
+ return (
61
+ <div className={`mb-4 ${className}`}>
62
+ {label && (
63
+ <label className="block text-sm font-medium mb-1 text-gray-700">
64
+ {label}
65
+ </label>
66
+ )}
67
+
68
+ <div className="relative" ref={dropdownRef}>
69
+ <button
70
+ type="button"
71
+ onClick={toggleDropdown}
72
+ className={`
73
+ ios-input flex items-center justify-between w-full
74
+ ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
75
+ ${error ? 'border-red-500' : ''}
76
+ `}
77
+ disabled={disabled}
78
+ >
79
+ <span className={`${!selectedOption ? 'text-gray-400' : ''}`}>
80
+ {selectedOption ? selectedOption.label : placeholder}
81
+ </span>
82
+ <svg
83
+ xmlns="http://www.w3.org/2000/svg"
84
+ className={`h-5 w-5 text-gray-400 transition-transform duration-200 ${isOpen ? 'transform rotate-180' : ''}`}
85
+ viewBox="0 0 20 20"
86
+ fill="currentColor"
87
+ >
88
+ <path
89
+ fillRule="evenodd"
90
+ d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
91
+ clipRule="evenodd"
92
+ />
93
+ </svg>
94
+ </button>
95
+
96
+ {isOpen && (
97
+ <div className="absolute z-10 mt-1 w-full bg-white rounded-md shadow-lg max-h-60 overflow-auto">
98
+ <div className="py-1">
99
+ {options.map((option) => (
100
+ <div
101
+ key={option.id}
102
+ className={`
103
+ px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer
104
+ ${option.value === value ? 'bg-blue-50 text-blue-600' : ''}
105
+ `}
106
+ onClick={() => handleOptionSelect(option.value)}
107
+ >
108
+ {option.label}
109
+ </div>
110
+ ))}
111
+ </div>
112
+ </div>
113
+ )}
114
+ </div>
115
+
116
+ {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
117
+ </div>
118
+ );
119
+ };
120
+
121
+ export default Dropdown;
src/components/common/Input.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { forwardRef } from 'react';
2
+
3
+ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
4
+ label?: string;
5
+ error?: string;
6
+ className?: string;
7
+ fullWidth?: boolean;
8
+ icon?: React.ReactNode;
9
+ }
10
+
11
+ const Input = forwardRef<HTMLInputElement, InputProps>(
12
+ ({ label, error, className = '', fullWidth = true, icon, ...props }, ref) => {
13
+ return (
14
+ <div className={`mb-4 ${fullWidth ? 'w-full' : ''} ${className}`}>
15
+ {label && (
16
+ <label className="block text-sm font-medium mb-1 text-gray-700">
17
+ {label}
18
+ </label>
19
+ )}
20
+ <div className="relative">
21
+ {icon && (
22
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
23
+ {icon}
24
+ </div>
25
+ )}
26
+ <input
27
+ ref={ref}
28
+ className={`
29
+ ios-input
30
+ ${icon ? 'pl-10' : ''}
31
+ ${error ? 'border-red-500' : ''}
32
+ `}
33
+ {...props}
34
+ />
35
+ </div>
36
+ {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
37
+ </div>
38
+ );
39
+ }
40
+ );
41
+
42
+ Input.displayName = 'Input';
43
+
44
+ export default Input;
src/components/common/Modal.tsx ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef } from 'react';
2
+ import ReactDOM from 'react-dom';
3
+
4
+ interface ModalProps {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ title?: string;
8
+ children: React.ReactNode;
9
+ footer?: React.ReactNode;
10
+ size?: 'sm' | 'md' | 'lg';
11
+ closeOnClickOutside?: boolean;
12
+ }
13
+
14
+ const Modal: React.FC<ModalProps> = ({
15
+ isOpen,
16
+ onClose,
17
+ title,
18
+ children,
19
+ footer,
20
+ size = 'md',
21
+ closeOnClickOutside = true
22
+ }) => {
23
+ const modalRef = useRef<HTMLDivElement>(null);
24
+
25
+ // 处理ESC按键关闭模态框
26
+ useEffect(() => {
27
+ const handleEscKey = (event: KeyboardEvent) => {
28
+ if (event.key === 'Escape' && isOpen) {
29
+ onClose();
30
+ }
31
+ };
32
+
33
+ window.addEventListener('keydown', handleEscKey);
34
+ return () => {
35
+ window.removeEventListener('keydown', handleEscKey);
36
+ };
37
+ }, [isOpen, onClose]);
38
+
39
+ // 阻止点击模态框内部时传播到外部背景
40
+ const handleModalClick = (e: React.MouseEvent) => {
41
+ e.stopPropagation();
42
+ };
43
+
44
+ // 处理点击背景关闭模态框
45
+ const handleBackdropClick = (e: React.MouseEvent) => {
46
+ if (closeOnClickOutside && modalRef.current && !modalRef.current.contains(e.target as Node)) {
47
+ onClose();
48
+ }
49
+ };
50
+
51
+ // 防止模态框打开时页面滚动
52
+ useEffect(() => {
53
+ if (isOpen) {
54
+ document.body.style.overflow = 'hidden';
55
+ } else {
56
+ document.body.style.overflow = '';
57
+ }
58
+
59
+ return () => {
60
+ document.body.style.overflow = '';
61
+ };
62
+ }, [isOpen]);
63
+
64
+ // 获取模态框大小样式
65
+ const getSizeClass = () => {
66
+ switch (size) {
67
+ case 'sm':
68
+ return 'max-w-md';
69
+ case 'md':
70
+ return 'max-w-lg';
71
+ case 'lg':
72
+ return 'max-w-2xl';
73
+ default:
74
+ return 'max-w-lg';
75
+ }
76
+ };
77
+
78
+ if (!isOpen) return null;
79
+
80
+ // 使用React Portal将模态框渲染到DOM树的最顶层
81
+ return ReactDOM.createPortal(
82
+ <div
83
+ className={`ios-modal-backdrop open fixed inset-0 z-50 overflow-auto bg-black bg-opacity-40 flex items-center justify-center p-4`}
84
+ onClick={handleBackdropClick}
85
+ >
86
+ <div
87
+ ref={modalRef}
88
+ className={`ios-modal ${getSizeClass()} bg-white rounded-xl shadow-xl transform transition-all`}
89
+ onClick={handleModalClick}
90
+ >
91
+ {title && (
92
+ <div className="ios-modal-header px-6 py-4 border-b border-gray-200">
93
+ <h3 className="ios-modal-title text-lg font-semibold text-center">{title}</h3>
94
+ </div>
95
+ )}
96
+
97
+ <div className="ios-modal-body p-6 max-h-[70vh] overflow-auto">
98
+ {children}
99
+ </div>
100
+
101
+ {footer && (
102
+ <div className="ios-modal-footer border-t border-gray-200">
103
+ {footer}
104
+ </div>
105
+ )}
106
+ </div>
107
+ </div>,
108
+ document.body
109
+ );
110
+ };
111
+
112
+ // 预定义的模态框按钮布局
113
+ export const ModalFooter: React.FC<{
114
+ children: React.ReactNode;
115
+ className?: string;
116
+ }> = ({ children, className = '' }) => {
117
+ return (
118
+ <div className={`flex border-t border-gray-200 ${className}`}>
119
+ {children}
120
+ </div>
121
+ );
122
+ };
123
+
124
+ // 预定义的模态框按钮
125
+ export const ModalButton: React.FC<{
126
+ children: React.ReactNode;
127
+ onClick: () => void;
128
+ variant?: 'primary' | 'secondary' | 'danger';
129
+ className?: string;
130
+ }> = ({
131
+ children,
132
+ onClick,
133
+ variant = 'secondary',
134
+ className = ''
135
+ }) => {
136
+ const getVariantClass = () => {
137
+ switch (variant) {
138
+ case 'primary':
139
+ return 'text-blue-600 hover:bg-blue-50';
140
+ case 'secondary':
141
+ return 'text-gray-600 hover:bg-gray-50';
142
+ case 'danger':
143
+ return 'text-red-600 hover:bg-red-50';
144
+ default:
145
+ return 'text-gray-600 hover:bg-gray-50';
146
+ }
147
+ };
148
+
149
+ return (
150
+ <button
151
+ className={`
152
+ flex-1 py-3 px-5 text-center font-medium text-sm transition-colors duration-200
153
+ ${getVariantClass()}
154
+ ${className}
155
+ `}
156
+ onClick={onClick}
157
+ >
158
+ {children}
159
+ </button>
160
+ );
161
+ };
162
+
163
+ export default Modal;
src/components/common/TextArea.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { forwardRef } from 'react';
2
+
3
+ interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
4
+ label?: string;
5
+ error?: string;
6
+ className?: string;
7
+ fullWidth?: boolean;
8
+ }
9
+
10
+ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
11
+ ({ label, error, className = '', fullWidth = true, ...props }, ref) => {
12
+ return (
13
+ <div className={`mb-4 ${fullWidth ? 'w-full' : ''} ${className}`}>
14
+ {label && (
15
+ <label className="block text-sm font-medium mb-1 text-gray-700">
16
+ {label}
17
+ </label>
18
+ )}
19
+ <textarea
20
+ ref={ref}
21
+ className={`
22
+ ios-textarea
23
+ ${error ? 'border-red-500' : ''}
24
+ `}
25
+ {...props}
26
+ />
27
+ {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
28
+ </div>
29
+ );
30
+ }
31
+ );
32
+
33
+ TextArea.displayName = 'TextArea';
34
+
35
+ export default TextArea;
src/contexts/AppContext.tsx ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect } from 'react';
2
+ import { PromptGroup, Category } from '../types';
3
+ import { promptGroupAPI, categoryAPI } from '../services/api';
4
+ import { useAuth } from './AuthContext';
5
+
6
+ interface AppContextType {
7
+ promptGroups: PromptGroup[];
8
+ categories: Category[];
9
+ loading: boolean;
10
+ error: string | null;
11
+ fetchPromptGroups: () => Promise<void>;
12
+ fetchCategories: () => Promise<void>;
13
+ addPromptGroup: (promptGroup: { name: string; description: string; category: string }) => Promise<PromptGroup>;
14
+ updatePromptGroup: (id: string, promptGroup: { name: string; description: string; category: string }) => Promise<void>;
15
+ deletePromptGroup: (id: string) => Promise<void>;
16
+ addPrompt: (groupId: string, prompt: { title: string; content: string; tags: string[] }) => Promise<void>;
17
+ updatePrompt: (groupId: string, promptId: string, prompt: { title: string; content: string; tags: string[] }) => Promise<void>;
18
+ deletePrompt: (groupId: string, promptId: string) => Promise<void>;
19
+ addDslFile: (groupId: string, dslFile: { name: string; content?: string; fileData?: string; mimeType?: string }) => Promise<void>;
20
+ updateDslFile: (groupId: string, fileId: string, dslFile: { name?: string; content?: string }) => Promise<void>;
21
+ deleteDslFile: (groupId: string, fileId: string) => Promise<void>;
22
+ addCategory: (category: { name: string; color: string }) => Promise<void>;
23
+ updateCategory: (id: string, category: { name: string; color: string }) => Promise<void>;
24
+ deleteCategory: (id: string) => Promise<void>;
25
+ }
26
+
27
+ const AppContext = createContext<AppContextType | undefined>(undefined);
28
+
29
+ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
30
+ const [promptGroups, setPromptGroups] = useState<PromptGroup[]>([]);
31
+ const [categories, setCategories] = useState<Category[]>([]);
32
+ const [loading, setLoading] = useState<boolean>(false);
33
+ const [error, setError] = useState<string | null>(null);
34
+ const { isAuthenticated } = useAuth();
35
+
36
+ // 初始加载数据 - 只有在用户已经认证后才加载
37
+ useEffect(() => {
38
+ const loadInitialData = async () => {
39
+ if (isAuthenticated) {
40
+ setLoading(true);
41
+ try {
42
+ await Promise.all([fetchPromptGroups(), fetchCategories()]);
43
+ } catch (err: any) {
44
+ setError(err.message || '加载数据失败');
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ }
49
+ };
50
+
51
+ loadInitialData();
52
+ // eslint-disable-next-line
53
+ }, [isAuthenticated]); // 添加 isAuthenticated 作为依赖项
54
+
55
+ // 获取所有提示词组
56
+ const fetchPromptGroups = async () => {
57
+ if (!isAuthenticated) return; // 如果未认证,直接返回
58
+
59
+ try {
60
+ const data = await promptGroupAPI.getAll();
61
+ setPromptGroups(data);
62
+ } catch (err: any) {
63
+ setError(err.message || '获取提示词组失败');
64
+ throw err;
65
+ }
66
+ };
67
+
68
+ // 获取所有分类
69
+ const fetchCategories = async () => {
70
+ if (!isAuthenticated) return; // 如果未认证,直接返回
71
+
72
+ try {
73
+ const data = await categoryAPI.getAll();
74
+ setCategories(data);
75
+ } catch (err: any) {
76
+ setError(err.message || '获取分类失败');
77
+ throw err;
78
+ }
79
+ };
80
+
81
+ // 添加提示词组
82
+ const addPromptGroup = async (promptGroupData: Omit<PromptGroup, '_id' | 'createdAt' | 'updatedAt' | 'prompts' | 'workflows' | 'dslFiles'>) => {
83
+ try {
84
+ // 确保 category 始终是字符串类型
85
+ const categoryId = typeof promptGroupData.category === 'object'
86
+ ? promptGroupData.category._id
87
+ : promptGroupData.category;
88
+
89
+ const newPromptGroup = await promptGroupAPI.create({
90
+ name: promptGroupData.name,
91
+ description: promptGroupData.description,
92
+ category: categoryId
93
+ });
94
+
95
+ setPromptGroups(prev => [...prev, newPromptGroup]);
96
+ return newPromptGroup;
97
+ } catch (err: any) {
98
+ setError(err.message || '添加提示词组失败');
99
+ throw err;
100
+ }
101
+ };
102
+
103
+ // 更新提示词组
104
+ const updatePromptGroup = async (id: string, promptGroupData: Omit<PromptGroup, '_id' | 'createdAt' | 'updatedAt' | 'prompts' | 'workflows' | 'dslFiles'>) => {
105
+ try {
106
+ // 确保 category 始终是字符串类型
107
+ const categoryId = typeof promptGroupData.category === 'object'
108
+ ? promptGroupData.category._id
109
+ : promptGroupData.category;
110
+
111
+ await promptGroupAPI.update(id, {
112
+ name: promptGroupData.name,
113
+ description: promptGroupData.description,
114
+ category: categoryId
115
+ });
116
+
117
+ await fetchPromptGroups(); // 重新获取最新数据
118
+ } catch (err: any) {
119
+ setError(err.message || '更新提示词组失败');
120
+ throw err;
121
+ }
122
+ };
123
+
124
+ // 删除提示词组
125
+ const deletePromptGroup = async (id: string) => {
126
+ try {
127
+ await promptGroupAPI.delete(id);
128
+ setPromptGroups(prev => prev.filter(group => group._id !== id));
129
+ } catch (err: any) {
130
+ setError(err.message || '删除提示词组失败');
131
+ throw err;
132
+ }
133
+ };
134
+
135
+ // 添加提示词
136
+ const addPrompt = async (groupId: string, promptData: { title: string; content: string; tags: string[] }) => {
137
+ try {
138
+ await promptGroupAPI.addPrompt(groupId, promptData);
139
+ await fetchPromptGroups(); // 重新获取最新数据
140
+ } catch (err: any) {
141
+ setError(err.message || '添加提示词失败');
142
+ throw err;
143
+ }
144
+ };
145
+
146
+ // 更新提示词
147
+ const updatePrompt = async (groupId: string, promptId: string, promptData: { title: string; content: string; tags: string[] }) => {
148
+ try {
149
+ await promptGroupAPI.updatePrompt(groupId, promptId, promptData);
150
+ await fetchPromptGroups(); // 重新获取最新数据
151
+ } catch (err: any) {
152
+ setError(err.message || '更新提示词失败');
153
+ throw err;
154
+ }
155
+ };
156
+
157
+ // 删除提示词
158
+ const deletePrompt = async (groupId: string, promptId: string) => {
159
+ try {
160
+ await promptGroupAPI.deletePrompt(groupId, promptId);
161
+ await fetchPromptGroups(); // 重新获取最新数据
162
+ } catch (err: any) {
163
+ setError(err.message || '删除提示词失败');
164
+ throw err;
165
+ }
166
+ };
167
+
168
+ // 添加DSL文件 - 修改为支持文本内容
169
+ const addDslFile = async (groupId: string, dslFileData: { name: string; content?: string; fileData?: string; mimeType?: string }) => {
170
+ try {
171
+ await promptGroupAPI.addDslFile(groupId, dslFileData);
172
+ await fetchPromptGroups(); // 重新获取最新数据
173
+ } catch (err: any) {
174
+ setError(err.message || '添加YAML文件失败');
175
+ throw err;
176
+ }
177
+ };
178
+
179
+ // 更新DSL文件
180
+ const updateDslFile = async (groupId: string, fileId: string, dslFileData: { name?: string; content?: string }) => {
181
+ try {
182
+ await promptGroupAPI.updateDslFile(groupId, fileId, dslFileData);
183
+ await fetchPromptGroups(); // 重新获取最新数据
184
+ } catch (err: any) {
185
+ setError(err.message || '更新YAML文件失败');
186
+ throw err;
187
+ }
188
+ };
189
+
190
+ // 删除DSL文件
191
+ const deleteDslFile = async (groupId: string, fileId: string) => {
192
+ try {
193
+ await promptGroupAPI.deleteDslFile(groupId, fileId);
194
+ await fetchPromptGroups(); // 重新获取最新数据
195
+ } catch (err: any) {
196
+ setError(err.message || '删除YAML文件失败');
197
+ throw err;
198
+ }
199
+ };
200
+
201
+ // 添加分类
202
+ const addCategory = async (categoryData: { name: string; color: string }) => {
203
+ try {
204
+ const newCategory = await categoryAPI.create(categoryData);
205
+ setCategories(prev => [...prev, newCategory]);
206
+ } catch (err: any) {
207
+ setError(err.message || '添加分类失败');
208
+ throw err;
209
+ }
210
+ };
211
+
212
+ // 更新分类
213
+ const updateCategory = async (id: string, categoryData: { name: string; color: string }) => {
214
+ try {
215
+ await categoryAPI.update(id, categoryData);
216
+ await fetchCategories(); // 重新获取最新数据
217
+ } catch (err: any) {
218
+ setError(err.message || '更新分类失败');
219
+ throw err;
220
+ }
221
+ };
222
+
223
+ // 删除分类
224
+ const deleteCategory = async (id: string) => {
225
+ try {
226
+ await categoryAPI.delete(id);
227
+ setCategories(prev => prev.filter(category => category._id !== id));
228
+ } catch (err: any) {
229
+ setError(err.message || '删除分类失败');
230
+ throw err;
231
+ }
232
+ };
233
+
234
+ const value = {
235
+ promptGroups,
236
+ categories,
237
+ loading,
238
+ error,
239
+ fetchPromptGroups,
240
+ fetchCategories,
241
+ addPromptGroup,
242
+ updatePromptGroup,
243
+ deletePromptGroup,
244
+ addPrompt,
245
+ updatePrompt,
246
+ deletePrompt,
247
+ addDslFile,
248
+ updateDslFile,
249
+ deleteDslFile,
250
+ addCategory,
251
+ updateCategory,
252
+ deleteCategory
253
+ };
254
+
255
+ return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
256
+ };
257
+
258
+ export const useApp = () => {
259
+ const context = useContext(AppContext);
260
+ if (context === undefined) {
261
+ throw new Error('useApp must be used within an AppProvider');
262
+ }
263
+ return context;
264
+ };
src/contexts/AuthContext.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect } from 'react';
2
+ import { authAPI } from '../services/api';
3
+
4
+ interface AuthContextType {
5
+ isAuthenticated: boolean;
6
+ user: string | null;
7
+ login: (username: string, password: string) => Promise<void>;
8
+ logout: () => void;
9
+ loading: boolean;
10
+ }
11
+
12
+ const AuthContext = createContext<AuthContextType | undefined>(undefined);
13
+
14
+ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
15
+ const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
16
+ const [user, setUser] = useState<string | null>(null);
17
+ const [loading, setLoading] = useState<boolean>(true);
18
+
19
+ // 检查是否已登录
20
+ useEffect(() => {
21
+ const checkAuth = async () => {
22
+ const token = localStorage.getItem('authToken');
23
+ const savedUser = localStorage.getItem('user');
24
+
25
+ if (token && savedUser) {
26
+ try {
27
+ // 验证令牌是否有效
28
+ await authAPI.getProfile();
29
+ setIsAuthenticated(true);
30
+ setUser(savedUser);
31
+ } catch (error) {
32
+ // 令牌无效,清除登录状态
33
+ localStorage.removeItem('authToken');
34
+ localStorage.removeItem('user');
35
+ setIsAuthenticated(false);
36
+ setUser(null);
37
+ }
38
+ }
39
+
40
+ setLoading(false);
41
+ };
42
+
43
+ checkAuth();
44
+ }, []);
45
+
46
+ // 登录方法
47
+ const login = async (username: string, password: string) => {
48
+ setLoading(true);
49
+ try {
50
+ const response = await authAPI.login(username, password);
51
+ const { token, username: user } = response;
52
+
53
+ // 保存令牌和用户信息到本地存储
54
+ localStorage.setItem('authToken', token);
55
+ localStorage.setItem('user', user);
56
+
57
+ setIsAuthenticated(true);
58
+ setUser(user);
59
+ } catch (error) {
60
+ // 登录失败
61
+ console.error('Login failed:', error);
62
+ throw error;
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ };
67
+
68
+ // 登出方法
69
+ const logout = () => {
70
+ authAPI.logout();
71
+ setIsAuthenticated(false);
72
+ setUser(null);
73
+ };
74
+
75
+ return (
76
+ <AuthContext.Provider value={{ isAuthenticated, user, login, logout, loading }}>
77
+ {children}
78
+ </AuthContext.Provider>
79
+ );
80
+ };
81
+
82
+ export const useAuth = () => {
83
+ const context = useContext(AuthContext);
84
+ if (context === undefined) {
85
+ throw new Error('useAuth must be used within an AuthProvider');
86
+ }
87
+ return context;
88
+ };
src/contexts/ThemeContext.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect } from 'react';
2
+
3
+ type ThemeMode = 'light' | 'dark' | 'system';
4
+
5
+ interface ThemeContextType {
6
+ themeMode: ThemeMode;
7
+ isDarkMode: boolean;
8
+ setThemeMode: (mode: ThemeMode) => void;
9
+ toggleTheme: () => void;
10
+ }
11
+
12
+ const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
13
+
14
+ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
15
+ // 从本地存储获取主题模式,默认为'system'
16
+ const [themeMode, setThemeModeState] = useState<ThemeMode>(() => {
17
+ const savedMode = localStorage.getItem('themeMode');
18
+ return (savedMode as ThemeMode) || 'system';
19
+ });
20
+
21
+ // 判断当前是否为深色模式
22
+ const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
23
+
24
+ // 当主题模式改变时,更新本地存储和文档根节点类名
25
+ useEffect(() => {
26
+ localStorage.setItem('themeMode', themeMode);
27
+ updateTheme();
28
+ }, [themeMode]);
29
+
30
+ // 监听系统主题变化
31
+ useEffect(() => {
32
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
33
+
34
+ const handleChange = () => {
35
+ if (themeMode === 'system') {
36
+ updateTheme();
37
+ }
38
+ };
39
+
40
+ mediaQuery.addEventListener('change', handleChange);
41
+ updateTheme();
42
+
43
+ return () => mediaQuery.removeEventListener('change', handleChange);
44
+ }, [themeMode]);
45
+
46
+ // 更新主题
47
+ const updateTheme = () => {
48
+ const isDark =
49
+ themeMode === 'dark' ||
50
+ (themeMode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
51
+
52
+ setIsDarkMode(isDark);
53
+
54
+ // 更新文档根节点的类名
55
+ if (isDark) {
56
+ document.documentElement.classList.add('dark');
57
+ } else {
58
+ document.documentElement.classList.remove('dark');
59
+ }
60
+ };
61
+
62
+ // 设置主题模式
63
+ const setThemeMode = (mode: ThemeMode) => {
64
+ setThemeModeState(mode);
65
+ };
66
+
67
+ // 切换主题(仅在light/dark之间切换,不涉及system)
68
+ const toggleTheme = () => {
69
+ setThemeModeState(prev => (prev === 'dark' ? 'light' : 'dark'));
70
+ };
71
+
72
+ const value = {
73
+ themeMode,
74
+ isDarkMode,
75
+ setThemeMode,
76
+ toggleTheme
77
+ };
78
+
79
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
80
+ };
81
+
82
+ export const useTheme = () => {
83
+ const context = useContext(ThemeContext);
84
+ if (!context) {
85
+ throw new Error('useTheme must be used within a ThemeProvider');
86
+ }
87
+ return context;
88
+ };
src/hooks/useLocalStorage.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+
3
+ /**
4
+ * 自定义钩子,用于在localStorage中持久化状态
5
+ * @param key localStorage的键名
6
+ * @param initialValue 初始值
7
+ * @returns [存储的值, 设置值的函数, 移除值的函数]
8
+ */
9
+ function useLocalStorage<T>(
10
+ key: string,
11
+ initialValue: T
12
+ ): [T, (value: T | ((val: T) => T)) => void, () => void] {
13
+ // 获取localStorage中的初始值
14
+ const readValue = (): T => {
15
+ // 防止服务器端渲染时出错
16
+ if (typeof window === 'undefined') {
17
+ return initialValue;
18
+ }
19
+
20
+ try {
21
+ const item = window.localStorage.getItem(key);
22
+ return item ? JSON.parse(item) : initialValue;
23
+ } catch (error) {
24
+ console.warn(`Error reading localStorage key "${key}":`, error);
25
+ return initialValue;
26
+ }
27
+ };
28
+
29
+ // 状态保存当前值
30
+ const [storedValue, setStoredValue] = useState<T>(readValue);
31
+
32
+ // 返回用于更新状态的函数
33
+ const setValue = (value: T | ((val: T) => T)) => {
34
+ try {
35
+ // 允许函数形式的更新
36
+ const valueToStore =
37
+ value instanceof Function ? value(storedValue) : value;
38
+
39
+ // 保存到状态
40
+ setStoredValue(valueToStore);
41
+
42
+ // 保存到localStorage
43
+ if (typeof window !== 'undefined') {
44
+ window.localStorage.setItem(key, JSON.stringify(valueToStore));
45
+ }
46
+ } catch (error) {
47
+ console.warn(`Error setting localStorage key "${key}":`, error);
48
+ }
49
+ };
50
+
51
+ // 移除localStorage中的值
52
+ const removeValue = () => {
53
+ try {
54
+ // 从localStorage中移除
55
+ if (typeof window !== 'undefined') {
56
+ window.localStorage.removeItem(key);
57
+ }
58
+
59
+ // 重置状态为初始值
60
+ setStoredValue(initialValue);
61
+ } catch (error) {
62
+ console.warn(`Error removing localStorage key "${key}":`, error);
63
+ }
64
+ };
65
+
66
+ // 监听其他标签页中的存储变化
67
+ useEffect(() => {
68
+ const handleStorageChange = (e: StorageEvent) => {
69
+ if (e.key === key && e.newValue !== null) {
70
+ try {
71
+ setStoredValue(JSON.parse(e.newValue));
72
+ } catch (error) {
73
+ console.warn(`Error parsing localStorage value on change:`, error);
74
+ }
75
+ } else if (e.key === key && e.newValue === null) {
76
+ setStoredValue(initialValue);
77
+ }
78
+ };
79
+
80
+ // 添加事件监听器
81
+ window.addEventListener('storage', handleStorageChange);
82
+
83
+ // 清理事件监听器
84
+ return () => {
85
+ window.removeEventListener('storage', handleStorageChange);
86
+ };
87
+ }, [key, initialValue]);
88
+
89
+ return [storedValue, setValue, removeValue];
90
+ }
91
+
92
+ export default useLocalStorage;
src/hooks/usePromptGroups.ts ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useEffect } from 'react';
2
+ import { PromptGroup, Prompt, DslFile } from '../types';
3
+ import useLocalStorage from './useLocalStorage';
4
+
5
+ /**
6
+ * 自定义钩子,用于管理提示词组数据
7
+ */
8
+ function usePromptGroups() {
9
+ // 从本地存储获取提示词组数据
10
+ const [savedPromptGroups, setSavedPromptGroups, clearSavedPromptGroups] = useLocalStorage<PromptGroup[]>('promptGroups', []);
11
+ // 状态管理当前提示词组数据
12
+ const [promptGroups, setPromptGroups] = useState<PromptGroup[]>(processSavedPromptGroups(savedPromptGroups));
13
+
14
+ // 处理保存的提示词组数据,将日期字符串转换为Date对象
15
+ function processSavedPromptGroups(groups: PromptGroup[]): PromptGroup[] {
16
+ return groups.map(group => ({
17
+ ...group,
18
+ // 不要将字符串转换为 Date 对象
19
+ createdAt: group.createdAt,
20
+ updatedAt: group.updatedAt,
21
+ prompts: group.prompts.map(prompt => ({
22
+ ...prompt,
23
+ createdAt: prompt.createdAt,
24
+ updatedAt: prompt.updatedAt,
25
+ })),
26
+ dslFiles: group.dslFiles.map(file => ({
27
+ ...file,
28
+ uploadedAt: file.uploadedAt,
29
+ })),
30
+ }));
31
+ }
32
+
33
+
34
+ // 当本地存储数据变化时更新状态
35
+ useEffect(() => {
36
+ setPromptGroups(processSavedPromptGroups(savedPromptGroups));
37
+ }, [savedPromptGroups]);
38
+
39
+ // 生成唯一ID
40
+ const generateId = useCallback(() => {
41
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
42
+ }, []);
43
+
44
+ // 添加提示词组
45
+ const addPromptGroup = useCallback((promptGroup: Omit<PromptGroup, 'id' | 'createdAt' | 'updatedAt' | 'prompts' | 'workflows' | 'dslFiles'>) => {
46
+ const now = new Date().toISOString();
47
+ const newPromptGroup: PromptGroup = {
48
+ ...promptGroup,
49
+ _id: generateId(),
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ prompts: [],
53
+ workflows: [],
54
+ dslFiles: []
55
+ };
56
+
57
+ setSavedPromptGroups(prev => [...prev, newPromptGroup]);
58
+ return newPromptGroup;
59
+ }, [generateId, setSavedPromptGroups]);
60
+
61
+ // 更新提示词组
62
+ const updatePromptGroup = useCallback((id: string, promptGroup: Omit<PromptGroup, '_id' | 'createdAt' | 'updatedAt' | 'prompts' | 'workflows' | 'dslFiles'>) => {
63
+ const now = new Date().toISOString();
64
+ setSavedPromptGroups(prev =>
65
+ prev.map(group =>
66
+ group._id === id
67
+ ? {
68
+ ...group,
69
+ name: promptGroup.name,
70
+ description: promptGroup.description,
71
+ category: promptGroup.category,
72
+ updatedAt: now
73
+ }
74
+ : group
75
+ )
76
+ );
77
+ }, [setSavedPromptGroups]);
78
+
79
+ // 删除提示词组
80
+ const deletePromptGroup = useCallback((id: string) => {
81
+ setSavedPromptGroups(prev => prev.filter(group => group._id !== id));
82
+ }, [setSavedPromptGroups]);
83
+
84
+ // 添加提示词
85
+ const addPrompt = useCallback((groupId: string, prompt: Omit<Prompt, 'id' | 'createdAt' | 'updatedAt'>) => {
86
+ const now = new Date().toISOString();
87
+ const newPrompt: Prompt = {
88
+ ...prompt,
89
+ _id: generateId(),
90
+ createdAt: now,
91
+ updatedAt: now
92
+ };
93
+
94
+ setSavedPromptGroups(prev =>
95
+ prev.map(group => {
96
+ if (group._id === groupId) {
97
+ return {
98
+ ...group,
99
+ prompts: [...group.prompts, newPrompt],
100
+ updatedAt: now
101
+ };
102
+ }
103
+ return group;
104
+ })
105
+ );
106
+
107
+ return newPrompt;
108
+ }, [generateId, setSavedPromptGroups]);
109
+
110
+ // 更新提示词
111
+ // 更新提示词方法
112
+ const updatePrompt = useCallback((groupId: string, promptId: string, promptData: { title: string; content: string; tags: string[] }) => {
113
+ const now = new Date().toISOString();
114
+ setSavedPromptGroups(prev =>
115
+ prev.map(group => {
116
+ if (group._id === groupId) {
117
+ return {
118
+ ...group,
119
+ prompts: group.prompts.map(p =>
120
+ p._id === promptId
121
+ ? {
122
+ ...p,
123
+ title: promptData.title,
124
+ content: promptData.content,
125
+ tags: promptData.tags,
126
+ updatedAt: now
127
+ }
128
+ : p
129
+ ),
130
+ updatedAt: now
131
+ };
132
+ }
133
+ return group;
134
+ })
135
+ );
136
+ }, [setSavedPromptGroups]);
137
+
138
+ // 删除提示词
139
+ const deletePrompt = useCallback((groupId: string, promptId: string) => {
140
+ const now = new Date().toISOString();
141
+ setSavedPromptGroups(prev =>
142
+ prev.map(group => {
143
+ if (group._id === groupId) {
144
+ return {
145
+ ...group,
146
+ prompts: group.prompts.filter(p => p._id !== promptId),
147
+ updatedAt: now
148
+ };
149
+ }
150
+ return group;
151
+ })
152
+ );
153
+ }, [setSavedPromptGroups]);
154
+
155
+ // 添加工作流
156
+ const addWorkflow = useCallback((groupId: string, workflow: string) => {
157
+ const now = new Date().toISOString();
158
+ setSavedPromptGroups(prev =>
159
+ prev.map(group => {
160
+ if (group._id === groupId) {
161
+ return {
162
+ ...group,
163
+ workflows: [...group.workflows, workflow],
164
+ updatedAt: now
165
+ };
166
+ }
167
+ return group;
168
+ })
169
+ );
170
+ }, [setSavedPromptGroups]);
171
+
172
+ // 删除工作流
173
+ const deleteWorkflow = useCallback((groupId: string, workflowIndex: number) => {
174
+ const now = new Date().toISOString();
175
+ setSavedPromptGroups(prev =>
176
+ prev.map(group => {
177
+ if (group._id === groupId) {
178
+ const newWorkflows = [...group.workflows];
179
+ newWorkflows.splice(workflowIndex, 1);
180
+ return {
181
+ ...group,
182
+ workflows: newWorkflows,
183
+ updatedAt: now
184
+ };
185
+ }
186
+ return group;
187
+ })
188
+ );
189
+ }, [setSavedPromptGroups]);
190
+
191
+ // 添加DSL文件
192
+ const addDslFile = useCallback((groupId: string, dslFile: Omit<DslFile, 'id' | 'uploadedAt'>) => {
193
+ const now = new Date().toISOString();
194
+ const newDslFile: DslFile = {
195
+ ...dslFile,
196
+ _id: generateId(),
197
+ uploadedAt: now
198
+ };
199
+
200
+ setSavedPromptGroups(prev =>
201
+ prev.map(group => {
202
+ if (group._id === groupId) {
203
+ return {
204
+ ...group,
205
+ dslFiles: [...group.dslFiles, newDslFile],
206
+ updatedAt: now
207
+ };
208
+ }
209
+ return group;
210
+ })
211
+ );
212
+
213
+ return newDslFile;
214
+ }, [generateId, setSavedPromptGroups]);
215
+
216
+ // 删除DSL文件
217
+ const deleteDslFile = useCallback((groupId: string, fileId: string) => {
218
+ const now = new Date().toISOString();
219
+ setSavedPromptGroups(prev =>
220
+ prev.map(group => {
221
+ if (group._id === groupId) {
222
+ return {
223
+ ...group,
224
+ dslFiles: group.dslFiles.filter(file => file._id !== fileId),
225
+ updatedAt: now
226
+ };
227
+ }
228
+ return group;
229
+ })
230
+ );
231
+ }, [setSavedPromptGroups]);
232
+
233
+ // 清除所有提示词组数据
234
+ const clearAllData = useCallback(() => {
235
+ clearSavedPromptGroups();
236
+ }, [clearSavedPromptGroups]);
237
+
238
+ return {
239
+ promptGroups,
240
+ addPromptGroup,
241
+ updatePromptGroup,
242
+ deletePromptGroup,
243
+ addPrompt,
244
+ updatePrompt,
245
+ deletePrompt,
246
+ addWorkflow,
247
+ deleteWorkflow,
248
+ addDslFile,
249
+ deleteDslFile,
250
+ clearAllData
251
+ };
252
+ }
253
+
254
+ export default usePromptGroups;
src/index.css ADDED
File without changes
src/index.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import './index.css';
4
+ import App from './App';
5
+ import reportWebVitals from './reportWebVitals';
6
+
7
+ // 确保这段代码正确
8
+ const rootElement = document.getElementById('root');
9
+ if (!rootElement) throw new Error('Root element not found');
10
+ const root = ReactDOM.createRoot(rootElement);
11
+
12
+ root.render(
13
+ <React.StrictMode>
14
+ <App />
15
+ </React.StrictMode>
16
+ );
17
+
18
+ reportWebVitals();
src/pages/CategoriesPage.tsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import Layout from '../components/Layout/Layout';
3
+ import Card, { CardHeader, CardContent } from '../components/common/Card';
4
+ import Button from '../components/common/Button';
5
+ import Input from '../components/common/Input';
6
+ import { useApp } from '../contexts/AppContext';
7
+
8
+ const CategoriesPage: React.FC = () => {
9
+ const { categories, addCategory, updateCategory, deleteCategory, promptGroups } = useApp();
10
+ const [showAddForm, setShowAddForm] = useState(false);
11
+ const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
12
+ const [categoryName, setCategoryName] = useState('');
13
+ const [categoryColor, setCategoryColor] = useState('#007AFF');
14
+
15
+ const colorOptions = [
16
+ { color: '#007AFF', name: '蓝色' },
17
+ { color: '#4CD964', name: '绿色' },
18
+ { color: '#FF3B30', name: '红色' },
19
+ { color: '#FF9500', name: '橙色' },
20
+ { color: '#FFCC00', name: '黄色' },
21
+ { color: '#5856D6', name: '紫色' },
22
+ { color: '#FF2D55', name: '粉色' },
23
+ { color: '#5AC8FA', name: '浅蓝色' },
24
+ ];
25
+
26
+ const resetForm = () => {
27
+ setCategoryName('');
28
+ setCategoryColor('#007AFF');
29
+ setShowAddForm(false);
30
+ setEditingCategoryId(null);
31
+ };
32
+
33
+ const handleAddCategory = () => {
34
+ if (!categoryName.trim()) return;
35
+
36
+ addCategory({
37
+ name: categoryName.trim(),
38
+ color: categoryColor
39
+ });
40
+
41
+ resetForm();
42
+ };
43
+
44
+ const handleUpdateCategory = () => {
45
+ if (!editingCategoryId || !categoryName.trim()) return;
46
+
47
+ updateCategory(editingCategoryId, {
48
+ name: categoryName.trim(),
49
+ color: categoryColor
50
+ });
51
+
52
+ resetForm();
53
+ };
54
+
55
+ const handleEditCategory = (categoryId: string) => {
56
+ const category = categories.find(c => c._id === categoryId);
57
+ if (category) {
58
+ setCategoryName(category.name);
59
+ setCategoryColor(category.color);
60
+ setEditingCategoryId(categoryId);
61
+ setShowAddForm(false);
62
+ }
63
+ };
64
+
65
+ const handleDeleteCategory = (categoryId: string) => {
66
+ // 检查是否有使用该分类的提示词组
67
+ const usingGroups = promptGroups.filter(group => group.category === categoryId);
68
+
69
+ if (usingGroups.length > 0) {
70
+ alert(`无法删除此分类,有 ${usingGroups.length} 个提示词组正在使用它。请先修改这些提示词组的分类。`);
71
+ return;
72
+ }
73
+
74
+ if (window.confirm('确定要删除此分类吗?')) {
75
+ deleteCategory(categoryId);
76
+ }
77
+ };
78
+
79
+ const getCategoryUsageCount = (categoryId: string) => {
80
+ return promptGroups.filter(group => group.category === categoryId).length;
81
+ };
82
+
83
+ return (
84
+ <Layout title="分类管理">
85
+ <div className="flex justify-between items-center mb-4">
86
+ <h2 className="text-xl font-bold">分类管理</h2>
87
+ {!showAddForm && !editingCategoryId && (
88
+ <Button
89
+ variant="primary"
90
+ onClick={() => setShowAddForm(true)}
91
+ >
92
+ 添加分类
93
+ </Button>
94
+ )}
95
+ </div>
96
+
97
+ {(showAddForm || editingCategoryId) && (
98
+ <Card className="mb-4">
99
+ <CardHeader
100
+ title={editingCategoryId ? "编辑分类" : "添加分类"}
101
+ />
102
+ <CardContent>
103
+ <div className="space-y-4">
104
+ <Input
105
+ label="分类名称"
106
+ value={categoryName}
107
+ onChange={(e) => setCategoryName(e.target.value)}
108
+ placeholder="请输入分类名称"
109
+ required
110
+ />
111
+
112
+ <div>
113
+ <label className="block text-sm font-medium mb-1 text-gray-700">
114
+ 颜色
115
+ </label>
116
+ <div className="flex flex-wrap gap-2">
117
+ {colorOptions.map((option) => (
118
+ <div
119
+ key={option.color}
120
+ className={`
121
+ w-8 h-8 rounded-full cursor-pointer
122
+ ${categoryColor === option.color ? 'ring-2 ring-offset-2 ring-gray-400' : ''}
123
+ `}
124
+ style={{ backgroundColor: option.color }}
125
+ onClick={() => setCategoryColor(option.color)}
126
+ title={option.name}
127
+ />
128
+ ))}
129
+ </div>
130
+ </div>
131
+
132
+ <div className="flex justify-end space-x-2 mt-4">
133
+ <Button
134
+ variant="secondary"
135
+ onClick={resetForm}
136
+ >
137
+ 取消
138
+ </Button>
139
+ <Button
140
+ variant="primary"
141
+ onClick={editingCategoryId ? handleUpdateCategory : handleAddCategory}
142
+ disabled={!categoryName.trim()}
143
+ >
144
+ {editingCategoryId ? '保存' : '添加'}
145
+ </Button>
146
+ </div>
147
+ </div>
148
+ </CardContent>
149
+ </Card>
150
+ )}
151
+
152
+ <div className="ios-list">
153
+ {categories.map((category) => (
154
+ <div key={category._id} className="ios-list-item">
155
+ <div
156
+ className="w-4 h-4 rounded-full mr-3"
157
+ style={{ backgroundColor: category.color }}
158
+ />
159
+ <div className="ios-list-item-content">
160
+ <div className="ios-list-item-title">{category.name}</div>
161
+ <div className="ios-list-item-subtitle">
162
+ 使用次数: {getCategoryUsageCount(category._id)}
163
+ </div>
164
+ </div>
165
+ <div className="flex space-x-2">
166
+ <button
167
+ className="text-blue-500 p-2"
168
+ onClick={() => handleEditCategory(category._id)}
169
+ >
170
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
171
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
172
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
173
+ </svg>
174
+ </button>
175
+ <button
176
+ className="text-red-500 p-2"
177
+ onClick={() => handleDeleteCategory(category._id)}
178
+ disabled={getCategoryUsageCount(category._id) > 0}
179
+ >
180
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
181
+ <polyline points="3 6 5 6 21 6"></polyline>
182
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
183
+ <line x1="10" y1="11" x2="10" y2="17"></line>
184
+ <line x1="14" y1="11" x2="14" y2="17"></line>
185
+ </svg>
186
+ </button>
187
+ </div>
188
+ </div>
189
+ ))}
190
+ </div>
191
+ </Layout>
192
+ );
193
+ };
194
+
195
+ export default CategoriesPage;
src/pages/CreatePromptGroupPage.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import Layout from '../components/Layout/Layout';
4
+ import Card, { CardHeader, CardContent } from '../components/common/Card';
5
+ import PromptGroupForm from '../components/PromptGroup/PromptGroupForm';
6
+ import { useApp } from '../contexts/AppContext';
7
+
8
+ const CreatePromptGroupPage: React.FC = () => {
9
+ const navigate = useNavigate();
10
+ const { addPromptGroup } = useApp();
11
+
12
+ const handleSubmit = (promptGroupData: { name: string; description: string; category: string }) => {
13
+ // Simply pass the form data directly to addPromptGroup
14
+ // The function itself will handle adding the additional properties
15
+ addPromptGroup(promptGroupData);
16
+ navigate('/');
17
+ };
18
+
19
+ return (
20
+ <Layout title="创建提示词组" showBackButton>
21
+ <Card>
22
+ <CardHeader title="新建提示词组" />
23
+ <CardContent>
24
+ <PromptGroupForm
25
+ onSubmit={handleSubmit}
26
+ onCancel={() => navigate('/')}
27
+ />
28
+ </CardContent>
29
+ </Card>
30
+ </Layout>
31
+ );
32
+ };
33
+
34
+ export default CreatePromptGroupPage;
src/pages/CreatePromptPage.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useNavigate, useParams } from 'react-router-dom';
3
+ import Layout from '../components/Layout/Layout';
4
+ import Card, { CardHeader, CardContent } from '../components/common/Card';
5
+ import PromptForm from '../components/Prompt/PromptForm';
6
+ import { useApp } from '../contexts/AppContext';
7
+
8
+ const CreatePromptPage: React.FC = () => {
9
+ const { id: groupId } = useParams<{ id: string }>();
10
+ const navigate = useNavigate();
11
+ const { promptGroups, addPrompt } = useApp();
12
+
13
+ if (!groupId) {
14
+ return <div>提示词组ID无效</div>;
15
+ }
16
+
17
+ const promptGroup = promptGroups.find(group => group._id === groupId);
18
+
19
+ if (!promptGroup) {
20
+ return (
21
+ <Layout title="未找到" showBackButton>
22
+ <div className="ios-empty-state">
23
+ <h3 className="ios-empty-state-title">未找到提示词组</h3>
24
+ <p className="ios-empty-state-text">该提示词组可能已被删除</p>
25
+ </div>
26
+ </Layout>
27
+ );
28
+ }
29
+
30
+ const handleSubmit = (promptData: Parameters<typeof addPrompt>[1]) => {
31
+ addPrompt(groupId, promptData);
32
+ navigate(`/prompt-group/${groupId}`);
33
+ };
34
+
35
+ return (
36
+ <Layout title="创建提示词" showBackButton>
37
+ <Card>
38
+ <CardHeader title={`在"${promptGroup.name}"中创建新提示词`} />
39
+ <CardContent>
40
+ <PromptForm
41
+ onSubmit={handleSubmit}
42
+ onCancel={() => navigate(`/prompt-group/${groupId}`)}
43
+ />
44
+ </CardContent>
45
+ </Card>
46
+ </Layout>
47
+ );
48
+ };
49
+
50
+ export default CreatePromptPage;
src/pages/EditPromptGroupPage.tsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useParams, useNavigate } from 'react-router-dom';
3
+ import Layout from '../components/Layout/Layout';
4
+ import Card, { CardHeader, CardContent } from '../components/common/Card';
5
+ import PromptGroupForm from '../components/PromptGroup/PromptGroupForm';
6
+ import { useApp } from '../contexts/AppContext';
7
+
8
+ const EditPromptGroupPage: React.FC = () => {
9
+ const { id } = useParams<{ id: string }>();
10
+ const navigate = useNavigate();
11
+ const { promptGroups, updatePromptGroup } = useApp();
12
+
13
+ if (!id) {
14
+ return <div>提示词组ID无效</div>;
15
+ }
16
+
17
+ const promptGroup = promptGroups.find(group => group._id === id);
18
+
19
+ if (!promptGroup) {
20
+ return (
21
+ <Layout title="未找到" showBackButton>
22
+ <div className="ios-empty-state">
23
+ <h3 className="ios-empty-state-title">未找到提示词组</h3>
24
+ <p className="ios-empty-state-text">该提示词组可能已被删除</p>
25
+ </div>
26
+ </Layout>
27
+ );
28
+ }
29
+
30
+ const handleSubmit = (promptGroupData: { name: string; description: string; category: string }) => {
31
+ updatePromptGroup(promptGroup._id, promptGroupData);
32
+ navigate(`/prompt-group/${promptGroup._id}`);
33
+ };
34
+
35
+ return (
36
+ <Layout title="编辑提示词组" showBackButton>
37
+ <Card>
38
+ <CardHeader title={`编辑: ${promptGroup.name}`} />
39
+ <CardContent>
40
+ <PromptGroupForm
41
+ initialPromptGroup={promptGroup}
42
+ onSubmit={handleSubmit}
43
+ onCancel={() => navigate(`/prompt-group/${promptGroup._id}`)}
44
+ />
45
+ </CardContent>
46
+ </Card>
47
+ </Layout>
48
+ );
49
+ };
50
+
51
+ export default EditPromptGroupPage;
src/pages/EditPromptPage.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useNavigate, useParams } from 'react-router-dom';
3
+ import Layout from '../components/Layout/Layout';
4
+ import Card, { CardHeader, CardContent } from '../components/common/Card';
5
+ import PromptForm from '../components/Prompt/PromptForm';
6
+ import { useApp } from '../contexts/AppContext';
7
+
8
+ const EditPromptPage: React.FC = () => {
9
+ const { groupId, promptId } = useParams<{ groupId: string; promptId: string }>();
10
+ const navigate = useNavigate();
11
+ const { promptGroups, updatePrompt } = useApp();
12
+
13
+ if (!groupId || !promptId) {
14
+ return <div>参数无效</div>;
15
+ }
16
+
17
+ const promptGroup = promptGroups.find(group => group._id === groupId);
18
+
19
+ if (!promptGroup) {
20
+ return (
21
+ <Layout title="未找到" showBackButton>
22
+ <div className="ios-empty-state">
23
+ <h3 className="ios-empty-state-title">未找到提示词组</h3>
24
+ <p className="ios-empty-state-text">该提示词组可能已被删除</p>
25
+ </div>
26
+ </Layout>
27
+ );
28
+ }
29
+
30
+ const prompt = promptGroup.prompts.find(p => p._id === promptId);
31
+
32
+ if (!prompt) {
33
+ return (
34
+ <Layout title="未找到" showBackButton>
35
+ <div className="ios-empty-state">
36
+ <h3 className="ios-empty-state-title">未找到提示词</h3>
37
+ <p className="ios-empty-state-text">该提示词可能已被删除</p>
38
+ </div>
39
+ </Layout>
40
+ );
41
+ }
42
+
43
+ const handleSubmit = (promptData: { title: string; content: string; tags: string[] }) => {
44
+ updatePrompt(groupId, prompt._id, promptData);
45
+ navigate(`/prompt-group/${groupId}`);
46
+ };
47
+
48
+ return (
49
+ <Layout title="编辑提示词" showBackButton>
50
+ <Card>
51
+ <CardHeader title={`编辑: ${prompt.title}`} />
52
+ <CardContent>
53
+ <PromptForm
54
+ initialPrompt={prompt}
55
+ onSubmit={handleSubmit}
56
+ onCancel={() => navigate(`/prompt-group/${groupId}`)}
57
+ />
58
+ </CardContent>
59
+ </Card>
60
+ </Layout>
61
+ );
62
+ };
63
+
64
+ export default EditPromptPage;
src/pages/HomePage.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import Layout from '../components/Layout/Layout';
3
+ import PromptGroupList from '../components/PromptGroup/PromptGroupList';
4
+ import { useApp } from '../contexts/AppContext';
5
+
6
+ const HomePage: React.FC = () => {
7
+ // eslint-disable-next-line
8
+ const { promptGroups } = useApp();
9
+
10
+ return (
11
+ <Layout title="提示词管理">
12
+ <PromptGroupList />
13
+ </Layout>
14
+ );
15
+ };
16
+
17
+ export default HomePage;
src/pages/LoginPage.tsx ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useNavigate, useLocation } from 'react-router-dom';
3
+ import { useAuth } from '../contexts/AuthContext';
4
+ import Button from '../components/common/Button';
5
+ import Input from '../components/common/Input';
6
+
7
+ const LoginPage: React.FC = () => {
8
+ const [username, setUsername] = useState('');
9
+ const [password, setPassword] = useState('');
10
+ const [error, setError] = useState('');
11
+ const { login, isAuthenticated, loading } = useAuth();
12
+ const navigate = useNavigate();
13
+ const location = useLocation();
14
+
15
+ // Get the redirect path from location state or default to home
16
+ const from = location.state?.from?.pathname || '/';
17
+
18
+ // If already authenticated, redirect to the original page or home
19
+ useEffect(() => {
20
+ if (isAuthenticated) {
21
+ navigate(from, { replace: true });
22
+ }
23
+ }, [isAuthenticated, navigate, from]);
24
+
25
+ const handleSubmit = async (e: React.FormEvent) => {
26
+ e.preventDefault();
27
+ setError('');
28
+
29
+ if (!username || !password) {
30
+ setError('请输入用户名和密码');
31
+ return;
32
+ }
33
+
34
+ try {
35
+ await login(username, password);
36
+ // Navigation is handled by the useEffect when isAuthenticated changes
37
+ } catch (err: any) {
38
+ console.error('Login error:', err);
39
+ setError(err.response?.data?.message || '登录失败,请检查凭据');
40
+ }
41
+ };
42
+
43
+ if (loading) {
44
+ return (
45
+ <div className="flex items-center justify-center min-h-screen bg-gray-100">
46
+ <div className="ios-loader"></div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div className="flex items-center justify-center min-h-screen bg-gray-100 p-4">
53
+ <div className="bg-white rounded-xl shadow-lg p-6 w-full max-w-md">
54
+ <h1 className="text-2xl font-bold text-center mb-6">提示词管理系统</h1>
55
+
56
+ {error && (
57
+ <div className="bg-red-50 text-red-600 p-3 rounded-lg mb-4">
58
+ {error}
59
+ </div>
60
+ )}
61
+
62
+ <form onSubmit={handleSubmit} className="space-y-4">
63
+ <Input
64
+ label="用户名"
65
+ value={username}
66
+ onChange={(e) => setUsername(e.target.value)}
67
+ placeholder="请输入用户名"
68
+ required
69
+ />
70
+
71
+ <Input
72
+ label="密码"
73
+ type="password"
74
+ value={password}
75
+ onChange={(e) => setPassword(e.target.value)}
76
+ placeholder="请输入密码"
77
+ required
78
+ />
79
+
80
+ <Button
81
+ variant="primary"
82
+ type="submit"
83
+ fullWidth
84
+ >
85
+ 登录
86
+ </Button>
87
+ </form>
88
+ </div>
89
+ </div>
90
+ );
91
+ };
92
+
93
+ export default LoginPage;
src/pages/PromptGroupDetailPage.tsx ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useParams, useNavigate } from 'react-router-dom';
3
+ import Layout from '../components/Layout/Layout';
4
+ import Card, { CardHeader, CardContent } from '../components/common/Card';
5
+ import Button from '../components/common/Button';
6
+ import PromptList from '../components/Prompt/PromptList';
7
+ import DslFileList from '../components/DslFile/DslFileList';
8
+ import DslFileUploader from '../components/DslFile/DslFileUploader';
9
+ import CategoryBadge from '../components/Category/CategoryBadge';
10
+ import { useApp } from '../contexts/AppContext';
11
+ import { exportPromptGroupToZip } from '../utils/exportUtils';
12
+
13
+ const PromptGroupDetailPage: React.FC = () => {
14
+ const { id } = useParams<{ id: string }>();
15
+ const navigate = useNavigate();
16
+ const {
17
+ promptGroups,
18
+ categories,
19
+ updatePromptGroup,
20
+ deletePromptGroup,
21
+ addPrompt,
22
+ updatePrompt,
23
+ deletePrompt
24
+ } = useApp();
25
+
26
+ const [activeTab, setActiveTab] = useState<'prompts' | 'dslFiles'>('prompts');
27
+
28
+ if (!id) {
29
+ return <div>提示词组ID无效</div>;
30
+ }
31
+
32
+ const promptGroup = promptGroups.find(group => group._id === id);
33
+
34
+ if (!promptGroup) {
35
+ return (
36
+ <Layout title="未找到" showBackButton>
37
+ <div className="ios-empty-state">
38
+ <div className="ios-empty-state-icon">
39
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
40
+ <circle cx="12" cy="12" r="10"></circle>
41
+ <line x1="12" y1="8" x2="12" y2="12"></line>
42
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
43
+ </svg>
44
+ </div>
45
+ <h3 className="ios-empty-state-title">未找到提示词组</h3>
46
+ <p className="ios-empty-state-text">该提示词组可能已被删除</p>
47
+ <Button
48
+ variant="primary"
49
+ className="mt-4"
50
+ onClick={() => navigate('/')}
51
+ >
52
+ 返回首页
53
+ </Button>
54
+ </div>
55
+ </Layout>
56
+ );
57
+ }
58
+
59
+ const category = categories.find(c => c._id === promptGroup.category);
60
+
61
+ const handleDeletePromptGroup = () => {
62
+ if (window.confirm('确定要删除此提示词组吗?此操作不可撤销,所有相关提示词和文件都将被删除。')) {
63
+ deletePromptGroup(promptGroup._id);
64
+ navigate('/');
65
+ }
66
+ };
67
+
68
+ const handleExportPromptGroup = () => {
69
+ exportPromptGroupToZip(promptGroup);
70
+ };
71
+
72
+ const formatDate = (date: string | Date) => {
73
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
74
+ return dateObj.toLocaleDateString('zh-CN', {
75
+ year: 'numeric',
76
+ month: 'long',
77
+ day: 'numeric'
78
+ });
79
+ };
80
+
81
+ return (
82
+ <Layout
83
+ title={promptGroup.name}
84
+ showBackButton
85
+ rightAction={
86
+ <Button
87
+ variant="primary"
88
+ size="small"
89
+ onClick={handleExportPromptGroup}
90
+ >
91
+ 导出
92
+ </Button>
93
+ }
94
+ >
95
+ <Card className="mb-4">
96
+ <CardContent>
97
+ <div className="flex justify-between items-start">
98
+ <div>
99
+ <h1 className="text-2xl font-bold mb-2">{promptGroup.name}</h1>
100
+ {category && <CategoryBadge category={category} className="mb-2" />}
101
+ <p className="text-gray-600 mb-2">{promptGroup.description || '无描述'}</p>
102
+ <div className="text-sm text-gray-500">
103
+ 创建于 {formatDate(promptGroup.createdAt)}
104
+ <span className="mx-2">•</span>
105
+ 更新于 {formatDate(promptGroup.updatedAt)}
106
+ </div>
107
+ </div>
108
+ <div className="flex space-x-2">
109
+ <Button
110
+ variant="secondary"
111
+ size="small"
112
+ onClick={() => navigate(`/edit-prompt-group/${promptGroup._id}`)}
113
+ >
114
+ 编辑
115
+ </Button>
116
+ <Button
117
+ variant="danger"
118
+ size="small"
119
+ onClick={handleDeletePromptGroup}
120
+ >
121
+ 删除
122
+ </Button>
123
+ </div>
124
+ </div>
125
+ </CardContent>
126
+ </Card>
127
+
128
+ <div className="flex border-b border-gray-200 mb-4">
129
+ <button
130
+ className={`py-2 px-4 font-medium text-sm ${activeTab === 'prompts' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-500'}`}
131
+ onClick={() => setActiveTab('prompts')}
132
+ >
133
+ 提示词 ({promptGroup.prompts.length})
134
+ </button>
135
+ <button
136
+ className={`py-2 px-4 font-medium text-sm ${activeTab === 'dslFiles' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-500'}`}
137
+ onClick={() => setActiveTab('dslFiles')}
138
+ >
139
+ DSL文件 ({promptGroup.dslFiles.length})
140
+ </button>
141
+ </div>
142
+ <div className="space-y-4 max-h-screen overflow-y-auto">
143
+ {activeTab === 'prompts' && (
144
+ <PromptList
145
+ groupId={promptGroup._id}
146
+ prompts={promptGroup.prompts}
147
+ onAddPrompt={(promptData) => addPrompt(promptGroup._id, promptData)}
148
+ onUpdatePrompt={(promptId, promptData) => updatePrompt(promptGroup._id, promptId, promptData)}
149
+ onDeletePrompt={(promptId) => deletePrompt(promptGroup._id, promptId)}
150
+ />
151
+ )}
152
+ </div>
153
+ {activeTab === 'dslFiles' && (
154
+ <div>
155
+ <div className="flex justify-between items-center mb-4">
156
+ <h3 className="text-lg font-medium">DSL文件列表</h3>
157
+ <DslFileUploader groupId={promptGroup._id} />
158
+ </div>
159
+ <DslFileList
160
+ groupId={promptGroup._id}
161
+ files={promptGroup.dslFiles}
162
+ />
163
+ </div>
164
+ )}
165
+ </Layout>
166
+ );
167
+ };
168
+
169
+ export default PromptGroupDetailPage;
src/pages/SettingsPage.tsx ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import Layout from '../components/Layout/Layout';
3
+ import Card, { CardHeader, CardContent } from '../components/common/Card';
4
+ import Button from '../components/common/Button';
5
+ import { useNavigate } from 'react-router-dom';
6
+
7
+ const SettingsPage: React.FC = () => {
8
+ const navigate = useNavigate();
9
+ const [dataExported, setDataExported] = useState(false);
10
+
11
+ // 导出所有数据
12
+ const handleExportAllData = () => {
13
+ const promptGroups = localStorage.getItem('promptGroups');
14
+ const categories = localStorage.getItem('categories');
15
+
16
+ const allData = {
17
+ promptGroups: promptGroups ? JSON.parse(promptGroups) : [],
18
+ categories: categories ? JSON.parse(categories) : []
19
+ };
20
+
21
+ const jsonString = JSON.stringify(allData, null, 2);
22
+ const blob = new Blob([jsonString], { type: 'application/json' });
23
+ const url = URL.createObjectURL(blob);
24
+
25
+ const a = document.createElement('a');
26
+ a.href = url;
27
+ a.download = `prompt-manager-backup-${new Date().toISOString().split('T')[0]}.json`;
28
+ document.body.appendChild(a);
29
+ a.click();
30
+
31
+ // 清理
32
+ setTimeout(() => {
33
+ document.body.removeChild(a);
34
+ URL.revokeObjectURL(url);
35
+ setDataExported(true);
36
+
37
+ // 重置状态
38
+ setTimeout(() => setDataExported(false), 3000);
39
+ }, 0);
40
+ };
41
+
42
+ // 导入数据文件
43
+ const handleImportData = (event: React.ChangeEvent<HTMLInputElement>) => {
44
+ const file = event.target.files?.[0];
45
+ if (!file) return;
46
+
47
+ const reader = new FileReader();
48
+ reader.onload = (e) => {
49
+ try {
50
+ const data = JSON.parse(e.target?.result as string);
51
+
52
+ if (data.promptGroups && Array.isArray(data.promptGroups)) {
53
+ localStorage.setItem('promptGroups', JSON.stringify(data.promptGroups));
54
+ }
55
+
56
+ if (data.categories && Array.isArray(data.categories)) {
57
+ localStorage.setItem('categories', JSON.stringify(data.categories));
58
+ }
59
+
60
+ alert('数据导入成功,应用将刷新以加载新数据。');
61
+ window.location.reload();
62
+ } catch (error) {
63
+ alert('导入失败,文件格式不正确。');
64
+ console.error('导入错误:', error);
65
+ }
66
+ };
67
+ reader.readAsText(file);
68
+ };
69
+
70
+ // 清除所有数据
71
+ const handleClearAllData = () => {
72
+ if (window.confirm('确定要清除所有数据吗?此操作不可撤销。')) {
73
+ localStorage.removeItem('promptGroups');
74
+ localStorage.removeItem('categories');
75
+ alert('所有数据已清除,应用将刷新。');
76
+ window.location.reload();
77
+ }
78
+ };
79
+
80
+ return (
81
+ <Layout title="设置">
82
+ <div className="space-y-4">
83
+ <Card>
84
+ <CardHeader title="数据管理" />
85
+ <CardContent>
86
+ <div className="space-y-4">
87
+ <div>
88
+ <h3 className="font-medium mb-2">备份数据</h3>
89
+ <p className="text-gray-600 text-sm mb-2">
90
+ 导出所有提示词组和分类数据为JSON文件,可用于备份或迁移。
91
+ </p>
92
+ <Button
93
+ variant="primary"
94
+ onClick={handleExportAllData}
95
+ >
96
+ {dataExported ? '✓ 导出成功' : '导出所有数据'}
97
+ </Button>
98
+ </div>
99
+
100
+ <div className="ios-divider"></div>
101
+
102
+ <div>
103
+ <h3 className="font-medium mb-2">导入数据</h3>
104
+ <p className="text-gray-600 text-sm mb-2">
105
+ 从之前导出的JSON文件中恢复数据。将覆盖当前所有数据。
106
+ </p>
107
+ <input
108
+ type="file"
109
+ id="import-file"
110
+ accept=".json"
111
+ style={{ display: 'none' }}
112
+ onChange={handleImportData}
113
+ />
114
+ <Button
115
+ variant="secondary"
116
+ onClick={() => document.getElementById('import-file')?.click()}
117
+ >
118
+ 导入数据
119
+ </Button>
120
+ </div>
121
+
122
+ <div className="ios-divider"></div>
123
+
124
+ <div>
125
+ <h3 className="font-medium mb-2">清除数据</h3>
126
+ <p className="text-gray-600 text-sm mb-2">
127
+ 删除所有存储的提示词组和分类数据。此操作不可撤销。
128
+ </p>
129
+ <Button
130
+ variant="danger"
131
+ onClick={handleClearAllData}
132
+ >
133
+ 清除所有数据
134
+ </Button>
135
+ </div>
136
+ </div>
137
+ </CardContent>
138
+ </Card>
139
+
140
+ <Card>
141
+ <CardHeader title="关于" />
142
+ <CardContent>
143
+ <h3 className="font-medium mb-2">提示词管理应用</h3>
144
+ <p className="text-gray-600 text-sm mb-4">
145
+ 版本 1.0.0
146
+ </p>
147
+ <p className="text-gray-600 text-sm">
148
+ 这是一个用于管理提示词、工作流和DSL文件的本地应用。所有数据均存储在浏览器的本地存储中。
149
+ </p>
150
+ </CardContent>
151
+ </Card>
152
+ </div>
153
+ </Layout>
154
+ );
155
+ };
156
+
157
+ export default SettingsPage;
src/react-app-env.d.ts ADDED
File without changes
src/reportWebVitals.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ReportHandler } from 'web-vitals';
2
+
3
+ const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4
+ if (onPerfEntry && onPerfEntry instanceof Function) {
5
+ import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6
+ getCLS(onPerfEntry);
7
+ getFID(onPerfEntry);
8
+ getFCP(onPerfEntry);
9
+ getLCP(onPerfEntry);
10
+ getTTFB(onPerfEntry);
11
+ });
12
+ }
13
+ };
14
+
15
+ export default reportWebVitals;
src/services/api.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+
3
+ // 创建 axios 实例
4
+ const api = axios.create({
5
+ baseURL: 'https://samlax12-promptbackend.hf.space/api', // 使用实际后端地址
6
+ headers: {
7
+ 'Content-Type': 'application/json'
8
+ }
9
+ });
10
+
11
+ // 请求拦截器,添加认证信息
12
+ api.interceptors.request.use(config => {
13
+ const token = localStorage.getItem('authToken');
14
+ if (token) {
15
+ config.headers.Authorization = `Bearer ${token}`;
16
+ }
17
+ return config;
18
+ }, error => {
19
+ return Promise.reject(error);
20
+ });
21
+
22
+ // 响应拦截器,处理错误
23
+ api.interceptors.response.use(response => {
24
+ return response;
25
+ }, error => {
26
+ if (error.response && error.response.status === 401) {
27
+ // 认证失败,清除登录信息并重定向到登录页
28
+ localStorage.removeItem('authToken');
29
+ localStorage.removeItem('user');
30
+ window.location.href = '/login';
31
+ }
32
+ return Promise.reject(error);
33
+ });
34
+
35
+ // 用户认证相关 API
36
+ export const authAPI = {
37
+ login: async (username: string, password: string) => {
38
+ const response = await api.post('/auth/login', { username, password });
39
+ // eslint-disable-next-line
40
+ const { token, _id, username: user } = response.data;
41
+ localStorage.setItem('authToken', token);
42
+ localStorage.setItem('user', user);
43
+ return response.data;
44
+ },
45
+
46
+ getProfile: async () => {
47
+ return api.get('/auth/profile');
48
+ },
49
+
50
+ logout: () => {
51
+ localStorage.removeItem('authToken');
52
+ localStorage.removeItem('user');
53
+ }
54
+ };
55
+
56
+ // 分类相关 API
57
+ export const categoryAPI = {
58
+ getAll: async () => {
59
+ const response = await api.get('/categories');
60
+ return response.data;
61
+ },
62
+
63
+ create: async (data: { name: string; color: string }) => {
64
+ const response = await api.post('/categories', data);
65
+ return response.data;
66
+ },
67
+
68
+ update: async (id: string, data: { name: string; color: string }) => {
69
+ const response = await api.put(`/categories/${id}`, data);
70
+ return response.data;
71
+ },
72
+
73
+ delete: async (id: string) => {
74
+ const response = await api.delete(`/categories/${id}`);
75
+ return response.data;
76
+ }
77
+ };
78
+
79
+ // 提示词组相关 API
80
+ export const promptGroupAPI = {
81
+ getAll: async () => {
82
+ const response = await api.get('/prompt-groups');
83
+ return response.data;
84
+ },
85
+
86
+ getById: async (id: string) => {
87
+ const response = await api.get(`/prompt-groups/${id}`);
88
+ return response.data;
89
+ },
90
+
91
+ create: async (data: { name: string; description: string; category: string }) => {
92
+ const response = await api.post('/prompt-groups', data);
93
+ return response.data;
94
+ },
95
+
96
+ update: async (id: string, data: { name: string; description: string; category: string }) => {
97
+ const response = await api.put(`/prompt-groups/${id}`, data);
98
+ return response.data;
99
+ },
100
+
101
+ delete: async (id: string) => {
102
+ const response = await api.delete(`/prompt-groups/${id}`);
103
+ return response.data;
104
+ },
105
+
106
+ // 提示词相关操作
107
+ addPrompt: async (groupId: string, data: { title: string; content: string; tags: string[] }) => {
108
+ const response = await api.post(`/prompt-groups/${groupId}/prompts`, data);
109
+ return response.data;
110
+ },
111
+
112
+ updatePrompt: async (groupId: string, promptId: string, data: { title: string; content: string; tags: string[] }) => {
113
+ const response = await api.put(`/prompt-groups/${groupId}/prompts/${promptId}`, data);
114
+ return response.data;
115
+ },
116
+
117
+ deletePrompt: async (groupId: string, promptId: string) => {
118
+ const response = await api.delete(`/prompt-groups/${groupId}/prompts/${promptId}`);
119
+ return response.data;
120
+ },
121
+
122
+ // DSL 文件相关操作 - 更新为支持内容上传
123
+ addDslFile: async (groupId: string, data: { name: string; content?: string; fileData?: string; mimeType?: string }) => {
124
+ const response = await api.post(`/prompt-groups/${groupId}/dsl-files`, data);
125
+ return response.data;
126
+ },
127
+
128
+ updateDslFile: async (groupId: string, fileId: string, data: { name?: string; content?: string }) => {
129
+ const response = await api.put(`/prompt-groups/${groupId}/dsl-files/${fileId}`, data);
130
+ return response.data;
131
+ },
132
+
133
+ deleteDslFile: async (groupId: string, fileId: string) => {
134
+ const response = await api.delete(`/prompt-groups/${groupId}/dsl-files/${fileId}`);
135
+ return response.data;
136
+ }
137
+ };
138
+
139
+ export default api;
src/setupTests.ts ADDED
File without changes
src/styles/global.css ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Tailwind基础样式 */
2
+ @import 'tailwindcss/base';
3
+ @import 'tailwindcss/components';
4
+ @import 'tailwindcss/utilities';
5
+
6
+ /* 全局样式 */
7
+ html, body {
8
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
9
+ -webkit-font-smoothing: antialiased;
10
+ -moz-osx-font-smoothing: grayscale;
11
+ color: #000000;
12
+ height: 100%;
13
+ }
14
+
15
+ #root {
16
+ min-height: 100%;
17
+ display: flex;
18
+ flex-direction: column;
19
+ }
20
+
21
+ /* 滚动条样式 */
22
+ ::-webkit-scrollbar {
23
+ width: 8px;
24
+ height: 8px;
25
+ }
26
+
27
+ ::-webkit-scrollbar-track {
28
+ background: transparent;
29
+ }
30
+
31
+ ::-webkit-scrollbar-thumb {
32
+ background: #d1d1d6;
33
+ border-radius: 4px;
34
+ }
35
+
36
+ ::-webkit-scrollbar-thumb:hover {
37
+ background: #c7c7cc;
38
+ }
39
+
40
+ /* 文本溢出处理 */
41
+ .line-clamp-1 {
42
+ display: -webkit-box;
43
+ -webkit-line-clamp: 1;
44
+ -webkit-box-orient: vertical;
45
+ overflow: hidden;
46
+ }
47
+
48
+ .line-clamp-2 {
49
+ display: -webkit-box;
50
+ -webkit-line-clamp: 2;
51
+ -webkit-box-orient: vertical;
52
+ overflow: hidden;
53
+ }
54
+
55
+ .line-clamp-3 {
56
+ display: -webkit-box;
57
+ -webkit-line-clamp: 3;
58
+ -webkit-box-orient: vertical;
59
+ overflow: hidden;
60
+ }
61
+
62
+ /* 修复安全区域问题 */
63
+ @supports (padding: max(0px)) {
64
+ .ios-safe-padding-bottom {
65
+ padding-bottom: max(16px, env(safe-area-inset-bottom));
66
+ }
67
+
68
+ .ios-toolbar {
69
+ padding-bottom: env(safe-area-inset-bottom);
70
+ }
71
+ }
src/styles/iosStyles.css ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* iOS风格全局样式 */
2
+ :root {
3
+ /* iOS 风格颜色 */
4
+ --ios-blue: #007AFF;
5
+ --ios-green: #4CD964;
6
+ --ios-red: #FF3B30;
7
+ --ios-orange: #FF9500;
8
+ --ios-yellow: #FFCC00;
9
+ --ios-purple: #5856D6;
10
+ --ios-pink: #FF2D55;
11
+ --ios-teal: #5AC8FA;
12
+ --ios-indigo: #5E5CE6;
13
+
14
+ /* 系统灰色 */
15
+ --ios-gray: #8E8E93;
16
+ --ios-gray2: #AEAEB2;
17
+ --ios-gray3: #C7C7CC;
18
+ --ios-gray4: #D1D1D6;
19
+ --ios-gray5: #E5E5EA;
20
+ --ios-gray6: #F2F2F7;
21
+
22
+ /* 背景色 */
23
+ --ios-background: #F2F2F7;
24
+ --ios-card-background: #FFFFFF;
25
+
26
+ /* 字体大小 */
27
+ --ios-font-small: 13px;
28
+ --ios-font-medium: 15px;
29
+ --ios-font-large: 17px;
30
+ --ios-font-xlarge: 20px;
31
+ --ios-font-xxlarge: 22px;
32
+ --ios-font-title: 28px;
33
+
34
+ /* 圆角 */
35
+ --ios-radius-small: 6px;
36
+ --ios-radius-medium: 10px;
37
+ --ios-radius-large: 14px;
38
+
39
+ /* 内边距 */
40
+ --ios-padding-small: 8px;
41
+ --ios-padding-medium: 16px;
42
+ --ios-padding-large: 24px;
43
+
44
+ /* 动画 */
45
+ --ios-transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
46
+ }
47
+
48
+ /* 全局样式重置 */
49
+ * {
50
+ margin: 0;
51
+ padding: 0;
52
+ box-sizing: border-box;
53
+ -webkit-tap-highlight-color: transparent;
54
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
55
+ }
56
+
57
+ body {
58
+ background-color: var(--ios-background);
59
+ color: #000000;
60
+ padding-bottom: env(safe-area-inset-bottom);
61
+ }
62
+
63
+ /* iOS风格容器 */
64
+ .ios-container {
65
+ max-width: 1200px;
66
+ margin: 0 auto;
67
+ padding: var(--ios-padding-medium);
68
+ }
69
+
70
+ /* iOS风格页面标题 */
71
+ .ios-page-title {
72
+ font-size: var(--ios-font-title);
73
+ font-weight: 700;
74
+ margin-bottom: var(--ios-padding-medium);
75
+ }
76
+
77
+ /* iOS风格卡片 */
78
+ .ios-card {
79
+ background-color: var(--ios-card-background);
80
+ border-radius: var(--ios-radius-medium);
81
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
82
+ overflow: hidden;
83
+ margin-bottom: var(--ios-padding-medium);
84
+ transition: var(--ios-transition);
85
+ }
86
+
87
+ .ios-card:hover {
88
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
89
+ transform: translateY(-2px);
90
+ }
91
+
92
+ /* iOS风格按钮 */
93
+ .ios-button {
94
+ display: inline-flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ min-height: 44px;
98
+ padding: 0 var(--ios-padding-medium);
99
+ border-radius: 22px;
100
+ font-size: var(--ios-font-medium);
101
+ font-weight: 600;
102
+ text-align: center;
103
+ transition: var(--ios-transition);
104
+ cursor: pointer;
105
+ user-select: none;
106
+ border: none;
107
+ outline: none;
108
+ }
109
+
110
+ .ios-button-primary {
111
+ background-color: var(--ios-blue);
112
+ color: white;
113
+ }
114
+
115
+ .ios-button-primary:hover {
116
+ background-color: #0071EB;
117
+ }
118
+
119
+ .ios-button-secondary {
120
+ background-color: var(--ios-gray5);
121
+ color: #000000;
122
+ }
123
+
124
+ .ios-button-secondary:hover {
125
+ background-color: var(--ios-gray4);
126
+ }
127
+
128
+ .ios-button-danger {
129
+ background-color: var(--ios-red);
130
+ color: white;
131
+ }
132
+
133
+ .ios-button-danger:hover {
134
+ background-color: #FF2D30;
135
+ }
136
+
137
+ /* iOS风格输入框 */
138
+ .ios-input {
139
+ width: 100%;
140
+ height: 44px;
141
+ padding: 0 var(--ios-padding-medium);
142
+ font-size: var(--ios-font-medium);
143
+ background-color: var(--ios-gray6);
144
+ border-radius: var(--ios-radius-medium);
145
+ border: none;
146
+ transition: var(--ios-transition);
147
+ }
148
+
149
+ .ios-input:focus {
150
+ background-color: white;
151
+ box-shadow: 0 0 0 2px var(--ios-blue);
152
+ outline: none;
153
+ }
154
+
155
+ /* iOS风格文本域 */
156
+ .ios-textarea {
157
+ width: 100%;
158
+ min-height: 100px;
159
+ padding: var(--ios-padding-medium);
160
+ font-size: var(--ios-font-medium);
161
+ background-color: var(--ios-gray6);
162
+ border-radius: var(--ios-radius-medium);
163
+ border: none;
164
+ resize: vertical;
165
+ transition: var(--ios-transition);
166
+ }
167
+
168
+ .ios-textarea:focus {
169
+ background-color: white;
170
+ box-shadow: 0 0 0 2px var(--ios-blue);
171
+ outline: none;
172
+ }
173
+
174
+ /* iOS风格标签 */
175
+ .ios-tag {
176
+ display: inline-flex;
177
+ align-items: center;
178
+ height: 24px;
179
+ padding: 0 10px;
180
+ font-size: var(--ios-font-small);
181
+ font-weight: 500;
182
+ border-radius: 12px;
183
+ margin-right: 8px;
184
+ margin-bottom: 8px;
185
+ }
186
+
187
+ /* iOS风格导航栏 */
188
+ .ios-navbar {
189
+ display: flex;
190
+ align-items: center;
191
+ height: 44px;
192
+ padding: 0 var(--ios-padding-medium);
193
+ background-color: rgba(255, 255, 255, 0.8);
194
+ backdrop-filter: blur(10px);
195
+ -webkit-backdrop-filter: blur(10px);
196
+ position: sticky;
197
+ top: 0;
198
+ z-index: 100;
199
+ border-bottom: 1px solid var(--ios-gray5);
200
+ }
201
+
202
+ .ios-navbar-title {
203
+ font-size: var(--ios-font-large);
204
+ font-weight: 600;
205
+ flex: 1;
206
+ text-align: center;
207
+ }
208
+
209
+ .ios-navbar-button {
210
+ font-size: var(--ios-font-medium);
211
+ font-weight: 500;
212
+ color: var(--ios-blue);
213
+ background: none;
214
+ border: none;
215
+ cursor: pointer;
216
+ padding: 0 10px;
217
+ height: 100%;
218
+ display: flex;
219
+ align-items: center;
220
+ }
221
+
222
+ /* iOS风格底部工具栏 */
223
+ .ios-toolbar {
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: space-around;
227
+ height: 50px;
228
+ background-color: rgba(255, 255, 255, 0.8);
229
+ backdrop-filter: blur(10px);
230
+ -webkit-backdrop-filter: blur(10px);
231
+ position: fixed;
232
+ bottom: 0;
233
+ left: 0;
234
+ right: 0;
235
+ border-top: 1px solid var(--ios-gray5);
236
+ padding-bottom: env(safe-area-inset-bottom);
237
+ z-index: 100;
238
+ }
239
+
240
+ .ios-toolbar-item {
241
+ flex: 1;
242
+ display: flex;
243
+ flex-direction: column;
244
+ align-items: center;
245
+ justify-content: center;
246
+ height: 100%;
247
+ font-size: 10px;
248
+ color: var(--ios-gray);
249
+ }
250
+
251
+ .ios-toolbar-item.active {
252
+ color: var(--ios-blue);
253
+ }
254
+
255
+ /* iOS风格列表 */
256
+ .ios-list {
257
+ background-color: white;
258
+ border-radius: var(--ios-radius-medium);
259
+ overflow: hidden;
260
+ }
261
+
262
+ .ios-list-item {
263
+ display: flex;
264
+ align-items: center;
265
+ min-height: 44px;
266
+ padding: 0 var(--ios-padding-medium);
267
+ border-bottom: 1px solid var(--ios-gray5);
268
+ }
269
+
270
+ .ios-list-item:last-child {
271
+ border-bottom: none;
272
+ }
273
+
274
+ .ios-list-item-content {
275
+ flex: 1;
276
+ }
277
+
278
+ .ios-list-item-title {
279
+ font-size: var(--ios-font-medium);
280
+ font-weight: 400;
281
+ }
282
+
283
+ .ios-list-item-subtitle {
284
+ font-size: var(--ios-font-small);
285
+ color: var(--ios-gray);
286
+ margin-top: 2px;
287
+ }
288
+
289
+ .ios-list-item-chevron {
290
+ color: var(--ios-gray3);
291
+ margin-left: var(--ios-padding-small);
292
+ }
293
+
294
+ /* iOS风格分割线 */
295
+ .ios-divider {
296
+ height: 1px;
297
+ background-color: var(--ios-gray5);
298
+ margin: var(--ios-padding-medium) 0;
299
+ }
300
+
301
+ /* iOS风格开关 */
302
+ .ios-switch {
303
+ position: relative;
304
+ display: inline-block;
305
+ width: 51px;
306
+ height: 31px;
307
+ }
308
+
309
+ .ios-switch input {
310
+ opacity: 0;
311
+ width: 0;
312
+ height: 0;
313
+ }
314
+
315
+ .ios-switch-slider {
316
+ position: absolute;
317
+ cursor: pointer;
318
+ top: 0;
319
+ left: 0;
320
+ right: 0;
321
+ bottom: 0;
322
+ background-color: var(--ios-gray4);
323
+ border-radius: 34px;
324
+ transition: var(--ios-transition);
325
+ }
326
+
327
+ .ios-switch-slider:before {
328
+ position: absolute;
329
+ content: "";
330
+ height: 27px;
331
+ width: 27px;
332
+ left: 2px;
333
+ bottom: 2px;
334
+ background-color: white;
335
+ border-radius: 50%;
336
+ transition: var(--ios-transition);
337
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
338
+ }
339
+
340
+ .ios-switch input:checked + .ios-switch-slider {
341
+ background-color: var(--ios-green);
342
+ }
343
+
344
+ .ios-switch input:checked + .ios-switch-slider:before {
345
+ transform: translateX(20px);
346
+ }
347
+
348
+ /* iOS风格模态框 */
349
+ .ios-modal-backdrop {
350
+ position: fixed;
351
+ top: 0;
352
+ left: 0;
353
+ right: 0;
354
+ bottom: 0;
355
+ background-color: rgba(0, 0, 0, 0.5);
356
+ display: flex;
357
+ align-items: center;
358
+ justify-content: center;
359
+ z-index: 1000;
360
+ opacity: 0;
361
+ pointer-events: none;
362
+ transition: var(--ios-transition);
363
+ }
364
+
365
+ .ios-modal-backdrop.open {
366
+ opacity: 1;
367
+ pointer-events: auto;
368
+ }
369
+
370
+ .ios-modal {
371
+ width: 90%;
372
+ max-width: 500px;
373
+ background-color: white;
374
+ border-radius: var(--ios-radius-large);
375
+ overflow: hidden;
376
+ transform: scale(0.9);
377
+ transition: var(--ios-transition);
378
+ }
379
+
380
+ .ios-modal-backdrop.open .ios-modal {
381
+ transform: scale(1);
382
+ }
383
+
384
+ .ios-modal-header {
385
+ padding: var(--ios-padding-medium);
386
+ text-align: center;
387
+ border-bottom: 1px solid var(--ios-gray5);
388
+ }
389
+
390
+ .ios-modal-title {
391
+ font-size: var(--ios-font-large);
392
+ font-weight: 600;
393
+ }
394
+
395
+ .ios-modal-body {
396
+ padding: var(--ios-padding-medium);
397
+ max-height: 50vh;
398
+ overflow-y: auto;
399
+ }
400
+
401
+ .ios-modal-footer {
402
+ display: flex;
403
+ border-top: 1px solid var(--ios-gray5);
404
+ }
405
+
406
+ .ios-modal-button {
407
+ flex: 1;
408
+ height: 44px;
409
+ border: none;
410
+ background: none;
411
+ font-size: var(--ios-font-medium);
412
+ font-weight: 500;
413
+ cursor: pointer;
414
+ transition: var(--ios-transition);
415
+ }
416
+
417
+ .ios-modal-button:not(:last-child) {
418
+ border-right: 1px solid var(--ios-gray5);
419
+ }
420
+
421
+ .ios-modal-button-primary {
422
+ color: var(--ios-blue);
423
+ }
424
+
425
+ .ios-modal-button-primary:hover {
426
+ background-color: rgba(0, 122, 255, 0.1);
427
+ }
428
+
429
+ .ios-modal-button-danger {
430
+ color: var(--ios-red);
431
+ }
432
+
433
+ .ios-modal-button-danger:hover {
434
+ background-color: rgba(255, 59, 48, 0.1);
435
+ }
436
+
437
+ /* iOS风格加载指示�� */
438
+ .ios-loader {
439
+ display: inline-block;
440
+ width: 20px;
441
+ height: 20px;
442
+ border: 2px solid rgba(0, 122, 255, 0.3);
443
+ border-radius: 50%;
444
+ border-top-color: var(--ios-blue);
445
+ animation: ios-spin 1s linear infinite;
446
+ }
447
+
448
+ @keyframes ios-spin {
449
+ to {
450
+ transform: rotate(360deg);
451
+ }
452
+ }
453
+
454
+ /* iOS风格空状态 */
455
+ .ios-empty-state {
456
+ display: flex;
457
+ flex-direction: column;
458
+ align-items: center;
459
+ justify-content: center;
460
+ min-height: 200px;
461
+ padding: var(--ios-padding-large);
462
+ text-align: center;
463
+ }
464
+
465
+ .ios-empty-state-icon {
466
+ font-size: 48px;
467
+ color: var(--ios-gray3);
468
+ margin-bottom: var(--ios-padding-medium);
469
+ }
470
+
471
+ .ios-empty-state-title {
472
+ font-size: var(--ios-font-large);
473
+ font-weight: 600;
474
+ margin-bottom: 8px;
475
+ }
476
+
477
+ .ios-empty-state-text {
478
+ font-size: var(--ios-font-medium);
479
+ color: var(--ios-gray);
480
+ max-width: 250px;
481
+ }