合并JWL,WZY,TRM代码
Change-Id: Ifb4fcad3c06733e1e005e7d8d9403e3561010fb4
diff --git a/Merge/front/src/App.css b/Merge/front/src/App.css
new file mode 100644
index 0000000..8fdd8d7
--- /dev/null
+++ b/Merge/front/src/App.css
@@ -0,0 +1,585 @@
+.app {
+ display: flex;
+ min-height: 100vh;
+ background-color: #f5f7fa;
+}
+
+/* Header */
+.header {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 60px;
+ background: #fff;
+ border-bottom: 1px solid #e8eaed;
+ display: flex;
+ align-items: center;
+ padding: 0 20px;
+ z-index: 1000;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.logo {
+ background: #ff4757;
+ color: white;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: bold;
+}
+
+.header-title {
+ font-size: 18px;
+ font-weight: 500;
+ color: #333;
+}
+
+.header-right {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.user-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #666;
+ font-size: 14px;
+}
+
+/* Sidebar */
+.sidebar {
+ position: fixed;
+ left: 0;
+ top: 60px;
+ width: 200px;
+ height: calc(100vh - 60px);
+ background: #fff;
+ border-right: 1px solid #e8eaed;
+ overflow-y: auto;
+ z-index: 999;
+}
+
+.publish-btn {
+ margin: 16px;
+ background: #ff4757;
+ color: white;
+ padding: 10px 16px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ text-align: center;
+ transition: background 0.2s;
+}
+
+.publish-btn:hover {
+ background: #ff3742;
+}
+
+.nav-menu {
+ padding: 0;
+ list-style: none;
+}
+
+.nav-item {
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.nav-link {
+ display: flex;
+ align-items: center;
+ padding: 12px 20px;
+ color: #333;
+ font-size: 14px;
+ transition: all 0.2s;
+ gap: 8px;
+}
+
+.nav-link:hover {
+ background: #f8f9fa;
+ color: #ff4757;
+}
+
+.nav-link.active {
+ background: linear-gradient(135deg, #ff4757, #ff6b7a);
+ color: white;
+ font-weight: 500;
+}
+
+.nav-link.active .lucide {
+ color: white;
+}
+
+.nav-submenu {
+ padding-left: 20px;
+ background: #fafafa;
+}
+
+.nav-submenu .nav-link {
+ padding: 8px 20px;
+ font-size: 13px;
+ color: #666;
+}
+
+.nav-submenu .nav-link:hover {
+ color: #ff4757;
+}
+
+/* Main Content */
+.main-content {
+ margin-left: 200px;
+ padding-top: 60px;
+ flex: 1;
+ min-height: 100vh;
+}
+
+.content-wrapper {
+ padding: 20px;
+ /* 原来是 max-width:1200px; */
+ max-width: none; /* 或者直接注释掉这一行 */
+ width: auto; /* 确保它能撑满父级 */
+ margin: 0; /* 取消水平 auto 居中 */
+}
+
+/* Upload Area */
+.upload-tabs {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 30px;
+ border-bottom: 1px solid #e8eaed;
+}
+
+.upload-tab {
+ padding: 12px 0;
+ font-size: 16px;
+ color: #666;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+}
+
+.upload-tab.active {
+ color: #ff4757;
+ border-bottom-color: #ff4757;
+ font-weight: 500;
+}
+
+.upload-area {
+ background: #fff;
+ border-radius: 8px;
+ padding: 80px 40px;
+ text-align: center;
+ border: 2px dashed #ddd;
+ margin-bottom: 40px;
+ transition: all 0.2s;
+ min-height: 300px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+}
+
+.upload-area:hover {
+ border-color: #ff4757;
+ background: #fff8f8;
+}
+
+.upload-area.drag-over {
+ border-color: #ff4757;
+ background: #fff0f0;
+ transform: scale(1.02);
+}
+
+.upload-icon {
+ width: 100px;
+ height: 100px;
+ margin: 0 auto 30px;
+ background: #f8f9fa;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 40px;
+ color: #ccc;
+ transition: all 0.3s ease;
+}
+
+.upload-area:hover .upload-icon {
+ background: #ff475710;
+ color: #ff4757;
+ transform: scale(1.1);
+}
+
+.upload-area.drag-over .upload-icon {
+ background: #ff475720;
+ color: #ff4757;
+ transform: scale(1.2);
+}
+
+.upload-title {
+ font-size: 20px;
+ color: #333;
+ margin-bottom: 12px;
+ font-weight: 500;
+}
+
+.upload-subtitle {
+ font-size: 14px;
+ color: #999;
+ margin-bottom: 30px;
+}
+
+.upload-btn {
+ background: #ff4757;
+ color: white;
+ padding: 14px 28px;
+ border-radius: 6px;
+ font-size: 16px;
+ font-weight: 500;
+ transition: background 0.2s;
+ min-width: 120px;
+}
+
+.upload-btn:hover:not(:disabled) {
+ background: #ff3742;
+}
+
+.upload-btn:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+}
+
+.upload-btn.uploading {
+ background: #ff4757;
+ opacity: 0.8;
+}
+
+/* File Preview */
+.file-preview-area {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 40px;
+ border: 1px solid #e8eaed;
+}
+
+/* Preview Header */
+.preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.preview-title {
+ font-size: 16px;
+ color: #333;
+ margin-bottom: 16px;
+ font-weight: 500;
+}
+
+.clear-files-btn {
+ background: #ff4757;
+ color: white;
+ padding: 6px 12px;
+ border-radius: 4px;
+ font-size: 12px;
+ transition: background 0.2s;
+}
+
+.clear-files-btn:hover {
+ background: #ff3742;
+}
+
+.file-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 16px;
+}
+
+.file-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 12px;
+ border: 1px solid #f0f0f0;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+ position: relative;
+}
+
+.file-item:hover {
+ border-color: #ff4757;
+ box-shadow: 0 2px 8px rgba(255, 71, 87, 0.1);
+}
+
+.file-item:hover .remove-file-btn {
+ opacity: 1;
+}
+
+.remove-file-btn {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ background: rgba(255, 71, 87, 0.8);
+ color: white;
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ font-weight: bold;
+ opacity: 0;
+ transition: all 0.2s;
+}
+
+.file-thumbnail {
+ width: 80px;
+ height: 80px;
+ border-radius: 6px;
+ overflow: hidden;
+ margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f8f9fa;
+}
+
+.file-thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.video-thumbnail {
+ color: #666;
+}
+
+.file-info {
+ text-align: center;
+ width: 100%;
+}
+
+.file-name {
+ font-size: 12px;
+ color: #333;
+ margin-bottom: 4px;
+ font-weight: 500;
+}
+
+.file-size {
+ font-size: 11px;
+ color: #999;
+}
+
+/* Upload Progress */
+.progress-container {
+ margin-top: 20px;
+ width: 100%;
+ max-width: 400px;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 8px;
+ background-color: #f0f0f0;
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 8px;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ff4757, #ff6b7a);
+ border-radius: 4px;
+ transition: width 0.3s ease;
+ position: relative;
+}
+
+.progress-fill::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+}
+
+.progress-text {
+ text-align: center;
+ font-size: 12px;
+ color: #666;
+ font-weight: 500;
+}
+
+/* Upload Info */
+.upload-info {
+ display: flex;
+ gap: 60px;
+ justify-content: center;
+ margin-top: 40px;
+ padding: 20px;
+ opacity: 1;
+ transition: opacity 0.3s ease;
+}
+
+.upload-info.fade-in {
+ animation: fadeIn 0.3s ease-in-out;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.info-item {
+ text-align: center;
+ flex: 1;
+ max-width: 300px;
+}
+
+.info-title {
+ font-size: 16px;
+ color: #333;
+ margin-bottom: 12px;
+ font-weight: 500;
+}
+
+.info-desc {
+ font-size: 13px;
+ color: #666;
+ line-height: 1.6;
+}
+
+/* Page Content Styles */
+.page-content {
+ padding: 40px;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ margin: 20px 0;
+ min-height: 500px;
+}
+
+.page-header {
+ margin-bottom: 40px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid #e8eaed;
+}
+
+.page-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: #333;
+ margin: 0;
+}
+
+.page-body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+}
+
+.placeholder-content {
+ text-align: center;
+ max-width: 400px;
+}
+
+.placeholder-icon {
+ color: #ff4757;
+ margin-bottom: 20px;
+ display: flex;
+ justify-content: center;
+}
+
+.placeholder-title {
+ font-size: 20px;
+ font-weight: 500;
+ color: #333;
+ margin: 0 0 15px 0;
+}
+
+.placeholder-desc {
+ font-size: 14px;
+ color: #666;
+ line-height: 1.6;
+ margin: 0;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .sidebar {
+ transform: translateX(-100%);
+ transition: transform 0.3s;
+ }
+
+ .main-content {
+ margin-left: 0;
+ }
+
+ .header-title {
+ display: none;
+ }
+
+ .upload-area {
+ padding: 60px 20px;
+ margin: 0 10px 30px;
+ }
+
+ .upload-info {
+ flex-direction: column;
+ gap: 30px;
+ padding: 10px;
+ }
+
+ .content-wrapper {
+ padding: 15px;
+ }
+
+ .upload-tabs {
+ gap: 15px;
+ }
+
+ .page-content {
+ padding: 20px;
+ margin: 10px;
+ }
+
+ .page-title {
+ font-size: 20px;
+ }
+
+ .placeholder-title {
+ font-size: 18px;
+ }
+
+ .placeholder-desc {
+ font-size: 13px;
+ }
+}
diff --git a/Merge/front/src/App.jsx b/Merge/front/src/App.jsx
new file mode 100644
index 0000000..770bc0a
--- /dev/null
+++ b/Merge/front/src/App.jsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import { BrowserRouter as Router } from 'react-router-dom'
+import Header from './components/Header'
+import Sidebar from './components/Sidebar'
+import AppRoutes from './router/App'
+import './App.css'
+
+export default function App() {
+ return (
+ <Router>
+ <div className="app">
+ <Header />
+ <Sidebar />
+ <main className="main-content">
+ <div className="content-wrapper">
+ <AppRoutes />
+ </div>
+ </main>
+ </div>
+ </Router>
+ )
+}
diff --git a/Merge/front/src/App.test.js b/Merge/front/src/App.test.js
new file mode 100644
index 0000000..1f03afe
--- /dev/null
+++ b/Merge/front/src/App.test.js
@@ -0,0 +1,8 @@
+import { render, screen } from '@testing-library/react';
+import App from './App';
+
+test('renders learn react link', () => {
+ render(<App />);
+ const linkElement = screen.getByText(/learn react/i);
+ expect(linkElement).toBeInTheDocument();
+});
diff --git a/Merge/front/src/api/posts.js b/Merge/front/src/api/posts.js
new file mode 100644
index 0000000..37acf43
--- /dev/null
+++ b/Merge/front/src/api/posts.js
@@ -0,0 +1,131 @@
+const BASE = 'http://10.126.59.25:5713' // 后端地址
+
+/**
+ * 获取待审核的帖子列表
+ * POST /apostlist
+ * @param {number|string} userId 平台管理员的用户 ID
+ * @returns Promise<[ {id, title, status}, … ]>
+ */
+export async function fetchPosts(userId) {
+ const res = await fetch(`${BASE}/apostlist`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userid: userId })
+ })
+ if (!res.ok) throw new Error(`fetchPosts: ${res.status}`)
+
+ const json = await res.json()
+ console.log('fetchPosts response:', json) // debug: inspect shape
+
+ // handle unauthorized
+ if (json.status === 'error' && json.message === 'Unauthorized') {
+ throw new Error('Unauthorized')
+ }
+
+ // normalize response into an array
+ let list
+ if (Array.isArray(json)) {
+ list = json
+ } else if (Array.isArray(json.data)) {
+ list = json.data
+ } else if (Array.isArray(json.posts)) {
+ list = json.posts
+ } else if (Array.isArray(json.data?.posts)) {
+ list = json.data.posts
+ } else {
+ list = []
+ }
+ console.log('Normalized post list:', list) // debug: check final shape
+ return list
+}
+
+/**
+ * 审核通过
+ * POST /areview
+ */
+export async function approvePost(postId, userId) {
+ const res = await fetch(`${BASE}/areview`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userid: userId, postid: postId, status: 'published' })
+ })
+ if (!res.ok) throw new Error(`approvePost: ${res.status}`)
+ return res.json()
+}
+
+/**
+ * 驳回
+ * POST /areview
+ */
+export async function rejectPost(postId, userId) {
+ const res = await fetch(`${BASE}/areview`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userid: userId, postid: postId, status: 'rejected' })
+ })
+ if (!res.ok) throw new Error(`rejectPost: ${res.status}`)
+ return res.json()
+}
+
+/**
+ * 获取单个帖子详情
+ * POST /agetpost
+ * @param {number|string} postId 帖子 ID
+ * @param {number|string} userId 平台管理员的用户 ID
+ * @returns Promise<{id, title, content, status}>
+ */
+export async function fetchPost(postId, userId) {
+ const res = await fetch(`${BASE}/agetpost`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userid: userId, postid: postId })
+ })
+ if (!res.ok) throw new Error(`fetchPost: ${res.status}`)
+ return res.json()
+}
+
+/**
+ * 获取超级管理员用户列表
+ * POST /sgetuserlist
+ * @param {number|string} userId 平台管理员的用户 ID
+ * @returns Promise<[ {id, name, role}, … ]>
+ */
+export async function fetchUserList(userId) {
+ const res = await fetch(`${BASE}/sgetuserlist`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userid: userId })
+ })
+ if (!res.ok) throw new Error(`fetchUserList: ${res.status}`)
+ return res.json()
+}
+
+export async function giveAdmin(userId, targetId) {
+ const res = await fetch(`${BASE}/sgiveadmin`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userid: userId, targetid: targetId })
+ })
+ if (!res.ok) throw new Error(`giveAdmin: ${res.status}`)
+ return res.json()
+}
+
+export async function giveSuperAdmin(userId, targetId) {
+ const res = await fetch(`${BASE}/sgivesuperadmin`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userid: userId, targetid: targetId })
+ })
+ if (!res.ok) throw new Error(`giveSuperAdmin: ${res.status}`)
+ return res.json()
+}
+
+export async function giveUser(userId, targetId) {
+ const res = await fetch(`${BASE}/sgiveuser`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userid: userId, targetid: targetId })
+ })
+ if (!res.ok) throw new Error(`giveUser: ${res.status}`)
+ return res.json()
+}
\ No newline at end of file
diff --git a/Merge/front/src/api/posts_wzy.js b/Merge/front/src/api/posts_wzy.js
new file mode 100644
index 0000000..ae65756
--- /dev/null
+++ b/Merge/front/src/api/posts_wzy.js
@@ -0,0 +1,130 @@
+// src/api/posts.js
+const BASE = 'http://127.0.0.1:5714/' // 如果有代理可以留空,否则填完整域名,如 'http://localhost:3000'
+
+/**
+ * 获取所有已发布的帖子列表
+ * GET /posts
+ */
+export async function fetchPosts() {
+ const res = await fetch(`${BASE}/posts`)
+ if (!res.ok) throw new Error(`fetchPosts: ${res.status}`)
+ console.log('fetchPosts response:', res) // debug: inspect response
+ return res.json() // 返回 [ { id, title, heat, created_at }, … ]
+}
+
+/**
+ * 查看单个帖子详情
+ * GET /posts/{postId}
+ */
+export async function fetchPost(postId) {
+ const res = await fetch(`${BASE}/posts/${postId}`)
+ if (!res.ok) throw new Error(`fetchPost(${postId}): ${res.status}`)
+ return res.json() // 返回完整的帖子对象
+}
+
+/**
+ * 发布新帖
+ * POST /posts
+ */
+export async function createPost(payload) {
+ const res = await fetch(`${BASE}/posts`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => null)
+ throw new Error(err?.error || `createPost: ${res.status}`)
+ }
+ return res.json() // { id }
+}
+
+/**
+ * 修改帖子
+ * PUT /posts/{postId}
+ */
+export async function updatePost(postId, payload) {
+ const res = await fetch(`${BASE}/posts/${postId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ })
+ if (!res.ok) throw new Error(`updatePost(${postId}): ${res.status}`)
+ // 204 No Content
+}
+
+/**
+ * 删除帖子
+ * DELETE /posts/{postId}
+ */
+export async function deletePost(postId) {
+ const res = await fetch(`${BASE}/posts/${postId}`, {
+ method: 'DELETE'
+ })
+ if (!res.ok) throw new Error(`deletePost(${postId}): ${res.status}`)
+}
+
+/**
+ * 点赞
+ * POST /posts/{postId}/like
+ */
+export async function likePost(postId, userId) {
+ const res = await fetch(`${BASE}/posts/${postId}/like`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user_id: userId })
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => null)
+ throw new Error(err?.error || `likePost: ${res.status}`)
+ }
+}
+
+/**
+ * 取消点赞
+ * DELETE /posts/{postId}/like
+ */
+export async function unlikePost(postId, userId) {
+ const res = await fetch(`${BASE}/posts/${postId}/like`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user_id: userId })
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => null)
+ throw new Error(err?.error || `unlikePost: ${res.status}`)
+ }
+}
+
+/**
+ * 收藏、取消收藏、浏览、分享 等接口:
+ * POST /posts/{postId}/favorite
+ * DELETE /posts/{postId}/favorite
+ * POST /posts/{postId}/view
+ * POST /posts/{postId}/share
+ * 用法同上,替换路径即可
+ */
+
+/**
+ * 添加评论
+ * POST /posts/{postId}/comments
+ */
+export async function addComment(postId, payload) {
+ const res = await fetch(`${BASE}/posts/${postId}/comments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ })
+ if (!res.ok) throw new Error(`addComment: ${res.status}`)
+ return res.json() // { id }
+}
+
+/**
+ * 获取评论列表
+ * GET /posts/{postId}/comments
+ */
+export async function fetchComments(postId) {
+ const res = await fetch(`${BASE}/posts/${postId}/comments`)
+ if (!res.ok) throw new Error(`fetchComments: ${res.status}`)
+ return res.json()
+}
diff --git a/Merge/front/src/components/Admin.js b/Merge/front/src/components/Admin.js
new file mode 100644
index 0000000..da11100
--- /dev/null
+++ b/Merge/front/src/components/Admin.js
@@ -0,0 +1,283 @@
+import 'antd/dist/antd.css';
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import { Layout, Tabs, Input, List, Card, Button, Tag, Spin, Typography, Divider } from 'antd';
+import '../style/Admin.css';
+import { fetchPosts, approvePost, rejectPost } from '../api/posts';
+
+export default function Admin() {
+ const ADMIN_USER_ID = 2;
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [hasPermission, setHasPermission] = useState(true);
+ const [activeTab, setActiveTab] = useState('all');
+ const [selectedPost, setSelectedPost] = useState(null);
+ const [searchTerm, setSearchTerm] = useState('');
+
+ // 新增:拖拽相关状态
+ const [leftPanelWidth, setLeftPanelWidth] = useState(300);
+ const [isResizing, setIsResizing] = useState(false);
+
+ const statusColors = {
+ draft: 'orange',
+ pending: 'blue',
+ published: 'green',
+ deleted: 'gray',
+ rejected: 'red'
+ };
+
+ useEffect(() => {
+ async function load() {
+ try {
+ const list = await fetchPosts(ADMIN_USER_ID)
+ setPosts(list)
+ } catch (e) {
+ if (e.message === 'Unauthorized') {
+ setHasPermission(false)
+ } else {
+ console.error(e)
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+ load()
+ }, [])
+
+ // 过滤并排序
+ const sortedPosts = useMemo(() => {
+ return [...posts].sort((a, b) => {
+ if (a.status === 'pending' && b.status !== 'pending') return -1
+ if (b.status === 'pending' && a.status !== 'pending') return 1
+ return 0
+ })
+ }, [posts])
+
+ // 调整:根据 activeTab 及搜索关键词过滤
+ const filteredPosts = useMemo(() => {
+ let list
+ switch (activeTab) {
+ case 'pending':
+ list = sortedPosts.filter(p => p.status === 'pending'); break
+ case 'published':
+ list = sortedPosts.filter(p => p.status === 'published'); break
+ case 'rejected':
+ list = sortedPosts.filter(p => p.status === 'rejected'); break
+ default:
+ list = sortedPosts
+ }
+ return list.filter(p =>
+ p.title.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ }, [sortedPosts, activeTab, searchTerm])
+
+ const handleApprove = async id => {
+ await approvePost(id, ADMIN_USER_ID)
+ setPosts(ps => ps.map(x => x.id === id ? { ...x, status: 'published' } : x))
+ // 同步更新选中的帖子状态
+ if (selectedPost?.id === id) {
+ setSelectedPost(prev => ({ ...prev, status: 'published' }));
+ }
+ }
+ const handleReject = async id => {
+ await rejectPost(id, ADMIN_USER_ID)
+ setPosts(ps => ps.map(x => x.id === id ? { ...x, status: 'rejected' } : x))
+ // 同步更新选中的帖子状态
+ if (selectedPost?.id === id) {
+ setSelectedPost(prev => ({ ...prev, status: 'rejected' }));
+ }
+ }
+ const handleSelect = post => setSelectedPost(post)
+
+ // 修复:拖拽处理函数
+ const handleMouseMove = useCallback((e) => {
+ if (!isResizing) return;
+
+ const newWidth = e.clientX;
+ const minWidth = 200;
+ const maxWidth = window.innerWidth - 300;
+
+ if (newWidth >= minWidth && newWidth <= maxWidth) {
+ setLeftPanelWidth(newWidth);
+ }
+ }, [isResizing]);
+
+ const handleMouseUp = useCallback(() => {
+ setIsResizing(false);
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ }, [handleMouseMove]);
+
+ const handleMouseDown = useCallback((e) => {
+ e.preventDefault();
+ setIsResizing(true);
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ }, [handleMouseMove, handleMouseUp]);
+
+ // 新增:组件卸载时清理事件监听器
+ useEffect(() => {
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ };
+ }, [handleMouseMove, handleMouseUp]);
+
+ if (loading) return <Spin spinning tip="加载中…" style={{ width: '100%', marginTop: 100 }} />;
+ if (!hasPermission) return <div style={{ textAlign: 'center', marginTop: 100 }}>权限不足</div>;
+
+ const { Content } = Layout;
+ const { TabPane } = Tabs;
+ const { Title, Text } = Typography;
+
+ return (
+ <div style={{ height: '100vh', display: 'flex' }}>
+ {/* 左侧面板 */}
+ <div
+ style={{
+ width: leftPanelWidth,
+ background: '#fff',
+ padding: 16,
+ borderRight: '1px solid #f0f0f0',
+ overflow: 'hidden'
+ }}
+ >
+ <div style={{ marginBottom: 24 }}>
+ <Title level={3}>小红书</Title>
+ <Input.Search
+ placeholder="搜索帖子标题..."
+ value={searchTerm}
+ onChange={e => setSearchTerm(e.target.value)}
+ enterButton
+ />
+ </div>
+ <Tabs activeKey={activeTab} onChange={key => { setActiveTab(key); setSelectedPost(null); }}>
+ <TabPane tab="全部" key="all" />
+ <TabPane tab="待审核" key="pending" />
+ <TabPane tab="已通过" key="published" />
+ <TabPane tab="已驳回" key="rejected" />
+ </Tabs>
+ <div style={{ height: 'calc(100vh - 200px)', overflow: 'auto' }}>
+ <List
+ dataSource={filteredPosts}
+ pagination={{
+ pageSize: 5,
+ showSizeChanger: true,
+ pageSizeOptions: ['5','10','20'],
+ onChange: () => setSelectedPost(null)
+ }}
+ renderItem={p => (
+ <List.Item
+ key={p.id}
+ style={{
+ background: selectedPost?.id === p.id ? '#e6f7ff' : '',
+ cursor: 'pointer',
+ marginBottom: 8
+ }}
+ onClick={() => handleSelect(p)}
+ >
+ <List.Item.Meta
+ avatar={
+ p.thumbnail && (
+ <img
+ src={p.thumbnail}
+ alt=""
+ style={{ width: 64, height: 64, objectFit: 'cover' }}
+ />
+ )
+ }
+ title={p.title}
+ description={`${p.createdAt} · ${p.author} · ${p.likes || 0}赞`}
+ />
+ <Tag color={statusColors[p.status]}>{p.status}</Tag>
+ </List.Item>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 拖拽分割条 */}
+ <div
+ style={{
+ width: 5,
+ cursor: 'col-resize',
+ background: isResizing ? '#1890ff' : '#f0f0f0',
+ transition: isResizing ? 'none' : 'background-color 0.2s',
+ position: 'relative',
+ flexShrink: 0
+ }}
+ onMouseDown={handleMouseDown}
+ onSelectStart={(e) => e.preventDefault()}
+ >
+ <div
+ style={{
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ width: 2,
+ height: 20,
+ background: '#999',
+ borderRadius: 1
+ }}
+ />
+ </div>
+
+ {/* 右侧内容区域 */}
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
+ <Content style={{ padding: 24, background: '#fff', overflow: 'auto' }}>
+ {selectedPost ? (
+ <Card
+ cover={selectedPost.image && <img alt="cover" src={selectedPost.image} />}
+ title={selectedPost.title}
+ extra={
+ <div>
+ {selectedPost.status === 'pending' && (
+ <>
+ <Button type="primary" onClick={() => handleApprove(selectedPost.id)}>通过</Button>
+ <Button danger onClick={() => handleReject(selectedPost.id)}>驳回</Button>
+ </>
+ )}
+ {selectedPost.status === 'published' && (
+ <Button danger onClick={() => handleReject(selectedPost.id)}>驳回</Button>
+ )}
+ {selectedPost.status === 'rejected' && (
+ <>
+ <Button onClick={() => {
+ setPosts(ps => ps.map(x => x.id === selectedPost.id ? { ...x, status: 'pending' } : x));
+ setSelectedPost(prev => ({ ...prev, status: 'pending' }));
+ }}>恢复待审</Button>
+ <Button onClick={() => {
+ setPosts(ps => ps.map(x => x.id === selectedPost.id ? { ...x, status: 'published' } : x));
+ setSelectedPost(prev => ({ ...prev, status: 'published' }));
+ }}>恢复已发</Button>
+ </>
+ )}
+ </div>
+ }
+ >
+ <Text type="secondary">
+ {`${selectedPost.createdAt} · ${selectedPost.author} · ${selectedPost.likes || 0}赞`}
+ </Text>
+ <Divider />
+ <p>{selectedPost.content}</p>
+ <Divider />
+ <Title level={4}>合规性指引</Title>
+ <ul>
+ <li>不含违法违规内容</li>
+ <li>不侵害他人合法权益</li>
+ </ul>
+ </Card>
+ ) : (
+ <Text type="secondary">请选择左侧列表中的帖子查看详情</Text>
+ )}
+ </Content>
+ </div>
+ </div>
+ );
+}
diff --git a/Merge/front/src/components/CreatePost.jsx b/Merge/front/src/components/CreatePost.jsx
new file mode 100644
index 0000000..7519d5b
--- /dev/null
+++ b/Merge/front/src/components/CreatePost.jsx
@@ -0,0 +1,168 @@
+// src/components/CreatePost.jsx
+
+import React, { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import UploadPage from './UploadPage'
+import { createPost } from '../api/posts_wzy'
+import '../style/CreatePost.css'
+
+export default function CreatePost() {
+ const navigate = useNavigate()
+
+ const [step, setStep] = useState('upload') // 'upload' | 'detail'
+ const [files, setFiles] = useState([]) // 本地 File 对象列表
+ const [mediaUrls, setMediaUrls] = useState([]) // 上传后得到的 URL 列表
+
+ // 详情表单字段
+ const [title, setTitle] = useState('')
+ const [content, setContent] = useState('')
+ const [topicId, setTopicId] = useState('')
+ const [status, setStatus] = useState('published')
+
+ const [error, setError] = useState(null)
+
+ // 静态话题数据
+ const TOPICS = [
+ { id: 1, name: '世俱杯环球评大会' },
+ { id: 2, name: '我的REDmentor' },
+ { id: 3, name: '我染上了拼豆' },
+ // …更多静态话题…
+ ]
+
+ // 上传页面回调 —— 上传完成后切换到“填写详情”步骤
+ const handleUploadComplete = async uploadedFiles => {
+ setFiles(uploadedFiles)
+
+ // TODO: 改成真实上传逻辑,拿到真正的 media_urls
+ const urls = await Promise.all(
+ uploadedFiles.map(f => URL.createObjectURL(f))
+ )
+ setMediaUrls(urls)
+
+ setStep('detail')
+ }
+
+ // 发布按钮
+ const handleSubmit = async () => {
+ if (!title.trim() || !content.trim()) {
+ setError('标题和正文必填')
+ return
+ }
+ setError(null)
+ try {
+ await createPost({
+ user_id: 1,
+ topic_id: topicId || undefined,
+ title: title.trim(),
+ content: content.trim(),
+ media_urls: mediaUrls,
+ status
+ })
+ // 发布成功后跳转回首页
+ navigate('/home', { replace: true })
+ } catch (e) {
+ setError(e.message)
+ }
+ }
+
+ // 渲染上传页
+ if (step === 'upload') {
+ return <UploadPage onComplete={handleUploadComplete} />
+ }
+
+ // 渲染详情页
+ return (
+ <div className="create-post">
+ <h2>填写帖子内容</h2>
+ {error && <div className="error">{error}</div>}
+
+ {/* 已上传媒体预览 */}
+ <div className="preview-media">
+ {mediaUrls.map((url, i) => (
+ <div key={i} className="preview-item">
+ {files[i].type.startsWith('image/') ? (
+ <img src={url} alt={`预览 ${i}`} />
+ ) : (
+ <video src={url} controls />
+ )}
+ </div>
+ ))}
+ </div>
+
+ {/* 标题 */}
+ <label className="form-label">
+ 标题(最多20字)
+ <input
+ type="text"
+ maxLength={20}
+ value={title}
+ onChange={e => setTitle(e.target.value)}
+ placeholder="填写标题会有更多赞哦~"
+ />
+ <span className="char-count">{title.length}/20</span>
+ </label>
+
+ {/* 正文 */}
+ <label className="form-label">
+ 正文(最多1000字)
+ <textarea
+ maxLength={1000}
+ value={content}
+ onChange={e => setContent(e.target.value)}
+ placeholder="输入正文描述,真诚有价值的分享予人温暖"
+ />
+ <span className="char-count">{content.length}/1000</span>
+ </label>
+
+ {/* 话题选择 */}
+ <label className="form-label">
+ 选择话题(可选)
+ <select
+ value={topicId}
+ onChange={e => setTopicId(e.target.value)}
+ >
+ <option value="">不添加话题</option>
+ {TOPICS.map(t => (
+ <option key={t.id} value={t.id}>
+ #{t.name}
+ </option>
+ ))}
+ </select>
+ </label>
+
+ {/* 发布状态 */}
+ <div className="status-group">
+ <label>
+ <input
+ type="radio"
+ name="status"
+ value="published"
+ checked={status === 'published'}
+ onChange={() => setStatus('published')}
+ />
+ 立即发布
+ </label>
+ <label>
+ <input
+ type="radio"
+ name="status"
+ value="draft"
+ checked={status === 'draft'}
+ onChange={() => setStatus('draft')}
+ />
+ 存为草稿
+ </label>
+ </div>
+
+ {/* 操作按钮 */}
+ <div className="btn-group">
+ <button className="btn btn-primary" onClick={handleSubmit}>
+ 发布
+ </button>
+ <button className="btn btn-secondary" onClick={() => setStep('upload')}>
+ 上一步
+ </button>
+ </div>
+ </div>
+ )
+}
diff --git a/Merge/front/src/components/Header.jsx b/Merge/front/src/components/Header.jsx
new file mode 100644
index 0000000..60a50b7
--- /dev/null
+++ b/Merge/front/src/components/Header.jsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import { User } from 'lucide-react'
+import '../App.css' // 或者单独的 Header.css
+
+export default function Header() {
+ return (
+ <header className="header">
+ <div className="header-left">
+ <div className="logo">小红书</div>
+ <h1 className="header-title">创作服务平台</h1>
+ </div>
+ <div className="header-right">
+ <div className="user-info">
+ <User size={16} />
+ <span>小红薯63081EA1</span>
+ </div>
+ </div>
+ </header>
+ )
+}
\ No newline at end of file
diff --git a/Merge/front/src/components/HomeFeed.jsx b/Merge/front/src/components/HomeFeed.jsx
new file mode 100644
index 0000000..39e0ca8
--- /dev/null
+++ b/Merge/front/src/components/HomeFeed.jsx
@@ -0,0 +1,90 @@
+// src/components/HomeFeed.jsx
+
+import React, { useState, useEffect } from 'react'
+import { ThumbsUp } from 'lucide-react'
+import { fetchPosts, fetchPost } from '../api/posts_wzy'
+import '../style/HomeFeed.css'
+
+const categories = [
+ '推荐','穿搭','美食','彩妆','影视',
+ '职场','情感','家居','游戏','旅行','健身'
+]
+
+export default function HomeFeed() {
+ const [activeCat, setActiveCat] = useState('推荐')
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ async function loadPosts() {
+ try {
+ const list = await fetchPosts() // [{id, title, heat, created_at}, …]
+ // 为了拿到 media_urls 和 user_id,这里再拉详情
+ const detailed = await Promise.all(
+ list.map(async p => {
+ const d = await fetchPost(p.id)
+ return {
+ id: d.id,
+ title: d.title,
+ author: `作者 ${d.user_id}`,
+ avatar: `https://i.pravatar.cc/40?img=${d.user_id}`,
+ img: d.media_urls?.[0] || '', // 用第一张媒体作为封面
+ likes: d.heat
+ }
+ })
+ )
+ setItems(detailed)
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+ loadPosts()
+ }, [])
+
+ return (
+ <div className="home-feed">
+ {/* 顶部分类 */}
+ <nav className="feed-tabs">
+ {categories.map(cat => (
+ <button
+ key={cat}
+ className={cat === activeCat ? 'tab active' : 'tab'}
+ onClick={() => setActiveCat(cat)}
+ >
+ {cat}
+ </button>
+ ))}
+ </nav>
+
+ {/* 状态提示 */}
+ {loading ? (
+ <div className="loading">加载中…</div>
+ ) : error ? (
+ <div className="error">加载失败:{error}</div>
+ ) : (
+ /* 瀑布流卡片区 */
+ <div className="feed-grid">
+ {items.map(item => (
+ <div key={item.id} className="feed-card">
+ <img className="card-img" src={item.img} alt={item.title} />
+ <h3 className="card-title">{item.title}</h3>
+ <div className="card-footer">
+ <div className="card-author">
+ <img className="avatar" src={item.avatar} alt={item.author} />
+ <span className="username">{item.author}</span>
+ </div>
+ <div className="card-likes">
+ <ThumbsUp size={16} />
+ <span className="likes-count">{item.likes}</span>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/Merge/front/src/components/LogsDashboard.js b/Merge/front/src/components/LogsDashboard.js
new file mode 100644
index 0000000..1bd6cb7
--- /dev/null
+++ b/Merge/front/src/components/LogsDashboard.js
@@ -0,0 +1,45 @@
+import React, { useEffect, useState } from 'react';
+import '../style/Admin.css';
+
+function LogsDashboard() {
+ const [logs, setLogs] = useState([]);
+ const [stats, setStats] = useState({});
+
+ useEffect(() => {
+ fetch('/api/logs')
+ .then(res => res.json())
+ .then(setLogs)
+ .catch(console.error);
+ fetch('/api/stats')
+ .then(res => res.json())
+ .then(setStats)
+ .catch(console.error);
+ }, []);
+
+ return (
+ <div className="admin-container">
+ <h2>运行日志 & 性能 Dashboard</h2>
+ <section className="dashboard-stats">
+ <pre>{JSON.stringify(stats, null, 2)}</pre>
+ </section>
+ <section className="dashboard-logs">
+ <table className="admin-table">
+ <thead>
+ <tr><th>时间</th><th>级别</th><th>消息</th></tr>
+ </thead>
+ <tbody>
+ {logs.map((log, i) => (
+ <tr key={i}>
+ <td>{new Date(log.time).toLocaleString()}</td>
+ <td>{log.level}</td>
+ <td>{log.message}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </section>
+ </div>
+ );
+}
+
+export default LogsDashboard;
diff --git a/Merge/front/src/components/PlaceholderPage.jsx b/Merge/front/src/components/PlaceholderPage.jsx
new file mode 100644
index 0000000..b290eb4
--- /dev/null
+++ b/Merge/front/src/components/PlaceholderPage.jsx
@@ -0,0 +1,55 @@
+import React from 'react'
+import {
+ Home,
+ BookOpen,
+ Activity,
+ Users
+} from 'lucide-react'
+import '../App.css' // 或 PlaceholderPage.css
+
+const icons = {
+ home: Home,
+ notebooks: BookOpen,
+ activity: Activity,
+ notes: BookOpen,
+ creator: Users,
+ journal: BookOpen,
+}
+
+const titles = {
+ home: '欢迎来到小红书创作平台',
+ notebooks: '笔记管理功能开发中',
+ activity: '活动中心功能开发中',
+ notes: '笔记灵感功能开发中',
+ creator: '创作学院功能开发中',
+ journal: '创作日刊功能开发中',
+}
+
+const descs = {
+ home: '在这里您可以管理您的创作内容,查看数据分析,获取创作灵感。',
+ notebooks: '这里将显示您的所有笔记,支持编辑、删除、分类等操作。',
+ activity: '这里将展示最新的平台活动,让您参与更多有趣的创作活动。',
+ notes: '这里将为您提供创作灵感和写作建议,帮助您创作更好的内容。',
+ creator: '这里将提供创作技巧教学和平台规则说明,助您成为优秀创作者。',
+ journal: '这里将展示创作相关的最新资讯和平台动态。',
+}
+
+export default function PlaceholderPage({ pageId }) {
+ const Icon = icons[pageId] || Home
+ return (
+ <div className="page-content">
+ <div className="page-header">
+ <h1 className="page-title">{titles[pageId]}</h1>
+ </div>
+ <div className="page-body">
+ <div className="placeholder-content">
+ <div className="placeholder-icon">
+ <Icon size={48} />
+ </div>
+ <h3 className="placeholder-title">{titles[pageId]}</h3>
+ <p className="placeholder-desc">{descs[pageId]}</p>
+ </div>
+ </div>
+ </div>
+ )
+}
\ No newline at end of file
diff --git a/Merge/front/src/components/Sidebar.jsx b/Merge/front/src/components/Sidebar.jsx
new file mode 100644
index 0000000..26118b2
--- /dev/null
+++ b/Merge/front/src/components/Sidebar.jsx
@@ -0,0 +1,103 @@
+import React, { useState, useEffect } from 'react'
+import { NavLink, useLocation, useNavigate } from 'react-router-dom'
+import {
+ Home,
+ BookOpen,
+ BarChart3,
+ Activity,
+ Users,
+ ChevronDown,
+} from 'lucide-react'
+import '../App.css'
+
+const menuItems = [
+ { id: 'home', label: '首页', icon: Home, path: '/home' },
+ { id: 'notebooks', label: '笔记管理', icon: BookOpen, path: '/notebooks' },
+ {
+ id: 'dashboard',
+ label: '数据看板',
+ icon: BarChart3,
+ path: '/dashboard',
+ submenu: [
+ { id: 'overview', label: '账号概况', path: '/dashboard/overview' },
+ { id: 'content', label: '内容分析', path: '/dashboard/content' },
+ { id: 'fans', label: '粉丝数据', path: '/dashboard/fans' },
+ ]
+ },
+ { id: 'activity', label: '活动中心', icon: Activity, path: '/activity' },
+ { id: 'notes', label: '笔记灵感', icon: BookOpen, path: '/notes' },
+ { id: 'creator', label: '创作学院', icon: Users, path: '/creator' },
+ { id: 'journal', label: '创作日刊', icon: BookOpen, path: '/journal' },
+]
+
+export default function Sidebar() {
+ const [expandedMenu, setExpandedMenu] = useState(null)
+ const location = useLocation()
+ const navigate = useNavigate()
+
+ // 打开 dashboard 下拉时保持展开
+ useEffect(() => {
+ if (location.pathname.startsWith('/dashboard')) {
+ setExpandedMenu('dashboard')
+ }
+ }, [location.pathname])
+
+ const toggleMenu = item => {
+ if (item.submenu) {
+ setExpandedMenu(expandedMenu === item.id ? null : item.id)
+ } else {
+ navigate(item.path)
+ setExpandedMenu(null)
+ }
+ }
+
+ return (
+ <aside className="sidebar">
+ {/* 发布笔记 按钮 */}
+ <button
+ className="publish-btn"
+ onClick={() => navigate('/posts/new')}
+ >
+ 发布笔记
+ </button>
+
+ <nav className="nav-menu">
+ {menuItems.map(item => (
+ <div key={item.id} className="nav-item">
+ <a
+ href="#"
+ className={`nav-link${location.pathname === item.path ? ' active' : ''}`}
+ onClick={e => { e.preventDefault(); toggleMenu(item) }}
+ >
+ <item.icon size={16} />
+ <span>{item.label}</span>
+ {item.submenu && (
+ <ChevronDown
+ size={16}
+ style={{
+ marginLeft: 'auto',
+ transform: expandedMenu === item.id ? 'rotate(180deg)' : 'rotate(0deg)',
+ transition: 'transform 0.3s ease'
+ }}
+ />
+ )}
+ </a>
+ {item.submenu && expandedMenu === item.id && (
+ <div className="nav-submenu">
+ {item.submenu.map(sub => (
+ <NavLink
+ key={sub.id}
+ to={sub.path}
+ className={({ isActive }) => `nav-link${isActive ? ' active' : ''}`}
+ >
+ {sub.label}
+ </NavLink>
+ ))}
+ </div>
+ )}
+ </div>
+ ))}
+ </nav>
+ </aside>
+ )
+}
diff --git a/Merge/front/src/components/SuperAdmin.js b/Merge/front/src/components/SuperAdmin.js
new file mode 100644
index 0000000..817b708
--- /dev/null
+++ b/Merge/front/src/components/SuperAdmin.js
@@ -0,0 +1,64 @@
+import React, { useState, useEffect } from 'react';
+import { NavLink, Outlet } from 'react-router-dom';
+import { Spin } from 'antd';
+import { fetchUserList } from '../api/posts';
+import '../style/SuperAdmin.css';
+
+export default function SuperAdmin() {
+ const SUPERADMIN_USER_ID = 3;
+ const [loading, setLoading] = useState(true);
+ const [hasPermission, setHasPermission] = useState(true);
+
+ useEffect(() => {
+ async function check() {
+ try {
+ await fetchUserList(SUPERADMIN_USER_ID);
+ } catch (e) {
+ if (e.message === 'Unauthorized') {
+ setHasPermission(false);
+ } else {
+ console.error(e);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+ check();
+ }, []);
+
+ if (loading) return <Spin spinning tip="加载中…" style={{ width: '100%', marginTop: 100 }} />;
+ if (!hasPermission) return <div style={{ textAlign: 'center', marginTop: 100 }}>权限不足</div>;
+
+ return (
+ <div className="super-admin-container">
+ <aside className="super-admin-sidebar">
+ <h2>超级管理员</h2>
+ <nav>
+ <ul>
+ <li>
+ <NavLink
+ to="users"
+ end
+ className={({ isActive }) => isActive ? 'active' : ''}
+ >
+ 用户管理
+ </NavLink>
+ </li>
+ <li>
+ <NavLink
+ to="dashboard"
+ className={({ isActive }) => isActive ? 'active' : ''}
+ >
+ 平台运行监控
+ </NavLink>
+ </li>
+ </ul>
+ </nav>
+ </aside>
+
+ <main className="super-admin-content">
+ <Outlet />
+ </main>
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/Merge/front/src/components/UploadPage.jsx b/Merge/front/src/components/UploadPage.jsx
new file mode 100644
index 0000000..817a210
--- /dev/null
+++ b/Merge/front/src/components/UploadPage.jsx
@@ -0,0 +1,230 @@
+// src/components/UploadPage.jsx
+
+import React, { useState } from 'react'
+import { Image, Video } from 'lucide-react'
+import '../style/UploadPage.css'
+
+
+/**
+ * @param {Object} props
+ * @param {(files: File[]) => void} [props.onComplete] 上传完成后回调,接收 File 数组
+ */
+export default function UploadPage({ onComplete }) {
+ const [activeTab, setActiveTab] = useState('image')
+ const [isDragOver, setIsDragOver] = useState(false)
+ const [isUploading, setIsUploading] = useState(false)
+ const [uploadedFiles, setUploadedFiles] = useState([])
+ const [uploadProgress, setUploadProgress] = useState(0)
+
+ const validateFiles = files => {
+ const imgTypes = ['image/jpeg','image/jpg','image/png','image/webp']
+ const vidTypes = ['video/mp4','video/mov','video/avi']
+ const types = activeTab === 'video' ? vidTypes : imgTypes
+ const max = activeTab === 'video'
+ ? 2 * 1024 * 1024 * 1024
+ : 32 * 1024 * 1024
+
+ const invalid = files.filter(f => !types.includes(f.type) || f.size > max)
+ if (invalid.length) {
+ alert(`发现 ${invalid.length} 个无效文件,请检查文件格式和大小`)
+ return false
+ }
+ return true
+ }
+
+ const simulateUpload = files => {
+ setIsUploading(true)
+ setUploadProgress(0)
+ setUploadedFiles(files)
+
+ const iv = setInterval(() => {
+ setUploadProgress(p => {
+ if (p >= 100) {
+ clearInterval(iv)
+ setIsUploading(false)
+ alert(`成功上传了 ${files.length} 个文件`)
+ // 上传完成后回调
+ if (typeof onComplete === 'function') {
+ onComplete(files)
+ }
+ return 100
+ }
+ return p + 10
+ })
+ }, 200)
+ }
+
+ const handleFileUpload = () => {
+ if (isUploading) return
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = activeTab === 'video' ? 'video/*' : 'image/*'
+ input.multiple = activeTab === 'image'
+ input.onchange = e => {
+ const files = Array.from(e.target.files)
+ if (files.length > 0 && validateFiles(files)) {
+ simulateUpload(files)
+ }
+ }
+ input.click()
+ }
+
+ const handleDragOver = e => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true) }
+ const handleDragLeave = e => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false) }
+ const handleDrop = e => {
+ e.preventDefault(); e.stopPropagation(); setIsDragOver(false)
+ if (isUploading) return
+ const files = Array.from(e.dataTransfer.files)
+ if (files.length > 0 && validateFiles(files)) {
+ simulateUpload(files)
+ }
+ }
+
+ const clearFiles = () => setUploadedFiles([])
+ const removeFile = idx => setUploadedFiles(prev => prev.filter((_, i) => i !== idx))
+
+ return (
+ <div className="upload-page">
+ {/* 上传类型切换 */}
+ <div className="upload-tabs">
+ <button
+ className={`upload-tab${activeTab === 'video' ? ' active' : ''}`}
+ onClick={() => setActiveTab('video')}
+ >
+ 上传视频
+ </button>
+ <button
+ className={`upload-tab${activeTab === 'image' ? ' active' : ''}`}
+ onClick={() => setActiveTab('image')}
+ >
+ 上传图文
+ </button>
+ </div>
+
+ {/* 拖拽/点击上传区域 */}
+ <div
+ className={`upload-area${isDragOver ? ' drag-over' : ''}`}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+ <div className="upload-icon">
+ {activeTab === 'video' ? <Video size={48} /> : <Image size={48} />}
+ </div>
+ <h2 className="upload-title">
+ {activeTab === 'video'
+ ? '拖拽视频到此处或点击上传'
+ : '拖拽图片到此处或点击上传'}
+ </h2>
+ <p className="upload-subtitle">(需支持上传格式)</p>
+ <button
+ className={`upload-btn${isUploading ? ' uploading' : ''}`}
+ onClick={handleFileUpload}
+ disabled={isUploading}
+ >
+ {isUploading
+ ? `上传中... ${uploadProgress}%`
+ : activeTab === 'video'
+ ? '上传视频'
+ : '上传图片'}
+ </button>
+
+ {isUploading && (
+ <div className="progress-container">
+ <div className="progress-bar">
+ <div
+ className="progress-fill"
+ style={{ width: `${uploadProgress}%` }}
+ />
+ </div>
+ <div className="progress-text">{uploadProgress}%</div>
+ </div>
+ )}
+ </div>
+
+ {/* 已上传文件预览 */}
+ {uploadedFiles.length > 0 && (
+ <div className="file-preview-area">
+ <div className="preview-header">
+ <h3 className="preview-title">
+ 已上传文件 ({uploadedFiles.length})
+ </h3>
+ <button
+ className="clear-files-btn"
+ onClick={clearFiles}
+ >
+ 清除所有
+ </button>
+ </div>
+ <div className="file-grid">
+ {uploadedFiles.map((file, i) => (
+ <div key={i} className="file-item">
+ <button
+ className="remove-file-btn"
+ onClick={() => removeFile(i)}
+ title="删除文件"
+ >
+ ×
+ </button>
+ {file.type.startsWith('image/') ? (
+ <div className="file-thumbnail">
+ <img src={URL.createObjectURL(file)} alt={file.name} />
+ </div>
+ ) : (
+ <div className="file-thumbnail video-thumbnail">
+ <Video size={24} />
+ </div>
+ )}
+ <div className="file-info">
+ <div className="file-name" title={file.name}>
+ {file.name.length > 20
+ ? file.name.slice(0, 17) + '...'
+ : file.name}
+ </div>
+ <div className="file-size">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 上传说明信息 */}
+ <div className="upload-info fade-in">
+ {activeTab === 'image' ? (
+ <>
+ <div className="info-item">
+ <h3 className="info-title">图片大小</h3>
+ <p className="info-desc">最大32MB</p>
+ </div>
+ <div className="info-item">
+ <h3 className="info-title">图片格式</h3>
+ <p className="info-desc">png/jpg/jpeg/webp</p>
+ </div>
+ <div className="info-item">
+ <h3 className="info-title">分辨率</h3>
+ <p className="info-desc">建议720×960及以上</p>
+ </div>
+ </>
+ ) : (
+ <>
+ <div className="info-item">
+ <h3 className="info-title">视频大小</h3>
+ <p className="info-desc">最大2GB,时长≤5分钟</p>
+ </div>
+ <div className="info-item">
+ <h3 className="info-title">视频格式</h3>
+ <p className="info-desc">mp4/mov</p>
+ </div>
+ <div className="info-item">
+ <h3 className="info-title">分辨率</h3>
+ <p className="info-desc">建议720P及以上</p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/Merge/front/src/components/UserManagement.js b/Merge/front/src/components/UserManagement.js
new file mode 100644
index 0000000..4bd05c5
--- /dev/null
+++ b/Merge/front/src/components/UserManagement.js
@@ -0,0 +1,76 @@
+import React, { useState, useEffect } from 'react';
+import '../style/Admin.css';
+import { Select, message, Table } from 'antd';
+import { fetchUserList, giveUser, giveAdmin, giveSuperAdmin } from '../api/posts';
+
+const { Option } = Select;
+const ROLE_LIST = ['用户', '管理员', '超级管理员'];
+
+function UserManagement({ superAdminId }) {
+ const [users, setUsers] = useState([]);
+
+ useEffect(() => {
+ async function load() {
+ try {
+ const data = superAdminId
+ ? await fetchUserList(superAdminId)
+ : await fetch('/api/users').then(res => res.json());
+ setUsers(data);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ load();
+ }, [superAdminId]);
+
+ // handle role changes
+ const handleRoleChange = async (userId, newRole) => {
+ try {
+ if (newRole === '用户') await giveUser(superAdminId, userId);
+ else if (newRole === '管理员') await giveAdmin(superAdminId, userId);
+ else if (newRole === '超级管理员') await giveSuperAdmin(superAdminId, userId);
+ setUsers(us => us.map(u => u.id === userId ? { ...u, role: newRole } : u));
+ message.success('修改成功');
+ } catch (e) {
+ console.error(e);
+ message.error('修改失败');
+ }
+ };
+
+ // define table columns
+ const columns = [
+ { title: '用户名', dataIndex: 'username', key: 'username' },
+ { title: '角色', dataIndex: 'role', key: 'role' },
+ {
+ title: '操作',
+ key: 'action',
+ render: (_, record) => {
+ const orderedRoles = [record.role, ...ROLE_LIST.filter(r => r !== record.role)];
+ return (
+ <Select
+ value={record.role}
+ style={{ width: 120 }}
+ onChange={value => handleRoleChange(record.id, value)}
+ >
+ {orderedRoles.map(r => (
+ <Option key={r} value={r}>{r}</Option>
+ ))}
+ </Select>
+ );
+ },
+ },
+ ];
+
+ return (
+ <div className="admin-container">
+ <Table
+ dataSource={users}
+ columns={columns}
+ rowKey="id"
+ pagination={false}
+ />
+ </div>
+ );
+}
+
+export default UserManagement;
diff --git a/Merge/front/src/index.css b/Merge/front/src/index.css
new file mode 100644
index 0000000..72c144a
--- /dev/null
+++ b/Merge/front/src/index.css
@@ -0,0 +1,29 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background-color: #f5f7fa;
+}
+
+button {
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-family: inherit;
+}
+
+a {
+ text-decoration: none;
+ color: inherit;
+}
+
+#root {
+ width: 100%;
+ min-height: 100vh;
+}
diff --git a/Merge/front/src/index.js b/Merge/front/src/index.js
new file mode 100644
index 0000000..1ce450d
--- /dev/null
+++ b/Merge/front/src/index.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import './style/index.css';
+import App from './App';
+
+
+const root = ReactDOM.createRoot(document.getElementById('root'));
+root.render(
+ <React.StrictMode>
+
+ <App />
+
+ </React.StrictMode>
+);
\ No newline at end of file
diff --git a/Merge/front/src/router/App.js b/Merge/front/src/router/App.js
new file mode 100644
index 0000000..1a7fe0e
--- /dev/null
+++ b/Merge/front/src/router/App.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import {
+ Routes,
+ Route,
+ Navigate,
+} from 'react-router-dom';
+import AdminPage from '../components/Admin';
+import UserManagement from '../components/UserManagement';
+import LogsDashboard from '../components/LogsDashboard';
+import SuperAdmin from '../components/SuperAdmin';
+
+import CreatePost from '../components/CreatePost' // src/components/CreatePost.jsx
+import HomeFeed from '../components/HomeFeed' // src/components/HomeFeed.jsx
+import PlaceholderPage from '../components/PlaceholderPage'// src/components/PlaceholderPage.jsx
+import UploadPage from '../components/UploadPage' // src/components/UploadPage.jsx
+
+
+export default function AppRoutes() {
+ return (
+ <Routes>
+ <Route path="/posts/new" element={<CreatePost />} />
+
+ <Route path="/home" element={<HomeFeed />} />
+
+ <Route path="/notebooks" element={<PlaceholderPage pageId="notebooks" />} />
+ <Route path="/activity" element={<PlaceholderPage pageId="activity" />} />
+ <Route path="/notes" element={<PlaceholderPage pageId="notes" />} />
+ <Route path="/creator" element={<PlaceholderPage pageId="creator" />} />
+ <Route path="/journal" element={<PlaceholderPage pageId="journal" />} />
+
+ <Route path="/dashboard/*" element={<UploadPage />} />
+
+ {/* 根路径重定向到 dashboard */}
+ <Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
+
+ {/* 最后一个兜底 */}
+ <Route path="*" element={<PlaceholderPage pageId="home" />} />
+
+ {/* 普通管理员,无 header */}
+ <Route path="admin" element={<AdminPage />} />
+
+ {/* 超级管理员,只用 SuperAdminLayout */}
+ <Route path="superadmin" element={<SuperAdmin />}>
+ <Route index element={<Navigate to="users" replace />} />
+ <Route path="users" element={<UserManagement superAdminId={3} />} />
+ <Route path="dashboard" element={<LogsDashboard />} />
+ </Route>
+ </Routes>
+ );
+}
\ No newline at end of file
diff --git a/Merge/front/src/style/Admin.css b/Merge/front/src/style/Admin.css
new file mode 100644
index 0000000..4a5bcb7
--- /dev/null
+++ b/Merge/front/src/style/Admin.css
@@ -0,0 +1,389 @@
+@import "~antd/dist/antd.css";
+
+/* 整体容器背景,弱化底层 */
+.admin-container {
+ background-color: #f5f6f8;
+}
+
+.admin-container {
+ padding: 24px;
+ background-color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+}
+
+/* 页眉分层:白底 + 圆角 + 阴影 */
+.page-header {
+ background: #fff;
+ padding: 12px 24px;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.admin-title {
+ font-size: 24px;
+ color: #e61515;
+ margin-bottom: 16px;
+}
+
+.admin-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.admin-table th,
+.admin-table td {
+ border: 1px solid #f0f0f0;
+ padding: 12px 16px;
+ text-align: left;
+}
+
+.admin-table th {
+ background-color: #fafafa;
+ color: #333;
+ font-weight: 500;
+}
+
+.status {
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.status.pending {
+ color: #f29900;
+}
+
+.status.approved {
+ color: #28a745;
+}
+
+.status.banned {
+ color: #d73a49;
+}
+
+.btn {
+ padding: 6px 12px;
+ margin-right: 8px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.btn-approve {
+ background-color: #e61515;
+ color: #fff;
+}
+
+.btn-ban {
+ background-color: #f5f5f5;
+ color: #333;
+}
+
+/* 1. 瀑布流容器 */
+.admin-grid {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ margin-top: 16px;
+}
+
+/* 2. 卡片 */
+.admin-card {
+ display: flex;
+ flex-direction: column;
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.08);
+ overflow: hidden;
+ transition: transform 0.2s;
+}
+.admin-card:hover {
+ transform: translateY(-4px);
+}
+
+/* 3. 头部:用户名 + 状态 */
+.card-header {
+ padding: 12px 16px;
+ border-bottom: 1px solid #f0f0f0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.card-header .username {
+ font-weight: 500;
+ color: #333;
+}
+.card-header .status {
+ font-weight: 500;
+ text-transform: capitalize;
+}
+.card-header .status.pending { color: #f29900; }
+.card-header .status.approved { color: #28a745; }
+.card-header .status.banned { color: #d73a49; }
+
+/* 4. 操作按钮区 */
+.card-actions {
+ display: flex;
+ padding: 12px 16px;
+ border-top: 1px solid #f0f0f0;
+ gap: 8px;
+}
+.card-actions .btn {
+ flex: 1;
+}
+.card-actions .btn-approve { background-color: #e61515; color: #fff; }
+.card-actions .btn-ban { background-color: #f5f5f5; color: #333; }
+
+/* —— Admin.js 专用布局 —— */
+.admin-layout {
+ display: flex;
+ gap: 16px;
+}
+
+/* 左侧列表区 */
+.list-panel {
+ width: 320px;
+ border-right: 1px solid #f0f0f0;
+ padding-right: 16px;
+ overflow-y: auto;
+ padding: 16px;
+}
+
+/* 顶部标签切换 */
+.tabs {
+ display: flex;
+ border-bottom: 1px solid #f0f0f0;
+ margin-bottom: 8px;
+ background: #fafafa;
+ padding: 0 16px;
+ border-radius: 8px 8px 0 0;
+}
+.tab-btn {
+ flex: 1;
+ padding: 8px 12px;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ font-size: 14px;
+}
+.tab-btn.active {
+ border-color: #e61515;
+ color: #e61515;
+}
+
+/* 帖子列表 */
+.post-list {
+ /* 可根据需要添加滚动或间距 */
+}
+.post-item {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ cursor: pointer;
+ border-bottom: 1px solid #f5f5f5;
+ background: #fff;
+ margin-bottom: 4px;
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+.post-item:hover {
+ background-color: #fafafa;
+}
+.post-item.selected {
+ background: #e6f1ff;
+}
+.thumb {
+ width: 40px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: 4px;
+ margin-right: 8px;
+}
+.info {
+ flex: 1;
+}
+.info .title {
+ font-weight: 500;
+ color: #333;
+}
+.info .meta {
+ font-size: 12px;
+ color: #888;
+}
+
+/* 状态标签 */
+.status-tag {
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 12px;
+ text-transform: capitalize;
+}
+.status-tag.pending {
+ background-color: #fff4e5;
+ color: #f29900;
+}
+.status-tag.approved {
+ background-color: #e6f9f0;
+ color: #28a745;
+}
+.status-tag.rejected {
+ background-color: #fceaea;
+ color: #d73a49;
+}
+
+/* 右侧详情面板 */
+.detail-panel {
+ flex: 1;
+ padding-left: 16px;
+ max-height: calc(100vh - 100px);
+ overflow-y: auto;
+ padding: 24px;
+ margin-left: 8px;
+}
+
+/* 卡片阴影微调 */
+.admin-card {
+ box-shadow: 0 2px 6px rgba(0,0,0,0.08);
+}
+
+.detail-meta {
+ font-size: 12px;
+ color: #888;
+ margin-bottom: 8px;
+}
+.detail-content {
+ margin-bottom: 16px;
+ line-height: 1.6;
+}
+.detail-actions {
+ margin-bottom: 16px;
+ background: #f9f9fb;
+ padding: 12px;
+ border-radius: 4px;
+}
+
+/* 操作按钮 */
+.btn-reject {
+ background-color: #f5f5f5;
+ color: #333;
+}
+.rejected-label {
+ color: #d73a49;
+ font-weight: 500;
+}
+
+/* 加载与空状态 */
+.loading,
+.empty-state {
+ text-align: center;
+ padding: 16px;
+ color: #888;
+}
+
+/* 合规性指引 */
+.compliance-guidelines {
+ border-top: 1px solid #f0f0f0;
+ padding-top: 12px;
+ margin-top: 12px;
+ background: #f9f9fb;
+ padding: 12px;
+ border-radius: 4px;
+}
+.compliance-guidelines h4 {
+ margin-bottom: 8px;
+ font-size: 16px;
+}
+.compliance-guidelines ul {
+ padding-left: 20px;
+}
+.compliance-guidelines li {
+ line-height: 1.4;
+ margin-bottom: 4px;
+}
+
+/* 管理员导航栏样式 */
+.admin-nav {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ margin: 1rem 0 2rem;
+ border-bottom: 2px solid #e5e5e5;
+}
+
+.admin-nav button {
+ background: none;
+ border: none;
+ padding: 0.5rem 0;
+ font-size: 1rem;
+ color: #555;
+ cursor: pointer;
+ position: relative;
+ transition: color 0.3s ease;
+}
+
+.admin-nav button:hover {
+ color: #000;
+}
+
+.admin-nav button.active {
+ color: #0078d4;
+}
+
+.admin-nav button.active::after {
+ content: '';
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ width: 100%;
+ height: 3px;
+ background-color: #0078d4;
+ border-radius: 2px 2px 0 0;
+}
+
+/* 页面头部:标题 + 搜索框 */
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ background: #fff;
+ padding: 12px 24px;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+.main-title {
+ font-size: 28px;
+ color: #e61515;
+ margin: 0;
+}
+.search-input {
+ width: 240px;
+ padding: 6px 12px;
+ font-size: 14px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ transition: border-color 0.2s;
+ background: #fafafa;
+}
+.search-input:focus {
+ outline: none;
+ border-color: #e61515;
+}
+
+/* 小红书品牌红 */
+:root {
+ --xiaohongshu-red: #e2204f;
+}
+
+/* Antd 表格表头背景小红书红,文字白色 */
+.ant-table-thead > tr > th {
+ background-color: var(--xiaohongshu-red) !important;
+ color: #fff;
+}
+
+/* 侧栏前两项文字变小红书红 */
+.ant-layout-sider .ant-menu-item:nth-child(1),
+.ant-layout-sider .ant-menu-item:nth-child(2) {
+ color: var(--xiaohongshu-red) !important;
+}
\ No newline at end of file
diff --git a/Merge/front/src/style/CreatePost.css b/Merge/front/src/style/CreatePost.css
new file mode 100644
index 0000000..4868132
--- /dev/null
+++ b/Merge/front/src/style/CreatePost.css
@@ -0,0 +1,98 @@
+/* src/style/CreatePost.css */
+.create-post {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ background: #fff;
+ border-radius: 8px;
+}
+
+/* 预览区 */
+.preview-media {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+.preview-item {
+ width: 100px;
+ height: 100px;
+ overflow: hidden;
+ border: 1px solid #eee;
+ border-radius: 4px;
+}
+.preview-item img,
+.preview-item video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+/* 表单项 */
+label {
+ display: block;
+ margin-bottom: 16px;
+ font-size: 14px;
+ color: #333;
+}
+label input[type="text"],
+label textarea,
+label select {
+ width: 100%;
+ padding: 8px;
+ margin-top: 6px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 14px;
+ box-sizing: border-box;
+}
+label textarea {
+ min-height: 120px;
+ resize: vertical;
+}
+.char-count {
+ float: right;
+ font-size: 12px;
+ color: #999;
+}
+
+/* 发布状态 */
+.status-group {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 20px;
+}
+.status-group label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 14px;
+}
+
+/* 按钮组 */
+.btn-group {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+}
+.btn {
+ padding: 8px 16px;
+ border-radius: 4px;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+}
+.btn-primary {
+ background: #ff4757;
+ color: #fff;
+}
+.btn-secondary {
+ background: #f0f0f0;
+ color: #333;
+}
+
+/* 错误信息 */
+.error {
+ color: #d9534f;
+ margin-bottom: 12px;
+}
diff --git a/Merge/front/src/style/HomeFeed.css b/Merge/front/src/style/HomeFeed.css
new file mode 100644
index 0000000..f1bf75d
--- /dev/null
+++ b/Merge/front/src/style/HomeFeed.css
@@ -0,0 +1,116 @@
+/* --------- 容器 & Tabs --------- */
+.home-feed {
+ padding: 20px;
+}
+
+.feed-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.feed-tabs .tab {
+ padding: 6px 12px;
+ border: none;
+ background: #f0f0f0;
+ border-radius: 16px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.feed-tabs .tab.active {
+ background: #ff4757;
+ color: #fff;
+}
+
+/* --------- 瀑布流布局 --------- */
+.feed-grid {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+}
+
+/* --------- 卡片样式及最大高度限制 --------- */
+.feed-card {
+ display: flex;
+ flex-direction: column;
+ max-height: 360px; /* 卡片最大高度 */
+ overflow: hidden; /* 超出部分隐藏 */
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.1);
+ transition: transform 0.2s;
+}
+
+.feed-card:hover {
+ transform: translateY(-4px);
+}
+
+/* 封面图固定高度 */
+.card-img {
+ width: 100%;
+ height: 180px; /* 固定图片区域高度 */
+ object-fit: cover;
+ flex-shrink: 0; /* 不随容器收缩 */
+}
+
+/* 标题填充剩余空间 */
+.card-title {
+ font-size: 14px;
+ color: #333;
+ margin: 12px;
+ line-height: 1.4;
+ flex: 1; /* 占满中间区域 */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2; /* 最多两行 */
+ -webkit-box-orient: vertical;
+}
+
+/* --------- 底部:作者 + 点赞 --------- */
+.card-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ border-top: 1px solid #f0f0f0;
+ background: #fff;
+ flex-shrink: 0; /* 保持在底部 */
+}
+
+/* 作者区域 */
+.card-author {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.card-author .avatar {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.card-author .username {
+ font-size: 13px;
+ color: #333;
+}
+
+/* 点赞区域 */
+.card-likes {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.card-likes svg {
+ color: #ff4757;
+}
+
+.card-likes .likes-count {
+ font-size: 13px;
+ color: #666;
+}
\ No newline at end of file
diff --git a/Merge/front/src/style/SuperAdmin.css b/Merge/front/src/style/SuperAdmin.css
new file mode 100644
index 0000000..2295f8b
--- /dev/null
+++ b/Merge/front/src/style/SuperAdmin.css
@@ -0,0 +1,30 @@
+.super-admin-container {
+ display: flex;
+ height: 100vh;
+}
+
+.super-admin-sidebar {
+ width: 200px;
+ padding: 20px;
+ background: #f5f5f5;
+}
+
+.super-admin-sidebar ul {
+ list-style: none;
+ padding: 0;
+}
+
+.super-admin-sidebar li {
+ margin-bottom: 10px;
+}
+
+.super-admin-sidebar .active {
+ font-weight: bold;
+ color: #1890ff;
+}
+
+.super-admin-content {
+ flex: 1;
+ padding: 20px;
+ background: #fff;
+}
\ No newline at end of file
diff --git a/Merge/front/src/style/UploadPage.css b/Merge/front/src/style/UploadPage.css
new file mode 100644
index 0000000..138b0c1
--- /dev/null
+++ b/Merge/front/src/style/UploadPage.css
@@ -0,0 +1,70 @@
+.upload-page {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 16px;
+ font-family: sans-serif;
+ color: #333;
+}
+
+.upload-tabs {
+ display: flex;
+ margin-bottom: 16px;
+}
+
+.upload-tab {
+ flex: 1;
+ padding: 8px 16px;
+ border: 1px solid #ddd;
+ background: #f9f9f9;
+ cursor: pointer;
+ text-align: center;
+}
+
+.upload-tab.active {
+ background: #fff;
+ border-bottom: 2px solid #1890ff;
+ color: #1890ff;
+}
+
+.upload-area {
+ border: 2px dashed #ccc;
+ padding: 40px;
+ text-align: center;
+ transition: background 0.3s;
+}
+
+.upload-area.drag-over {
+ background: #eef6ff;
+}
+
+.upload-btn {
+ margin-top: 16px;
+ padding: 8px 24px;
+ border: none;
+ background: #1890ff;
+ color: #fff;
+ cursor: pointer;
+}
+
+.upload-btn:disabled {
+ background: #aaa;
+ cursor: not-allowed;
+}
+
+/* 如果有 upload-table 相关,用类似方式定义 */
+.upload-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 24px;
+}
+
+.upload-table th,
+.upload-table td {
+ border: 1px solid #ddd;
+ padding: 8px;
+ text-align: left;
+}
+
+.upload-table th {
+ background: #f5f5f5;
+}
diff --git a/Merge/front/src/style/index.css b/Merge/front/src/style/index.css
new file mode 100644
index 0000000..ec2585e
--- /dev/null
+++ b/Merge/front/src/style/index.css
@@ -0,0 +1,13 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}