[dev]brands & chat

This commit is contained in:
susie-laptop 2025-05-09 14:14:03 -04:00
parent 1f97455961
commit cfb82c19b7
25 changed files with 1190 additions and 104 deletions

21
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.8.1",
"bootstrap": "^5.3.3",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.508.0",
"react": "^19.1.0",
@ -23,7 +24,8 @@
"react-bootstrap-range-slider": "^3.0.8",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.0"
"react-router-dom": "^7.6.0",
"redux-persist": "^6.0.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
@ -2240,6 +2242,15 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -4088,6 +4099,14 @@
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-persist": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
"integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==",
"peerDependencies": {
"redux": ">4.0.0"
}
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",

View File

@ -18,6 +18,7 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.8.1",
"bootstrap": "^5.3.3",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.508.0",
"react": "^19.1.0",
@ -25,7 +26,8 @@
"react-bootstrap-range-slider": "^3.0.8",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.0"
"react-router-dom": "^7.6.0",
"redux-persist": "^6.0.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",

View File

@ -0,0 +1,57 @@
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchBrands } from '../store/slices/brandsSlice';
import { Card } from 'react-bootstrap';
import { Folders, Hash, Link, Users } from 'lucide-react';
export default function BrandsList() {
const brands = useSelector((state) => state.brands.brands);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchBrands());
}, [dispatch]);
return (
<div className='brands-list'>
{brands.map((brand) => (
<div className='brand-card shadow-xs' key={brand.id}>
<Card.Body>
<Card.Title className='text-primary fw-bold'>
<span className='card-logo'>{brand.name.slice(0, 1)}</span>
{brand.name.toUpperCase()}
</Card.Title>
<Card.Text className=''>
<span className='card-text-title'>
<Folders size={16} />
Category
</span>
<span className='card-text-content'>{brand.category}</span>
</Card.Text>
<Card.Text className=''>
<span className='card-text-title'>
<Hash size={16} />
Collab.
</span>
<span className='card-text-content'>{brand.collab}</span>
</Card.Text>
<Card.Text className=''>
<span className='card-text-title'>
<Users size={16} />
Creators
</span>
<span className='card-text-content'>{brand.creators}</span>
</Card.Text>
<Card.Text className=''>
<span className='card-text-title'>
<Link size={16} />
Source
</span>
<span className='card-text-content'>{brand.source}</span>
</Card.Text>
</Card.Body>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,26 @@
import React, { useRef, useEffect } from 'react';
import { Form } from 'react-bootstrap';
export default function ChatInput({ value, onChange }) {
const textareaRef = useRef(null);
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto'; //
textarea.style.height = `${textarea.scrollHeight}px`; //
}
}, [value]);
return (
<Form.Control
as='textarea'
rows={1}
ref={textareaRef}
value={value}
onChange={onChange}
placeholder='Send a message...'
className='chat-textarea'
/>
);
}

View File

@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchChatHistory } from '../store/slices/inboxSlice';
import { Ellipsis, Send } from 'lucide-react';
import { Button, Form } from 'react-bootstrap';
import ChatInput from './ChatInput';
export default function ChatWindow() {
const { selectedChat, chatStatus } = useSelector((state) => state.inbox);
const [activePlatform, setActivePlatform] = useState('email');
const dispatch = useDispatch();
const [message, setMessage] = useState('');
const platformOptions = [
{
name: 'Email',
value: 'email',
},
{
name: 'WhatsApp',
value: 'whatsapp',
},
];
const handlePlatformChange = (value) => {
setActivePlatform(value);
};
useEffect(() => {
if (selectedChat) {
console.log(selectedChat);
}
}, [selectedChat]);
const handleSendMessage = (e) => {
e.preventDefault();
console.log(e.target.message.value);
};
return (
<div className='chat-window'>
<div className='chat-window-header'>
<div className='chat-window-header-left'>
<div className='chat-window-header-left-avatar'>
<img src={selectedChat.avatar} alt='avatar' />
</div>
<div className='chat-window-header-left-info'>
<div className='chat-window-header-left-info-name fw-bold'>{selectedChat.name}</div>
</div>
</div>
<div className='chat-window-header-right'>
<div className='platform-selection'>
{platformOptions.map((option) => (
<div
key={option.value}
className={`platform-selection-item ${activePlatform === option.value ? 'active' : ''}`}
onClick={() => handlePlatformChange(option.value)}
>
{option.name}
</div>
))}
</div>
<div className='actions'>
<Ellipsis />
</div>
</div>
</div>
<div className='chat-window-body'>
<div className='chat-body'>
{selectedChat?.chatHistory?.length > 0 &&
selectedChat?.chatHistory?.map((msg) => (
<div key={msg.id} className={`message ${msg.role === 'user' ? 'user' : 'assistant'}`}>
{msg.content}
</div>
))}
</div>
</div>
<div className='chat-window-footer'>
<Form className='footer-input' onSubmit={handleSendMessage}>
<ChatInput value={message} onChange={(e) => setMessage(e.target.value)} />
<Button variant='outline-primary' className='border-0' type='submit'>
<Send />
</Button>
</Form>
</div>
</div>
);
}

