Initial commit: 入舍智能家装前端项目

This commit is contained in:
MaeLucia 2026-02-17 20:28:53 +08:00
commit 0551fb9e20
48 changed files with 7330 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Production
dist
build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.idea
.vscode
*.swp
*.swo
# Trae
.trae

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#f97316" />
<title>入舍 - 智能家装</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2621
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "rushe-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"zustand": "^4.5.0",
"framer-motion": "^11.0.0",
"lucide-react": "^0.323.0",
"clsx": "^2.1.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.1.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

61
src/App.tsx Normal file
View File

@ -0,0 +1,61 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import AdminLayout from './layouts/AdminLayout'
import UserLayout from './layouts/UserLayout'
import AdminMinePage from './pages/admin/mine/MinePage'
import ToolboxPage from './pages/admin/toolbox/ToolboxPage'
import WorkspacePage from './pages/admin/workspace/WorkspacePage'
import AIBudgetPage from './pages/user/ai/AIBudgetPage'
import AIPage from './pages/user/ai/AIPage'
import BudgetDetailPage from './pages/user/ai/BudgetDetailPage'
import ExplorePage from './pages/user/explore/ExplorePage'
import BusBookingPage from './pages/user/group/BusBookingPage'
import GroupBuyPage from './pages/user/group/GroupBuyPage'
import GroupStorePage from './pages/user/group/GroupStorePage'
import PackageDetailPage from './pages/user/group/PackageDetailPage'
import BalconyPage from './pages/user/home/BalconyPage'
import DesignerPage from './pages/user/home/DesignerPage'
import HomePage from './pages/user/home/HomePage'
import HomePage2 from './pages/user/home/HomePage2'
import DataAssetsPage from './pages/user/mine/DataAssetsPage'
import DesignSchemesPage from './pages/user/mine/DesignSchemesPage'
import MinePage from './pages/user/mine/MinePage'
import PartnerCenterPage from './pages/user/mine/PartnerCenterPage'
import SettingsPage from './pages/user/mine/SettingsPage'
import ProgressPage from './pages/user/progress/ProgressPage'
function App() {
return (
<Routes>
<Route path="/" element={<Navigate to="/user/home" replace />} />
<Route path="/user" element={<UserLayout />}>
<Route path="home" element={<HomePage />} />
<Route path="home2" element={<HomePage2 />} />
<Route path="designer" element={<DesignerPage />} />
<Route path="balcony" element={<BalconyPage />} />
<Route path="ai" element={<AIPage />} />
<Route path="ai/budget" element={<AIBudgetPage />} />
<Route path="ai/budget/:category" element={<BudgetDetailPage />} />
<Route path="explore" element={<ExplorePage />} />
<Route path="group" element={<GroupBuyPage />} />
<Route path="group/store" element={<GroupStorePage />} />
<Route path="group/package/:id" element={<PackageDetailPage />} />
<Route path="group/bus-booking" element={<BusBookingPage />} />
<Route path="progress" element={<ProgressPage />} />
<Route path="mine" element={<MinePage />} />
<Route path="mine/data" element={<DataAssetsPage />} />
<Route path="mine/designs" element={<DesignSchemesPage />} />
<Route path="mine/partner" element={<PartnerCenterPage />} />
<Route path="mine/settings" element={<SettingsPage />} />
</Route>
<Route path="/admin" element={<AdminLayout />}>
<Route path="workspace" element={<WorkspacePage />} />
<Route path="mine" element={<AdminMinePage />} />
<Route path="toolbox" element={<ToolboxPage />} />
</Route>
</Routes>
)
}
export default App

View File

@ -0,0 +1,227 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Calculator, Send, Sparkles, X } from 'lucide-react'
import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
interface Message {
id: number
type: 'ai' | 'user'
content: string
time: string
}
interface AIChatModalProps {
isOpen: boolean
onClose: () => void
}
const initialMessages: Message[] = [
{
id: 1,
type: 'ai',
content: '您好!我是入舍智能家装助手,可以为您提供装修咨询、预算估算、风格推荐等服务。有什么可以帮助您的吗?',
time: '刚刚',
},
]
const quickQuestions = [
'装修大概需要多少钱?',
'现代简约风格特点',
'如何选择装修材料?',
'小户型怎么设计?',
]
export default function AIChatModal({ isOpen, onClose }: AIChatModalProps) {
const navigate = useNavigate()
const [messages, setMessages] = useState<Message[]>(initialMessages)
const [inputValue, setInputValue] = useState('')
const [isTyping, setIsTyping] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const handleSend = () => {
if (!inputValue.trim()) return
const userMessage: Message = {
id: Date.now(),
type: 'user',
content: inputValue,
time: '刚刚',
}
setMessages((prev) => [...prev, userMessage])
setInputValue('')
setIsTyping(true)
setTimeout(() => {
const aiResponses = [
'根据您的需求我建议您可以先了解一下我们的装修套餐从基础装修到全屋定制价格区间在5万到30万不等。您想了解哪个档次的装修方案呢',
'这是一个很好的问题!现代简约风格注重简洁的线条和功能性,通常使用中性色调,搭配少量亮色点缀。家具选择以实用为主,避免过多装饰。',
'选择装修材料时建议关注以下几点1. 环保等级E0/E1级2. 耐用性 3. 性价比 4. 售后服务。我们可以为您提供一线品牌材料,品质有保障。',
'小户型设计的关键是"轻装修、重装饰"建议1. 选择浅色系墙面 2. 利用镜面增加空间感 3. 定制收纳家具 4. 选择多功能家具。',
]
const aiMessage: Message = {
id: Date.now(),
type: 'ai',
content: aiResponses[Math.floor(Math.random() * aiResponses.length)],
time: '刚刚',
}
setMessages((prev) => [...prev, aiMessage])
setIsTyping(false)
setTimeout(scrollToBottom, 100)
}, 1500)
setTimeout(scrollToBottom, 100)
}
const handleQuickQuestion = (question: string) => {
setInputValue(question)
}
const handleBudgetClick = () => {
onClose()
navigate('/user/ai/budget')
}
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-50"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 h-[75vh] z-50 mx-4 rounded-t-3xl shadow-2xl overflow-hidden flex flex-col"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
<Sparkles size={16} className="text-white" />
</div>
<div>
<h3 className="font-bold text-sm" style={{ color: 'var(--text-primary)' }}></h3>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>线</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleBudgetClick}
className="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-orange-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:shadow-md transition-shadow"
>
<Calculator size={14} />
</button>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-gray-100 transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
<X size={18} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3">
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex mb-3 ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.type === 'ai' && (
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center mr-2 flex-shrink-0">
<Sparkles size={12} className="text-white" />
</div>
)}
<div
className={`max-w-[75%] px-3 py-2 rounded-2xl text-sm ${
message.type === 'user'
? 'bg-primary-500 text-white rounded-br-md'
: 'rounded-bl-md'
}`}
style={message.type === 'ai' ? { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)' } : {}}
>
<p className="leading-relaxed">{message.content}</p>
<p className={`text-xs mt-1 ${message.type === 'user' ? 'text-white/70' : ''}`} style={message.type === 'ai' ? { color: 'var(--text-tertiary)' } : {}}>
{message.time}
</p>
</div>
</motion.div>
))}
{isTyping && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex mb-3 justify-start"
>
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center mr-2 flex-shrink-0">
<Sparkles size={12} className="text-white" />
</div>
<div className="px-3 py-2 rounded-2xl rounded-bl-md" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-primary-400 animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 rounded-full bg-primary-400 animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 rounded-full bg-primary-400 animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
<div className="px-4 py-2 border-t" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex gap-2 mb-2 overflow-x-auto pb-1">
{quickQuestions.map((question, index) => (
<button
key={index}
onClick={() => handleQuickQuestion(question)}
className="px-3 py-1 rounded-full text-xs whitespace-nowrap border transition-colors hover:bg-primary-50 hover:border-primary-300"
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
>
{question}
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
placeholder="输入您的问题..."
className="flex-1 px-4 py-2 rounded-full text-sm outline-none"
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
/>
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className="w-10 h-10 rounded-full bg-primary-500 text-white flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
<Send size={18} />
</button>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}

View File

@ -0,0 +1,198 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Bell, Check, Filter, Package, Percent, Settings, Trash2, X } from 'lucide-react'
import { useState } from 'react'
import { notifications as initialNotifications } from '@/mock/homeData'
interface Notification {
id: number
type: string
title: string
content: string
time: string
isRead: boolean
}
interface NotificationModalProps {
isOpen: boolean
onClose: () => void
}
const typeConfig: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
order: { icon: <Package size={16} />, color: 'bg-blue-500', label: '订单' },
promotion: { icon: <Percent size={16} />, color: 'bg-orange-500', label: '优惠' },
system: { icon: <Settings size={16} />, color: 'bg-gray-500', label: '系统' },
design: { icon: <Bell size={16} />, color: 'bg-purple-500', label: '设计' },
}
export default function NotificationModal({ isOpen, onClose }: NotificationModalProps) {
const [notifications, setNotifications] = useState<Notification[]>(initialNotifications)
const [activeFilter, setActiveFilter] = useState<string>('all')
const filters = [
{ key: 'all', label: '全部' },
{ key: 'unread', label: '未读' },
{ key: 'order', label: '订单' },
{ key: 'promotion', label: '优惠' },
{ key: 'system', label: '系统' },
]
const filteredNotifications = notifications.filter((n) => {
if (activeFilter === 'all') return true
if (activeFilter === 'unread') return !n.isRead
return n.type === activeFilter
})
const unreadCount = notifications.filter((n) => !n.isRead).length
const markAsRead = (id: number) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
)
}
const markAllAsRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
}
const deleteNotification = (id: number) => {
setNotifications((prev) => prev.filter((n) => n.id !== id))
}
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-50"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed top-0 left-0 right-0 max-h-[85vh] z-50 mx-4 mt-16 rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
<div className="flex flex-col h-full max-h-[85vh]">
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-2">
<h3 className="font-bold text-base" style={{ color: 'var(--text-primary)' }}></h3>
{unreadCount > 0 && (
<span className="bg-primary-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">
{unreadCount}
</span>
)}
</div>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-xs text-primary-500 font-medium flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-primary-50 transition-colors"
>
<Check size={14} />
</button>
)}
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-gray-100 transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
<X size={18} />
</button>
</div>
</div>
<div className="flex items-center gap-2 px-4 py-2 overflow-x-auto border-b" style={{ borderColor: 'var(--border-color)' }}>
<Filter size={14} style={{ color: 'var(--text-tertiary)' }} />
{filters.map((filter) => (
<button
key={filter.key}
onClick={() => setActiveFilter(filter.key)}
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
activeFilter === filter.key
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{filter.label}
{filter.key === 'unread' && unreadCount > 0 && (
<span className="ml-1">({unreadCount})</span>
)}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{filteredNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12" style={{ color: 'var(--text-tertiary)' }}>
<Bell size={48} className="opacity-30 mb-3" />
<p className="text-sm"></p>
</div>
) : (
<div className="divide-y" style={{ borderColor: 'var(--border-color)' }}>
{filteredNotifications.map((notification, index) => (
<motion.div
key={notification.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className={`relative px-4 py-3 hover:bg-gray-50 transition-colors ${
!notification.isRead ? 'bg-primary-50/30' : ''
}`}
>
{!notification.isRead && (
<div className="absolute left-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-primary-500" />
)}
<div className="flex items-start gap-3 ml-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white flex-shrink-0 ${typeConfig[notification.type]?.color || 'bg-gray-500'}`}>
{typeConfig[notification.type]?.icon || <Bell size={16} />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-medium text-sm truncate" style={{ color: 'var(--text-primary)' }}>
{notification.title}
</h4>
<span className="text-xs flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>
{notification.time.split(' ')[1]}
</span>
</div>
<p className="text-xs mt-1 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{notification.content}
</p>
<div className="flex items-center gap-2 mt-2">
{!notification.isRead && (
<button
onClick={() => markAsRead(notification.id)}
className="text-xs text-primary-500 font-medium flex items-center gap-1 hover:underline"
>
<Check size={12} />
</button>
)}
<button
onClick={() => deleteNotification(notification.id)}
className="text-xs text-red-500 font-medium flex items-center gap-1 hover:underline"
>
<Trash2 size={12} />
</button>
</div>
</div>
</div>
</motion.div>
))}
</div>
)}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}

View File

@ -0,0 +1,33 @@
import { useEffect } from 'react'
import { useThemeStore, ThemeMode } from '@/store/themeStore'
interface ThemeProviderProps {
children: React.ReactNode
}
const getThemeClass = (theme: ThemeMode): string => {
switch (theme) {
case 'dark':
return 'dark'
case 'deepBlue':
return 'deep-blue'
default:
return ''
}
}
export default function ThemeProvider({ children }: ThemeProviderProps) {
const { theme } = useThemeStore()
useEffect(() => {
const root = document.documentElement
root.classList.remove('dark', 'deep-blue')
const themeClass = getThemeClass(theme)
if (themeClass) {
root.classList.add(themeClass)
}
}, [theme])
return <>{children}</>
}

128
src/index.css Normal file
View File

@ -0,0 +1,128 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-bottom: env(safe-area-inset-bottom);
}
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #f9fafb;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-color: #e5e7eb;
--shadow-color: rgba(0, 0, 0, 0.05);
--gradient-start: #f97316;
--gradient-end: #ea580c;
}
.dark {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-tertiary: #374151;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-tertiary: #9ca3af;
--border-color: #374151;
--shadow-color: rgba(0, 0, 0, 0.3);
--gradient-start: #f97316;
--gradient-end: #ea580c;
}
.deep-blue {
--bg-primary: #0f172a;
--bg-secondary: #020617;
--bg-tertiary: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-tertiary: #94a3b8;
--border-color: #334155;
--shadow-color: rgba(0, 0, 0, 0.4);
--gradient-start: #3b82f6;
--gradient-end: #2563eb;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s ease, color 0.3s ease;
}
#root {
height: 100%;
}
.page-container {
min-height: 100vh;
padding-bottom: calc(60px + env(safe-area-inset-bottom));
background-color: var(--bg-secondary);
transition: background-color 0.3s ease;
}
.page-content {
padding: 12px;
}
.card {
background: var(--bg-primary);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 1px 3px var(--shadow-color);
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.btn-primary {
@apply bg-primary-500 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200;
@apply hover:bg-primary-600 active:scale-95;
}
.btn-secondary {
@apply bg-gray-100 text-gray-700 px-4 py-2 rounded-lg font-medium transition-all duration-200;
@apply hover:bg-gray-200 active:scale-95;
}
.input-field {
@apply w-full px-4 py-3 border border-gray-200 rounded-lg text-base;
@apply focus:outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500;
}
@layer utilities {
.text-gradient {
@apply bg-gradient-to-r from-primary-500 to-primary-600 bg-clip-text text-transparent;
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-top {
padding-top: env(safe-area-inset-top);
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}

View File

@ -0,0 +1,60 @@
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Briefcase, Wrench, User } from 'lucide-react'
import { motion } from 'framer-motion'
const tabs = [
{ path: '/admin/workspace', icon: Briefcase, label: '工作台' },
{ path: '/admin/toolbox', icon: Wrench, label: '百宝箱' },
{ path: '/admin/mine', icon: User, label: '我的' },
]
export default function AdminLayout() {
const location = useLocation()
const navigate = useNavigate()
const isActive = (path: string) => location.pathname.startsWith(path)
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<main className="flex-1 pb-20">
<Outlet />
</main>
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 safe-bottom z-50">
<div className="flex justify-around items-center h-14 max-w-lg mx-auto">
{tabs.map((tab) => {
const active = isActive(tab.path)
return (
<motion.button
key={tab.path}
onClick={() => navigate(tab.path)}
className="flex flex-col items-center justify-center flex-1 h-full"
whileTap={{ scale: 0.95 }}
>
<tab.icon
size={22}
className={active ? 'text-primary-500' : 'text-gray-400'}
/>
<span
className={`text-xs mt-1 ${
active ? 'text-primary-500 font-medium' : 'text-gray-400'
}`}
>
{tab.label}
</span>
{active && (
<motion.div
layoutId="activeAdminTab"
className="absolute -top-0.5 w-8 h-0.5 bg-primary-500 rounded-full"
initial={false}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
)}
</motion.button>
)
})}
</div>
</nav>
</div>
)
}

View File

@ -0,0 +1,59 @@
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Compass, Home, ShoppingBag, Sparkles, User } from 'lucide-react'
import { motion } from 'framer-motion'
const tabs = [
{ path: '/user/home', icon: Home, label: '首页' },
{ path: '/user/ai', icon: Sparkles, label: '智能管家' },
{ path: '/user/explore', icon: Compass, label: '逛逛' },
{ path: '/user/group', icon: ShoppingBag, label: '团购' },
{ path: '/user/mine', icon: User, label: '我的' },
]
export default function UserLayout() {
const location = useLocation()
const navigate = useNavigate()
const isActive = (path: string) => {
if (path === '/user/home') {
return location.pathname === '/user/home' || location.pathname === '/user/home2'
}
return location.pathname.startsWith(path)
}
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<main className="flex-1 pb-20">
<Outlet />
</main>
<nav className="fixed bottom-0 left-0 right-0 border-t safe-bottom z-50" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-color)' }}>
<div className="flex justify-around items-center h-14 max-w-lg mx-auto">
{tabs.map((tab) => {
const active = isActive(tab.path)
return (
<motion.button
key={tab.path}
onClick={() => navigate(tab.path)}
className="flex flex-col items-center justify-center flex-1 h-full"
whileTap={{ scale: 0.95 }}
>
<tab.icon
size={22}
className={active ? 'text-primary-500' : ''}
style={{ color: active ? undefined : 'var(--text-tertiary)' }}
/>
<span
className={`text-xs mt-1 ${active ? 'text-primary-500 font-medium' : ''}`}
style={{ color: active ? undefined : 'var(--text-tertiary)' }}
>
{tab.label}
</span>
</motion.button>
)
})}
</div>
</nav>
</div>
)
}

