[dev]database list

This commit is contained in:
susie-laptop 2025-05-08 22:18:49 -04:00
parent d9e0750441
commit 1f97455961
16 changed files with 836 additions and 65 deletions

62
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.8.1",
"bootstrap": "^5.3.3",
"lodash": "^4.17.21",
"lucide-react": "^0.508.0",
@ -1396,6 +1397,31 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.1.tgz",
"integrity": "sha512-GLjHS13LiBdiuxSJvfWs3+Cx5yt97mCbuVlDteTusS6VRksPhoWviO8L1e3Re1G94m6lkw/l4pjEEyyNaGf19g==",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@restart/hooks": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
@ -1706,6 +1732,16 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
@ -2955,6 +2991,15 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/immutable": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
@ -4041,9 +4086,20 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"optional": true,
"peer": true
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/resolve-from": {
"version": "4.0.0",

View File

@ -16,6 +16,7 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.8.1",
"bootstrap": "^5.3.3",
"lodash": "^4.17.21",
"lucide-react": "^0.508.0",

View File

@ -1,10 +1,23 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Card, Row, Col, Form, InputGroup, Button } from 'react-bootstrap';
import { Eye } from 'lucide-react';
import RangeSlider from './RangeSlider';
import {
setCategory,
toggleEcommerceRating,
toggleExposureRating,
toggleGmvRange,
setViewsRange,
resetFilters,
} from '../store/slices/filtersSlice';
import { fetchCreators } from '../store/slices/creatorsSlice';
import '../styles/DatabaseFilter.scss';
export default function DatabaseFilter() {
export default function DatabaseFilter({ path }) {
const dispatch = useDispatch();
const filters = useSelector((state) => state.filters);
//
const categories = [
'Phones & Electronics',
@ -50,54 +63,44 @@ export default function DatabaseFilter() {
return closestIndex;
};
//
const [selectedCategory, setSelectedCategory] = useState('Homes Supplies');
const [selectedEcommerceRatings, setSelectedEcommerceRatings] = useState(['L2', 'L3']);
const [selectedExposureRatings, setSelectedExposureRatings] = useState([]);
const [selectedGmvRanges, setSelectedGmvRanges] = useState(['$5k - $25k', '$25k - $60k']);
//
const [minViews, setMinViews] = useState(filters.viewsRange[0]);
const [maxViews, setMaxViews] = useState(filters.viewsRange[1]);
// - 使
const [viewsRange, setViewsRange] = useState([0, 100000]);
const [minViews, setMinViews] = useState(0);
const [maxViews, setMaxViews] = useState(100000);
// Redux
useEffect(() => {
setMinViews(filters.viewsRange[0]);
setMaxViews(filters.viewsRange[1]);
}, [filters.viewsRange]);
//
useEffect(() => {
dispatch(fetchCreators({ path }));
}, [dispatch, filters]);
//
const handleCategorySelect = (category) => {
setSelectedCategory(category);
dispatch(setCategory(category));
};
//
const handleEcommerceRatingSelect = (rating) => {
if (selectedEcommerceRatings.includes(rating)) {
setSelectedEcommerceRatings(selectedEcommerceRatings.filter((r) => r !== rating));
} else {
setSelectedEcommerceRatings([...selectedEcommerceRatings, rating]);
}
dispatch(toggleEcommerceRating(rating));
};
//
const handleExposureRatingSelect = (rating) => {
if (selectedExposureRatings.includes(rating)) {
setSelectedExposureRatings(selectedExposureRatings.filter((r) => r !== rating));
} else {
setSelectedExposureRatings([...selectedExposureRatings, rating]);
}
dispatch(toggleExposureRating(rating));
};
// GMV
const handleGmvRangeSelect = (range) => {
if (selectedGmvRanges.includes(range)) {
setSelectedGmvRanges(selectedGmvRanges.filter((r) => r !== range));
} else {
setSelectedGmvRanges([...selectedGmvRanges, range]);
}
dispatch(toggleGmvRange(range));
};
//
const handleViewsRangeChange = (newRange) => {
setViewsRange(newRange);
setMinViews(newRange[0]);
setMaxViews(newRange[1]);
dispatch(setViewsRange(newRange));
};
// min input
@ -123,7 +126,7 @@ export default function DatabaseFilter() {
const finalValue = Math.min(discreteValue, maxViews);
setMinViews(finalValue);
setViewsRange([finalValue, viewsRange[1]]);
dispatch(setViewsRange([finalValue, filters.viewsRange[1]]));
} else {
//
const closestIndex = findClosestDiscreteIndex(maxViews);
@ -133,7 +136,7 @@ export default function DatabaseFilter() {
const finalValue = Math.max(discreteValue, minViews);
setMaxViews(finalValue);
setViewsRange([viewsRange[0], finalValue]);
dispatch(setViewsRange([filters.viewsRange[0], finalValue]));
}
};
@ -149,7 +152,7 @@ export default function DatabaseFilter() {
};
return (
<Card className='shadow-sm mb-4 filter-card'>
<div className='shadow-sm mb-4 filter-card'>
<Card.Body>
<h3 className='mb-4'>Filter</h3>
@ -160,7 +163,7 @@ export default function DatabaseFilter() {
{categories.map((category) => (
<Button
key={category}
variant={selectedCategory === category ? 'primary' : 'light'}
variant={filters.category.includes(category) ? 'primary' : 'light'}
className='rounded-pill'
onClick={() => handleCategorySelect(category)}
>
@ -177,7 +180,7 @@ export default function DatabaseFilter() {
{ecommerceRatings.map((rating) => (
<Button
key={rating}
variant={selectedEcommerceRatings.includes(rating) ? 'primary' : 'light'}
variant={filters.ecommerceRatings.includes(rating) ? 'primary' : 'light'}
className='rounded-pill'
onClick={() => handleEcommerceRatingSelect(rating)}
>
@ -194,7 +197,7 @@ export default function DatabaseFilter() {
{exposureRatings.map((rating) => (
<Button
key={rating}
variant={selectedExposureRatings.includes(rating) ? 'primary' : 'light'}
variant={filters.exposureRatings.includes(rating) ? 'primary' : 'light'}
className='rounded-pill'
onClick={() => handleExposureRatingSelect(rating)}
>
@ -211,7 +214,7 @@ export default function DatabaseFilter() {
{gmvRanges.map((range) => (
<Button
key={range.label}
variant={selectedGmvRanges.includes(range.label) ? 'primary' : 'light'}
variant={filters.gmvRanges.includes(range.label) ? 'primary' : 'light'}
className='rounded-pill'
onClick={() => handleGmvRangeSelect(range.label)}
>
@ -225,7 +228,12 @@ export default function DatabaseFilter() {
<div className='filter-item'>
<h5 className='filter-title'>Views</h5>
<div className='filter-options filter-views'>
<RangeSlider min={0} max={500000} value={viewsRange} onChange={handleViewsRangeChange} />
<RangeSlider
min={0}
max={500000}
value={filters.viewsRange}
onChange={handleViewsRangeChange}
/>
<div className='range-input'>
<InputGroup>
@ -255,6 +263,6 @@ export default function DatabaseFilter() {
</div>
</div>
</Card.Body>
</Card>
</div>
);
}

View File

@ -1,4 +1,189 @@
export default function DatabaseList() {
return <div>DatabaseList</div>;
}
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Table, Form, Spinner } from 'react-bootstrap';
import { ChevronUp, ChevronDown } from 'lucide-react';
import {
fetchCreators,
toggleCreatorSelection,
selectAllCreators,
clearCreatorSelection,
} from '../store/slices/creatorsSlice';
import { setSortBy } from '../store/slices/filtersSlice';
import '../styles/DatabaseList.scss';
export default function DatabaseList({ path }) {
const dispatch = useDispatch();
const { creators, status, selectedCreators } = useSelector((state) => state.creators);
const { sortBy, sortDirection } = useSelector((state) => state.filters);
//
useEffect(() => {
if (status === 'idle') {
dispatch(fetchCreators({path}));
}
}, [dispatch, status]);
useEffect(() => {
console.log(creators);
}, [creators]);
// /
const handleSelectAll = (e) => {
if (e.target.checked) {
dispatch(selectAllCreators());
} else {
dispatch(clearCreatorSelection());
}
};
//
const handleSelectCreator = (creatorId) => {
dispatch(toggleCreatorSelection(creatorId));
};
//
const handleSort = (field) => {
dispatch(setSortBy(field));
};
//
const renderSortIcon = (field) => {
if (sortBy === field) {
return sortDirection === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />;
}
return null;
};
//
const getCategoryClassName = (category) => {
const categoryMap = {
'Phones & Electronics': 'phones',
'Womenswear & Underwear': 'women',
'Sports & Outdoor': 'sports',
'Food & Beverage': 'food',
Health: 'health',
Kitchenware: 'kitchen',
};
return categoryMap[category] || '';
};
//
if (status === 'loading') {
return (
<div className='text-center p-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
);
}
//
if (status === 'failed') {
return <div className='alert alert-danger'>Failed to load creators. Please try again later.</div>;
}
return (
<div className='creator-database-table'>
<Table responsive hover className='bg-white shadow-sm rounded overflow-hidden'>
<thead>
<tr>
<th className='selector' style={{ width: '40px' }}>
<Form.Check
type='checkbox'
checked={selectedCreators.length === creators.length && creators.length > 0}
onChange={handleSelectAll}
/>
</th>
<th className='creator' onClick={() => handleSort('name')} style={{ width: '180px' }}>
Creator {renderSortIcon('name')}
</th>
<th
className='category text-center'
onClick={() => handleSort('category')}
style={{ width: '180px' }}
>
Category {renderSortIcon('category')}
</th>
<th className='e-commerce-level text-center' onClick={() => handleSort('ecommerceLevel')}>
E-commerce Level {renderSortIcon('ecommerceLevel')}
</th>
<th className='exposure-level text-center' onClick={() => handleSort('exposureLevel')}>
Exposure Level {renderSortIcon('exposureLevel')}
</th>
<th className='followers text-center' onClick={() => handleSort('followers')}>
Followers {renderSortIcon('followers')}
</th>
<th className='gmv text-center' onClick={() => handleSort('gmv')}>
GMV {renderSortIcon('gmv')}
</th>
<th className='views text-center' onClick={() => handleSort('avgViews')}>
Avg. Video Views {renderSortIcon('avgViews')}
</th>
<th className='e-commerce text-center'>E-commerce</th>
<th className='profile text-center'>Profile</th>
</tr>
</thead>
<tbody>
{creators.length === 0 ? (
<tr>
<td colSpan='10' className='text-center py-4'>
No creators found matching your filters.
</td>
</tr>
) : (
creators.map((creator) => (
<tr key={creator.id} className={selectedCreators.includes(creator.id) ? 'selected' : ''}>
<td>
<Form.Check
type='checkbox'
checked={selectedCreators.includes(creator.id)}
onChange={() => handleSelectCreator(creator.id)}
/>
</td>
<td className='creator-cell'>
<div className='d-flex align-items-center'>
<div className='creator-avatar'>
<img src={creator.avatar} alt={creator.name} />
{creator.verified && <span className='verified-badge'></span>}
</div>
<div className='creator-name'>{creator.name}</div>
</div>
</td>
<td>
<span className={`category-pill ${getCategoryClassName(creator.category)}`}>
{creator.category}
</span>
</td>
<td className='text-center'>
<span className='level-badge ecommerce-level'>{creator.ecommerceLevel}</span>
</td>
<td className='text-center'>
<span className='level-badge exposure-level' data-level={creator.exposureLevel}>
{creator.exposureLevel}
</span>
</td>
<td className='text-nowrap text-center'>{creator.followers}</td>
<td className='text-center'>
<div>{creator.gmv}</div>
<div className='small text-muted'>Items Sold: {creator.soldPercentage}</div>
</td>
<td className='text-nowrap text-center'>{creator.avgViews}</td>
<td className='text-center'>
{creator.hasEcommerce ? <div className='colored-dot blue mx-auto'></div> : null}
</td>
<td className='text-center'>
{creator.hasTiktok && (
<div className='social-icon tiktok-icon mx-auto'>
<i className='fab fa-tiktok'></i>
</div>
)}
</td>
</tr>
))
)}
</tbody>
</Table>
</div>
);
}

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Nav, Accordion } from 'react-bootstrap';
import { Settings, ChevronDown, Blocks, SquareActivity, LayoutDashboard, Mail, UserSearch, Heart } 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';
@ -12,33 +12,33 @@ const menuItems = [
id: 'creator-discovery',
title: 'Creator Discovery',
path: '/creator-discovery',
icon: <UserSearch style={{ width: 20, height: 20 }} />,
icon: <UserSearch />,
hasSubmenu: false,
},
{
id: 'creator-database',
title: 'Creator Database',
path: '/creator-database',
icon: <Blocks style={{ width: 20, height: 20 }} />,
icon: <Blocks/>,
hasSubmenu: true,
submenuItems: [
{
id: 'tiktok',
title: 'TikTok',
path: '/creator-database/tiktok',
icon: <FontAwesomeIcon style={{ width: 20, height: 20 }} icon='fa-brands fa-tiktok' />,
icon: <FontAwesomeIcon icon='fa-brands fa-tiktok' />,
},
{
id: 'instagram',
title: 'Instagram',
path: '/creator-database/instagram',
icon: <FontAwesomeIcon style={{ width: 20, height: 20 }} icon='fa-brands fa-instagram' />,
icon: <FontAwesomeIcon icon='fa-brands fa-instagram' />,
},
{
id: 'youtube',
title: 'YouTube',
path: '/creator-database/youtube',
icon: <FontAwesomeIcon style={{ width: 20, height: 20 }} icon='fa-brands fa-youtube' />,
icon: <FontAwesomeIcon icon='fa-brands fa-youtube' />,
},
],
},
@ -46,7 +46,7 @@ const menuItems = [
id: 'private-creators',
title: 'Private Creators',
path: '/private-creators',
icon: <Heart style={{ width: 20, height: 20 }} />,
icon: <Heart />,
hasSubmenu: true,
submenuItems: [],
},
@ -54,29 +54,42 @@ const menuItems = [
id: 'deep-analysis',
title: 'Deep Analysis',
path: '/deep-analysis',
icon: <SquareActivity style={{ width: 20, height: 20 }} />,
icon: <SquareActivity />,
hasSubmenu: false,
},
{
id: 'brands',
title: 'Brands',
path: '/brands',
icon: <LayoutDashboard style={{ width: 20, height: 20 }} />,
icon: <LayoutDashboard />,
hasSubmenu: false,
},
{
id: 'creator-inbox',
title: 'Creator Inbox',
path: '/creator-inbox',
icon: <Mail style={{ width: 20, height: 20 }} />,
icon: <Mail />,
hasSubmenu: true,
submenuItems: [],
submenuItems: [
{
id: 'inbox',
title: 'Inbox',
path: '/creator-inbox',
icon: <Send/>,
},
{
id: 'templates',
title: 'Templates',
path: '/creator-inbox/templates',
icon: <FileText />,
},
],
},
{
id: 'settings',
title: 'Settings',
path: '/settings',
icon: <Settings style={{ width: 20, height: 20 }} />,
icon: <Settings />,
hasSubmenu: false,
},
];

View File

@ -1,11 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store';
import './styles/global.scss';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);

4
src/pages/Brands.jsx Normal file
View File

@ -0,0 +1,4 @@
export default function Brands() {
return <div>Brands</div>;
}

View File

@ -0,0 +1,4 @@
export default function CreatorInbox() {
return <div>CreatorInbox</div>;
}

View File

@ -9,8 +9,8 @@ export default function Database({ path }) {
<div className='breadcrumb-item'>Creator Database</div>
<div className='breadcrumb-item'>{path}</div>
</div>
<DatabaseFilter />
<DatabaseList />
<DatabaseFilter path={path} />
<DatabaseList path={path} />
</React.Fragment>
);
}

View File

@ -2,6 +2,8 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Home from '../pages/Home';
import Database from '../pages/Database';
import MainLayout from '../components/MainLayout';
import Brands from '../pages/Brands';
import CreatorInbox from '../pages/CreatorInbox';
// Routes configuration object
const routes = [
@ -44,11 +46,20 @@ const routes = [
},
{
path: '/brands',
element: <Home />,
element: <Brands />,
},
{
path: '/creator-inbox/*',
element: <Home />,
path: '/creator-inbox',
children: [
{
path: '',
element: <CreatorInbox />,
},
{
path: 'templates',
element: <Home />,
},
],
},
{
path: '/settings',

16
src/store/index.js Normal file
View File

@ -0,0 +1,16 @@
import { configureStore } from '@reduxjs/toolkit';
import creatorsReducer from './slices/creatorsSlice';
import filtersReducer from './slices/filtersSlice';
export const store = configureStore({
reducer: {
creators: creatorsReducer,
filters: filtersReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export default store;

View File

@ -0,0 +1,204 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// 模拟创作者数据实际项目中会从API获取
const mockCreators = [
{
id: 1,
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=1',
category: 'Phones & Electronics',
ecommerceLevel: 'L2',
exposureLevel: 'KOC-1',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
hasEcommerce: true,
hasTiktok: true,
verified: true,
},
{
id: 2,
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=2',
category: 'Womenswear & Underwear',
ecommerceLevel: 'L3',
exposureLevel: 'KOL-3',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
hasEcommerce: false,
hasTiktok: true,
verified: false,
},
{
id: 3,
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=3',
category: 'Sports & Outdoor',
ecommerceLevel: 'L4',
exposureLevel: 'KOC-2',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
hasEcommerce: true,
hasTiktok: true,
verified: false,
},
{
id: 4,
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=4',
category: 'Food & Beverage',
ecommerceLevel: 'L1',
exposureLevel: 'KOC-2',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
hasEcommerce: true,
hasTiktok: true,
hasInstagram: true,
hasYoutube: true,
verified: true,
},
{
id: 5,
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=5',
category: 'Health',
ecommerceLevel: 'L5',
exposureLevel: 'KOL-2',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
hasEcommerce: false,
hasTiktok: true,
hasInstagram: true,
hasYoutube: true,
verified: true,
},
{
id: 6,
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=6',
category: 'Kitchenware',
ecommerceLevel: 'New tag',
exposureLevel: 'New tag',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
hasEcommerce: true,
hasTiktok: true,
hasInstagram: true,
hasYoutube: true,
verified: false,
},
];
// 模拟API获取数据的异步Thunk
export const fetchCreators = createAsyncThunk('creators/fetchCreators', async ({ path }, { getState }) => {
// 模拟API调用延迟
await new Promise((resolve) => setTimeout(resolve, 500));
// 获取当前的筛选条件
const state = getState();
const filters = state.filters;
// 应用筛选逻辑(实际项目中可能在服务器端进行)
let filteredCreators = [...mockCreators];
console.log(filters);
// 如果有选定的类别,进行筛选
if (filters.category.length > 0) {
filteredCreators = filteredCreators.filter((creator) => filters.category.includes(creator.category));
}
// 如果有选定的电商评级,进行筛选
if (filters.ecommerceRatings.length > 0) {
filteredCreators = filteredCreators.filter((creator) =>
filters.ecommerceRatings.includes(creator.ecommerceLevel)
);
}
// 如果有选定的曝光评级,进行筛选
if (filters.exposureRatings.length > 0) {
filteredCreators = filteredCreators.filter((creator) =>
filters.exposureRatings.includes(creator.exposureLevel)
);
}
// 筛选观看量范围
if (filters.viewsRange.length === 2) {
const minViews = filters.viewsRange[0];
const maxViews = filters.viewsRange[1];
filteredCreators = filteredCreators.filter((creator) => {
// 将带k的字符串转换为数字
const viewsStr = creator.avgViews;
let views = parseFloat(viewsStr);
if (viewsStr.includes('k')) {
views *= 1000;
} else if (viewsStr.includes('M')) {
views *= 1000000;
}
return views >= minViews && views <= maxViews;
});
}
return filteredCreators;
});
const initialState = {
creators: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
selectedCreators: [],
};
const creatorsSlice = createSlice({
name: 'creators',
initialState,
reducers: {
toggleCreatorSelection: (state, action) => {
const creatorId = action.payload;
const isSelected = state.selectedCreators.includes(creatorId);
if (isSelected) {
state.selectedCreators = state.selectedCreators.filter((id) => id !== creatorId);
} else {
state.selectedCreators.push(creatorId);
}
},
selectAllCreators: (state) => {
state.selectedCreators = state.creators.map((creator) => creator.id);
},
clearCreatorSelection: (state) => {
state.selectedCreators = [];
},
},
extraReducers: (builder) => {
builder
.addCase(fetchCreators.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchCreators.fulfilled, (state, action) => {
state.status = 'succeeded';
state.creators = action.payload;
})
.addCase(fetchCreators.rejected, (state, action) => {
console.log(action);
state.status = 'failed';
state.error = action.error.message;
});
},
});
export const { toggleCreatorSelection, selectAllCreators, clearCreatorSelection } = creatorsSlice.actions;
export default creatorsSlice.reducer;

View File

@ -0,0 +1,78 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
category: ['Homes Supplies'],
ecommerceRatings: ['L2', 'L3'],
exposureRatings: [],
gmvRanges: ['$5k - $25k', '$25k - $60k'],
viewsRange: [0, 100000],
sortBy: 'followers',
sortDirection: 'desc',
};
const filtersSlice = createSlice({
name: 'filters',
initialState,
reducers: {
setCategory: (state, action) => {
const category = action.payload;
if (state.category.includes(category)) {
state.category = state.category.filter((r) => r !== category);
} else {
state.category.push(category);
}
},
toggleEcommerceRating: (state, action) => {
const rating = action.payload;
if (state.ecommerceRatings.includes(rating)) {
state.ecommerceRatings = state.ecommerceRatings.filter((r) => r !== rating);
} else {
state.ecommerceRatings.push(rating);
}
},
toggleExposureRating: (state, action) => {
const rating = action.payload;
if (state.exposureRatings.includes(rating)) {
state.exposureRatings = state.exposureRatings.filter((r) => r !== rating);
} else {
state.exposureRatings.push(rating);
}
},
toggleGmvRange: (state, action) => {
const range = action.payload;
if (state.gmvRanges.includes(range)) {
state.gmvRanges = state.gmvRanges.filter((r) => r !== range);
} else {
state.gmvRanges.push(range);
}
},
setViewsRange: (state, action) => {
state.viewsRange = action.payload;
},
setSortBy: (state, action) => {
// 如果选择了当前已激活的排序项,则切换排序方向
if (state.sortBy === action.payload) {
state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
// 否则设置新的排序项,并重置排序方向为降序
state.sortBy = action.payload;
state.sortDirection = 'desc';
}
},
resetFilters: () => {
return initialState;
},
},
});
export const {
setCategory,
toggleEcommerceRating,
toggleExposureRating,
toggleGmvRange,
setViewsRange,
setSortBy,
resetFilters,
} = filtersSlice.actions;
export default filtersSlice.reducer;

View File

@ -17,7 +17,7 @@
.filter-title {
width: 170px;
margin: 0;
color: $gray-500;
color: $gray-700;
}
.filter-options {

View File

@ -0,0 +1,186 @@
@import './custom-theme.scss';
.creator-database-table {
.table {
border-collapse: separate;
border-spacing: 0;
th {
background-color: #f8f9fa;
border-top: none;
border-bottom: 1px solid #dee2e6;
padding: 1rem 0.75rem;
font-weight: 600;
font-size: 0.875rem;
color: #495057;
cursor: pointer;
position: relative;
vertical-align: middle;
&:first-child {
border-top-left-radius: 0.5rem;
}
&:last-child {
border-top-right-radius: 0.5rem;
}
&:hover {
background-color: #f1f3f5;
}
}
td {
padding: 0.75rem;
vertical-align: middle;
border-bottom: 1px solid #f1f3f4;
font-size: 0.875rem;
}
tbody tr {
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
&.selected {
background-color: rgba(99, 102, 241, 0.05);
&:hover {
background-color: rgba(99, 102, 241, 0.1);
}
}
&:last-child td {
border-bottom: none;
}
}
}
.creator-cell {
.creator-avatar {
position: relative;
width: 36px;
height: 36px;
margin-right: 12px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
.verified-badge {
position: absolute;
bottom: 0;
right: 0;
background-color: #10b981;
color: white;
border-radius: 50%;
width: 10px;
height: 10px;
font-size: 8px;
display: flex;
align-items: center;
justify-content: center;
}
}
.creator-name {
font-weight: 500;
}
}
.category-pill {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
background-color: #f8f9fa;
font-size: 0.75rem;
color: #495057;
white-space: nowrap;
&.phones {
background-color: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
&.women {
background-color: rgba(244, 114, 182, 0.1);
color: #f472b6;
}
&.sports {
background-color: rgba(52, 211, 153, 0.1);
color: #34d399;
}
&.food {
background-color: rgba(251, 146, 60, 0.1);
color: #fb923c;
}
&.health {
background-color: rgba(56, 189, 248, 0.1);
color: #38bdf8;
}
&.kitchen {
background-color: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
}
.level-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
white-space: nowrap;
&.ecommerce-level {
background-color: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
&.exposure-level {
background-color: rgba(14, 165, 233, 0.1);
color: #0ea5e9;
&[data-level^="KOC"] {
background-color: rgba(52, 211, 153, 0.1);
color: #34d399;
}
&[data-level^="KOL"] {
background-color: rgba(251, 113, 133, 0.1);
color: #fb7185;
}
}
}
.colored-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
&.blue {
background-color: #6366f1;
}
}
.social-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
&.tiktok-icon {
font-size: 14px;
color: #000000;
}
}
}

View File

@ -28,7 +28,7 @@
align-items: center;
text-decoration: none;
font-weight: 500;
border-left: 4px solid transparent;
&:hover {
text-decoration: none;
color: var(--bs-primary);
@ -64,7 +64,8 @@
font-size: 1rem;
box-shadow: none;
font-weight: 500;
border-left: 4px solid transparent;
&:not(.collapsed) {
color: var(--bs-primary);
background-color: rgba(var(--bs-primary-rgb), 0.05);