chore: initialize tuhui repository
17
frontend/cypress.config.js
Normal 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,
|
||||
},
|
||||
})
|
||||
107
frontend/cypress/e2e/auth.cy.js
Normal 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('✅ 密码确认测试通过');
|
||||
});
|
||||
});
|
||||
124
frontend/cypress/e2e/purchase.cy.js
Normal 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('✅ 分享功能测试通过');
|
||||
});
|
||||
});
|
||||
70
frontend/cypress/e2e/works.cy.js
Normal 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('✅ 相关作品功能未实现(跳过)');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 89 KiB |
48
frontend/cypress/support/commands.js
Normal 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) => { ... })
|
||||
20
frontend/cypress/support/e2e.js
Normal 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')
|
||||
BIN
frontend/cypress/videos/auth.cy.js.mp4
Normal file
BIN
frontend/cypress/videos/purchase.cy.js.mp4
Normal file
BIN
frontend/cypress/videos/works.cy.js.mp4
Normal file
29
frontend/eslint.config.js
Normal 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
@@ -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
37
frontend/package.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
};
|
||||
72
frontend/src/api/download.js
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
||||
59
frontend/src/api/orders.js
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
||||
58
frontend/src/api/payment.js
Normal 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
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
1
frontend/src/assets/react.svg
Normal 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 |
275
frontend/src/components/AuthModal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
128
frontend/src/components/Categories.css
Normal 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;
|
||||
}
|
||||
}
|
||||
67
frontend/src/components/Categories.jsx
Normal 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;
|
||||
144
frontend/src/components/Designers.css
Normal 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;
|
||||
}
|
||||
}
|
||||
72
frontend/src/components/Designers.jsx
Normal 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;
|
||||
122
frontend/src/components/Festival.css
Normal 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;
|
||||
}
|
||||
}
|
||||
81
frontend/src/components/Festival.jsx
Normal 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;
|
||||
159
frontend/src/components/Footer.css
Normal 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;
|
||||
}
|
||||
}
|
||||
107
frontend/src/components/Footer.jsx
Normal 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;
|
||||
175
frontend/src/components/Header.css
Normal 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;
|
||||
}
|
||||
}
|
||||
150
frontend/src/components/Header.jsx
Normal 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;
|
||||
177
frontend/src/components/Hero.css
Normal 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%);
|
||||
}
|
||||
}
|
||||
76
frontend/src/components/Hero.jsx
Normal 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;
|
||||
143
frontend/src/components/LoginModal.jsx
Normal 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;
|
||||
175
frontend/src/components/RegisterModal.jsx
Normal 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;
|
||||
275
frontend/src/components/Works.css
Normal 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;
|
||||
}
|
||||
}
|
||||
120
frontend/src/components/Works.jsx
Normal 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
@@ -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
@@ -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>,
|
||||
);
|
||||
121
frontend/src/pages/CategoryDetail.css
Normal 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;
|
||||
}
|
||||
}
|
||||
56
frontend/src/pages/CategoryDetail.jsx
Normal 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;
|
||||
76
frontend/src/pages/Copyright.jsx
Normal 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
@@ -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
@@ -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;
|
||||
24
frontend/src/pages/Home.jsx
Normal 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;
|
||||
79
frontend/src/pages/PayProtocol.jsx
Normal 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;
|
||||
164
frontend/src/pages/Protocol.css
Normal 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;
|
||||
}
|
||||
}
|
||||
70
frontend/src/pages/Protocol.jsx
Normal 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;
|
||||
132
frontend/src/pages/UploadGuide.jsx
Normal 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;
|
||||
474
frontend/src/pages/WorkDetail.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
463
frontend/src/pages/WorkDetail.jsx
Normal 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;
|
||||
8
frontend/src/utils/config.js
Normal 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}`;
|
||||
69
frontend/src/utils/request.js
Normal 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
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||