16
src/main.tsx Normal file
View File

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

90
src/mock/adminData.ts Normal file
View File

@ -0,0 +1,90 @@
export const workspaceTasks = [
{
id: 1,
title: '张先生 - 阳光花园',
type: '报价待确认',
status: 'urgent',
amount: 158000,
createTime: '2024-01-28 10:30',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
},
{
id: 2,
title: '李女士 - 翠湖花园',
type: '二期款待收',
status: 'pending',
amount: 45000,
createTime: '2024-01-27 15:20',
avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200',
},
{
id: 3,
title: '王先生 - 金色家园',
type: '方案待审核',
status: 'normal',
amount: 0,
createTime: '2024-01-26 09:15',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200',
},
{
id: 4,
title: '赵女士 - 幸福小区',
type: '验收待安排',
status: 'normal',
amount: 0,
createTime: '2024-01-25 14:00',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
},
]
export const aiNotifications = [
{
id: 1,
type: 'payment',
title: 'AI催款提醒',
content: '李女士的二期款已逾期3天建议及时跟进',
action: '立即催款',
amount: 45000,
},
{
id: 2,
type: 'quote',
title: 'AI报价推送',
content: '张先生的报价已生成,等待客户确认',
action: '查看报价',
amount: 158000,
},
{
id: 3,
type: 'bonus',
title: '挽回奖金',
content: '成功挽回王先生的订单,奖金已到账',
action: '查看详情',
amount: 2000,
},
]
export const statistics = {
todayOrders: 5,
monthOrders: 28,
monthAmount: 458000,
completionRate: 92,
}
export const toolboxItems = [
{ id: 1, title: '报价计算器', icon: 'calculator', desc: '快速生成报价单' },
{ id: 2, title: '合同模板', icon: 'file-text', desc: '标准合同下载' },
{ id: 3, title: '材料清单', icon: 'list', desc: '材料用量计算' },
{ id: 4, title: '进度模板', icon: 'calendar', desc: '施工进度表' },
{ id: 5, title: '验收标准', icon: 'check-square', desc: '验收规范查询' },
{ id: 6, title: '客户管理', icon: 'users', desc: '客户信息管理' },
]
export const adminUserInfo = {
id: 1,
name: '项目经理张',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
phone: '139****9999',
department: '工程部',
position: '项目经理',
}

63
src/mock/aiData.ts Normal file
View File

@ -0,0 +1,63 @@
export const budgetCategories = [
{ id: 'kitchen', name: '厨房', icon: '🍳', count: 15 },
{ id: 'bathroom', name: '卫浴', icon: '🛁', count: 12 },
{ id: 'living', name: '厅房', icon: '🛋️', count: 18 },
{ id: 'balcony', name: '阳台', icon: '🌿', count: 8 },
{ id: 'garden', name: '花园', icon: '🌸', count: 6 },
]
export const budgetItems = {
kitchen: [
{ id: 1, name: '整体橱柜', brand: '欧派', spec: '定制', price: 15000, unit: '套' },
{ id: 2, name: '油烟机', brand: '方太', spec: '侧吸式', price: 3500, unit: '台' },
{ id: 3, name: '燃气灶', brand: '老板', spec: '嵌入式', price: 2000, unit: '台' },
{ id: 4, name: '消毒柜', brand: '美的', spec: '嵌入式', price: 1500, unit: '台' },
{ id: 5, name: '水槽龙头', brand: '九牧', spec: '不锈钢', price: 800, unit: '套' },
{ id: 6, name: '厨房吊顶', brand: '奥普', spec: '铝扣板', price: 1200, unit: '㎡' },
{ id: 7, name: '厨房瓷砖', brand: '东鹏', spec: '300x600', price: 45, unit: '片' },
{ id: 8, name: '厨房防水', brand: '东方雨虹', spec: '柔性', price: 65, unit: '㎡' },
],
bathroom: [
{ id: 1, name: '马桶', brand: 'TOTO', spec: '智能款', price: 4500, unit: '个' },
{ id: 2, name: '浴室柜', brand: '箭牌', spec: '实木', price: 3500, unit: '套' },
{ id: 3, name: '淋浴花洒', brand: '汉斯格雅', spec: '恒温', price: 2800, unit: '套' },
{ id: 4, name: '浴缸', brand: '科勒', spec: '亚克力', price: 6000, unit: '个' },
{ id: 5, name: '浴室镜', brand: '定制', spec: '带灯', price: 800, unit: '面' },
{ id: 6, name: '卫浴五金', brand: '九牧', spec: '套装', price: 1200, unit: '套' },
{ id: 7, name: '卫生间瓷砖', brand: '马可波罗', spec: '300x300', price: 35, unit: '片' },
{ id: 8, name: '卫生间防水', brand: '东方雨虹', spec: '柔性', price: 75, unit: '㎡' },
],
living: [
{ id: 1, name: '客厅沙发', brand: '顾家', spec: '真皮', price: 12000, unit: '套' },
{ id: 2, name: '电视柜', brand: '全友', spec: '实木', price: 3500, unit: '个' },
{ id: 3, name: '茶几', brand: '林氏木业', spec: '岩板', price: 1800, unit: '个' },
{ id: 4, name: '餐桌餐椅', brand: '曲美', spec: '一桌六椅', price: 4500, unit: '套' },
{ id: 5, name: '床', brand: '喜临门', spec: '1.8米', price: 5000, unit: '张' },
{ id: 6, name: '床垫', brand: '席梦思', spec: '乳胶', price: 6000, unit: '张' },
{ id: 7, name: '衣柜', brand: '索菲亚', spec: '定制', price: 12000, unit: '套' },
{ id: 8, name: '书桌', brand: '宜家', spec: '实木', price: 1500, unit: '张' },
],
balcony: [
{ id: 1, name: '阳台推拉门', brand: '凤铝', spec: '断桥铝', price: 800, unit: '㎡' },
{ id: 2, name: '阳台护栏', brand: '定制', spec: '不锈钢', price: 300, unit: '米' },
{ id: 3, name: '阳台地砖', brand: '东鹏', spec: '防滑', price: 55, unit: '片' },
{ id: 4, name: '阳台吊柜', brand: '定制', spec: '防水', price: 800, unit: '米' },
{ id: 5, name: '晾衣架', brand: '好太太', spec: '电动', price: 2000, unit: '套' },
{ id: 6, name: '阳台防水', brand: '东方雨虹', spec: '柔性', price: 55, unit: '㎡' },
],
garden: [
{ id: 1, name: '花园围栏', brand: '定制', spec: '防腐木', price: 350, unit: '米' },
{ id: 2, name: '花园地砖', brand: '户外专用', spec: '透水砖', price: 85, unit: '㎡' },
{ id: 3, name: '花架', brand: '定制', spec: '防腐木', price: 3000, unit: '套' },
{ id: 4, name: '花园灯具', brand: '欧普', spec: '太阳能', price: 150, unit: '个' },
{ id: 5, name: '自动灌溉系统', brand: '雨鸟', spec: '智能', price: 5000, unit: '套' },
{ id: 6, name: '花园座椅', brand: '户外专用', spec: '藤编', price: 2500, unit: '套' },
],
}
export const selectedItems = [
{ categoryId: 'kitchen', itemId: 1, quantity: 1 },
{ categoryId: 'kitchen', itemId: 2, quantity: 1 },
{ categoryId: 'bathroom', itemId: 1, quantity: 2 },
{ categoryId: 'living', itemId: 1, quantity: 1 },
]

98
src/mock/groupData.ts Normal file
View File

@ -0,0 +1,98 @@
export const groupBuyPackages = [
{
id: 1,
title: '极速焕新套餐',
subtitle: '7天快速改造',
price: 29999,
originalPrice: 39999,
image: 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=600',
features: ['全屋墙面翻新', '地板更换', '厨卫改造', '软装搭配'],
sales: 128,
rating: 4.8,
},
{
id: 2,
title: '品质整装套餐',
subtitle: '一站式服务',
price: 89999,
originalPrice: 129999,
image: 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600',
features: ['全屋设计', '主材包', '施工服务', '家具软装'],
sales: 86,
rating: 4.9,
},
{
id: 3,
title: '智能家装套餐',
subtitle: '科技赋能生活',
price: 159999,
originalPrice: 199999,
image: 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600',
features: ['全屋智能', '灯光系统', '安防系统', '环境控制'],
sales: 45,
rating: 4.7,
},
]
export const factoryStores = [
{
id: 1,
name: '佛山陶瓷工厂店',
address: '佛山市禅城区南庄镇',
distance: '15km',
image: 'https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=600',
rating: 4.8,
products: ['瓷砖', '大理石', '马赛克'],
busAvailable: true,
busTime: '每周六 9:00',
},
{
id: 2,
name: '顺德家具工厂店',
address: '佛山市顺德区龙江镇',
distance: '25km',
image: 'https://images.unsplash.com/photo-1600573472592-401b489a3cdc?w=600',
rating: 4.9,
products: ['沙发', '床具', '餐桌'],
busAvailable: true,
busTime: '每周日 9:00',
},
{
id: 3,
name: '中山灯饰工厂店',
address: '中山市古镇镇',
distance: '35km',
image: 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600',
rating: 4.7,
products: ['吊灯', '台灯', '智能灯'],
busAvailable: false,
busTime: '',
},
]
export const busSeats = Array.from({ length: 45 }, (_, i) => ({
id: i + 1,
row: Math.floor(i / 4) + 1,
col: (i % 4) + 1,
status: Math.random() > 0.3 ? 'available' : 'occupied',
price: 0,
}))
export const renovationSchemes = [
{
id: 1,
title: '现代简约方案',
image: 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=600',
description: '简约不简单,追求功能与美学的完美平衡',
price: 15000,
duration: '30天',
},
{
id: 2,
title: '北欧风格方案',
image: 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600',
description: '自然清新,营造温馨舒适的居家氛围',
price: 18000,
duration: '35天',
},
]