View File

@ -24,7 +24,7 @@ export default function DatabaseList({ path }) {
}, [dispatch, status]);
useEffect(() => {
console.log(creators);
// console.log(creators);
}, [creators]);
// /
const handleSelectAll = (e) => {

View File

@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchChatHistory, fetchInboxList, selectChat } from '../store/slices/inboxSlice';
export default function InboxList() {
const dispatch = useDispatch();
const { inboxList, inboxStatus: status, error, selectedChat } = useSelector((state) => state.inbox);
const [activeToggle, setActiveToggle] = useState('all');
const [activeSelect, setActiveSelect] = useState('all');
const toggleOptions = [
{ name: '全部', value: 'all' },
{ name: '首次建联', value: 'initial' },
{ name: '砍价邮件', value: 'bargain' },
{ name: '合作追踪', value: 'coop' },
];
useEffect(() => {
dispatch(fetchInboxList());
}, [dispatch]);
useEffect(() => {
if (inboxList.length > 0) {
// dispatch(selectChat(inboxList[0]));
// dispatch(fetchChatHistory(inboxList[0].id));
}
}, [dispatch, inboxList]);
const handleSelectChat = (item) => {
dispatch(selectChat(item));
dispatch(fetchChatHistory(item.id));
};
return (
<div className='inbox-list-container'>
<div className='breadcrumb'>
<div className='breadcrumb-item'>Creator Inbox</div>
</div>
<div className='list-filter mb-2'>
<div className='toggle-list'>
{toggleOptions.map((option) => (
<div
key={option.value}
className={`toggle-option ${option.value === activeToggle ? 'active' : ''}`}
onClick={() => setActiveToggle(option.value)}
>
{option.name}
</div>
))}
</div>
<div className='select-list'>
<span
className={`select-option ${activeSelect === 'all' ? 'active' : ''}`}
onClick={() => setActiveSelect('all')}
>
All
</span>
<span
className={`select-option ${activeSelect === 'unread' ? 'active' : ''}`}
onClick={() => setActiveSelect('unread')}
>
Unread
</span>
</div>
<div className='actions'>批量</div>
</div>
<div className='list-content'>
{status === 'loading' && <div>Loading...</div>}
{status === 'failed' && <div>Error: {error}</div>}
{status === 'succeeded' && inboxList.length > 0 &&
inboxList.map((item) => (
<div
key={item.id}
className={`list-item ${selectedChat.id === item.id ? 'active' : ''}`}
onClick={() => handleSelectChat(item)}
>
<div className='list-item-left'>
<div className='list-item-left-avatar'>
<img src={item.avatar} alt={item.name} />
</div>
<div className='list-item-info'>
<div className='list-name fw-bold'>{item.name}</div>
<div className='list-message'>{item.message}</div>
</div>
</div>
<div className='list-item-right'>
<div className='list-item-right-time'>{item.date}</div>
{item.unreadMessageCount > 0 && (
<div className='list-item-right-badge'>{item.unreadMessageCount}</div>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
import React, { useState } from 'react';
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
export default function DividLayout() {
return (
<div className='d-flex'>
<Sidebar />
<main className='main-content w-100 d-flex flex-row gap-3' style={{ backgroundColor: '#f5f3ff' }}>
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,17 @@
import React, { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Button } from 'react-bootstrap';
import { List } from 'lucide-react';
import Sidebar from './Sidebar';
export default function MainLayout() {
return (
<div className='d-flex'>
<Sidebar />
<main className='main-content w-100'>
<Outlet />
</main>
</div>
);
}

View File

@ -1,10 +1,21 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Nav, Accordion } from 'react-bootstrap';
import { Settings, ChevronDown, Blocks, SquareActivity, LayoutDashboard, Mail, UserSearch, Heart, Send, FileText } from 'lucide-react';
import {
Settings,
ChevronDown,
Blocks,
SquareActivity,
LayoutDashboard,
Mail,
UserSearch,
Heart,
Send,
FileText,
} from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import logo from '@/assets/logo.png';
import '../styles/sidebar.scss';
import '@/styles/sidebar.scss';
// Organized menu items
const menuItems = [
@ -12,14 +23,14 @@ const menuItems = [
id: 'creator-discovery',
title: 'Creator Discovery',
path: '/creator-discovery',
icon: <UserSearch />,
icon: <UserSearch />,
hasSubmenu: false,
},
{
id: 'creator-database',
title: 'Creator Database',
path: '/creator-database',
icon: <Blocks/>,
icon: <Blocks />,
hasSubmenu: true,
submenuItems: [
{
@ -38,7 +49,7 @@ const menuItems = [
id: 'youtube',
title: 'YouTube',
path: '/creator-database/youtube',
icon: <FontAwesomeIcon icon='fa-brands fa-youtube' />,
icon: <FontAwesomeIcon icon='fa-brands fa-youtube' />,
},
],
},
@ -61,7 +72,7 @@ const menuItems = [
id: 'brands',
title: 'Brands',
path: '/brands',
icon: <LayoutDashboard />,
icon: <LayoutDashboard />,
hasSubmenu: false,
},
{
@ -75,7 +86,7 @@ const menuItems = [
id: 'inbox',
title: 'Inbox',
path: '/creator-inbox',
icon: <Send/>,
icon: <Send />,
},
{
id: 'templates',
@ -97,6 +108,20 @@ const menuItems = [
export default function Sidebar() {
const location = useLocation();
const [expanded, setExpanded] = useState({});
const [collapsed, setCollapsed] = useState(false);
//
useEffect(() => {
const handleResize = () => {
setCollapsed(window.innerWidth < 768);
};
//
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
//
const isActive = (path) => {
@ -118,13 +143,15 @@ export default function Sidebar() {
};
return (
<div className='sidebar'>
<div className={`sidebar ${collapsed ? 'sidebar-collapsed' : ''}`}>
<div className='sidebar-header p-3 d-flex align-items-center'>
<img src={logo} alt='OOIN Logo' width={48} height={48} className='me-2' />
<div>
<div className='fw-bold'>OOIN Media</div>
<div className='small text-muted'>Creator Center</div>
</div>
{!collapsed && (
<div>
<div className='fw-bold'>OOIN Media</div>
<div className='small text-muted'>Creator Center</div>
</div>
)}
</div>
<Nav className='flex-column sidebar-nav'>
@ -138,30 +165,40 @@ export default function Sidebar() {
<Accordion.Item eventKey={item.id} className='border-0 bg-transparent'>
<Accordion.Header
onClick={() => handleAccordionToggle(item.id)}
className={isItemActive ? 'active' : ''}
className={`${isItemActive ? 'active' : ''} ${
collapsed ? 'collapsed-header' : ''
}`}
>
<div className='d-flex align-items-center'>
<div
className={`d-flex align-items-center ${
collapsed ? 'justify-content-center w-100' : ''
}`}
>
<span className='sidebar-icon me-2'>{item.icon}</span>
{item.title}
{!collapsed && item.title}
</div>
</Accordion.Header>
<Accordion.Body className='p-0'>
<Nav className='flex-column sidebar-submenu'>
{item.submenuItems &&
item.submenuItems.map((subItem) => (
<Nav.Item key={`${item.id}-${subItem.id}`}>
<Nav.Link
as={Link}
to={subItem.path}
className={isActive(subItem.path) ? 'active' : ''}
>
<span className='sidebar-icon me-2'>{subItem.icon}</span>
{subItem.title}
</Nav.Link>
</Nav.Item>
))}
</Nav>
</Accordion.Body>
{!collapsed && (
<Accordion.Body className='p-0'>
<Nav className='flex-column sidebar-submenu'>
{item.submenuItems &&
item.submenuItems.map((subItem) => (
<Nav.Item key={`${item.id}-${subItem.id}`}>
<Nav.Link
as={Link}
to={subItem.path}
className={isActive(subItem.path) ? 'active' : ''}
>
<span className='sidebar-icon me-2'>
{subItem.icon}
</span>
{!collapsed && subItem.title}
</Nav.Link>
</Nav.Item>
))}
</Nav>
</Accordion.Body>
)}
</Accordion.Item>
</Accordion>
);
@ -170,7 +207,7 @@ export default function Sidebar() {
<Nav.Item key={item.id}>
<Nav.Link as={Link} to={item.path} className={isActive(item.path) ? 'active' : ''}>
<span className='sidebar-icon me-2'>{item.icon}</span>
{item.title}
{!collapsed && item.title}
</Nav.Link>
</Nav.Item>
);

View File

@ -1,29 +0,0 @@
import React, { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Button } from 'react-bootstrap';
import { List } from 'lucide-react';
import Sidebar from './Sidebar';
export default function MainLayout() {
const [showSidebar, setShowSidebar] = useState(true);
const toggleSidebar = () => {
setShowSidebar(!showSidebar);
};
return (
<div className='d-flex'>
<div className={`sidebar ${showSidebar ? 'show' : ''}`}>
<Sidebar />
</div>
<Button variant='light' size='sm' className='sidebar-toggle' onClick={toggleSidebar}>
<List size={20} />
</Button>
<main className='main-content w-100'>
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { useState } from "react";
import { Form, InputGroup } from "react-bootstrap";
export default function SearchBar() {
const [searchValue, setSearchValue] = useState('');
const handleSearch = (e) => {
e.preventDefault();
console.log(searchValue);
};
return (
<Form onSubmit={handleSearch} className='search-bar'>
<InputGroup>
<Form.Control type='text' list='datalistOptions' placeholder='Search' value={searchValue} onChange={(e) => setSearchValue(e.target.value)} />
</InputGroup>
<datalist id='datalistOptions'>
<option value='San Francisco' />
<option value='New York' />
<option value='Seattle' />
<option value='Los Angeles' />
<option value='Chicago' />
</datalist>
</Form>
);
}

View File

@ -1,4 +1,94 @@
import React, { useState } from 'react';
import BrandsList from '../components/BrandsList';
import SearchBar from '../components/SearchBar';
import { Button, Modal, Form } from 'react-bootstrap';
import '../styles/Brands.scss';
export default function Brands() {
return <div>Brands</div>;
const [showAddBrandModal, setShowAddBrandModal] = useState(false);
return (
<React.Fragment>
<div className='function-bar'>
<SearchBar />
<Button onClick={() => setShowAddBrandModal(true)}>+ Add Brand</Button>
</div>
<div className='breadcrumb'>
<div className='breadcrumb-item'>Brands</div>
</div>
<BrandsList />
<AddBrandModal show={showAddBrandModal} onHide={() => setShowAddBrandModal(false)} />
</React.Fragment>
);
}
function AddBrandModal({ show, onHide }) {
const [brandName, setBrandName] = useState('');
const [brandSource, setBrandSource] = useState('');
const [campaignId, setCampaignId] = useState('');
const sourceOptions = [
{ value: '1', label: 'Third Party Agency' },
{ value: '2', label: 'Official Event' },
{ value: '3', label: 'Social Media' },
];
const handleCreateBrand = () => {
console.log(brandName, brandSource, campaignId);
handleCancel();
};
const handleCancel = () => {
onHide();
setBrandName('');
setBrandSource('');
setCampaignId('');
};
return (
<Modal show={show} onHide={onHide}>
<Modal.Header>
<Modal.Title>Add Brand</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={handleCreateBrand} className='add-brand-form'>
<Form.Group className='mb-3' controlId='formBasicName'>
<Form.Label>BrandName</Form.Label>
<Form.Control
type='text'
placeholder='Enter name'
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
/>
</Form.Group>
<Form.Group className='mb-3' controlId='formBasicSource'>
<Form.Label>Source</Form.Label>
<Form.Select value={brandSource} onChange={(e) => setBrandSource(e.target.value)}>
{sourceOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className='mb-3' controlId='formBasicCampaignId'>
<Form.Label>Campaign ID</Form.Label>
<Form.Control
type='text'
placeholder='Enter campaign ID'
value={campaignId}
onChange={(e) => setCampaignId(e.target.value)}
/>
</Form.Group>
<div className='button-group'>
<Button variant='outline-light' className='text-primary' onClick={handleCancel}>
Cancel
</Button>
<Button variant='primary' onClick={handleCreateBrand}>
Create
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
);
}

View File

@ -1,4 +1,25 @@
export default function CreatorInbox() {
return <div>CreatorInbox</div>;
}
import React, { useEffect } from 'react';
import InboxList from '@/components/InboxList';
import ChatWindow from '@/components/ChatWindow';
import '@/styles/Inbox.scss';
import { useSelector, useDispatch } from 'react-redux';
import { resetSelectedChat } from '@/store/slices/inboxSlice';
export default function CreatorInbox() {
const { selectedChat } = useSelector((state) => state.inbox);
const dispatch = useDispatch();
useEffect(() => {
return () => {
dispatch(resetSelectedChat());
};
}, []);
useEffect(() => {}, [selectedChat]);
return (
<React.Fragment>
<InboxList />
{selectedChat?.id && <ChatWindow />}
</React.Fragment>
);
}

View File

@ -1,13 +1,20 @@
import React from 'react';
import DatabaseFilter from '../components/DatabaseFilter';
import DatabaseList from '../components/DatabaseList';
import { Link } from 'react-router-dom';
import SearchBar from '../components/SearchBar';
import { Button } from 'react-bootstrap';
export default function Database({ path }) {
return (
<React.Fragment>
<div className='function-bar'>
<SearchBar />
<Button>+ Add to Campaign</Button>
</div>
<div className='breadcrumb'>
<div className='breadcrumb-item'>Creator Database</div>
<div className='breadcrumb-item'>{path}</div>
{path === 'tiktok' && <div className='breadcrumb-item'>TikTok</div>}
{path === 'instagram' && <div className='breadcrumb-item'>Instagram</div>}
{path === 'youtube' && <div className='breadcrumb-item'>YouTube</div>}
</div>
<DatabaseFilter path={path} />
<DatabaseList path={path} />

View File

@ -1,9 +1,10 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Home from '../pages/Home';
import Database from '../pages/Database';
import MainLayout from '../components/MainLayout';
import MainLayout from '../components/Layouts/MainLayout';
import Brands from '../pages/Brands';
import CreatorInbox from '../pages/CreatorInbox';
import DividLayout from '../components/Layouts/DividLayout';
// Routes configuration object
const routes = [
@ -48,19 +49,7 @@ const routes = [
path: '/brands',
element: <Brands />,
},
{
path: '/creator-inbox',
children: [
{
path: '',
element: <CreatorInbox />,
},
{
path: 'templates',
element: <Home />,
},
],
},
{
path: '/settings',
element: <Home />,
@ -74,6 +63,20 @@ const router = createBrowserRouter([
element: <MainLayout />,
children: routes,
},
{
path: '/creator-inbox',
element: <DividLayout />,
children: [
{
path: '',
element: <CreatorInbox />,
},
{
path: 'templates',
element: <Home />,
},
],
},
]);
export default function Router() {

View File

@ -1,16 +1,32 @@
import { configureStore } from '@reduxjs/toolkit';
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import creatorsReducer from './slices/creatorsSlice';
import filtersReducer from './slices/filtersSlice';
import brandsReducer from './slices/brandsSlice';
import { persistReducer, persistStore } from 'redux-persist';
import sessionStorage from 'redux-persist/es/storage/session';
import inboxReducer from './slices/inboxSlice';
const reducers = combineReducers({
creators: creatorsReducer,
filters: filtersReducer,
brands: brandsReducer,
inbox: inboxReducer,
});
export const store = configureStore({
reducer: {
creators: creatorsReducer,
filters: filtersReducer,
},
const persistConfig = {
key: 'root',
storage: sessionStorage,
};
const persistedReducer = persistReducer(persistConfig, reducers);
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export const persistor = persistStore(store);
export default store;

View File

@ -0,0 +1,66 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
const mockBrands = [
{
id: 1,
name: 'Sunlink',
category: 'furniture',
collab: '3',
creators: 20,
source: 'TKS Official',
description: 'Description 1',
website: 'https://www.brand1.com',
},
{
id: 2,
name: 'MINISO',
category: 'furniture',
collab: '5',
creators: 30,
source: 'TKS Official',
description: 'Description 1',
website: 'https://www.brand1.com',
},
];
export const fetchBrands = createAsyncThunk('brands/fetchBrands', async () => {
// const response = await fetch('https://api.example.com/brands');
await new Promise((resolve) => setTimeout(resolve, 500));
return mockBrands;
});
const initialState = {
brands: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
selectedBrand: {},
};
const brandsSlice = createSlice({
name: 'brands',
initialState,
reducers: {
selectBrand: (state, action) => {
state.selectedBrand = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchBrands.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchBrands.fulfilled, (state, action) => {
state.status = 'succeeded';
state.brands = action.payload;
})
.addCase(fetchBrands.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export const { selectBrand } = brandsSlice.actions;
export default brandsSlice.reducer;

View File

@ -111,7 +111,6 @@ export const fetchCreators = createAsyncThunk('creators/fetchCreators', async ({
// 应用筛选逻辑(实际项目中可能在服务器端进行)
let filteredCreators = [...mockCreators];
console.log(filters);
// 如果有选定的类别,进行筛选
if (filters.category.length > 0) {

View File

@ -0,0 +1,159 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { format, isToday, parseISO } from 'date-fns';
const mockInboxList = [
{
id: 1,
name: 'John',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=1',
email: 'john.doe@example.com',
date: '2025-05-09 10:00:00',
status: 'unread',
message: 'Proident non lorem ex...',
unreadMessageCount: 0,
},
{
id: 2,
name: 'Jane',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=2',
email: 'jane.doe@example.com',
date: '2025-05-09 10:00:00',
status: 'read',
message: 'Proident non lorem ex...',
unreadMessageCount: 2,
},
{
id: 3,
name: 'Michelle',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=3',
email: 'michelle.doe@example.com',
date: '2025-05-05 10:00:00',
status: 'unread',
message: 'Proident non lorem ex...',
unreadMessageCount: 0,
},
{
id: 4,
name: 'Alex',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=4',
email: 'alex.doe@example.com',
date: '2025-05-03 10:00:00',
status: 'read',
message: 'Proident non lorem ex...',
unreadMessageCount: 0,
},
];
const mockChatHistory = [
{
id: '7',
role: 'user',
content: '我想了解这个系统的功能',
created_at: '2025-03-21 03:33:49',
},
{
id: '8',
role: 'assistant',
content:
'当然!为了更好地帮助你,我需要了解你指的是哪个系统。你可以提供以下信息吗?\n\n1. **系统的名称**:你提到的系统具体叫什么名字? \n2. **系统的用途**:它是用于什么领域的?比如,是企业管理、教育、医疗、技术开发,还是其他? \n3. **你的具体需求**:你想了解系统的哪些功能?是整体功能概述,还是某个特定模块的功能? \n\n提供这些信息后我可以更详细地为你解答',
created_at: '2025-03-21 03:33:49',
},
{
id: '11',
role: 'user',
content: '操作系统的功能',
created_at: '2025-03-21 06:39:46',
},
{
id: '12',
role: 'assistant',
content:
'操作系统Operating SystemOS是管理计算机硬件与软件资源的系统软件为用户和应用程序提供接口并协调和控制计算机系统的运行。其主要功能包括以下几个方面\n\n---\n\n### 1. **进程管理**\n - **进程调度**管理和分配CPU时间决定哪个进程在何时运行。\n - **进程创建与终止**:创建、销毁和监控进程。\n - **进程同步**:协调多个进程的执行,避免冲突。\n - **进程通信**:提供进程间通信的机制。\n\n---\n\n### 2. **内存管理**\n - **内存分配与回收**:为程序和数据分配内存,并在程序结束时回收内存。\n - **内存保护**:防止一个进程访问另一个进程的内存空间。\n - **虚拟内存**:通过分页或分段技术扩展可用内存。\n - **地址映射**:将逻辑地址转换为物理地址。\n\n---\n\n### 3. **文件管理**\n - **文件存储与检索**:管理文件的存储位置和访问方式。\n - **目录管理**:组织文件的目录结构。\n - **文件保护**:控制文件的访问权限。\n - **磁盘管理**:管理磁盘空间和文件存储。\n\n---\n\n### 4. **设备管理**\n - **设备驱动**:管理与硬件设备的交互。\n - **设备分配**:分配和释放设备资源。\n - **缓冲与缓存**:提高设备访问效率。\n - **I/O控制**:管理输入输出操作。\n\n---\n\n### 5. **用户接口**\n - **命令行界面CLI**:通过文本命令与系统交互。\n - **图形用户界面GUI**:通过图形化界面与系统交互。\n - **应用程序接口API**:为应用程序提供系统调用接口。\n\n---\n\n### 6. **安全与保护**\n - **身份验证**:验证用户身份。\n - **访问控制**:限制用户对资源的访问权限。\n - **加密与解密**:保护数据安全。\n - **审计与日志**:记录系统活动,便于监控和追踪。\n\n---\n\n### 7. **网络管理**\n - **网络通信**:管理网络连接和数据传输。\n - **资源共享**:支持网络资源共享(如文件、打印机)。\n - **网络安全**:保护网络通信的安全性。\n\n---\n\n### 8. **系统性能监控**\n - **资源监控**监控CPU、内存、磁盘等资源的使用情况。\n - **性能优化**:调整系统参数以提高性能。\n - **错误检测与恢复**:检测系统错误并尝试恢复。\n\n---\n\n### 9. **多用户支持**\n - **用户管理**:创建和管理用户账户。\n - **资源隔离**:确保不同用户的资源相互隔离。\n - **优先级管理**:为不同用户或任务分配优先级。\n\n---\n\n### 10. **时间管理**\n - **系统时钟**:维护系统时间。\n - **任务调度**:基于时间调度任务(如定时任务)。\n\n---\n\n操作系统通过这些功能实现了对计算机资源的有效管理和高效利用为用户和应用程序提供了稳定、可靠的环境。',
created_at: '2025-03-21 06:39:46',
},
{
id: '13',
role: 'user',
content: 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc,',
created_at: '2025-03-22 06:39:46',
},
];
const chatDateFormat = (date) => {
const now = typeof date === 'string' ? parseISO(date) : date;
if (isToday(now)) {
return format(now, 'HH:mm');
} else {
return format(now, 'MMM do');
}
};
export const fetchInboxList = createAsyncThunk('inbox/fetchInboxList', async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
const formattedInboxList = mockInboxList.map((item) => ({
...item,
date: chatDateFormat(item.date),
}));
return formattedInboxList;
});
export const fetchChatHistory = createAsyncThunk('inbox/fetchChatHistory', async (id) => {
await new Promise((resolve) => setTimeout(resolve, 500));
return { chatHistory: mockChatHistory, chatId: id };
});
const initialState = {
inboxList: [],
inboxStatus: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
chatStatus: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
selectedChat: {},
};
const inboxSlice = createSlice({
name: 'inbox',
initialState,
reducers: {
selectChat: (state, action) => {
state.selectedChat = action.payload;
},
resetSelectedChat: (state) => {
state.selectedChat = {};
},
},
extraReducers: (builder) => {
builder
.addCase(fetchInboxList.pending, (state) => {
state.inboxStatus = 'loading';
})
.addCase(fetchInboxList.fulfilled, (state, action) => {
state.inboxStatus = 'succeeded';
state.inboxList = action.payload;
})
.addCase(fetchInboxList.rejected, (state, action) => {
state.inboxStatus = 'failed';
state.error = action.error.message;
})
.addCase(fetchChatHistory.pending, (state) => {
state.chatStatus = 'loading';
})
.addCase(fetchChatHistory.fulfilled, (state, action) => {
state.chatStatus = 'succeeded';
const { chatHistory, chatId } = action.payload;
state.inboxList.forEach((item) => {
if (item.id === chatId) {
item.chatHistory = chatHistory;
}
});
state.selectedChat = { ...state.selectedChat, chatHistory };
})
.addCase(fetchChatHistory.rejected, (state, action) => {
state.chatStatus = 'failed';
state.error = action.error.message;
});
},
});
export const { selectChat, resetSelectedChat } = inboxSlice.actions;
export default inboxSlice.reducer;

60
src/styles/Brands.scss Normal file
View File

@ -0,0 +1,60 @@
@import './custom-theme.scss';
.brands-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
.brand-card {
width: 325px;
padding: 1.5rem;
border-radius: 0.4rem;
background-color: white;
cursor: pointer;
.card-body {
display: flex;
flex-flow: column nowrap;
gap: 0.75rem;
.card-logo {
display: inline-block;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
background-color: $indigo-500;
color: white;
line-height: 2.25rem;
text-align: center;
margin-right: 0.5rem;
}
.card-text {
margin: 0;
display: inline-flex;
align-items: center;
.card-text-title {
display: inline-flex;
width: 100px;
flex-flow: row nowrap;
align-items: center;
gap: 0.25rem;
color: $neutral-600;
line-height: 1.5rem;
}
.card-text-content {
line-height: 1.5rem;
color: $neutral-900;
}
}
}
}
}
.add-brand-form {
.button-group {
display: flex;
flex-flow: row nowrap;
gap: 0.5rem;
justify-content: flex-end;
}
}

254
src/styles/Inbox.scss Normal file
View File

@ -0,0 +1,254 @@
@import './custom-theme.scss';
.inbox-list-container {
background-color: #fff;
border-radius: 0.4rem;
padding: 1.5rem;
width: 360px;
flex-shrink: 0;
.list-filter {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
.toggle-list {
width: 100%;
display: flex;
flex-flow: row nowrap;
background-color: $neutral-200;
border-radius: 0.4rem;
.toggle-option {
padding: 0.5rem 0;
cursor: pointer;
border-right: 1px solid #d4d7de;
transition: 0.25s;
flex-grow: 1;
text-align: center;
&.active {
color: white;
background-color: $primary;
&:hover {
color: white;
background-color: $primary;
opacity: 0.8;
}
}
&:hover {
color: $neutral-700;
background-color: $neutral-350;
}
&:first-child {
border-top-left-radius: 0.4rem;
border-bottom-left-radius: 0.4rem;
}
&:last-child {
border-top-right-radius: 0.4rem;
border-bottom-right-radius: 0.4rem;
border-right: 1px solid transparent;
}
}
}
.select-list {
display: flex;
flex-flow: row nowrap;
width: fit-content;
.select-option {
padding: 0 1rem;
cursor: pointer;
&:first-child {
border-right: 1px solid $neutral-350;
}
&.active {
color: $primary;
}
}
}
.actions {
width: fit-content;
cursor: pointer;
}
}
.list-content {
display: flex;
flex-flow: column nowrap;
gap: 0.5rem;
.list-item {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
padding: 1rem;
border-radius: 12px;
cursor: pointer;
transition: 0.25s;
&:hover {
background-color: $neutral-150;
}
&.active {
background-color: $neutral-200;
}
.list-item-left {
display: flex;
flex-flow: row nowrap;
gap: 0.5rem;
align-items: center;
.list-item-left-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
overflow: hidden;
}
.list-item-info {
.list-message {
color: $neutral-700;
}
}
}
.list-item-right {
display: flex;
flex-flow: column nowrap;
align-items: flex-end;
justify-content: space-between;
height: 100%;
gap: 0.25rem;
.list-item-right-time {
color: $neutral-600;
font-size: 0.875rem;
}
.list-item-right-badge {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background-color: #de3b40;
color: white;
text-align: center;
font-size: 0.75rem;
line-height: 1.25rem;
}
}
}
}
}
.chat-window {
background-color: #fff;
border-radius: 0.4rem;
display: flex;
flex-flow: column nowrap;
flex-grow: 1;
.chat-window-header {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid $neutral-200;
.chat-window-header-left {
display: flex;
flex-flow: row nowrap;
gap: 0.5rem;
align-items: center;
.chat-window-header-left-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
overflow: hidden;
}
}
.chat-window-header-right {
display: flex;
flex-flow: row nowrap;
gap: 0.5rem;
align-items: center;
font-size: 0.875rem;
.platform-selection {
display: flex;
flex-flow: row nowrap;
.platform-selection-item {
padding: 0.5rem;
cursor: pointer;
transition: 0.25s;
border-bottom: 3px solid transparent;
font-weight: 600;
&.active {
color: $primary;
border-bottom: 3px solid $primary;
}
}
}
.actions {
cursor: pointer;
}
}
}
.chat-window-body {
display: flex;
flex-flow: column nowrap;
flex-grow: 1;
height: 100%;
overflow: hidden;
.chat-body {
padding: 1rem;
overflow-y: auto;
height: 100%;
.message {
max-width: 80%;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 1rem;
line-height: 1.5;
}
.message.user {
background-color: $violet-150;
align-self: flex-end;
margin-left: auto;
}
.message.assistant {
background-color: $neutral-200;
align-self: flex-start;
margin-right: auto;
}
}
}
.chat-window-footer {
border-top: 1px solid $neutral-200;
padding: 1rem;
.footer-input {
display: flex;
flex-flow: row nowrap;
gap: 0.5rem;
align-items: center;
}
}
}
.chat-textarea {
resize: none; /* 禁用右下角拖拽 */
overflow: hidden; /* 防止出现滚动条 */
line-height: 1.5;
font-size: 1rem;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
box-shadow: none;
border: 1px solid #ccc;
}

View File

@ -15,7 +15,13 @@ $indigo-100: #e0e7ff;
$indigo-500: #6366f1;
$violet-50: #f5f3ff;
$violet-100: #ede9fe;
$neutral-600: #525252;
$violet-150: #E0E1FAFF;
$neutral-150: #f8f9faFF;
$neutral-200: #F3F4F6FF;
$neutral-350: #CFD2DAFF;
$neutral-600: #565e6cff;
$neutral-700: #323842ff;
$neutral-900: #171a1fff;
$zinc-600: #52525b;
// 字体
$font-family-sans-serif: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
@ -32,16 +38,18 @@ $box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
:root {
--bs-breadcrumb-font-size: 1.5rem;
--bs-body-color: #171A1FFF;
--bs-btn-color: white;
--bs-btn-hover-color: white;
--bs-btn-color: white !important;
--bs-btn-hover-color: white !important;
}
.btn-primary {
--bs-btn-color: white !important;
--bs-btn-hover-color: white !important;
}
.btn-outline-primary {
--bs-btn-color: #6366f1 !important;
--bs-btn-hover-color: white !important;
}
#root {
background-color: #f5f3ff;

View File

@ -1,7 +1,16 @@
@import 'custom-theme.scss';
.breadcrumb {
font-weight: 700;
a {
color: var(--bs-body-color) !important;
}
}
.function-bar {
display: flex;
flex-flow: row wrap;
gap: 1rem;
justify-content: flex-end;
float: right;
}

View File

@ -13,6 +13,34 @@
overflow-y: auto;
background: $violet-50;
// Collapsed sidebar style
&.sidebar-collapsed {
width: 70px;
.sidebar-icon {
margin-right: 0 !important;
}
.accordion-button::after {
display: none;
}
.nav-link {
justify-content: center;
padding: 0.75rem 0.5rem;
}
.accordion-button {
justify-content: center;
padding: 0.75rem 0.5rem;
}
.sidebar-header {
justify-content: center;
padding: 1rem 0.5rem !important;
}
}
.sidebar-header {
}
@ -91,6 +119,15 @@
color: var(--bs-primary);
}
}
&.collapsed-header .accordion-button {
justify-content: center;
padding: 0.75rem 0.5rem;
&::after {
display: none;
}
}
}
.sidebar-submenu {
@ -107,25 +144,26 @@
padding: 1.5rem;
margin: 1rem;
margin-left: 220px;
height: 100vh;
height: calc(100vh - 2rem);
transition: all 0.3s ease;
background: #f8f9fa;
border-radius: 8px;
}
// Collapsed sidebar adjustments
.sidebar-collapsed + .main-content {
margin-left: 70px;
}
// Responsive sidebar for mobile
@media (max-width: 768px) {
.sidebar {
width: 0;
overflow: hidden;
&.show {
width: 250px;
}
}
.main-content {
margin-left: 0;
margin-left: 70px;
}
}