Spaces:
Sleeping
Sleeping
Upload 55 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- public/index.html +43 -0
- public/manifest.json +0 -0
- public/robots.txt +0 -0
- src/App.tsx +89 -0
- src/components/Category/CategoryBadge.tsx +26 -0
- src/components/Category/CategoryForm.tsx +99 -0
- src/components/Category/CategorySelector.tsx +96 -0
- src/components/DslFile/DslFileList.tsx +275 -0
- src/components/DslFile/DslFileUploader.tsx +238 -0
- src/components/Layout/Header.tsx +50 -0
- src/components/Layout/Layout.tsx +50 -0
- src/components/Layout/Navigation.tsx +74 -0
- src/components/Layout/Sidebar.tsx +102 -0
- src/components/Prompt/PromptCard.tsx +141 -0
- src/components/Prompt/PromptDetail.tsx +186 -0
- src/components/Prompt/PromptForm.tsx +166 -0
- src/components/Prompt/PromptList.tsx +275 -0
- src/components/PromptGroup/PromptGroupCard.tsx +88 -0
- src/components/PromptGroup/PromptGroupDetail.tsx +156 -0
- src/components/PromptGroup/PromptGroupForm.tsx +118 -0
- src/components/PromptGroup/PromptGroupList.tsx +100 -0
- src/components/ProtectedRoute.tsx +28 -0
- src/components/common/Button.tsx +75 -0
- src/components/common/Card.tsx +82 -0
- src/components/common/Dropdown.tsx +121 -0
- src/components/common/Input.tsx +44 -0
- src/components/common/Modal.tsx +163 -0
- src/components/common/TextArea.tsx +35 -0
- src/contexts/AppContext.tsx +264 -0
- src/contexts/AuthContext.tsx +88 -0
- src/contexts/ThemeContext.tsx +88 -0
- src/hooks/useLocalStorage.ts +92 -0
- src/hooks/usePromptGroups.ts +254 -0
- src/index.css +0 -0
- src/index.tsx +18 -0
- src/pages/CategoriesPage.tsx +195 -0
- src/pages/CreatePromptGroupPage.tsx +34 -0
- src/pages/CreatePromptPage.tsx +50 -0
- src/pages/EditPromptGroupPage.tsx +51 -0
- src/pages/EditPromptPage.tsx +64 -0
- src/pages/HomePage.tsx +17 -0
- src/pages/LoginPage.tsx +93 -0
- src/pages/PromptGroupDetailPage.tsx +169 -0
- src/pages/SettingsPage.tsx +157 -0
- src/react-app-env.d.ts +0 -0
- src/reportWebVitals.ts +15 -0
- src/services/api.ts +139 -0
- src/setupTests.ts +0 -0
- src/styles/global.css +71 -0
- 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 |
+
}
|