299
src/mock/homeData.ts Normal file
View File

@ -0,0 +1,299 @@
export const homeBanners = [
{
id: 1,
title: '春季家装节',
subtitle: '限时优惠',
image: 'https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=800',
link: '/user/group',
},
{
id: 2,
title: 'AI智能设计',
subtitle: '免费体验',
image: 'https://images.unsplash.com/photo-1618221195710-dd6b41faaea6?w=800',
link: '/user/ai',
},
{
id: 3,
title: '品质团购',
subtitle: '工厂直供',
image: 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800',
link: '/user/group',
},
]
export const quickActions = [
{ id: 1, icon: 'calculator', title: '装修预算', desc: 'AI智能计算', link: '/user/ai/budget' },
{ id: 2, icon: 'palette', title: '设计方案', desc: '专业设计师', link: '/user/mine/designs' },
{ id: 3, icon: 'truck', title: '团购优惠', desc: '工厂直供', link: '/user/group' },
{ id: 4, icon: 'calendar', title: '预约服务', desc: '上门量房', link: '/user/home' },
]
export const designers = [
{
id: 1,
name: '张设计',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
title: '首席设计师',
experience: '10年',
style: '现代简约',
rating: 4.9,
projects: 128,
},
{
id: 2,
name: '李雅',
avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200',
title: '资深设计师',
experience: '8年',
style: '北欧风格',
rating: 4.8,
projects: 96,
},
{
id: 3,
name: '王明',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200',
title: '高级设计师',
experience: '6年',
style: '中式风格',
rating: 4.7,
projects: 72,
},
]
export const groupPackages = [
{
id: 1,
name: '全屋定制套餐',
image: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400',
originalPrice: 89999,
groupPrice: 59999,
sold: 128,
tag: '限时秒杀',
},
{
id: 2,
name: '厨房焕新套餐',
image: 'https://images.unsplash.com/photo-1556909114-44e3e70034e2?w=400',
originalPrice: 39999,
groupPrice: 25999,
sold: 96,
tag: '爆款',
},
{
id: 3,
name: '卫浴升级套餐',
image: 'https://images.unsplash.com/photo-1552321554-5fefe8c9ef14?w=400',
originalPrice: 29999,
groupPrice: 19999,
sold: 72,
tag: '热销',
},
]
export const featuredProjects = [
{
id: 1,
title: '现代简约三居室',
area: '120㎡',
style: '现代简约',
budget: '15-20万',
image: 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=600',
},
{
id: 2,
title: '北欧风两居室',
area: '85㎡',
style: '北欧风格',
budget: '10-15万',
image: 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600',
},
{
id: 3,
title: '新中式别墅',
area: '280㎡',
style: '新中式',
budget: '50-80万',
image: 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=600',
},
]
export const balconyDesigns = [
{
id: 1,
title: '休闲阳台',
desc: '打造惬意休闲空间',
image: 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600',
features: ['绿植装饰', '休闲座椅', '遮阳设施'],
price: '3000-8000',
},
{
id: 2,
title: '洗衣阳台',
desc: '实用功能型设计',
image: 'https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=600',
features: ['洗衣区', '收纳柜', '晾晒区'],
price: '2000-5000',
},
{
id: 3,
title: '花园阳台',
desc: '都市中的小花园',
image: 'https://images.unsplash.com/photo-1600573472592-401b489a3cdc?w=600',
features: ['花架', '园艺工具', '自动灌溉'],
price: '5000-15000',
},
]
export const notifications = [
{
id: 1,
type: 'order',
title: '订单发货通知',
content: '您的订单 #20240315001 已发货预计3天内送达',
time: '2024-03-15 14:30',
isRead: false,
},
{
id: 2,
type: 'promotion',
title: '限时优惠活动',
content: '春季家装节火热进行中全场套餐低至6折起',
time: '2024-03-15 10:00',
isRead: false,
},
{
id: 3,
type: 'system',
title: '系统升级通知',
content: '系统将于今晚22:00-23:00进行维护升级届时部分功能暂不可用',
time: '2024-03-14 18:00',
isRead: true,
},
{
id: 4,
type: 'design',
title: '设计方案完成',
content: '您预约的设计方案"现代简约三居室"已完成,点击查看详情',
time: '2024-03-14 15:20',
isRead: true,
},
{
id: 5,
type: 'order',
title: '订单支付成功',
content: '您已成功支付订单 #20240313002金额¥25999',
time: '2024-03-13 09:45',
isRead: true,
},
{
id: 6,
type: 'promotion',
title: '优惠券到账',
content: '恭喜您获得200元优惠券一张有效期30天',
time: '2024-03-12 16:30',
isRead: true,
},
]
export const exploreContents = [
{
id: 1,
type: 'designer',
author: {
name: '李雅设计',
avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200',
title: '资深设计师',
},
title: '120㎡现代简约打造温馨三口之家',
content: '这套案例以白色和原木色为主调,搭配简洁的线条设计,营造出明亮通透的居住空间。客厅采用大面积落地窗,引入充足自然光...',
images: [
'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=600',
'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600',
],
likes: 328,
comments: 56,
shares: 23,
time: '2小时前',
tags: ['现代简约', '三居室', '120㎡'],
},
{
id: 2,
type: 'owner',
author: {
name: '小王装修记',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
title: '装修达人',
},
title: '历时3个月我的北欧风小窝终于完工啦',
content: '从毛坯到入住记录每一个装修细节。厨房采用了U型布局最大化利用空间卧室选用了浅灰色墙面搭配原木家具...',
images: [
'https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=600',
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600',
'https://images.unsplash.com/photo-1600573472592-401b489a3cdc?w=600',
],
likes: 892,
comments: 134,
shares: 67,
time: '5小时前',
tags: ['北欧风', '装修日记', '两居室'],
},
{
id: 3,
type: 'designer',
author: {
name: '张设计工作室',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200',
title: '首席设计师',
},
title: '新中式别墅设计,传承与现代的完美融合',
content: '本案将传统中式元素与现代设计手法相结合,通过木质格栅、水墨画背景墙、禅意茶室等设计,打造出既有东方韵味又不失现代感的居住空间...',
images: [
'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=600',
],
likes: 567,
comments: 89,
shares: 45,
time: '昨天',
tags: ['新中式', '别墅', '280㎡'],
},
{
id: 4,
type: 'owner',
author: {
name: '装修小白成长记',
avatar: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=200',
title: '业主',
},
title: '小户型逆袭35㎡变身精致单身公寓',
content: '谁说小户型不能有品质通过巧妙的空间规划和收纳设计我的35㎡小窝实现了客餐厨卫卧一应俱全分享我的改造心得...',
images: [
'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600',
'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=600',
],
likes: 1256,
comments: 234,
shares: 189,
time: '昨天',
tags: ['小户型', '单身公寓', '收纳设计'],
},
{
id: 5,
type: 'video',
author: {
name: '入舍设计官方',
avatar: 'https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=200',
title: '官方认证',
},
title: '【视频】全屋定制避坑指南,设计师亲授',
content: '全屋定制有哪些坑?如何避免踩雷?本期视频邀请资深设计师为大家详细讲解全屋定制的注意事项...',
video: 'https://example.com/video.mp4',
videoCover: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=600',
likes: 2341,
comments: 456,
shares: 321,
time: '3天前',
tags: ['全屋定制', '避坑指南', '设计师'],
},
]

88
src/mock/mineData.ts Normal file
View File

@ -0,0 +1,88 @@
export const userInfo = {
id: 1,
name: '用户小明',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
phone: '138****8888',
level: 'VIP会员',
points: 2580,
coupons: 5,
}
export const dataAssets = [
{ id: 1, title: '设计方案', count: 3, icon: 'palette' },
{ id: 2, title: '报价单', count: 2, icon: 'file-text' },
{ id: 3, title: '合同文件', count: 1, icon: 'file-signature' },
{ id: 4, title: '验收报告', count: 0, icon: 'check-circle' },
]
export const designSchemes = [
{
id: 1,
title: '现代简约三居室',
status: '进行中',
progress: 65,
image: 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=600',
designer: '张设计',
updateDate: '2024-01-28',
},
{
id: 2,
title: '北欧风两居室',
status: '已完成',
progress: 100,
image: 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600',
designer: '李雅',
updateDate: '2024-01-15',
},
{
id: 3,
title: '新中式方案',
status: '待确认',
progress: 80,
image: 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600',
designer: '王明',
updateDate: '2024-01-20',
},
]
export const partnerInfo = {
level: '金牌合伙人',
commission: 15800,
orders: 28,
teamSize: 15,
monthlyTarget: 50000,
monthlyProgress: 32000,
}
export const partnerProducts = [
{
id: 1,
title: '极速焕新套餐',
commission: 1500,
sales: 12,
image: 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=600',
},
{
id: 2,
title: '品质整装套餐',
commission: 4500,
sales: 8,
image: 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600',
},
{
id: 3,
title: '智能家装套餐',
commission: 8000,
sales: 5,
image: 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600',
},
]
export const menuItems = [
{ id: 1, title: '装修进度', icon: 'clock', link: '/user/progress' },
{ id: 2, title: '我的订单', icon: 'shopping-bag', link: '/user/mine/orders' },
{ id: 3, title: '我的收藏', icon: 'heart', link: '/user/mine/favorites' },
{ id: 4, title: '我的地址', icon: 'map-pin', link: '/user/mine/address' },
{ id: 5, title: '帮助中心', icon: 'help-circle', link: '/user/mine/help' },
{ id: 6, title: '设置', icon: 'settings', link: '/user/mine/settings' },
]

78
src/mock/progressData.ts Normal file
View File

@ -0,0 +1,78 @@
export const progressStages = [
{ id: 'prepare', name: '准备阶段', icon: '📋' },
{ id: 'design', name: '方案阶段', icon: '✏️' },
{ id: 'implement', name: '落地阶段', icon: '🔨' },
{ id: 'visit', name: '上门阶段', icon: '🏠' },
{ id: 'deliver', name: '交付阶段', icon: '🎉' },
]
export const progressDetails = {
prepare: {
title: '准备阶段',
status: 'completed',
tasks: [
{ id: 1, title: '需求沟通', status: 'completed', date: '2024-01-15' },
{ id: 2, title: '上门量房', status: 'completed', date: '2024-01-18' },
{ id: 3, title: '方案初稿', status: 'completed', date: '2024-01-22' },
],
tips: '准备阶段已完成,设计师已获取房屋详细信息。',
},
design: {
title: '方案阶段',
status: 'in_progress',
tasks: [
{ id: 1, title: '平面布局', status: 'completed', date: '2024-01-25' },
{ id: 2, title: '效果图设计', status: 'in_progress', date: '2024-01-28' },
{ id: 3, title: '施工图绘制', status: 'pending', date: '' },
{ id: 4, title: '预算报价', status: 'pending', date: '' },
],
tips: '效果图设计中预计3天内完成。',
},
implement: {
title: '落地阶段',
status: 'pending',
tasks: [
{ id: 1, title: '材料采购', status: 'pending', date: '' },
{ id: 2, title: '水电改造', status: 'pending', date: '' },
{ id: 3, title: '泥瓦工程', status: 'pending', date: '' },
{ id: 4, title: '木工工程', status: 'pending', date: '' },
{ id: 5, title: '油漆工程', status: 'pending', date: '' },
],
tips: '等待方案确认后开始施工。',
},
visit: {
title: '上门阶段',
status: 'pending',
tasks: [
{ id: 1, title: '中期验收', status: 'pending', date: '' },
{ id: 2, title: '安装调试', status: 'pending', date: '' },
{ id: 3, title: '软装进场', status: 'pending', date: '' },
],
tips: '等待施工完成后进行上门验收。',
},
deliver: {
title: '交付阶段',
status: 'pending',
tasks: [
{ id: 1, title: '最终验收', status: 'pending', date: '' },
{ id: 2, title: '交付使用', status: 'pending', date: '' },
{ id: 3, title: '售后服务', status: 'pending', date: '' },
],
tips: '等待所有工程完成后交付。',
},
}
export const projectInfo = {
name: '阳光花园3栋1201室',
area: '120㎡',
style: '现代简约',
budget: '15-20万',
startDate: '2024-01-15',
expectedEndDate: '2024-04-15',
designer: {
name: '张设计',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
phone: '138****8888',
},
progress: 35,
}

View File

@ -0,0 +1,67 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronRight, Settings, HelpCircle, Shield, LogOut } from 'lucide-react'
import { adminUserInfo } from '@/mock/adminData'
export default function MinePage() {
const navigate = useNavigate()
const menuItems = [
{ id: 1, title: '账号设置', icon: Settings, link: '/admin/mine/settings' },
{ id: 2, title: '帮助中心', icon: HelpCircle, link: '/admin/mine/help' },
{ id: 3, title: '隐私政策', icon: Shield, link: '/admin/mine/privacy' },
]
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white px-4 pt-12 pb-8">
<div className="flex items-center gap-4">
<img
src={adminUserInfo.avatar}
alt={adminUserInfo.name}
className="w-16 h-16 rounded-full object-cover border-2 border-white"
/>
<div className="flex-1">
<h2 className="text-xl font-bold">{adminUserInfo.name}</h2>
<p className="text-primary-100">{adminUserInfo.phone}</p>
<p className="text-sm text-primary-200 mt-1">
{adminUserInfo.department} · {adminUserInfo.position}
</p>
</div>
<ChevronRight size={24} className="text-white/60" />
</div>
</div>
<div className="px-4 -mt-4">
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
{menuItems.map((item, index) => (
<motion.button
key={item.id}
onClick={() => navigate(item.link)}
className={`w-full flex items-center justify-between p-4 ${
index !== menuItems.length - 1 ? 'border-b border-gray-50' : ''
}`}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gray-50 flex items-center justify-center text-gray-500">
<item.icon size={18} />
</div>
<span className="text-gray-700">{item.title}</span>
</div>
<ChevronRight size={20} className="text-gray-400" />
</motion.button>
))}
</div>
<motion.button
className="w-full mt-4 flex items-center justify-center gap-2 p-4 bg-white rounded-xl text-red-500"
whileTap={{ scale: 0.98 }}
>
<LogOut size={18} />
退
</motion.button>
</div>
</div>
)
}

