a
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/* ========================================
|
||||
飞点健康 CMS - 全局样式
|
||||
基于 Modern Healthcare 风格
|
||||
======================================== */
|
||||
|
||||
/* 访客端页面基础样式 */
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 响应式容器 */
|
||||
.container-wide {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container-wide {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container-wide {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container-wide {
|
||||
padding: 0 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平滑滚动 */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 焦点样式 */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--health-primary, #10B981);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 图片懒加载占位 */
|
||||
img {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 文本截断 */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { settingsApi } from './api';
|
||||
import { updateSEO } from './utils/seo';
|
||||
import { PublicLayout } from './components/Layout';
|
||||
import { AdminLayout } from './components/AdminLayout';
|
||||
import Login from './pages/Login';
|
||||
import PublicHome from './pages/PublicHome';
|
||||
import AdminHome from './pages/AdminHome';
|
||||
import HomeManage from './pages/admin/HomeManage';
|
||||
import NavMenus from './pages/NavMenus';
|
||||
import Settings from './pages/Settings';
|
||||
import ImageManager from './pages/admin/images/ImageManager';
|
||||
import ColumnList from './pages/admin/columns/ColumnList';
|
||||
import ArticleList from './pages/admin/articles/ArticleList';
|
||||
import ArticleEdit from './pages/admin/articles/ArticleEdit';
|
||||
import ArticleCrawl from './pages/admin/articles/ArticleCrawl';
|
||||
import WechatCrawlEdit from './pages/admin/articles/WechatCrawlEdit';
|
||||
import WechatBatchCrawl from './pages/admin/articles/WechatBatchCrawl';
|
||||
import NewsList from './pages/NewsList';
|
||||
import NewsDetail from './pages/NewsDetail';
|
||||
import ColumnDetail from './pages/ColumnDetail';
|
||||
import ArticleDetail from './pages/ArticleDetail';
|
||||
|
||||
// 全局:加载站点设置并应用 favicon/title(访客端+后台通用)
|
||||
function SiteSettingsProvider() {
|
||||
useEffect(() => {
|
||||
settingsApi.getPublic().then((data) => {
|
||||
if (!data) return;
|
||||
if (data.site_favicon) {
|
||||
let link = document.querySelector("link[rel~='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = data.site_favicon;
|
||||
}
|
||||
if (data.site_name) {
|
||||
document.title = data.site_name;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 全局路由 SEO 监听:admin 路由自动 noindex
|
||||
function RouteSEOListener() {
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/admin')) {
|
||||
updateSEO({ noIndex: true });
|
||||
}
|
||||
}, [location.pathname]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Protected route for admin
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">加载中...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
// Public route wrapper
|
||||
function PublicRoute({ children }) {
|
||||
return <PublicLayout>{children}</PublicLayout>;
|
||||
}
|
||||
|
||||
// Admin route wrapper
|
||||
function AdminRoute({ children }) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AdminLayout>{children}</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<SiteSettingsProvider />
|
||||
<RouteSEOListener />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<PublicHome />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/about"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Navigate to="/article/about-us" replace />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contact"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<div className="py-20 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">联系我们</h1>
|
||||
<p className="text-gray-600">如有任何问题,欢迎与我们联系</p>
|
||||
</div>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* News public routes - 兼容老链接 */}
|
||||
<Route
|
||||
path="/news"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<NewsList />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/news/:slug"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<NewsDetail />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Column & Article public routes */}
|
||||
{/* 新闻资讯栏目页 - 使用 NewsList 样式 */}
|
||||
<Route
|
||||
path="/column/xin-wen-zi-xun"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<NewsList />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
{/* 其他栏目页 - 使用通用栏目样式 */}
|
||||
<Route
|
||||
path="/column/:slug"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<ColumnDetail />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
{/* 文章详情页 */}
|
||||
<Route
|
||||
path="/article/:slug"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<ArticleDetail />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin/login" element={<Login />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminHome />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/home"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<HomeManage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/nav-menus"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<NavMenus />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<Settings />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Column Management Routes */}
|
||||
<Route
|
||||
path="/admin/columns"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ColumnList />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Article Management Routes */}
|
||||
<Route
|
||||
path="/admin/articles"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ArticleList />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/articles/new"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ArticleEdit />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/articles/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ArticleEdit />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/articles/crawl"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ArticleCrawl />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/articles/wechat-crawl"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<WechatCrawlEdit />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/articles/wechat-batch"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<WechatBatchCrawl />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Image Manager Route */}
|
||||
<Route
|
||||
path="/admin/images"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ImageManager />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 404 */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold mb-4">404</h1>
|
||||
<p className="text-gray-600 mb-4">页面未找到</p>
|
||||
<a href="/" className="btn-primary">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,190 @@
|
||||
// API client configuration
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
class ApiClient {
|
||||
constructor(baseURL) {
|
||||
this.baseURL = baseURL;
|
||||
}
|
||||
|
||||
async request(url, options = {}, raw = false) {
|
||||
const token = localStorage.getItem('token');
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const headers = {
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseURL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.detail || error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// API returns {code, data, message} format for list operations
|
||||
// But auth returns {code, token, admin} without data field
|
||||
if (raw) return result;
|
||||
return result.data !== undefined ? result.data : result;
|
||||
}
|
||||
|
||||
get(url, options = {}) {
|
||||
let fullUrl = url;
|
||||
const { params, ...fetchOptions } = options;
|
||||
if (params) {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
)
|
||||
).toString();
|
||||
if (queryString) fullUrl = `${url}?${queryString}`;
|
||||
}
|
||||
return this.request(fullUrl, fetchOptions);
|
||||
}
|
||||
|
||||
post(url, data, raw = false) {
|
||||
// 如果是FormData,不设置Content-Type,让浏览器自动设置
|
||||
if (data instanceof FormData) {
|
||||
return this.request(url, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}, true); // raw=true 避免嵌套data字段
|
||||
}
|
||||
return this.request(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}, raw);
|
||||
}
|
||||
|
||||
put(url, data) {
|
||||
return this.request(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
delete(url) {
|
||||
return this.request(url, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
const api = new ApiClient(API_BASE_URL);
|
||||
export default api;
|
||||
|
||||
// Auth API - use raw=true to get full response including token
|
||||
export const authApi = {
|
||||
login: (username, password) => api.post('/api/auth/login', { username, password }, true),
|
||||
me: () => api.get('/api/auth/me'),
|
||||
changePassword: (oldPassword, newPassword) =>
|
||||
api.post('/api/auth/change-password', { old_password: oldPassword, new_password: newPassword }),
|
||||
};
|
||||
|
||||
// Home Modules API
|
||||
export const modulesApi = {
|
||||
getPublic: () => api.get('/api/home/modules'),
|
||||
getAll: () => api.get('/api/admin/home/modules'),
|
||||
getById: (id) => api.get(`/api/admin/home/modules/${id}`),
|
||||
update: (id, data) => api.put(`/api/admin/home/modules/${id}`, data),
|
||||
sort: (orders) => api.put('/api/admin/home/modules/sort', { orders }),
|
||||
toggle: (id) => api.put(`/api/admin/home/modules/${id}/toggle`),
|
||||
};
|
||||
|
||||
// Nav Menu API
|
||||
export const navApi = {
|
||||
getPublic: () => api.get('/api/nav'),
|
||||
getAll: () => api.get('/api/admin/nav'),
|
||||
create: (data) => api.post('/api/admin/nav', data),
|
||||
update: (id, data) => api.put(`/api/admin/nav/${id}`, data),
|
||||
delete: (id) => api.delete(`/api/admin/nav/${id}`),
|
||||
};
|
||||
|
||||
// Contact QR Code API
|
||||
export const contactQrApi = {
|
||||
getPublic: () => api.get('/api/contact-qrcodes'),
|
||||
getAll: () => api.get('/api/admin/contact-qrcodes'),
|
||||
create: (data) => api.post('/api/admin/contact-qrcodes', data),
|
||||
update: (id, data) => api.put(`/api/admin/contact-qrcodes/${id}`, data),
|
||||
delete: (id) => api.delete(`/api/admin/contact-qrcodes/${id}`),
|
||||
};
|
||||
|
||||
// Settings API
|
||||
export const settingsApi = {
|
||||
getPublic: () => api.get('/api/settings'),
|
||||
getAll: () => api.get('/api/admin/settings'),
|
||||
update: (settings) => api.put('/api/admin/settings', { settings }),
|
||||
};
|
||||
|
||||
// App Download API
|
||||
export const appDownloadsApi = {
|
||||
getPublic: () => api.get('/api/app-downloads'),
|
||||
getAll: () => api.get('/api/admin/app-downloads'),
|
||||
create: (data) => api.post('/api/admin/app-downloads', data),
|
||||
update: (id, data) => api.put(`/api/admin/app-downloads/${id}`, data),
|
||||
delete: (id) => api.delete(`/api/admin/app-downloads/${id}`),
|
||||
};
|
||||
|
||||
// Home Experts API
|
||||
export const homeExpertsApi = {
|
||||
getPublic: () => api.get('/api/home-experts'),
|
||||
getAll: () => api.get('/api/admin/home-experts'),
|
||||
getAvailablePosts: (keyword) => api.get('/api/admin/home-experts/available-posts', { params: { keyword } }),
|
||||
create: (data) => api.post('/api/admin/home-experts', data),
|
||||
update: (id, data) => api.put(`/api/admin/home-experts/${id}`, data),
|
||||
delete: (id) => api.delete(`/api/admin/home-experts/${id}`),
|
||||
};
|
||||
|
||||
// Home Partners API
|
||||
export const homePartnersApi = {
|
||||
getPublic: () => api.get('/api/home-partners'),
|
||||
getAll: () => api.get('/api/admin/home-partners'),
|
||||
create: (data) => api.post('/api/admin/home-partners', data),
|
||||
update: (id, data) => api.put(`/api/admin/home-partners/${id}`, data),
|
||||
delete: (id) => api.delete(`/api/admin/home-partners/${id}`),
|
||||
};
|
||||
|
||||
// Columns API
|
||||
export const columnsApi = {
|
||||
getAll: (tree = false) => api.get('/api/admin/columns', { params: { tree } }),
|
||||
getTree: () => api.get('/api/admin/columns/tree'),
|
||||
getById: (id) => api.get(`/api/admin/columns/${id}`),
|
||||
create: (data) => api.post('/api/admin/columns', data),
|
||||
update: (id, data) => api.put(`/api/admin/columns/${id}`, data),
|
||||
delete: (id, force = false) => api.delete(`/api/admin/columns/${id}?force=${force}`),
|
||||
};
|
||||
|
||||
// Articles API
|
||||
export const articlesApi = {
|
||||
getAll: (params = {}) => api.get('/api/admin/articles', { params }),
|
||||
getById: (id) => api.get(`/api/admin/articles/${id}`),
|
||||
create: (data) => api.post('/api/admin/articles', data),
|
||||
update: (id, data) => api.put(`/api/admin/articles/${id}`, data),
|
||||
delete: (id) => api.delete(`/api/admin/articles/${id}`),
|
||||
batchDelete: (ids) => api.post('/api/admin/articles/batch-delete', { ids }),
|
||||
batchMove: (ids, column_id) => api.post('/api/admin/articles/batch-move', { ids, column_id }),
|
||||
publish: (id, is_published) => api.post(`/api/admin/articles/${id}/publish`, { is_published }),
|
||||
rewritePublish: (id) => api.post(`/api/admin/articles/${id}/rewrite-publish`),
|
||||
};
|
||||
|
||||
// Upload API
|
||||
export const uploadApi = {
|
||||
uploadImage: (file, subdir = '') => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (subdir) formData.append('subdir', subdir);
|
||||
return fetch(`${API_BASE_URL}/api/upload/image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: formData,
|
||||
}).then(r => r.json());
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,238 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, useLocation, useNavigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Home,
|
||||
Menu,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
PanelLeft,
|
||||
User,
|
||||
Image,
|
||||
FolderTree,
|
||||
FileStack
|
||||
} from 'lucide-react';
|
||||
|
||||
// 菜单配置
|
||||
const menuItems = [
|
||||
{ key: 'dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin' },
|
||||
{ key: 'home', label: '首页管理', icon: Home, path: '/admin/home' },
|
||||
{ key: 'nav-menus', label: '导航菜单', icon: Menu, path: '/admin/nav-menus' },
|
||||
{
|
||||
key: 'columns',
|
||||
label: '栏目管理',
|
||||
icon: FolderTree,
|
||||
children: [
|
||||
{ key: 'column-list', label: '栏目列表', path: '/admin/columns' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'articles',
|
||||
label: '内容管理',
|
||||
icon: FileStack,
|
||||
children: [
|
||||
{ key: 'article-list', label: '内容列表', path: '/admin/articles' },
|
||||
{ key: 'article-new', label: '添加内容', path: '/admin/articles/new' },
|
||||
{ key: 'article-crawl', label: '采集内容', path: '/admin/articles/crawl' },
|
||||
{ key: 'article-wechat', label: '公众号采集', path: '/admin/articles/wechat-crawl' },
|
||||
{ key: 'article-wechat-batch', label: '公众号批量采集', path: '/admin/articles/wechat-batch' }
|
||||
]
|
||||
},
|
||||
{ key: 'images', label: '图片管理', icon: Image, path: '/admin/images' },
|
||||
{
|
||||
key: 'settings',
|
||||
label: '系统设置',
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ key: 'settings-site', label: '网站信息', path: '/admin/settings' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
export const AdminLayout = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { logout, user } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [expandedMenus, setExpandedMenus] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// 自动展开当前所在的父菜单
|
||||
const currentParent = menuItems.find(item =>
|
||||
item.children?.some(child => location.pathname === child.path)
|
||||
);
|
||||
if (currentParent) {
|
||||
const key = currentParent.key;
|
||||
const timer = setTimeout(() => {
|
||||
setExpandedMenus(prev => prev.includes(key) ? prev : [key]);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleMenu = (key) => {
|
||||
setExpandedMenus(prev =>
|
||||
prev.includes(key)
|
||||
? prev.filter(k => k !== key)
|
||||
: [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
const isActive = (path) => location.pathname === path;
|
||||
const isParentActive = (item) =>
|
||||
item.children?.some(child => location.pathname === child.path);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-full bg-[#0F172A] transition-all duration-300 z-50 flex flex-col ${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center px-4 border-b border-slate-700/50">
|
||||
<div className="w-8 h-8 bg-emerald-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white font-bold text-sm">飞</span>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="ml-3 text-white font-semibold text-lg truncate">
|
||||
飞点健康
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-2">
|
||||
<ul className="space-y-1">
|
||||
{menuItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedMenus.includes(item.key);
|
||||
const active = isParentActive(item) || isActive(item.path);
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<li key={item.key}>
|
||||
<button
|
||||
onClick={() => !collapsed && toggleMenu(item.key)}
|
||||
className={`w-full flex items-center px-3 py-2.5 rounded-lg transition-colors ${
|
||||
active
|
||||
? 'text-white bg-emerald-600'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
} ${collapsed ? 'justify-center' : ''}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="ml-3 text-sm font-medium flex-1 text-left">
|
||||
{item.label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && isExpanded && (
|
||||
<ul className="mt-1 ml-4 pl-4 border-l border-slate-700 space-y-1">
|
||||
{item.children.map(child => (
|
||||
<li key={child.key}>
|
||||
<NavLink
|
||||
to={child.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? 'text-emerald-400 bg-slate-700/50'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/30'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={item.key}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2.5 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'text-white bg-emerald-600'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
} ${collapsed ? 'justify-center' : ''}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<span className="ml-3 text-sm font-medium">{item.label}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* User & Logout */}
|
||||
<div className="p-3 border-t border-slate-700/50">
|
||||
{!collapsed && user && (
|
||||
<div className="px-3 py-2 mb-2">
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<User className="w-4 h-4" />
|
||||
<span className="text-sm truncate">{user?.username || '管理员'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`w-full flex items-center px-3 py-2 text-slate-400 hover:text-white hover:bg-slate-700/50 rounded-lg transition-colors ${
|
||||
collapsed ? 'justify-center' : ''
|
||||
}`}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
{!collapsed && <span className="ml-3 text-sm font-medium">退出登录</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapse Toggle */}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="absolute -right-3 top-20 w-6 h-6 bg-emerald-500 rounded-full flex items-center justify-center text-white shadow-lg hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
<PanelLeft className={`w-3 h-3 ${collapsed ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
className={`flex-1 transition-all duration-300 ${
|
||||
collapsed ? 'ml-16' : 'ml-64'
|
||||
}`}
|
||||
>
|
||||
<div className="p-8 w-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function HelpModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Help Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-6 h-6 flex items-center justify-center text-sm font-bold border-2 border-black bg-[var(--accent-yellow)] hover:translate-x-[1px] hover:translate-y-[1px] transition-all"
|
||||
title="帮助"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
|
||||
<div className="bg-white border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b-2 border-black bg-[var(--bg)]">
|
||||
<h2 className="text-xl font-bold">帮助文档</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="w-8 h-8 flex items-center justify-center font-bold border-2 border-black hover:bg-gray-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto">
|
||||
|
||||
<section className="mb-6">
|
||||
<h3 className="text-lg font-bold mb-2 border-b border-gray-300 pb-1">
|
||||
链接地址填写规则
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="bg-gray-50 p-3 border border-gray-200">
|
||||
<p className="font-semibold mb-1">1. 站内页面(相对路径)</p>
|
||||
<p className="text-gray-600">示例:/about、/products、/contact</p>
|
||||
<p className="text-gray-500 mt-1">用于本站内部页面跳转,自动拼接当前域名</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 border border-gray-200">
|
||||
<p className="font-semibold mb-1">2. 外部网站(完整URL)</p>
|
||||
<p className="text-gray-600">示例:https://www.example.com</p>
|
||||
<p className="text-gray-500 mt-1">需包含 http:// 或 https:// 协议头</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 border border-gray-200">
|
||||
<p className="font-semibold mb-1">3. 外部网站(裸域名)</p>
|
||||
<p className="text-gray-600">示例:www.baidu.com、example.com/page</p>
|
||||
<p className="text-gray-500 mt-1">系统会自动补全为 https://www.baidu.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Phone, Mail, MapPin, Menu, X, ChevronDown } from 'lucide-react';
|
||||
import { navApi, contactQrApi, settingsApi } from '../api';
|
||||
import { SocialIcon } from './SocialIcon';
|
||||
|
||||
// Admin Layout 用的导航链接
|
||||
function NavLink({ to, children }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`relative px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'text-emerald-600'
|
||||
: 'text-gray-700 hover:text-emerald-600'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-0.5 bg-emerald-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 导航菜单项链接处理
|
||||
function NavMenuLink({ link, isExternal, children, className, onClick }) {
|
||||
const isExt = isExternal || /^https?:\/\//.test(link);
|
||||
|
||||
if (isExt) {
|
||||
return (
|
||||
<a
|
||||
href={link || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (link && link.startsWith('#')) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.getElementById(link.slice(1));
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth' });
|
||||
if (onClick) onClick();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={link || '/'} className={className} onClick={onClick}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 桌面端二级菜单项
|
||||
function DesktopDropdownItem({ menu }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
const children = menu.children || [];
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
const handleEnter = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (hasChildren) setOpen(true);
|
||||
};
|
||||
|
||||
const handleLeave = () => {
|
||||
timerRef.current = setTimeout(() => setOpen(false), 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-gray-700 hover:text-emerald-600 transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{menu.name}
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
) : (
|
||||
<NavMenuLink
|
||||
link={menu.link}
|
||||
isExternal={menu.is_external}
|
||||
className="block px-4 py-2 text-sm font-medium text-gray-700 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
{menu.name}
|
||||
</NavMenuLink>
|
||||
)}
|
||||
|
||||
{hasChildren && open && (
|
||||
<div className="absolute top-full left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-100 py-2 z-50 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
{children.map((child) => (
|
||||
<NavMenuLink
|
||||
key={child.id}
|
||||
link={child.link}
|
||||
isExternal={child.is_external}
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:text-emerald-600 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{child.name}
|
||||
</NavMenuLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端二级菜单项
|
||||
function MobileMenuItem({ menu, onClose }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const children = menu.children || [];
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-3 px-4 text-base font-medium text-gray-700 hover:bg-gray-50 hover:text-emerald-600 transition-colors rounded-lg"
|
||||
>
|
||||
{menu.name}
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 space-y-1">
|
||||
{children.map((child) => (
|
||||
<NavMenuLink
|
||||
key={child.id}
|
||||
link={child.link}
|
||||
isExternal={child.is_external}
|
||||
className="block py-2 px-4 text-sm text-gray-600 hover:text-emerald-600 hover:bg-gray-50 transition-colors rounded-lg"
|
||||
onClick={onClose}
|
||||
>
|
||||
{child.name}
|
||||
</NavMenuLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavMenuLink
|
||||
link={menu.link}
|
||||
isExternal={menu.is_external}
|
||||
className="block py-3 px-4 text-base font-medium text-gray-700 hover:bg-gray-50 hover:text-emerald-600 transition-colors rounded-lg"
|
||||
onClick={onClose}
|
||||
>
|
||||
{menu.name}
|
||||
</NavMenuLink>
|
||||
);
|
||||
}
|
||||
|
||||
// 访客端布局
|
||||
export function PublicLayout({ children }) {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [navMenus, setNavMenus] = useState([]);
|
||||
const [qrCodes, setQrCodes] = useState([]);
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const qrTimerRef = useRef(null);
|
||||
const [settings, setSettings] = useState({});
|
||||
|
||||
// 监听滚动
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 10);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// 获取动态导航菜单
|
||||
useEffect(() => {
|
||||
navApi
|
||||
.getPublic()
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setNavMenus(data);
|
||||
})
|
||||
.catch((err) => console.error('获取导航菜单失败:', err));
|
||||
contactQrApi
|
||||
.getPublic()
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setQrCodes(data);
|
||||
})
|
||||
.catch((err) => console.error('获取二维码失败:', err));
|
||||
settingsApi
|
||||
.getPublic()
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
setSettings(data);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('获取设置失败:', err));
|
||||
}, []);
|
||||
|
||||
const scrollToSection = (sectionId) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* 顶部信息栏 */}
|
||||
<div className="bg-emerald-600 text-white text-sm">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
<div className="flex items-center justify-between h-10">
|
||||
<div className="flex items-center gap-6">
|
||||
{(() => {
|
||||
const phones = (() => { try { return JSON.parse(settings.contact_phones || '[]'); } catch { return []; } })();
|
||||
const firstPhone = phones[0];
|
||||
return firstPhone ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Phone className="w-3.5 h-3.5" />
|
||||
{firstPhone}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
<span className="hidden sm:inline text-emerald-100">|</span>
|
||||
<span className="hidden sm:inline text-emerald-100">周一至周日 8:00-20:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主导航栏 */}
|
||||
<header
|
||||
className={`sticky top-0 z-50 bg-white transition-all duration-300 ${
|
||||
isScrolled ? 'shadow-lg' : 'shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
{settings.site_logo ? (
|
||||
<img src={settings.site_logo} alt={settings.site_name || '飞点健康'} className="h-10 w-auto object-contain rounded-xl" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center shadow-md">
|
||||
<span className="text-white font-bold text-lg">飞</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-bold text-gray-900">{settings.site_name || '飞点健康'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{navMenus.map((menu) => (
|
||||
<DesktopDropdownItem key={menu.id} menu={menu} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* CTA Button with QR dropdown */}
|
||||
<div className="hidden md:block relative"
|
||||
onMouseEnter={() => {
|
||||
if (qrTimerRef.current) clearTimeout(qrTimerRef.current);
|
||||
if (qrCodes.length > 0) setQrOpen(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
qrTimerRef.current = setTimeout(() => setQrOpen(false), 150);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => qrCodes.length === 0 && scrollToSection('contact')}
|
||||
className="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium rounded-full transition-all duration-200 shadow-md hover:shadow-lg hover:-translate-y-0.5 flex items-center gap-1"
|
||||
>
|
||||
立即咨询
|
||||
{qrCodes.length > 0 && <ChevronDown className={`w-3.5 h-3.5 transition-transform ${qrOpen ? 'rotate-180' : ''}`} />}
|
||||
</button>
|
||||
{qrOpen && qrCodes.length > 0 && (
|
||||
<div className="absolute top-full right-0 mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-100 py-3 z-50 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
{qrCodes.map((qr) => (
|
||||
<div key={qr.id} className="flex flex-col items-center gap-2 px-4 py-3 hover:bg-gray-50 transition-colors">
|
||||
<img src={qr.image_path} alt={qr.description || '二维码'} className="w-28 h-28 object-contain" />
|
||||
<p className="text-sm font-medium text-gray-700 text-center">{qr.description || '扫码咨询'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="w-6 h-6 text-gray-700" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6 text-gray-700" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div
|
||||
className={`md:hidden bg-white border-t border-gray-100 transition-all duration-300 overflow-hidden ${
|
||||
isMobileMenuOpen ? 'max-h-[32rem] opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<nav className="px-4 py-4 space-y-1">
|
||||
{navMenus.map((menu) => (
|
||||
<MobileMenuItem
|
||||
key={menu.id}
|
||||
menu={menu}
|
||||
onClose={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
))}
|
||||
<div className="pt-4 mt-4 border-t border-gray-100 space-y-1">
|
||||
{qrCodes.length > 0 ? (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setQrOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-3 px-4 text-base font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
|
||||
>
|
||||
立即咨询
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${qrOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{qrOpen && (
|
||||
<div className="mt-2 space-y-3 px-2">
|
||||
{qrCodes.map((qr) => (
|
||||
<div key={qr.id} className="flex flex-col items-center gap-2 bg-white rounded-lg p-3 border border-gray-100">
|
||||
<img src={qr.image_path} alt={qr.description || '二维码'} className="w-24 h-24 object-contain" />
|
||||
<p className="text-sm font-medium text-gray-700 text-center">{qr.description || '扫码咨询'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => scrollToSection('contact')}
|
||||
className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
立即咨询
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="min-h-screen">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-gray-300">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12 py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-12">
|
||||
{/* 品牌信息 */}
|
||||
<div className="md:col-span-3">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
{settings.site_logo ? (
|
||||
<img src={settings.site_logo} alt={settings.site_name} className="h-10 w-auto object-contain rounded-xl" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">飞</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xl font-bold text-white">{settings.site_name || '飞点健康'}</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm leading-relaxed mb-6">
|
||||
{settings.site_description || '飞点健康致力于为您提供专业、便捷的健康管理服务,让健康生活触手可及。'}
|
||||
</p>
|
||||
{(settings.social_links && (() => {
|
||||
try { return JSON.parse(settings.social_links).length > 0; } catch { return false; }
|
||||
})()) && (
|
||||
<div className="flex items-center gap-4">
|
||||
{(() => {
|
||||
try { return JSON.parse(settings.social_links); } catch { return []; }
|
||||
})().map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-14 h-14 rounded-full bg-gray-800 flex items-center justify-center hover:bg-emerald-600 transition-colors"
|
||||
title={link.name}
|
||||
>
|
||||
<SocialIcon name={link.icon} className="w-7 h-7" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 友情链接 */}
|
||||
<div className="md:col-span-6">
|
||||
<h4 className="text-white font-semibold mb-6">友情链接</h4>
|
||||
{(settings.friend_links && (() => {
|
||||
try { return JSON.parse(settings.friend_links).length > 0; } catch { return false; }
|
||||
})()) ? (
|
||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-2">
|
||||
{(() => {
|
||||
try { return JSON.parse(settings.friend_links); } catch { return []; }
|
||||
})().map((link, i, arr) => (
|
||||
<span key={i} className="flex items-center">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-emerald-400 transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
{i < arr.length - 1 && (
|
||||
<span className="text-gray-600 mx-2">|</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">暂无友情链接</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 联系我们 */}
|
||||
<div className="md:col-span-3">
|
||||
<h4 className="text-white font-semibold mb-6">联系我们</h4>
|
||||
<ul className="space-y-3">
|
||||
{(() => {
|
||||
try { return JSON.parse(settings.contact_phones || '[]'); } catch { return []; }
|
||||
})().map((phone, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<Phone className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span className="text-gray-400 text-sm">{phone}</span>
|
||||
</li>
|
||||
))}
|
||||
{(() => {
|
||||
try { return JSON.parse(settings.contact_emails || '[]'); } catch { return []; }
|
||||
})().map((email, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<Mail className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span className="text-gray-400 text-sm">{email}</span>
|
||||
</li>
|
||||
))}
|
||||
{settings.contact_address && (
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPin className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span className="text-gray-400 text-sm">{settings.contact_address}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部版权栏 - 全居中 */}
|
||||
<div className="border-t border-gray-800">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12 py-8 text-center">
|
||||
{settings.copyright && (
|
||||
<p className="text-gray-500 text-sm">{settings.copyright}</p>
|
||||
)}
|
||||
{(settings.privacy_url || settings.terms_url || settings.sitemap_url) && (
|
||||
<div className="flex items-center justify-center gap-4 mt-2 text-sm text-gray-500">
|
||||
{settings.privacy_url && (
|
||||
<Link to={settings.privacy_url} className="hover:text-gray-400 transition-colors">隐私政策</Link>
|
||||
)}
|
||||
{settings.terms_url && (
|
||||
<Link to={settings.terms_url} className="hover:text-gray-400 transition-colors">服务条款</Link>
|
||||
)}
|
||||
{settings.sitemap_url && (
|
||||
<Link to={settings.sitemap_url} className="hover:text-gray-400 transition-colors">网站地图</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const bl = (() => { try { return JSON.parse(settings.bottom_links || '[]'); } catch { return []; } })();
|
||||
if (!bl.length) return null;
|
||||
// 过滤掉管理后台相关链接,不对外暴露
|
||||
const filtered = bl.filter(item => !item.url?.includes('/admin/') && !item.label?.includes('管理后台'));
|
||||
if (!filtered.length) return null;
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{filtered.map((item, i) => {
|
||||
const inner = item.type === 'image'
|
||||
? <img src={item.content} alt={item.label || ''} className="h-8 inline-block" />
|
||||
: <span className="text-gray-500 text-sm">{item.content}</span>;
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-center gap-1">
|
||||
{item.url
|
||||
? <a href={item.url} target="_blank" rel="noopener noreferrer" className="hover:opacity-80 transition-opacity">{inner}</a>
|
||||
: inner
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin Layout - 保持原有样式
|
||||
export function AdminLayout({ children }) {
|
||||
const { logout, user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b-2 border-black sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link to="/admin" className="text-xl font-bold">
|
||||
飞点健康 CMS
|
||||
</Link>
|
||||
<nav className="hidden md:flex">
|
||||
<NavLink to="/admin">首页</NavLink>
|
||||
<NavLink to="/admin/modules">模块</NavLink>
|
||||
<NavLink to="/admin/nav">导航</NavLink>
|
||||
<NavLink to="/admin/settings">设置</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{user?.username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile nav */}
|
||||
<nav className="md:hidden bg-white border-b-2 border-black flex overflow-x-auto">
|
||||
<NavLink to="/admin">首页</NavLink>
|
||||
<NavLink to="/admin/modules">模块</NavLink>
|
||||
<NavLink to="/admin/nav">导航</NavLink>
|
||||
<NavLink to="/admin/settings">设置</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicLayout;
|
||||
@@ -0,0 +1,86 @@
|
||||
// 社交媒体图标组件
|
||||
// 支持国内外主流平台,未知平台回退到 Globe 图标
|
||||
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
const ICONS = {
|
||||
x: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
),
|
||||
twitter: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
),
|
||||
facebook: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
),
|
||||
linkedin: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
),
|
||||
github: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
youtube: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
),
|
||||
instagram: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" />
|
||||
</svg>
|
||||
),
|
||||
wechat: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c4.801 0 8.692-3.287 8.692-7.342 0-4.054-3.89-7.34-8.692-7.34z" />
|
||||
</svg>
|
||||
),
|
||||
weibo: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10.098 20.323c-3.977.391-7.414-1.406-7.672-4.02-.259-2.609 2.759-5.047 6.74-5.441 3.979-.394 7.413 1.404 7.671 4.018.259 2.6-2.759 5.049-6.739 5.443zM9.05 17.219c-.384.616-1.208.884-1.829.602-.612-.279-.793-.991-.406-1.593.379-.595 1.176-.861 1.793-.601.622.263.82.972.442 1.592zm1.27-1.627c-.141.237-.449.353-.689.253-.236-.09-.313-.361-.177-.586.138-.227.436-.346.672-.24.239.09.315.36.194.573zm.176-2.719c-1.893-.493-4.033.45-4.857 2.118-.836 1.704-.026 3.591 1.886 4.21 1.983.64 4.318-.341 5.132-2.179.8-1.793-.201-3.642-2.161-4.149zm7.563-1.224c-.346-.105-.578-.172-.4-.626.386-.981.426-1.828-.003-2.431-.801-1.127-2.99-1.067-5.492-.031 0 0-.787.345-.586-.281.39-1.24.332-2.277-.276-2.877-1.381-1.367-5.055.051-8.206 3.171C.759 11.676-.001 14.018 0 16.002c0 3.778 4.858 6.076 9.61 6.076 6.229 0 10.378-3.617 10.378-6.49 0-1.736-1.465-2.718-2.919-3.039zm1.837-5.676c-.747-.865-1.857-1.233-2.946-1.146l.002-.001c-.217.018-.369.207-.351.424.018.217.208.37.425.351.846-.067 1.697.218 2.278.891.581.674.775 1.574.554 2.409-.054.211.075.426.286.48.212.054.427-.075.48-.287.278-1.053.039-2.205-.728-3.121zm1.148-2.654C19.736.675 17.652-.092 15.457.021c-.217.012-.386.195-.374.412.012.218.196.387.413.375 1.858-.098 3.694.56 4.929 1.995 1.235 1.435 1.663 3.34 1.253 5.124-.051.212.08.427.292.478.213.051.428-.08.479-.292.47-2.06-.013-4.262-1.414-5.895z" />
|
||||
</svg>
|
||||
),
|
||||
douyin: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" />
|
||||
</svg>
|
||||
),
|
||||
xiaohongshu: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm4.5 14h-9a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5z" />
|
||||
</svg>
|
||||
),
|
||||
bilibili: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373z" />
|
||||
</svg>
|
||||
),
|
||||
zhihu: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.721 0C2.251 0 0 2.25 0 5.719V18.28C0 21.751 2.252 24 5.721 24h12.56C21.751 24 24 21.75 24 18.281V5.72C24 2.249 21.75 0 18.281 0zm1.964 4.078c-.271.73-.5 1.434-.68 2.11h4.587c.545-.006.445 1.168.445 1.168H6.986c-.157.5-.287 1.007-.386 1.507h3.13c.388 0 .595.291.54.54-.055.25-.285 1.184-.557 2.136-.27.952-.684 2.27-1.25 3.184-.566.916-1.322 1.655-2.268 2.218-.945.564-2.006.846-3.183.846H2.05l.76-1.336h1.082c.788 0 1.49-.2 2.105-.6.616-.4 1.148-.988 1.596-1.763.448-.775.796-1.694 1.044-2.757H4.69l.23-1.336h4.68c.057-.29.107-.574.15-.85H5.015l.23-1.336h5.04c.063-.35.118-.695.165-1.034H6.803l.23-1.336h3.652c.18-.676.409-1.38.68-2.11zM16.8 4.078h1.695L17.496 6.188h2.134l-.318 1.336h-2.134l-.724 3.32c-.16.73-.213 1.198-.16 1.403.053.204.244.307.573.307h1.338l-.287 1.336h-1.864c-.693 0-1.146-.172-1.36-.517-.213-.344-.214-.957-.002-1.838l.87-4.011h-1.373l.318-1.336h1.373z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export const SocialIcon = ({ name, className = 'w-4 h-4' }) => {
|
||||
const icon = ICONS[name?.toLowerCase()];
|
||||
if (!icon) {
|
||||
return <Globe className={className} />;
|
||||
}
|
||||
return (
|
||||
<span className={`inline-block ${className}`}>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialIcon;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { X, Maximize2, Play } from 'lucide-react';
|
||||
|
||||
const VIDEO_EXTENSIONS = /\.(mp4|webm|ogg|mov|avi|mkv|flv|wmv|m4v)(\?.*)?$/i;
|
||||
|
||||
export function isVideoUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const path = new URL(url, window.location.origin).pathname;
|
||||
return VIDEO_EXTENSIONS.test(path);
|
||||
} catch {
|
||||
return VIDEO_EXTENSIONS.test(url);
|
||||
}
|
||||
}
|
||||
|
||||
export default function VideoModal({ src, title, onClose }) {
|
||||
const videoRef = useRef(null);
|
||||
const overlayRef = useRef(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
onCloseRef.current();
|
||||
}, []);
|
||||
|
||||
// 点击遮罩关闭
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
// ESC 关闭 + 锁定滚动(仅当有视频源时)
|
||||
useEffect(() => {
|
||||
if (!src) return;
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 z-[9999] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 animate-in fade-in duration-200"
|
||||
>
|
||||
<div className="relative bg-black rounded-2xl overflow-hidden shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
{/* 顶部栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/80 to-transparent absolute top-0 left-0 right-0 z-10">
|
||||
<h3 className="text-white/90 text-sm font-medium truncate pr-4">
|
||||
{title || '视频播放'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
className="w-full max-h-[85vh] bg-black"
|
||||
style={{ minHeight: 300 }}
|
||||
playsInline
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Badge 标签组件 - Modern Dashboard 风格
|
||||
|
||||
const variantStyles = {
|
||||
default: 'bg-slate-100 text-slate-700',
|
||||
primary: 'bg-emerald-100 text-emerald-700',
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
warning: 'bg-amber-100 text-amber-700',
|
||||
danger: 'bg-rose-100 text-rose-700',
|
||||
info: 'bg-blue-100 text-blue-700',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-0.5 text-sm',
|
||||
};
|
||||
|
||||
export const Badge = ({ children, variant = 'default', size = 'sm', className = '' }) => (
|
||||
<span className={`
|
||||
inline-flex items-center font-medium rounded-full
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
// Button 组件 - Modern Dashboard 风格
|
||||
// 翠绿主色,圆角设计
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-emerald-600 hover:bg-emerald-700 text-white border-transparent',
|
||||
secondary: 'bg-white hover:bg-slate-50 text-slate-700 border-slate-300',
|
||||
danger: 'bg-rose-600 hover:bg-rose-700 text-white border-transparent',
|
||||
ghost: 'bg-transparent hover:bg-slate-100 text-slate-600 border-transparent',
|
||||
outline: 'bg-white hover:bg-slate-50 text-slate-700 border-slate-300 hover:border-slate-400',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
export const Button = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
...props
|
||||
}) => (
|
||||
<button
|
||||
disabled={disabled || loading}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2
|
||||
font-medium rounded-lg
|
||||
border
|
||||
transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
// Card 组件 - Modern Dashboard 风格
|
||||
// 白色背景 + 细边框 + 柔和阴影
|
||||
|
||||
export const Card = ({ children, className = '' }) => (
|
||||
<div className={`bg-white rounded-xl border border-slate-200 shadow-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CardHeader = ({ title, subtitle, action, icon: Icon, children }) => (
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && <Icon className="w-5 h-5 text-slate-400" />}
|
||||
<div>
|
||||
{title && <h3 className="text-lg font-semibold text-slate-900">{title}</h3>}
|
||||
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CardContent = ({ children, className = '' }) => (
|
||||
<div className={`p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CardFooter = ({ children, className = '' }) => (
|
||||
<div className={`px-6 py-4 border-t border-slate-100 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
// Input 组件 - Modern Dashboard 风格
|
||||
// 边框式输入框,聚焦时绿色环
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export const Input = forwardRef(({ className = '', error, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-3.5 py-2.5
|
||||
bg-white
|
||||
border rounded-lg
|
||||
text-slate-900 placeholder-slate-400
|
||||
transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500
|
||||
disabled:bg-slate-50 disabled:text-slate-500
|
||||
${error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-slate-300'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export const Textarea = forwardRef(({ className = '', error, rows = 4, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
className={`
|
||||
w-full px-3.5 py-2.5
|
||||
bg-white
|
||||
border rounded-lg
|
||||
text-slate-900 placeholder-slate-400
|
||||
transition-all duration-200 resize-y
|
||||
focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500
|
||||
disabled:bg-slate-50 disabled:text-slate-500
|
||||
${error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-slate-300'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export const Select = forwardRef(({ children, className = '', error, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-3.5 py-2.5
|
||||
bg-white
|
||||
border rounded-lg
|
||||
text-slate-900
|
||||
transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500
|
||||
disabled:bg-slate-50 disabled:text-slate-500
|
||||
${error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-slate-300'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
));
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export const Label = ({ children, htmlFor, required, className = '' }) => (
|
||||
<label htmlFor={htmlFor} className={`block text-sm font-medium text-slate-700 mb-1.5 ${className}`}>
|
||||
{children}
|
||||
{required && <span className="text-rose-500 ml-1">*</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
export const FormGroup = ({ children, label, htmlFor, required, error, helpText }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <Label htmlFor={htmlFor} required={required}>{label}</Label>}
|
||||
{children}
|
||||
{helpText && !error && <p className="text-xs text-slate-500">{helpText}</p>}
|
||||
{error && <p className="text-xs text-rose-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Modal 组件 - Modern Dashboard 风格
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export const Modal = ({ isOpen, onClose, title, children, size = 'md' }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
'2xl': 'max-w-6xl',
|
||||
screen: 'max-w-[95vw]',
|
||||
full: 'max-w-full mx-4'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* 遮罩 */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗容器 */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className={`relative w-full ${sizeClasses[size]} bg-white rounded-xl shadow-xl`}>
|
||||
{/* 头部 */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,50 @@
|
||||
// Stats 统计卡片组件 - Modern Dashboard 风格
|
||||
|
||||
import { ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
export const StatCard = ({ title, value, change, changeType = 'neutral', icon: Icon, color = 'emerald' }) => {
|
||||
const colorStyles = {
|
||||
emerald: 'bg-emerald-50 text-emerald-600',
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
rose: 'bg-rose-50 text-rose-600',
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">{title}</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-2">{value}</p>
|
||||
{change && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
{changeType === 'increase' ? (
|
||||
<ArrowUp className="w-4 h-4 text-emerald-500" />
|
||||
) : changeType === 'decrease' ? (
|
||||
<ArrowDown className="w-4 h-4 text-rose-500" />
|
||||
) : null}
|
||||
<span className={`text-sm font-medium ${
|
||||
changeType === 'increase' ? 'text-emerald-600' :
|
||||
changeType === 'decrease' ? 'text-rose-600' : 'text-slate-600'
|
||||
}`}>
|
||||
{change}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={`p-3 rounded-lg ${colorStyles[color]}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatsGrid = ({ children }) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Table 组件 - Modern Dashboard 风格
|
||||
|
||||
export const Table = ({ children, className = '' }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={`w-full text-left ${className}`}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TableHead = ({ children }) => (
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
|
||||
export const TableBody = ({ children }) => (
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
|
||||
export const TableRow = ({ children, className = '' }) => (
|
||||
<tr className={`hover:bg-slate-50/50 transition-colors ${className}`}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
|
||||
export const TableHeader = ({ children, className = '' }) => (
|
||||
<th className={`px-6 py-3.5 text-xs font-semibold text-slate-500 uppercase tracking-wider ${className}`}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
|
||||
export const TableCell = ({ children, className = '' }) => (
|
||||
<td className={`px-6 py-4 text-sm text-slate-700 ${className}`}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
|
||||
export const TableEmpty = ({ colSpan, message = '暂无数据' }) => (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-6 py-12 text-center">
|
||||
<div className="text-slate-400">
|
||||
<svg className="mx-auto h-12 w-12 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-sm">{message}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
// Admin Components - Modern Dashboard Style
|
||||
export { Card, CardHeader, CardContent } from './Card';
|
||||
export { Button } from './Button';
|
||||
export { Input, Textarea, Select } from './Input';
|
||||
export { Table, TableHead, TableBody, TableRow, TableHeader, TableCell, TableEmpty } from './Table';
|
||||
export { Badge } from './Badge';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { authApi } from '../api';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
authApi.me()
|
||||
.then(user => setUser(user))
|
||||
.catch(() => localStorage.removeItem('token'))
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (username, password) => {
|
||||
const res = await authApi.login(username, password);
|
||||
// API returns {code, message, data: {token, admin}}
|
||||
const { token, admin } = res.data || {};
|
||||
localStorage.setItem('token', token);
|
||||
setUser(admin);
|
||||
return res;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
loading,
|
||||
isAuthenticated: !!user,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
/* ========================================
|
||||
飞点健康 CMS - 设计系统
|
||||
基于 Modern Healthcare 风格
|
||||
======================================== */
|
||||
|
||||
/* 1. Tailwind 基础导入 */
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* 2. CSS 变量定义 - Admin Dashboard 主题 */
|
||||
:root {
|
||||
/* 侧边栏 */
|
||||
--admin-sidebar-bg: #0F172A;
|
||||
--admin-sidebar-hover: #1E293B;
|
||||
--admin-sidebar-active: #334155;
|
||||
--admin-sidebar-text: #94A3B8;
|
||||
--admin-sidebar-text-active: #FFFFFF;
|
||||
|
||||
/* 主内容区 */
|
||||
--admin-bg: #F8FAFC;
|
||||
--admin-card: #FFFFFF;
|
||||
--admin-border: #E2E8F0;
|
||||
|
||||
/* 主色调 - 翠绿 */
|
||||
--admin-primary: #10B981;
|
||||
--admin-primary-hover: #059669;
|
||||
--admin-primary-light: #D1FAE5;
|
||||
--admin-primary-subtle: #ECFDF5;
|
||||
|
||||
/* 语义色 */
|
||||
--admin-success: #10B981;
|
||||
--admin-warning: #F59E0B;
|
||||
--admin-danger: #EF4444;
|
||||
--admin-info: #3B82F6;
|
||||
|
||||
/* 文字色 */
|
||||
--admin-text-primary: #1E293B;
|
||||
--admin-text-secondary: #64748B;
|
||||
--admin-text-muted: #94A3B8;
|
||||
|
||||
/* ===== 访客端 Modern Healthcare 主题 ===== */
|
||||
/* 主色调 - 健康绿 */
|
||||
--health-primary: #10B981;
|
||||
--health-primary-hover: #059669;
|
||||
--health-primary-light: #D1FAE5;
|
||||
--health-primary-subtle: #ECFDF5;
|
||||
|
||||
/* 辅助色 - 深蓝 */
|
||||
--health-secondary: #1E40AF;
|
||||
--health-secondary-hover: #1E3A8A;
|
||||
|
||||
/* 背景色 */
|
||||
--health-bg: #FFFFFF;
|
||||
--health-bg-alt: #F9FAFB;
|
||||
--health-bg-muted: #F3F4F6;
|
||||
|
||||
/* 卡片 */
|
||||
--health-card: #FFFFFF;
|
||||
--health-card-border: #E5E7EB;
|
||||
|
||||
/* 文字色 */
|
||||
--health-text: #111827;
|
||||
--health-text-secondary: #4B5563;
|
||||
--health-text-muted: #6B7280;
|
||||
--health-text-light: #9CA3AF;
|
||||
|
||||
/* 阴影 */
|
||||
--health-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--health-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
--health-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
--health-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 3. Admin Dashboard 基础样式 */
|
||||
body {
|
||||
background-color: var(--admin-bg);
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
/* 4. Admin Dashboard 工具类 */
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: var(--admin-bg);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
background-color: var(--admin-sidebar-bg);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
background-color: var(--admin-bg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background-color: var(--admin-card);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
background-color: var(--admin-primary);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-btn-primary:hover {
|
||||
background-color: var(--admin-primary-hover);
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 0.5rem;
|
||||
background-color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-primary);
|
||||
box-shadow: 0 0 0 3px var(--admin-primary-subtle);
|
||||
}
|
||||
|
||||
/* 5. Tailwind 颜色工具类扩展 */
|
||||
@layer utilities {
|
||||
.text-admin-primary {
|
||||
color: var(--admin-primary);
|
||||
}
|
||||
|
||||
.text-admin-text-primary {
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
.text-admin-text-secondary {
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.text-admin-text-muted {
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.bg-admin-primary {
|
||||
background-color: var(--admin-primary);
|
||||
}
|
||||
|
||||
.bg-admin-primary\/10 {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.bg-admin-sidebar {
|
||||
background-color: var(--admin-sidebar-bg);
|
||||
}
|
||||
|
||||
.bg-admin-bg {
|
||||
background-color: var(--admin-bg);
|
||||
}
|
||||
|
||||
.bg-admin-card {
|
||||
background-color: var(--admin-card);
|
||||
}
|
||||
|
||||
.border-admin-border {
|
||||
border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
.ring-admin-primary {
|
||||
--tw-ring-color: var(--admin-primary);
|
||||
}
|
||||
|
||||
/* 翻转卡片 - 3D Flip */
|
||||
.flip-card {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.flip-card-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.6s;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.flip-card:hover .flip-card-inner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flip-card-front,
|
||||
.flip-card-back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.flip-card-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 6. 表格样式 */
|
||||
.admin-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--admin-text-secondary);
|
||||
background-color: var(--admin-bg);
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
/* 7. 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #CBD5E1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94A3B8;
|
||||
}
|
||||
|
||||
/* 8. 动画 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(10px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideInRight 0.2s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { updateSEO } from '../utils/seo';
|
||||
import { Card, CardHeader, CardContent } from '../components/admin/Card';
|
||||
import { Badge } from '../components/admin/Badge';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Sparkles,
|
||||
Image,
|
||||
Settings,
|
||||
ArrowRight,
|
||||
TrendingUp,
|
||||
Eye,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function AdminHome() {
|
||||
const [stats, setStats] = useState({
|
||||
navMenus: 0,
|
||||
homeModules: 0,
|
||||
banners: 0,
|
||||
features: 0
|
||||
});
|
||||
const [recentActivity, setRecentActivity] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
updateSEO({ title: '管理后台', noIndex: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 获取导航菜单统计
|
||||
const navRes = await api.get('/api/nav');
|
||||
// 获取首页模块统计
|
||||
const homeRes = await api.get('/api/home/modules');
|
||||
// 轮播图和特色模块从首页模块数据中统计
|
||||
const banners = homeRes.data?.find(m => m.type === 'banner')?.content?.slides || [];
|
||||
const features = homeRes.data?.find(m => m.type === 'features')?.content?.items || [];
|
||||
|
||||
setStats({
|
||||
navMenus: navRes.data?.length || 0,
|
||||
homeModules: homeRes.data?.length || 0,
|
||||
banners: banners.length,
|
||||
features: features.length
|
||||
});
|
||||
|
||||
// 构建最近活动列表
|
||||
const activities = [];
|
||||
if (navRes.data?.length > 0) {
|
||||
activities.push({
|
||||
type: 'nav',
|
||||
title: '导航菜单更新',
|
||||
count: navRes.data.length,
|
||||
time: '最近'
|
||||
});
|
||||
}
|
||||
if (homeRes.data?.length > 0) {
|
||||
activities.push({
|
||||
type: 'module',
|
||||
title: '首页模块配置',
|
||||
count: homeRes.data.length,
|
||||
time: '最近'
|
||||
});
|
||||
}
|
||||
setRecentActivity(activities);
|
||||
} catch (error) {
|
||||
console.error('获取仪表盘数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: '导航菜单',
|
||||
value: stats.navMenus,
|
||||
icon: LayoutDashboard,
|
||||
color: 'blue',
|
||||
link: '/admin/nav-menus'
|
||||
},
|
||||
{
|
||||
title: '首页模块',
|
||||
value: stats.homeModules,
|
||||
icon: Sparkles,
|
||||
color: 'emerald',
|
||||
link: '/admin/home'
|
||||
},
|
||||
{
|
||||
title: '轮播图',
|
||||
value: stats.banners,
|
||||
icon: Image,
|
||||
color: 'amber',
|
||||
link: '/admin/banners'
|
||||
},
|
||||
{
|
||||
title: '特色模块',
|
||||
value: stats.features,
|
||||
icon: Activity,
|
||||
color: 'purple',
|
||||
link: '/admin/features'
|
||||
}
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
title: '管理导航菜单',
|
||||
description: '配置网站顶部导航',
|
||||
icon: LayoutDashboard,
|
||||
link: '/admin/nav-menus',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
title: '配置首页模块',
|
||||
description: '设置首页展示内容',
|
||||
icon: Sparkles,
|
||||
link: '/admin/home',
|
||||
color: 'emerald'
|
||||
},
|
||||
{
|
||||
title: '管理轮播图',
|
||||
description: '上传和编辑首页轮播',
|
||||
icon: Image,
|
||||
link: '/admin/banners',
|
||||
color: 'amber'
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
description: '修改网站基本配置',
|
||||
icon: Settings,
|
||||
link: '/admin/settings',
|
||||
color: 'slate'
|
||||
}
|
||||
];
|
||||
|
||||
const colorStyles = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
emerald: 'bg-emerald-50 text-emerald-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
slate: 'bg-slate-50 text-slate-600',
|
||||
rose: 'bg-rose-50 text-rose-600'
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">仪表盘</h1>
|
||||
<p className="text-slate-500 mt-1">欢迎回来,查看网站整体状况</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statCards.map((stat, index) => (
|
||||
<Card key={index} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">{stat.title}</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mt-2">{stat.value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${colorStyles[stat.color]}`}>
|
||||
<stat.icon className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={stat.link}
|
||||
className="inline-flex items-center gap-1 mt-4 text-sm font-medium text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
查看详情
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & Recent Activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader title="快速操作" subtitle="常用管理功能入口" />
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{quickActions.map((action, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
to={action.link}
|
||||
className="flex items-start gap-4 p-4 rounded-xl border border-slate-200 hover:border-emerald-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className={`p-3 rounded-xl ${colorStyles[action.color]} group-hover:scale-110 transition-transform`}>
|
||||
<action.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900">{action.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{action.description}</p>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-slate-400 group-hover:text-emerald-600 group-hover:translate-x-1 transition-all" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader title="最近活动" />
|
||||
<CardContent>
|
||||
{recentActivity.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 mt-2"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{activity.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{activity.count} 个项目 · {activity.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<TrendingUp className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">暂无最近活动</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Status */}
|
||||
<Card>
|
||||
<CardHeader title="系统状态" />
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500"></div>
|
||||
<span className="text-sm text-slate-700">API 服务正常</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500"></div>
|
||||
<span className="text-sm text-slate-700">数据库连接正常</span>
|
||||
</div>
|
||||
<Badge variant="success">运行中</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Calendar, Eye, ChevronLeft, Share2, User, Clock,
|
||||
FolderTree, Loader2
|
||||
} from 'lucide-react';
|
||||
import api from '../api';
|
||||
import VideoModal, { isVideoUrl } from '../components/VideoModal';
|
||||
import { updateSEO, extractSummary } from '../utils/seo';
|
||||
import { renderContent } from '../utils/contentRenderer';
|
||||
|
||||
const ArticleDetail = () => {
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [article, setArticle] = useState(null);
|
||||
const [relatedArticles, setRelatedArticles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [videoSrc, setVideoSrc] = useState('');
|
||||
const [videoTitle, setVideoTitle] = useState('');
|
||||
const contentRef = useRef(null);
|
||||
|
||||
// 视频链接拦截
|
||||
const handleContentClick = useCallback((e) => {
|
||||
const link = e.target.closest('a');
|
||||
if (!link) return;
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) return;
|
||||
if (isVideoUrl(href)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setVideoSrc(href);
|
||||
setVideoTitle(link.textContent || '视频播放');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 同时绑定原生事件作为双重保险(React onClick + 原生监听器)
|
||||
useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('click', handleContentClick);
|
||||
return () => el.removeEventListener('click', handleContentClick);
|
||||
}, [handleContentClick, article]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
loadArticle();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (article) {
|
||||
updateSEO({
|
||||
title: article.title,
|
||||
description: article.summary || extractSummary(article.content, 150),
|
||||
keywords: `${article.title},${article.column?.name || ''},尿检,体检,飞点健康,吾大夫`,
|
||||
ogTitle: article.title,
|
||||
ogDescription: article.summary || extractSummary(article.content, 150),
|
||||
ogImage: article.cover_image || '',
|
||||
canonical: `http://team.niaodaifu.cn/article/${article.slug}`
|
||||
});
|
||||
}
|
||||
}, [article]);
|
||||
|
||||
|
||||
const loadArticle = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.get(`/api/public/articles/${slug}`);
|
||||
setArticle(res);
|
||||
// 加载同栏目相关文章
|
||||
if (res.column?.slug) {
|
||||
loadRelatedArticles(res.column.slug, res.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('内容加载失败,请稍后重试');
|
||||
console.error('加载内容失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRelatedArticles = async (columnSlug, excludeId) => {
|
||||
try {
|
||||
const res = await api.get('/api/public/articles', {
|
||||
params: { column_slug: columnSlug, page: 1, size: 5 }
|
||||
});
|
||||
const filtered = (res.items || []).filter(a => a.id !== excludeId).slice(0, 4);
|
||||
setRelatedArticles(filtered);
|
||||
} catch (err) {
|
||||
console.error('加载相关文章失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric', month: 'long', day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: article?.title,
|
||||
text: article?.summary,
|
||||
url: window.location.href
|
||||
});
|
||||
} catch { /* 用户取消 */ }
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert('链接已复制到剪贴板');
|
||||
}
|
||||
};
|
||||
|
||||
// 内容渲染(使用共享工具函数)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 text-emerald-600 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !article) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">内容未找到</h2>
|
||||
<p className="text-gray-600 mb-6">{error || '该内容可能已被删除或不存在'}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 返回导航 */}
|
||||
<div className="bg-white border-b border-gray-100">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文章内容 */}
|
||||
<article className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 文章头部 */}
|
||||
<header className="mb-8">
|
||||
{/* 栏目标签 */}
|
||||
{article.column && (
|
||||
<Link
|
||||
to={`/column/${article.column.slug}`}
|
||||
className="inline-flex items-center gap-1 px-4 py-1 bg-emerald-50 text-emerald-600 text-sm font-medium rounded-full mb-4 hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
<FolderTree className="w-3.5 h-3.5" />
|
||||
{article.column.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 标题 */}
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500 border-b border-gray-100 pb-6">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(article.updated_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
{article.view_count || 0} 阅读
|
||||
</span>
|
||||
{article.author && (
|
||||
<span className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
{article.author}
|
||||
</span>
|
||||
)}
|
||||
{article.content_type === 'markdown' && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
约 {Math.ceil((article.content?.length || 0) / 500)} 分钟阅读
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="ml-auto flex items-center gap-2 text-emerald-600 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
分享
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 封面图 */}
|
||||
{article.cover_image && (
|
||||
<div className="mb-8 rounded-xl overflow-hidden shadow-lg">
|
||||
<img
|
||||
src={(() => {
|
||||
const path = article.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()}
|
||||
alt={article.title}
|
||||
className="w-full h-auto"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文章正文 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
onClick={handleContentClick}
|
||||
className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-emerald-600 hover:prose-a:text-emerald-700 prose-img:rounded-lg prose-img:shadow-md"
|
||||
dangerouslySetInnerHTML={{ __html: renderContent(article.content, article.content_type) }}
|
||||
/>
|
||||
|
||||
{/* 文章底部 */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
分享文章
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* 相关文章 */}
|
||||
{relatedArticles.length > 0 && (
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12 border-t border-gray-200">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">相关文章</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{relatedArticles.map((related) => (
|
||||
<Link
|
||||
key={related.id}
|
||||
to={`/article/${related.slug}`}
|
||||
className="flex gap-4 p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
{related.cover_image && (
|
||||
<div className="w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={(() => {
|
||||
const path = related.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()}
|
||||
alt={related.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 line-clamp-2 mb-2 hover:text-emerald-600 transition-colors">
|
||||
{related.title}
|
||||
</h4>
|
||||
<span className="text-sm text-gray-400">
|
||||
{formatDate(related.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 视频弹窗 */}
|
||||
<VideoModal
|
||||
src={videoSrc}
|
||||
title={videoTitle}
|
||||
onClose={() => { setVideoSrc(''); setVideoTitle(''); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleDetail;
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FolderTree, Calendar, Eye, ChevronRight, Loader2,
|
||||
LayoutList, LayoutGrid, ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import api from '../api';
|
||||
import { updateSEO } from '../utils/seo';
|
||||
|
||||
const ColumnDetail = () => {
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [column, setColumn] = useState(null);
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 12;
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
loadColumn();
|
||||
loadArticles();
|
||||
}
|
||||
}, [slug, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (column) {
|
||||
updateSEO({
|
||||
title: `${column.name} - 飞点健康`,
|
||||
description: column.description || `${column.name}栏目,飞点健康尿大夫提供专业的${column.name}相关知识与资讯。`,
|
||||
keywords: `${column.name},尿检,体检,飞点健康,吾大夫`,
|
||||
canonical: `http://team.niaodaifu.cn/column/${column.slug}`
|
||||
});
|
||||
}
|
||||
}, [column]);
|
||||
|
||||
const loadColumn = async () => {
|
||||
try {
|
||||
const res = await api.get(`/api/public/columns/${slug}`);
|
||||
setColumn(res);
|
||||
} catch (err) {
|
||||
setError('栏目加载失败');
|
||||
console.error('加载栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadArticles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/api/public/articles', {
|
||||
params: { page, size: pageSize, column_slug: slug }
|
||||
});
|
||||
setArticles(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (err) {
|
||||
console.error('加载内容失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const isListMode = column?.display_mode === 'list';
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">栏目未找到</h2>
|
||||
<p className="text-gray-600 mb-6">该栏目可能已被删除或不存在</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 页面标题 */}
|
||||
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="inline-flex items-center gap-2 text-emerald-100 hover:text-white transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回首页
|
||||
</button>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<FolderTree className="w-8 h-8" />
|
||||
<h1 className="text-3xl md:text-4xl font-bold">{column?.name || '加载中...'}</h1>
|
||||
</div>
|
||||
{column?.description && (
|
||||
<p className="text-emerald-100 text-lg max-w-2xl">{column.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 子栏目 */}
|
||||
{column?.children && column.children.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<span className="w-1 h-5 bg-emerald-500 rounded-full"></span>
|
||||
子栏目
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{column.children.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/column/${child.slug}`}
|
||||
className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900">{child.name}</h3>
|
||||
{child.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{child.description}</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容列表 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<span className="w-1 h-5 bg-emerald-500 rounded-full"></span>
|
||||
内容列表
|
||||
<span className="text-sm font-normal text-gray-500">({total})</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
{isListMode ? <LayoutList className="w-4 h-4" /> : <LayoutGrid className="w-4 h-4" />}
|
||||
{isListMode ? '列表式' : '卡片式'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && articles.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-emerald-600 animate-spin" />
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white rounded-xl shadow-sm">
|
||||
<FolderTree className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">该栏目暂无内容</p>
|
||||
</div>
|
||||
) : isListMode ? (
|
||||
/* 列表式 */
|
||||
<div className="space-y-4">
|
||||
{articles.map((article) => (
|
||||
<article
|
||||
key={article.id}
|
||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
<Link to={`/article/${article.slug}`} className="flex flex-col md:flex-row">
|
||||
{article.cover_image && (
|
||||
<div className="w-full md:w-48 h-48 md:h-36 flex-shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={(() => {
|
||||
const path = article.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 p-5">
|
||||
<h3 className="text-lg font-bold text-gray-900 hover:text-emerald-600 transition-colors mb-2">
|
||||
{article.title}
|
||||
</h3>
|
||||
{article.summary && (
|
||||
<p className="text-gray-600 line-clamp-2 mb-3">{article.summary}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(article.updated_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
{article.view_count || 0} 阅读
|
||||
</span>
|
||||
{article.author && <span>作者:{article.author}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 卡片式 */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{articles.map((article) => (
|
||||
<article
|
||||
key={article.id}
|
||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
<Link to={`/article/${article.slug}`}>
|
||||
{article.cover_image && (
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<img
|
||||
src={(() => {
|
||||
const path = article.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5">
|
||||
<h3 className="font-bold text-gray-900 hover:text-emerald-600 transition-colors mb-2 line-clamp-2">
|
||||
{article.title}
|
||||
</h3>
|
||||
{article.summary && (
|
||||
<p className="text-gray-600 text-sm line-clamp-2 mb-3">{article.summary}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(article.updated_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
{article.view_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-gray-600 text-sm">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnDetail;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { updateSEO } from '../utils/seo';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
updateSEO({ title: '登录', noIndex: true });
|
||||
}, []);
|
||||
|
||||
// 如果已登录,自动跳转到管理后台
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/admin', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate('/admin');
|
||||
} catch (err) {
|
||||
setError(err.message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-slate-200 p-8">
|
||||
<h1 className="text-2xl font-bold text-center text-slate-900 mb-2">飞点健康 CMS</h1>
|
||||
<p className="text-slate-500 text-center mb-8">后台管理系统</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-rose-50 border border-rose-200 text-rose-600 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-4 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-300 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { modulesApi } from '../api';
|
||||
import { Card, CardHeader, CardContent } from '../components/admin/Card';
|
||||
import { Button } from '../components/admin/Button';
|
||||
import { Input, Textarea } from '../components/admin/Input';
|
||||
import { Badge } from '../components/admin/Badge';
|
||||
import { Loader2, ChevronUp, ChevronDown, Edit2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
function ModuleEditor({ module, onSave, onCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
title: module.title || '',
|
||||
subtitle: module.subtitle || '',
|
||||
is_enabled: module.is_enabled ?? true,
|
||||
content: JSON.stringify(module.content || {}, null, 2),
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const content = JSON.parse(form.content);
|
||||
await onSave({
|
||||
title: form.title,
|
||||
subtitle: form.subtitle,
|
||||
is_enabled: form.is_enabled,
|
||||
content,
|
||||
});
|
||||
} catch (err) {
|
||||
alert('内容 JSON 格式错误: ' + err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader title={`编辑: ${module.name}`} />
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">标题</label>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="输入标题"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">副标题</label>
|
||||
<Input
|
||||
value={form.subtitle}
|
||||
onChange={(e) => setForm({ ...form, subtitle: e.target.value })}
|
||||
placeholder="输入副标题"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_enabled"
|
||||
checked={form.is_enabled}
|
||||
onChange={(e) => setForm({ ...form, is_enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300 focus:ring-emerald-500"
|
||||
/>
|
||||
<label htmlFor="is_enabled" className="text-sm text-slate-700">启用此模块</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">内容 (JSON)</label>
|
||||
<Textarea
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">模块内容数据,使用 JSON 格式</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={saving}>保存</Button>
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>取消</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Modules() {
|
||||
const [modules, setModules] = useState([]);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadModules();
|
||||
}, []);
|
||||
|
||||
const loadModules = async () => {
|
||||
try {
|
||||
const res = await modulesApi.getAll();
|
||||
setModules(res || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load modules:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (data) => {
|
||||
await modulesApi.update(editingId, data);
|
||||
await loadModules();
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleToggle = async (id) => {
|
||||
await modulesApi.toggle(id);
|
||||
await loadModules();
|
||||
};
|
||||
|
||||
const moveModule = async (id, direction) => {
|
||||
const idx = modules.findIndex(m => m.id === id);
|
||||
if (direction === 'up' && idx > 0) {
|
||||
const newModules = [...modules];
|
||||
[newModules[idx], newModules[idx - 1]] = [newModules[idx - 1], newModules[idx]];
|
||||
await updateOrder(newModules);
|
||||
} else if (direction === 'down' && idx < modules.length - 1) {
|
||||
const newModules = [...modules];
|
||||
[newModules[idx], newModules[idx + 1]] = [newModules[idx + 1], newModules[idx]];
|
||||
await updateOrder(newModules);
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrder = async (newModules) => {
|
||||
const orders = newModules.map((m, idx) => ({
|
||||
id: m.id,
|
||||
sort_order: idx + 1,
|
||||
}));
|
||||
await modulesApi.sort(orders);
|
||||
await loadModules();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-emerald-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const editingModule = modules.find(m => m.id === editingId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900">首页模块管理</h1>
|
||||
<p className="text-slate-500 mt-1">管理首页各个模块的内容和显示顺序</p>
|
||||
</div>
|
||||
|
||||
{editingId ? (
|
||||
<ModuleEditor
|
||||
module={editingModule}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader title="模块列表" />
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-slate-200">
|
||||
{modules.map((module, idx) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className={`flex items-center justify-between p-4 ${!module.is_enabled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => moveModule(module.id, 'up')}
|
||||
disabled={idx === 0}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveModule(module.id, 'down')}
|
||||
disabled={idx === modules.length - 1}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-slate-900">{module.name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
类型: {module.type} | 标识: {module.code}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={module.is_enabled ? 'success' : 'default'}>
|
||||
{module.is_enabled ? '已启用' : '已禁用'}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => handleToggle(module.id)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
title={module.is_enabled ? '禁用' : '启用'}
|
||||
>
|
||||
{module.is_enabled ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(module.id)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Edit2, Trash2, ChevronDown, ChevronRight, Loader2, Save, X, QrCode } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '../components/admin/Card';
|
||||
import { Button } from '../components/admin/Button';
|
||||
import { Input } from '../components/admin/Input';
|
||||
import { Badge } from '../components/admin/Badge';
|
||||
import { navApi, contactQrApi } from '../api';
|
||||
|
||||
const NavMenus = () => {
|
||||
const [menus, setMenus] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
link: '',
|
||||
sort_order: 0,
|
||||
is_enabled: true,
|
||||
is_external: false,
|
||||
parent_id: null,
|
||||
});
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [expandedIds, setExpandedIds] = useState(new Set());
|
||||
|
||||
// Contact QR Code states
|
||||
const [qrCodes, setQrCodes] = useState([]);
|
||||
const [qrSaving, setQrSaving] = useState(false);
|
||||
const [qrEditing, setQrEditing] = useState(null);
|
||||
const [qrForm, setQrForm] = useState({
|
||||
content: '',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
is_enabled: true,
|
||||
});
|
||||
const [showQrForm, setShowQrForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMenus();
|
||||
fetchQrCodes();
|
||||
}, []);
|
||||
|
||||
const fetchMenus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await navApi.getAll();
|
||||
setMenus(data || []);
|
||||
} catch (error) {
|
||||
console.error('获取导航菜单失败:', error);
|
||||
setMessage({ type: 'error', text: '获取导航菜单失败' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchQrCodes = async () => {
|
||||
try {
|
||||
const data = await contactQrApi.getAll();
|
||||
setQrCodes(data || []);
|
||||
} catch (error) {
|
||||
console.error('获取二维码失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const topMenus = menus.filter((m) => !m.parent_id);
|
||||
const childMenusMap = {};
|
||||
menus.forEach((m) => {
|
||||
if (m.parent_id) {
|
||||
if (!childMenusMap[m.parent_id]) childMenusMap[m.parent_id] = [];
|
||||
childMenusMap[m.parent_id].push(m);
|
||||
}
|
||||
});
|
||||
Object.keys(childMenusMap).forEach((pid) => {
|
||||
childMenusMap[pid].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||
});
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddChild = (parentMenu) => {
|
||||
setEditing(null);
|
||||
setForm({
|
||||
name: '',
|
||||
link: '',
|
||||
sort_order: 0,
|
||||
is_enabled: true,
|
||||
is_external: false,
|
||||
parent_id: parentMenu.id,
|
||||
});
|
||||
setShowForm(true);
|
||||
setExpandedIds((prev) => new Set(prev).add(parentMenu.id));
|
||||
};
|
||||
|
||||
const handleAddTop = () => {
|
||||
setEditing(null);
|
||||
setForm({
|
||||
name: '',
|
||||
link: '',
|
||||
sort_order: 0,
|
||||
is_enabled: true,
|
||||
is_external: false,
|
||||
parent_id: null,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (menu) => {
|
||||
setEditing(menu);
|
||||
setForm({
|
||||
name: menu.name || '',
|
||||
link: menu.link || '',
|
||||
sort_order: menu.sort_order ?? 0,
|
||||
is_enabled: menu.is_enabled ?? true,
|
||||
is_external: menu.is_external ?? false,
|
||||
parent_id: menu.parent_id || null,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
setMessage({ type: 'error', text: '菜单名称不能为空' });
|
||||
return;
|
||||
}
|
||||
const isTop = !form.parent_id;
|
||||
const hasChildren = isTop && childMenusMap[editing?.id]?.length > 0;
|
||||
if (!form.parent_id && !hasChildren && !form.link.trim()) {
|
||||
setMessage({ type: 'error', text: '没有子菜单的顶级菜单需要填写链接' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
link: form.link.trim(),
|
||||
sort_order: Number(form.sort_order) || 0,
|
||||
is_enabled: Boolean(form.is_enabled),
|
||||
is_external: Boolean(form.is_external),
|
||||
parent_id: form.parent_id || null,
|
||||
};
|
||||
if (editing) {
|
||||
await navApi.update(editing.id, payload);
|
||||
} else {
|
||||
await navApi.create(payload);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditing(null);
|
||||
await fetchMenus();
|
||||
setMessage({ type: 'success', text: editing ? '更新成功' : '创建成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
setMessage({ type: 'error', text: '保存失败: ' + (error.message || '未知错误') });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const children = childMenusMap[id] || [];
|
||||
let confirmMsg = '确定要删除这个导航菜单吗?';
|
||||
if (children.length > 0) {
|
||||
confirmMsg = `该菜单下有 ${children.length} 个子菜单,删除将同时移除所有子菜单,确定继续吗?`;
|
||||
}
|
||||
if (!confirm(confirmMsg)) return;
|
||||
try {
|
||||
await navApi.delete(id);
|
||||
await fetchMenus();
|
||||
setMessage({ type: 'success', text: '删除成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
setMessage({ type: 'error', text: '删除失败' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowForm(false);
|
||||
setEditing(null);
|
||||
setForm({
|
||||
name: '',
|
||||
link: '',
|
||||
sort_order: 0,
|
||||
is_enabled: true,
|
||||
is_external: false,
|
||||
parent_id: null,
|
||||
});
|
||||
};
|
||||
|
||||
// QR Code handlers
|
||||
const handleQrAdd = () => {
|
||||
setQrEditing(null);
|
||||
setQrForm({ content: '', description: '', sort_order: 0, is_enabled: true });
|
||||
setShowQrForm(true);
|
||||
};
|
||||
|
||||
const handleQrEdit = (qr) => {
|
||||
setQrEditing(qr);
|
||||
setQrForm({
|
||||
content: qr.content || '',
|
||||
description: qr.description || '',
|
||||
sort_order: qr.sort_order ?? 0,
|
||||
is_enabled: qr.is_enabled ?? true,
|
||||
});
|
||||
setShowQrForm(true);
|
||||
};
|
||||
|
||||
const handleQrSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!qrForm.content.trim()) {
|
||||
setMessage({ type: 'error', text: '二维码内容不能为空' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setQrSaving(true);
|
||||
const payload = {
|
||||
content: qrForm.content.trim(),
|
||||
description: qrForm.description.trim(),
|
||||
sort_order: Number(qrForm.sort_order) || 0,
|
||||
is_enabled: Boolean(qrForm.is_enabled),
|
||||
};
|
||||
if (qrEditing) {
|
||||
await contactQrApi.update(qrEditing.id, payload);
|
||||
} else {
|
||||
await contactQrApi.create(payload);
|
||||
}
|
||||
setShowQrForm(false);
|
||||
setQrEditing(null);
|
||||
await fetchQrCodes();
|
||||
setMessage({ type: 'success', text: qrEditing ? '二维码更新成功' : '二维码创建成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (error) {
|
||||
console.error('保存二维码失败:', error);
|
||||
setMessage({ type: 'error', text: '保存二维码失败: ' + (error.message || '未知错误') });
|
||||
} finally {
|
||||
setQrSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQrDelete = async (id) => {
|
||||
if (!confirm('确定要删除这个二维码吗?')) return;
|
||||
try {
|
||||
await contactQrApi.delete(id);
|
||||
await fetchQrCodes();
|
||||
setMessage({ type: 'success', text: '删除成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (error) {
|
||||
console.error('删除二维码失败:', error);
|
||||
setMessage({ type: 'error', text: '删除失败' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleQrCancel = () => {
|
||||
setShowQrForm(false);
|
||||
setQrEditing(null);
|
||||
setQrForm({ content: '', description: '', sort_order: 0, is_enabled: true });
|
||||
};
|
||||
|
||||
const parentOptions = topMenus.map((m) => ({ id: m.id, name: m.name }));
|
||||
|
||||
const MenuRow = ({ menu, isChild = false }) => {
|
||||
const children = childMenusMap[menu.id] || [];
|
||||
const isExpanded = expandedIds.has(menu.id);
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={`border-b border-slate-100 hover:bg-slate-50/50 transition-colors ${isChild ? 'bg-slate-50/30' : ''}`}>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isChild && hasChildren && (
|
||||
<button
|
||||
onClick={() => toggleExpand(menu.id)}
|
||||
className="p-0.5 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{isChild && <div className="w-5" />}
|
||||
<span className="text-slate-500 text-sm">{menu.sort_order ?? 0}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className={`font-medium ${isChild ? 'text-sm text-slate-600 pl-6' : 'text-slate-900'}`}>
|
||||
{isChild && <span className="text-slate-300 mr-1">└</span>}
|
||||
{menu.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500 text-sm max-w-xs truncate">
|
||||
{menu.link || (hasChildren && !isChild ? <span className="text-slate-400 italic">含子菜单</span> : '-')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={menu.is_enabled ? 'success' : 'default'}>
|
||||
{menu.is_enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{!isChild && (
|
||||
<button
|
||||
onClick={() => handleAddChild(menu)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
title="添加子菜单"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleEdit(menu)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(menu.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded &&
|
||||
children.map((child) => <MenuRow key={child.id} menu={child} isChild={true} />)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-emerald-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">导航菜单</h1>
|
||||
<p className="text-slate-500 mt-1">管理网站顶部导航菜单,支持二级菜单</p>
|
||||
</div>
|
||||
<Button onClick={handleAddTop}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加顶级菜单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message.text && (
|
||||
<div
|
||||
className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader
|
||||
title={editing ? '编辑菜单' : form.parent_id ? '添加子菜单' : '添加顶级菜单'}
|
||||
action={
|
||||
<button onClick={handleCancel} className="text-slate-400 hover:text-slate-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">所属父级菜单</label>
|
||||
<select
|
||||
value={form.parent_id || ''}
|
||||
onChange={(e) => setForm({ ...form, parent_id: e.target.value ? Number(e.target.value) : null })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent text-sm"
|
||||
disabled={!!editing}
|
||||
>
|
||||
<option value="">顶级菜单(无父级)</option>
|
||||
{parentOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editing && (
|
||||
<p className="text-xs text-slate-400 mt-1">编辑时不可修改父级菜单</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">菜单名称</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="请输入菜单名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
链接路径
|
||||
{!form.parent_id && childMenusMap[editing?.id]?.length > 0 && (
|
||||
<span className="text-slate-400 font-normal ml-1">(有子菜单时可选)</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
value={form.link}
|
||||
onChange={(e) => setForm({ ...form, link: e.target.value })}
|
||||
placeholder={form.parent_id ? '如: /about/team' : '如: /about'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">排序</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.sort_order}
|
||||
onChange={(e) => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })}
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_enabled"
|
||||
checked={form.is_enabled}
|
||||
onChange={(e) => setForm({ ...form, is_enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300 focus:ring-emerald-500"
|
||||
/>
|
||||
<label htmlFor="is_enabled" className="text-sm text-slate-700">启用</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_external"
|
||||
checked={form.is_external}
|
||||
onChange={(e) => setForm({ ...form, is_external: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300 focus:ring-emerald-500"
|
||||
/>
|
||||
<label htmlFor="is_external" className="text-sm text-slate-700">外部链接</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-end gap-3">
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" loading={saving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="菜单列表" />
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
排序
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
名称
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
路径
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topMenus.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-slate-400">
|
||||
暂无导航菜单
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
topMenus
|
||||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
|
||||
.map((menu) => <MenuRow key={menu.id} menu={menu} />)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact QR Code Settings */}
|
||||
<div className="mt-10 mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900">咨询设置</h2>
|
||||
<p className="text-slate-500 mt-1">配置访客端"立即咨询"按钮展开的二维码</p>
|
||||
</div>
|
||||
<Button onClick={handleQrAdd}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加二维码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showQrForm && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader
|
||||
title={qrEditing ? '编辑二维码' : '添加二维码'}
|
||||
action={
|
||||
<button onClick={handleQrCancel} className="text-slate-400 hover:text-slate-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<form onSubmit={handleQrSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
内容(URL 或文字)
|
||||
<span className="text-slate-400 font-normal ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={qrForm.content}
|
||||
onChange={(e) => setQrForm({ ...qrForm, content: e.target.value })}
|
||||
placeholder="如: https://weixin.qq.com/... 或 微信号: xxx"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">保存时会自动生成二维码图片</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">描述</label>
|
||||
<Input
|
||||
value={qrForm.description}
|
||||
onChange={(e) => setQrForm({ ...qrForm, description: e.target.value })}
|
||||
placeholder="如: 扫码添加微信客服"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">排序</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={qrForm.sort_order}
|
||||
onChange={(e) => setQrForm({ ...qrForm, sort_order: parseInt(e.target.value) || 0 })}
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="qr_is_enabled"
|
||||
checked={qrForm.is_enabled}
|
||||
onChange={(e) => setQrForm({ ...qrForm, is_enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300 focus:ring-emerald-500"
|
||||
/>
|
||||
<label htmlFor="qr_is_enabled" className="text-sm text-slate-700">启用</label>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-end gap-3">
|
||||
<Button type="button" variant="secondary" onClick={handleQrCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" loading={qrSaving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="二维码列表" />
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
排序
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
二维码
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
内容
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
描述
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qrCodes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-slate-400">
|
||||
暂无二维码,点击右上角添加
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
qrCodes
|
||||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
|
||||
.map((qr) => (
|
||||
<tr key={qr.id} className="border-b border-slate-100 hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-4 py-3 text-slate-500">{qr.sort_order ?? 0}</td>
|
||||
<td className="px-4 py-3">
|
||||
{qr.image_path ? (
|
||||
<img
|
||||
src={qr.image_path}
|
||||
alt="二维码"
|
||||
className="w-16 h-16 object-contain border border-slate-200 rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
<QrCode className="w-6 h-6 text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 max-w-xs truncate" title={qr.content}>
|
||||
{qr.content}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500 max-w-xs truncate">
|
||||
{qr.description || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={qr.is_enabled ? 'success' : 'default'}>
|
||||
{qr.is_enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleQrEdit(qr)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQrDelete(qr.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavMenus;
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import { Calendar, Eye, ChevronLeft, Share2, User, Clock } from 'lucide-react';
|
||||
import api from '../api';
|
||||
import { updateSEO, extractSummary } from '../utils/seo';
|
||||
|
||||
const NewsDetail = () => {
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [post, setPost] = useState(null);
|
||||
const [relatedPosts, setRelatedPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const contentRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
loadPost();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (post) {
|
||||
updateSEO({
|
||||
title: post.title,
|
||||
description: post.summary || extractSummary(post.content, 150),
|
||||
keywords: `${post.title},${post.column?.name || ''},飞点健康,吾大夫`,
|
||||
ogTitle: post.title,
|
||||
ogDescription: post.summary || extractSummary(post.content, 150),
|
||||
ogImage: post.cover_image || '',
|
||||
canonical: `http://team.niaodaifu.cn/news/${post.slug}`
|
||||
});
|
||||
}
|
||||
}, [post]);
|
||||
|
||||
// 渲染数学公式(KaTeX)
|
||||
useEffect(() => {
|
||||
if (!post || !contentRef.current) return;
|
||||
|
||||
const renderMath = () => {
|
||||
if (window.renderMathInElement) {
|
||||
window.renderMathInElement(contentRef.current, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '$', right: '$', display: false }
|
||||
],
|
||||
throwOnError: false,
|
||||
trust: true,
|
||||
strict: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(renderMath, 100);
|
||||
const retryTimer = setTimeout(renderMath, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(retryTimer);
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
const loadPost = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.get(`/api/public/articles/${slug}`);
|
||||
setPost(res);
|
||||
|
||||
// 加载相关文章
|
||||
if (res.column?.slug) {
|
||||
loadRelatedPosts(res.column.slug, res.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('文章加载失败,请稍后重试');
|
||||
console.error('加载文章失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRelatedPosts = async (columnSlug, excludeId) => {
|
||||
try {
|
||||
const res = await api.get('/api/public/articles', {
|
||||
params: {
|
||||
column_slug: columnSlug,
|
||||
page: 1,
|
||||
size: 5
|
||||
}
|
||||
});
|
||||
// 排除当前文章
|
||||
const filtered = (res.items || []).filter(p => p.id !== excludeId).slice(0, 4);
|
||||
setRelatedPosts(filtered);
|
||||
} catch (err) {
|
||||
console.error('加载相关文章失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: post?.title,
|
||||
text: post?.summary,
|
||||
url: window.location.href
|
||||
});
|
||||
} catch {
|
||||
// 用户取消分享
|
||||
}
|
||||
} else {
|
||||
// 复制链接
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert('链接已复制到剪贴板');
|
||||
}
|
||||
};
|
||||
|
||||
// Markdown 渲染(从 personal-blog 移植,样式适配 emerald 主题)
|
||||
const renderMarkdown = (markdown) => {
|
||||
if (!markdown) return '';
|
||||
if (post?.content_type !== 'markdown') return markdown;
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// 代码块(必须在行内代码之前处理)
|
||||
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre class="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto my-6"><code>$2</code></pre>');
|
||||
|
||||
// 标题
|
||||
html = html.replace(/^#### (.*$)/gim, '<h4 class="text-lg font-bold mt-6 mb-3 text-gray-900">$1</h4>');
|
||||
html = html.replace(/^### (.*$)/gim, '<h3 class="text-xl font-bold mt-8 mb-4 text-gray-900 border-b border-gray-200 pb-2">$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2 class="text-2xl font-bold mt-10 mb-5 text-gray-900 border-b-2 border-emerald-200 pb-3">$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1 class="text-3xl font-black mt-10 mb-6 text-gray-900">$1</h1>');
|
||||
|
||||
// 粗体
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-gray-900">$1</strong>');
|
||||
|
||||
// 斜体
|
||||
html = html.replace(/\*(.*?)\*/g, '<em class="italic text-gray-700">$1</em>');
|
||||
|
||||
// 删除线
|
||||
html = html.replace(/~~(.*?)~~/g, '<del class="line-through text-gray-400">$1</del>');
|
||||
|
||||
// 行内代码
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="bg-emerald-50 text-emerald-700 px-1.5 py-0.5 rounded font-mono text-sm border border-emerald-200">$1</code>');
|
||||
|
||||
// 引用
|
||||
html = html.replace(/^> (.*$)/gim, '<blockquote class="border-l-4 border-emerald-400 pl-4 py-3 my-6 bg-emerald-50/50 italic text-gray-700 rounded-r-lg">$1</blockquote>');
|
||||
|
||||
// 图片(必须在链接之前处理)
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const fullSrc = src.startsWith('http') ? src : src;
|
||||
return `<img src="${fullSrc}" alt="${alt}" class="max-w-full h-auto my-6 rounded-xl shadow-md" loading="lazy" onerror="this.style.display='none'" />`;
|
||||
});
|
||||
|
||||
// 链接
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" class="text-emerald-600 hover:text-emerald-700 hover:underline transition-colors">$1</a>');
|
||||
|
||||
// 无序列表
|
||||
html = html.replace(/^\s*[-*] (.*$)/gim, '<li data-type="ul" class="ml-5">$1</li>');
|
||||
html = html.replace(/(<li data-type="ul"[^>]*>.*?<\/li>\n?)+/g, '<ul class="list-disc my-5 space-y-2 text-gray-700">$&</ul>\n');
|
||||
html = html.replace(/data-type="ul"/g, '');
|
||||
|
||||
// 有序列表
|
||||
html = html.replace(/^\s*(\d+)\. (.*$)/gim, '<li data-type="ol" class="ml-5">$2</li>');
|
||||
html = html.replace(/(<li data-type="ol"[^>]*>.*?<\/li>\n?)+/g, '<ol class="list-decimal my-5 space-y-2 text-gray-700">$&</ol>\n');
|
||||
html = html.replace(/data-type="ol"/g, '');
|
||||
|
||||
// 分割线
|
||||
html = html.replace(/^---$/gim, '<hr class="my-10 border-t border-gray-200" />');
|
||||
|
||||
// 段落
|
||||
html = html.replace(/\n\n/g, '</p><p class="my-4 leading-relaxed text-gray-700">');
|
||||
html = '<p class="my-4 leading-relaxed text-gray-700">' + html + '</p>';
|
||||
|
||||
// 清理空段落
|
||||
html = html.replace(/<p class="my-4 leading-relaxed text-gray-700"><\/p>/g, '');
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-emerald-200 border-t-emerald-600 rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">文章未找到</h2>
|
||||
<p className="text-gray-600 mb-6">{error || '该文章可能已被删除或不存在'}</p>
|
||||
<Link
|
||||
to="/column/xin-wen-zi-xun"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
返回资讯列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 返回导航 */}
|
||||
<div className="bg-white border-b border-gray-100">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<button
|
||||
onClick={() => navigate('/column/xin-wen-zi-xun')}
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
返回资讯列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文章内容 */}
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 文章头部 */}
|
||||
<header className="mb-8">
|
||||
{/* 分类标签 */}
|
||||
{post.column && (
|
||||
<Link
|
||||
to={`/column/xin-wen-zi-xun?category=${post.column.slug}`}
|
||||
className="inline-block px-4 py-1 bg-emerald-50 text-emerald-600 text-sm font-medium rounded-full mb-4 hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
{post.column.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 标题 */}
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500 border-b border-gray-100 pb-6">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(post.updated_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
{post.view_count || 0} 阅读
|
||||
</span>
|
||||
{post.author && (
|
||||
<span className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
{post.author}
|
||||
</span>
|
||||
)}
|
||||
{post.content_type === 'markdown' && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
约 {Math.ceil((post.content?.length || 0) / 500)} 分钟阅读
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="ml-auto flex items-center gap-2 text-emerald-600 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
分享
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 封面图 */}
|
||||
{post.cover_image && (
|
||||
<div className="mb-8 rounded-xl overflow-hidden shadow-lg">
|
||||
<img
|
||||
src={post.cover_image.startsWith('http') ? post.cover_image : `${post.cover_image.startsWith('/storage/') || post.cover_image.startsWith('/uploads/') ? '' : '/uploads'}${post.cover_image}`}
|
||||
alt={post.title}
|
||||
className="w-full h-auto"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文章正文 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-emerald-600 hover:prose-a:text-emerald-700 prose-img:rounded-lg prose-img:shadow-md"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(post.content) }}
|
||||
/>
|
||||
|
||||
{/* 文章底部 */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
to="/column/xin-wen-zi-xun"
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
返回资讯列表
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
分享文章
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* 相关文章 */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<section className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12 border-t border-gray-200">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">相关文章</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{relatedPosts.map((related) => (
|
||||
<Link
|
||||
key={related.id}
|
||||
to={`/article/${related.slug}`}
|
||||
className="flex gap-4 p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
{related.cover_image && (
|
||||
<div className="w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={related.cover_image.startsWith('http') ? related.cover_image : `${related.cover_image.startsWith('/storage/') || related.cover_image.startsWith('/uploads/') ? '' : '/uploads'}${related.cover_image}`}
|
||||
alt={related.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 line-clamp-2 mb-2 hover:text-emerald-600 transition-colors">
|
||||
{related.title}
|
||||
</h4>
|
||||
<span className="text-sm text-gray-400">
|
||||
{formatDate(related.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsDetail;
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Calendar, Eye, ChevronRight, Newspaper, Loader2,
|
||||
FileText, Heart, Activity, Droplets, Flame, Microscope,
|
||||
Stethoscope, Users, FolderTree, Home, Image, Star,
|
||||
BookOpen, Brain, Leaf, Apple, Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
// lucide 图标名称映射表
|
||||
const ICON_MAP = {
|
||||
Newspaper, FileText, Heart, Activity, Droplets, Flame,
|
||||
Microscope, Stethoscope, Users, FolderTree, Home, Image,
|
||||
Star, BookOpen, Brain, Leaf, Apple, Zap
|
||||
};
|
||||
import api from '../api';
|
||||
import { updateSEO } from '../utils/seo';
|
||||
|
||||
const NewsList = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [columnInfo, setColumnInfo] = useState({ name: '新闻资讯', description: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(12);
|
||||
|
||||
const currentCategory = searchParams.get('category') || '';
|
||||
|
||||
// 加载栏目列表
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 加载文章列表
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [page, currentCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSEO({
|
||||
title: `${columnInfo.name} - 飞点健康`,
|
||||
description: columnInfo.description || `${columnInfo.name},飞点健康尿大夫提供最新的健康资讯与行业动态。`,
|
||||
keywords: `${columnInfo.name},健康资讯,飞点健康,吾大夫`,
|
||||
canonical: 'http://team.niaodaifu.cn/news'
|
||||
});
|
||||
}, [columnInfo]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const res = await api.get('/api/public/columns/xin-wen-zi-xun');
|
||||
// 子栏目在 children 字段中
|
||||
const items = res.children || [];
|
||||
setCategories(items);
|
||||
// 同时保存栏目名称、描述和图标
|
||||
setColumnInfo({
|
||||
name: res.name || '新闻资讯',
|
||||
description: res.description || '',
|
||||
icon_name: res.icon_name || ''
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('加载栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPosts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page,
|
||||
size: pageSize
|
||||
};
|
||||
if (currentCategory) {
|
||||
params.column_slug = currentCategory;
|
||||
} else {
|
||||
// 未选择子栏目时,显示新闻资讯顶级栏目下的所有文章
|
||||
params.column_slug = 'xin-wen-zi-xun';
|
||||
}
|
||||
const res = await api.get('/api/public/articles', { params });
|
||||
setPosts(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (err) {
|
||||
console.error('加载文章失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = (slug) => {
|
||||
setPage(1);
|
||||
if (slug) {
|
||||
setSearchParams({ category: slug });
|
||||
} else {
|
||||
setSearchParams({});
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 页面标题 */}
|
||||
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{(() => {
|
||||
const Icon = ICON_MAP[columnInfo.icon_name] || Newspaper;
|
||||
return <Icon className="w-8 h-8" />;
|
||||
})()}
|
||||
<h1 className="text-3xl md:text-4xl font-bold">{columnInfo.name}</h1>
|
||||
</div>
|
||||
{columnInfo.description && (
|
||||
<p className="text-emerald-100 text-lg max-w-2xl">
|
||||
{columnInfo.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 栏目筛选(移动端) */}
|
||||
<div className="lg:hidden mb-6">
|
||||
<select
|
||||
value={currentCategory}
|
||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">全部资讯</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* 左侧:栏目导航 */}
|
||||
<aside className="lg:w-56 flex-shrink-0 order-1">
|
||||
<div className="bg-white rounded-xl shadow-sm p-5 sticky top-24">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<span className="w-1 h-5 bg-emerald-500 rounded-full"></span>
|
||||
资讯分类
|
||||
</h3>
|
||||
<nav className="space-y-1">
|
||||
<button
|
||||
onClick={() => handleCategoryChange('')}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors flex items-center justify-between text-sm ${
|
||||
currentCategory === ''
|
||||
? 'bg-emerald-50 text-emerald-600 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
全部资讯
|
||||
<ChevronRight className={`w-4 h-4 transition-transform ${
|
||||
currentCategory === '' ? 'rotate-90' : ''
|
||||
}`} />
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => handleCategoryChange(cat.slug)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors flex items-center justify-between text-sm ${
|
||||
currentCategory === cat.slug
|
||||
? 'bg-emerald-50 text-emerald-600 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
<ChevronRight className={`w-4 h-4 transition-transform ${
|
||||
currentCategory === cat.slug ? 'rotate-90' : ''
|
||||
}`} />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="mt-5 pt-5 border-t border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-emerald-600">{total}</div>
|
||||
<div className="text-xs text-gray-500">篇文章</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-emerald-600">{categories.length}</div>
|
||||
<div className="text-xs text-gray-500">个分类</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右侧:文章列表 */}
|
||||
<div className="flex-1 order-2">
|
||||
{/* 文章列表 */}
|
||||
{loading && posts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-emerald-600 animate-spin" />
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white rounded-xl shadow-sm">
|
||||
<Newspaper className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">暂无文章</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
<article
|
||||
key={post.id}
|
||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
<Link to={`/article/${post.slug}`} className="flex flex-col md:flex-row items-center">
|
||||
{/* 封面图 */}
|
||||
{post.cover_image && (
|
||||
<div className="w-[120px] h-[120px] flex-shrink-0 rounded-lg overflow-hidden flex items-center">
|
||||
<img
|
||||
src={post.cover_image.startsWith('http') ? post.cover_image : `${post.cover_image.startsWith('/storage/') || post.cover_image.startsWith('/uploads/') ? '' : '/uploads'}${post.cover_image}`}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{post.column && (
|
||||
<span className="px-3 py-1 bg-emerald-50 text-emerald-600 text-sm rounded-full">
|
||||
{post.column.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(post.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2 hover:text-emerald-600 transition-colors line-clamp-2">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{post.summary && (
|
||||
<p className="text-gray-600 line-clamp-2 mb-4">
|
||||
{post.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Eye className="w-4 h-4" />
|
||||
{post.view_count || 0} 阅读
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-emerald-600 text-sm font-medium">
|
||||
阅读全文
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-600 text-sm">{page} / {totalPages}</span>
|
||||
<span className="text-gray-400 text-sm">跳至</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={page}
|
||||
className="w-14 px-2 py-1.5 text-sm text-center border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<span className="text-gray-400 text-sm">页</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}}
|
||||
disabled={!jumpPage}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
跳转
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsList;
|
||||
@@ -0,0 +1,807 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { modulesApi, settingsApi, appDownloadsApi, homeExpertsApi, homePartnersApi } from '../api';
|
||||
import { updateSEO } from '../utils/seo';
|
||||
import { ArrowRight, ChevronLeft, ChevronRight, Download, Phone, Mail, MapPin, Newspaper, Users } from 'lucide-react';
|
||||
|
||||
// Module Components
|
||||
function BannerModule({ content }) {
|
||||
const slides = content?.slides || [];
|
||||
const settings = content?.settings || {};
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
const autoplay = settings.autoplay ?? true;
|
||||
const interval = settings.interval || 5000;
|
||||
const showDots = settings.show_dots ?? true;
|
||||
const showArrows = settings.show_arrows ?? true;
|
||||
const height = settings.height || '600px';
|
||||
|
||||
useEffect(() => {
|
||||
if (slides.length <= 1 || !autoplay || isPaused) return;
|
||||
const timer = setInterval(() => {
|
||||
setCurrent((c) => (c + 1) % slides.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [slides.length, autoplay, interval, isPaused]);
|
||||
|
||||
const goToSlide = (index) => {
|
||||
setCurrent(index);
|
||||
};
|
||||
|
||||
const goToPrev = () => {
|
||||
setCurrent((c) => (c - 1 + slides.length) % slides.length);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrent((c) => (c + 1) % slides.length);
|
||||
};
|
||||
|
||||
const handleSlideClick = (slide) => {
|
||||
if (slide.link) {
|
||||
let url = slide.link;
|
||||
if (!url.startsWith('http') && !url.startsWith('/') && url.includes('.')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
if (url.startsWith('http')) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
window.location.assign(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (slides.length === 0) {
|
||||
return (
|
||||
<section className="relative w-full overflow-hidden bg-gradient-to-br from-emerald-500 to-blue-600" style={{ height }}>
|
||||
<div className="w-full h-full px-4 sm:px-6 lg:px-8 xl:px-12 flex items-center">
|
||||
<div className="max-w-3xl text-white">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight drop-shadow-lg">
|
||||
飞点健康
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-white/95 mb-8 drop-shadow-md">
|
||||
专业的健康管理平台,守护您和家人的健康
|
||||
</p>
|
||||
<button className="px-8 py-3.5 bg-white text-emerald-600 font-semibold rounded-full shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200">
|
||||
了解更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative w-full overflow-hidden bg-gray-100"
|
||||
style={{ height }}
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
{/* Slides */}
|
||||
{slides.map((slide, idx) => (
|
||||
<div
|
||||
key={slide.id || idx}
|
||||
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
|
||||
idx === current ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||
}`}
|
||||
onClick={() => handleSlideClick(slide)}
|
||||
style={{ cursor: slide.link ? 'pointer' : 'default' }}
|
||||
>
|
||||
<img
|
||||
src={slide.image}
|
||||
alt={slide.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Navigation Arrows - Modern style */}
|
||||
{showArrows && slides.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
className="absolute left-4 md:left-8 top-1/2 -translate-y-1/2 z-20 w-12 h-12 bg-white/90 hover:bg-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-gray-700" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute right-4 md:right-8 top-1/2 -translate-y-1/2 z-20 w-12 h-12 bg-white/90 hover:bg-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-gray-700" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination Dots - Modern round style */}
|
||||
{showDots && slides.length > 1 && (
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex gap-2">
|
||||
{slides.map((slide, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => goToSlide(idx)}
|
||||
className={`transition-all duration-300 rounded-full ${
|
||||
idx === current
|
||||
? 'w-8 h-2.5 bg-emerald-500'
|
||||
: 'w-2.5 h-2.5 bg-white/60 hover:bg-white'
|
||||
}`}
|
||||
title={slide.title || `幻灯片 ${idx + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard({ item, index }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
// 兼容后台管理使用的 image 字段;前4个特性按索引兜底使用默认特性图
|
||||
const fallbackUrl = index < 4 ? `/storage/admin/images/features/feature${index + 1}.png` : null;
|
||||
const rawMediaUrl = item.media_url || item.image;
|
||||
const mediaUrl = (!imgError && rawMediaUrl) || fallbackUrl;
|
||||
const isVideo = item.media_type === 'video' && mediaUrl;
|
||||
const hasMedia = mediaUrl;
|
||||
|
||||
// 悬停时播放视频
|
||||
useEffect(() => {
|
||||
if (isVideo && videoRef.current) {
|
||||
if (isHovered) {
|
||||
videoRef.current.play().catch(() => {});
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.currentTime = 0;
|
||||
}
|
||||
}
|
||||
}, [isHovered, isVideo]);
|
||||
|
||||
const cardContent = (
|
||||
<div
|
||||
className={`bg-white rounded-xl overflow-hidden h-full flex flex-col transition-all duration-300 ${
|
||||
isHovered ? 'shadow-xl -translate-y-2' : 'shadow-md'
|
||||
}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{hasMedia ? (
|
||||
<div className="relative w-full aspect-[4/3] overflow-hidden">
|
||||
{isVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={mediaUrl}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
poster={item.poster_url}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt={item.title || ''}
|
||||
className={`w-full h-full object-cover transition-transform duration-500 ${
|
||||
isHovered ? 'scale-110' : ''
|
||||
}`}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overlay gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
|
||||
{/* Content overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-5">
|
||||
{item.title && (
|
||||
<h3 className="text-lg font-bold text-white mb-1">{item.title}</h3>
|
||||
)}
|
||||
{item.description && (
|
||||
<p className="text-white/80 text-sm line-clamp-2">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center flex-1 flex flex-col justify-center">
|
||||
{item.icon && (
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-emerald-100 rounded-2xl flex items-center justify-center text-3xl">
|
||||
{item.icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.title && (
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">{item.title}</h3>
|
||||
)}
|
||||
|
||||
{item.description && (
|
||||
<p className="text-gray-600 text-sm">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link indicator */}
|
||||
{item.link && (
|
||||
<div className="px-5 pb-5 pt-2">
|
||||
<span className={`inline-flex items-center gap-1 text-sm font-medium transition-colors ${
|
||||
isHovered ? 'text-emerald-600' : 'text-gray-500'
|
||||
}`}>
|
||||
了解更多
|
||||
<ArrowRight className={`w-4 h-4 transition-transform duration-200 ${
|
||||
isHovered ? 'translate-x-1' : ''
|
||||
}`} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (item.link) {
|
||||
let url = item.link;
|
||||
if (!url.startsWith('http') && !url.startsWith('/') && url.includes('.')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
if (url.startsWith('http')) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
{cardContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link to={url} className="block h-full">
|
||||
{cardContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return cardContent;
|
||||
}
|
||||
|
||||
function FeaturesModule({ title, subtitle, content }) {
|
||||
const items = content?.items || [];
|
||||
const total = items.length;
|
||||
|
||||
const getGridClass = () => {
|
||||
if (total <= 1) return 'grid-cols-1 max-w-2xl mx-auto';
|
||||
if (total === 2) return 'grid-cols-1 md:grid-cols-2';
|
||||
if (total === 3) return 'grid-cols-1 md:grid-cols-3';
|
||||
if (total === 4) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4';
|
||||
return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5';
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="features" className="py-20 md:py-28 bg-gray-50">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<span className="inline-block px-4 py-1.5 bg-emerald-100 text-emerald-700 text-sm font-medium rounded-full mb-4">
|
||||
我们的服务
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{title || '特色服务'}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className={`grid gap-6 ${getGridClass()}`}>
|
||||
{items.map((item, idx) => (
|
||||
<FeatureCard key={item.id || idx} item={item} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl shadow-md p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-emerald-100 rounded-2xl flex items-center justify-center text-2xl">
|
||||
{i === 1 ? '📊' : i === 2 ? '👨⚕️' : '⚡'}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">
|
||||
{i === 1 ? '数据驱动' : i === 2 ? '专家团队' : '快速响应'}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{i === 1 ? '智能分析您的健康数据' : i === 2 ? '专业医疗团队为您服务' : '7x24小时健康守护'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NewsModule({ title, subtitle, content }) {
|
||||
const posts = (content?.posts || []).filter(Boolean).slice(0, 4);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const resolveImage = (path) => {
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="news" className="py-20 md:py-28 bg-white">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<span className="inline-block px-4 py-1.5 bg-blue-100 text-blue-700 text-sm font-medium rounded-full mb-4">
|
||||
最新资讯
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{title || '新闻动态'}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||
{posts.map((post, idx) => (
|
||||
<Link
|
||||
key={post.id || idx}
|
||||
to={`/news/${post.slug}`}
|
||||
className="group flex gap-6 bg-gray-50 rounded-xl p-6 md:p-8 hover:bg-white hover:shadow-lg transition-all duration-300 min-h-[180px]"
|
||||
>
|
||||
{/* 左侧封面图 */}
|
||||
<div className="w-40 h-28 sm:w-52 sm:h-36 lg:w-60 lg:h-44 flex-shrink-0 rounded-xl overflow-hidden bg-gray-200">
|
||||
<img
|
||||
src={resolveImage(post.cover_image)}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
onError={(e) => { e.target.src = '/placeholder-image.png'; }}
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧内容 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center py-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{post.category_name && (
|
||||
<span className="text-sm text-emerald-600 font-medium bg-emerald-50 px-3 py-1 rounded-full">
|
||||
{post.category_name}
|
||||
</span>
|
||||
)}
|
||||
{post.updated_at && (
|
||||
<span className="text-sm text-gray-400">
|
||||
{formatDate(post.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg lg:text-xl font-bold text-gray-900 mb-2 group-hover:text-emerald-600 transition-colors line-clamp-2">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-base text-gray-500 line-clamp-3 leading-relaxed">
|
||||
{post.summary || ''}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<Newspaper className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无新闻动态</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mt-10">
|
||||
<Link
|
||||
to="/news"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 border-2 border-gray-200 text-gray-700 font-medium rounded-full hover:border-emerald-500 hover:text-emerald-600 transition-all duration-200"
|
||||
>
|
||||
查看全部新闻
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PartnersModule({ title, subtitle }) {
|
||||
const [partners, setPartners] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
homePartnersApi.getPublic()
|
||||
.then((data) => setPartners(data || []))
|
||||
.catch((err) => console.error('获取合作伙伴失败:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const resolveImage = (path) => {
|
||||
if (!path) return null;
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="partners" className="py-20 md:py-28 bg-gray-900">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<span className="inline-block px-4 py-1.5 bg-purple-500/20 text-purple-300 text-sm font-medium rounded-full mb-4">
|
||||
信赖之选
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
{title || '合作伙伴'}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-400 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-10 h-10 border-4 border-purple-500/20 border-t-purple-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : partners.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 max-w-6xl mx-auto">
|
||||
{partners.map((partner) => {
|
||||
const logoSrc = resolveImage(partner.logo_url);
|
||||
const inner = logoSrc ? (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={partner.name}
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">Logo</div>
|
||||
);
|
||||
|
||||
const cardClass = "bg-white flex items-center justify-center hover:shadow-lg transition-shadow overflow-hidden aspect-[92/36]";
|
||||
|
||||
if (partner.website_url) {
|
||||
return (
|
||||
<a
|
||||
key={partner.id}
|
||||
href={partner.website_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cardClass}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={partner.id} className={cardClass}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-12">暂无合作伙伴</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactModule({ title, subtitle, content }) {
|
||||
return (
|
||||
<section id="contact" className="py-20 md:py-28 bg-white">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<span className="inline-block px-4 py-1.5 bg-orange-100 text-orange-700 text-sm font-medium rounded-full mb-4">
|
||||
随时联系
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{title || '联系我们'}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gray-50 rounded-xl p-8 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-14 h-14 mx-auto mb-4 bg-emerald-100 rounded-2xl flex items-center justify-center">
|
||||
<Phone className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">客服热线</h3>
|
||||
<p className="text-emerald-600 font-medium">{content?.phone || '400-888-8888'}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-8 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-14 h-14 mx-auto mb-4 bg-blue-100 rounded-2xl flex items-center justify-center">
|
||||
<Mail className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">电子邮箱</h3>
|
||||
<p className="text-blue-600 font-medium">{content?.email || 'contact@feidian.com'}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-8 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-2xl flex items-center justify-center">
|
||||
<MapPin className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">公司地址</h3>
|
||||
<p className="text-gray-600">{content?.address || '北京市朝阳区建国路88号'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadsModule({ title, subtitle }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
appDownloadsApi.getPublic()
|
||||
.then((data) => setItems(data || []))
|
||||
.catch((err) => console.error('获取下载链接失败:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const platformLabels = {
|
||||
ios: 'iOS',
|
||||
android: 'Android',
|
||||
harmony: 'HarmonyOS',
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 text-white py-20 md:py-28">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
<div className="text-center mb-16">
|
||||
<span className="inline-block px-4 py-1.5 bg-emerald-500/20 text-emerald-400 text-sm font-medium rounded-full mb-4">
|
||||
移动应用
|
||||
</span>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-4">{title || '下载我们的APP'}</h2>
|
||||
{subtitle && <p className="text-xl text-gray-400">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-10 h-10 border-4 border-emerald-500/20 border-t-emerald-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : items.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 max-w-6xl mx-auto">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flip-card block h-48"
|
||||
>
|
||||
<div className="flip-card-inner">
|
||||
{/* 正面 */}
|
||||
<div className="flip-card-front flex flex-col items-center justify-center bg-white/5 backdrop-blur rounded-2xl border border-white/10 hover:border-emerald-500/50 transition-colors">
|
||||
{item.icon ? (
|
||||
<img src={item.icon} alt={item.app_name} className="w-16 h-16 rounded-xl object-cover mb-3" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-xl bg-emerald-500/20 flex items-center justify-center mb-3">
|
||||
<Download className="w-8 h-8 text-emerald-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-lg">{item.app_name}</div>
|
||||
<div className="text-emerald-400 text-sm">{platformLabels[item.platform] || item.platform}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 背面 */}
|
||||
<div className="flip-card-back flex flex-col items-center justify-center bg-white/10 backdrop-blur rounded-2xl border border-emerald-500/30">
|
||||
{item.qr_image_path ? (
|
||||
<img src={item.qr_image_path} alt="二维码" className="w-24 h-24 object-contain mb-2" />
|
||||
) : (
|
||||
<div className="w-24 h-24 bg-white rounded-lg flex items-center justify-center mb-2">
|
||||
<span className="text-gray-400 text-xs">二维码</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-emerald-300 text-sm font-medium">扫码下载APP</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-12">请在后台配置下载应用</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpertsModule({ title, subtitle, content }) {
|
||||
const [experts, setExperts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const viewMoreLink = content?.view_more_link || '';
|
||||
|
||||
useEffect(() => {
|
||||
homeExpertsApi.getPublic()
|
||||
.then((data) => setExperts(data || []))
|
||||
.catch((err) => console.error('获取专家团队失败:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const resolveImage = (path) => {
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 md:py-28 bg-white">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-12">
|
||||
<div className="text-center mb-16">
|
||||
<span className="inline-block px-4 py-1.5 bg-emerald-100 text-emerald-700 text-sm font-medium rounded-full mb-4">
|
||||
专业力量
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{title || '专家团队'}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-10 h-10 border-4 border-emerald-200 border-t-emerald-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : experts.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-5xl mx-auto">
|
||||
{experts.map((expert) => {
|
||||
const post = expert.post;
|
||||
if (!post) return null;
|
||||
return (
|
||||
<a
|
||||
key={expert.id}
|
||||
href={`/news/${post.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-center"
|
||||
>
|
||||
<div className="mx-auto w-28 h-28 md:w-32 md:h-32 rounded-full overflow-hidden border-4 border-emerald-100 group-hover:border-emerald-300 transition-colors mb-5">
|
||||
<img
|
||||
src={resolveImage(post.cover_image || post.cover_thumb)}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
onError={(e) => { e.target.src = '/placeholder-image.png'; }}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-emerald-600 transition-colors mb-2">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 line-clamp-3 leading-relaxed">
|
||||
{post.summary || ''}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{viewMoreLink && (
|
||||
<div className="text-center mt-12">
|
||||
<a
|
||||
href={viewMoreLink}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-50 text-emerald-700 rounded-full font-medium hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
查看更多
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-12">请在后台配置专家团队</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const moduleComponents = {
|
||||
banner: BannerModule,
|
||||
features: FeaturesModule,
|
||||
news: NewsModule,
|
||||
partners: PartnersModule,
|
||||
contact: ContactModule,
|
||||
downloads: DownloadsModule,
|
||||
experts: ExpertsModule,
|
||||
};
|
||||
|
||||
export default function PublicHome() {
|
||||
const [modules, setModules] = useState([]);
|
||||
const [_settings, setSettings] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
updateSEO({
|
||||
title: '飞点健康 - 尿大夫 | 在家做体检,疾病早发现',
|
||||
description: '尿大夫、吾大夫是国内领先的移动健康服务品牌,专注尿液检测与慢病管理。在家做体检,疾病早发现,提供糖尿病、肾病、肿瘤、结石等20多种疾病的尿液筛查服务。',
|
||||
keywords: '尿检,体检,糖尿病,肾病,尿液,肿瘤,结石,尿常规,试纸,尿酸高,痛风,飞点健康,吾大夫',
|
||||
canonical: 'http://team.niaodaifu.cn/'
|
||||
});
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [modulesRes, settingsRes] = await Promise.all([
|
||||
modulesApi.getPublic(),
|
||||
settingsApi.getPublic(),
|
||||
]);
|
||||
setModules(modulesRes || []);
|
||||
setSettings(settingsRes || {});
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-emerald-200 border-t-emerald-500 rounded-full animate-spin" />
|
||||
<p className="text-gray-600 font-medium">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按 sort_order 排序模块,相同 sort_order 时按 id 排序保证稳定性
|
||||
const sortedModules = [...modules].sort((a, b) => {
|
||||
const orderDiff = (a.sort_order || 0) - (b.sort_order || 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return (a.id || 0) - (b.id || 0); // 相同 order 时按 id 排序
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{sortedModules.map((module) => {
|
||||
const Component = moduleComponents[module.type];
|
||||
if (!Component) return null;
|
||||
|
||||
return (
|
||||
<Component
|
||||
key={module.code}
|
||||
title={module.title}
|
||||
subtitle={module.subtitle}
|
||||
content={module.content}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Save, Loader2, Globe, Mail, Phone, MapPin, Plus, Trash2, Upload, Link as LinkIcon, Image as ImageIcon } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '../components/admin/Card';
|
||||
import { Button } from '../components/admin/Button';
|
||||
import { Input, Textarea } from '../components/admin/Input';
|
||||
import api from '../api';
|
||||
|
||||
const SOCIAL_ICON_OPTIONS = [
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'weibo', label: '微博' },
|
||||
{ value: 'douyin', label: '抖音' },
|
||||
{ value: 'xiaohongshu', label: '小红书' },
|
||||
{ value: 'bilibili', label: '哔哩哔哩' },
|
||||
{ value: 'zhihu', label: '知乎' },
|
||||
{ value: 'x', label: 'X (Twitter)' },
|
||||
{ value: 'facebook', label: 'Facebook' },
|
||||
{ value: 'linkedin', label: 'LinkedIn' },
|
||||
{ value: 'instagram', label: 'Instagram' },
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
];
|
||||
|
||||
const safeJsonParse = (str, defaultVal = []) => {
|
||||
try { return str ? JSON.parse(str) : defaultVal; } catch { return defaultVal; }
|
||||
};
|
||||
|
||||
// 图片上传按钮组件
|
||||
const ImageUploadField = ({ label, value, onChange, accept = 'image/*', helpText }) => {
|
||||
const inputRef = useRef(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('subdir', 'setting');
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.data?.url) {
|
||||
setImgError(false);
|
||||
onChange(json.data.url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('上传失败:', err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const showPreview = value && !imgError;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{label}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 min-w-[120px] w-auto max-w-[280px] rounded-lg border border-slate-200 bg-slate-50 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{showPreview ? (
|
||||
<img
|
||||
src={value}
|
||||
alt="preview"
|
||||
className="max-h-10 w-auto max-w-full object-contain"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Upload className="w-4 h-4 text-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="/storage/admin/images/setting/..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-2.5 text-sm bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg border border-slate-300 transition-colors"
|
||||
>
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
上传
|
||||
</button>
|
||||
<input ref={inputRef} type="file" accept={accept} onChange={handleUpload} className="hidden" />
|
||||
</div>
|
||||
{helpText && <p className="text-xs text-slate-400 mt-1">{helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Settings = () => {
|
||||
const [settings, setSettings] = useState({
|
||||
site_name: '',
|
||||
site_description: '',
|
||||
site_keywords: '',
|
||||
site_logo: '',
|
||||
site_favicon: '',
|
||||
contact_phones: '',
|
||||
contact_emails: '',
|
||||
contact_address: '',
|
||||
social_links: '',
|
||||
friend_links: '',
|
||||
copyright: '',
|
||||
bottom_links: '',
|
||||
privacy_url: '',
|
||||
terms_url: '',
|
||||
sitemap_url: '',
|
||||
});
|
||||
|
||||
const [socialLinks, setSocialLinks] = useState([]);
|
||||
const [friendLinks, setFriendLinks] = useState([]);
|
||||
const [phones, setPhones] = useState([]);
|
||||
const [emails, setEmails] = useState([]);
|
||||
const [bottomLinks, setBottomLinks] = useState([]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.get('/api/admin/settings');
|
||||
if (data) {
|
||||
const flattened = {};
|
||||
Object.keys(data).forEach(key => {
|
||||
flattened[key] = data[key]?.value || '';
|
||||
});
|
||||
setSettings(prev => ({ ...prev, ...flattened }));
|
||||
setSocialLinks(safeJsonParse(flattened.social_links, []));
|
||||
setFriendLinks(safeJsonParse(flattened.friend_links, []));
|
||||
setPhones(safeJsonParse(flattened.contact_phones, []));
|
||||
setEmails(safeJsonParse(flattened.contact_emails, []));
|
||||
setBottomLinks(safeJsonParse(flattened.bottom_links, []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设置失败:', error);
|
||||
setMessage({ type: 'error', text: '获取设置失败' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setSettings(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleImgChange = (name, value) => {
|
||||
setSettings(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
...settings,
|
||||
social_links: JSON.stringify(socialLinks),
|
||||
friend_links: JSON.stringify(friendLinks),
|
||||
contact_phones: JSON.stringify(phones),
|
||||
contact_emails: JSON.stringify(emails),
|
||||
bottom_links: JSON.stringify(bottomLinks),
|
||||
};
|
||||
await api.put('/api/admin/settings', { settings: payload });
|
||||
setMessage({ type: 'success', text: '保存成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error);
|
||||
setMessage({ type: 'error', text: '保存失败' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 社交链接
|
||||
const addSocialLink = () => setSocialLinks(prev => [...prev, { name: '', url: '', icon: 'wechat' }]);
|
||||
const updateSocialLink = (i, f, v) => setSocialLinks(prev => prev.map((item, idx) => idx === i ? { ...item, [f]: v } : item));
|
||||
const removeSocialLink = (i) => setSocialLinks(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
// 友情链接
|
||||
const addFriendLink = () => setFriendLinks(prev => [...prev, { name: '', url: '' }]);
|
||||
const updateFriendLink = (i, f, v) => setFriendLinks(prev => prev.map((item, idx) => idx === i ? { ...item, [f]: v } : item));
|
||||
const removeFriendLink = (i) => setFriendLinks(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
// 电话
|
||||
const addPhone = () => setPhones(prev => [...prev, '']);
|
||||
const updatePhone = (i, v) => setPhones(prev => prev.map((item, idx) => idx === i ? v : item));
|
||||
const removePhone = (i) => setPhones(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
// 邮箱
|
||||
const addEmail = () => setEmails(prev => [...prev, '']);
|
||||
const updateEmail = (i, v) => setEmails(prev => prev.map((item, idx) => idx === i ? v : item));
|
||||
const removeEmail = (i) => setEmails(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
// 底部资质链接
|
||||
const addBottomLink = () => setBottomLinks(prev => [...prev, { label: '', content: '', url: '', type: 'text' }]);
|
||||
const updateBottomLink = (i, f, v) => setBottomLinks(prev => prev.map((item, idx) => idx === i ? { ...item, [f]: v } : item));
|
||||
const removeBottomLink = (i) => setBottomLinks(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
const BottomLinkImageUpload = ({ index, value }) => {
|
||||
const inputRef = useRef(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('subdir', 'setting');
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/upload/image', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData });
|
||||
const json = await res.json();
|
||||
if (json.data?.url) updateBottomLink(index, 'content', json.data.url);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Input value={value || ''} onChange={(e) => updateBottomLink(index, 'content', e.target.value)} placeholder="图标URL 或 /storage/admin/..." />
|
||||
<button type="button" onClick={() => inputRef.current?.click()} disabled={uploading}
|
||||
className="shrink-0 flex items-center gap-1 px-2.5 py-2.5 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg border border-slate-300">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
</button>
|
||||
<input ref={inputRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-emerald-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900">系统设置</h1>
|
||||
<p className="text-slate-500 mt-1">管理网站基本信息和配置</p>
|
||||
</div>
|
||||
|
||||
{message.text && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* 网站信息 */}
|
||||
<Card>
|
||||
<CardHeader title="网站信息" icon={Globe} />
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">网站名称</label>
|
||||
<Input name="site_name" value={settings.site_name || ''} onChange={handleChange} placeholder="飞点健康" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">SEO 关键词</label>
|
||||
<Input name="site_keywords" value={settings.site_keywords || ''} onChange={handleChange} placeholder="多个关键词用逗号分隔" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">网站描述</label>
|
||||
<Textarea name="site_description" value={settings.site_description || ''} onChange={handleChange} placeholder="请输入网站描述" rows={3} />
|
||||
</div>
|
||||
<ImageUploadField
|
||||
label="网站 Logo"
|
||||
value={settings.site_logo}
|
||||
onChange={(v) => handleImgChange('site_logo', v)}
|
||||
accept="image/*"
|
||||
helpText="建议上传宽高比合适的图片(宽度>高度),支持 PNG/SVG"
|
||||
/>
|
||||
<ImageUploadField
|
||||
label="网站 Favicon"
|
||||
value={settings.site_favicon}
|
||||
onChange={(v) => handleImgChange('site_favicon', v)}
|
||||
accept=".ico,image/x-icon,image/png,image/svg+xml"
|
||||
helpText="建议使用 .ico 或 32×32 PNG,浏览器标签页图标"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 联系方式 */}
|
||||
<Card>
|
||||
<CardHeader title="联系方式" subtitle="Footer 联系我们区域展示" />
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">电话(可添加多个,建议不超过2个)</label>
|
||||
<div className="space-y-2">
|
||||
{phones.map((phone, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<Input value={phone} onChange={(e) => updatePhone(i, e.target.value)} placeholder="400-888-8888" />
|
||||
<button type="button" onClick={() => removePhone(i)}
|
||||
className="p-2 text-rose-500 hover:bg-rose-50 rounded-lg shrink-0 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={addPhone}
|
||||
className="mt-2 flex items-center gap-1 text-sm text-emerald-600 hover:text-emerald-700 transition-colors">
|
||||
<Plus className="w-4 h-4" />添加电话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">邮箱(可添加多个,建议不超过2个)</label>
|
||||
<div className="space-y-2">
|
||||
{emails.map((email, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<Input value={email} onChange={(e) => updateEmail(i, e.target.value)} placeholder="contact@example.com" />
|
||||
<button type="button" onClick={() => removeEmail(i)}
|
||||
className="p-2 text-rose-500 hover:bg-rose-50 rounded-lg shrink-0 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={addEmail}
|
||||
className="mt-2 flex items-center gap-1 text-sm text-emerald-600 hover:text-emerald-700 transition-colors">
|
||||
<Plus className="w-4 h-4" />添加邮箱
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">地址</label>
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="w-4 h-4 text-slate-400 mt-2.5 shrink-0" />
|
||||
<Textarea name="contact_address" value={settings.contact_address || ''} onChange={handleChange} placeholder="请输入地址" rows={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 社交媒体链接 */}
|
||||
<Card>
|
||||
<CardHeader title="社交媒体链接" subtitle="Footer 品牌区展示" />
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{socialLinks.map((link, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<select
|
||||
value={link.icon}
|
||||
onChange={(e) => updateSocialLink(i, 'icon', e.target.value)}
|
||||
className="w-32 shrink-0 px-2.5 py-2.5 bg-white border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500"
|
||||
>
|
||||
{SOCIAL_ICON_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="w-28 shrink-0">
|
||||
<Input value={link.name} onChange={(e) => updateSocialLink(i, 'name', e.target.value)} placeholder="名称" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input value={link.url} onChange={(e) => updateSocialLink(i, 'url', e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
<button type="button" onClick={() => removeSocialLink(i)}
|
||||
className="p-2 text-rose-500 hover:bg-rose-50 rounded-lg shrink-0 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addSocialLink}
|
||||
className="flex items-center gap-1 text-sm text-emerald-600 hover:text-emerald-700 transition-colors mt-1">
|
||||
<Plus className="w-4 h-4" />添加社交链接
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 友情链接 */}
|
||||
<Card>
|
||||
<CardHeader title="友情链接" subtitle="Footer 友情链接区域展示,点击在新标签页打开" />
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{friendLinks.map((link, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-36 shrink-0">
|
||||
<Input value={link.name} onChange={(e) => updateFriendLink(i, 'name', e.target.value)} placeholder="网站名称" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input value={link.url} onChange={(e) => updateFriendLink(i, 'url', e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
<button type="button" onClick={() => removeFriendLink(i)}
|
||||
className="p-2 text-rose-500 hover:bg-rose-50 rounded-lg shrink-0 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addFriendLink}
|
||||
className="flex items-center gap-1 text-sm text-emerald-600 hover:text-emerald-700 transition-colors mt-1">
|
||||
<Plus className="w-4 h-4" />添加友情链接
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 版权与底部资质信息 */}
|
||||
<Card>
|
||||
<CardHeader title="版权与底部资质信息" subtitle="访客端 Footer 最底部居中区域" />
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">版权文字</label>
|
||||
<Textarea name="copyright" value={settings.copyright || ''} onChange={handleChange}
|
||||
placeholder="Copyright © 深圳市飞点健康管理有限公司 All Rights Reserved." rows={2} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">隐私政策 URL</label>
|
||||
<Input name="privacy_url" value={settings.privacy_url || ''} onChange={handleChange} placeholder="/news/privacy-policy" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">服务条款 URL</label>
|
||||
<Input name="terms_url" value={settings.terms_url || ''} onChange={handleChange} placeholder="/news/terms-of-service" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">网站地图 URL</label>
|
||||
<Input name="sitemap_url" value={settings.sitemap_url || ''} onChange={handleChange} placeholder="/news/sitemap" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
ICP / 许可证 / 工商网监等资质(可添加多项,支持文字和图标图片)
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{bottomLinks.map((item, i) => (
|
||||
<div key={i} className="border border-slate-200 rounded-lg p-3 space-y-2.5 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={(e) => updateBottomLink(i, 'label', e.target.value)}
|
||||
placeholder="描述,如:ICP备案号"
|
||||
/>
|
||||
<Input
|
||||
value={item.url || ''}
|
||||
onChange={(e) => updateBottomLink(i, 'url', e.target.value)}
|
||||
placeholder="点击跳转链接(可选)"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={() => removeBottomLink(i)}
|
||||
className="p-2 text-rose-500 hover:bg-rose-50 rounded-lg shrink-0 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg overflow-hidden border border-slate-300 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateBottomLink(i, 'type', 'text')}
|
||||
className={`flex items-center gap-1 px-3 py-2 text-xs transition-colors ${item.type === 'text' ? 'bg-emerald-600 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<LinkIcon className="w-3 h-3" />文字
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateBottomLink(i, 'type', 'image')}
|
||||
className={`flex items-center gap-1 px-3 py-2 text-xs transition-colors ${item.type === 'image' ? 'bg-emerald-600 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<ImageIcon className="w-3 h-3" />图标
|
||||
</button>
|
||||
</div>
|
||||
{item.type === 'text' ? (
|
||||
<Input
|
||||
value={item.content || ''}
|
||||
onChange={(e) => updateBottomLink(i, 'content', e.target.value)}
|
||||
placeholder="显示文字,如:粤ICP备16014833号"
|
||||
className="flex-1"
|
||||
/>
|
||||
) : (
|
||||
<BottomLinkImageUpload index={i} value={item.content} />
|
||||
)}
|
||||
{item.type === 'image' && item.content && (
|
||||
<img src={item.content} alt="preview" className="w-8 h-8 object-contain rounded shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={addBottomLink}
|
||||
className="mt-3 flex items-center gap-1 text-sm text-emerald-600 hover:text-emerald-700 transition-colors">
|
||||
<Plus className="w-4 h-4" />添加资质信息
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={saving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Save, Loader2, Pencil, Trash2, ChevronUp, ChevronDown, X, Upload, Image as ImageIcon, Eye, EyeOff } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '../../components/admin/Card';
|
||||
import { Button } from '../../components/admin/Button';
|
||||
import { Input } from '../../components/admin/Input';
|
||||
import { Badge } from '../../components/admin/Badge';
|
||||
import { modulesApi, uploadApi } from '../../api';
|
||||
|
||||
const BannerManage = () => {
|
||||
const [module, setModule] = useState(null);
|
||||
const [slides, setSlides] = useState([]);
|
||||
const [settings, setSettings] = useState({
|
||||
autoplay: true,
|
||||
interval: 5000,
|
||||
show_dots: true,
|
||||
show_arrows: true,
|
||||
height: '500px'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [editingSlide, setEditingSlide] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadModule();
|
||||
}, []);
|
||||
|
||||
const loadModule = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const modules = await modulesApi.getAll();
|
||||
const bannerModule = modules.find(m => m.type === 'banner');
|
||||
if (bannerModule) {
|
||||
setModule(bannerModule);
|
||||
const content = bannerModule.content || {};
|
||||
setSlides(content.slides || []);
|
||||
setSettings({
|
||||
autoplay: content.autoplay ?? true,
|
||||
interval: content.interval ?? 5000,
|
||||
show_dots: content.show_dots ?? true,
|
||||
show_arrows: content.show_arrows ?? true,
|
||||
height: content.height || '500px'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await modulesApi.update(module.id, {
|
||||
content: {
|
||||
slides,
|
||||
...settings
|
||||
}
|
||||
});
|
||||
setMessage({ type: 'success', text: '保存成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '保存失败: ' + err.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSlide = () => {
|
||||
setEditingSlide({
|
||||
id: Date.now(),
|
||||
title: '',
|
||||
subtitle: '',
|
||||
image: '',
|
||||
link: '',
|
||||
is_active: true,
|
||||
sort_order: slides.length
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEditSlide = (slide) => {
|
||||
setEditingSlide({ ...slide });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDeleteSlide = (id) => {
|
||||
if (!confirm('确定要删除这个轮播图吗?')) return;
|
||||
setSlides(slides.filter(s => s.id !== id));
|
||||
};
|
||||
|
||||
const handleSaveSlide = () => {
|
||||
if (!editingSlide.title || !editingSlide.image) {
|
||||
alert('标题和图片不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = slides.find(s => s.id === editingSlide.id);
|
||||
if (exists) {
|
||||
setSlides(slides.map(s => s.id === editingSlide.id ? editingSlide : s));
|
||||
} else {
|
||||
setSlides([...slides, editingSlide]);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingSlide(null);
|
||||
};
|
||||
|
||||
const moveSlide = (id, direction) => {
|
||||
const idx = slides.findIndex(s => s.id === id);
|
||||
if (direction === 'up' && idx > 0) {
|
||||
const newSlides = [...slides];
|
||||
[newSlides[idx], newSlides[idx - 1]] = [newSlides[idx - 1], newSlides[idx]];
|
||||
setSlides(newSlides);
|
||||
} else if (direction === 'down' && idx < slides.length - 1) {
|
||||
const newSlides = [...slides];
|
||||
[newSlides[idx], newSlides[idx + 1]] = [newSlides[idx + 1], newSlides[idx]];
|
||||
setSlides(newSlides);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const result = await uploadApi.upload(file);
|
||||
setEditingSlide({ ...editingSlide, image: result.url });
|
||||
} catch (err) {
|
||||
alert('上传失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSlideActive = (id) => {
|
||||
setSlides(slides.map(s =>
|
||||
s.id === id ? { ...s, is_active: !s.is_active } : s
|
||||
));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-emerald-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{message.text && (
|
||||
<div className={`p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<Card>
|
||||
<CardHeader title="轮播设置" />
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoplay"
|
||||
checked={settings.autoplay}
|
||||
onChange={(e) => setSettings({ ...settings, autoplay: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300"
|
||||
/>
|
||||
<label htmlFor="autoplay" className="text-sm text-slate-700">自动播放</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="show_dots"
|
||||
checked={settings.show_dots}
|
||||
onChange={(e) => setSettings({ ...settings, show_dots: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300"
|
||||
/>
|
||||
<label htmlFor="show_dots" className="text-sm text-slate-700">显示指示点</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="show_arrows"
|
||||
checked={settings.show_arrows}
|
||||
onChange={(e) => setSettings({ ...settings, show_arrows: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300"
|
||||
/>
|
||||
<label htmlFor="show_arrows" className="text-sm text-slate-700">显示箭头</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">高度</label>
|
||||
<Input
|
||||
value={settings.height}
|
||||
onChange={(e) => setSettings({ ...settings, height: e.target.value })}
|
||||
placeholder="如: 500px"
|
||||
className="w-48"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Slides List */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="轮播图片"
|
||||
action={
|
||||
<Button onClick={handleAddSlide}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加图片
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-slate-200">
|
||||
{slides.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<ImageIcon className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无轮播图片</p>
|
||||
</div>
|
||||
) : (
|
||||
slides.map((slide, idx) => (
|
||||
<div key={slide.id} className="flex items-center gap-4 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => moveSlide(slide.id, 'up')}
|
||||
disabled={idx === 0}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveSlide(slide.id, 'down')}
|
||||
disabled={idx === slides.length - 1}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{slide.image && (
|
||||
<img src={slide.image} alt="" className="w-20 h-12 object-cover rounded-lg" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900 truncate">{slide.title}</div>
|
||||
<div className="text-sm text-slate-500 truncate">{slide.subtitle}</div>
|
||||
</div>
|
||||
|
||||
<Badge variant={slide.is_active ? 'success' : 'default'}>
|
||||
{slide.is_active ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleSlideActive(slide.id)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg"
|
||||
>
|
||||
{slide.is_active ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditSlide(slide)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSlide(slide.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Form Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{editingSlide?.id && slides.find(s => s.id === editingSlide.id) ? '编辑' : '添加'}轮播图
|
||||
</h3>
|
||||
<button onClick={() => setShowForm(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">标题</label>
|
||||
<Input
|
||||
value={editingSlide?.title || ''}
|
||||
onChange={(e) => setEditingSlide({ ...editingSlide, title: e.target.value })}
|
||||
placeholder="输入标题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">副标题</label>
|
||||
<Input
|
||||
value={editingSlide?.subtitle || ''}
|
||||
onChange={(e) => setEditingSlide({ ...editingSlide, subtitle: e.target.value })}
|
||||
placeholder="输入副标题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">图片</label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
value={editingSlide?.image || ''}
|
||||
onChange={(e) => setEditingSlide({ ...editingSlide, image: e.target.value })}
|
||||
placeholder="图片 URL"
|
||||
className="flex-1"
|
||||
/>
|
||||
<label className="flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 rounded-lg cursor-pointer transition-colors">
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="text-sm">上传</span>
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
|
||||
</label>
|
||||
</div>
|
||||
{editingSlide?.image && (
|
||||
<img src={editingSlide.image} alt="" className="mt-3 w-full h-32 object-cover rounded-lg" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">链接</label>
|
||||
<Input
|
||||
value={editingSlide?.link || ''}
|
||||
onChange={(e) => setEditingSlide({ ...editingSlide, link: e.target.value })}
|
||||
placeholder="如: /about"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="slide_active"
|
||||
checked={editingSlide?.is_active ?? true}
|
||||
onChange={(e) => setEditingSlide({ ...editingSlide, is_active: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300"
|
||||
/>
|
||||
<label htmlFor="slide_active" className="text-sm text-slate-700">启用</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button>
|
||||
<Button onClick={handleSaveSlide}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存更改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerManage;
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Save, Loader2, Pencil, Trash2, ChevronUp, ChevronDown, X, Upload, Sparkles, Eye, EyeOff } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '../../components/admin/Card';
|
||||
import { Button } from '../../components/admin/Button';
|
||||
import { Input, Textarea } from '../../components/admin/Input';
|
||||
import { Badge } from '../../components/admin/Badge';
|
||||
import { modulesApi, uploadApi } from '../../api';
|
||||
|
||||
const FeaturesManage = () => {
|
||||
const [module, setModule] = useState(null);
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadModule();
|
||||
}, []);
|
||||
|
||||
const loadModule = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const modules = await modulesApi.getAll();
|
||||
const featuresModule = modules.find(m => m.type === 'features');
|
||||
if (featuresModule) {
|
||||
setModule(featuresModule);
|
||||
const content = featuresModule.content || {};
|
||||
setItems(content.items || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await modulesApi.update(module.id, {
|
||||
content: { items }
|
||||
});
|
||||
setMessage({ type: 'success', text: '保存成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '保存失败: ' + err.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
setEditingItem({
|
||||
id: Date.now(),
|
||||
title: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
image: '',
|
||||
link: '',
|
||||
is_active: true,
|
||||
sort_order: items.length
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEditItem = (item) => {
|
||||
setEditingItem({ ...item });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (id) => {
|
||||
if (!confirm('确定要删除这个特性吗?')) return;
|
||||
setItems(items.filter(i => i.id !== id));
|
||||
};
|
||||
|
||||
const handleSaveItem = () => {
|
||||
if (!editingItem.title) {
|
||||
alert('标题不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = items.find(i => i.id === editingItem.id);
|
||||
if (exists) {
|
||||
setItems(items.map(i => i.id === editingItem.id ? editingItem : i));
|
||||
} else {
|
||||
setItems([...items, editingItem]);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const moveItem = (id, direction) => {
|
||||
const idx = items.findIndex(i => i.id === id);
|
||||
if (direction === 'up' && idx > 0) {
|
||||
const newItems = [...items];
|
||||
[newItems[idx], newItems[idx - 1]] = [newItems[idx - 1], newItems[idx]];
|
||||
setItems(newItems);
|
||||
} else if (direction === 'down' && idx < items.length - 1) {
|
||||
const newItems = [...items];
|
||||
[newItems[idx], newItems[idx + 1]] = [newItems[idx + 1], newItems[idx]];
|
||||
setItems(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const result = await uploadApi.upload(file);
|
||||
setEditingItem({ ...editingItem, image: result.url });
|
||||
} catch (err) {
|
||||
alert('上传失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemActive = (id) => {
|
||||
setItems(items.map(i =>
|
||||
i.id === id ? { ...i, is_active: !i.is_active } : i
|
||||
));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-emerald-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{message.text && (
|
||||
<div className={`p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="特性列表"
|
||||
action={
|
||||
<Button onClick={handleAddItem}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加特性
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-slate-200">
|
||||
{items.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<Sparkles className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无特性项目</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map((item, idx) => (
|
||||
<div key={item.id} className="flex items-center gap-4 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => moveItem(item.id, 'up')}
|
||||
disabled={idx === 0}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveItem(item.id, 'down')}
|
||||
disabled={idx === items.length - 1}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{item.image && (
|
||||
<img src={item.image} alt="" className="w-12 h-12 object-cover rounded-lg" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900 truncate">{item.title}</div>
|
||||
<div className="text-sm text-slate-500 truncate">{item.description}</div>
|
||||
</div>
|
||||
|
||||
<Badge variant={item.is_active ? 'success' : 'default'}>
|
||||
{item.is_active ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleItemActive(item.id)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg"
|
||||
>
|
||||
{item.is_active ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditItem(item)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Form Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{editingItem?.id && items.find(i => i.id === editingItem.id) ? '编辑' : '添加'}特性
|
||||
</h3>
|
||||
<button onClick={() => setShowForm(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">标题</label>
|
||||
<Input
|
||||
value={editingItem?.title || ''}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, title: e.target.value })}
|
||||
placeholder="输入标题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">描述</label>
|
||||
<Textarea
|
||||
value={editingItem?.description || ''}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, description: e.target.value })}
|
||||
placeholder="输入描述"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">图标 (Lucide 名称)</label>
|
||||
<Input
|
||||
value={editingItem?.icon || ''}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, icon: e.target.value })}
|
||||
placeholder="如: Sparkles"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">图片</label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
value={editingItem?.image || ''}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, image: e.target.value })}
|
||||
placeholder="图片 URL"
|
||||
className="flex-1"
|
||||
/>
|
||||
<label className="flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 rounded-lg cursor-pointer transition-colors">
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="text-sm">上传</span>
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
|
||||
</label>
|
||||
</div>
|
||||
{editingItem?.image && (
|
||||
<img src={editingItem.image} alt="" className="mt-3 w-24 h-24 object-cover rounded-lg" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">链接</label>
|
||||
<Input
|
||||
value={editingItem?.link || ''}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, link: e.target.value })}
|
||||
placeholder="如: /features/detail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="item_active"
|
||||
checked={editingItem?.is_active ?? true}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, is_active: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 rounded border-slate-300"
|
||||
/>
|
||||
<label htmlFor="item_active" className="text-sm text-slate-700">启用</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button>
|
||||
<Button onClick={handleSaveItem}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存更改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesManage;
|
||||
@@ -0,0 +1,473 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus, Trash2, ChevronUp, ChevronDown, X, Search, Loader2, Newspaper, Users, Save
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../components/admin/Button';
|
||||
import { Card, CardHeader, CardContent } from '../../components/admin/Card';
|
||||
import { Input } from '../../components/admin/Input';
|
||||
import { Badge } from '../../components/admin/Badge';
|
||||
import api, { homeExpertsApi } from '../../api';
|
||||
|
||||
const HomeExpertsTab = ({ module, onSave }) => {
|
||||
const [experts, setExperts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [moduleForm, setModuleForm] = useState({ title: '', subtitle: '', is_enabled: true, view_more_link: '' });
|
||||
|
||||
// 弹窗状态
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [availablePosts, setAvailablePosts] = useState([]);
|
||||
const [selectedPostId, setSelectedPostId] = useState(null);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
const [pickerPage, setPickerPage] = useState(1);
|
||||
const [pickerSize] = useState(10);
|
||||
const [pickerTotal, setPickerTotal] = useState(0);
|
||||
const [pickerCategory, setPickerCategory] = useState('');
|
||||
const [pickerKeyword, setPickerKeyword] = useState('');
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (module) {
|
||||
setModuleForm({
|
||||
title: module.title || '',
|
||||
subtitle: module.subtitle || '',
|
||||
is_enabled: module.is_enabled ?? true,
|
||||
view_more_link: module.content?.view_more_link || '',
|
||||
});
|
||||
}
|
||||
}, [module]);
|
||||
|
||||
useEffect(() => {
|
||||
loadExperts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
// 从新栏目系统拉取“新闻资讯”下的二级子栏目(作为筛选分类)
|
||||
const res = await api.get('/api/admin/columns');
|
||||
const allColumns = res.items || [];
|
||||
const topNews = allColumns.find(c => c.slug === 'xin-wen-zi-xun' && c.parent_id == null);
|
||||
const subCategories = topNews
|
||||
? allColumns.filter(c => c.parent_id === topNews.id)
|
||||
: [];
|
||||
setCategories(subCategories);
|
||||
} catch (err) {
|
||||
console.error('加载栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExperts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await homeExpertsApi.getAll();
|
||||
setExperts(data || []);
|
||||
} catch (err) {
|
||||
console.error('获取专家团队失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvailablePosts = async () => {
|
||||
setPickerLoading(true);
|
||||
try {
|
||||
const params = { page: pickerPage, size: pickerSize, status: 'published' };
|
||||
if (pickerCategory) params.column_id = pickerCategory;
|
||||
if (pickerKeyword) params.keyword = pickerKeyword;
|
||||
const res = await api.get('/api/admin/articles', { params });
|
||||
const existingIds = new Set(experts.map(e => e.post_id));
|
||||
const filtered = (res.items || []).filter(p => !existingIds.has(p.id));
|
||||
setAvailablePosts(filtered);
|
||||
setPickerTotal(res.total || 0);
|
||||
} catch (err) {
|
||||
console.error('加载文章失败:', err);
|
||||
} finally {
|
||||
setPickerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showPicker) {
|
||||
loadAvailablePosts();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showPicker, pickerPage, pickerCategory]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPickerPage(1);
|
||||
loadAvailablePosts();
|
||||
};
|
||||
|
||||
const openPicker = () => {
|
||||
setShowPicker(true);
|
||||
setSelectedPostId(null);
|
||||
setPickerPage(1);
|
||||
setPickerKeyword('');
|
||||
setPickerCategory('');
|
||||
};
|
||||
|
||||
const handleSelectPost = async () => {
|
||||
const post = availablePosts.find(p => p.id === selectedPostId);
|
||||
if (!post) return;
|
||||
try {
|
||||
await homeExpertsApi.create({
|
||||
post_id: post.id,
|
||||
sort_order: experts.length,
|
||||
is_enabled: true,
|
||||
});
|
||||
await loadExperts();
|
||||
setShowPicker(false);
|
||||
setSelectedPostId(null);
|
||||
setMessage({ type: 'success', text: '添加成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '添加失败: ' + err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveModule = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
title: moduleForm.title,
|
||||
subtitle: moduleForm.subtitle,
|
||||
is_enabled: moduleForm.is_enabled,
|
||||
content: { view_more_link: moduleForm.view_more_link },
|
||||
});
|
||||
setMessage({ type: 'success', text: '模块配置保存成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '保存失败: ' + err.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('确定要从专家团队中移除此专家吗?')) return;
|
||||
try {
|
||||
await homeExpertsApi.delete(id);
|
||||
await loadExperts();
|
||||
setMessage({ type: 'success', text: '移除成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '移除失败: ' + err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (expert) => {
|
||||
try {
|
||||
await homeExpertsApi.update(expert.id, { is_enabled: !expert.is_enabled });
|
||||
await loadExperts();
|
||||
} catch (err) {
|
||||
console.error('切换状态失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const moveExpert = (idx, direction) => {
|
||||
const newExperts = [...experts];
|
||||
if (direction === 'up' && idx > 0) {
|
||||
[newExperts[idx], newExperts[idx - 1]] = [newExperts[idx - 1], newExperts[idx]];
|
||||
} else if (direction === 'down' && idx < newExperts.length - 1) {
|
||||
[newExperts[idx], newExperts[idx + 1]] = [newExperts[idx + 1], newExperts[idx]];
|
||||
}
|
||||
// Update sort_order based on new positions
|
||||
newExperts.forEach((e, i) => { e.sort_order = i; });
|
||||
setExperts(newExperts);
|
||||
// Batch update
|
||||
Promise.all(newExperts.map(e => homeExpertsApi.update(e.id, { sort_order: e.sort_order })))
|
||||
.catch(err => console.error('更新排序失败:', err));
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(pickerTotal / pickerSize);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const resolveImage = (path) => {
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{message.text && (
|
||||
<div className={`p-4 rounded-lg ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-rose-50 text-rose-700 border border-rose-200'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模块配置 */}
|
||||
<Card>
|
||||
<CardHeader title="模块配置" />
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">模块标题</label>
|
||||
<Input value={moduleForm.title} onChange={(e) => setModuleForm({ ...moduleForm, title: e.target.value })} placeholder="输入模块标题" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">副标题</label>
|
||||
<Input value={moduleForm.subtitle} onChange={(e) => setModuleForm({ ...moduleForm, subtitle: e.target.value })} placeholder="输入副标题" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">查看更多链接</label>
|
||||
<Input value={moduleForm.view_more_link} onChange={(e) => setModuleForm({ ...moduleForm, view_more_link: e.target.value })} placeholder="如:/news?category=experts" />
|
||||
<p className="text-xs text-slate-500 mt-1">留空则不显示"查看更多"按钮</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<input type="checkbox" id="exp_is_enabled" checked={moduleForm.is_enabled}
|
||||
onChange={(e) => setModuleForm({ ...moduleForm, is_enabled: e.target.checked })} className="w-4 h-4 text-emerald-600 rounded border-slate-300" />
|
||||
<label htmlFor="exp_is_enabled" className="text-sm text-slate-700">启用模块</label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={handleSaveModule} loading={saving}><Save className="w-4 h-4 mr-2" />保存模块配置</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 专家列表 */}
|
||||
<Card>
|
||||
<CardHeader title="专家团队列表" />
|
||||
<CardContent>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={openPicker}><Plus className="w-4 h-4 mr-2" />添加专家</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 text-emerald-600 animate-spin" /></div>
|
||||
) : experts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">暂无专家,点击上方按钮从资讯中选择</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{experts.map((item, idx) => (
|
||||
<div key={item.id} className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100">
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-slate-200 rounded-lg text-sm font-semibold text-slate-600 flex-shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||
<button onClick={() => moveExpert(idx, 'up')} disabled={idx === 0}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded">
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => moveExpert(idx, 'down')} disabled={idx === experts.length - 1}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded">
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{item.post?.cover_image ? (
|
||||
<img src={resolveImage(item.post.cover_image)} alt="" className="w-14 h-14 rounded-full object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-5 h-5 text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900 truncate">{item.post?.title || '-'}</div>
|
||||
<div className="text-sm text-slate-500 truncate">{item.post?.summary || '无摘要'}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<button onClick={() => handleToggle(item)} className="cursor-pointer">
|
||||
{item.is_enabled ? <Badge variant="success">启用</Badge> : <Badge variant="secondary">禁用</Badge>}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(item.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 文章选择弹窗 */}
|
||||
{showPicker && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={() => setShowPicker(false)} />
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-4xl bg-white rounded-xl shadow-xl">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900">选择资讯作为专家</h3>
|
||||
<button onClick={() => setShowPicker(false)}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
<select
|
||||
value={pickerCategory}
|
||||
onChange={(e) => { setPickerCategory(e.target.value); setPickerPage(1); }}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">所有栏目</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2 flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={pickerKeyword}
|
||||
onChange={(e) => setPickerKeyword(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="搜索标题..."
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleSearch}>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文章列表 */}
|
||||
{pickerLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : availablePosts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Newspaper className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无文章</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-4 text-xs font-medium text-slate-500 w-10"></th>
|
||||
<th className="text-left py-2 px-4 text-xs font-medium text-slate-500">标题</th>
|
||||
<th className="text-left py-2 px-4 text-xs font-medium text-slate-500">栏目</th>
|
||||
<th className="text-left py-2 px-4 text-xs font-medium text-slate-500">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{availablePosts.map((post) => (
|
||||
<tr
|
||||
key={post.id}
|
||||
className={`hover:bg-slate-50 cursor-pointer ${selectedPostId === post.id ? 'bg-emerald-50' : ''}`}
|
||||
onClick={() => setSelectedPostId(post.id)}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedPostId === post.id}
|
||||
onChange={() => setSelectedPostId(post.id)}
|
||||
className="w-4 h-4 text-emerald-600"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{post.cover_image && (
|
||||
<img
|
||||
src={resolveImage(post.cover_image)}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-slate-900">{post.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{post.column?.name || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{formatDate(post.updated_at || post.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 弹窗分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
共 {pickerTotal} 条,第 {pickerPage}/{totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerPage(p => Math.max(1, p - 1))}
|
||||
disabled={pickerPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-slate-500">跳至</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPickerPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-14 px-2 py-1 border border-slate-300 rounded text-center text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPickerPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}}
|
||||
disabled={!jumpPage}
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={pickerPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200">
|
||||
<Button variant="outline" onClick={() => setShowPicker(false)}>取消</Button>
|
||||
<Button onClick={handleSelectPost} disabled={!selectedPostId}>确认选择</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeExpertsTab;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,408 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus, Trash2, ChevronUp, ChevronDown, X, Search, Loader2, Newspaper
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../components/admin/Button';
|
||||
import { Card, CardHeader, CardContent } from '../../components/admin/Card';
|
||||
import api from '../../api';
|
||||
|
||||
const HomeNewsTab = ({ module, onSave }) => {
|
||||
const [posts, setPosts] = useState([null, null, null, null]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 弹窗状态
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState(null);
|
||||
const [availablePosts, setAvailablePosts] = useState([]);
|
||||
const [selectedPostId, setSelectedPostId] = useState(null);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
const [pickerPage, setPickerPage] = useState(1);
|
||||
const [pickerSize] = useState(10);
|
||||
const [pickerTotal, setPickerTotal] = useState(0);
|
||||
const [pickerCategory, setPickerCategory] = useState('');
|
||||
const [pickerKeyword, setPickerKeyword] = useState('');
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
if (module?.content) {
|
||||
const saved = module.content.posts || [];
|
||||
const padded = [...saved];
|
||||
while (padded.length < 4) padded.push(null);
|
||||
setPosts(padded.slice(0, 4));
|
||||
}
|
||||
}, [module]);
|
||||
|
||||
// 加载分类
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
// 从新栏目体系拉取“新闻资讯”下的二级子栏目作为筛选分类
|
||||
const res = await api.get('/api/admin/columns');
|
||||
const allColumns = res.items || [];
|
||||
const topNews = allColumns.find(c => c.slug === 'xin-wen-zi-xun' && c.parent_id == null);
|
||||
const subCategories = topNews
|
||||
? allColumns.filter(c => c.parent_id === topNews.id)
|
||||
: [];
|
||||
setCategories(subCategories);
|
||||
} catch (err) {
|
||||
console.error('加载栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvailablePosts = async () => {
|
||||
setPickerLoading(true);
|
||||
try {
|
||||
const params = { page: pickerPage, size: pickerSize, status: 'published' };
|
||||
if (pickerCategory) params.column_id = pickerCategory;
|
||||
if (pickerKeyword) params.keyword = pickerKeyword;
|
||||
const res = await api.get('/api/admin/articles', { params });
|
||||
setAvailablePosts(res.items || []);
|
||||
setPickerTotal(res.total || 0);
|
||||
} catch (err) {
|
||||
console.error('加载文章失败:', err);
|
||||
} finally {
|
||||
setPickerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 弹窗打开时加载文章
|
||||
useEffect(() => {
|
||||
if (showPicker) {
|
||||
loadAvailablePosts();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showPicker, pickerPage, pickerCategory]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPickerPage(1);
|
||||
loadAvailablePosts();
|
||||
};
|
||||
|
||||
const openPicker = (idx) => {
|
||||
setEditingIndex(idx);
|
||||
setSelectedPostId(null);
|
||||
setPickerPage(1);
|
||||
setPickerKeyword('');
|
||||
setPickerCategory('');
|
||||
setShowPicker(true);
|
||||
};
|
||||
|
||||
const handleSelectPost = () => {
|
||||
const post = availablePosts.find(p => p.id === selectedPostId);
|
||||
if (!post) return;
|
||||
|
||||
const newPosts = [...posts];
|
||||
newPosts[editingIndex] = {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
summary: post.summary,
|
||||
cover_image: post.cover_image,
|
||||
slug: post.slug,
|
||||
category_name: post.column?.name || '',
|
||||
created_at: post.created_at,
|
||||
updated_at: post.updated_at
|
||||
};
|
||||
setPosts(newPosts);
|
||||
setShowPicker(false);
|
||||
setEditingIndex(null);
|
||||
setSelectedPostId(null);
|
||||
};
|
||||
|
||||
const handleClear = (idx) => {
|
||||
const newPosts = [...posts];
|
||||
newPosts[idx] = null;
|
||||
setPosts(newPosts);
|
||||
};
|
||||
|
||||
const movePost = (idx, direction) => {
|
||||
const newPosts = [...posts];
|
||||
if (direction === 'up' && idx > 0) {
|
||||
[newPosts[idx], newPosts[idx - 1]] = [newPosts[idx - 1], newPosts[idx]];
|
||||
} else if (direction === 'down' && idx < 3) {
|
||||
[newPosts[idx], newPosts[idx + 1]] = [newPosts[idx + 1], newPosts[idx]];
|
||||
}
|
||||
setPosts(newPosts);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ content: { posts } });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(pickerTotal / pickerSize);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const resolveImage = (path) => {
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader title="新闻动态配置" />
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
最多配置4条新闻,将展示在官网首页。点击"选择"从资讯库中挑选文章。
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{posts.map((post, idx) => (
|
||||
<div key={idx} className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100">
|
||||
{/* 序号 */}
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-slate-200 rounded-lg text-sm font-semibold text-slate-600 flex-shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
{/* 上下移动 */}
|
||||
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||
<button onClick={() => movePost(idx, 'up')} disabled={idx === 0}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded">
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => movePost(idx, 'down')} disabled={idx === 3}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded">
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{post ? (
|
||||
<>
|
||||
{/* 已配置的文章预览 */}
|
||||
<div className="w-16 h-12 flex-shrink-0 rounded-lg overflow-hidden bg-slate-200">
|
||||
<img
|
||||
src={resolveImage(post.cover_image)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900 truncate">{post.title}</div>
|
||||
<div className="text-sm text-slate-500 truncate">{post.summary || '无摘要'}</div>
|
||||
<div className="text-xs text-emerald-600 mt-1">{post.category_name || ''}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => openPicker(idx)}>替换</Button>
|
||||
<button onClick={() => handleClear(idx)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 空位 */}
|
||||
<div className="flex-1 text-sm text-slate-400">位置 {idx + 1} · 未配置</div>
|
||||
<Button size="sm" onClick={() => openPicker(idx)}>
|
||||
<Plus className="w-4 h-4 mr-1" />选择文章
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
保存更改
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 文章选择弹窗 */}
|
||||
{showPicker && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={() => setShowPicker(false)} />
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-4xl bg-white rounded-xl shadow-xl">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900">选择资讯文章</h3>
|
||||
<button onClick={() => setShowPicker(false)}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
<select
|
||||
value={pickerCategory}
|
||||
onChange={(e) => { setPickerCategory(e.target.value); setPickerPage(1); }}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">所有栏目</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2 flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={pickerKeyword}
|
||||
onChange={(e) => setPickerKeyword(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="搜索标题..."
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleSearch}>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文章列表 */}
|
||||
{pickerLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : availablePosts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Newspaper className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无文章</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-4 text-xs font-medium text-slate-500 w-10"></th>
|
||||
<th className="text-left py-2 px-4 text-xs font-medium text-slate-500">标题</th>
|
||||
<th className="text-left py-2 px-4 text-xs font-medium text-slate-500">栏目</th>
|
||||
<th className="text-left py-2 px-4 text-xs font-medium text-slate-500">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{availablePosts.map((post) => (
|
||||
<tr
|
||||
key={post.id}
|
||||
className={`hover:bg-slate-50 cursor-pointer ${selectedPostId === post.id ? 'bg-emerald-50' : ''}`}
|
||||
onClick={() => setSelectedPostId(post.id)}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedPostId === post.id}
|
||||
onChange={() => setSelectedPostId(post.id)}
|
||||
className="w-4 h-4 text-emerald-600"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{post.cover_image && (
|
||||
<img
|
||||
src={resolveImage(post.cover_image)}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-slate-900">{post.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{post.column?.name || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{formatDate(post.updated_at || post.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 弹窗分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
共 {pickerTotal} 条,第 {pickerPage}/{totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerPage(p => Math.max(1, p - 1))}
|
||||
disabled={pickerPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-slate-500">跳至</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPickerPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-14 px-2 py-1 border border-slate-300 rounded text-center text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPickerPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}}
|
||||
disabled={!jumpPage}
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={pickerPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200">
|
||||
<Button variant="outline" onClick={() => setShowPicker(false)}>取消</Button>
|
||||
<Button onClick={handleSelectPost} disabled={!selectedPostId}>确认选择</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeNewsTab;
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus, Trash2, ChevronUp, ChevronDown, X, Loader2, Upload, Save
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../components/admin/Button';
|
||||
import { Card, CardHeader, CardContent } from '../../components/admin/Card';
|
||||
import { Input, Textarea } from '../../components/admin/Input';
|
||||
import { Badge } from '../../components/admin/Badge';
|
||||
import { homePartnersApi, uploadApi } from '../../api';
|
||||
|
||||
const HomePartnersTab = ({ module, onSave }) => {
|
||||
const [partners, setPartners] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [moduleForm, setModuleForm] = useState({ title: '', subtitle: '', is_enabled: true });
|
||||
|
||||
// 弹窗状态
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
logo_url: '',
|
||||
description: '',
|
||||
website_url: '',
|
||||
is_enabled: true,
|
||||
sort_order: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (module) {
|
||||
setModuleForm({
|
||||
title: module.title || '',
|
||||
subtitle: module.subtitle || '',
|
||||
is_enabled: module.is_enabled ?? true,
|
||||
});
|
||||
}
|
||||
}, [module]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPartners();
|
||||
}, []);
|
||||
|
||||
const loadPartners = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await homePartnersApi.getAll();
|
||||
setPartners(data || []);
|
||||
} catch (err) {
|
||||
console.error('获取合作伙伴失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({
|
||||
name: '',
|
||||
logo_url: '',
|
||||
description: '',
|
||||
website_url: '',
|
||||
is_enabled: true,
|
||||
sort_order: partners.length,
|
||||
});
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
resetForm();
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (item) => {
|
||||
setForm({
|
||||
name: item.name || '',
|
||||
logo_url: item.logo_url || '',
|
||||
description: item.description || '',
|
||||
website_url: item.website_url || '',
|
||||
is_enabled: item.is_enabled ?? true,
|
||||
sort_order: item.sort_order || 0,
|
||||
});
|
||||
setEditingId(item.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('确定要删除这个合作伙伴吗?')) return;
|
||||
try {
|
||||
await homePartnersApi.delete(id);
|
||||
await loadPartners();
|
||||
setMessage({ type: 'success', text: '删除成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '删除失败: ' + err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
setMessage({ type: 'error', text: '伙伴名称不能为空' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
description: form.description?.trim() || null,
|
||||
website_url: form.website_url?.trim() || null,
|
||||
logo_url: form.logo_url?.trim() || null,
|
||||
};
|
||||
if (editingId) {
|
||||
await homePartnersApi.update(editingId, payload);
|
||||
} else {
|
||||
await homePartnersApi.create(payload);
|
||||
}
|
||||
await loadPartners();
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
setMessage({ type: 'success', text: editingId ? '更新成功' : '添加成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '保存失败: ' + err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (item) => {
|
||||
try {
|
||||
await homePartnersApi.update(item.id, { is_enabled: !item.is_enabled });
|
||||
await loadPartners();
|
||||
} catch (err) {
|
||||
console.error('切换状态失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const movePartner = async (idx, direction) => {
|
||||
const newPartners = [...partners];
|
||||
if (direction === 'up' && idx > 0) {
|
||||
[newPartners[idx], newPartners[idx - 1]] = [newPartners[idx - 1], newPartners[idx]];
|
||||
} else if (direction === 'down' && idx < newPartners.length - 1) {
|
||||
[newPartners[idx], newPartners[idx + 1]] = [newPartners[idx + 1], newPartners[idx]];
|
||||
}
|
||||
// Update sort_order based on new positions
|
||||
const updates = newPartners.map((p, i) => ({
|
||||
id: p.id,
|
||||
sort_order: i,
|
||||
}));
|
||||
setPartners(newPartners);
|
||||
try {
|
||||
await Promise.all(updates.map(u => homePartnersApi.update(u.id, { sort_order: u.sort_order })));
|
||||
} catch (err) {
|
||||
console.error('更新排序失败:', err);
|
||||
loadPartners();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveModule = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
title: moduleForm.title,
|
||||
subtitle: moduleForm.subtitle,
|
||||
is_enabled: moduleForm.is_enabled,
|
||||
});
|
||||
setMessage({ type: 'success', text: '模块配置保存成功' });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '保存失败: ' + err.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadLogo = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const res = await uploadApi.uploadImage(file, 'partners');
|
||||
const url = res.data?.url || res.url;
|
||||
if (url) {
|
||||
setForm({ ...form, logo_url: url });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('上传失败:', err);
|
||||
setMessage({ type: 'error', text: '上传失败' });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveImage = (path) => {
|
||||
if (!path) return null;
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{message.text && (
|
||||
<div className={`p-4 rounded-lg ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-rose-50 text-rose-700 border border-rose-200'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模块配置 */}
|
||||
<Card>
|
||||
<CardHeader title="模块配置" />
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">模块标题</label>
|
||||
<Input value={moduleForm.title} onChange={(e) => setModuleForm({ ...moduleForm, title: e.target.value })} placeholder="输入模块标题" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">副标题</label>
|
||||
<Input value={moduleForm.subtitle} onChange={(e) => setModuleForm({ ...moduleForm, subtitle: e.target.value })} placeholder="输入副标题" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<input type="checkbox" id="partner_is_enabled" checked={moduleForm.is_enabled}
|
||||
onChange={(e) => setModuleForm({ ...moduleForm, is_enabled: e.target.checked })} className="w-4 h-4 text-emerald-600 rounded border-slate-300" />
|
||||
<label htmlFor="partner_is_enabled" className="text-sm text-slate-700">启用模块</label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={handleSaveModule} loading={saving}><Save className="w-4 h-4 mr-2" />保存模块配置</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 合作伙伴列表 */}
|
||||
<Card>
|
||||
<CardHeader title="合作伙伴列表" action={<Button onClick={handleAdd}><Plus className="w-4 h-4 mr-2" />添加合作伙伴</Button>} />
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 text-emerald-600 animate-spin" /></div>
|
||||
) : partners.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">暂无合作伙伴,点击上方按钮添加</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{partners.map((item, idx) => (
|
||||
<div key={item.id} className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100">
|
||||
{/* 序号 */}
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-slate-200 rounded-lg text-sm font-semibold text-slate-600 flex-shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
{/* 上下移动 */}
|
||||
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||
<button onClick={() => movePartner(idx, 'up')} disabled={idx === 0}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded">
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => movePartner(idx, 'down')} disabled={idx === partners.length - 1}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded">
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Logo */}
|
||||
{resolveImage(item.logo_url) ? (
|
||||
<img src={resolveImage(item.logo_url)} alt={item.name} className="w-14 h-10 object-contain rounded-lg bg-white border border-slate-200 flex-shrink-0 p-1" />
|
||||
) : (
|
||||
<div className="w-14 h-10 rounded-lg bg-white border border-slate-200 flex items-center justify-center flex-shrink-0 text-slate-300 text-xs">Logo</div>
|
||||
)}
|
||||
{/* 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900 truncate">{item.name}</div>
|
||||
<div className="text-sm text-slate-500 break-words whitespace-normal leading-relaxed">{item.description || '无简介'}</div>
|
||||
</div>
|
||||
{/* 操作 */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<button onClick={() => handleToggle(item)} className="cursor-pointer">
|
||||
{item.is_enabled ? <Badge variant="success">启用</Badge> : <Badge variant="secondary">禁用</Badge>}
|
||||
</button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(item)}>编辑</Button>
|
||||
<button onClick={() => handleDelete(item.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 添加/编辑弹窗 */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{editingId ? '编辑合作伙伴' : '添加合作伙伴'}</h3>
|
||||
<button onClick={() => { setShowForm(false); resetForm(); }} className="text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">伙伴名称 <span className="text-rose-500">*</span></label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:GSMA" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Logo图片</label>
|
||||
<p className="text-xs text-slate-400 mb-1.5">建议尺寸 92×36px</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{resolveImage(form.logo_url) && (
|
||||
<img src={resolveImage(form.logo_url)} alt="" className="w-16 h-12 object-contain rounded-lg border border-slate-200 bg-white p-1" />
|
||||
)}
|
||||
<Input value={form.logo_url || ''} onChange={(e) => setForm({ ...form, logo_url: e.target.value })} placeholder="图片URL" className="flex-1" />
|
||||
<label className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg border border-slate-300 cursor-pointer transition-colors">
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>上传</span>
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleUploadLogo} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">简介</label>
|
||||
<Textarea value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="输入简介(可选)" rows={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">网站链接</label>
|
||||
<Input value={form.website_url || ''} onChange={(e) => setForm({ ...form, website_url: e.target.value })} placeholder="https://..." />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="p_is_enabled" checked={form.is_enabled}
|
||||
onChange={(e) => setForm({ ...form, is_enabled: e.target.checked })} className="w-4 h-4 text-emerald-600 rounded border-slate-300" />
|
||||
<label htmlFor="p_is_enabled" className="text-sm text-slate-700">启用</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => { setShowForm(false); resetForm(); }}>取消</Button>
|
||||
<Button onClick={handleSubmit}>{editingId ? '保存更改' : '添加'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePartnersTab;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import '@uiw/react-md-editor/markdown-editor.css';
|
||||
import { articlesApi, columnsApi } from '../../../api';
|
||||
|
||||
export default function ArticleCrawl() {
|
||||
const navigate = useNavigate();
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [selectedColumn, setSelectedColumn] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchColumns();
|
||||
}, []);
|
||||
|
||||
const fetchColumns = async () => {
|
||||
try {
|
||||
const res = await columnsApi.getTree();
|
||||
const flatColumns = [];
|
||||
const flatten = (items, level = 0) => {
|
||||
for (const item of items) {
|
||||
flatColumns.push({ ...item, level });
|
||||
if (item.children?.length > 0) flatten(item.children, level + 1);
|
||||
}
|
||||
};
|
||||
flatten(res.items || []);
|
||||
// 只展示可关联内容的栏目
|
||||
const available = flatColumns.filter(c => {
|
||||
if (c.level === 0 && flatColumns.some(cc => cc.parent_id === c.id)) return false;
|
||||
return true;
|
||||
});
|
||||
setColumns(available);
|
||||
if (available.length > 0) {
|
||||
setSelectedColumn(available[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCrawl = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('请输入URL');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const api = (await import('../../../api')).default;
|
||||
const data = await api.post('/api/crawl', { url: url.trim(), use_playwright: false });
|
||||
if (!data.success) {
|
||||
setError(data.error || '采集失败');
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError('网络请求失败: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!result || !selectedColumn) {
|
||||
setError('请选择栏目');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
let coverImage = '';
|
||||
if (result.images && result.images.length > 0) {
|
||||
coverImage = result.images[0].local_path || result.images[0].thumb_path || '';
|
||||
}
|
||||
const slug = result.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 200) || '';
|
||||
|
||||
const data = {
|
||||
title: result.title,
|
||||
slug: slug,
|
||||
content: result.content,
|
||||
summary: result.summary || result.content.slice(0, 200),
|
||||
column_id: parseInt(selectedColumn),
|
||||
status: 'draft',
|
||||
cover_image: coverImage,
|
||||
};
|
||||
|
||||
const article = await articlesApi.create(data);
|
||||
navigate(`/admin/articles/${article.id}/edit`);
|
||||
} catch (err) {
|
||||
setError('保存失败: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/admin/articles');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-slate-800">采集内容</h1>
|
||||
<button onClick={handleCancel}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-800 transition-colors">
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">文章链接 URL</label>
|
||||
<div className="flex gap-3">
|
||||
<input type="url" value={url} onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/article"
|
||||
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
disabled={loading} />
|
||||
<button onClick={handleCrawl} disabled={loading}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
采集中...
|
||||
</span>
|
||||
) : '开始采集'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
支持微信公众号、掘金、博客园、腾讯云社区等平台
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">采集结果预览</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">标题</label>
|
||||
<input type="text" value={result.title}
|
||||
onChange={(e) => setResult({...result, title: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
选择栏目 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select value={selectedColumn}
|
||||
onChange={(e) => setSelectedColumn(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
|
||||
<option value="">请选择栏目</option>
|
||||
{columns.map(col => (
|
||||
<option key={col.id} value={col.id}>
|
||||
{' '.repeat(col.level)}{col.level > 0 ? '└ ' : ''}{col.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{columns.length === 0 && (
|
||||
<p className="mt-1 text-sm text-amber-600">
|
||||
没有可用栏目,请先<Link to="/admin/columns" className="underline">创建栏目</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.images && result.images.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
采集的图片 ({result.images.length}张)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{result.images.map((img, idx) => (
|
||||
<div key={idx} className="relative">
|
||||
<img src={img.local_path || img.original_url} alt={`图片 ${idx + 1}`}
|
||||
className="w-24 h-24 object-cover rounded-lg border border-slate-200"
|
||||
onError={(e) => { e.target.src = '/placeholder.png'; }} />
|
||||
{idx === 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-emerald-500 text-white text-xs px-2 py-0.5 rounded-full">封面</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">内容编辑</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor value={result.content || ''}
|
||||
onChange={(val) => setResult({...result, content: val || ''})}
|
||||
height={500} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">共 {result.content.length} 字符</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={handleCancel}
|
||||
className="px-6 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving || !selectedColumn}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
{saving ? '保存中...' : '保存到草稿'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Loader2, Save, Eye, Image as ImageIcon,
|
||||
FileText, Code, Upload, X, Copy, Check
|
||||
} from 'lucide-react';
|
||||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||
import '@uiw/react-md-editor/markdown-editor.css';
|
||||
import { Button } from '../../../components/admin/Button';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/admin/Card';
|
||||
import { Modal } from '../../../components/admin/Modal';
|
||||
import { articlesApi, columnsApi } from '../../../api';
|
||||
|
||||
// Markdown 编辑器组件
|
||||
const MarkdownEditor = ({ value, onChange, onImageInsert }) => {
|
||||
const apiRef = useRef(null);
|
||||
|
||||
const customImageCommand = {
|
||||
...commands.image,
|
||||
execute: (state, textApi) => {
|
||||
apiRef.current = textApi;
|
||||
onImageInsert?.((img) => {
|
||||
const url = typeof img === 'string' ? img : img.file_path;
|
||||
const imageMarkdown = ``;
|
||||
textApi.replaceSelection(imageMarkdown);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const customCommands = commands.getCommands().map((cmd) => {
|
||||
if (cmd.name === 'image' || cmd.keyCommand === 'image') {
|
||||
return customImageCommand;
|
||||
}
|
||||
if (cmd.groupName === 'title' && Array.isArray(cmd.children)) {
|
||||
return {
|
||||
...cmd,
|
||||
children: cmd.children.map((child) =>
|
||||
child.keyCommand === 'image' ? customImageCommand : child
|
||||
),
|
||||
};
|
||||
}
|
||||
return cmd;
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
height={768}
|
||||
commands={customCommands}
|
||||
extraCommands={commands.getExtraCommands()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// HTML 编辑器组件(支持可视化 / 源码分屏)
|
||||
const HtmlEditor = ({ value, onChange, onImageInsert }) => {
|
||||
const editorRef = useRef(null);
|
||||
const savedSelRef = useRef(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('visual'); // 'visual' | 'split'
|
||||
const [htmlSource, setHtmlSource] = useState(value || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
||||
editorRef.current.innerHTML = value || '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 外部 value 变化时同步源码状态
|
||||
useEffect(() => {
|
||||
setHtmlSource(value || '');
|
||||
}, [value]);
|
||||
|
||||
const handleInput = () => {
|
||||
if (editorRef.current) {
|
||||
const html = editorRef.current.innerHTML;
|
||||
onChange(html);
|
||||
setHtmlSource(html);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceChange = (e) => {
|
||||
const html = e.target.value;
|
||||
setHtmlSource(html);
|
||||
onChange(html);
|
||||
};
|
||||
|
||||
const toggleViewMode = () => {
|
||||
if (viewMode === 'visual') {
|
||||
// 切到分屏:从可视化同步源码
|
||||
const currentHtml = editorRef.current?.innerHTML || value || '';
|
||||
setHtmlSource(currentHtml);
|
||||
setViewMode('split');
|
||||
} else {
|
||||
// 切回可视化:从源码同步到 contentEditable
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = htmlSource;
|
||||
}
|
||||
setViewMode('visual');
|
||||
}
|
||||
};
|
||||
|
||||
const insertImage = () => {
|
||||
if (viewMode === 'split') {
|
||||
onImageInsert?.((url) => {
|
||||
const imgTag = `<img src="${url}" alt="" style="max-width:100%;" />`;
|
||||
setHtmlSource((prev) => {
|
||||
const next = prev + imgTag;
|
||||
onChange(next);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
onImageInsert?.((url) => {
|
||||
document.execCommand('insertImage', false, url);
|
||||
handleInput();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const execCommand = (command, val = null) => {
|
||||
if (viewMode !== 'visual') return;
|
||||
document.execCommand(command, false, val);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.focus();
|
||||
handleInput();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg overflow-hidden ${isFocused && viewMode === 'visual' ? 'ring-2 ring-emerald-500 border-emerald-500' : 'border-slate-300'}`}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-1 px-3 py-2 bg-slate-50 border-b border-slate-300">
|
||||
{viewMode === 'visual' && (
|
||||
<>
|
||||
<button type="button" onClick={() => execCommand('bold')} className="px-2 py-1 text-sm font-bold text-slate-600 hover:bg-white rounded">B</button>
|
||||
<button type="button" onClick={() => execCommand('italic')} className="px-2 py-1 text-sm italic text-slate-600 hover:bg-white rounded">I</button>
|
||||
<button type="button" onClick={() => execCommand('underline')} className="px-2 py-1 text-sm underline text-slate-600 hover:bg-white rounded">U</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<button type="button" onClick={() => execCommand('formatBlock', 'H1')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">H1</button>
|
||||
<button type="button" onClick={() => execCommand('formatBlock', 'H2')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">H2</button>
|
||||
<button type="button" onClick={() => execCommand('formatBlock', 'P')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">P</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<button type="button" onClick={() => execCommand('insertUnorderedList')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">• 列表</button>
|
||||
<button type="button" onClick={() => execCommand('insertOrderedList')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">1. 列表</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertImage}
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm text-slate-600 hover:text-emerald-600 hover:bg-white rounded"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
图片
|
||||
</button>
|
||||
{viewMode === 'visual' && (
|
||||
<button type="button"
|
||||
onMouseDown={() => {
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount > 0) {
|
||||
savedSelRef.current = sel.getRangeAt(0).cloneRange();
|
||||
} else {
|
||||
savedSelRef.current = null;
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
const range = savedSelRef.current;
|
||||
savedSelRef.current = null;
|
||||
if (range) {
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
let targetImg = null;
|
||||
if (ancestor.nodeName === 'IMG') {
|
||||
targetImg = ancestor;
|
||||
} else {
|
||||
const doc = ancestor.ownerDocument || document;
|
||||
const walker = doc.createTreeWalker(
|
||||
ancestor.nodeType === 3 ? ancestor.parentNode : ancestor,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
{ acceptNode: (n) => n.nodeName === 'IMG' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP }
|
||||
);
|
||||
targetImg = walker.nextNode();
|
||||
}
|
||||
if (targetImg && !targetImg.closest('a')) {
|
||||
const url = prompt('请输入链接地址:');
|
||||
if (!url) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
targetImg.parentNode.insertBefore(a, targetImg);
|
||||
a.appendChild(targetImg);
|
||||
handleInput();
|
||||
return;
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
execCommand('createLink', prompt('请输入链接地址:'));
|
||||
}} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">链接</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleViewMode}
|
||||
className={`flex items-center gap-1 px-3 py-1 text-sm rounded border transition-colors ${
|
||||
viewMode === 'split'
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
{viewMode === 'split' ? '关闭源码' : '源码'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'visual' ? (
|
||||
<div
|
||||
key="visual-editor"
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={handleInput}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className="w-full p-4 overflow-y-auto focus:outline-none"
|
||||
style={{ minHeight: '768px' }}
|
||||
placeholder="输入内容..."
|
||||
/>
|
||||
) : (
|
||||
<div key="split-editor" className="flex" style={{ minHeight: '768px' }}>
|
||||
{/* 左侧:HTML 源码 */}
|
||||
<textarea
|
||||
value={htmlSource}
|
||||
onChange={handleSourceChange}
|
||||
className="w-1/2 p-4 font-mono text-sm bg-slate-900 text-slate-100 resize-none focus:outline-none border-r border-slate-300"
|
||||
spellCheck={false}
|
||||
placeholder="在此编辑 HTML 源码..."
|
||||
/>
|
||||
{/* 右侧:实时预览 */}
|
||||
<div
|
||||
className="w-1/2 p-4 bg-white overflow-y-auto prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: htmlSource }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 图片选择器弹窗
|
||||
const ImagePickerModal = ({ isOpen, onClose, onSelect }) => {
|
||||
const [images, setImages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) loadImages();
|
||||
}, [isOpen]);
|
||||
|
||||
const loadImages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await (await import('../../../api')).default.get('/api/images?page=1&size=50');
|
||||
setImages(res.items || []);
|
||||
} catch (err) {
|
||||
console.error('加载图片失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', files[0]);
|
||||
formData.append('module', 'articles');
|
||||
const res = await (await import('../../../api')).default.post('/api/images/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
await loadImages();
|
||||
onSelect(res.data.file_path);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('上传失败: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const getImageUrl = (img) => {
|
||||
const path = img.thumb_path || img.file_path;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/')) return path;
|
||||
if (path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="选择图片" size="xl">
|
||||
<div className="flex flex-col h-[600px]">
|
||||
<div className="flex justify-between items-center mb-4 pb-4 border-b border-slate-200">
|
||||
<p className="text-sm text-slate-500">共 {images.length} 张图片</p>
|
||||
<div className="flex gap-2">
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileUpload} accept="image/*" className="hidden" />
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()} disabled={uploading} className="flex items-center gap-2">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
{uploading ? '上传中...' : '上传新图片'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-8 h-8 text-slate-400 animate-spin" /></div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-500">
|
||||
<ImageIcon className="w-16 h-16 mx-auto mb-4 text-slate-300" />
|
||||
<p className="mb-2">暂无图片</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-3 p-2">
|
||||
{images.map((img) => (
|
||||
<button key={img.id} onClick={() => onSelect(img)}
|
||||
className="relative aspect-square border border-slate-200 rounded-lg overflow-hidden hover:ring-2 hover:ring-emerald-500 transition-all group"
|
||||
title={img.original_name}>
|
||||
<img src={getImageUrl(img)} alt={img.original_name} className="w-full h-full object-cover" loading="lazy"
|
||||
onError={(e) => { e.target.src = '/placeholder-image.png'; }} />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-slate-200">
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 主组件
|
||||
const ArticleEdit = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
content_type: 'markdown',
|
||||
summary: '',
|
||||
cover_image: '',
|
||||
column_id: '',
|
||||
author: '',
|
||||
status: 'draft',
|
||||
sort_order: 0,
|
||||
});
|
||||
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [showImagePicker, setShowImagePicker] = useState(false);
|
||||
const [imagePickerCallback, setImagePickerCallback] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadColumns();
|
||||
if (isEdit) loadArticle();
|
||||
}, [id]);
|
||||
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
const res = await columnsApi.getTree();
|
||||
const flatColumns = [];
|
||||
const flatten = (items, level = 0) => {
|
||||
for (const item of items) {
|
||||
flatColumns.push({ ...item, level });
|
||||
if (item.children?.length > 0) flatten(item.children, level + 1);
|
||||
}
|
||||
};
|
||||
flatten(res.items || []);
|
||||
// 只展示可关联内容的栏目(不含含有子栏目的顶级栏目)
|
||||
const available = flatColumns.filter(c => {
|
||||
if (c.level === 0 && flatColumns.some(cc => cc.parent_id === c.id)) return false;
|
||||
return true;
|
||||
});
|
||||
setColumns(available);
|
||||
} catch (err) {
|
||||
console.error('加载栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadArticle = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const article = await articlesApi.getById(id);
|
||||
setFormData({
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
content: article.content || '',
|
||||
content_type: article.content_type || 'markdown',
|
||||
summary: article.summary || '',
|
||||
cover_image: article.cover_image || '',
|
||||
column_id: article.column?.id || '',
|
||||
author: article.author || '',
|
||||
status: article.status || 'draft',
|
||||
sort_order: article.sort_order || 0,
|
||||
});
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 自动生成 slug
|
||||
const generateSlug = (title) => {
|
||||
if (!title) return '';
|
||||
return title.trim().replace(/[\s]+/g, '-').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase().substring(0, 200);
|
||||
};
|
||||
|
||||
const handleTitleChange = (e) => {
|
||||
const title = e.target.value;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
title,
|
||||
slug: !isEdit && !prev.slug ? generateSlug(title) : prev.slug
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (publish = false) => {
|
||||
if (!formData.title.trim()) { setMessage({ type: 'error', text: '请输入标题' }); return; }
|
||||
if (!formData.column_id) { setMessage({ type: 'error', text: '请选择栏目' }); return; }
|
||||
if (!formData.content.trim()) { setMessage({ type: 'error', text: '请输入内容' }); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
...formData,
|
||||
column_id: parseInt(formData.column_id),
|
||||
sort_order: parseInt(formData.sort_order) || 0,
|
||||
status: publish ? 'published' : 'draft',
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await articlesApi.update(id, data);
|
||||
} else {
|
||||
await articlesApi.create(data);
|
||||
}
|
||||
setMessage({ type: 'success', text: publish ? '发布成功' : '保存成功' });
|
||||
setTimeout(() => navigate('/admin/articles'), 1000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err.message || '保存失败' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageInsert = (callback) => {
|
||||
setImagePickerCallback(() => callback);
|
||||
setShowImagePicker(true);
|
||||
};
|
||||
|
||||
const handleImageSelect = (img) => {
|
||||
if (imagePickerCallback) imagePickerCallback(img);
|
||||
setShowImagePicker(false);
|
||||
setImagePickerCallback(null);
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (!formData.slug) return;
|
||||
navigator.clipboard.writeText(`/article/${formData.slug}`).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-64"><Loader2 className="w-8 h-8 text-slate-400 animate-spin" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => navigate('/admin/articles')}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{isEdit ? '编辑内容' : '添加内容'}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => handleSave(false)} disabled={saving}>
|
||||
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
<Save className="w-4 h-4 mr-2" />保存草稿
|
||||
</Button>
|
||||
<Button onClick={() => handleSave(true)} disabled={saving}>
|
||||
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
<Eye className="w-4 h-4 mr-2" />发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.text && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>{message.text}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader title="基本信息" icon={FileText} />
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
标题 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="text" value={formData.title} onChange={handleTitleChange}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="输入内容标题" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">URL标识</label>
|
||||
<input type="text" value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="留空将由系统自动生成拼音" />
|
||||
<p className="text-xs text-slate-400 mt-1">中文标题留空时自动生成拼音</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
所属栏目 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<select value={formData.column_id}
|
||||
onChange={(e) => setFormData({ ...formData, column_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
|
||||
<option value="">请选择栏目</option>
|
||||
{columns.map(col => (
|
||||
<option key={col.id} value={col.id}>
|
||||
{' '.repeat(col.level)}{col.level > 0 ? '└ ' : ''}{col.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">作者</label>
|
||||
<input type="text" value={formData.author}
|
||||
onChange={(e) => setFormData({ ...formData, author: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="可选" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">排序权重</label>
|
||||
<input type="number" value={formData.sort_order}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">摘要</label>
|
||||
<textarea value={formData.summary}
|
||||
onChange={(e) => setFormData({ ...formData, summary: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
rows={3} placeholder="留空将自动从内容中提取" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader title="封面图" icon={ImageIcon} />
|
||||
<CardContent>
|
||||
{formData.cover_image ? (
|
||||
<div className="relative group">
|
||||
<img src={(() => {
|
||||
const path = formData.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()} alt="封面" className="w-full h-32 object-cover rounded-lg" />
|
||||
<button type="button"
|
||||
onClick={() => setFormData({ ...formData, cover_image: '' })}
|
||||
className="absolute top-2 right-2 p-1 bg-rose-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
handleImageInsert((img) => {
|
||||
const imgData = typeof img === 'string' ? { file_path: img } : img;
|
||||
setFormData(prev => ({ ...prev, cover_image: imgData.file_path || imgData.url }));
|
||||
});
|
||||
}}
|
||||
className="h-20 border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-400 hover:border-emerald-500 hover:text-emerald-600 transition-colors">
|
||||
<ImageIcon className="w-6 h-6 mb-1" /><span className="text-xs">选择图片</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file'; input.accept = 'image/*';
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file); fd.append('module', 'articles');
|
||||
const res = await (await import('../../../api')).default.post('/api/images/upload', fd);
|
||||
if (res.success && res.data) {
|
||||
setFormData(prev => ({ ...prev, cover_image: res.data.file_path }));
|
||||
}
|
||||
} catch { alert('上传失败'); }
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
className="h-20 border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-400 hover:border-emerald-500 hover:text-emerald-600 transition-colors">
|
||||
<Upload className="w-6 h-6 mb-1" /><span className="text-xs">上传图片</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="发布设置" icon={Eye} />
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-700">状态</span>
|
||||
<span className={`text-sm ${formData.status === 'draft' ? 'text-slate-500' : 'text-emerald-600'}`}>
|
||||
{formData.status === 'draft' ? '草稿' : '已发布'}
|
||||
</span>
|
||||
</div>
|
||||
{formData.slug && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1.5">访问地址</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-2.5 py-1.5 bg-slate-50 border border-slate-200 rounded text-xs text-slate-600 truncate">
|
||||
/article/{formData.slug}
|
||||
</code>
|
||||
<button type="button" onClick={handleCopyUrl}
|
||||
className="p-1.5 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded transition-colors" title="复制链接">
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-600" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">点击右上角"保存草稿"或"发布"按钮来更改状态</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Card>
|
||||
<CardHeader title="内容编辑" icon={Code} />
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="content_type" value="markdown"
|
||||
checked={formData.content_type === 'markdown'}
|
||||
onChange={() => setFormData({ ...formData, content_type: 'markdown' })}
|
||||
className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-sm text-slate-700">Markdown</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="content_type" value="html"
|
||||
checked={formData.content_type === 'html'}
|
||||
onChange={() => setFormData({ ...formData, content_type: 'html' })}
|
||||
className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-sm text-slate-700">富文本 (HTML)</span>
|
||||
</label>
|
||||
</div>
|
||||
{formData.content_type === 'markdown' ? (
|
||||
<MarkdownEditor value={formData.content} onChange={(content) => setFormData({ ...formData, content })} onImageInsert={handleImageInsert} />
|
||||
) : (
|
||||
<HtmlEditor value={formData.content} onChange={(content) => setFormData({ ...formData, content })} onImageInsert={handleImageInsert} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ImagePickerModal isOpen={showImagePicker} onClose={() => setShowImagePicker(false)} onSelect={handleImageSelect} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleEdit;
|
||||
@@ -0,0 +1,691 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus, Pencil, Trash2, Loader2, Search, Eye, EyeOff,
|
||||
FileText, Filter, FileStack, Download, ArrowRightLeft,
|
||||
Copy, Check, Send
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../../components/admin/Button';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/admin/Card';
|
||||
import { Modal } from '../../../components/admin/Modal';
|
||||
import { articlesApi, columnsApi } from '../../../api';
|
||||
import { renderContent } from '../../../utils/contentRenderer';
|
||||
|
||||
const CopyUrlButton = ({ url }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-slate-400 hover:text-emerald-600 transition-colors"
|
||||
title="复制访问地址"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-emerald-600" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ArticleList = () => {
|
||||
const navigate = useNavigate();
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState(null);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [rewritingId, setRewritingId] = useState(null); // 正在改写的文章ID
|
||||
|
||||
// 批量操作
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [showBatchMove, setShowBatchMove] = useState(false);
|
||||
const [batchMoveColumnId, setBatchMoveColumnId] = useState('');
|
||||
|
||||
// 筛选和分页
|
||||
const [page, setPage] = useState(1);
|
||||
const [size] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// 预览弹窗
|
||||
const [previewArticle, setPreviewArticle] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [columnFilter, setColumnFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
loadColumns();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadArticles();
|
||||
}, [page, columnFilter, statusFilter]);
|
||||
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
const res = await columnsApi.getTree();
|
||||
const flatColumns = [];
|
||||
const flatten = (items, level = 0) => {
|
||||
for (const item of items) {
|
||||
flatColumns.push({ ...item, level });
|
||||
if (item.children?.length > 0) {
|
||||
flatten(item.children, level + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
flatten(res.items || []);
|
||||
setColumns(flatColumns);
|
||||
} catch (err) {
|
||||
console.error('加载栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadArticles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { page, size, status: statusFilter };
|
||||
if (columnFilter) params.column_id = columnFilter;
|
||||
if (keyword) params.keyword = keyword;
|
||||
const res = await articlesApi.getAll(params);
|
||||
setArticles(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadArticles();
|
||||
};
|
||||
|
||||
const handlePreview = async (id) => {
|
||||
setPreviewLoading(true);
|
||||
setPreviewArticle(null);
|
||||
try {
|
||||
const article = await articlesApi.getById(id);
|
||||
setPreviewArticle(article);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载内容失败: ' + err.message });
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate('/admin/articles/new');
|
||||
};
|
||||
|
||||
const handleCrawl = () => {
|
||||
navigate('/admin/articles/crawl');
|
||||
};
|
||||
|
||||
const handleEdit = (article) => {
|
||||
navigate(`/admin/articles/${article.id}/edit`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await articlesApi.delete(id);
|
||||
setMessage({ type: 'success', text: '删除成功' });
|
||||
setDeleteId(null);
|
||||
loadArticles();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '删除失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishToggle = async (article) => {
|
||||
try {
|
||||
await articlesApi.publish(article.id, article.status !== 'published');
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: article.status === 'published' ? '已取消发布' : '发布成功'
|
||||
});
|
||||
loadArticles();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '操作失败: ' + err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRewritePublish = async (article) => {
|
||||
try {
|
||||
await articlesApi.rewritePublish(article.id);
|
||||
setRewritingId(article.id);
|
||||
setMessage({ type: 'success', text: '改写任务已启动,请稍候...' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '改写启动失败: ' + err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 轮询改写状态
|
||||
useEffect(() => {
|
||||
if (!rewritingId) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const article = await articlesApi.getById(rewritingId);
|
||||
if (article.status !== 'rewriting') {
|
||||
// 完成(成功 or 失败回退)
|
||||
setRewritingId(null);
|
||||
setMessage({
|
||||
type: article.status === 'published' ? 'success' : 'warning',
|
||||
text: article.status === 'published' ? '改写发布完成!' : '改写完成,但发布可能失败,已恢复草稿'
|
||||
});
|
||||
loadArticles(); // 刷新列表
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore polling errors
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [rewritingId]);
|
||||
|
||||
const handleSelectAll = (e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedIds(articles.map(a => a.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOne = (id) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await articlesApi.batchDelete(selectedIds);
|
||||
setMessage({ type: 'success', text: `成功删除 ${selectedIds.length} 条内容` });
|
||||
setSelectedIds([]);
|
||||
loadArticles();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '批量删除失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchMove = async () => {
|
||||
if (!batchMoveColumnId || selectedIds.length === 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await articlesApi.batchMove(selectedIds, parseInt(batchMoveColumnId));
|
||||
setMessage({ type: 'success', text: `成功转移 ${selectedIds.length} 条内容` });
|
||||
setSelectedIds([]);
|
||||
setShowBatchMove(false);
|
||||
setBatchMoveColumnId('');
|
||||
loadArticles();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '批量转移失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
|
||||
// 获取可关联内容的栏目(不含有子栏目的顶级栏目)
|
||||
const availableColumns = columns.filter(c => {
|
||||
if (c.level === 0 && columns.some(cc => cc.parent_id === c.id)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">内容管理</h1>
|
||||
<p className="text-slate-500 mt-1">管理所有栏目下的内容,支持发布、筛选、批量操作</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCrawl} variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
采集内容
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加内容
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.text && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索标题..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={columnFilter}
|
||||
onChange={(e) => setColumnFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">所有栏目</option>
|
||||
{columns.map(col => (
|
||||
<option key={col.id} value={col.id}>
|
||||
{' '.repeat(col.level)}{col.level > 0 ? '└ ' : ''}{col.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
</select>
|
||||
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-4">
|
||||
<span className="text-sm text-blue-700">已选择 {selectedIds.length} 条内容</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowBatchMove(true)}>
|
||||
<ArrowRightLeft className="w-4 h-4 mr-1" />
|
||||
批量转移
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleBatchDelete} disabled={loading}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedIds([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 列表 */}
|
||||
<Card>
|
||||
<CardHeader title="内容列表" icon={FileStack} />
|
||||
<CardContent>
|
||||
{loading && articles.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p>暂无内容</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.length === articles.length && articles.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
className="w-4 h-4 text-emerald-600 border-slate-300 rounded focus:ring-emerald-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700" style={{ width: '40%' }}>标题</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700 whitespace-nowrap" style={{ width: '12%' }}>栏目</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700 whitespace-nowrap" style={{ width: '10%' }}>格式</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700 whitespace-nowrap" style={{ width: '10%' }}>状态</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700 whitespace-nowrap" style={{ width: '14%' }}>更新时间</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-700 whitespace-nowrap" style={{ width: '14%' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{articles.map((article) => (
|
||||
<tr key={article.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(article.id)}
|
||||
onChange={() => handleSelectOne(article.id)}
|
||||
className="w-4 h-4 text-emerald-600 border-slate-300 rounded focus:ring-emerald-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{article.cover_image && (
|
||||
<img
|
||||
src={(() => {
|
||||
const path = article.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p
|
||||
className="font-medium text-slate-900 truncate max-w-[200px] cursor-pointer hover:text-emerald-600 transition-colors"
|
||||
onClick={() => handlePreview(article.id)}
|
||||
>
|
||||
{article.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<code className="text-xs text-slate-500 bg-slate-50 px-1.5 py-0.5 rounded whitespace-nowrap truncate max-w-[180px]">
|
||||
/article/{article.slug}
|
||||
</code>
|
||||
<CopyUrlButton url={`/article/${article.slug}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-600 whitespace-nowrap">
|
||||
{article.column?.name || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-1 text-xs rounded ${
|
||||
article.content_type === 'markdown'
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'bg-purple-50 text-purple-600'
|
||||
}`}>
|
||||
{article.content_type === 'markdown' ? 'Markdown' : 'HTML'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{article.status === 'published' ? (
|
||||
<span className="flex items-center gap-1 text-emerald-600">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span className="text-sm">已发布</span>
|
||||
</span>
|
||||
) : article.status === 'rewriting' ? (
|
||||
<span className="flex items-center gap-1 text-amber-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">改写中</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-slate-400">
|
||||
<EyeOff className="w-4 h-4" />
|
||||
<span className="text-sm">草稿</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-500 text-sm">
|
||||
{formatDate(article.updated_at)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{article.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => handleRewritePublish(article)}
|
||||
disabled={!!rewritingId}
|
||||
title={rewritingId ? '已有文章正在改写中' : '一键改写并发布到公众号'}
|
||||
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handlePublishToggle(article)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
article.status === 'published'
|
||||
? 'text-emerald-600 hover:bg-emerald-50'
|
||||
: 'text-slate-400 hover:text-emerald-600 hover:bg-emerald-50'
|
||||
}`}
|
||||
title={article.status === 'published' ? '取消发布' : '发布'}
|
||||
>
|
||||
{article.status === 'published' ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(article)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(article.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-500">
|
||||
共 {total} 条,第 {page}/{totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-slate-500">跳至</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={page}
|
||||
className="w-14 px-2 py-1 text-sm text-center border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-500">页</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}}
|
||||
disabled={!jumpPage}
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Modal
|
||||
isOpen={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
title="确认删除"
|
||||
>
|
||||
<div className="text-center py-4">
|
||||
<div className="w-12 h-12 bg-rose-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Trash2 className="w-6 h-6 text-rose-600" />
|
||||
</div>
|
||||
<p className="text-slate-600">确定要删除这篇内容吗?此操作不可恢复。</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(deleteId)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 批量转移弹窗 */}
|
||||
<Modal
|
||||
isOpen={showBatchMove}
|
||||
onClose={() => { setShowBatchMove(false); setBatchMoveColumnId(''); }}
|
||||
title="批量转移栏目"
|
||||
>
|
||||
<div className="py-4">
|
||||
<p className="text-slate-600 mb-4">将选中的 {selectedIds.length} 条内容转移到:</p>
|
||||
<select
|
||||
value={batchMoveColumnId}
|
||||
onChange={(e) => setBatchMoveColumnId(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">请选择目标栏目</option>
|
||||
{availableColumns.map(col => (
|
||||
<option key={col.id} value={col.id}>
|
||||
{' '.repeat(col.level)}{col.level > 0 ? '└ ' : ''}{col.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" onClick={() => { setShowBatchMove(false); setBatchMoveColumnId(''); }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchMove}
|
||||
disabled={!batchMoveColumnId || loading}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
确认转移
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 内容预览弹窗 */}
|
||||
<Modal
|
||||
isOpen={!!previewArticle || previewLoading}
|
||||
onClose={() => { setPreviewArticle(null); setPreviewLoading(false); }}
|
||||
title={previewArticle?.title || '内容预览'}
|
||||
size="screen"
|
||||
>
|
||||
{previewLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-emerald-600" />
|
||||
</div>
|
||||
) : previewArticle ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 封面图 */}
|
||||
{previewArticle.cover_image && (
|
||||
<div className="mb-8 rounded-xl overflow-hidden shadow-lg">
|
||||
<img
|
||||
src={(() => {
|
||||
const path = previewArticle.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/') || path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()}
|
||||
alt={previewArticle.title}
|
||||
className="w-full h-auto max-h-96 object-cover"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-500 border-b border-slate-100 pb-6 mb-6">
|
||||
{previewArticle.column?.name && (
|
||||
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded text-xs font-medium">
|
||||
{previewArticle.column.name}
|
||||
</span>
|
||||
)}
|
||||
<span>{previewArticle.author && `作者: ${previewArticle.author}`}</span>
|
||||
<span>格式: {previewArticle.content_type === 'markdown' ? 'Markdown' : 'HTML'}</span>
|
||||
<span className="ml-auto">
|
||||
{new Date(previewArticle.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 正文内容 - 参考访客端 ArticleDetail 样式 */}
|
||||
<div
|
||||
className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-emerald-600 hover:prose-a:text-emerald-700 prose-img:rounded-lg prose-img:shadow-md"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderContent(previewArticle.content, previewArticle.content_type)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleList;
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import '@uiw/react-md-editor/markdown-editor.css';
|
||||
|
||||
export default function WechatCrawlEdit() {
|
||||
const navigate = useNavigate();
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [previewData, setPreviewData] = useState(null);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [selectedColumn, setSelectedColumn] = useState('');
|
||||
const [step, setStep] = useState('input'); // input | preview | saving | done
|
||||
|
||||
useEffect(() => {
|
||||
fetchColumns();
|
||||
}, []);
|
||||
|
||||
const fetchColumns = async () => {
|
||||
try {
|
||||
const api = (await import('../../../api')).default;
|
||||
const data = await api.post('/api/wechat-crawl/columns');
|
||||
const list = data?.data || data || [];
|
||||
setColumns(list);
|
||||
} catch (err) {
|
||||
console.error('获取栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCrawl = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('请输入公众号文章链接');
|
||||
return;
|
||||
}
|
||||
if (!url.trim().startsWith('https://mp.weixin.qq.com/') &&
|
||||
!url.trim().startsWith('https://weixin.sogou.com/')) {
|
||||
setError('请输入正确的微信公众号文章链接(以 mp.weixin.qq.com 或 weixin.sogou.com 开头)');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setPreviewData(null);
|
||||
try {
|
||||
const api = (await import('../../../api')).default;
|
||||
const data = await api.post('/api/wechat-crawl/preview', { url: url.trim() });
|
||||
|
||||
// 从 response 中提取 columns
|
||||
if (data && data.columns && data.columns.length > 0) {
|
||||
setColumns(data.columns);
|
||||
}
|
||||
|
||||
setPreviewData(data);
|
||||
setStep('preview');
|
||||
} catch (err) {
|
||||
setError('采集失败: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!previewData) {
|
||||
setError('没有可保存的文章数据');
|
||||
return;
|
||||
}
|
||||
if (!selectedColumn) {
|
||||
setError('请选择栏目');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const api = (await import('../../../api')).default;
|
||||
await api.post('/api/wechat-crawl/save', {
|
||||
url: url.trim(),
|
||||
title: previewData.title || '',
|
||||
author: previewData.author || '',
|
||||
summary: previewData.summary || '',
|
||||
cover_image: previewData.cover_image || '',
|
||||
content: previewData.content || '',
|
||||
images: previewData.images || [],
|
||||
column_id: parseInt(selectedColumn),
|
||||
});
|
||||
setStep('done');
|
||||
} catch (err) {
|
||||
setError('保存失败: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setUrl('');
|
||||
setPreviewData(null);
|
||||
setError('');
|
||||
setStep('input');
|
||||
setSelectedColumn('');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/admin/articles');
|
||||
};
|
||||
|
||||
// 将图片插入到正文末尾
|
||||
const insertImageIntoContent = (imageUrl) => {
|
||||
if (!previewData) return;
|
||||
const imageMd = `\n\n\n\n`;
|
||||
const newContent = (previewData.content || '') + imageMd;
|
||||
setPreviewData({ ...previewData, content: newContent });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-slate-800">公众号采集</h1>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => navigate('/admin/articles/wechat-batch')}
|
||||
className="px-4 py-2 text-emerald-600 border border-emerald-300 hover:bg-emerald-50 rounded-lg transition-colors text-sm">
|
||||
批量采集
|
||||
</button>
|
||||
<button onClick={handleBack}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-800 transition-colors">
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤1:输入URL */}
|
||||
{step === 'input' && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">公众号文章链接</label>
|
||||
<div className="flex gap-3">
|
||||
<input type="url" value={url} onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://mp.weixin.qq.com/s/... 或 weixin.sogou.com/link?..."
|
||||
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
disabled={loading} />
|
||||
<button onClick={handleCrawl} disabled={loading}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
采集中...
|
||||
</span>
|
||||
) : '开始采集'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
输入微信公众号文章的链接,系统将自动采集标题、作者、正文内容及图片
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤2:预览与编辑 */}
|
||||
{step === 'preview' && previewData && (
|
||||
<div className="space-y-6">
|
||||
{/* 文章信息 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">文章信息</h2>
|
||||
<div className="flex flex-wrap gap-6 mb-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">标题</label>
|
||||
<input type="text" value={previewData.title || ''}
|
||||
onChange={(e) => setPreviewData({...previewData, title: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500" />
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">作者</label>
|
||||
<input type="text" value={previewData.author || ''}
|
||||
onChange={(e) => setPreviewData({...previewData, author: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">摘要</label>
|
||||
<textarea value={previewData.summary || ''}
|
||||
onChange={(e) => setPreviewData({...previewData, summary: e.target.value})}
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500" />
|
||||
</div>
|
||||
|
||||
{/* 封面图 */}
|
||||
{previewData.cover_image && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">封面图片</label>
|
||||
<img src={previewData.cover_image} alt="封面"
|
||||
className="w-48 h-32 object-cover rounded-lg border border-slate-200"
|
||||
onError={(e) => { e.target.style.display = 'none'; }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 栏目选择 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
选择栏目 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select value={selectedColumn}
|
||||
onChange={(e) => setSelectedColumn(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
|
||||
<option value="">请选择栏目</option>
|
||||
{columns.map(col => (
|
||||
<option key={col.id} value={col.id}>{col.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{columns.length === 0 && (
|
||||
<p className="mt-1 text-sm text-amber-600">
|
||||
没有可用栏目,请先<a href="/admin/columns" className="underline">创建栏目</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 采集的图片 */}
|
||||
{previewData.images && previewData.images.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-3">
|
||||
采集的图片
|
||||
<span className="ml-2 text-xs text-slate-400 font-normal">
|
||||
(共{previewData.images.length}张,点击缩略图插入正文末尾)
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
|
||||
{previewData.images.map((imgUrl, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => insertImageIntoContent(imgUrl)}
|
||||
className="relative aspect-square border border-slate-200 rounded-lg overflow-hidden hover:border-emerald-500 hover:ring-2 hover:ring-emerald-200 transition-all cursor-pointer group"
|
||||
title="点击插入正文"
|
||||
>
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={`图片${idx + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-emerald-500/0 group-hover:bg-emerald-500/10 flex items-center justify-center transition-all">
|
||||
<span className="text-white text-xs font-medium bg-emerald-500/80 px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
插入
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容编辑器 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">正文内容(Markdown)</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor value={previewData.content || ''}
|
||||
onChange={(val) => setPreviewData({...previewData, content: val || ''})}
|
||||
height={500} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">共 {(previewData.content || '').length} 字符</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={handleReset}
|
||||
className="px-6 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors">
|
||||
重新采集
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving || !selectedColumn}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
保存中...
|
||||
</span>
|
||||
) : '保存到草稿'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤3:完成 */}
|
||||
{step === 'done' && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<div className="max-w-lg mx-auto text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-2">文章已保存</h3>
|
||||
<p className="text-slate-500 mb-6">公众号文章已成功采集并入库</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button onClick={handleBack}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors">
|
||||
返回文章列表
|
||||
</button>
|
||||
<button onClick={handleReset}
|
||||
className="px-6 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors">
|
||||
继续采集
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Plus, Pencil, Trash2, Loader2, ChevronRight, ChevronDown,
|
||||
FolderTree, AlertCircle, GripVertical, Copy, Check
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../../components/admin/Button';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/admin/Card';
|
||||
import { Modal } from '../../../components/admin/Modal';
|
||||
import { columnsApi } from '../../../api';
|
||||
|
||||
// ========== 常量 ==========
|
||||
const DISPLAY_MODE_OPTIONS = [
|
||||
{ value: 'list', label: '列表式' },
|
||||
{ value: 'card', label: '卡片式' },
|
||||
];
|
||||
|
||||
const LAYOUT_STYLE_OPTIONS = [
|
||||
{ value: 'left', label: '图左文右' },
|
||||
{ value: 'right', label: '图右文左' },
|
||||
{ value: 'top', label: '上图下文' },
|
||||
];
|
||||
|
||||
// ========== 辅助组件 ==========
|
||||
|
||||
const Badge = ({ children, variant = 'default' }) => {
|
||||
const styles = {
|
||||
default: 'bg-slate-100 text-slate-600',
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
warning: 'bg-amber-100 text-amber-700',
|
||||
danger: 'bg-rose-100 text-rose-700',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${styles[variant]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ========== 栏目表单弹窗 ==========
|
||||
|
||||
const ColumnForm = ({ column, treeData, onSubmit, onCancel, loading }) => {
|
||||
const isEdit = !!column;
|
||||
const [formData, setFormData] = useState({
|
||||
name: column?.name || '',
|
||||
slug: column?.slug || '',
|
||||
description: column?.description || '',
|
||||
parent_id: column?.parent_id ?? '',
|
||||
sort_order: column?.sort_order ?? 0,
|
||||
is_enabled: column?.is_enabled ?? true,
|
||||
display_mode: column?.display_mode || 'list',
|
||||
layout_style: column?.layout_style || 'left',
|
||||
icon_name: column?.icon_name || '',
|
||||
});
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 自动生成 slug(中文交给后端转拼音)
|
||||
const generateSlug = (name) => {
|
||||
if (!name) return '';
|
||||
const slug = name
|
||||
.trim()
|
||||
.replace(/[\s]+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9-]/g, '')
|
||||
.toLowerCase()
|
||||
.substring(0, 50);
|
||||
return slug;
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
const name = e.target.value;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name,
|
||||
slug: !isEdit && !prev.slug ? generateSlug(name) : prev.slug,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
...formData,
|
||||
parent_id: formData.parent_id === '' ? null : parseInt(formData.parent_id),
|
||||
sort_order: parseInt(formData.sort_order) || 0,
|
||||
};
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
// 过滤可选的上级栏目(编辑时排除自己和自己的子树)
|
||||
const getAvailableParents = (items, excludeId) => {
|
||||
const result = [{ value: '', label: '无(顶级栏目)' }];
|
||||
for (const item of items) {
|
||||
if (item.id === excludeId) continue;
|
||||
result.push({ value: item.id, label: item.name });
|
||||
// 最多两级,顶级栏目的子栏目不能再作为上级
|
||||
if (item.children?.length > 0) {
|
||||
for (const child of item.children) {
|
||||
if (child.id === excludeId) continue;
|
||||
// 二级栏目不能再有子栏目,所以不作为上级选项
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const parentOptions = getAvailableParents(treeData, column?.id);
|
||||
|
||||
// 如果当前是二级栏目,不能更改上级(防止变成三级)
|
||||
const isSecondLevel = column?.parent_id != null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 栏目名称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
栏目名称 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={handleNameChange}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
placeholder="如:产品中心"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 上级栏目 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">上级栏目</label>
|
||||
<select
|
||||
value={formData.parent_id}
|
||||
onChange={(e) => handleChange('parent_id', e.target.value)}
|
||||
disabled={isEdit && isSecondLevel}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none disabled:bg-slate-100"
|
||||
>
|
||||
{parentOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{isEdit && isSecondLevel && (
|
||||
<p className="text-xs text-slate-500 mt-1">二级栏目的上级不可更改</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL标识 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
URL标识
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => handleChange('slug', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
placeholder="留空将由系统自动生成拼音"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">用于URL显示,只能包含字母、数字、连字符</p>
|
||||
</div>
|
||||
|
||||
{/* 栏目描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">栏目描述</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
rows={2}
|
||||
placeholder="栏目简介..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 图标名称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">图标名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.icon_name}
|
||||
onChange={(e) => handleChange('icon_name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
placeholder="如:Newspaper、Heart、Activity"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">lucide-react 图标组件名称,留空使用默认图标</p>
|
||||
</div>
|
||||
|
||||
{/* 排序权重 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">排序权重</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => handleChange('sort_order', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">数字越小越靠前</p>
|
||||
</div>
|
||||
|
||||
{/* 是否前端可见 */}
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_enabled}
|
||||
onChange={(e) => handleChange('is_enabled', e.target.checked)}
|
||||
className="w-4 h-4 text-emerald-600 border-slate-300 rounded focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">前端可见(显示在栏目列表中)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 展示模式 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">展示模式</label>
|
||||
<div className="flex gap-3">
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => (
|
||||
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="display_mode"
|
||||
value={opt.value}
|
||||
checked={formData.display_mode === opt.value}
|
||||
onChange={(e) => handleChange('display_mode', e.target.value)}
|
||||
className="w-4 h-4 text-emerald-600 border-slate-300 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 布局样式 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">布局样式</label>
|
||||
<div className="flex gap-3">
|
||||
{LAYOUT_STYLE_OPTIONS.map(opt => (
|
||||
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="layout_style"
|
||||
value={opt.value}
|
||||
checked={formData.layout_style === opt.value}
|
||||
onChange={(e) => handleChange('layout_style', e.target.value)}
|
||||
className="w-4 h-4 text-emerald-600 border-slate-300 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isEdit ? '保存' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// ========== 删除确认弹窗 ==========
|
||||
|
||||
const DeleteConfirmModal = ({ column, onConfirm, onCancel, loading }) => {
|
||||
const [force, setForce] = useState(false);
|
||||
const hasChildren = column?.children?.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-center py-4">
|
||||
<AlertCircle className="w-12 h-12 text-rose-500 mx-auto mb-3" />
|
||||
<p className="text-slate-600">
|
||||
确定要删除栏目 <strong className="text-slate-900">「{column?.name}」</strong> 吗?
|
||||
</p>
|
||||
{hasChildren && (
|
||||
<p className="text-amber-600 text-sm mt-2">
|
||||
该栏目存在 {column.children.length} 个子栏目
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 text-emerald-600 border-slate-300 rounded focus:ring-emerald-500"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-amber-800">强制删除关联内容</span>
|
||||
<p className="text-amber-700 mt-0.5">
|
||||
勾选后将同时删除该栏目下的所有子栏目,此操作不可恢复!
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => onConfirm(force)}
|
||||
disabled={loading || (hasChildren && !force)}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ========== 树形栏目行 ==========
|
||||
|
||||
const TreeRow = ({ column, level = 0, expandedKeys, onToggleExpand, onEdit, onDelete }) => {
|
||||
const hasChildren = column.children && column.children.length > 0;
|
||||
const isExpanded = expandedKeys.has(column.id);
|
||||
const indent = level * 24;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const displayModeLabel = DISPLAY_MODE_OPTIONS.find(o => o.value === column.display_mode)?.label || column.display_mode;
|
||||
const layoutLabel = LAYOUT_STYLE_OPTIONS.find(o => o.value === column.layout_style)?.label || column.layout_style;
|
||||
|
||||
const handleCopyUrl = (e) => {
|
||||
e.stopPropagation();
|
||||
const url = `/column/${column.slug}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex items-center gap-3 p-4 hover:bg-slate-50 transition-colors border-b border-slate-100 ${
|
||||
level > 0 ? 'bg-slate-50/50' : ''
|
||||
}`}
|
||||
>
|
||||
{/* 缩进 + 展开按钮 */}
|
||||
<div className="flex items-center" style={{ marginLeft: indent }}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => onToggleExpand(column.id)}
|
||||
className="w-5 h-5 flex items-center justify-center text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-slate-100 rounded text-xs font-medium text-slate-500 flex-shrink-0">
|
||||
{column.sort_order}
|
||||
</div>
|
||||
|
||||
{/* 栏目信息 */}
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-slate-900">{column.name}</h3>
|
||||
{!column.is_enabled && (
|
||||
<Badge variant="warning">隐藏</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<code className="text-xs text-slate-500 bg-slate-50 px-1.5 py-0.5 rounded truncate">
|
||||
/column/{column.slug}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="text-slate-400 hover:text-emerald-600 transition-colors"
|
||||
title="复制访问地址"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-emerald-600" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
{column.description && (
|
||||
<span className="text-xs text-slate-400 truncate">· {column.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展示模式 */}
|
||||
<div className="hidden md:block w-20 text-center flex-shrink-0">
|
||||
<Badge variant="default">{displayModeLabel}</Badge>
|
||||
</div>
|
||||
|
||||
{/* 布局样式 */}
|
||||
<div className="hidden md:block w-20 text-center flex-shrink-0">
|
||||
<Badge variant="default">{layoutLabel}</Badge>
|
||||
</div>
|
||||
|
||||
{/* 图标 */}
|
||||
<div className="hidden md:block w-20 text-center flex-shrink-0">
|
||||
<span className="text-xs text-slate-500">{column.icon_name || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 状态 */}
|
||||
<div className="w-16 text-center flex-shrink-0">
|
||||
{column.is_enabled ? (
|
||||
<Badge variant="success">可见</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">隐藏</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onEdit(column)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(column)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 子栏目 */}
|
||||
{isExpanded && hasChildren && (
|
||||
<div>
|
||||
{column.children.map(child => (
|
||||
<TreeRow
|
||||
key={child.id}
|
||||
column={child}
|
||||
level={level + 1}
|
||||
expandedKeys={expandedKeys}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ========== 主组件 ==========
|
||||
|
||||
const ColumnList = () => {
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [treeData, setTreeData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = useState(new Set());
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editingColumn, setEditingColumn] = useState(null);
|
||||
const [deleteColumn, setDeleteColumn] = useState(null);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
const loadColumns = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [flatRes, treeRes] = await Promise.all([
|
||||
columnsApi.getAll(),
|
||||
columnsApi.getTree(),
|
||||
]);
|
||||
setColumns(flatRes.items || []);
|
||||
setTreeData(treeRes.items || []);
|
||||
// 默认展开所有顶级栏目
|
||||
const topIds = new Set((treeRes.items || []).map(c => c.id));
|
||||
setExpandedKeys(topIds);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadColumns();
|
||||
}, [loadColumns]);
|
||||
|
||||
const handleToggleExpand = (id) => {
|
||||
setExpandedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingColumn(null);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (column) => {
|
||||
setEditingColumn(column);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (editingColumn) {
|
||||
await columnsApi.update(editingColumn.id, formData);
|
||||
setMessage({ type: 'success', text: '栏目更新成功' });
|
||||
} else {
|
||||
await columnsApi.create(formData);
|
||||
setMessage({ type: 'success', text: '栏目创建成功' });
|
||||
}
|
||||
setShowFormModal(false);
|
||||
setEditingColumn(null);
|
||||
loadColumns();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err.message || '操作失败' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (force) => {
|
||||
if (!deleteColumn) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await columnsApi.delete(deleteColumn.id, force);
|
||||
setMessage({ type: 'success', text: '栏目删除成功' });
|
||||
setDeleteColumn(null);
|
||||
loadColumns();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err.message || '删除失败' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandAll = () => {
|
||||
const allIds = new Set();
|
||||
const collect = (items) => {
|
||||
for (const item of items) {
|
||||
allIds.add(item.id);
|
||||
if (item.children) collect(item.children);
|
||||
}
|
||||
};
|
||||
collect(treeData);
|
||||
setExpandedKeys(allIds);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedKeys(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">栏目管理</h1>
|
||||
<p className="text-slate-500 mt-1">管理全站栏目分类架构,支持两级栏目</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={handleExpandAll}>
|
||||
全部展开
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCollapseAll}>
|
||||
全部折叠
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加栏目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message.text && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 栏目列表 */}
|
||||
<Card>
|
||||
<CardHeader title="栏目列表" icon={FolderTree} />
|
||||
<CardContent className="p-0">
|
||||
{/* 表头 */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<div className="w-5 flex-shrink-0" />
|
||||
<div className="w-8 flex-shrink-0 text-center">排序</div>
|
||||
<div className="flex-1">栏目信息</div>
|
||||
<div className="hidden md:block w-20 text-center flex-shrink-0">展示模式</div>
|
||||
<div className="hidden md:block w-20 text-center flex-shrink-0">布局样式</div>
|
||||
<div className="hidden md:block w-20 text-center flex-shrink-0">图标</div>
|
||||
<div className="w-16 text-center flex-shrink-0">状态</div>
|
||||
<div className="w-20 flex-shrink-0 text-right">操作</div>
|
||||
</div>
|
||||
|
||||
{loading && columns.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : treeData.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FolderTree className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p>暂无栏目,点击上方按钮创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{treeData.map(column => (
|
||||
<TreeRow
|
||||
key={column.id}
|
||||
column={column}
|
||||
level={0}
|
||||
expandedKeys={expandedKeys}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteColumn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
<Modal
|
||||
isOpen={showFormModal}
|
||||
onClose={() => { setShowFormModal(false); setEditingColumn(null); }}
|
||||
title={editingColumn ? '编辑栏目' : '添加栏目'}
|
||||
size="lg"
|
||||
>
|
||||
<ColumnForm
|
||||
column={editingColumn}
|
||||
treeData={treeData}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => { setShowFormModal(false); setEditingColumn(null); }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Modal
|
||||
isOpen={!!deleteColumn}
|
||||
onClose={() => setDeleteColumn(null)}
|
||||
title="确认删除"
|
||||
>
|
||||
<DeleteConfirmModal
|
||||
column={deleteColumn}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteColumn(null)}
|
||||
loading={loading}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnList;
|
||||
@@ -0,0 +1,613 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Upload, Trash2, Loader2, Search, Image as ImageIcon,
|
||||
Copy, Check, AlertCircle, X, Grid, List, Eraser
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../../components/admin/Button';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/admin/Card';
|
||||
import { Modal } from '../../../components/admin/Modal';
|
||||
import api from '../../../api';
|
||||
|
||||
const ImageManager = () => {
|
||||
const [images, setImages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [size] = useState(20);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [moduleFilter, setModuleFilter] = useState('');
|
||||
const [selectedImages, setSelectedImages] = useState([]);
|
||||
const [viewMode, setViewMode] = useState('grid'); // grid | list
|
||||
const [previewImage, setPreviewImage] = useState(null);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [cleanupModal, setCleanupModal] = useState(false);
|
||||
const [unreferencedImages, setUnreferencedImages] = useState([]);
|
||||
const [cleanupLoading, setCleanupLoading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadImages();
|
||||
}, [page, moduleFilter]);
|
||||
|
||||
const loadImages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { page, size };
|
||||
if (moduleFilter) params.module = moduleFilter;
|
||||
|
||||
const res = await api.get('/api/images', { params });
|
||||
setImages(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('module', moduleFilter || 'news');
|
||||
|
||||
try {
|
||||
await api.post('/api/images/upload', formData);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
console.error('上传失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
setMessage({
|
||||
type: errorCount === 0 ? 'success' : 'error',
|
||||
text: `上传完成:${successCount} 成功${errorCount > 0 ? `,${errorCount} 失败` : ''}`
|
||||
});
|
||||
|
||||
// 清除 input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
loadImages();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('确定要删除这张图片吗?')) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.delete(`/api/images/${id}`);
|
||||
setMessage({ type: 'success', text: '删除成功' });
|
||||
loadImages();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '删除失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedImages.length === 0) return;
|
||||
if (!confirm(`确定要删除选中的 ${selectedImages.length} 张图片吗?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/api/images/batch-delete', selectedImages);
|
||||
setMessage({ type: 'success', text: '批量删除成功' });
|
||||
setSelectedImages([]);
|
||||
loadImages();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '删除失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedImages(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(i => i !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (selectedImages.length === images.length) {
|
||||
setSelectedImages([]);
|
||||
} else {
|
||||
setSelectedImages(images.map(img => img.id));
|
||||
}
|
||||
};
|
||||
|
||||
const copyUrl = (url) => {
|
||||
navigator.clipboard.writeText(window.location.origin + url);
|
||||
setMessage({ type: 'success', text: '链接已复制到剪贴板' });
|
||||
};
|
||||
|
||||
const handleScanUnreferenced = async () => {
|
||||
setCleanupLoading(true);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 25000); // 25s timeout
|
||||
try {
|
||||
const res = await api.get('/api/images/unreferenced', { signal: controller.signal });
|
||||
setUnreferencedImages(res.items || []);
|
||||
setCleanupModal(true);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
setMessage({ type: 'error', text: '扫描超时(25秒),图片数量较大,请稍后重试' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: '扫描失败: ' + err.message });
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setCleanupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
if (unreferencedImages.length === 0) {
|
||||
setCleanupModal(false);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`确定要删除 ${unreferencedImages.length} 张未引用图片吗?此操作不可恢复。`)) return;
|
||||
|
||||
setCleanupLoading(true);
|
||||
try {
|
||||
const res = await api.post('/api/images/cleanup');
|
||||
setMessage({ type: 'success', text: res.message });
|
||||
setCleanupModal(false);
|
||||
setUnreferencedImages([]);
|
||||
loadImages();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '清理失败: ' + err.message });
|
||||
} finally {
|
||||
setCleanupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '-';
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
return `${(kb / 1024).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const getImageUrl = (path) => {
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/')) return path;
|
||||
if (path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">图片管理</h1>
|
||||
<p className="text-slate-500 mt-1">管理网站图片资源,支持多模块共用</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleScanUnreferenced}
|
||||
disabled={cleanupLoading}
|
||||
>
|
||||
{cleanupLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Eraser className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
清理未引用图片
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{uploading ? '上传中...' : '上传图片'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.text && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏 */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={moduleFilter}
|
||||
onChange={(e) => setModuleFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">所有模块</option>
|
||||
<option value="news">新闻资讯</option>
|
||||
<option value="product">产品介绍</option>
|
||||
<option value="page">页面</option>
|
||||
</select>
|
||||
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-600">
|
||||
已选择 {selectedImages.length} 项
|
||||
</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-emerald-100 text-emerald-600'
|
||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Grid className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-emerald-100 text-emerald-600'
|
||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 图片列表 */}
|
||||
<Card>
|
||||
<CardHeader title={`图片列表 (${total})`} icon={ImageIcon} />
|
||||
<CardContent>
|
||||
{loading && images.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<ImageIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p>暂无图片,点击上方按钮上传</p>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
// 网格视图
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className={`group relative aspect-square border rounded-lg overflow-hidden cursor-pointer transition-all ${
|
||||
selectedImages.includes(img.id)
|
||||
? 'ring-2 ring-emerald-500 border-emerald-500'
|
||||
: 'border-slate-200 hover:border-emerald-300'
|
||||
}`}
|
||||
onClick={() => toggleSelect(img.id)}
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(img.thumb_path || img.file_path)}
|
||||
alt={img.original_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* 选中标记 */}
|
||||
{selectedImages.includes(img.id) && (
|
||||
<div className="absolute top-2 left-2 w-5 h-5 bg-emerald-500 rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬停操作 */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewImage(img);
|
||||
}}
|
||||
className="p-2 bg-white rounded-lg text-slate-700 hover:text-emerald-600"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyUrl(img.file_path);
|
||||
}}
|
||||
className="p-2 bg-white rounded-lg text-slate-700 hover:text-emerald-600"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(img.id);
|
||||
}}
|
||||
className="p-2 bg-white rounded-lg text-slate-700 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/70 to-transparent text-white text-xs">
|
||||
<p className="truncate">{img.original_name}</p>
|
||||
<p className="opacity-80">{formatSize(img.file_size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 列表视图
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedImages.length === images.length && images.length > 0}
|
||||
onChange={selectAll}
|
||||
className="w-4 h-4 text-emerald-600 border-slate-300 rounded focus:ring-emerald-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">图片</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">文件名</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">模块</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">尺寸</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">大小</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">上传时间</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{images.map((img) => (
|
||||
<tr key={img.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedImages.includes(img.id)}
|
||||
onChange={() => toggleSelect(img.id)}
|
||||
className="w-4 h-4 text-emerald-600 border-slate-300 rounded focus:ring-emerald-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<img
|
||||
src={getImageUrl(img.thumb_path || img.file_path)}
|
||||
alt=""
|
||||
className="w-12 h-12 object-cover rounded cursor-pointer"
|
||||
onClick={() => setPreviewImage(img)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<p className="text-sm text-slate-900">{img.original_name}</p>
|
||||
<p className="text-xs text-slate-500 truncate max-w-[200px]">{img.file_path}</p>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="px-2 py-1 text-xs bg-slate-100 text-slate-600 rounded">
|
||||
{img.module || 'news'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-600">
|
||||
{img.width && img.height ? `${img.width}×${img.height}` : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-600">
|
||||
{formatSize(img.file_size)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{formatDate(img.created_at)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => copyUrl(img.file_path)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
title="复制链接"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(img.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-500">
|
||||
共 {total} 张,第 {page}/{totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 清理未引用图片弹窗 */}
|
||||
<Modal
|
||||
isOpen={cleanupModal}
|
||||
onClose={() => setCleanupModal(false)}
|
||||
title="清理未引用图片"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{unreferencedImages.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Check className="w-12 h-12 mx-auto mb-3 text-emerald-500" />
|
||||
<p>所有图片均已被引用,无需清理</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-slate-600">
|
||||
扫描到 <span className="font-bold text-rose-600">{unreferencedImages.length}</span> 张未被任何文章引用的图片:
|
||||
</p>
|
||||
<div className="max-h-72 overflow-y-auto border border-slate-200 rounded-lg">
|
||||
{unreferencedImages.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="flex items-center gap-3 p-3 border-b border-slate-100 last:border-b-0 hover:bg-slate-50"
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(img.thumb_path || img.file_path)}
|
||||
alt={img.original_name}
|
||||
loading="lazy"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
className="w-10 h-10 object-cover rounded bg-slate-100"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-slate-900 truncate">{img.original_name}</p>
|
||||
<p className="text-xs text-slate-500">{formatSize(img.file_size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => setCleanupModal(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleCleanup} disabled={cleanupLoading}>
|
||||
{cleanupLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
确认删除
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 图片预览弹窗 */}
|
||||
<Modal
|
||||
isOpen={!!previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
title="图片预览"
|
||||
>
|
||||
{previewImage && (
|
||||
<div className="space-y-4">
|
||||
<img
|
||||
src={getImageUrl(previewImage.file_path)}
|
||||
alt={previewImage.original_name}
|
||||
className="max-w-full max-h-96 mx-auto rounded-lg"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">文件名:</span>
|
||||
<span className="text-slate-900">{previewImage.original_name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">尺寸:</span>
|
||||
<span className="text-slate-900">
|
||||
{previewImage.width && previewImage.height
|
||||
? `${previewImage.width}×${previewImage.height}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">大小:</span>
|
||||
<span className="text-slate-900">{formatSize(previewImage.file_size)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">上传时间:</span>
|
||||
<span className="text-slate-900">{formatDate(previewImage.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-slate-50 rounded-lg">
|
||||
<code className="flex-1 text-sm text-slate-600 truncate">
|
||||
{window.location.origin + previewImage.file_path}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyUrl(previewImage.file_path)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageManager;
|
||||
@@ -0,0 +1,319 @@
|
||||
// ⚠️ 此新闻模块已废弃,不再使用。
|
||||
// 已被「栏目管理 + 内容管理」(columns / articles)取代。
|
||||
// 请勿删除此注释,供后续维护人员参考。
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus, Pencil, Trash2, Loader2, Search,
|
||||
FolderOpen, AlertCircle, GripVertical
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../../components/admin/Button';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/admin/Card';
|
||||
import { Modal } from '../../../components/admin/Modal';
|
||||
import api from '../../../api';
|
||||
|
||||
const CategoryForm = ({ category, onSubmit, onCancel, loading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: category?.name || '',
|
||||
slug: category?.slug || '',
|
||||
description: category?.description || '',
|
||||
sort_order: category?.sort_order ?? 0,
|
||||
is_enabled: category?.is_enabled ?? true
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
// 自动生成 slug(中文交给后端转拼音)
|
||||
const generateSlug = (name) => {
|
||||
if (!name) return '';
|
||||
const slug = name
|
||||
.trim()
|
||||
.replace(/[\s]+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9-]/g, '')
|
||||
.toLowerCase()
|
||||
.substring(0, 50);
|
||||
return slug;
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
const name = e.target.value;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name,
|
||||
slug: !category && !prev.slug ? generateSlug(name) : prev.slug
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
栏目名称 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={handleNameChange}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="如:公司新闻"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
URL标识 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="如:company-news"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">用于URL显示,只能包含字母、数字、连字符</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">栏目描述</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
rows={3}
|
||||
placeholder="栏目简介..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">排序权重</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
min={0}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">数字越小越靠前</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-emerald-600 border-slate-300 rounded focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">显示在资讯列表中</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{category ? '保存' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const CategoryList = () => {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState(null);
|
||||
const [deleteId, setDeleteId] = useState(null);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/api/news/categories?include_count=true');
|
||||
setCategories(res.items || []);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingCategory(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (category) => {
|
||||
setEditingCategory(category);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (editingCategory) {
|
||||
await api.put(`/api/news/categories/${editingCategory.id}`, formData);
|
||||
setMessage({ type: 'success', text: '栏目更新成功' });
|
||||
} else {
|
||||
await api.post('/api/news/categories', formData);
|
||||
setMessage({ type: 'success', text: '栏目创建成功' });
|
||||
}
|
||||
setShowModal(false);
|
||||
loadCategories();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.detail || '操作失败' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.delete(`/api/news/categories/${id}`);
|
||||
setMessage({ type: 'success', text: '栏目删除成功' });
|
||||
setDeleteId(null);
|
||||
loadCategories();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.detail || '删除失败' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">栏目管理</h1>
|
||||
<p className="text-slate-500 mt-1">管理新闻资讯的栏目分类</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加栏目
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message.text && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="栏目列表" icon={FolderOpen} />
|
||||
<CardContent>
|
||||
{loading && categories.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FolderOpen className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p>暂无栏目,点击上方按钮创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center gap-4 p-4 hover:bg-slate-50 transition-colors">
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-slate-100 rounded text-slate-500">
|
||||
{category.sort_order}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-slate-900">{category.name}</h3>
|
||||
{!category.is_enabled && (
|
||||
<span className="px-2 py-0.5 text-xs bg-slate-100 text-slate-500 rounded">不显示在资讯列表</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
/{category.slug} · {category.post_count || 0} 篇文章
|
||||
{category.description && ` · ${category.description}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(category)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(category.id)}
|
||||
disabled={category.post_count > 0}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={category.post_count > 0 ? '该栏目下还有文章,无法删除' : '删除'}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title={editingCategory ? '编辑栏目' : '添加栏目'}
|
||||
>
|
||||
<CategoryForm
|
||||
category={editingCategory}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setShowModal(false)}
|
||||
loading={loading}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Modal
|
||||
isOpen={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
title="确认删除"
|
||||
>
|
||||
<div className="text-center py-4">
|
||||
<AlertCircle className="w-12 h-12 text-rose-500 mx-auto mb-3" />
|
||||
<p className="text-slate-600">确定要删除这个栏目吗?此操作不可恢复。</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(deleteId)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryList;
|
||||
@@ -0,0 +1,265 @@
|
||||
// ⚠️ 此新闻模块已废弃,不再使用。
|
||||
// 已被「栏目管理 + 内容管理」(columns / articles)取代。
|
||||
// 请勿删除此注释,供后续维护人员参考。
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import '@uiw/react-md-editor/markdown-editor.css';
|
||||
import api from '../../../api';
|
||||
|
||||
export default function CrawlNews() {
|
||||
const navigate = useNavigate();
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 获取分类列表
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const data = await api.get('/api/news/categories');
|
||||
setCategories(data.items || []);
|
||||
if (data.items && data.items.length > 0) {
|
||||
setSelectedCategory(data.items[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取分类失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCrawl = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('请输入URL');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/crawl', {
|
||||
url: url.trim(),
|
||||
use_playwright: false
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
setError(data.error || '采集失败');
|
||||
return;
|
||||
}
|
||||
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError('网络请求失败: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!result || !selectedCategory) {
|
||||
setError('请选择分类');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 查找第一张图片作为封面
|
||||
let coverImage = '';
|
||||
if (result.images && result.images.length > 0) {
|
||||
coverImage = result.images[0].local_path || result.images[0].thumb_path || '';
|
||||
}
|
||||
|
||||
// 生成 slug(中文交给后端转拼音)
|
||||
const slug = result.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 200) || '';
|
||||
|
||||
const postData = {
|
||||
title: result.title,
|
||||
slug: slug,
|
||||
content: result.content,
|
||||
summary: result.summary || result.content.slice(0, 200),
|
||||
category_id: parseInt(selectedCategory),
|
||||
is_draft: true,
|
||||
cover_image: coverImage,
|
||||
cover_thumb: result.images && result.images[0]?.thumb_path || coverImage
|
||||
};
|
||||
|
||||
const data = await api.post('/api/news/posts', postData);
|
||||
navigate(`/admin/news/posts/${data.id}/edit`);
|
||||
} catch (err) {
|
||||
setError('保存失败: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/admin/news');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-slate-800">采集资讯</h1>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-800 transition-colors"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* URL输入区 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
文章链接 URL
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/article"
|
||||
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCrawl}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
采集中...
|
||||
</span>
|
||||
) : '开始采集'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
支持微信公众号、掘金、博客园、腾讯云社区等平台
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 采集结果 */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">采集结果预览</h2>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={result.title}
|
||||
onChange={(e) => setResult({...result, title: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分类选择 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
选择分类 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">请选择分类</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{categories.length === 0 && (
|
||||
<p className="mt-1 text-sm text-amber-600">
|
||||
没有可用分类,请先<Link to="/admin/news/categories" className="underline">创建分类</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片预览 */}
|
||||
{result.images && result.images.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
采集的图片 ({result.images.length}张)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{result.images.map((img, idx) => (
|
||||
<div key={idx} className="relative">
|
||||
<img
|
||||
src={img.local_path || img.original_url}
|
||||
alt={`图片 ${idx + 1}`}
|
||||
className="w-24 h-24 object-cover rounded-lg border border-slate-200"
|
||||
onError={(e) => { e.target.src = '/placeholder.png'; }}
|
||||
/>
|
||||
{idx === 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-emerald-500 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
封面
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容编辑 */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">内容编辑</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={result.content || ''}
|
||||
onChange={(val) => setResult({...result, content: val || ''})}
|
||||
height={500}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
共 {result.content.length} 字符
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !selectedCategory}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? '保存中...' : '保存到草稿'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
// ⚠️ 此新闻模块已废弃,不再使用。
|
||||
// 已被「栏目管理 + 内容管理」(columns / articles)取代。
|
||||
// 请勿删除此注释,供后续维护人员参考。
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Loader2, Save, Eye, Image as ImageIcon,
|
||||
FileText, Code, AlertCircle, Upload, X, Copy, Check
|
||||
} from 'lucide-react';
|
||||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||
import '@uiw/react-md-editor/markdown-editor.css';
|
||||
import { Button } from '../../../components/admin/Button';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/admin/Card';
|
||||
import { Modal } from '../../../components/admin/Modal';
|
||||
import api from '../../../api';
|
||||
|
||||
// Markdown 编辑器组件(基于 react-md-editor)
|
||||
const MarkdownEditor = ({ value, onChange, onImageInsert }) => {
|
||||
const apiRef = useRef(null);
|
||||
|
||||
const customImageCommand = {
|
||||
...commands.image,
|
||||
execute: (state, textApi) => {
|
||||
apiRef.current = textApi;
|
||||
onImageInsert?.((img) => {
|
||||
const url = typeof img === 'string' ? img : img.file_path;
|
||||
const imageMarkdown = ``;
|
||||
textApi.replaceSelection(imageMarkdown);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const customCommands = commands.getCommands().map((cmd) => {
|
||||
if (cmd.name === 'image' || cmd.keyCommand === 'image') {
|
||||
return customImageCommand;
|
||||
}
|
||||
if (cmd.groupName === 'title' && Array.isArray(cmd.children)) {
|
||||
return {
|
||||
...cmd,
|
||||
children: cmd.children.map((child) =>
|
||||
child.keyCommand === 'image' ? customImageCommand : child
|
||||
),
|
||||
};
|
||||
}
|
||||
return cmd;
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
height={768}
|
||||
commands={customCommands}
|
||||
extraCommands={commands.getExtraCommands()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// HTML 编辑器组件(支持可视化 / 源码分屏)
|
||||
const HtmlEditor = ({ value, onChange, onImageInsert }) => {
|
||||
const editorRef = useRef(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('visual'); // 'visual' | 'split'
|
||||
const [htmlSource, setHtmlSource] = useState(value || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
||||
editorRef.current.innerHTML = value || '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHtmlSource(value || '');
|
||||
}, [value]);
|
||||
|
||||
const handleInput = () => {
|
||||
if (editorRef.current) {
|
||||
const html = editorRef.current.innerHTML;
|
||||
onChange(html);
|
||||
setHtmlSource(html);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceChange = (e) => {
|
||||
const html = e.target.value;
|
||||
setHtmlSource(html);
|
||||
onChange(html);
|
||||
};
|
||||
|
||||
const toggleViewMode = () => {
|
||||
if (viewMode === 'visual') {
|
||||
const currentHtml = editorRef.current?.innerHTML || value || '';
|
||||
setHtmlSource(currentHtml);
|
||||
setViewMode('split');
|
||||
} else {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = htmlSource;
|
||||
}
|
||||
setViewMode('visual');
|
||||
}
|
||||
};
|
||||
|
||||
const insertImage = () => {
|
||||
if (viewMode === 'split') {
|
||||
onImageInsert?.((url) => {
|
||||
const imgTag = `<img src="${url}" alt="" style="max-width:100%;" />`;
|
||||
setHtmlSource((prev) => {
|
||||
const next = prev + imgTag;
|
||||
onChange(next);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
onImageInsert?.((url) => {
|
||||
document.execCommand('insertImage', false, url);
|
||||
handleInput();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const execCommand = (command, val = null) => {
|
||||
if (viewMode !== 'visual') return;
|
||||
document.execCommand(command, false, val);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.focus();
|
||||
handleInput();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg overflow-hidden ${isFocused && viewMode === 'visual' ? 'ring-2 ring-emerald-500 border-emerald-500' : 'border-slate-300'}`}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-1 px-3 py-2 bg-slate-50 border-b border-slate-300">
|
||||
{viewMode === 'visual' && (
|
||||
<>
|
||||
<button type="button" onClick={() => execCommand('bold')} className="px-2 py-1 text-sm font-bold text-slate-600 hover:bg-white rounded">B</button>
|
||||
<button type="button" onClick={() => execCommand('italic')} className="px-2 py-1 text-sm italic text-slate-600 hover:bg-white rounded">I</button>
|
||||
<button type="button" onClick={() => execCommand('underline')} className="px-2 py-1 text-sm underline text-slate-600 hover:bg-white rounded">U</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<button type="button" onClick={() => execCommand('formatBlock', 'H1')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">H1</button>
|
||||
<button type="button" onClick={() => execCommand('formatBlock', 'H2')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">H2</button>
|
||||
<button type="button" onClick={() => execCommand('formatBlock', 'P')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">P</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<button type="button" onClick={() => execCommand('insertUnorderedList')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">• 列表</button>
|
||||
<button type="button" onClick={() => execCommand('insertOrderedList')} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">1. 列表</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertImage}
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm text-slate-600 hover:text-emerald-600 hover:bg-white rounded"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
图片
|
||||
</button>
|
||||
{viewMode === 'visual' && (
|
||||
<button type="button" onClick={() => {
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
let targetImg = null;
|
||||
if (ancestor.nodeName === 'IMG') {
|
||||
targetImg = ancestor;
|
||||
} else if (ancestor.nodeType === 1) {
|
||||
targetImg = ancestor.closest?.('p,div')?.querySelector?.('img') || ancestor.querySelector?.('img');
|
||||
}
|
||||
if (targetImg && !targetImg.closest('a')) {
|
||||
const url = prompt('请输入链接地址:');
|
||||
if (!url) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
targetImg.parentNode.insertBefore(a, targetImg);
|
||||
a.appendChild(targetImg);
|
||||
handleInput();
|
||||
return;
|
||||
}
|
||||
}
|
||||
execCommand('createLink', prompt('请输入链接地址:'));
|
||||
}} className="px-2 py-1 text-sm text-slate-600 hover:bg-white rounded">链接</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleViewMode}
|
||||
className={`flex items-center gap-1 px-3 py-1 text-sm rounded border transition-colors ${
|
||||
viewMode === 'split'
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
{viewMode === 'split' ? '关闭源码' : '源码'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'visual' ? (
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={handleInput}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className="w-full p-4 overflow-y-auto focus:outline-none"
|
||||
style={{ minHeight: '768px' }}
|
||||
placeholder="输入内容..."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex" style={{ minHeight: '768px' }}>
|
||||
<textarea
|
||||
value={htmlSource}
|
||||
onChange={handleSourceChange}
|
||||
className="w-1/2 p-4 font-mono text-sm bg-slate-900 text-slate-100 resize-none focus:outline-none border-r border-slate-300"
|
||||
spellCheck={false}
|
||||
placeholder="在此编辑 HTML 源码..."
|
||||
/>
|
||||
<div
|
||||
className="w-1/2 p-4 bg-white overflow-y-auto prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: htmlSource }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 图片选择器弹窗
|
||||
const ImagePickerModal = ({ isOpen, onClose, onSelect }) => {
|
||||
const [images, setImages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadImages();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadImages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/api/images?page=1&size=50');
|
||||
setImages(res.items || []);
|
||||
} catch (err) {
|
||||
console.error('加载图片失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', files[0]);
|
||||
formData.append('module', 'news');
|
||||
|
||||
const res = await api.post('/api/images/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 上传成功,刷新图片列表并自动选中新图片
|
||||
await loadImages();
|
||||
onSelect(res.data.file_path);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('上传失败:', err);
|
||||
alert('上传失败: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getImageUrl = (img) => {
|
||||
const path = img.thumb_path || img.file_path;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/')) return path;
|
||||
if (path.startsWith('/uploads/')) return path;
|
||||
return `/uploads${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="选择图片" size="xl">
|
||||
<div className="flex flex-col h-[600px]">
|
||||
{/* 上传按钮 */}
|
||||
<div className="flex justify-between items-center mb-4 pb-4 border-b border-slate-200">
|
||||
<p className="text-sm text-slate-500">
|
||||
共 {images.length} 张图片
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
{uploading ? '上传中...' : '上传新图片'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片网格 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-500">
|
||||
<ImageIcon className="w-16 h-16 mx-auto mb-4 text-slate-300" />
|
||||
<p className="mb-2">暂无图片</p>
|
||||
<p className="text-sm text-slate-400">点击上方按钮上传图片</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-3 p-2">
|
||||
{images.map((img) => (
|
||||
<button
|
||||
key={img.id}
|
||||
onClick={() => onSelect(img)}
|
||||
className="relative aspect-square border border-slate-200 rounded-lg overflow-hidden hover:ring-2 hover:ring-emerald-500 transition-all group"
|
||||
title={img.original_name}
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(img)}
|
||||
alt={img.original_name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.src = '/placeholder-image.png';
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-slate-200">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const PostEdit = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
content_type: 'markdown',
|
||||
summary: '',
|
||||
cover_image: '',
|
||||
cover_thumb: '',
|
||||
category_id: '',
|
||||
is_draft: true
|
||||
});
|
||||
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [showImagePicker, setShowImagePicker] = useState(false);
|
||||
const [imagePickerCallback, setImagePickerCallback] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
if (isEdit) {
|
||||
loadPost();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const res = await api.get('/api/news/categories');
|
||||
setCategories(res.items || []);
|
||||
} catch (err) {
|
||||
console.error('加载栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPost = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const post = await api.get(`/api/news/posts/${id}`);
|
||||
setFormData({
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
content: post.content,
|
||||
content_type: post.content_type || 'markdown',
|
||||
summary: post.summary || '',
|
||||
cover_image: post.cover_image || '',
|
||||
cover_thumb: post.cover_thumb || '',
|
||||
category_id: post.category?.id || '',
|
||||
is_draft: post.is_draft
|
||||
});
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载文章失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 自动生成 slug(中文交给后端转拼音)
|
||||
const generateSlug = (title) => {
|
||||
if (!title) return '';
|
||||
const slug = title
|
||||
.trim()
|
||||
.replace(/[\s]+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9-]/g, '')
|
||||
.toLowerCase()
|
||||
.substring(0, 200);
|
||||
return slug;
|
||||
};
|
||||
|
||||
const handleTitleChange = (e) => {
|
||||
const title = e.target.value;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
title,
|
||||
slug: !isEdit && !prev.slug ? generateSlug(title) : prev.slug
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (publish = false) => {
|
||||
if (!formData.title.trim()) {
|
||||
setMessage({ type: 'error', text: '请输入标题' });
|
||||
return;
|
||||
}
|
||||
if (!formData.category_id) {
|
||||
setMessage({ type: 'error', text: '请选择栏目' });
|
||||
return;
|
||||
}
|
||||
if (!formData.content.trim()) {
|
||||
setMessage({ type: 'error', text: '请输入内容' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
...formData,
|
||||
category_id: parseInt(formData.category_id),
|
||||
is_draft: !publish
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await api.put(`/api/news/posts/${id}`, data);
|
||||
} else {
|
||||
await api.post('/api/news/posts', data);
|
||||
}
|
||||
|
||||
setMessage({ type: 'success', text: publish ? '发布成功' : '保存成功' });
|
||||
setTimeout(() => {
|
||||
navigate('/admin/news/posts');
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.detail || '保存失败' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageInsert = (callback) => {
|
||||
setImagePickerCallback(() => callback);
|
||||
setShowImagePicker(true);
|
||||
};
|
||||
|
||||
const handleImageSelect = (img) => {
|
||||
if (imagePickerCallback) {
|
||||
imagePickerCallback(img);
|
||||
}
|
||||
setShowImagePicker(false);
|
||||
setImagePickerCallback(null);
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (!formData.slug) return;
|
||||
const url = `/news/${formData.slug}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 顶部导航 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/news/posts')}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
{isEdit ? '编辑资讯' : '添加资讯'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.text && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 左侧主内容 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* 标题和标识 */}
|
||||
<Card>
|
||||
<CardHeader title="基本信息" icon={FileText} />
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
标题 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={handleTitleChange}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="输入资讯标题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
URL标识
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="留空将由系统自动生成拼音"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">如:shi-ran-geng(中文标题留空时自动生成)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
所属栏目 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.category_id}
|
||||
onChange={(e) => setFormData({ ...formData, category_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">请选择栏目</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">摘要</label>
|
||||
<textarea
|
||||
value={formData.summary}
|
||||
onChange={(e) => setFormData({ ...formData, summary: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
rows={3}
|
||||
placeholder="文章摘要,留空将自动从内容中提取"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧侧边栏 */}
|
||||
<div className="space-y-6">
|
||||
{/* 封面图 */}
|
||||
<Card>
|
||||
<CardHeader title="封面图" icon={ImageIcon} />
|
||||
<CardContent>
|
||||
{formData.cover_image ? (
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={(() => {
|
||||
const path = formData.cover_thumb || formData.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()}
|
||||
alt="封面"
|
||||
className="w-full h-32 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, cover_image: '', cover_thumb: '' })}
|
||||
className="absolute top-2 right-2 p-1 bg-rose-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleImageInsert((img) => {
|
||||
const imgData = typeof img === 'string' ? { file_path: img, thumb_path: img } : img;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
cover_image: imgData.file_path || imgData.url,
|
||||
cover_thumb: imgData.thumb_path || imgData.thumb_url
|
||||
}));
|
||||
});
|
||||
}}
|
||||
className="h-20 border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-400 hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<ImageIcon className="w-6 h-6 mb-1" />
|
||||
<span className="text-xs">选择图片</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('module', 'news');
|
||||
const res = await api.post('/api/images/upload', fd);
|
||||
if (res.success && res.data) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
cover_image: res.data.file_path,
|
||||
cover_thumb: res.data.thumb_path
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('上传失败: ' + (err.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
className="h-20 border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-400 hover:border-emerald-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<Upload className="w-6 h-6 mb-1" />
|
||||
<span className="text-xs">上传图片</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 发布设置 */}
|
||||
<Card>
|
||||
<CardHeader title="发布设置" icon={Eye} />
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-700">状态</span>
|
||||
<span className={`text-sm ${formData.is_draft ? 'text-slate-500' : 'text-emerald-600'}`}>
|
||||
{formData.is_draft ? '草稿' : '已发布'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文章访问地址 */}
|
||||
{formData.slug && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1.5">文章访问地址</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-2.5 py-1.5 bg-slate-50 border border-slate-200 rounded text-xs text-slate-600 truncate">
|
||||
/news/{formData.slug}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className="p-1.5 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded transition-colors"
|
||||
title="复制链接"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-600" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
点击右上角"保存草稿"或"发布"按钮来更改文章状态
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容编辑 - 独占一行,宽度100% */}
|
||||
<div className="mt-6">
|
||||
<Card>
|
||||
<CardHeader title="内容编辑" icon={Code} />
|
||||
<CardContent>
|
||||
{/* 编辑器类型切换 */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="content_type"
|
||||
value="markdown"
|
||||
checked={formData.content_type === 'markdown'}
|
||||
onChange={() => setFormData({ ...formData, content_type: 'markdown' })}
|
||||
className="w-4 h-4 text-emerald-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">Markdown</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="content_type"
|
||||
value="html"
|
||||
checked={formData.content_type === 'html'}
|
||||
onChange={() => setFormData({ ...formData, content_type: 'html' })}
|
||||
className="w-4 h-4 text-emerald-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">富文本 (HTML)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 编辑器 */}
|
||||
{formData.content_type === 'markdown' ? (
|
||||
<MarkdownEditor
|
||||
value={formData.content}
|
||||
onChange={(content) => setFormData({ ...formData, content })}
|
||||
onImageInsert={handleImageInsert}
|
||||
/>
|
||||
) : (
|
||||
<HtmlEditor
|
||||
value={formData.content}
|
||||
onChange={(content) => setFormData({ ...formData, content })}
|
||||
onImageInsert={handleImageInsert}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 图片选择器 */}
|
||||
<ImagePickerModal
|
||||
isOpen={showImagePicker}
|
||||
onClose={() => setShowImagePicker(false)}
|
||||
onSelect={handleImageSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostEdit;
|
||||
@@ -0,0 +1,412 @@
|
||||
// ⚠️ 此新闻模块已废弃,不再使用。
|
||||
// 已被「栏目管理 + 内容管理」(columns / articles)取代。
|
||||
// 请勿删除此注释,供后续维护人员参考。
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus, Pencil, Trash2, Loader2, Search, Eye, EyeOff,
|
||||
FileText, Filter, MoreHorizontal, Newspaper, Download
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../../components/admin/Button';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/admin/Card';
|
||||
import { Modal } from '../../../components/admin/Modal';
|
||||
import api from '../../../api';
|
||||
|
||||
const PostList = () => {
|
||||
const navigate = useNavigate();
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState(null);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
// 筛选和分页
|
||||
const [page, setPage] = useState(1);
|
||||
const [size] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
loadPosts();
|
||||
}, [page, categoryFilter, statusFilter]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const res = await api.get('/api/news/categories');
|
||||
setCategories(res.items || []);
|
||||
} catch (err) {
|
||||
console.error('加载栏目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPosts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page,
|
||||
size,
|
||||
status: statusFilter
|
||||
};
|
||||
if (categoryFilter) params.category_id = categoryFilter;
|
||||
if (keyword) params.keyword = keyword;
|
||||
|
||||
const res = await api.get('/api/news/posts', { params });
|
||||
setPosts(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '加载失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadPosts();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate('/admin/news/posts/new');
|
||||
};
|
||||
|
||||
const handleCrawl = () => {
|
||||
navigate('/admin/news/crawl');
|
||||
};
|
||||
|
||||
const handleEdit = (post) => {
|
||||
navigate(`/admin/news/posts/${post.id}/edit`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.delete(`/api/news/posts/${id}`);
|
||||
setMessage({ type: 'success', text: '删除成功' });
|
||||
setDeleteId(null);
|
||||
loadPosts();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '删除失败: ' + err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishToggle = async (post) => {
|
||||
try {
|
||||
await api.post(`/api/news/posts/${post.id}/publish`, {
|
||||
is_published: !post.is_published
|
||||
});
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: post.is_published ? '已取消发布' : '发布成功'
|
||||
});
|
||||
loadPosts();
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '操作失败: ' + err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">资讯管理</h1>
|
||||
<p className="text-slate-500 mt-1">发布和管理新闻资讯内容</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCrawl} variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
采集资讯
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加资讯
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.text && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索标题..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">所有栏目</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
</select>
|
||||
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 列表 */}
|
||||
<Card>
|
||||
<CardHeader title="资讯列表" icon={Newspaper} />
|
||||
<CardContent>
|
||||
{loading && posts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p>暂无资讯</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">标题</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">栏目</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">格式</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">状态</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">浏览</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">更新时间</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{posts.map((post) => (
|
||||
<tr key={post.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{(post.cover_image || post.cover_thumb) && (
|
||||
<img
|
||||
src={(() => {
|
||||
const path = post.cover_thumb || post.cover_image;
|
||||
if (!path) return '/placeholder-image.png';
|
||||
if (path.startsWith('http')) return path;
|
||||
if (path.startsWith('/storage/')) return path;
|
||||
return `/uploads${path}`;
|
||||
})()}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 truncate max-w-[200px]">
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">/{post.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-600">
|
||||
{post.category?.name || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-1 text-xs rounded ${
|
||||
post.content_type === 'markdown'
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'bg-purple-50 text-purple-600'
|
||||
}`}>
|
||||
{post.content_type === 'markdown' ? 'Markdown' : 'HTML'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{post.is_published ? (
|
||||
<span className="flex items-center gap-1 text-emerald-600">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span className="text-sm">已发布</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-slate-400">
|
||||
<EyeOff className="w-4 h-4" />
|
||||
<span className="text-sm">草稿</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-600 text-sm">
|
||||
{post.view_count}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-500 text-sm">
|
||||
{formatDate(post.updated_at)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handlePublishToggle(post)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
post.is_published
|
||||
? 'text-emerald-600 hover:bg-emerald-50'
|
||||
: 'text-slate-400 hover:text-emerald-600 hover:bg-emerald-50'
|
||||
}`}
|
||||
title={post.is_published ? '取消发布' : '发布'}
|
||||
>
|
||||
{post.is_published ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(post)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(post.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-500">
|
||||
共 {total} 条,第 {page}/{totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-slate-500">跳至</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={page}
|
||||
className="w-14 px-2 py-1 text-sm text-center border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-500">页</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const p = parseInt(jumpPage);
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
setPage(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
}}
|
||||
disabled={!jumpPage}
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Modal
|
||||
isOpen={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
title="确认删除"
|
||||
>
|
||||
<div className="text-center py-4">
|
||||
<div className="w-12 h-12 bg-rose-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Trash2 className="w-6 h-6 text-rose-600" />
|
||||
</div>
|
||||
<p className="text-slate-600">确定要删除这篇资讯吗?此操作不可恢复。</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(deleteId)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostList;
|
||||
@@ -0,0 +1,35 @@
|
||||
// 图片 URL 工具函数
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://129.211.173.87:10003';
|
||||
|
||||
/**
|
||||
* 获取图片的完整 URL
|
||||
* @param {string} path - 图片路径(可能是相对路径或绝对路径)
|
||||
* @returns {string} 完整的图片 URL
|
||||
*/
|
||||
export function getImageUrl(path) {
|
||||
if (!path) return '';
|
||||
|
||||
// 如果已经是完整 URL,直接返回
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// 如果以 /storage/ 开头,添加后端基础 URL
|
||||
if (path.startsWith('/storage/')) {
|
||||
return `${API_BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
// 如果以 /uploads/ 开头(旧路径),添加后端基础 URL
|
||||
if (path.startsWith('/uploads/')) {
|
||||
return `${API_BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
// 其他相对路径,添加 / 前缀
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path;
|
||||
}
|
||||
|
||||
return `${API_BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
export default getImageUrl;
|
||||
Reference in New Issue
Block a user