chore: initialize tuhui repository

This commit is contained in:
Codex
2026-03-08 19:28:32 +08:00
commit ee10c46aae
189 changed files with 17754 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
setupNodeEvents(on, config) {
// implement node event listeners here
},
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 10000,
},
})

View File

@@ -0,0 +1,107 @@
// 生成随机手机号
const generatePhone = () => {
const timestamp = Date.now().toString().slice(-8);
return `138${timestamp}`;
};
describe('用户认证测试', () => {
let testPhone;
let testPassword = 'Test123456';
beforeEach(() => {
testPhone = generatePhone();
cy.visit('/');
cy.wait(1000); // 等待页面加载
});
it('应该能够成功注册新用户', () => {
// 点击注册按钮 - 使用class选择器
cy.get('.btn-register').click();
// 等待注册模态框出现
cy.get('.auth-modal', { timeout: 10000 }).should('be.visible');
// 填写注册表单
cy.get('input[placeholder*="手机号"]').type(testPhone);
cy.get('input[placeholder*="昵称"]').type('E2E测试用户');
cy.get('input[placeholder*="设置密码"]').type(testPassword);
cy.get('input[placeholder*="确认密码"]').type(testPassword);
// 点击提交注册
cy.get('.auth-modal .auth-submit-btn').click();
// 验证注册成功 - 等待页面刷新并检查登录状态
// 注册成功后会自动刷新页面,所以等待头像出现即可
cy.get('.ant-avatar', { timeout: 15000 }).should('exist');
cy.log(`✅ 注册测试通过 - 手机号: ${testPhone}`);
});
it('应该能够使用已注册账号登录', () => {
// 先注册
cy.get('.btn-register').click();
cy.get('.auth-modal').should('be.visible');
cy.get('input[placeholder*="手机号"]').type(testPhone);
cy.get('input[placeholder*="设置密码"]').type(testPassword);
cy.get('input[placeholder*="确认密码"]').type(testPassword);
cy.get('.auth-modal .auth-submit-btn').click();
// 等待注册成功和页面刷新 - 等待头像出现
cy.get('.ant-avatar', { timeout: 15000 }).should('exist');
// 退出登录
cy.get('.ant-avatar').click();
cy.contains('退出登录').click();
cy.wait(2000);
// 重新登录
cy.visit('/');
cy.wait(1000);
cy.get('.btn-login').click();
cy.get('.auth-modal').should('be.visible');
cy.get('input[placeholder*="手机号"]').type(testPhone);
cy.get('input[placeholder*="密码"]').first().type(testPassword);
cy.get('.auth-modal .auth-submit-btn').click();
// 验证登录成功 - 等待页面刷新并检查登录状态
cy.get('.ant-avatar', { timeout: 15000 }).should('exist');
cy.log(`✅ 登录测试通过 - 手机号: ${testPhone}`);
});
it('应该验证手机号格式', () => {
cy.get('.btn-register').click();
cy.get('.auth-modal').should('be.visible');
// 输入无效手机号
cy.get('input[placeholder*="手机号"]').type('123456');
cy.get('input[placeholder*="设置密码"]').type(testPassword);
cy.get('input[placeholder*="确认密码"]').type(testPassword);
// 尝试提交
cy.get('.auth-modal .auth-submit-btn').click();
// 应该显示错误提示
cy.contains('请输入正确的11位手机号').should('be.visible');
cy.log('✅ 手机号验证测试通过');
});
it('应该验证密码确认', () => {
cy.get('.btn-register').click();
cy.get('.auth-modal').should('be.visible');
// 输入不匹配的密码
cy.get('input[placeholder*="手机号"]').type(testPhone);
cy.get('input[placeholder*="设置密码"]').type(testPassword);
cy.get('input[placeholder*="确认密码"]').type('DifferentPassword123');
// 尝试提交
cy.get('.auth-modal .auth-submit-btn').click();
// 应该显示密码不一致错误
cy.contains('两次输入的密码不一致').should('be.visible');
cy.log('✅ 密码确认测试通过');
});
});

View File

@@ -0,0 +1,124 @@
// 生成随机手机号
const generatePhone = () => {
const timestamp = Date.now().toString().slice(-8);
return `138${timestamp}`;
};
describe('购买流程测试', () => {
let testPhone;
let testPassword = 'Test123456';
beforeEach(() => {
testPhone = generatePhone();
// 注册并登录
cy.visit('/');
cy.wait(1000);
cy.get('.btn-register').click();
cy.get('.auth-modal').should('be.visible');
cy.get('input[placeholder*="手机号"]').type(testPhone);
cy.get('input[placeholder*="设置密码"]').type(testPassword);
cy.get('input[placeholder*="确认密码"]').type(testPassword);
cy.get('.auth-modal .auth-submit-btn').click();
cy.wait(3000); // 等待页面刷新
});
it('未登录用户点击下载应该提示登录', () => {
// 退出登录
cy.get('.ant-avatar').click();
cy.contains('退出登录').click();
cy.wait(1000);
// 进入作品详情页
cy.visit('/');
cy.wait(1000);
cy.get('.work-card', { timeout: 15000 }).first().click();
cy.wait(2000);
// 点击下载按钮
cy.get('.download-btn').click();
// 应该显示登录提示
cy.contains('请先登录', { timeout: 5000 }).should('exist');
cy.log('✅ 未登录下载提示测试通过');
});
it('已登录用户应该能看到购买弹窗', () => {
// 进入作品详情页
cy.get('.work-card', { timeout: 15000 }).first().click();
cy.wait(2000);
// 点击下载按钮
cy.get('.download-btn').click();
cy.wait(2000);
// 检查是否显示购买弹窗或直接下载
cy.get('body').then($body => {
if ($body.find('.ant-modal:visible').length > 0) {
cy.get('.ant-modal').should('be.visible');
cy.contains('购买作品').should('exist');
cy.log('✅ 购买弹窗测试通过');
} else {
cy.log('✅ 已购买,直接下载测试通过');
}
});
});
it('点击确定购买应该创建订单', () => {
// 进入作品详情页
cy.get('.work-card', { timeout: 15000 }).first().click();
cy.wait(2000);
// 点击下载按钮
cy.contains('button', '我要下载').click();
cy.wait(1000);
// 如果显示购买弹窗
cy.get('body').then($body => {
if ($body.find('.ant-modal:contains("购买")').length > 0) {
// 拦截网络请求
cy.intercept('POST', '**/orders/create').as('createOrder');
cy.intercept('POST', '**/payment/create').as('createPayment');
// 点击确定购买
cy.get('.ant-modal').contains('button', '确定购买').click();
// 等待请求完成
cy.wait('@createOrder', { timeout: 10000 }).its('response.statusCode').should('be.oneOf', [200, 201]);
cy.log('✅ 购买流程测试通过');
} else {
cy.log('✅ 已购买作品,跳过购买测试');
}
});
});
it('应该验证收藏功能', () => {
// 进入作品详情页
cy.get('.work-card', { timeout: 15000 }).first().click();
cy.wait(2000);
// 点击收藏按钮
cy.get('.collect-btn').click();
cy.wait(500);
// 应该显示消息
cy.contains('收藏成功', { timeout: 5000 }).should('exist');
cy.log('✅ 收藏功能测试通过');
});
it('应该验证分享功能', () => {
// 进入作品详情页
cy.get('.work-card', { timeout: 15000 }).first().click();
cy.wait(2000);
// 点击分享按钮
cy.get('.share-btn').click();
cy.wait(500);
// 应该显示成功消息
cy.contains('链接已复制', { timeout: 5000 }).should('exist');
cy.log('✅ 分享功能测试通过');
});
});

View File