View File

@ -0,0 +1,81 @@
import { motion } from 'framer-motion'
import { Calculator, FileText, List, Calendar, CheckSquare, Users } from 'lucide-react'
import { toolboxItems } from '@/mock/adminData'
export default function ToolboxPage() {
const getIcon = (icon: string) => {
const icons: Record<string, React.ReactNode> = {
'calculator': <Calculator size={24} />,
'file-text': <FileText size={24} />,
'list': <List size={24} />,
'calendar': <Calendar size={24} />,
'check-square': <CheckSquare size={24} />,
'users': <Users size={24} />,
}
return icons[icon] || <Calculator size={24} />
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white px-4 pt-12 pb-6">
<h1 className="text-xl font-bold text-center"></h1>
<p className="text-center text-primary-100 text-sm mt-1"></p>
</div>
<div className="px-4 -mt-4">
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="grid grid-cols-3 gap-4">
{toolboxItems.map((item) => (
<motion.button
key={item.id}
className="flex flex-col items-center p-4 rounded-xl bg-gray-50"
whileTap={{ scale: 0.95 }}
>
<div className="w-12 h-12 rounded-xl bg-primary-50 text-primary-500 flex items-center justify-center mb-2">
{getIcon(item.icon)}
</div>
<h3 className="text-sm font-medium text-gray-800">{item.title}</h3>
<p className="text-xs text-gray-500 mt-1">{item.desc}</p>
</motion.button>
))}
</div>
</div>
<div className="bg-primary-50 rounded-xl p-4 mt-4">
<h4 className="font-medium text-primary-700 mb-2"></h4>
<div className="space-y-2">
<motion.div
className="flex items-center justify-between p-3 bg-white rounded-lg"
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
<Calculator size={20} />
</div>
<div>
<h5 className="font-medium text-gray-800 text-sm"></h5>
<p className="text-xs text-gray-500"></p>
</div>
</div>
</motion.div>
<motion.div
className="flex items-center justify-between p-3 bg-white rounded-lg"
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
<FileText size={20} />
</div>
<div>
<h5 className="font-medium text-gray-800 text-sm"></h5>
<p className="text-xs text-gray-500"></p>
</div>
</div>
</motion.div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,160 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Bell, ChevronRight, AlertTriangle, DollarSign, FileText, CheckCircle } from 'lucide-react'
import { workspaceTasks, aiNotifications, statistics } from '@/mock/adminData'
export default function WorkspacePage() {
const [showNotifications, setShowNotifications] = useState(false)
const getStatusColor = (status: string) => {
switch (status) {
case 'urgent':
return 'bg-red-50 text-red-500'
case 'pending':
return 'bg-yellow-50 text-yellow-500'
default:
return 'bg-gray-50 text-gray-500'
}
}
const getNotificationIcon = (type: string) => {
switch (type) {
case 'payment':
return <DollarSign size={20} className="text-red-500" />
case 'quote':
return <FileText size={20} className="text-blue-500" />
case 'bonus':
return <CheckCircle size={20} className="text-green-500" />
default:
return <Bell size={20} className="text-gray-500" />
}
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white px-4 pt-12 pb-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold"></h1>
<motion.button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2"
whileTap={{ scale: 0.9 }}
>
<Bell size={22} />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</motion.button>
</div>
<div className="grid grid-cols-4 gap-4 text-center">
<div>
<p className="text-2xl font-bold">{statistics.todayOrders}</p>
<p className="text-xs text-primary-100"></p>
</div>
<div>
<p className="text-2xl font-bold">{statistics.monthOrders}</p>
<p className="text-xs text-primary-100"></p>
</div>
<div>
<p className="text-2xl font-bold">{(statistics.monthAmount / 10000).toFixed(1)}</p>
<p className="text-xs text-primary-100"></p>
</div>
<div>
<p className="text-2xl font-bold">{statistics.completionRate}%</p>
<p className="text-xs text-primary-100"></p>
</div>
</div>
</div>
{showNotifications && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="px-4 -mt-2"
>
<div className="bg-white rounded-xl shadow-lg p-4 mb-4">
<h3 className="font-bold text-gray-800 mb-3">AI智能提醒</h3>
<div className="space-y-3">
{aiNotifications.map((notification) => (
<div
key={notification.id}
className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg"
>
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-800 text-sm">{notification.title}</h4>
<p className="text-xs text-gray-500 mt-1">{notification.content}</p>
<div className="flex items-center justify-between mt-2">
{notification.amount > 0 && (
<span className="text-sm font-medium text-primary-500">
¥{notification.amount.toLocaleString()}
</span>
)}
<motion.button
className="text-xs text-primary-500"
whileTap={{ scale: 0.95 }}
>
{notification.action}
</motion.button>
</div>
</div>
</div>
))}
</div>
</div>
</motion.div>
)}
<div className="px-4 mt-4">
<div className="bg-white rounded-xl shadow-sm p-4 mb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-800"></h3>
<span className="text-sm text-gray-500">{workspaceTasks.length}</span>
</div>
<div className="space-y-3">
{workspaceTasks.map((task) => (
<motion.div
key={task.id}
className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl"
whileTap={{ scale: 0.98 }}
>
<img
src={task.avatar}
alt={task.title}
className="w-10 h-10 rounded-full object-cover"
/>
<div className="flex-1">
<h4 className="font-medium text-gray-800 text-sm">{task.title}</h4>
<p className="text-xs text-gray-500">{task.createTime}</p>
</div>
<div className="text-right">
<span className={`text-xs px-2 py-1 rounded-full ${getStatusColor(task.status)}`}>
{task.type}
</span>
{task.amount > 0 && (
<p className="text-sm font-medium text-primary-500 mt-1">
¥{task.amount.toLocaleString()}
</p>
)}
</div>
<ChevronRight size={20} className="text-gray-400" />
</motion.div>
))}
</div>
</div>
<div className="bg-primary-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle size={18} className="text-primary-500" />
<h4 className="font-medium text-primary-700">AI工作建议</h4>
</div>
<p className="text-sm text-primary-600">
215%
</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,134 @@
import { budgetCategories, budgetItems, selectedItems as initialSelectedItems } from '@/mock/aiData'
import { motion } from 'framer-motion'
import { ChevronLeft, ShoppingCart, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
export default function AIBudgetPage() {
const navigate = useNavigate()
const [selectedItems, setSelectedItems] = useState(initialSelectedItems)
const getTotalPrice = () => {
return selectedItems.reduce((total, item) => {
const categoryItems = budgetItems[item.categoryId as keyof typeof budgetItems]
const budgetItem = categoryItems?.find((i) => i.id === item.itemId)
return total + (budgetItem?.price || 0) * item.quantity
}, 0)
}
const removeItem = (categoryId: string, itemId: number) => {
setSelectedItems(
selectedItems.filter(
(item) => !(item.categoryId === categoryId && item.itemId === itemId)
)
)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800"></h1>
<div className="w-6" />
</div>
<div className="p-4">
<div className="bg-white rounded-xl p-4 shadow-sm mb-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="grid grid-cols-5 gap-2">
{budgetCategories.map((category) => (
<motion.button
key={category.id}
onClick={() => navigate(`/user/ai/budget/${category.id}`)}
className="flex flex-col items-center gap-1 p-2 rounded-xl bg-gray-50"
whileTap={{ scale: 0.95 }}
>
<span className="text-2xl">{category.icon}</span>
<span className="text-xs text-gray-600">{category.name}</span>
</motion.button>
))}
</div>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm mb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-800"></h3>
<span className="text-sm text-gray-500">{selectedItems.length}</span>
</div>
{selectedItems.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<ShoppingCart size={40} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
) : (
<div className="space-y-3">
{selectedItems.map((item) => {
const categoryItems = budgetItems[item.categoryId as keyof typeof budgetItems]
const budgetItem = categoryItems?.find((i) => i.id === item.itemId)
const category = budgetCategories.find((c) => c.id === item.categoryId)
if (!budgetItem) return null
return (
<motion.div
key={`${item.categoryId}-${item.itemId}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
>
<span className="text-xl">{category?.icon}</span>
<div className="flex-1">
<h4 className="font-medium text-gray-800 text-sm">{budgetItem.name}</h4>
<p className="text-xs text-gray-500">
{budgetItem.brand} · {budgetItem.spec}
</p>
</div>
<div className="text-right">
<p className="font-medium text-primary-500">
¥{budgetItem.price.toLocaleString()}
</p>
<p className="text-xs text-gray-400">x{item.quantity}{budgetItem.unit}</p>
</div>
<motion.button
onClick={() => removeItem(item.categoryId, item.itemId)}
className="p-1 text-gray-400 hover:text-red-500"
whileTap={{ scale: 0.9 }}
>
<Trash2 size={16} />
</motion.button>
</motion.div>
)
})}
</div>
)}
</div>
<div className="bg-gradient-to-r from-primary-500 to-primary-600 rounded-xl p-4 text-white mb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-primary-100 text-sm"></p>
<p className="text-3xl font-bold">¥{getTotalPrice().toLocaleString()}</p>
</div>
<motion.button
className="px-6 py-2 bg-white text-primary-500 rounded-lg font-medium"
whileTap={{ scale: 0.95 }}
>
</motion.button>
</div>
</div>
<div className="bg-primary-50 rounded-xl p-4">
<h4 className="font-medium text-primary-700 mb-2">AI智能推荐</h4>
<p className="text-sm text-primary-600">
</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,170 @@
import { motion } from 'framer-motion'
import { Calculator, ChevronRight, Mic, Send, Volume2 } from 'lucide-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
export default function AIPage() {
const navigate = useNavigate()
const [inputText, setInputText] = useState('')
const [isListening, setIsListening] = useState(false)
const [messages, setMessages] = useState([
{
id: 1,
type: 'ai',
content: '您好!我是入舍智能管家,我可以帮您:\n\n• 计算装修预算\n• 推荐设计方案\n• 解答装修问题\n• 预约上门服务\n\n请问有什么可以帮您的',
},
])
const quickQuestions = [
'帮我计算装修预算',
'推荐现代简约风格',
'如何选择装修材料',
'预约设计师上门',
]
const handleSend = () => {
if (!inputText.trim()) return
setMessages([
...messages,
{ id: Date.now(), type: 'user', content: inputText },
{
id: Date.now() + 1,
type: 'ai',
content: '好的,我来帮您处理这个问题。请问您的房屋面积是多少平方米?',
},
])
setInputText('')
}
const handleMicClick = () => {
setIsListening(!isListening)
if (!isListening) {
setTimeout(() => {
setIsListening(false)
setInputText('我想计算一下120平米房子的装修预算')
}, 2000)
}
}
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white px-4 pt-12 pb-6">
<h1 className="text-xl font-bold text-center mb-4"></h1>
<div className="flex justify-center gap-4">
<motion.button
onClick={() => navigate('/user/ai/budget')}
className="flex flex-col items-center gap-2"
whileTap={{ scale: 0.95 }}
>
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center">
<Calculator size={24} />
</div>
<span className="text-sm"></span>
</motion.button>
<motion.button
className="flex flex-col items-center gap-2"
whileTap={{ scale: 0.95 }}
>
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center">
<Volume2 size={24} />
</div>
<span className="text-sm"></span>
</motion.button>
<motion.button
className="flex flex-col items-center gap-2"
whileTap={{ scale: 0.95 }}
>
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center">
<Mic size={24} />
</div>
<span className="text-sm"></span>
</motion.button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-4">
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 ${message.type === 'user'
? 'bg-primary-500 text-white rounded-br-md'
: 'rounded-bl-md shadow-sm'
}`}
style={message.type === 'ai' ? { backgroundColor: 'var(--bg-primary)' } : {}}
>
<p className="text-sm whitespace-pre-line">{message.content}</p>
</div>
</motion.div>
))}
<div className="rounded-xl p-4 shadow-sm" style={{ backgroundColor: 'var(--bg-primary)' }}>
<h3 className="font-medium mb-3" style={{ color: 'var(--text-primary)' }}></h3>
<div className="space-y-2">
{quickQuestions.map((question, index) => (
<motion.button
key={index}
onClick={() => {
if (index === 0) {
navigate('/user/ai/budget')
} else {
setInputText(question)
}
}}
className="w-full flex items-center justify-between p-3 rounded-lg"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
whileTap={{ scale: 0.98 }}
>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>{question}</span>
<ChevronRight size={16} style={{ color: 'var(--text-tertiary)' }} />
</motion.button>
))}
</div>
</div>
</div>
<div className="fixed bottom-16 left-0 right-0 border-t p-4 z-40" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3 max-w-lg mx-auto">
<motion.button
onClick={handleMicClick}
className={`w-12 h-12 rounded-full flex items-center justify-center ${isListening ? 'bg-red-500 text-white' : ''
}`}
style={isListening ? {} : { backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}
whileTap={{ scale: 0.95 }}
animate={isListening ? { scale: [1, 1.1, 1] } : {}}
transition={{ repeat: Infinity, duration: 1 }}
>
<Mic size={20} />
</motion.button>
<div className="flex-1 relative">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="请输入您的问题..."
className="w-full h-12 pl-4 pr-12 rounded-full border focus:outline-none focus:border-primary-500"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-color)',
color: 'var(--text-primary)'
}}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
/>
<motion.button
onClick={handleSend}
className="absolute right-1 top-1 w-10 h-10 rounded-full bg-primary-500 text-white flex items-center justify-center"
whileTap={{ scale: 0.95 }}
>
<Send size={18} />
</motion.button>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,154 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, Plus, Minus, Check } from 'lucide-react'
import { budgetCategories, budgetItems } from '@/mock/aiData'
export default function BudgetDetailPage() {
const navigate = useNavigate()
const { category } = useParams<{ category: string }>()
const [quantities, setQuantities] = useState<Record<number, number>>({})
const categoryInfo = budgetCategories.find((c) => c.id === category)
const items = budgetItems[category as keyof typeof budgetItems] || []
const updateQuantity = (itemId: number, delta: number) => {
setQuantities((prev) => ({
...prev,
[itemId]: Math.max(0, (prev[itemId] || 0) + delta),
}))
}
const handleConfirm = () => {
const selected = Object.entries(quantities)
.filter(([_, qty]) => qty > 0)
.map(([id, qty]) => ({
categoryId: category!,
itemId: parseInt(id),
quantity: qty,
}))
if (selected.length > 0) {
alert(`已添加 ${selected.length} 个项目到清单`)
}
navigate('/user/ai/budget')
}
const getTotalPrice = () => {
return items.reduce((total, item) => {
return total + item.price * (quantities[item.id] || 0)
}, 0)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800">
{categoryInfo?.name || '装修预算'}
</h1>
<div className="w-6" />
</div>
<div className="p-4">
<div className="flex gap-2 mb-4 overflow-x-auto pb-2">
{budgetCategories.map((cat) => (
<motion.button
key={cat.id}
onClick={() => navigate(`/user/ai/budget/${cat.id}`)}
className={`flex-shrink-0 px-4 py-2 rounded-full text-sm ${
cat.id === category
? 'bg-primary-500 text-white'
: 'bg-white text-gray-600 border border-gray-200'
}`}
whileTap={{ scale: 0.95 }}
>
{cat.icon} {cat.name}
</motion.button>
))}
</div>
<div className="space-y-3">
{items.map((item) => (
<motion.div
key={item.id}
className="bg-white rounded-xl p-4 shadow-sm"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-medium text-gray-800">{item.name}</h3>
<p className="text-sm text-gray-500">
{item.brand} · {item.spec}
</p>
</div>
<p className="font-bold text-primary-500">
¥{item.price.toLocaleString()}
<span className="text-xs text-gray-400 font-normal">/{item.unit}</span>
</p>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<motion.button
onClick={() => updateQuantity(item.id, -1)}
disabled={!quantities[item.id]}
className={`w-8 h-8 rounded-full flex items-center justify-center ${
quantities[item.id]
? 'bg-gray-100 text-gray-600'
: 'bg-gray-50 text-gray-300'
}`}
whileTap={{ scale: 0.9 }}
>
<Minus size={16} />
</motion.button>
<span className="w-8 text-center font-medium">
{quantities[item.id] || 0}
</span>
<motion.button
onClick={() => updateQuantity(item.id, 1)}
className="w-8 h-8 rounded-full bg-primary-500 text-white flex items-center justify-center"
whileTap={{ scale: 0.9 }}
>
<Plus size={16} />
</motion.button>
</div>
{quantities[item.id] > 0 && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-6 h-6 rounded-full bg-primary-500 text-white flex items-center justify-center"
>
<Check size={14} />
</motion.div>
)}
</div>
</motion.div>
))}
</div>
</div>
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-4 safe-bottom">
<div className="flex items-center justify-between max-w-lg mx-auto">
<div>
<p className="text-sm text-gray-500"> {Object.values(quantities).filter(q => q > 0).length } </p>
<p className="text-xl font-bold text-primary-500">
¥{getTotalPrice().toLocaleString()}
</p>
</div>
<motion.button
onClick={handleConfirm}
className="px-8 py-3 bg-primary-500 text-white rounded-xl font-medium"
whileTap={{ scale: 0.95 }}
>
</motion.button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,160 @@
import { exploreContents } from '@/mock/homeData'
import { motion } from 'framer-motion'
import { Heart, MessageCircle, Play, Share2 } from 'lucide-react'
import { useState } from 'react'
export default function ExplorePage() {
const [activeTab, setActiveTab] = useState('all')
const [likedPosts, setLikedPosts] = useState<number[]>([])
const tabs = [
{ key: 'all', label: '推荐' },
{ key: 'designer', label: '设计师' },
{ key: 'owner', label: '业主分享' },
{ key: 'video', label: '视频' },
]
const filteredContents = activeTab === 'all'
? exploreContents
: exploreContents.filter((c) => c.type === activeTab)
const toggleLike = (id: number) => {
setLikedPosts((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
)
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="sticky top-0 z-10 px-4 py-3 border-b" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-color)' }}>
<h1 className="text-lg font-bold text-center mb-3" style={{ color: 'var(--text-primary)' }}></h1>
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-all ${
activeTab === tab.key
? 'bg-primary-500 text-white'
: ''
}`}
style={activeTab !== tab.key ? { backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' } : {}}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="px-4 py-4 space-y-4">
{filteredContents.map((content, index) => (
<motion.div
key={content.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="rounded-xl shadow-sm overflow-hidden"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
<div className="p-4">
<div className="flex items-center gap-3 mb-3">
<img
src={content.author.avatar}
alt={content.author.name}
className="w-10 h-10 rounded-full object-cover"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>{content.author.name}</h3>
{content.type === 'designer' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary-50 text-primary-500"></span>
)}
{content.type === 'video' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-50 text-orange-500"></span>
)}
</div>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{content.author.title}</p>
</div>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{content.time}</span>
</div>
<h4 className="font-bold text-sm mb-2" style={{ color: 'var(--text-primary)' }}>{content.title}</h4>
<p className="text-sm mb-3 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{content.content}</p>
{content.type === 'video' && content.videoCover ? (
<div className="relative rounded-lg overflow-hidden mb-3">
<img
src={content.videoCover}
alt={content.title}
className="w-full h-48 object-cover"
/>
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
<div className="w-14 h-14 rounded-full bg-white/90 flex items-center justify-center">
<Play size={24} className="text-primary-500 ml-1" fill="currentColor" />
</div>
</div>
</div>
) : content.images && content.images.length > 0 && (
<div className={`grid gap-2 mb-3 ${content.images.length === 1 ? 'grid-cols-1' : content.images.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>
{content.images.map((image, imgIndex) => (
<img
key={imgIndex}
src={image}
alt=""
className={`w-full object-cover rounded-lg ${content.images!.length === 1 ? 'h-48' : content.images!.length === 2 ? 'h-32' : 'h-24'}`}
/>
))}
</div>
)}
<div className="flex flex-wrap gap-2 mb-3">
{content.tags.map((tag, tagIndex) => (
<span
key={tagIndex}
className="text-xs px-2 py-1 rounded-full"
style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}
>
#{tag}
</span>
))}
</div>
<div className="flex items-center justify-between pt-3 border-t" style={{ borderColor: 'var(--border-color)' }}>
<motion.button
onClick={() => toggleLike(content.id)}
className="flex items-center gap-1"
whileTap={{ scale: 0.9 }}
>
<Heart
size={18}
className={likedPosts.includes(content.id) ? 'text-red-500 fill-red-500' : ''}
style={likedPosts.includes(content.id) ? {} : { color: 'var(--text-tertiary)' }}
/>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{likedPosts.includes(content.id) ? content.likes + 1 : content.likes}
</span>
</motion.button>
<motion.button className="flex items-center gap-1" whileTap={{ scale: 0.9 }}>
<MessageCircle size={18} style={{ color: 'var(--text-tertiary)' }} />
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{content.comments}</span>
</motion.button>
<motion.button className="flex items-center gap-1" whileTap={{ scale: 0.9 }}>
<Share2 size={18} style={{ color: 'var(--text-tertiary)' }} />
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{content.shares}</span>
</motion.button>
</div>
</div>
</motion.div>
))}
{filteredContents.length === 0 && (
<div className="flex flex-col items-center justify-center py-12" style={{ color: 'var(--text-tertiary)' }}>
<p className="text-sm"></p>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,174 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, User, Check } from 'lucide-react'
import { busSeats } from '@/mock/groupData'
export default function BusBookingPage() {
const navigate = useNavigate()
const [selectedSeats, setSelectedSeats] = useState<number[]>([])
const [passengerName, setPassengerName] = useState('')
const [passengerPhone, setPassengerPhone] = useState('')
const toggleSeat = (seatId: number, status: string) => {
if (status === 'occupied') return
setSelectedSeats((prev) =>
prev.includes(seatId)
? prev.filter((id) => id !== seatId)
: prev.length < 4
? [...prev, seatId]
: prev
)
}
const handleSubmit = () => {
if (selectedSeats.length === 0) {
alert('请选择座位')
return
}
if (!passengerName || !passengerPhone) {
alert('请填写乘客信息')
return
}
alert(`预约成功!已为您预留 ${selectedSeats.length} 个座位`)
navigate('/user/group')
}
const renderSeat = (seat: typeof busSeats[0]) => {
const isSelected = selectedSeats.includes(seat.id)
const isOccupied = seat.status === 'occupied'
return (
<motion.button
key={seat.id}
onClick={() => toggleSeat(seat.id, seat.status)}
disabled={isOccupied}
className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs ${
isOccupied
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: isSelected
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
whileTap={!isOccupied ? { scale: 0.95 } : {}}
>
{isSelected ? <Check size={16} /> : seat.id}
</motion.button>
)
}
const rows = []
for (let i = 0; i < busSeats.length; i += 4) {
const rowSeats = busSeats.slice(i, i + 4)
rows.push(rowSeats)
}
return (
<div className="min-h-screen bg-gray-50 pb-24">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800"></h1>
<div className="w-6" />
</div>
<div className="p-4">
<div className="bg-white rounded-xl p-4 shadow-sm mb-4">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-primary-50 flex items-center justify-center">
<User size={24} className="text-primary-500" />
</div>
<div>
<h3 className="font-medium text-gray-800"></h3>
<p className="text-sm text-gray-500"> 9:00 </p>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>广</span>
<span>|</span>
<span>1</span>
</div>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm mb-4">
<h3 className="font-medium text-gray-800 mb-4"></h3>
<div className="flex justify-center gap-4 mb-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-gray-100" />
<span className="text-xs text-gray-500"></span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary-500" />
<span className="text-xs text-gray-500"></span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-gray-200" />
<span className="text-xs text-gray-500"></span>
</div>
</div>
<div className="flex justify-center mb-4">
<div className="w-3/4 h-8 bg-gray-200 rounded-t-3xl flex items-center justify-center text-xs text-gray-500">
</div>
</div>
<div className="space-y-2">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex justify-center gap-2">
{renderSeat(row[0])}
{renderSeat(row[1])}
<div className="w-6" />
{renderSeat(row[2])}
{renderSeat(row[3])}
</div>
))}
</div>
<p className="text-center text-xs text-gray-400 mt-4">
{selectedSeats.length} 4
</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm mb-4">
<h3 className="font-medium text-gray-800 mb-3"></h3>
<div className="space-y-3">
<div>
<label className="text-sm text-gray-500 mb-1 block"></label>
<input
type="text"
value={passengerName}
onChange={(e) => setPassengerName(e.target.value)}
placeholder="请输入姓名"
className="input-field"
/>
</div>
<div>
<label className="text-sm text-gray-500 mb-1 block"></label>
<input
type="tel"
value={passengerPhone}
onChange={(e) => setPassengerPhone(e.target.value)}
placeholder="请输入手机号"
className="input-field"
/>
</div>
</div>
</div>
</div>
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-4 safe-bottom">
<motion.button
onClick={handleSubmit}
className="w-full h-12 bg-primary-500 text-white rounded-xl font-medium"
whileTap={{ scale: 0.98 }}
>
</motion.button>
</div>
</div>
)
}

View File

@ -0,0 +1,122 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronRight, Star, MapPin, Clock, Users } from 'lucide-react'
import { groupBuyPackages, factoryStores } from '@/mock/groupData'
export default function GroupBuyPage() {
const navigate = useNavigate()
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white px-4 pt-12 pb-6">
<h1 className="text-xl font-bold text-center mb-4"></h1>
<p className="text-center text-primary-100 text-sm"> · · </p>
</div>
<div className="px-4 -mt-4">
<div className="bg-white rounded-xl shadow-sm p-4 mb-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="space-y-3">
{groupBuyPackages.map((pkg) => (
<motion.div
key={pkg.id}
onClick={() => navigate(`/user/group/package/${pkg.id}`)}
className="flex gap-3 p-3 bg-gray-50 rounded-xl"
whileTap={{ scale: 0.98 }}
>
<img
src={pkg.image}
alt={pkg.title}
className="w-24 h-24 rounded-lg object-cover"
/>
<div className="flex-1">
<h4 className="font-medium text-gray-800">{pkg.title}</h4>
<p className="text-xs text-gray-500 mt-1">{pkg.subtitle}</p>
<div className="flex items-center gap-1 mt-1">
<Star size={12} className="text-yellow-400 fill-yellow-400" />
<span className="text-xs text-gray-600">{pkg.rating}</span>
<span className="text-xs text-gray-400">| {pkg.sales}</span>
</div>
<div className="flex items-baseline gap-2 mt-2">
<span className="text-lg font-bold text-primary-500">
¥{pkg.price.toLocaleString()}
</span>
<span className="text-xs text-gray-400 line-through">
¥{pkg.originalPrice.toLocaleString()}
</span>
</div>
</div>
<ChevronRight size={20} className="text-gray-400 self-center" />
</motion.div>
))}
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 mb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-800"></h3>
<button
onClick={() => navigate('/user/group/store')}
className="text-primary-500 text-sm flex items-center"
>
<ChevronRight size={16} />
</button>
</div>
<div className="space-y-3">
{factoryStores.slice(0, 2).map((store) => (
<motion.div
key={store.id}
onClick={() => navigate('/user/group/store')}
className="flex gap-3 p-3 bg-gray-50 rounded-xl"
whileTap={{ scale: 0.98 }}
>
<img
src={store.image}
alt={store.name}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-1">
<h4 className="font-medium text-gray-800">{store.name}</h4>
<div className="flex items-center gap-1 mt-1 text-xs text-gray-500">
<MapPin size={12} />
<span>{store.address}</span>
</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-1">
<Star size={12} className="text-yellow-400 fill-yellow-400" />
<span className="text-xs text-gray-600">{store.rating}</span>
</div>
<span className="text-xs text-gray-400">{store.distance}</span>
</div>
{store.busAvailable && (
<div className="flex items-center gap-1 mt-2 text-xs text-primary-500">
<Clock size={12} />
<span> · {store.busTime}</span>
</div>
)}
</div>
</motion.div>
))}
</div>
</div>
<motion.button
onClick={() => navigate('/user/group/bus-booking')}
className="w-full bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl p-4 flex items-center justify-between"
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
<Users size={20} />
</div>
<div>
<h4 className="font-medium"></h4>
<p className="text-xs text-primary-100"></p>
</div>
</div>
<ChevronRight size={20} />
</motion.button>
</div>
</div>
)
}

View File

@ -0,0 +1,84 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, Star, MapPin, Clock, Bus } from 'lucide-react'
import { factoryStores } from '@/mock/groupData'
export default function GroupStorePage() {
const navigate = useNavigate()
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800"></h1>
<div className="w-6" />
</div>
<div className="p-4 space-y-4">
{factoryStores.map((store) => (
<motion.div
key={store.id}
className="bg-white rounded-xl shadow-sm overflow-hidden"
whileTap={{ scale: 0.98 }}
>
<img
src={store.image}
alt={store.name}
className="w-full h-40 object-cover"
/>
<div className="p-4">
<h3 className="font-bold text-gray-800">{store.name}</h3>
<div className="flex items-center gap-1 mt-2 text-sm text-gray-500">
<MapPin size={14} />
<span>{store.address}</span>
<span className="text-gray-300 mx-1">|</span>
<span>{store.distance}</span>
</div>
<div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-1">
<Star size={14} className="text-yellow-400 fill-yellow-400" />
<span className="text-sm text-gray-600">{store.rating}</span>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-3">
{store.products.map((product, index) => (
<span
key={index}
className="px-2 py-1 bg-gray-50 rounded-md text-xs text-gray-600"
>
{product}
</span>
))}
</div>
{store.busAvailable && (
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bus size={18} className="text-primary-500" />
<div>
<p className="text-sm font-medium text-gray-800"></p>
<p className="text-xs text-gray-500">{store.busTime}</p>
</div>
</div>
<motion.button
onClick={() => navigate('/user/group/bus-booking')}
className="px-4 py-2 bg-primary-500 text-white rounded-lg text-sm"
whileTap={{ scale: 0.95 }}
>
</motion.button>
</div>
</div>
)}
</div>
</motion.div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,133 @@
import { useNavigate, useParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, Star, Check, Share2 } from 'lucide-react'
import { groupBuyPackages, renovationSchemes } from '@/mock/groupData'
export default function PackageDetailPage() {
const navigate = useNavigate()
const { id } = useParams()
const pkg = groupBuyPackages.find((p) => p.id === parseInt(id || '1')) || groupBuyPackages[0]
return (
<div className="min-h-screen bg-gray-50 pb-20">
<div className="relative">
<img
src={pkg.image}
alt={pkg.title}
className="w-full h-56 object-cover"
/>
<div className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/50 to-transparent p-4 pt-12">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-white" />
</button>
</div>
</div>
<div className="px-4 -mt-6 relative">
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800">{pkg.title}</h1>
<p className="text-sm text-gray-500 mt-1">{pkg.subtitle}</p>
</div>
<motion.button
className="p-2 rounded-full bg-gray-50"
whileTap={{ scale: 0.9 }}
>
<Share2 size={20} className="text-gray-500" />
</motion.button>
</div>
<div className="flex items-center gap-2 mt-3">
<div className="flex items-center gap-1">
<Star size={14} className="text-yellow-400 fill-yellow-400" />
<span className="text-sm text-gray-600">{pkg.rating}</span>
</div>
<span className="text-sm text-gray-400">| {pkg.sales}</span>
</div>
<div className="flex items-baseline gap-2 mt-3">
<span className="text-2xl font-bold text-primary-500">
¥{pkg.price.toLocaleString()}
</span>
<span className="text-sm text-gray-400 line-through">
¥{pkg.originalPrice.toLocaleString()}
</span>
<span className="px-2 py-0.5 bg-red-50 text-red-500 text-xs rounded">
¥{(pkg.originalPrice - pkg.price).toLocaleString()}
</span>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 mt-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="space-y-2">
{pkg.features.map((feature, index) => (
<div key={index} className="flex items-center gap-2">
<Check size={16} className="text-primary-500" />
<span className="text-sm text-gray-600">{feature}</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 mt-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="space-y-3">
{renovationSchemes.map((scheme) => (
<motion.div
key={scheme.id}
className="flex gap-3 p-3 bg-gray-50 rounded-xl"
whileTap={{ scale: 0.98 }}
>
<img
src={scheme.image}
alt={scheme.title}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-1">
<h4 className="font-medium text-gray-800">{scheme.title}</h4>
<p className="text-xs text-gray-500 mt-1">{scheme.description}</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-primary-500 font-medium">
¥{scheme.price.toLocaleString()}
</span>
<span className="text-xs text-gray-400">{scheme.duration}</span>
</div>
</div>
</motion.div>
))}
</div>
</div>
<div className="bg-primary-50 rounded-xl p-4 mt-4">
<h4 className="font-medium text-primary-700 mb-2"></h4>
<ul className="text-sm text-primary-600 space-y-1">
<li>· 30退</li>
<li>· </li>
<li>· </li>
<li>· </li>
</ul>
</div>
</div>
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-4 safe-bottom">
<div className="flex gap-3 max-w-lg mx-auto">
<motion.button
className="flex-1 h-12 rounded-xl border border-primary-500 text-primary-500 font-medium"
whileTap={{ scale: 0.98 }}
>
</motion.button>
<motion.button
className="flex-1 h-12 rounded-xl bg-primary-500 text-white font-medium"
whileTap={{ scale: 0.98 }}
>
</motion.button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,71 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, Check } from 'lucide-react'
import { balconyDesigns } from '@/mock/homeData'
export default function BalconyPage() {
const navigate = useNavigate()
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800"></h1>
<div className="w-6" />
</div>
<div className="p-4 space-y-4">
{balconyDesigns.map((design) => (
<motion.div
key={design.id}
className="bg-white rounded-xl shadow-sm overflow-hidden"
whileTap={{ scale: 0.98 }}
>
<img
src={design.image}
alt={design.title}
className="w-full h-40 object-cover"
/>
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-gray-800">{design.title}</h3>
<span className="text-primary-500 font-medium">¥{design.price}</span>
</div>
<p className="text-sm text-gray-500 mb-3">{design.desc}</p>
<div className="flex flex-wrap gap-2">
{design.features.map((feature, index) => (
<span
key={index}
className="px-2 py-1 bg-gray-50 rounded-md text-xs text-gray-600"
>
{feature}
</span>
))}
</div>
</div>
</motion.div>
))}
<div className="bg-white rounded-xl shadow-sm p-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<ul className="space-y-2">
{[
'合理规划功能区域',
'选择防水防晒材料',
'注意承重安全',
'考虑采光通风',
'绿植搭配技巧',
].map((item, index) => (
<li key={index} className="flex items-center gap-2 text-sm text-gray-600">
<Check size={16} className="text-primary-500" />
{item}
</li>
))}
</ul>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,139 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, Star, MessageCircle, Phone, MapPin } from 'lucide-react'
import { designers } from '@/mock/homeData'
export default function DesignerPage() {
const navigate = useNavigate()
const designer = designers[0]
const works = [
{ id: 1, image: 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=400' },
{ id: 2, image: 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=400' },
{ id: 3, image: 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400' },
{ id: 4, image: 'https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=400' },
]
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white">
<div className="px-4 py-3 flex items-center">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} />
</button>
<h1 className="flex-1 text-center font-bold"></h1>
<div className="w-6" />
</div>
<div className="px-4 pb-6 pt-4">
<div className="flex items-center gap-4">
<img
src={designer.avatar}
alt={designer.name}
className="w-20 h-20 rounded-full object-cover border-2 border-white"
/>
<div className="flex-1">
<h2 className="text-xl font-bold">{designer.name}</h2>
<p className="text-primary-100">{designer.title}</p>
<div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-1">
<Star size={14} className="fill-yellow-400 text-yellow-400" />
<span className="text-sm">{designer.rating}</span>
</div>
<span className="text-primary-200">|</span>
<span className="text-sm">{designer.projects}</span>
</div>
</div>
</div>
</div>
</div>
<div className="px-4 -mt-4">
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-lg font-bold text-gray-800">{designer.experience}</p>
<p className="text-xs text-gray-500"></p>
</div>
<div>
<p className="text-lg font-bold text-gray-800">{designer.style}</p>
<p className="text-xs text-gray-500"></p>
</div>
<div>
<p className="text-lg font-bold text-gray-800">98%</p>
<p className="text-xs text-gray-500"></p>
</div>
</div>
</div>
</div>
<div className="px-4 mt-4">
<div className="bg-white rounded-xl shadow-sm p-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<p className="text-sm text-gray-600 leading-relaxed">
10
Elle Decoration
</p>
</div>
</div>
<div className="px-4 mt-4">
<div className="bg-white rounded-xl shadow-sm p-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="grid grid-cols-2 gap-2">
{works.map((work) => (
<motion.div
key={work.id}
className="aspect-square rounded-lg overflow-hidden"
whileTap={{ scale: 0.98 }}
>
<img
src={work.image}
alt={`作品${work.id}`}
className="w-full h-full object-cover"
/>
</motion.div>
))}
</div>
</div>
</div>
<div className="px-4 mt-4 pb-20">
<div className="bg-white rounded-xl shadow-sm p-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="flex items-center gap-2 text-gray-600">
<MapPin size={16} className="text-primary-500" />
<span className="text-sm">广</span>
</div>
</div>
</div>
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-4 safe-bottom">
<div className="flex gap-3 max-w-lg mx-auto">
<motion.button
className="flex-1 flex items-center justify-center gap-2 h-12 rounded-xl border border-gray-200 text-gray-600"
whileTap={{ scale: 0.98 }}
>
<MessageCircle size={20} />
</motion.button>
<motion.button
className="flex-1 flex items-center justify-center gap-2 h-12 rounded-xl border border-gray-200 text-gray-600"
whileTap={{ scale: 0.98 }}
>
<Phone size={20} />
</motion.button>
<motion.button
className="flex-1 h-12 rounded-xl bg-primary-500 text-white font-medium"
whileTap={{ scale: 0.98 }}
onClick={() => navigate('/user/home2')}
>
</motion.button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,313 @@
import AIChatModal from '@/components/AIChatModal'
import NotificationModal from '@/components/NotificationModal'
import { featuredProjects, groupPackages, homeBanners, quickActions } from '@/mock/homeData'
import { AnimatePresence, motion } from 'framer-motion'
import {
Bell,
Calculator,
Calendar,
ChevronRight,
Palette,
Sparkles,
Truck
} from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
function useHorizontalScroll() {
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = scrollRef.current
if (!el) return
let isDown = false
let startX = 0
let scrollLeft = 0
const handleMouseDown = (e: MouseEvent) => {
isDown = true
el.style.cursor = 'grabbing'
startX = e.pageX - el.offsetLeft
scrollLeft = el.scrollLeft
}
const handleMouseLeave = () => {
isDown = false
el.style.cursor = 'grab'
}
const handleMouseUp = () => {
isDown = false
el.style.cursor = 'grab'
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDown) return
e.preventDefault()
const x = e.pageX - el.offsetLeft
const walk = (x - startX) * 1.5
el.scrollLeft = scrollLeft - walk
}
const handleWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault()
el.scrollLeft += e.deltaY
}
}
el.style.cursor = 'grab'
el.addEventListener('mousedown', handleMouseDown)
el.addEventListener('mouseleave', handleMouseLeave)
el.addEventListener('mouseup', handleMouseUp)
el.addEventListener('mousemove', handleMouseMove)
el.addEventListener('wheel', handleWheel, { passive: false })
return () => {
el.removeEventListener('mousedown', handleMouseDown)
el.removeEventListener('mouseleave', handleMouseLeave)
el.removeEventListener('mouseup', handleMouseUp)
el.removeEventListener('mousemove', handleMouseMove)
el.removeEventListener('wheel', handleWheel)
}
}, [])
return scrollRef
}
export default function HomePage() {
const navigate = useNavigate()
const [currentBanner, setCurrentBanner] = useState(0)
const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 })
const [showNotifications, setShowNotifications] = useState(false)
const [showAIChat, setShowAIChat] = useState(false)
const scrollRef = useHorizontalScroll()
useEffect(() => {
const timer = setInterval(() => {
setCurrentBanner((prev) => (prev + 1) % homeBanners.length)
}, 4000)
return () => clearInterval(timer)
}, [])
useEffect(() => {
const endTime = new Date()
endTime.setDate(endTime.getDate() + 3)
endTime.setHours(23, 59, 59, 999)
const updateCountdown = () => {
const now = new Date()
const diff = endTime.getTime() - now.getTime()
if (diff <= 0) {
setCountdown({ days: 0, hours: 0, minutes: 0, seconds: 0 })
return
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
setCountdown({ days, hours, minutes, seconds })
}
updateCountdown()
const timer = setInterval(updateCountdown, 1000)
return () => clearInterval(timer)
}, [])
const getActionIcon = (icon: string) => {
const icons: Record<string, React.ReactNode> = {
calculator: <Calculator size={24} />,
palette: <Palette size={24} />,
truck: <Truck size={24} />,
calendar: <Calendar size={24} />,
}
return icons[icon] || <Calculator size={24} />
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white px-4 pt-12 pb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="text-primary-100 text-sm mt-1"></p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowNotifications(true)}
className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center relative"
>
<Bell size={20} />
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs flex items-center justify-center font-bold">2</span>
</button>
<button
onClick={() => setShowAIChat(true)}
className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center"
>
<Sparkles size={20} />
</button>
</div>
</div>
<div className="relative h-40 rounded-xl overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={currentBanner}
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.5 }}
className="absolute inset-0"
>
<img
src={homeBanners[currentBanner].image}
alt={homeBanners[currentBanner].title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<div className="absolute bottom-4 left-4 text-white">
<h3 className="font-bold text-lg">{homeBanners[currentBanner].title}</h3>
<p className="text-sm opacity-90">{homeBanners[currentBanner].subtitle}</p>
</div>
</motion.div>
</AnimatePresence>
<div className="absolute bottom-2 right-4 flex gap-1">
{homeBanners.map((_, index) => (
<div
key={index}
className={`w-2 h-2 rounded-full transition-all ${index === currentBanner ? 'bg-white w-4' : 'bg-white/50'
}`}
/>
))}
</div>
</div>
</div>
<div className="px-4 -mt-4">
<div className="rounded-xl shadow-sm p-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="grid grid-cols-4 gap-4">
{quickActions.map((action) => (
<motion.button
key={action.id}
onClick={() => navigate(action.link)}
className="flex flex-col items-center"
whileTap={{ scale: 0.95 }}
>
<div className="w-12 h-12 rounded-xl bg-primary-50 text-primary-500 flex items-center justify-center mb-2">
{getActionIcon(action.icon)}
</div>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{action.title}</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{action.desc}</span>
</motion.button>
))}
</div>
</div>
</div>
<div className="px-4 mt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h2 className="font-bold text-base" style={{ color: 'var(--text-primary)' }}></h2>
<div className="flex items-center gap-1">
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}></span>
<span className="bg-primary-500 text-white text-xs font-bold px-1.5 py-0.5 rounded min-w-[22px] text-center leading-tight">
{String(countdown.days).padStart(2, '0')}
</span>
<span className="text-primary-500 font-bold text-xs"></span>
<span className="bg-primary-500 text-white text-xs font-bold px-1.5 py-0.5 rounded min-w-[22px] text-center leading-tight">
{String(countdown.hours).padStart(2, '0')}
</span>
<span className="text-primary-500 font-bold text-xs"></span>
<span className="bg-primary-500 text-white text-xs font-bold px-1.5 py-0.5 rounded min-w-[22px] text-center leading-tight">
{String(countdown.minutes).padStart(2, '0')}
</span>
<span className="text-primary-500 font-bold text-xs"></span>
<span className="bg-primary-500 text-white text-xs font-bold px-1.5 py-0.5 rounded min-w-[22px] text-center leading-tight">
{String(countdown.seconds).padStart(2, '0')}
</span>
<span className="text-primary-500 font-bold text-xs"></span>
</div>
</div>
<button
onClick={() => navigate('/user/group')}
className="text-primary-500 text-sm flex items-center font-medium"
>
<ChevronRight size={16} />
</button>
</div>
<div ref={scrollRef} className="flex gap-3 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide select-none">
{groupPackages.map((pkg) => (
<motion.div
key={pkg.id}
onClick={() => navigate('/user/group')}
className="flex-shrink-0 w-36 rounded-xl overflow-hidden shadow-sm"
style={{ backgroundColor: 'var(--bg-primary)' }}
whileTap={{ scale: 0.98 }}
>
<img
src={pkg.image}
alt={pkg.name}
className="w-full h-24 object-cover"
/>
<div className="p-3">
<h3 className="font-medium text-sm truncate" style={{ color: 'var(--text-primary)' }}>{pkg.name}</h3>
<div className="flex items-center gap-1 mt-1">
<span className="text-primary-500 font-bold text-sm">¥{pkg.groupPrice}</span>
<span className="text-xs line-through" style={{ color: 'var(--text-tertiary)' }}>¥{pkg.originalPrice}</span>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-primary-50 text-primary-500">{pkg.tag}</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{pkg.sold}</span>
</div>
</div>
</motion.div>
))}
</div>
</div>
<div className="px-4 mt-4">
<div className="flex items-center justify-between mb-3">
<h2 className="font-bold" style={{ color: 'var(--text-primary)' }}></h2>
<button className="text-primary-500 text-sm flex items-center">
<ChevronRight size={16} />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{featuredProjects.map((project) => (
<motion.div
key={project.id}
className="rounded-xl overflow-hidden shadow-sm"
style={{ backgroundColor: 'var(--bg-primary)' }}
whileTap={{ scale: 0.98 }}
>
<img
src={project.image}
alt={project.title}
className="w-full h-28 object-cover"
/>
<div className="p-3">
<h3 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>{project.title}</h3>
<div className="flex items-center gap-2 mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
<span>{project.area}</span>
<span>|</span>
<span>{project.style}</span>
</div>
<p className="text-primary-500 text-xs mt-1">{project.budget}</p>
</div>
</motion.div>
))}
</div>
</div>
<NotificationModal isOpen={showNotifications} onClose={() => setShowNotifications(false)} />
<AIChatModal isOpen={showAIChat} onClose={() => setShowAIChat(false)} />
</div>
)
}

View File

@ -0,0 +1,153 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, ChevronRight, Check } from 'lucide-react'
const timeSlots = [
{ id: 1, time: '09:00-11:00', available: true },
{ id: 2, time: '11:00-13:00', available: true },
{ id: 3, time: '14:00-16:00', available: false },
{ id: 4, time: '16:00-18:00', available: true },
]
const dates = Array.from({ length: 7 }, (_, i) => {
const date = new Date()
date.setDate(date.getDate() + i + 1)
return {
id: i + 1,
day: date.getDate(),
week: ['日', '一', '二', '三', '四', '五', '六'][date.getDay()],
fullDate: date.toLocaleDateString('zh-CN'),
}
})
export default function HomePage2() {
const navigate = useNavigate()
const [selectedDate, setSelectedDate] = useState(1)
const [selectedTime, setSelectedTime] = useState<number | null>(null)
const [address, setAddress] = useState('')
const [phone, setPhone] = useState('')
const [remark, setRemark] = useState('')
const handleSubmit = () => {
if (!selectedTime || !address || !phone) {
alert('请填写完整信息')
return
}
alert('预约成功!我们将尽快与您联系确认。')
navigate('/user/home')
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800"></h1>
<div className="w-6" />
</div>
<div className="p-4">
<div className="bg-white rounded-xl p-4 shadow-sm mb-4">
<h3 className="font-medium text-gray-800 mb-3"></h3>
<div className="flex gap-2 overflow-x-auto pb-2">
{dates.map((date) => (
<motion.button
key={date.id}
onClick={() => setSelectedDate(date.id)}
className={`flex-shrink-0 w-14 h-16 rounded-xl flex flex-col items-center justify-center ${
selectedDate === date.id
? 'bg-primary-500 text-white'
: 'bg-gray-50 text-gray-600'
}`}
whileTap={{ scale: 0.95 }}
>
<span className="text-lg font-bold">{date.day}</span>
<span className="text-xs">{date.week}</span>
</motion.button>
))}
</div>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm mb-4">
<h3 className="font-medium text-gray-800 mb-3"></h3>
<div className="grid grid-cols-2 gap-3">
{timeSlots.map((slot) => (
<motion.button
key={slot.id}
onClick={() => slot.available && setSelectedTime(slot.id)}
disabled={!slot.available}
className={`h-12 rounded-xl flex items-center justify-center gap-2 ${
!slot.available
? 'bg-gray-100 text-gray-400'
: selectedTime === slot.id
? 'bg-primary-500 text-white'
: 'bg-gray-50 text-gray-600 border border-gray-200'
}`}
whileTap={slot.available ? { scale: 0.95 } : {}}
>
{slot.time}
{selectedTime === slot.id && <Check size={16} />}
</motion.button>
))}
</div>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm mb-4">
<h3 className="font-medium text-gray-800 mb-3"></h3>
<div className="space-y-3">
<div>
<label className="text-sm text-gray-500 mb-1 block"></label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="请输入详细地址"
className="input-field"
/>
</div>
<div>
<label className="text-sm text-gray-500 mb-1 block"></label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="请输入联系电话"
className="input-field"
/>
</div>
<div>
<label className="text-sm text-gray-500 mb-1 block"></label>
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="请输入备注(选填)"
rows={3}
className="input-field resize-none"
/>
</div>
</div>
</div>
<div className="bg-primary-50 rounded-xl p-4 mb-4">
<h4 className="font-medium text-primary-700 mb-2"></h4>
<ul className="text-sm text-primary-600 space-y-1">
<li>· </li>
<li>· </li>
<li>· 3</li>
<li>· 1-2</li>
</ul>
</div>
<motion.button
onClick={handleSubmit}
className="w-full btn-primary h-12 text-lg"
whileTap={{ scale: 0.98 }}
>
</motion.button>
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, FileText, FileSignature, CheckCircle } from 'lucide-react'
import { dataAssets } from '@/mock/mineData'
export default function DataAssetsPage() {
const navigate = useNavigate()
const getIcon = (icon: string) => {
const icons: Record<string, React.ReactNode> = {
'palette': <FileText size={24} />,
'file-text': <FileText size={24} />,
'file-signature': <FileSignature size={24} />,
'check-circle': <CheckCircle size={24} />,
}
return icons[icon] || <FileText size={24} />
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800"></h1>
<div className="w-6" />
</div>
<div className="p-4 space-y-4">
{dataAssets.map((asset) => (
<motion.div
key={asset.id}
className="bg-white rounded-xl shadow-sm p-4"
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-primary-50 flex items-center justify-center text-primary-500">
{getIcon(asset.icon)}
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-800">{asset.title}</h3>
<p className="text-sm text-gray-500">{asset.count} </p>
</div>
<div className="text-right">
<span className="text-2xl font-bold text-primary-500">{asset.count}</span>
</div>
</div>
</motion.div>
))}
<div className="bg-primary-50 rounded-xl p-4">
<h4 className="font-medium text-primary-700 mb-2"></h4>
<ul className="text-sm text-primary-600 space-y-1">
<li>· </li>
<li>· </li>
<li>· </li>
</ul>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,102 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, Eye, Edit, Share2 } from 'lucide-react'
import { designSchemes } from '@/mock/mineData'
export default function DesignSchemesPage() {
const navigate = useNavigate()
const getStatusColor = (status: string) => {
switch (status) {
case '已完成':
return 'bg-green-50 text-green-500'
case '进行中':
return 'bg-primary-50 text-primary-500'
case '待确认':
return 'bg-yellow-50 text-yellow-500'
default:
return 'bg-gray-50 text-gray-500'
}
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800"></h1>
<div className="w-6" />
</div>
<div className="p-4 space-y-4">
{designSchemes.map((scheme) => (
<motion.div
key={scheme.id}
className="bg-white rounded-xl shadow-sm overflow-hidden"
whileTap={{ scale: 0.98 }}
>
<img
src={scheme.image}
alt={scheme.title}
className="w-full h-40 object-cover"
/>
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-gray-800">{scheme.title}</h3>
<span className={`text-xs px-2 py-1 rounded-full ${getStatusColor(scheme.status)}`}>
{scheme.status}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-3">
<span>{scheme.designer}</span>
<span>|</span>
<span>{scheme.updateDate}</span>
</div>
<div className="relative pt-1 mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-500"></span>
<span className="text-xs font-medium text-primary-500">{scheme.progress}%</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<motion.div
className="h-full bg-primary-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${scheme.progress}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
</div>
</div>
<div className="flex gap-2">
<motion.button
className="flex-1 flex items-center justify-center gap-1 py-2 rounded-lg border border-gray-200 text-gray-600 text-sm"
whileTap={{ scale: 0.95 }}
>
<Eye size={16} />
</motion.button>
<motion.button
className="flex-1 flex items-center justify-center gap-1 py-2 rounded-lg border border-gray-200 text-gray-600 text-sm"
whileTap={{ scale: 0.95 }}
>
<Edit size={16} />
</motion.button>
<motion.button
className="flex-1 flex items-center justify-center gap-1 py-2 rounded-lg bg-primary-500 text-white text-sm"
whileTap={{ scale: 0.95 }}
>
<Share2 size={16} />
</motion.button>
</div>
</div>
</motion.div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,115 @@
import { dataAssets, menuItems, userInfo } from '@/mock/mineData'
import { motion } from 'framer-motion'
import { ChevronRight, Clock, Heart, HelpCircle, MapPin, Settings, ShoppingBag } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
export default function MinePage() {
const navigate = useNavigate()
const getIcon = (icon: string) => {
const icons: Record<string, React.ReactNode> = {
'clock': <Clock size={20} />,
'shopping-bag': <ShoppingBag size={20} />,
'heart': <Heart size={20} />,
'map-pin': <MapPin size={20} />,
'help-circle': <HelpCircle size={20} />,
'settings': <Settings size={20} />,
}
return icons[icon] || <ShoppingBag size={20} />
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white px-4 pt-12 pb-8">
<div className="flex items-center gap-4">
<img
src={userInfo.avatar}
alt={userInfo.name}
className="w-16 h-16 rounded-full object-cover border-2 border-white"
/>
<div className="flex-1">
<h2 className="text-xl font-bold">{userInfo.name}</h2>
<p className="text-primary-100">{userInfo.phone}</p>
<span className="inline-block mt-1 px-2 py-0.5 bg-white/20 rounded text-xs">
{userInfo.level}
</span>
</div>
<ChevronRight size={24} className="text-white/60" />
</div>
</div>
<div className="px-4 -mt-4">
<div className="rounded-xl shadow-sm p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="grid grid-cols-3 gap-4 text-center">
<motion.div
onClick={() => navigate('/user/mine/data')}
className="cursor-pointer"
whileTap={{ scale: 0.95 }}
>
<p className="text-2xl font-bold text-primary-500">{userInfo.points}</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}></p>
</motion.div>
<motion.div
onClick={() => navigate('/user/mine/designs')}
className="cursor-pointer"
whileTap={{ scale: 0.95 }}
>
<p className="text-2xl font-bold text-primary-500">{userInfo.coupons}</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}></p>
</motion.div>
<motion.div
onClick={() => navigate('/user/mine/partner')}
className="cursor-pointer"
whileTap={{ scale: 0.95 }}
>
<p className="text-2xl font-bold text-primary-500"></p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}></p>
</motion.div>
</div>
</div>
<div className="rounded-xl shadow-sm p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}></h3>
<ChevronRight size={20} style={{ color: 'var(--text-tertiary)' }} />
</div>
<div className="grid grid-cols-4 gap-4">
{dataAssets.map((asset) => (
<motion.div
key={asset.id}
onClick={() => navigate('/user/mine/data')}
className="text-center cursor-pointer"
whileTap={{ scale: 0.95 }}
>
<div className="w-12 h-12 mx-auto rounded-full bg-primary-50 flex items-center justify-center mb-1">
<span className="text-lg">{asset.count}</span>
</div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{asset.title}</p>
</motion.div>
))}
</div>
</div>
<div className="rounded-xl shadow-sm overflow-hidden" style={{ backgroundColor: 'var(--bg-primary)' }}>
{menuItems.map((item, index) => (
<motion.button
key={item.id}
onClick={() => navigate(item.link)}
className={`w-full flex items-center justify-between p-4 ${index !== menuItems.length - 1 ? 'border-b' : ''}`}
style={{ borderColor: 'var(--border-color)' }}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
{getIcon(item.icon)}
</div>
<span style={{ color: 'var(--text-primary)' }}>{item.title}</span>
</div>
<ChevronRight size={20} style={{ color: 'var(--text-tertiary)' }} />
</motion.button>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,116 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeft, Users, TrendingUp, Gift, ChevronRight, Share2 } from 'lucide-react'
import { partnerInfo, partnerProducts } from '@/mock/mineData'
export default function PartnerCenterPage() {
const navigate = useNavigate()
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white px-4 py-3 flex items-center border-b border-gray-100">
<button onClick={() => navigate(-1)} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<h1 className="flex-1 text-center font-bold text-gray-800"></h1>
<div className="w-6" />
</div>
<div className="p-4">
<div className="bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl p-4 text-white mb-4">
<div className="flex items-center gap-2 mb-4">
<Users size={20} />
<span className="font-medium">{partnerInfo.level}</span>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-2xl font-bold">¥{partnerInfo.commission.toLocaleString()}</p>
<p className="text-xs text-primary-100"></p>
</div>
<div>
<p className="text-2xl font-bold">{partnerInfo.orders}</p>
<p className="text-xs text-primary-100">广</p>
</div>
<div>
<p className="text-2xl font-bold">{partnerInfo.teamSize}</p>
<p className="text-xs text-primary-100"></p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 mb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-800"></h3>
<span className="text-sm text-gray-500">
¥{partnerInfo.monthlyProgress.toLocaleString()} / ¥{partnerInfo.monthlyTarget.toLocaleString()}
</span>
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-primary-400 to-primary-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${(partnerInfo.monthlyProgress / partnerInfo.monthlyTarget) * 100}%` }}
transition={{ duration: 1, ease: 'easeOut' }}
/>
</div>
<p className="text-xs text-gray-400 mt-2">
¥{(partnerInfo.monthlyTarget - partnerInfo.monthlyProgress).toLocaleString()}
</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 mb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-800">广</h3>
<ChevronRight size={20} className="text-gray-400" />
</div>
<div className="space-y-3">
{partnerProducts.map((product) => (
<motion.div
key={product.id}
className="flex gap-3 p-3 bg-gray-50 rounded-xl"
whileTap={{ scale: 0.98 }}
>
<img
src={product.image}
alt={product.title}
className="w-16 h-16 rounded-lg object-cover"
/>
<div className="flex-1">
<h4 className="font-medium text-gray-800 text-sm">{product.title}</h4>
<p className="text-xs text-gray-500 mt-1"> {product.sales} </p>
<p className="text-sm text-primary-500 font-medium mt-1">
¥{product.commission.toLocaleString()}
</p>
</div>
<motion.button
className="self-center p-2 rounded-full bg-primary-50 text-primary-500"
whileTap={{ scale: 0.9 }}
>
<Share2 size={18} />
</motion.button>
</motion.div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<motion.div
className="bg-white rounded-xl shadow-sm p-4 text-center"
whileTap={{ scale: 0.98 }}
>
<TrendingUp size={24} className="mx-auto text-primary-500 mb-2" />
<p className="text-sm text-gray-600">广</p>
</motion.div>
<motion.div
className="bg-white rounded-xl shadow-sm p-4 text-center"
whileTap={{ scale: 0.98 }}
>
<Gift size={24} className="mx-auto text-primary-500 mb-2" />
<p className="text-sm text-gray-600"></p>
</motion.div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,148 @@
import { useThemeStore, ThemeMode } from '@/store/themeStore'
import { motion } from 'framer-motion'
import { Check, Moon, Sun, Waves } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
interface ThemeOption {
mode: ThemeMode
label: string
description: string
icon: React.ReactNode
previewBg: string
previewGradient: string
}
const themeOptions: ThemeOption[] = [
{
mode: 'light',
label: '亮色模式',
description: '明亮清新的界面风格',
icon: <Sun size={24} />,
previewBg: 'bg-white',
previewGradient: 'from-orange-400 to-orange-500',
},
{
mode: 'dark',
label: '暗色模式',
description: '护眼舒适的深色界面',
icon: <Moon size={24} />,
previewBg: 'bg-gray-800',
previewGradient: 'from-orange-400 to-orange-500',
},
{
mode: 'deepBlue',
label: '深蓝模式',
description: '沉稳专业的蓝色主题',
icon: <Waves size={24} />,
previewBg: 'bg-slate-900',
previewGradient: 'from-blue-500 to-blue-600',
},
]
export default function SettingsPage() {
const navigate = useNavigate()
const { theme, setTheme } = useThemeStore()
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="sticky top-0 z-10 backdrop-blur-md" style={{ backgroundColor: 'var(--bg-primary)', borderBottom: '1px solid var(--border-color)' }}>
<div className="flex items-center justify-between px-4 py-3">
<button
onClick={() => navigate(-1)}
className="text-base font-medium"
style={{ color: 'var(--text-primary)' }}
>
</button>
<h1 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
</h1>
<div className="w-12" />
</div>
</div>
<div className="p-4">
<div className="mb-4">
<h2 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
</p>
</div>
<div className="space-y-3">
{themeOptions.map((option) => {
const isActive = theme === option.mode
return (
<motion.button
key={option.mode}
onClick={() => setTheme(option.mode)}
className="w-full rounded-xl overflow-hidden transition-all duration-300"
style={{
backgroundColor: 'var(--bg-primary)',
boxShadow: isActive ? `0 4px 12px var(--shadow-color)` : '0 1px 3px var(--shadow-color)',
border: isActive ? '2px solid var(--gradient-start)' : '2px solid transparent',
}}
whileTap={{ scale: 0.98 }}
>
<div className="p-4">
<div className="flex items-start gap-4">
<div
className={`w-16 h-20 rounded-lg overflow-hidden flex-shrink-0 ${option.previewBg}`}
>
<div className={`h-8 bg-gradient-to-r ${option.previewGradient}`} />
<div className="p-2 space-y-1">
<div className="h-2 rounded opacity-30" style={{ backgroundColor: 'var(--text-primary)' }} />
<div className="h-2 w-3/4 rounded opacity-20" style={{ backgroundColor: 'var(--text-primary)' }} />
</div>
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2 mb-1">
<span
className="p-1.5 rounded-lg"
style={{ backgroundColor: 'var(--bg-tertiary)', color: isActive ? 'var(--gradient-start)' : 'var(--text-secondary)' }}
>
{option.icon}
</span>
<h3 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{option.label}
</h3>
</div>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{option.description}
</p>
</div>
<div className="flex-shrink-0 mt-1">
{isActive && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-6 h-6 rounded-full flex items-center justify-center"
style={{ backgroundColor: 'var(--gradient-start)' }}
>
<Check size={14} className="text-white" />
</motion.div>
)}
</div>
</div>
</div>
</motion.button>
)
})}
</div>
<div className="mt-6 p-4 rounded-xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
<h3 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{themeOptions.find(t => t.mode === theme)?.label} - {themeOptions.find(t => t.mode === theme)?.description}
</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,221 @@
import { progressDetails, progressStages, projectInfo } from '@/mock/progressData'
import { motion } from 'framer-motion'
import { Check, ChevronRight, Clock, MessageCircle, Phone } from 'lucide-react'
import { useState } from 'react'
export default function ProgressPage() {
const [activeStage, setActiveStage] = useState('design')
const currentStageIndex = progressStages.findIndex((s) => s.id === activeStage)
const currentDetail = progressDetails[activeStage as keyof typeof progressDetails]
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-500'
case 'in_progress':
return 'text-primary-500'
default:
return 'text-gray-400'
}
}
const getStatusBg = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-500'
case 'in_progress':
return 'bg-primary-500'
default:
return 'bg-gray-200'
}
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="bg-gradient-to-br from-primary-500 to-primary-600 text-white px-4 pt-12 pb-6">
<h1 className="text-xl font-bold text-center mb-4"></h1>
<div className="bg-white/10 rounded-xl p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-full bg-white/20 flex items-center justify-center text-2xl">
🏠
</div>
<div>
<h3 className="font-medium">{projectInfo.name}</h3>
<p className="text-sm text-primary-100">{projectInfo.area} · {projectInfo.style}</p>
</div>
</div>
<div className="relative pt-1">
<div className="flex items-center justify-between mb-2">
<span className="text-sm"></span>
<span className="text-sm font-medium">{projectInfo.progress}%</span>
</div>
<div className="h-2 bg-white/20 rounded-full overflow-hidden">
<motion.div
className="h-full bg-white rounded-full"
initial={{ width: 0 }}
animate={{ width: `${projectInfo.progress}%` }}
transition={{ duration: 1, ease: 'easeOut' }}
/>
</div>
</div>
</div>
</div>
<div className="px-4 -mt-4">
<div className="rounded-xl shadow-sm p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center gap-3">
<img
src={projectInfo.designer.avatar}
alt={projectInfo.designer.name}
className="w-12 h-12 rounded-full object-cover"
/>
<div className="flex-1">
<h4 className="font-medium" style={{ color: 'var(--text-primary)' }}>{projectInfo.designer.name}</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}></p>
</div>
<div className="flex gap-2">
<motion.button
className="w-10 h-10 rounded-full flex items-center justify-center"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
whileTap={{ scale: 0.9 }}
>
<MessageCircle size={18} style={{ color: 'var(--text-secondary)' }} />
</motion.button>
<motion.button
className="w-10 h-10 rounded-full bg-primary-50 flex items-center justify-center"
whileTap={{ scale: 0.9 }}
>
<Phone size={18} className="text-primary-500" />
</motion.button>
</div>
</div>
</div>
<div className="rounded-xl shadow-sm p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<h3 className="font-bold mb-4" style={{ color: 'var(--text-primary)' }}></h3>
<div className="flex justify-between mb-6">
{progressStages.map((stage, index) => {
const isActive = stage.id === activeStage
const isPast = index < currentStageIndex
return (
<motion.button
key={stage.id}
onClick={() => setActiveStage(stage.id)}
className="flex flex-col items-center"
whileTap={{ scale: 0.95 }}
>
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg mb-1 ${isPast
? 'bg-green-500 text-white'
: isActive
? 'bg-primary-500 text-white'
: ''
}`}
style={(!isPast && !isActive) ? { backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-tertiary)' } : {}}
>
{isPast ? <Check size={18} /> : stage.icon}
</div>
<span
className={`text-xs ${isActive ? 'text-primary-500 font-medium' : ''}`}
style={!isActive ? { color: 'var(--text-secondary)' } : {}}
>
{stage.name}
</span>
</motion.button>
)
})}
</div>
<div className="border-t pt-4" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium" style={{ color: 'var(--text-primary)' }}>{currentDetail.title}</h4>
<span
className={`text-xs px-2 py-1 rounded-full ${currentDetail.status === 'completed'
? 'bg-green-50 text-green-500'
: currentDetail.status === 'in_progress'
? 'bg-primary-50 text-primary-500'
: ''
}`}
style={currentDetail.status === 'pending' ? { backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-tertiary)' } : {}}
>
{currentDetail.status === 'completed'
? '已完成'
: currentDetail.status === 'in_progress'
? '进行中'
: '待开始'}
</span>
</div>
<div className="space-y-3">
{currentDetail.tasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<div className="flex items-center gap-3">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center ${getStatusBg(
task.status
)}`}
>
{task.status === 'completed' ? (
<Check size={14} className="text-white" />
) : task.status === 'in_progress' ? (
<Clock size={14} className="text-white" />
) : (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
<span
className={`text-sm ${getStatusColor(task.status)}`}
>
{task.title}
</span>
</div>
{task.date && (
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{task.date}</span>
)}
</div>
))}
</div>
<div className="mt-4 p-3 bg-primary-50 rounded-lg">
<p className="text-sm text-primary-600">{currentDetail.tips}</p>
</div>
</div>
</div>
<div className="rounded-xl shadow-sm p-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}></h3>
<ChevronRight size={20} style={{ color: 'var(--text-tertiary)' }} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}></p>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{projectInfo.budget}</p>
</div>
<div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}></p>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{projectInfo.startDate}</p>
</div>
<div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}></p>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{projectInfo.expectedEndDate}</p>
</div>
<div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}></p>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{projectInfo.style}</p>
</div>
</div>
</div>
</div>
</div>
)
}

21
src/store/themeStore.ts Normal file
View File

@ -0,0 +1,21 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ThemeMode = 'light' | 'dark' | 'deepBlue'
interface ThemeState {
theme: ThemeMode
setTheme: (theme: ThemeMode) => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'theme-storage',
}
)
)

52
tailwind.config.js Normal file
View File

@ -0,0 +1,52 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
},
secondary: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
},
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'PingFang SC',
'Microsoft YaHei',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
},
},
plugins: [],
}

28
tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

12
vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})