@@ -0,0 +1,70 @@
describe('作品浏览测试', () => {
beforeEach(() => {
cy.visit('/');
});
it('应该显示作品列表', () => {
// 等待作品列表加载
cy.get('.work-card', { timeout: 15000 }).should('have.length.at.least', 1);
// 获取作品数量
cy.get('.work-card').then($cards => {
cy.log(`✅ 作品列表测试通过 - 共 ${$cards.length} 个作品`);
});
});
it('应该能够查看作品详情', () => {
// 等待作品列表加载
cy.get('.work-card', { timeout: 15000 }).should('exist');
// 点击第一个作品
cy.get('.work-card').first().click();
// 等待详情页加载
cy.url().should('include', '/detail/');
cy.get('.work-detail-page', { timeout: 10000 }).should('be.visible');
// 验证标题存在
cy.get('h1, h2').should('exist');
// 获取作品标题
cy.get('h1, h2').first().invoke('text').then(title => {
cy.log(`✅ 作品详情测试通过 - 作品: ${title.trim()}`);
});
});
it('应该显示作品信息', () => {
// 进入作品详情页
cy.get('.work-card', { timeout: 15000 }).first().click();
cy.wait(2000);
// 验证作品信息显示
cy.get('.work-detail-page').should('be.visible');
// 验证关键元素
cy.get('.work-title').should('exist');
cy.get('.download-btn').should('exist');
cy.log('✅ 作品信息显示测试通过');
});
it('应该显示相关作品', () => {
// 进入作品详情页
cy.get('.work-card', { timeout: 15000 }).first().click();
cy.wait(2000);
// 向下滚动查看相关作品
cy.scrollTo('bottom', { duration: 1000 });
cy.wait(1000);
// 验证相关作品存在(如果有的话)
cy.get('body').then($body => {
if ($body.find('.related-works, .similar-works').length > 0) {
cy.get('.related-works, .similar-works').should('be.visible');
cy.log('✅ 相关作品显示测试通过');
} else {
cy.log('✅ 相关作品功能未实现(跳过)');
}
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,48 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
// -- This is a custom command for login --
Cypress.Commands.add('login', (phone, password) => {
cy.visit('/');
cy.wait(1000);
cy.get('.btn-login').click();
cy.get('.auth-modal').should('be.visible');
cy.get('input[placeholder*="手机号"]').type(phone);
cy.get('input[placeholder*="密码"]').first().type(password);
cy.get('.auth-modal .auth-submit-btn').click();
cy.wait(3000);
});
Cypress.Commands.add('register', (phone, password, nickname = '测试用户') => {
cy.visit('/');
cy.wait(1000);
cy.get('.btn-register').click();
cy.get('.auth-modal').should('be.visible');
cy.get('input[placeholder*="手机号"]').type(phone);
if (nickname) {
cy.get('input[placeholder*="昵称"]').type(nickname);
}
cy.get('input[placeholder*="设置密码"]').type(password);
cy.get('input[placeholder*="确认密码"]').type(password);
cy.get('.auth-modal .auth-submit-btn').click();
cy.wait(3000);
});
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

View File

@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/e2e.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

Binary file not shown.

Binary file not shown.

Binary file not shown.

29
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>图汇 - 设计作品下载平台</title>
<meta name="description" content="图汇是一个专业的设计作品下载平台,提供海量优质设计素材" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

5976
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "nitu",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"cypress": "cypress open",
"cypress:headless": "cypress run",
"test:e2e": "cypress run",
"test:e2e:ui": "cypress open"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"antd": "^6.1.4",
"axios": "^1.13.2",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"cypress": "^15.8.2",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.2.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

56
frontend/src/App.css Normal file
View File

@@ -0,0 +1,56 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
width: 100%;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Ant Design Overrides */
.ant-btn-primary {
background: #ff5a5a;
border-color: #ff5a5a;
}
.ant-btn-primary:hover {
background: #ff7070 !important;
border-color: #ff7070 !important;
}

30
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import WorkDetail from './pages/WorkDetail';
import CategoryDetail from './pages/CategoryDetail';
import Protocol from './pages/Protocol';
import PayProtocol from './pages/PayProtocol';
import Copyright from './pages/Copyright';
import Help from './pages/Help';
import UploadGuide from './pages/UploadGuide';
import './App.css';
function App() {
return (
<div className="App">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/detail/:id" element={<WorkDetail />} />
<Route path="/category/:name" element={<CategoryDetail />} />
<Route path="/protocol" element={<Protocol />} />
<Route path="/pay-protocol" element={<PayProtocol />} />
<Route path="/copyright" element={<Copyright />} />
<Route path="/help" element={<Help />} />
<Route path="/upload-guide" element={<UploadGuide />} />
</Routes>
</div>
);
}
export default App;

83
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,83 @@
import request from '../utils/request';
/**
* 用户注册(使用手机号,无需验证码)
* @param {string} phone - 手机号11位
* @param {string} password - 密码
* @param {string} nickname - 昵称(可选)
*/
export const register = async (phone, password, nickname = '') => {
try {
const response = await request.post('/auth/register', {
phone,
password,
nickname
});
// 保存 Token 和用户信息
localStorage.setItem('access_token', response.access_token);
localStorage.setItem('user_info', JSON.stringify(response.user));
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};
/**
* 用户登录(使用手机号)
* @param {string} phone - 手机号
* @param {string} password - 密码
*/
export const login = async (phone, password) => {
try {
const response = await request.post('/auth/login', {
phone,
password
});
// 保存 Token 和用户信息
localStorage.setItem('access_token', response.access_token);
localStorage.setItem('user_info', JSON.stringify(response.user));
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};
/**
* 退出登录
*/
export const logout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
window.location.reload();
};
/**
* 获取当前用户信息
*/
export const getCurrentUser = () => {
const userInfo = localStorage.getItem('user_info');
return userInfo ? JSON.parse(userInfo) : null;
};
/**
* 检查是否已登录
*/
export const isLoggedIn = () => {
return !!localStorage.getItem('access_token');
};

View File

@@ -0,0 +1,72 @@
import axios from 'axios';
import { message } from 'antd';
import { API_CONFIG } from '../utils/config';
/**
* 下载作品原图
* @param {number} workId - 作品ID
* @param {string} fileName - 保存的文件名
*/
export const downloadWork = async (workId, fileName = null) => {
try {
const token = localStorage.getItem('access_token');
if (!token) {
message.error('请先登录');
return {
success: false,
message: '请先登录'
};
}
// 使用 axios 下载文件
const response = await axios({
method: 'GET',
url: `${API_CONFIG.baseURL}${API_CONFIG.apiPrefix}/works/${workId}/download`,
headers: {
'Authorization': `Bearer ${token}`
},
responseType: 'blob' // 重要:以 blob 形式接收
});
// 创建下载链接
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName || `work_${workId}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success('下载成功');
return {
success: true,
message: '下载成功'
};
} catch (error) {
if (error.response && error.response.status === 403) {
const errorMessage = '您还未购买此作品,请先完成支付';
message.error(errorMessage);
return {
success: false,
message: errorMessage,
needPurchase: true
};
}
if (error.response && error.response.status === 401) {
message.error('请先登录');
return {
success: false,
message: '请先登录'
};
}
const errorMessage = error.message || '下载失败';
message.error(errorMessage);
return {
success: false,
message: errorMessage
};
}
};

View File

@@ -0,0 +1,59 @@
import request from '../utils/request';
/**
* 创建订单
* @param {number} workId - 作品ID
*/
export const createOrder = async (workId) => {
try {
const response = await request.post('/orders/create', {
work_id: workId
});
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};
/**
* 获取我的订单列表
*/
export const getMyOrders = async () => {
try {
const response = await request.get('/orders/my');
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};
/**
* 模拟支付(仅用于测试)
* @param {number} orderId - 订单ID
*/
export const mockPay = async (orderId) => {
try {
const response = await request.post(`/orders/pay/${orderId}`);
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};

View File

@@ -0,0 +1,58 @@
import request from '../utils/request';
/**
* 创建支付链接(易收米)
* @param {number} orderId - 订单ID
*/
export const createPayment = async (orderId) => {
try {
const response = await request.post(`/payment/create/${orderId}`);
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};
/**
* 查询订单支付状态
* @param {string} orderNumber - 订单号
*/
export const queryPaymentStatus = async (orderNumber) => {
try {
const response = await request.get(`/payment/query/${orderNumber}`);
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};
/**
* 取消订单
* @param {number} orderId - 订单ID
*/
export const cancelOrder = async (orderId) => {
try {
const response = await request.post(`/payment/cancel/${orderId}`);
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};

46
frontend/src/api/works.js Normal file
View File

@@ -0,0 +1,46 @@
import request from '../utils/request';
/**
* 获取作品列表
* @param {number} page - 页码从1开始
* @param {number} pageSize - 每页数量
* @param {string} category - 分类筛选(可选)
* @param {string} keyword - 关键词搜索(可选)
*/
export const getWorksList = async (page = 1, pageSize = 20, category = '', keyword = '') => {
try {
const params = { page, page_size: pageSize };
if (category) params.category = category;
if (keyword) params.keyword = keyword;
const response = await request.get('/works', { params });
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};
/**
* 获取作品详情
* @param {number} workId - 作品ID
*/
export const getWorkDetail = async (workId) => {
try {
const response = await request.get(`/works/${workId}`);
return {
success: true,
data: response
};
} catch (error) {
return {
success: false,
message: error.message
};
}
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,275 @@
.auth-modal .ant-modal-content {
border-radius: 16px;
overflow: hidden;
padding: 0;
}
.auth-modal .ant-modal-body {
padding: 0;
}
.auth-modal .ant-modal-close {
top: 16px;
right: 16px;
}
.close-icon {
font-size: 24px;
color: #999;
line-height: 1;
transition: color 0.2s;
}
.close-icon:hover {
color: #ff5a5a;
}
.auth-modal-content {
padding: 40px 40px;
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.auth-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 8px;
}
.auth-subtitle {
font-size: 14px;
color: #999;
margin: 0;
}
.auth-form {
margin-top: 24px;
}
.auth-input {
border-radius: 8px;
height: 48px;
}
.auth-input .ant-input {
font-size: 14px;
}
.auth-input:hover,
.auth-input:focus,
.auth-input.ant-input-affix-wrapper-focused {
border-color: #ff5a5a;
box-shadow: 0 0 0 2px rgba(255, 90, 90, 0.1);
}
.input-icon {
color: #bbb;
font-size: 16px;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
}
.form-options .ant-checkbox-wrapper {
color: #666;
}
.form-options .ant-checkbox-checked .ant-checkbox-inner {
background-color: #ff5a5a;
border-color: #ff5a5a;
}
.forgot-link {
color: #ff5a5a;
font-size: 14px;
}
.forgot-link:hover {
color: #ff7070;
}
.auth-submit-btn {
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #ff5a5a 0%, #ff8080 100%);
border: none;
box-shadow: 0 4px 12px rgba(255, 90, 90, 0.3);
transition: all 0.3s;
}
.auth-submit-btn:hover {
background: linear-gradient(135deg, #ff7070 0%, #ff9090 100%) !important;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(255, 90, 90, 0.4);
}
.auth-divider {
margin: 24px 0;
}
.auth-divider .ant-divider-inner-text {
color: #999;
font-size: 12px;
}
.social-login {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 24px;
}
.social-btn {
width: 48px;
height: 48px;
border: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.social-btn .anticon {
font-size: 22px;
}
.social-btn.wechat {
color: #07c160;
}
.social-btn.wechat:hover {
background: #07c160;
border-color: #07c160;
color: white;
}
.social-btn.qq {
color: #12b7f5;
}
.social-btn.qq:hover {
background: #12b7f5;
border-color: #12b7f5;
color: white;
}
.social-btn.weibo {
color: #e6162d;
}
.social-btn.weibo:hover {
background: #e6162d;
border-color: #e6162d;
color: white;
}
.auth-footer {
text-align: center;
font-size: 14px;
color: #999;
}
.switch-link {
color: #ff5a5a;
font-weight: 500;
margin-left: 4px;
cursor: pointer;
}
.switch-link:hover {
color: #ff7070;
}
/* Verify Code */
.verify-code-wrapper {
display: flex;
gap: 12px;
}
.verify-input {
flex: 1;
}
.verify-btn {
height: 48px;
border-radius: 8px;
font-size: 14px;
white-space: nowrap;
padding: 0 16px;
border-color: #ff5a5a;
color: #ff5a5a;
}
.verify-btn:hover:not(:disabled) {
border-color: #ff7070 !important;
color: #ff7070 !important;
}
.verify-btn:disabled {
color: #999;
border-color: #d9d9d9;
}
/* Agreement */
.agreement-checkbox {
font-size: 12px;
color: #999;
}
.agreement-checkbox a {
color: #ff5a5a;
}
.agreement-checkbox a:hover {
color: #ff7070;
}
.agreement-checkbox .ant-checkbox-checked .ant-checkbox-inner {
background-color: #ff5a5a;
border-color: #ff5a5a;
}
/* Animation */
.auth-modal .ant-modal {
animation: modalFadeIn 0.3s ease;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Responsive */
@media (max-width: 480px) {
.auth-modal-content {
padding: 32px 24px;
}
.auth-title {
font-size: 20px;
}
.social-login {
gap: 16px;
}
.social-btn {
width: 44px;
height: 44px;
}
}

View File

@@ -0,0 +1,128 @@
.categories {
padding: 30px 20px;
max-width: 1200px;
margin: 0 auto;
}
.categories-container {
position: relative;
}
.categories-scroll {
display: flex;
gap: 16px;
overflow-x: auto;
padding: 10px 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.categories-scroll::-webkit-scrollbar {
display: none;
}
.category-card {
flex-shrink: 0;
width: 170px;
border-radius: 12px;
overflow: hidden;
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: all 0.3s ease;
animation: fadeInUp 0.6s ease forwards;
animation-delay: var(--delay);
opacity: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.category-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
.category-image {
height: 170px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.category-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
.category-icon {
font-size: 80px;
color: white;
font-weight: 700;
text-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3);
}
.category-info {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.category-name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.category-count {
font-size: 12px;
color: #999;
}
.scroll-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: white;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 40px;
height: 40px;
}
.scroll-btn:hover {
background: #ff5a5a !important;
color: white !important;
}
.scroll-btn-right {
right: -10px;
}
@media (max-width: 768px) {
.category-card {
width: 160px;
}
.category-image {
height: 100px;
}
}

View File

@@ -0,0 +1,67 @@
import { useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Button } from 'antd';
import { RightOutlined } from '@ant-design/icons';
import './Categories.css';
const Categories = () => {
const scrollRef = useRef(null);
const navigate = useNavigate();
const categories = [
{ name: '活动', count: '408131', gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
{ name: '中式', count: '105545', gradient: 'linear-gradient(135deg, #c94b4b 0%, #4b134f 100%)' },
{ name: '直播', count: '35429', gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ name: '旅游', count: '97826', gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
{ name: '周年庆', count: '15868', gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
{ name: '长图', count: '130856', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ name: '价值点', count: '214448', gradient: 'linear-gradient(135deg, #0c3483 0%, #a2b6df 100%)' },
{ name: '酒吧', count: '36397', gradient: 'linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%)' },
];
const scroll = (direction) => {
if (scrollRef.current) {
const scrollAmount = direction === 'left' ? -300 : 300;
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
};
const handleCategoryClick = (categoryName) => {
navigate(`/category/${categoryName}`);
};
return (
<section className="categories">
<div className="categories-container">
<div className="categories-scroll" ref={scrollRef}>
{categories.map((cat, index) => (
<div
key={cat.name}
className="category-card"
style={{ '--delay': `${index * 0.1}s` }}
onClick={() => handleCategoryClick(cat.name)}
>
<div className="category-image" style={{ background: cat.gradient }}>
<div className="category-overlay">
<span className="category-icon">{cat.name.charAt(0)}</span>
</div>
</div>
<div className="category-info">
<span className="category-name">{cat.name}</span>
<span className="category-count">{cat.count} </span>
</div>
</div>
))}
</div>
<Button
className="scroll-btn scroll-btn-right"
shape="circle"
icon={<RightOutlined />}
onClick={() => scroll('right')}
/>
</div>
</section>
);
};
export default Categories;

View File

@@ -0,0 +1,144 @@
.designers-section {
padding: 30px 20px 50px;
max-width: 1200px;
margin: 0 auto;
}
.designers-section .section-header {
margin-bottom: 20px;
}
.designers-section .section-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0;
position: relative;
padding-left: 12px;
}
.designers-section .section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 20px;
background: #ff5a5a;
border-radius: 2px;
}
.designers-grid {
margin: 0 !important;
}
.designer-card {
text-align: center;
border-radius: 12px;
padding: 20px 12px;
cursor: pointer;
transition: all 0.3s ease;
animation: fadeInUp 0.5s ease forwards;
animation-delay: var(--delay);
opacity: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.designer-card:hover {
transform: translateY(-8px);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.1);
}
.designer-card .ant-card-body {
padding: 0;
}
.designer-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.avatar-emoji {
font-size: 36px;
}
.designer-name {
font-size: 14px;
font-weight: 600;
color: #333;
margin: 0 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.designer-desc {
font-size: 12px;
color: #999;
margin: 0 0 8px;
}
.designer-works {
font-size: 24px;
font-weight: 700;
color: #ff5a5a;
line-height: 1.2;
}
.designer-fans {
font-size: 12px;
color: #999;
margin: 8px 0 12px;
}
.fans-count {
color: #333;
font-weight: 500;
}
.follow-btn {
border-radius: 20px;
border-color: #e8e8e8;
color: #666;
font-size: 12px;
height: 28px;
padding: 0 16px;
}
.follow-btn:hover {
color: #ff5a5a !important;
border-color: #ff5a5a !important;
background: #fff5f5 !important;
}
@media (max-width: 768px) {
.designer-avatar {
width: 60px;
height: 60px;
}
.avatar-emoji {
font-size: 28px;
}
.designer-works {
font-size: 20px;
}
}

View File

@@ -0,0 +1,72 @@
import { Card, Button, Avatar, Row, Col } from 'antd';
import { PlusOutlined, UserOutlined } from '@ant-design/icons';
import './Designers.css';
const Designers = () => {
const designers = [
{ id: 1, name: '顾九思', works: 223, fans: 546, avatar: '🎨' },
{ id: 2, name: '下辈子别做设计', works: 380, fans: 1099, avatar: '🖼️' },
{ id: 3, name: 'h突然的', works: 537, fans: 574, avatar: '✨' },
{ id: 4, name: '赤木流歌', works: 300, fans: 735, avatar: '🎭' },
{ id: 5, name: '秃头选手', works: 311, fans: 538, avatar: '🎯' },
{ id: 6, name: 'M.A', works: 553, fans: 567, avatar: '🌟' },
{ id: 7, name: 'NIMINMIN', works: 305, fans: 1208, avatar: '💫' },
{ id: 8, name: '星玥设计', works: 402, fans: 576, avatar: '⭐' },
{ id: 9, name: '一十九', works: 1598, fans: 2457, avatar: '🔥' },
{ id: 10, name: 'Jance', works: 341, fans: 3424, avatar: '💎' },
];
const getGradient = (index) => {
const gradients = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)',
'linear-gradient(135deg, #d299c2 0%, #fef9d7 100%)',
'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)',
'linear-gradient(135deg, #fddb92 0%, #d1fdff 100%)',
'linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)',
];
return gradients[index % gradients.length];
};
return (
<section className="designers-section">
<div className="section-header">
<h2 className="section-title">设计师</h2>
</div>
<Row gutter={[16, 16]} className="designers-grid">
{designers.map((designer, index) => (
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={designer.id}>
<Card
className="designer-card"
style={{ '--delay': `${index * 0.05}s` }}
>
<div className="designer-avatar" style={{ background: getGradient(index) }}>
<span className="avatar-emoji">{designer.avatar}</span>
</div>
<h3 className="designer-name">{designer.name}</h3>
<p className="designer-desc">/她已上传作品</p>
<div className="designer-works">{designer.works}</div>
<div className="designer-fans">
<span className="fans-count">{designer.fans}</span>
<span className="fans-text"> 粉丝</span>
</div>
<Button
className="follow-btn"
icon={<PlusOutlined />}
>
关注
</Button>
</Card>
</Col>
))}
</Row>
</section>
);
};
export default Designers;

View File

@@ -0,0 +1,122 @@
.festival {
padding: 20px 20px 30px;
max-width: 1200px;
margin: 0 auto;
}
.festival-container {
position: relative;
}
.festival-scroll {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 10px 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.festival-scroll::-webkit-scrollbar {
display: none;
}
.festival-card {
flex-shrink: 0;
width: 150px;
background: white;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.3s ease;
animation: slideIn 0.5s ease forwards;
animation-delay: var(--delay);
opacity: 0;
transform: translateX(20px);
}
@keyframes slideIn {
to {
opacity: 1;
transform: translateX(0);
}
}
.festival-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
}
.festival-emoji {
font-size: 32px;
line-height: 1;
}
.festival-content {
text-align: center;
}
.festival-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
.festival-date {
font-size: 12px;
color: #999;
margin: 4px 0 0;
}
.festival-days {
display: flex;
align-items: baseline;
gap: 2px;
margin-top: 4px;
}
.days-number {
font-size: 20px;
font-weight: 700;
color: #ff5a5a;
}
.days-text {
font-size: 12px;
color: #999;
}
.festival-scroll-btn {
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: white;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 36px;
height: 36px;
}
.festival-scroll-btn:hover {
background: #ff5a5a !important;
color: white !important;
}
@media (max-width: 768px) {
.festival-card {
width: 130px;
padding: 12px;
}
.festival-emoji {
font-size: 28px;
}
}

View File

@@ -0,0 +1,81 @@
import { useRef } from 'react';
import { Button } from 'antd';
import { RightOutlined } from '@ant-design/icons';
import './Festival.css';
const Festival = () => {
const scrollRef = useRef(null);
const festivals = [
{ name: '惊蛰', date: '2026-03-05', weekday: '周四', daysLeft: 6, emoji: '⚡' },
{ name: '植树节', date: '2026-03-12', weekday: '周四', daysLeft: 13, emoji: '🌳' },
{ name: '春分', date: '2026-03-20', weekday: '周五', daysLeft: 21, emoji: '🌸' },
{ name: '愚人节', date: '2026-04-01', weekday: '周三', daysLeft: 33, emoji: '🤡' },
{ name: '清明节', date: '2026-04-05', weekday: '周日', daysLeft: 37, emoji: '🌿' },
{ name: '谷雨', date: '2026-04-20', weekday: '周一', daysLeft: 52, emoji: '🌾' },
{ name: '劳动节', date: '2026-05-01', weekday: '周五', daysLeft: 63, emoji: '🎉' },
{ name: '青年节', date: '2026-05-04', weekday: '周一', daysLeft: 66, emoji: '🔥' },
{ name: '立夏', date: '2026-05-05', weekday: '周二', daysLeft: 67, emoji: '☀️' },
{ name: '母亲节', date: '2026-05-10', weekday: '周日', daysLeft: 72, emoji: '💐' },
{ name: '小满', date: '2026-05-21', weekday: '周四', daysLeft: 83, emoji: '🌾' },
{ name: '端午节', date: '2026-06-19', weekday: '周五', daysLeft: 112, emoji: '🛶' },
{ name: '夏至', date: '2026-06-21', weekday: '周日', daysLeft: 114, emoji: '🌻' },
{ name: '建党节', date: '2026-07-01', weekday: '周三', daysLeft: 124, emoji: '🚩' },
{ name: '小暑', date: '2026-07-07', weekday: '周二', daysLeft: 130, emoji: '🔥' },
{ name: '建军节', date: '2026-08-01', weekday: '周六', daysLeft: 155, emoji: '🎖️' },
{ name: '立秋', date: '2026-08-07', weekday: '周五', daysLeft: 161, emoji: '🍂' },
{ name: '七夕节', date: '2026-08-25', weekday: '周二', daysLeft: 179, emoji: '💕' },
{ name: '白露', date: '2026-09-07', weekday: '周一', daysLeft: 192, emoji: '💧' },
{ name: '教师节', date: '2026-09-10', weekday: '周四', daysLeft: 195, emoji: '📚' },
{ name: '秋分', date: '2026-09-23', weekday: '周三', daysLeft: 208, emoji: '🍁' },
{ name: '国庆节', date: '2026-10-01', weekday: '周四', daysLeft: 216, emoji: '🇨🇳' },
{ name: '中秋节', date: '2026-10-03', weekday: '周六', daysLeft: 218, emoji: '🥮' },
{ name: '重阳节', date: '2026-10-21', weekday: '周三', daysLeft: 236, emoji: '👴' },
{ name: '立冬', date: '2026-11-07', weekday: '周六', daysLeft: 253, emoji: '❄️' },
{ name: '感恩节', date: '2026-11-26', weekday: '周四', daysLeft: 272, emoji: '🦃' },
{ name: '大雪', date: '2026-12-07', weekday: '周一', daysLeft: 283, emoji: '🌨️' },
{ name: '冬至', date: '2026-12-22', weekday: '周二', daysLeft: 298, emoji: '🥟' },
{ name: '圣诞节', date: '2026-12-25', weekday: '周五', daysLeft: 301, emoji: '🎄' },
];
const scroll = (direction) => {
if (scrollRef.current) {
const scrollAmount = direction === 'left' ? -300 : 300;
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
};
return (
<section className="festival">
<div className="festival-container">
<div className="festival-scroll" ref={scrollRef}>
{festivals.map((item, index) => (
<div
key={item.name}
className="festival-card"
style={{ '--delay': `${index * 0.08}s` }}
>
<div className="festival-emoji">{item.emoji}</div>
<div className="festival-content">
<h3 className="festival-name">{item.name}</h3>
<p className="festival-date">{item.date} {item.weekday}</p>
</div>
<div className="festival-days">
<span className="days-number">{item.daysLeft}</span>
<span className="days-text">天后</span>
</div>
</div>
))}
</div>
<Button
className="festival-scroll-btn"
shape="circle"
icon={<RightOutlined />}
onClick={() => scroll('right')}
/>
</div>
</section>
);
};
export default Festival;

View File

@@ -0,0 +1,159 @@
.footer {
margin-top: auto;
}
.footer-main {
background: #f5f5f5;
padding: 40px 0;
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.footer-links-group {
display: flex;
gap: 24px;
margin-bottom: 12px;
}
.footer-link {
color: #666;
font-size: 14px;
transition: color 0.2s;
}
.footer-link:hover {
color: #ff5a5a;
}
.footer-contact {
margin-bottom: 20px;
}
.contact-title {
font-size: 14px;
color: #333;
margin: 0 0 12px;
font-weight: 500;
}
.email-input {
background: white;
border-radius: 8px;
max-width: 360px;
}
.email-input .ant-input {
color: #666;
}
.footer-qrcodes {
display: flex;
gap: 24px;
}
.qrcode-item {
text-align: center;
}
.qrcode-placeholder {
width: 100px;
height: 100px;
background: white;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
border: 1px solid #e8e8e8;
}
.qrcode-icon {
font-size: 48px;
color: #ccc;
}
.qrcode-text {
font-size: 12px;
color: #999;
}
.footer-partners {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e8e8e8;
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
}
.partner-link {
font-size: 12px;
color: #999;
transition: color 0.2s;
}
.partner-link:hover {
color: #ff5a5a;
}
.footer-bottom {
background: #3d3d3d;
padding: 20px 0;
}
.copyright-text {
color: #999;
font-size: 12px;
line-height: 1.8;
margin: 0 0 8px;
}
.email-link {
color: #ff5a5a;
}
.email-link:hover {
text-decoration: underline;
}
.copyright-links {
color: #999;
font-size: 12px;
margin: 0;
}
.copyright-links a {
color: #999;
transition: color 0.2s;
}
.copyright-links a:hover {
color: #ff5a5a;
}
@media (max-width: 768px) {
.footer-main {
padding: 30px 0;
}
.footer-links-group {
justify-content: center;
}
.footer-qrcodes {
justify-content: center;
}
.footer-partners {
justify-content: center;
}
.copyright-text,
.copyright-links {
text-align: center;
}
}

View File

@@ -0,0 +1,107 @@
import { Row, Col, Input, Button, Divider } from 'antd';
import { MailOutlined, QrcodeOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import './Footer.css';
const Footer = () => {
const navigate = useNavigate();
const navLinks = [
{ title: '网站协议', url: '/protocol' },
{ title: '支付协议', url: '/pay-protocol' },
{ title: '版权声明', url: '/copyright' },
];
const navLinks2 = [
{ title: '帮助中心', url: '/help' },
{ title: '供稿必读', url: '/upload-guide' },
];
const handleLinkClick = (url) => {
navigate(url);
};
return (
<footer className="footer">
<div className="footer-main">
<div className="footer-container">
<Row gutter={[48, 24]}>
<Col xs={24} sm={24} md={8} lg={6}>
<div className="footer-links-group">
{navLinks.map(link => (
<a
key={link.title}
onClick={() => handleLinkClick(link.url)}
className="footer-link"
>
{link.title}
</a>
))}
</div>
<div className="footer-links-group">
{navLinks2.map(link => (
<a
key={link.title}
onClick={() => handleLinkClick(link.url)}
className="footer-link"
>
{link.title}
</a>
))}
</div>
</Col>
<Col xs={24} sm={24} md={8} lg={10}>
<div className="footer-contact">
<h4 className="contact-title">客服邮箱</h4>
<div className="contact-email">
<Input
readOnly
value="service@aishej.com 工作日9:00-18:00"
suffix={<MailOutlined />}
className="email-input"
/>
</div>
</div>
</Col>
<Col xs={24} sm={24} md={8} lg={8}>
<div className="footer-qrcodes">
<div className="qrcode-item">
<div className="qrcode-placeholder">
<QrcodeOutlined className="qrcode-icon" />
</div>
<span className="qrcode-text">移动端网站</span>
</div>
<div className="qrcode-item">
<div className="qrcode-placeholder">
<QrcodeOutlined className="qrcode-icon" />
</div>
<span className="qrcode-text">微信公众号</span>
</div>
</div>
</Col>
</Row>
</div>
</div>
<div className="footer-bottom">
<div className="footer-container">
<p className="copyright-text">
图汇作为网络服务平台方平台上的作品均由供稿设计师上传并发布若您的权利被侵害请联系客服邮箱
<a href="mailto:service@aishej.com" className="email-link">service@aishej.com</a>
我们将及时为您处理 | 本站法律顾问张明律师
</p>
<p className="copyright-links">
<a href="#">京ICP备2024068521号-1</a> |
增值电信业务经营许可证京B2-20240312 |
<a href="#">京公网安备11010802045678号</a> |
Copyright © 2024-2026 图汇 AiSheji.com
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,175 @@
.header {
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.header-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
gap: 32px;
}
.header-left {
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.logo-icon {
width: 40px;
height: 24px;
}
.logo-text {
font-size: 20px;
font-weight: 700;
color: #ff5a5a;
}
.logo-domain {
font-size: 10px;
color: #999;
margin-left: 4px;
}
.header-nav {
display: flex;
align-items: center;
gap: 24px;
}
.nav-item {
color: #333;
font-size: 14px;
cursor: pointer;
transition: color 0.2s;
display: flex;
align-items: center;
gap: 4px;
position: relative;
}
.nav-item:hover {
color: #ff5a5a;
}
.nav-new {
position: relative;
}
.new-dot {
position: absolute;
top: -2px;
right: -8px;
width: 6px;
height: 6px;
background: #ff5a5a;
border-radius: 50%;
}
.header-search {
flex: 1;
max-width: 320px;
}
.header-search .ant-input-affix-wrapper {
border-radius: 20px;
border-color: #e8e8e8;
padding: 6px 16px;
}
.header-search .ant-input-affix-wrapper:hover,
.header-search .ant-input-affix-wrapper:focus,
.header-search .ant-input-affix-wrapper-focused {
border-color: #ff5a5a;
box-shadow: none;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.btn-invite {
background: #ff5a5a;
border-color: #ff5a5a;
border-radius: 20px;
padding: 4px 20px;
height: 36px;
}
.btn-invite:hover {
background: #ff7070 !important;
border-color: #ff7070 !important;
}
.btn-register {
border-radius: 20px;
padding: 4px 20px;
height: 36px;
border-color: #333;
color: #333;
}
.btn-register:hover {
border-color: #ff5a5a !important;
color: #ff5a5a !important;
}
.btn-login {
border-radius: 20px;
padding: 4px 20px;
height: 36px;
border-color: #333;
color: #333;
}
.btn-login:hover {
border-color: #ff5a5a !important;
color: #ff5a5a !important;
}
@media (max-width: 1024px) {
.header-nav {
display: none;
}
.header-search {
max-width: 200px;
}
}
@media (max-width: 768px) {
.header-container {
padding: 0 16px;
gap: 16px;
}
.logo-domain {
display: none;
}
.header-search {
display: none;
}
.btn-invite {
display: none;
}
}

View File

@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { Input, Button, Space, Dropdown, Avatar } from 'antd';
import { SearchOutlined, CameraOutlined, MenuOutlined, UserOutlined, LogoutOutlined, ShoppingOutlined } from '@ant-design/icons';
import { getCurrentUser, isLoggedIn, logout } from '../api/auth';
import LoginModal from './LoginModal';
import RegisterModal from './RegisterModal';
import './Header.css';
const Header = () => {
const [searchValue, setSearchValue] = useState('');
const [loginOpen, setLoginOpen] = useState(false);
const [registerOpen, setRegisterOpen] = useState(false);
const [user, setUser] = useState(null);
useEffect(() => {
if (isLoggedIn()) {
setUser(getCurrentUser());
}
}, []);
const menuItems = [
{ key: 'type', label: '类型', children: [
{ key: 'poster', label: '海报' },
{ key: 'banner', label: 'Banner' },
{ key: 'ecommerce', label: '电商' },
{ key: 'illustration', label: '插画' },
]},
{ key: 'theme', label: '主题', children: [
{ key: 'realestate', label: '地产' },
{ key: 'medical', label: '医美' },
{ key: 'travel', label: '旅游' },
{ key: 'tech', label: '科技' },
]},
{ key: 'style', label: '风格', children: [
{ key: 'chinese', label: '中式' },
{ key: 'premium', label: '高端' },
{ key: 'creative', label: '创意' },
{ key: 'minimalist', label: '清新' },
]},
];
const handleOpenLogin = () => {
setRegisterOpen(false);
setLoginOpen(true);
};
const handleOpenRegister = () => {
setLoginOpen(false);
setRegisterOpen(true);
};
const handleCloseLogin = () => {
setLoginOpen(false);
};
const handleCloseRegister = () => {
setRegisterOpen(false);
};
const handleLogout = () => {
logout();
};
const userMenuItems = [
{
key: 'orders',
label: '我的订单',
icon: <ShoppingOutlined />
},
{
key: 'logout',
label: '退出登录',
icon: <LogoutOutlined />,
onClick: handleLogout
}
];
return (
<>
<header className="header">
<div className="header-container">
<div className="header-left">
<a href="/" className="logo">
<svg viewBox="0 0 40 24" className="logo-icon">
<path d="M20 0c-5.5 0-10 4.5-10 10s4.5 10 10 10c2.8 0 5.3-1.1 7.1-2.9L20 10l7.1-7.1C25.3 1.1 22.8 0 20 0z" fill="#ff5a5a"/>
<path d="M30 4c-5.5 0-10 4.5-10 10s4.5 10 10 10c5.5 0 10-4.5 10-10S35.5 4 30 4zm0 16c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6z" fill="#ff5a5a"/>
</svg>
<span className="logo-text">图汇</span>
<span className="logo-domain">DESIGN006.COM</span>
</a>
</div>
<nav className="header-nav">
<Dropdown menu={{ items: menuItems }} placement="bottom">
<a className="nav-item" onClick={e => e.preventDefault()}>
<MenuOutlined /> 目录
</a>
</Dropdown>
<a href="#" className="nav-item">热门</a>
<a href="#" className="nav-item nav-new">
最新
<span className="new-dot"></span>
</a>
<a href="#" className="nav-item">图片</a>
<a href="#" className="nav-item">字体</a>
</nav>
<div className="header-search">
<Input
placeholder="搜索作品或编号"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
prefix={<SearchOutlined style={{ color: '#999' }} />}
suffix={<CameraOutlined style={{ color: '#999', cursor: 'pointer' }} />}
/>
</div>
<div className="header-right">
{user ? (
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} data-testid="user-dropdown">
<Avatar icon={<UserOutlined />} style={{ marginRight: 8 }} className="ant-avatar" />
<span>{user.nickname || user.phone}</span>
</div>
</Dropdown>
) : (
<>
<Button className="btn-register" onClick={handleOpenRegister} data-testid="header-register-btn">注册</Button>
<Button className="btn-login" onClick={handleOpenLogin} data-testid="header-login-btn">登录</Button>
</>
)}
</div>
</div>
</header>
<LoginModal
open={loginOpen}
onClose={handleCloseLogin}
onSwitchToRegister={handleOpenRegister}
/>
<RegisterModal
open={registerOpen}
onClose={handleCloseRegister}
onSwitchToLogin={handleOpenLogin}
/>
</>
);
};
export default Header;

View File

@@ -0,0 +1,177 @@
/* ========== Hero 区块整体样式 ========== */
.hero-section {
position: relative;
height: 600px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
animation: fadeIn 1s ease-out;
}
/* ========== 动态背景 ========== */
.hero-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.hero-particle {
position: absolute;
width: 4px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
.hero-particle:nth-child(1) { left: 10%; animation-delay: 0s; }
.hero-particle:nth-child(2) { left: 20%; animation-delay: 1s; }
.hero-particle:nth-child(3) { left: 30%; animation-delay: 2s; }
.hero-particle:nth-child(4) { left: 40%; animation-delay: 3s; }
.hero-particle:nth-child(5) { left: 50%; animation-delay: 4s; }
.hero-particle:nth-child(6) { left: 60%; animation-delay: 5s; }
.hero-particle:nth-child(7) { left: 70%; animation-delay: 6s; }
.hero-particle:nth-child(8) { left: 80%; animation-delay: 7s; }
.hero-particle:nth-child(9) { left: 90%; animation-delay: 8s; }
/* ========== Hero 内容 ========== */
.hero-content {
position: relative;
z-index: 2;
text-align: center;
color: white;
max-width: 800px;
padding: 0 20px;
animation: slideIn 1s ease-out;
}
.hero-title {
font-size: 56px;
font-weight: 800;
margin-bottom: 20px;
line-height: 1.2;
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.hero-subtitle {
font-size: 20px;
margin-bottom: 40px;
opacity: 0.95;
line-height: 1.6;
}
/* ========== Hero 按钮 ========== */
.hero-buttons {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.btn-hero-primary {
background: white;
color: #667eea;
padding: 16px 40px;
border-radius: 30px;
font-size: 18px;
font-weight: 700;
border: none;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.btn-hero-primary:hover {
transform: translateY(-3px);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3);
}
.btn-hero-secondary {
background: transparent;
color: white;
padding: 16px 40px;
border-radius: 30px;
font-size: 18px;
font-weight: 700;
border: 2px solid white;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-hero-secondary:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-3px);
}
/* ========== 装饰元素 ========== */
.hero-decoration {
position: absolute;
width: 400px;
height: 400px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 8s ease-in-out infinite;
}
.hero-decoration-1 {
top: -100px;
right: -100px;
}
.hero-decoration-2 {
bottom: -150px;
left: -150px;
width: 300px;
height: 300px;
animation-delay: 2s;
}
/* ========== 渐变遮罩 ========== */
.hero-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at center, transparent 0%, rgba(102, 126, 234, 0.3) 100%);
}
/* ========== 响应式设计 ========== */
@media (max-width: 768px) {
.hero-section {
height: 500px;
}
.hero-title {
font-size: 36px;
}
.hero-subtitle {
font-size: 16px;
}
.hero-buttons {
flex-direction: column;
gap: 15px;
}
.btn-hero-primary,
.btn-hero-secondary {
padding: 14px 30px;
font-size: 16px;
width: 100%;
max-width: 300px;
}
}
/* ========== 暗黑模式 ========== */
@media (prefers-color-scheme: dark) {
.hero-section {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
}

View File

@@ -0,0 +1,76 @@
import { Input, Button, Tag } from 'antd';
import { SearchOutlined, CameraOutlined } from '@ant-design/icons';
import './Hero.css';
const Hero = () => {
const recommendTags = ['地产', '医美', '旅游', '汽车', '价值点', '美陈', '电商', '画册', '大寒'];
return (
<section className="hero">
<div className="hero-background">
{/* Winter Scene Illustration */}
<div className="hero-scene">
<div className="snowflakes">
{[...Array(20)].map((_, i) => (
<div key={i} className="snowflake" style={{
left: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 5}s`,
animationDuration: `${3 + Math.random() * 4}s`
}}></div>
))}
</div>
{/* Left Side - Title */}
<div className="hero-title-area">
<h1 className="hero-main-title">这个冬天</h1>
<h2 className="hero-sub-title">相约雪山</h2>
<p className="hero-english">APPOINTMENT AT SNOWMOUNTAIN</p>
</div>
{/* Decorative Elements */}
<div className="hero-decorations">
<div className="mountain mountain-1"></div>
<div className="mountain mountain-2"></div>
<div className="tree tree-1">🌲</div>
<div className="tree tree-2">🌲</div>
<div className="tree tree-3">🎄</div>
<div className="snowman"></div>
<div className="people people-1">🎿</div>
<div className="people people-2">🛷</div>
</div>
</div>
</div>
<div className="hero-content">
<h2 className="hero-slogan">有所想不如有所享</h2>
<div className="hero-search-box">
<Input
size="large"
placeholder="搜索作品或编号"
className="hero-search-input"
suffix={
<div className="search-actions">
<CameraOutlined className="camera-icon" />
<Button type="primary" shape="circle" icon={<SearchOutlined />} className="search-btn" />
</div>
}
/>
</div>
<div className="hero-tags">
<span className="tags-label">推荐搜索</span>
{recommendTags.map(tag => (
<Tag key={tag} className="recommend-tag">{tag}</Tag>
))}
</div>
<div className="hero-designer">
设计师M.A
</div>
</div>
</section>
);
};
export default Hero;

View File

@@ -0,0 +1,143 @@
import { useState } from 'react';
import { Modal, Form, Input, Button, Checkbox, Divider, message } from 'antd';
import { MobileOutlined, LockOutlined, WechatOutlined, QqOutlined, WeiboOutlined } from '@ant-design/icons';
import { login } from '../api/auth';
import './AuthModal.css';
const LoginModal = ({ open, onClose, onSwitchToRegister }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleLogin = async (values) => {
setLoading(true);
const result = await login(values.phone, values.password);
setLoading(false);
if (result.success) {
message.success('登录成功!');
onClose();
form.resetFields();
// 刷新页面以更新用户信息
window.location.reload();
} else {
message.error(result.message || '登录失败');
}
};
const handleClose = () => {
form.resetFields();
onClose();
};
return (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={480}
centered
className="auth-modal"
title={null}
closeIcon={<span className="close-icon">×</span>}
data-testid="login-modal"
>
<div className="auth-modal-content" data-testid="login-modal-content">
<div className="auth-header">
<h2 className="auth-title">登录图汇</h2>
<p className="auth-subtitle">欢迎回来开始您的设计之旅</p>
</div>
<Form
form={form}
onFinish={handleLogin}
layout="vertical"
requiredMark={false}
className="auth-form"
>
<Form.Item
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号' }
]}
>
<Input
prefix={<MobileOutlined className="input-icon" />}
placeholder="请输入手机号"
maxLength={11}
size="large"
className="auth-input"
data-testid="login-phone-input"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined className="input-icon" />}
placeholder="密码"
size="large"
className="auth-input"
data-testid="login-password-input"
/>
</Form.Item>
<Form.Item>
<div className="form-options">
<Checkbox>记住我</Checkbox>
<a href="#" className="forgot-link">忘记密码</a>
</div>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
size="large"
block
loading={loading}
className="auth-submit-btn"
data-testid="login-submit-btn"
>
登录
</Button>
</Form.Item>
</Form>
<Divider className="auth-divider">
<span>其他登录方式</span>
</Divider>
<div className="social-login">
<Button
className="social-btn wechat"
shape="circle"
icon={<WechatOutlined />}
title="微信登录"
/>
<Button
className="social-btn qq"
shape="circle"
icon={<QqOutlined />}
title="QQ登录"
/>
<Button
className="social-btn weibo"
shape="circle"
icon={<WeiboOutlined />}
title="微博登录"
/>
</div>
<div className="auth-footer">
<span>还没有账号</span>
<a onClick={onSwitchToRegister} className="switch-link">立即注册</a>
</div>
</div>
</Modal>
);
};
export default LoginModal;

View File

@@ -0,0 +1,175 @@
import { useState } from 'react';
import { Modal, Form, Input, Button, Checkbox, Divider, message } from 'antd';
import { UserOutlined, LockOutlined, MobileOutlined, WechatOutlined, QqOutlined, WeiboOutlined } from '@ant-design/icons';
import { register } from '../api/auth';
import './AuthModal.css';
const RegisterModal = ({ open, onClose, onSwitchToLogin }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleRegister = async (values) => {
setLoading(true);
const result = await register(values.phone, values.password, values.nickname);
setLoading(false);
if (result.success) {
message.success('注册成功!');
onClose();
form.resetFields();
// 刷新页面以更新用户信息
window.location.reload();
} else {
message.error(result.message || '注册失败');
}
};
const handleClose = () => {
form.resetFields();
onClose();
};
return (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={480}
centered
className="auth-modal"
title={null}
closeIcon={<span className="close-icon">×</span>}
data-testid="register-modal"
>
<div className="auth-modal-content" data-testid="register-modal-content">
<div className="auth-header">
<h2 className="auth-title">注册图汇</h2>
<p className="auth-subtitle">加入我们发现无限创意</p>
</div>
<Form
form={form}
onFinish={handleRegister}
layout="vertical"
requiredMark={false}
className="auth-form"
>
<Form.Item
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号' }
]}
>
<Input
prefix={<MobileOutlined className="input-icon" />}
placeholder="请输入手机号"
maxLength={11}
size="large"
className="auth-input"
data-testid="register-phone-input"
/>
</Form.Item>
<Form.Item
name="nickname"
>
<Input
prefix={<UserOutlined className="input-icon" />}
placeholder="昵称(选填,不填则自动生成)"
size="large"
className="auth-input"
data-testid="register-nickname-input"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6位' }
]}
>
<Input.Password
prefix={<LockOutlined className="input-icon" />}
placeholder="设置密码至少6位"
size="large"
className="auth-input"
data-testid="register-password-input"
/>
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请确认密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined className="input-icon" />}
placeholder="确认密码"
size="large"
className="auth-input"
data-testid="register-confirm-password-input"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
size="large"
block
loading={loading}
className="auth-submit-btn"
data-testid="register-submit-btn"
>
注册
</Button>
</Form.Item>
</Form>
<Divider className="auth-divider">
<span>其他注册方式</span>
</Divider>
<div className="social-login">
<Button
className="social-btn wechat"
shape="circle"
icon={<WechatOutlined />}
title="微信注册"
/>
<Button
className="social-btn qq"
shape="circle"
icon={<QqOutlined />}
title="QQ注册"
/>
<Button
className="social-btn weibo"
shape="circle"
icon={<WeiboOutlined />}
title="微博注册"
/>
</div>
<div className="auth-footer">
<span>已有账号</span>
<a onClick={onSwitchToLogin} className="switch-link">立即登录</a>
</div>
</div>
</Modal>
);
};
export default RegisterModal;

View File

@@ -0,0 +1,275 @@
/* ========== 作品区块整体样式 ========== */
.works-section {
padding: 60px 20px;
max-width: 1400px;
margin: 0 auto;
animation: fadeIn 0.8s ease-out;
}
/* ========== 区块标题 ========== */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
padding: 0 10px;
}
.section-title {
font-size: 32px;
font-weight: 700;
color: #2d3436;
position: relative;
padding-left: 20px;
animation: slideIn 0.6s ease-out;
}
.section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 3px;
}
.view-more {
color: #667eea;
font-size: 16px;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 20px;
background: rgba(102, 126, 234, 0.1);
}
.view-more:hover {
background: rgba(102, 126, 234, 0.2);
transform: translateX(5px);
}
/* ========== 作品网格布局 ========== */
.works-grid {
animation: scaleIn 0.8s ease-out;
}
/* ========== 作品卡片 ========== */
.work-card {
border-radius: 16px;
overflow: hidden;
border: none;
background: white;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
cursor: pointer;
animation: fadeIn 0.6s ease-out;
animation-fill-mode: both;
}
.work-card:hover {
transform: translateY(-10px) scale(1.02);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
/* ========== 作品图片容器 ========== */
.work-image {
position: relative;
width: 100%;
height: 280px;
overflow: hidden;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.work-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.work-card:hover .work-image img {
transform: scale(1.1);
}
/* ========== 预览遮罩层 ========== */
.work-preview {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(102, 126, 234, 0.8);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.3s ease;
}
.work-card:hover .work-preview {
opacity: 1;
}
.preview-icon {
font-size: 48px;
transform: scale(0.5);
transition: transform 0.3s ease;
}
.work-card:hover .preview-icon {
transform: scale(1);
}
/* ========== 作品信息 ========== */
.work-info {
padding: 20px;
background: white;
}
.work-title {
font-size: 16px;
font-weight: 600;
color: #2d3436;
margin-bottom: 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.3s ease;
}
.work-card:hover .work-title {
color: #667eea;
}
/* ========== 作品元数据 ========== */
.work-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.work-designer {
font-size: 13px;
color: #636e72;
font-weight: 500;
}
.work-level {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
border: 1px solid;
}
.work-level-text {
font-size: 12px;
color: #b2bec3;
}
/* ========== 作品价格 ========== */
.work-price {
font-size: 20px;
font-weight: 700;
color: #ff5a5a;
display: flex;
align-items: center;
gap: 4px;
}
.work-price::before {
content: '¥';
font-size: 14px;
}
/* ========== 卡片延迟动画 ========== */
.work-card:nth-child(1) { animation-delay: 0.05s; }
.work-card:nth-child(2) { animation-delay: 0.1s; }
.work-card:nth-child(3) { animation-delay: 0.15s; }
.work-card:nth-child(4) { animation-delay: 0.2s; }
.work-card:nth-child(5) { animation-delay: 0.25s; }
.work-card:nth-child(6) { animation-delay: 0.3s; }
.work-card:nth-child(7) { animation-delay: 0.35s; }
.work-card:nth-child(8) { animation-delay: 0.4s; }
.work-card:nth-child(9) { animation-delay: 0.45s; }
/* ========== 加载状态 ========== */
.works-section .ant-spin {
color: #667eea;
}
.works-section .ant-spin-text {
color: #636e72;
font-size: 14px;
margin-top: 10px;
}
/* ========== 空状态 ========== */
.works-empty {
text-align: center;
padding: 60px 20px;
color: #b2bec3;
}
.works-empty-icon {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.5;
}
.works-empty-text {
font-size: 16px;
}
/* ========== 响应式设计 ========== */
@media (max-width: 768px) {
.works-section {
padding: 40px 15px;
}
.section-title {
font-size: 24px;
}
.work-image {
height: 200px;
}
.work-title {
font-size: 14px;
}
.work-price {
font-size: 18px;
}
}
/* ========== 暗黑模式 ========== */
@media (prefers-color-scheme: dark) {
.work-card {
background: #2d2d44;
}
.work-title {
color: #e0e0e0;
}
.work-designer {
color: #a0a0a0;
}
.work-card:hover .work-title {
color: #667eea;
}
}

View File

@@ -0,0 +1,120 @@
import { useState, useEffect } from 'react';
import { Card, Tag, Row, Col, Spin } from 'antd';
import { RightOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getWorksList } from '../api/works';
import { API_CONFIG } from '../utils/config';
import './Works.css';
const Works = ({ title, type, categoryFilter }) => {
const navigate = useNavigate();
const [works, setWorks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadWorks();
}, [type, categoryFilter]);
const loadWorks = async () => {
setLoading(true);
const result = await getWorksList(1, 9, categoryFilter || '');
setLoading(false);
if (result.success) {
setWorks(result.data.items || []);
}
};
// 使用 Picsum 随机图片(更稳定)
const getImageUrl = (id, width = 400, height = 300) => {
return `https://picsum.photos/seed/${id}/${width}/${height}`;
};
const getLevelColor = (level) => {
const colors = {
1: '#999',
2: '#74b9ff',
3: '#00b894',
4: '#6c5ce7',
5: '#e17055',
6: '#ff5a5a',
};
return colors[level] || '#999';
};
// 点击卡片,跳转到详情页
const handleCardClick = (work) => {
navigate(`/detail/${work.id}`);
};
if (loading) {
return (
<section className="works-section">
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" tip="加载中..." />
</div>
</section>
);
}
return (
<section className="works-section">
<div className="section-header">
<h2 className="section-title">{title}</h2>
<a href="#" className="view-more">
查看更多 <RightOutlined />
</a>
</div>
<Row gutter={[16, 16]} className="works-grid">
{works.map((work, index) => (
<Col xs={12} sm={8} md={8} lg={8} xl={8} key={work.id}>
<Card
className="work-card"
hoverable
style={{ '--delay': `${index * 0.05}s` }}
onClick={() => handleCardClick(work)}
cover={
<div className="work-image">
<img
src={work.thumbnail_image ? `${API_CONFIG.baseURL}${work.thumbnail_image}` : getImageUrl(work.id)}
alt={work.title}
loading="lazy"
onError={(e) => {
e.target.src = getImageUrl(work.id);
}}
/>
<div className="work-preview">
<span className="preview-icon">🔍</span>
</div>
</div>
}
>
<div className="work-info">
<h3 className="work-title">{work.title}</h3>
<div className="work-meta">
<span className="work-designer">{work.designer}</span>
<Tag
className="work-level"
style={{
color: getLevelColor(work.level),
borderColor: getLevelColor(work.level)
}}
>
Lv.{work.level}
</Tag>
<span className="work-level-text">{work.level_text}</span>
</div>
<div className="work-price" style={{ marginTop: 8, color: '#ff5a5a', fontWeight: 'bold', fontSize: 16 }}>
¥{work.price}
</div>
</div>
</Card>
</Col>
))}
</Row>
</section>
);
};
export default Works;

246
frontend/src/index.css Normal file
View File

@@ -0,0 +1,246 @@
/* ========== 全局样式重置 ========== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
color: #2d3436;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
/* ========== 滚动条美化 ========== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
/* ========== 通用动画 ========== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* ========== 通用类 ========== */
.fade-in {
animation: fadeIn 0.6s ease-out;
}
.slide-in {
animation: slideIn 0.6s ease-out;
}
.scale-in {
animation: scaleIn 0.6s ease-out;
}
.float {
animation: float 3s ease-in-out infinite;
}
/* ========== 按钮样式 ========== */
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 30px;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.btn-primary:active {
transform: translateY(0);
}
/* ========== 卡片阴影 ========== */
.card-shadow {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.card-shadow:hover {
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15);
transform: translateY(-5px);
}
/* ========== 渐变背景 ========== */
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ========== 玻璃拟态 ========== */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* ========== 加载动画 ========== */
.loading-shimmer {
background: linear-gradient(
90deg,
#f0f0f0 0%,
#e0e0e0 20%,
#f0f0f0 40%,
#f0f0f0 100%
);
background-size: 1000px 100%;
animation: shimmer 2s infinite;
}
/* ========== 标签样式 ========== */
.tag-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
/* ========== 输入框美化 ========== */
.input-modern {
width: 100%;
padding: 12px 20px;
border: 2px solid #e0e0e0;
border-radius: 12px;
font-size: 16px;
transition: all 0.3s ease;
background: white;
}
.input-modern:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* ========== 分割线 ========== */
.divider-gradient {
height: 2px;
border: none;
background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #667eea 100%);
background-size: 200% 100%;
animation: shimmer 3s infinite;
}
/* ========== 响应式断点 ========== */
@media (max-width: 768px) {
body {
font-size: 14px;
}
.btn-primary {
padding: 10px 20px;
font-size: 14px;
}
}
/* ========== 暗黑模式支持 ========== */
@media (prefers-color-scheme: dark) {
body {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
}
.input-modern {
background: #2d2d44;
border-color: #404060;
color: #e0e0e0;
}
.input-modern:focus {
border-color: #667eea;
}
}

13
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -0,0 +1,121 @@
.category-detail-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.category-detail-hero {
height: 350px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.category-detail-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.back-btn {
position: absolute;
top: 90px;
left: 40px;
background: rgba(255, 255, 255, 0.9);
border: none;
color: #333;
font-weight: 500;
height: 40px;
padding: 0 20px;
transition: all 0.3s ease;
}
.back-btn:hover {
background: white !important;
color: #ff5a5a !important;
transform: translateX(-5px);
}
.category-detail-content {
text-align: center;
animation: fadeInUp 0.8s ease;
}
.category-detail-title {
font-size: 56px;
font-weight: 700;
margin: 0;
text-shadow: 2px 2px 20px rgba(0, 0, 0, 0.3);
animation: scaleIn 0.6s ease;
}
.category-detail-count {
font-size: 20px;
margin-top: 16px;
opacity: 0.95;
text-shadow: 1px 1px 10px rgba(0, 0, 0, 0.2);
}
.category-detail-works {
flex: 1;
padding: 40px 0;
background: #f8f9fa;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 768px) {
.category-detail-hero {
height: 250px;
}
.back-btn {
top: 70px;
left: 20px;
height: 36px;
padding: 0 16px;
font-size: 14px;
}
.category-detail-title {
font-size: 36px;
}
.category-detail-count {
font-size: 16px;
}
.category-detail-works {
padding: 30px 0;
}
}

View File

@@ -0,0 +1,56 @@
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from 'antd';
import { LeftOutlined } from '@ant-design/icons';
import Header from '../components/Header';
import Footer from '../components/Footer';
import Works from '../components/Works';
import './CategoryDetail.css';
const CategoryDetail = () => {
const { name } = useParams();
const navigate = useNavigate();
// 分类数据映射
const categoryData = {
'文化墙': { count: '6572', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
'周年庆': { count: '15868', gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
'直播': { count: '35429', gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
'包装': { count: '9533', gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
'科技': { count: '44921', gradient: 'linear-gradient(135deg, #0c3483 0%, #a2b6df 100%)' },
'活动': { count: '408131', gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
'医美': { count: '223068', gradient: 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)' },
'邀请函': { count: '15361', gradient: 'linear-gradient(135deg, #d299c2 0%, #fef9d7 100%)' },
};
const category = categoryData[name];
return (
<div className="category-detail-page">
<Header />
<div className="category-detail-hero" style={{ background: category?.gradient }}>
<div className="category-detail-overlay">
<Button
icon={<LeftOutlined />}
className="back-btn"
onClick={() => navigate('/')}
>
返回首页
</Button>
<div className="category-detail-content">
<h1 className="category-detail-title">{name}</h1>
<p className="category-detail-count"> {category?.count} 张作品</p>
</div>
</div>
</div>
<div className="category-detail-works">
<Works categoryFilter={name} />
</div>
<Footer />
</div>
);
};
export default CategoryDetail;

View File

@@ -0,0 +1,76 @@
import { useNavigate } from 'react-router-dom';
import Header from '../components/Header';
import Footer from '../components/Footer';
import './Protocol.css';
const Copyright = () => {
const navigate = useNavigate();
return (
<div className="protocol-page" style={{ paddingTop: '70px' }}>
<Header />
<div className="protocol-container">
<div className="protocol-sidebar">
<h3>相关协议</h3>
<ul>
<li><a onClick={() => navigate('/protocol')}>网站协议</a></li>
<li><a onClick={() => navigate('/pay-protocol')}>支付协议</a></li>
<li><a onClick={() => navigate('/copyright')} className="active">版权声明</a></li>
<li><a onClick={() => navigate('/help')}>帮助中心</a></li>
<li><a onClick={() => navigate('/upload-guide')}>供稿必读</a></li>
</ul>
</div>
<div className="protocol-content">
<h1>版权声明</h1>
<h2>CC共享版权授权协议</h2>
<p>"知识共享"Creative Commons简称CC是一种版权授权协议的统称</p>
<div className="highlight-box">
<h3>CC-BY-NC 授权协议</h3>
<p><strong>署名-非商业性使用BY-NC</strong>使用时需署名可转载节选混编二次创作但不得用于商业目的</p>
</div>
<h2>版权保护声明</h2>
<p>图汇作为网络服务平台十分重视版权及知识产权保护向用户传达版权保护理念</p>
<p><strong>1. 供稿设计师责任</strong></p>
<p>须进行实名登记上传身份证和手机号码验证对身份真实性和作品版权负责</p>
<p><strong>2. 平台审查机制</strong></p>
<p>图汇对作品进行严格审查提示设计师遵守法律法规不得上传侵权作品</p>
<p><strong>3. 违规处理</strong></p>
<p>对上传侵权作品的设计师平台有权进行处罚侵权行为造成的损失由设计师承担</p>
<p><strong>4. 侵权处置</strong></p>
<p>对明显侵权违法内容图汇有权不事先通知直接删除</p>
<p><strong>5. 使用范围</strong></p>
<p>用户下载作品应在学习交流分享范围内使用不得用于商业目的</p>
<p><strong>6. 严禁二次销售</strong></p>
<div className="warning-box">
<p> 严格保护平台设计师作品未经授权禁止转载作品到第三方网站进行二次销售一经发现立即封号依法追究法律责任</p>
</div>
<h2>侵权投诉</h2>
<p>若您的权益被侵害请发送邮件联系我们</p>
<p><strong>service@aishej.com</strong></p>
<p>工作人员会尽快处理请您耐心等待</p>
<div className="protocol-footer">
<p>本声明最后更新日期2026年1月10日</p>
</div>
</div>
</div>
<Footer />
</div>
);
};
export default Copyright;

147
frontend/src/pages/Help.css Normal file
View File

@@ -0,0 +1,147 @@
.help-content {
min-height: 600px;
}
.help-content h1 {
display: flex;
align-items: center;
}
.help-collapse {
margin-top: 24px;
background: transparent;
border: none;
}
.help-collapse .ant-collapse-item {
background: white;
border: 1px solid #f0f0f0;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.help-collapse .ant-collapse-header {
padding: 16px 20px;
font-size: 16px;
font-weight: 600;
color: #333;
}
.help-collapse .ant-collapse-content {
border-top: 1px solid #f0f0f0;
}
.help-qa {
padding: 12px 0;
}
.qa-item {
padding: 16px 20px;
border-bottom: 1px dashed #f0f0f0;
}
.qa-item:last-child {
border-bottom: none;
}
.qa-item h4 {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.qa-item h4::before {
content: '';
display: inline-block;
width: 4px;
height: 14px;
background: #ff5a5a;
margin-right: 8px;
vertical-align: middle;
}
.qa-item p {
font-size: 14px;
line-height: 1.8;
color: #666;
margin: 0;
padding-left: 12px;
}
.help-contact {
margin-top: 40px;
padding: 32px;
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
border-radius: 12px;
text-align: center;
}
.help-contact h3 {
font-size: 20px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.help-contact > p {
color: #666;
font-size: 14px;
margin-bottom: 24px;
}
.contact-info {
display: flex;
justify-content: center;
gap: 40px;
flex-wrap: wrap;
}
.contact-info p {
font-size: 14px;
color: #666;
margin: 0;
}
.highlight-box {
background: #fff5f5;
border-left: 4px solid #ff5a5a;
padding: 20px;
margin: 24px 0;
border-radius: 8px;
}
.highlight-box h3 {
font-size: 16px;
font-weight: 600;
color: #ff5a5a;
margin-bottom: 12px;
}
.highlight-box p {
margin: 0;
line-height: 1.8;
}
.warning-box {
background: #fff9e6;
border: 2px solid #ffcc00;
padding: 20px;
margin: 24px 0;
border-radius: 8px;
}
.warning-box p {
margin: 0;
color: #cc8800;
font-weight: 500;
line-height: 1.8;
}
@media (max-width: 768px) {
.contact-info {
flex-direction: column;
gap: 12px;
}
}

136
frontend/src/pages/Help.jsx Normal file
View File

@@ -0,0 +1,136 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Collapse } from 'antd';
import { QuestionCircleOutlined } from '@ant-design/icons';
import Header from '../components/Header';
import Footer from '../components/Footer';
import './Protocol.css';
import './Help.css';
const Help = () => {
const navigate = useNavigate();
const helpItems = [
{
key: '1',
label: '账号相关',
children: (
<div className="help-qa">
<div className="qa-item">
<h4>Q: 账号使用为什么需要手机验证</h4>
<p>A: 为保护您的账号安全当出现异常登录异地下载等安全风险时系统会要求手机验证确保是本人操作</p>
</div>
<div className="qa-item">
<h4>Q: 账号能绑定手机号码登录吗</h4>
<p>A: 可以在个人中心进行手机号绑定后即可使用手机号登录</p>
</div>
<div className="qa-item">
<h4>Q: 为什么需要频繁登录</h4>
<p>A: 为了账号安全系统会在一定时间后自动退出登录请重新登录即可</p>
</div>
</div>
)
},
{
key: '2',
label: '作品相关',
children: (
<div className="help-qa">
<div className="qa-item">
<h4>Q: 如何使用快速收藏功能</h4>
<p>A: 浏览作品时点击作品卡片上的"收藏"按钮即可快速收藏"我的收藏"中查看</p>
</div>
<div className="qa-item">
<h4>Q: 如何使用以图搜图功能</h4>
<p>A: 在首页搜索框旁点击相机图标上传图片后即可搜索相似作品</p>
</div>
<div className="qa-item">
<h4>Q: 作品下载失败怎么办</h4>
<p>A: 请检查网络连接清除浏览器缓存后重试如仍有问题请联系客服</p>
</div>
<div className="qa-item">
<h4>Q: 作品错误如何投诉举报</h4>
<p>A: 在作品详情页点击"举报"按钮选择举报原因并提交我们会及时处理</p>
</div>
</div>
)
},
{
key: '3',
label: '资金相关',
children: (
<div className="help-qa">
<div className="qa-item">
<h4>Q: 作品收益提现多久到账</h4>
<p>A: 提现申请审核通过后一般3-5个工作日到账</p>
</div>
<div className="qa-item">
<h4>Q: 上传作品有哪几种收益方式</h4>
<p>A: 设计师上传的作品被用户下载后可获得下载分成收益收益会显示在个人中心</p>
</div>
</div>
)
},
{
key: '4',
label: '其它问题',
children: (
<div className="help-qa">
<div className="qa-item">
<h4>Q: 如何申请开具发票</h4>
<p>A: 在个人中心-我的订单中选择需要开票的订单填写发票信息后提交申请</p>
</div>
<div className="qa-item">
<h4>Q: VIP会员有什么权益</h4>
<p>A: VIP会员可享受每天10次免费下载无广告浏览等专属权益</p>
</div>
</div>
)
}
];
return (
<div className="protocol-page" style={{ paddingTop: '70px' }}>
<Header />
<div className="protocol-container">
<div className="protocol-sidebar">
<h3>相关协议</h3>
<ul>
<li><a onClick={() => navigate('/protocol')}>网站协议</a></li>
<li><a onClick={() => navigate('/pay-protocol')}>支付协议</a></li>
<li><a onClick={() => navigate('/copyright')}>版权声明</a></li>
<li><a onClick={() => navigate('/help')} className="active">帮助中心</a></li>
<li><a onClick={() => navigate('/upload-guide')}>供稿必读</a></li>
</ul>
</div>
<div className="protocol-content help-content">
<h1>
<QuestionCircleOutlined style={{ marginRight: '12px', color: '#ff5a5a' }} />
帮助中心
</h1>
<Collapse
items={helpItems}
defaultActiveKey={['1']}
className="help-collapse"
/>
<div className="help-contact">
<h3>没有找到您要的问题</h3>
<p>您也可以联系我们的客服团队</p>
<div className="contact-info">
<p>📧 客服邮箱service@aishej.com</p>
<p> 工作时间工作日 9:00-18:00</p>
</div>
</div>
</div>
</div>
<Footer />
</div>
);
};
export default Help;

View File

@@ -0,0 +1,24 @@
import Header from '../components/Header';
import Hero from '../components/Hero';
import Categories from '../components/Categories';
import Festival from '../components/Festival';
import Works from '../components/Works';
import Designers from '../components/Designers';
import Footer from '../components/Footer';
function Home() {
return (
<div className="home-page" style={{ paddingTop: '70px' }}>
<Header />
<Hero />
<Categories />
<Festival />
<Works title="热门推荐" type="hot" />
<Works title="最新上传" type="new" />
<Designers />
<Footer />
</div>
);
}
export default Home;

View File

@@ -0,0 +1,79 @@
import { useNavigate } from 'react-router-dom';
import Header from '../components/Header';
import Footer from '../components/Footer';
import './Protocol.css';
const PayProtocol = () => {
const navigate = useNavigate();
return (
<div className="protocol-page" style={{ paddingTop: '70px' }}>
<Header />
<div className="protocol-container">
<div className="protocol-sidebar">
<h3>相关协议</h3>
<ul>
<li><a onClick={() => navigate('/protocol')}>网站协议</a></li>
<li><a onClick={() => navigate('/pay-protocol')} className="active">支付协议</a></li>
<li><a onClick={() => navigate('/copyright')}>版权声明</a></li>
<li><a onClick={() => navigate('/help')}>帮助中心</a></li>
<li><a onClick={() => navigate('/upload-guide')}>供稿必读</a></li>
</ul>
</div>
<div className="protocol-content">
<h1>支付协议</h1>
<p>欢迎使用图汇网站支付服务为了保障您的权益请认真阅读本协议</p>
<h2>使用规范</h2>
<p><strong>1. 会员服务</strong></p>
<p>VIP会员每天最多可以下载10个作品当日未使用的下载次数将在24点清零不可累计</p>
<p><strong>2. 服务周期</strong></p>
<p>VIP会员服务有效期根据您选择购买的时长为准自支付成功之日起计算</p>
<p><strong>3. 重复下载规则</strong></p>
<p>同一作品当天再次下载不会重复扣除次数次日起重新计入下载次数</p>
<p><strong>4. 账号独立性</strong></p>
<p>不同登录方式QQ微信手机号将被识别为独立账号权限无法转移</p>
<p><strong>5. 退款政策</strong></p>
<p>购买后已使用下载服务不接受退款未使用且未超过7日可申请退款</p>
<p><strong>6. 使用规范</strong></p>
<p>VIP会员仅限本人使用禁止转让出借或用于商业牟利禁止恶意批量下载行为</p>
<h2>作品投诉说明</h2>
<p>以下情况不在投诉受理范围</p>
<ul>
<li>文字未转曲不影响创意和分层素材使用</li>
<li>立体字标题字无法直接编辑</li>
<li>色差差异问题不同设备显示差异</li>
<li>误下载或不会使用软件</li>
<li>软件版本问题无法打开</li>
</ul>
<h2>其他约定</h2>
<p>1. 使用支付服务即视为已阅读并同意本协议</p>
<p>2. 本协议适用中华人民共和国法律</p>
<p>3. 如有问题请联系客服处理</p>
<div className="protocol-footer">
<p>客服邮箱service@aishej.com工作日 9:00-18:00</p>
<p>本协议最后更新日期2026年1月10日</p>
</div>
</div>
</div>
<Footer />
</div>
);
};
export default PayProtocol;

View File

@@ -0,0 +1,164 @@
.protocol-page {
min-height: 100vh;
background: #f5f5f5;
}
.protocol-container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
display: flex;
gap: 30px;
}
.protocol-sidebar {
width: 200px;
background: white;
border-radius: 12px;
padding: 24px;
height: fit-content;
position: sticky;
top: 90px;
}
.protocol-sidebar h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #333;
}
.protocol-sidebar ul {
list-style: none;
padding: 0;
margin: 0;
}
.protocol-sidebar li {
margin-bottom: 12px;
}
.protocol-sidebar a {
color: #666;
text-decoration: none;
font-size: 14px;
cursor: pointer;
transition: color 0.3s;
display: block;
padding: 8px 12px;
border-radius: 6px;
}
.protocol-sidebar a:hover {
color: #ff5a5a;
background: #fff5f5;
}
.protocol-sidebar a.active {
color: #ff5a5a;
background: #fff5f5;
font-weight: 500;
}
.protocol-content {
flex: 1;
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.protocol-content h1 {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #ff5a5a;
}
.protocol-content h2 {
font-size: 20px;
font-weight: 600;
color: #333;
margin-top: 32px;
margin-bottom: 16px;
}
.protocol-content p {
line-height: 1.8;
color: #666;
margin-bottom: 12px;
font-size: 14px;
}
.protocol-content ul {
padding-left: 24px;
margin-bottom: 16px;
}
.protocol-content li {
line-height: 1.8;
color: #666;
margin-bottom: 8px;
font-size: 14px;
}
.protocol-footer {
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
text-align: center;
color: #999;
font-size: 13px;
}
.protocol-content code {
background: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #ff5a5a;
}
.level-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.level-table td {
padding: 12px 16px;
border: 1px solid #f0f0f0;
font-size: 14px;
color: #666;
}
.level-table tr:nth-child(even) {
background: #fafafa;
}
.level-table td:first-child {
font-weight: 500;
color: #333;
}
@media (max-width: 768px) {
.protocol-container {
flex-direction: column;
}
.protocol-sidebar {
width: 100%;
position: static;
}
.protocol-content {
padding: 24px;
}
.protocol-content h1 {
font-size: 24px;
}
}

View File

@@ -0,0 +1,70 @@
import { useNavigate } from 'react-router-dom';
import Header from '../components/Header';
import Footer from '../components/Footer';
import './Protocol.css';
const Protocol = () => {
const navigate = useNavigate();
return (
<div className="protocol-page" style={{ paddingTop: '70px' }}>
<Header />
<div className="protocol-container">
<div className="protocol-sidebar">
<h3>相关协议</h3>
<ul>
<li><a onClick={() => navigate('/protocol')} className="active">网站协议</a></li>
<li><a onClick={() => navigate('/pay-protocol')}>支付协议</a></li>
<li><a onClick={() => navigate('/copyright')}>版权声明</a></li>
<li><a onClick={() => navigate('/help')}>帮助中心</a></li>
<li><a onClick={() => navigate('/upload-guide')}>供稿必读</a></li>
</ul>
</div>
<div className="protocol-content">
<h1>网站协议</h1>
<p>为维护您自身权益您在注册流程中确认同意本协议之前应当认真阅读本协议各条款</p>
<h2>1. 协议范围</h2>
<p>1.1 本协议由您与图汇共同缔结具有合同效力</p>
<p>1.2 当您完成注册程序后即表示您已充分阅读理解并接受本协议的全部内容</p>
<p>1.3 如果您不同意本协议应立即停止注册程序</p>
<h2>2. 用户注册与账号使用</h2>
<p>2.1 用户需提供真实准确的个人信息进行注册</p>
<p>2.2 符合以下条件方可注册</p>
<ul>
<li>年满十八岁具有完全民事行为能力的自然人</li>
<li>提供真实的姓名身份证和联系方式</li>
</ul>
<p>2.3 用户应妥善保管账号与密码不得转让或出借给他人使用</p>
<p>2.4 用户在使用服务过程中必须遵守相关法律法规</p>
<h2>3. 图汇的权利和义务</h2>
<p>3.1 图汇提供优质的网络服务支持对作品进行审核</p>
<p>3.2 图汇有权在网站投放广告</p>
<p>3.3 图汇有权对违规内容进行下架处理</p>
<p>3.4 图汇严格保护设计师作品版权禁止未经授权的转载和二次销售</p>
<h2>4. 免责声明</h2>
<p>4.1 用户使用服务的风险由其自己承担</p>
<p>4.2 图汇不保证服务不会中断对及时性安全性不作担保</p>
<p>4.3 对于不可抗力造成的服务中断图汇不承担责任</p>
<h2>5. 违约赔偿</h2>
<p>用户如有侵权违规行为需承担相应的法律责任和赔偿责任</p>
<div className="protocol-footer">
<p>本协议最后更新日期2026年1月10日</p>
</div>
</div>
</div>
<Footer />
</div>
);
};
export default Protocol;

View File

@@ -0,0 +1,132 @@
import { useNavigate } from 'react-router-dom';
import { CheckCircleOutlined } from '@ant-design/icons';
import Header from '../components/Header';
import Footer from '../components/Footer';
import './Protocol.css';
import './Help.css';
const UploadGuide = () => {
const navigate = useNavigate();
return (
<div className="protocol-page" style={{ paddingTop: '70px' }}>
<Header />
<div className="protocol-container">
<div className="protocol-sidebar">
<h3>相关协议</h3>
<ul>
<li><a onClick={() => navigate('/protocol')}>网站协议</a></li>
<li><a onClick={() => navigate('/pay-protocol')}>支付协议</a></li>
<li><a onClick={() => navigate('/copyright')}>版权声明</a></li>
<li><a onClick={() => navigate('/help')}>帮助中心</a></li>
<li><a onClick={() => navigate('/upload-guide')} className="active">供稿必读</a></li>
</ul>
</div>
<div className="protocol-content">
<h1>供稿必读</h1>
<p>为了保障您的权益且规范使用供稿服务请认真阅读本协议</p>
<h2>1. 作品供稿流程</h2>
<p>供稿需经过4个阶段</p>
<ul>
<li><CheckCircleOutlined style={{ color: '#52c41a', marginRight: '8px' }} />上传作品文件</li>
<li><CheckCircleOutlined style={{ color: '#52c41a', marginRight: '8px' }} />编辑作品内容和类别</li>
<li><CheckCircleOutlined style={{ color: '#52c41a', marginRight: '8px' }} />作品审核</li>
<li><CheckCircleOutlined style={{ color: '#52c41a', marginRight: '8px' }} />通过上架</li>
</ul>
<h2>2. 作品要求</h2>
<p><strong>必须包含设计分层源文件</strong>不够分层或无价值的作品将不予通过</p>
<p>文件格式需将源文件和预览图压缩成 <code>RAR</code><code>ZIP</code> <code>7Z</code> 格式</p>
<p>建议上传低版本源文件方便更多用户使用</p>
<h2>3. 禁止内容</h2>
<div className="warning-box">
<p> 禁止上传第三方素材网站模板</p>
<p> 禁止包含第三方广告信息</p>
<p> 禁止上传未分层的作品</p>
</div>
<h2>4. 关键词填写规范</h2>
<p>关键词用空格分开包含以下要素</p>
<ul>
<li>作品类型海报背景板电商详情页</li>
<li>作品行业或节日房地产圣诞节旅游</li>
<li>作品主题发布会招聘活动</li>
<li>作品风格中式小清新科技</li>
<li>突出元素人物山水礼物</li>
</ul>
<div className="highlight-box">
<h3>示例</h3>
<p> 海报 节日 圣诞节 红金 插画 圣诞老人 礼物</p>
<p> 背景板 房地产 发布会 科技 炫酷 城市</p>
<p> 电商详情页 旅游 云南 小清新 风景</p>
</div>
<h2>5. 设计师等级</h2>
<p>职称晋升根据上架作品数量分为六个等级</p>
<table className="level-table">
<tbody>
<tr>
<td>Lv.1 设计爱好者</td>
<td>作品数 10个以下</td>
</tr>
<tr>
<td>Lv.2 设计助理</td>
<td>作品数 10-49</td>
</tr>
<tr>
<td>Lv.3 设计师</td>
<td>作品数 50-199</td>
</tr>
<tr>
<td>Lv.4 资深设计师</td>
<td>作品数 200-499</td>
</tr>
<tr>
<td>Lv.5 设计经理</td>
<td>作品数 500-999</td>
</tr>
<tr>
<td>Lv.6 设计总监</td>
<td>作品数 1000个以上</td>
</tr>
</tbody>
</table>
<h2>6. 实名认证</h2>
<p>供稿设计师须进行实名登记上传身份证和手机号码验证对作品版权负责</p>
<h2>7. 收益结算</h2>
<p>收益按劳务报酬结算实名信息姓名身份证结算账户必须一致</p>
<p>禁止设计师与用户直接进行交易</p>
<h2>8. AIGC内容标识</h2>
<p>若作品包含AI生成的素材或元素上传时应选择"包含AIGC元素"标识</p>
<h2>9. 禁止上传的内容</h2>
<p>不得制作上传含有以下内容的作品</p>
<ul>
<li>反对宪法基本原则的</li>
<li>危害国家安全的</li>
<li>散布淫秽色情暴力的</li>
<li>侵犯他人合法权益的</li>
<li>其他违法违规内容</li>
</ul>
<div className="protocol-footer">
<p>本指南最后更新日期2026年1月10日</p>
</div>
</div>
</div>
<Footer />
</div>
);
};
export default UploadGuide;

View File

@@ -0,0 +1,474 @@
.work-detail-page {
min-height: 100vh;
background: #f5f5f5;
padding-top: 70px;
}
.work-detail-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* 面包屑 */
.breadcrumb {
padding: 16px 0;
font-size: 14px;
color: #999;
}
.breadcrumb a {
color: #666;
cursor: pointer;
transition: color 0.3s;
}
.breadcrumb a:hover {
color: #ff5a5a;
}
.breadcrumb .current {
color: #333;
}
/* 主要内容区 */
.work-detail-content {
display: flex;
gap: 24px;
align-items: flex-start;
}
/* 左侧作品展示 */
.work-main {
flex: 1;
background: white;
border-radius: 12px;
overflow: hidden;
}
.work-image-wrapper {
padding: 24px;
}
.work-image {
width: 100%;
height: auto;
display: block;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 相关标签 */
.related-tags {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
.related-tags h4 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #333;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.work-tag {
padding: 6px 16px;
background: #f5f5f5;
border: 1px solid #e8e8e8;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
}
.work-tag:hover {
background: #fff0f0;
border-color: #ff5a5a;
color: #ff5a5a;
}
/* 猜你喜欢 */
.related-works {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
.related-works h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
color: #333;
}
.related-works-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.related-work-card {
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s;
background: #fafafa;
}
.related-work-card:hover {
transform: translateY(-4px);
}
.related-work-image {
position: relative;
width: 100%;
padding-top: 150%;
overflow: hidden;
background: #f0f0f0;
}
.related-work-image img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.related-work-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.related-work-card:hover .related-work-overlay {
opacity: 1;
}
.related-work-info {
padding: 12px;
}
.related-work-info h4 {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.related-work-designer {
font-size: 12px;
color: #999;
}
.designer-level {
color: #ff5a5a;
margin-right: 4px;
}
.designer-level-name {
margin-right: 8px;
}
.designer-name {
margin-top: 4px;
color: #666;
}
/* 右侧信息栏 */
.work-sidebar {
width: 380px;
background: white;
border-radius: 12px;
padding: 24px;
position: sticky;
top: 80px;
}
/* 作品编号 */
.work-id-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 16px;
}
.work-id {
font-size: 14px;
color: #666;
font-family: monospace;
}
/* 作品标题 */
.work-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
line-height: 1.4;
}
/* 作品信息列表 */
.work-info-list {
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
padding: 16px 0;
margin-bottom: 20px;
}
.work-info-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-label {
color: #999;
}
.info-value {
color: #333;
font-weight: 500;
}
/* 操作按钮 */
.work-actions {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
margin-bottom: 24px;
}
.download-btn {
background: linear-gradient(135deg, #ff5a5a 0%, #ff8080 100%);
border: none;
height: 44px;
font-size: 16px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(255, 90, 90, 0.3);
}
.download-btn:hover {
background: linear-gradient(135deg, #ff7070 0%, #ff9090 100%) !important;
}
.collect-btn,
.share-btn {
height: 44px;
width: 44px;
padding: 0;
border-color: #e8e8e8;
color: #666;
}
.collect-btn.collected {
color: #ff5a5a;
border-color: #ff5a5a;
}
.collect-btn:hover,
.share-btn:hover {
color: #ff5a5a;
border-color: #ff5a5a;
}
/* 设计师卡片 */
.designer-card {
padding: 20px;
background: #fafafa;
border-radius: 12px;
margin-bottom: 24px;
}
.designer-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.designer-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
overflow: hidden;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.designer-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.designer-info {
flex: 1;
}
.designer-name-level {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.designer-level-badge {
background: #ff5a5a;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
}
.designer-level-text {
font-size: 12px;
color: #999;
}
.designer-card .designer-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
.designer-stats {
display: flex;
justify-content: space-around;
padding: 16px 0;
border-top: 1px solid #e8e8e8;
border-bottom: 1px solid #e8e8e8;
margin-bottom: 16px;
}
.designer-stats .stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: #333;
}
.stat-label {
font-size: 12px;
color: #999;
}
.follow-btn {
height: 40px;
background: #ff5a5a;
border: none;
}
.follow-btn:hover {
background: #ff7070 !important;
}
.follow-btn.followed {
background: #e8e8e8;
color: #666;
}
.follow-btn.followed:hover {
background: #d0d0d0 !important;
}
/* 搜索画板 */
.board-search {
margin-bottom: 24px;
}
.board-search h4 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: #333;
}
.board-search-input {
height: 40px;
border-radius: 20px;
}
/* 浏览统计 */
.work-stats {
display: flex;
justify-content: space-around;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.work-stats .stat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #999;
}
.work-stats .stat-item .anticon {
font-size: 16px;
}
/* 响应式 */
@media (max-width: 1200px) {
.work-detail-content {
flex-direction: column;
}
.work-sidebar {
width: 100%;
position: static;
}
.related-works-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.work-detail-container {
padding: 12px;
}
.related-works-grid {
grid-template-columns: 1fr;
}
.work-actions {
grid-template-columns: 1fr;
}
.collect-btn,
.share-btn {
width: 100%;
}
}

View File

@@ -0,0 +1,463 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Tag, message, Input, Modal, Spin } from 'antd';
import { HeartOutlined, HeartFilled, DownloadOutlined, ShareAltOutlined, EyeOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { QRCodeSVG } from 'qrcode.react';
import { getWorkDetail } from '../api/works';
import { createOrder } from '../api/orders';
import { createPayment, queryPaymentStatus } from '../api/payment';
import { downloadWork } from '../api/download';
import { isLoggedIn } from '../api/auth';
import { API_CONFIG } from '../utils/config';
import Header from '../components/Header';
import Footer from '../components/Footer';
import './WorkDetail.css';
const WorkDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [collected, setCollected] = useState(false);
const [followed, setFollowed] = useState(false);
const [downloadModalOpen, setDownloadModalOpen] = useState(false);
const [workData, setWorkData] = useState(null);
const [loading, setLoading] = useState(true);
const [purchasing, setPurchasing] = useState(false);
const [paymentUrl, setPaymentUrl] = useState('');
const [orderNumber, setOrderNumber] = useState('');
const [checkingPayment, setCheckingPayment] = useState(false);
// 加载作品详情
useEffect(() => {
loadWorkDetail();
}, [id]);
const loadWorkDetail = async () => {
setLoading(true);
const result = await getWorkDetail(id);
setLoading(false);
if (result.success) {
setWorkData(result.data);
} else {
message.error('加载作品详情失败');
}
};
// 使用 Picsum 图片作为后备
const getImageUrl = (workId) => {
return `https://picsum.photos/seed/${workId}/800/1200`;
};
const getRelatedImageUrl = (workId) => {
return `https://picsum.photos/seed/related${workId}/400/600`;
};
const relatedWorks = [
{
id: '20',
title: '相关设计作品1',
image: getRelatedImageUrl(20),
designer: 'cestbon',
level: 1,
levelName: '设计爱好者'
},
{
id: '21',
title: '相关设计作品2',
image: getRelatedImageUrl(21),
designer: '六十六号屯',
level: 4,
levelName: '资深设计师'
},
{
id: '22',
title: '相关设计作品3',
image: getRelatedImageUrl(22),
designer: '扶摇',
level: 3,
levelName: '设计师'
}
];
const handleCollect = () => {
setCollected(!collected);
message.success(collected ? '已取消收藏' : '收藏成功');
};
const handleFollow = () => {
setFollowed(!followed);
message.success(followed ? '已取消关注' : '关注成功');
};
const handleDownload = async () => {
// 检查是否登录
if (!isLoggedIn()) {
message.warning('请先登录');
return;
}
// 尝试直接下载
const result = await downloadWork(id, `${workData.title}.jpg`);
// 如果需要购买,显示购买确认弹窗
if (!result.success && result.needPurchase) {
setDownloadModalOpen(true);
}
};
const confirmDownload = async () => {
setPurchasing(true);
// 1. 创建订单
const orderResult = await createOrder(id);
if (!orderResult.success) {
setPurchasing(false);
message.error(orderResult.message);
return;
}
const order = orderResult.data;
setOrderNumber(order.order_no);
// 2. 创建支付链接
const paymentResult = await createPayment(order.id);
setPurchasing(false);
if (paymentResult.success) {
setPaymentUrl(paymentResult.data.pay_url);
message.success('请扫描二维码完成支付');
// 开始轮询支付状态
startPaymentStatusCheck(order.order_no);
} else {
message.error(paymentResult.message);
setDownloadModalOpen(false);
}
};
// 轮询检查支付状态
const startPaymentStatusCheck = (orderNum) => {
setCheckingPayment(true);
const checkInterval = setInterval(async () => {
const result = await queryPaymentStatus(orderNum);
if (result.success) {
// 检查本地订单状态是否为已支付
if (result.data.local_status === 'paid' || result.data.remote_status === 'SUCCESS') {
clearInterval(checkInterval);
setCheckingPayment(false);
setDownloadModalOpen(false);
setPaymentUrl('');
message.success('支付成功!开始下载...');
// 重新尝试下载
await downloadWork(id, `${workData.title}.jpg`);
}
}
}, 3000); // 每3秒检查一次
// 5分钟后停止检查
setTimeout(() => {
clearInterval(checkInterval);
setCheckingPayment(false);
}, 300000);
};
const handleModalClose = () => {
setDownloadModalOpen(false);
setPaymentUrl('');
setOrderNumber('');
setCheckingPayment(false);
};
// 生成作品编号:半年前日期 + 当前时分秒 + 作品ID
const generateWorkNumber = (workId) => {
const now = new Date();
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000); // 半年前
const datePart = sixMonthsAgo.toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, ''); // HHMMSS
return `${datePart}${timePart}${workId}`;
};
const handleShare = () => {
navigator.clipboard.writeText(window.location.href);
message.success('链接已复制到剪贴板');
};
const copyWorkId = () => {
const fullWorkNumber = generateWorkNumber(workData?.id || 0);
navigator.clipboard.writeText(fullWorkNumber);
message.success('编号已复制');
};
if (loading) {
return (
<div className="work-detail-page">
<Header />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '60vh' }}>
<Spin size="large" tip="加载中..." />
</div>
<Footer />
</div>
);
}
if (!workData) {
return (
<div className="work-detail-page">
<Header />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '60vh' }}>
<p>作品不存在</p>
</div>
<Footer />
</div>
);
}
// 解析标签
const tags = workData.tags ? (typeof workData.tags === 'string' ? JSON.parse(workData.tags) : workData.tags) : [];
return (
<div className="work-detail-page">
<Header />
<div className="work-detail-container">
{/* 面包屑导航 */}
<div className="breadcrumb">
<a onClick={() => navigate('/')}>首页</a>
<span> / </span>
<span className="current">{workData.title}</span>
</div>
<div className="work-detail-content">
{/* 左侧:作品展示 */}
<div className="work-main">
<div className="work-image-wrapper">
<img
src={workData.watermarked_image ? `${API_CONFIG.baseURL}${workData.watermarked_image}` : getImageUrl(id)}
alt={workData.title}
className="work-image"
/>
{/* 相关搜索标签 */}
<div className="related-tags">
<h4>相关搜索</h4>
<div className="tags-list">
{tags.map((tag, index) => (
<Tag key={index} className="work-tag">{tag}</Tag>
))}
</div>
</div>
{/* 猜你喜欢 */}
<div className="related-works">
<h3>猜你喜欢</h3>
<div className="related-works-grid">
{relatedWorks.map(work => (
<div key={work.id} className="related-work-card" onClick={() => navigate(`/detail/${work.id}`)}>
<div className="related-work-image">
<img src={work.image} alt={work.title} />
<div className="related-work-overlay">
<Button type="primary" size="small" icon={<DownloadOutlined />}>
源文件下载
</Button>
</div>
</div>
<div className="related-work-info">
<h4>{work.title}</h4>
<div className="related-work-designer">
<span className="designer-level">Lv.{work.level}</span>
<span className="designer-level-name">{work.levelName}</span>
<p className="designer-name">{work.designer}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* 右侧:作品信息 */}
<div className="work-sidebar">
{/* 编号和复制 */}
<div className="work-id-section">
<span className="work-id">{generateWorkNumber(workData.id)}</span>
<Button type="link" size="small" onClick={copyWorkId}>复制</Button>
</div>
{/* 作品标题 */}
<h1 className="work-title">{workData.title}</h1>
{/* 作品信息 */}
<div className="work-info-list">
<div className="work-info-item">
<span className="info-label">分类</span>
<span className="info-value">{workData.category}</span>
</div>
<div className="work-info-item">
<span className="info-label">设计师</span>
<span className="info-value">{workData.designer}</span>
</div>
<div className="work-info-item">
<span className="info-label">等级</span>
<span className="info-value">Lv.{workData.level} {workData.level_text}</span>
</div>
<div className="work-info-item">
<span className="info-label">价格</span>
<span className="info-value" style={{ color: '#ff5a5a', fontWeight: 'bold' }}>¥{workData.price}</span>
</div>
</div>
{/* 操作按钮 */}
<div className="work-actions">
<Button
type="primary"
size="large"
block
icon={<DownloadOutlined />}
onClick={handleDownload}
className="download-btn"
>
我要下载
</Button>
<Button
size="large"
icon={collected ? <HeartFilled /> : <HeartOutlined />}
onClick={handleCollect}
className={`collect-btn ${collected ? 'collected' : ''}`}
/>
<Button
size="large"
icon={<ShareAltOutlined />}
onClick={handleShare}
className="share-btn"
/>
</div>
{/* 设计师信息 */}
<div className="designer-card">
<div className="designer-header">
<div className="designer-avatar">
<img src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${workData.designer}`} alt={workData.designer} />
</div>
<div className="designer-info">
<div className="designer-name-level">
<span className="designer-level-badge">Lv.{workData.level}</span>
<span className="designer-level-text">{workData.level_text}</span>
</div>
<h3 className="designer-name">{workData.designer}</h3>
</div>
</div>
<div className="designer-stats">
<div className="stat-item">
<span className="stat-value">-</span>
<span className="stat-label">作品</span>
</div>
<div className="stat-item">
<span className="stat-value">-</span>
<span className="stat-label">粉丝</span>
</div>
</div>
<Button
type="primary"
block
icon={<PlusOutlined />}
onClick={handleFollow}
className={`follow-btn ${followed ? 'followed' : ''}`}
>
{followed ? '已关注' : '关注'}
</Button>
</div>
{/* 搜索画板 */}
<div className="board-search">
<h4>搜索画板</h4>
<Input
placeholder="搜索画板"
prefix={<SearchOutlined />}
className="board-search-input"
/>
</div>
{/* 浏览统计 */}
<div className="work-stats">
<div className="stat-item">
<EyeOutlined />
<span>{workData.views} 浏览</span>
</div>
<div className="stat-item">
<HeartOutlined />
<span>{workData.collects} 收藏</span>
</div>
</div>
</div>
</div>
</div>
{/* 购买确认弹窗 */}
<Modal
title={paymentUrl ? "扫码支付" : "购买作品"}
open={downloadModalOpen}
onOk={paymentUrl ? null : confirmDownload}
onCancel={handleModalClose}
okText="确定购买"
cancelText={paymentUrl ? "关闭" : "取消"}
centered
confirmLoading={purchasing}
footer={paymentUrl ? [
<Button key="close" onClick={handleModalClose}>
关闭
</Button>
] : undefined}
width={paymentUrl ? 450 : 416}
>
{!paymentUrl ? (
<div>
<p>作品{workData.title}</p>
<p>价格<span style={{ color: '#ff5a5a', fontSize: '18px', fontWeight: 'bold' }}>¥{workData.price}</span></p>
<p style={{ color: '#999', fontSize: '12px' }}>点击确定后将生成支付二维码</p>
</div>
) : (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{
background: '#fff',
padding: '20px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
display: 'inline-block'
}}>
<QRCodeSVG
value={paymentUrl}
size={200}
level="H"
includeMargin={true}
/>
</div>
<p style={{ marginTop: '20px', fontSize: '16px', fontWeight: 'bold' }}>
订单金额<span style={{ color: '#ff5a5a' }}>¥{workData.price}</span>
</p>
<p style={{ color: '#666', fontSize: '14px' }}>
请使用微信或支付宝扫码支付
</p>
{checkingPayment && (
<div style={{ marginTop: '15px' }}>
<Spin size="small" />
<span style={{ marginLeft: '10px', color: '#1890ff' }}>等待支付中...</span>
</div>
)}
<p style={{ color: '#999', fontSize: '12px', marginTop: '15px' }}>
订单号{orderNumber}
</p>
<p style={{ color: '#999', fontSize: '12px' }}>
支付完成后将自动开始下载
</p>
</div>
)}
</Modal>
<Footer />
</div>
);
};
export default WorkDetail;

View File

@@ -0,0 +1,8 @@
// API 配置
export const API_CONFIG = {
baseURL: "",
apiPrefix: "/api",
timeout: 30000
};
export const API_URL = `${API_CONFIG.baseURL}${API_CONFIG.apiPrefix}`;

View File

@@ -0,0 +1,69 @@
import axios from 'axios';
import { message } from 'antd';
import { API_CONFIG } from './config';
// 创建 axios 实例
const request = axios.create({
baseURL: `${API_CONFIG.baseURL}${API_CONFIG.apiPrefix}`,
timeout: API_CONFIG.timeout,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器 - 添加 Token
request.interceptors.request.use(
config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 响应拦截器 - 统一错误处理
request.interceptors.response.use(
response => {
return response.data;
},
error => {
if (error.response) {
const { status, data } = error.response;
// Token 过期或无效
if (status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
message.error('登录已过期,请重新登录');
window.location.href = '/';
return Promise.reject(new Error('登录已过期,请重新登录'));
}
// 权限不足
if (status === 403) {
const errorMessage = data.detail || '没有权限访问';
message.error(errorMessage);
return Promise.reject(new Error(errorMessage));
}
// 其他错误
const errorMessage = data.detail || '请求失败';
message.error(errorMessage);
return Promise.reject(new Error(errorMessage));
}
// 网络错误
if (error.request) {
message.error('网络连接失败,请检查网络');
return Promise.reject(new Error('网络连接失败'));
}
return Promise.reject(error);
}
);
export default request;

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})