feat: 炫酷的登录页

This commit is contained in:
Blizzard
2026-04-28 16:43:34 +08:00
parent 3cade8e7ef
commit ccb36fa59c
34 changed files with 2390 additions and 253 deletions
+9 -6
View File
@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sundynix-micro-ui</title>
</head>
<body>
<title>Sundynix</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</body>
</html>
+156
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
@@ -26,9 +27,12 @@
"axios": "^1.15.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.11.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-parallax-tilt": "^1.7.324",
"react-router-dom": "^7.14.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
@@ -925,6 +929,90 @@
}
}
},
"node_modules/@radix-ui/react-context-menu": {
"version": "2.2.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
@@ -3615,6 +3703,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -4127,6 +4231,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4782,6 +4913,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4987,6 +5133,16 @@
"react": "^19.2.5"
}
},
"node_modules/react-parallax-tilt": {
"version": "1.7.324",
"resolved": "https://registry.npmjs.org/react-parallax-tilt/-/react-parallax-tilt-1.7.324.tgz",
"integrity": "sha512-iOmPMoObfRwozzf6Dfpp2k5irtjNhng8jgRX9RZFz40bTeuQT5ZRZW7o0u8vhtRqNvWwlEBkl9++qzoW+p6eFg==",
"license": "MIT",
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+4
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
@@ -28,9 +29,12 @@
"axios": "^1.15.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.11.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-parallax-tilt": "^1.7.324",
"react-router-dom": "^7.14.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 MiB

+19
View File
@@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { useAppStore } from '@/store/app'
import AdminLayout from '@/layouts/AdminLayout'
import LoginPage from '@/pages/LoginPage'
import ErrorBoundary from '@/components/ErrorBoundary'
@@ -77,9 +78,27 @@ function AppRoutes() {
}
export default function App() {
const themeHue = useAppStore(s => s.themeHue)
useEffect(() => {
document.documentElement.style.setProperty('--theme-hue', themeHue)
if (themeHue === '45') {
document.documentElement.style.setProperty('--theme-l', '0.65')
document.documentElement.style.setProperty('--theme-c', '0.18')
document.documentElement.style.setProperty('--theme-l-dark', '0.70')
document.documentElement.style.setProperty('--theme-c-dark', '0.16')
} else {
document.documentElement.style.removeProperty('--theme-l')
document.documentElement.style.removeProperty('--theme-c')
document.documentElement.style.removeProperty('--theme-l-dark')
document.documentElement.style.removeProperty('--theme-c-dark')
}
}, [themeHue])
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
)
}
+73 -41
View File
@@ -1,5 +1,5 @@
import { post, type PageResult, type PageParams } from '@/lib/request'
import { USE_MOCK, delay, paginate, mockId } from '@/mock'
import { USE_MOCK, delay, paginate, mockId, mockResponse } from '@/mock'
import { mockWikis, mockWikiClasses, type PlantWiki, type PlantWikiClass } from '@/mock/plant/wiki'
import { mockBanners, type Banner } from '@/mock/plant/banner'
import { mockTopics, mockPosts, type Topic, type Post } from '@/mock/plant/community'
@@ -7,94 +7,126 @@ import { mockExchangeItems, mockExchangeOrders, type ExchangeItem, type Exchange
export type { PlantWiki, PlantWikiClass, Banner, Topic, Post, ExchangeItem, ExchangeOrder }
type R<T> = { code: number; data: T; msg: string }
// ==================== Wiki ====================
export async function getWikiList(data: PageParams & { classId?: string }) {
if (USE_MOCK) { await delay(); let f = [...mockWikis]; if (data.classId) f = f.filter(w => w.classId === data.classId); if (data.keyword) f = f.filter(w => w.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
return post<{ data: PageResult<PlantWiki> }>('/plant/wiki/list', data)
if (USE_MOCK) {
await delay(); let f = [...mockWikis]
if (data.classId) f = f.filter(w => w.classId === data.classId)
if (data.keyword) f = f.filter(w => w.title.includes(data.keyword!))
return mockResponse(paginate(f, data.current, data.pageSize))
}
return post<R<PageResult<PlantWiki>>>('/plant/wiki/list', data)
}
export async function saveWiki(data: Partial<PlantWiki>) {
if (USE_MOCK) { await delay(); mockWikis.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PlantWiki); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/wiki/save', data)
if (USE_MOCK) {
await delay()
mockWikis.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PlantWiki)
return mockResponse(null)
}
return post<R<null>>('/plant/wiki/save', data)
}
export async function updateWiki(data: Partial<PlantWiki>) {
if (USE_MOCK) { await delay(); const i = mockWikis.findIndex(w => w.id === data.id); if (i >= 0) Object.assign(mockWikis[i], data); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/wiki/update', data)
if (USE_MOCK) { await delay(); const i = mockWikis.findIndex(w => w.id === data.id); if (i >= 0) Object.assign(mockWikis[i], data); return mockResponse(null) }
return post<R<null>>('/plant/wiki/update', data)
}
export async function deleteWiki(id: string) {
if (USE_MOCK) { await delay(); const i = mockWikis.findIndex(w => w.id === id); if (i >= 0) mockWikis.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/wiki/delete', { id })
if (USE_MOCK) { await delay(); const i = mockWikis.findIndex(w => w.id === id); if (i >= 0) mockWikis.splice(i, 1); return mockResponse(null) }
return post<R<null>>('/plant/wiki/delete', { id })
}
// ==================== Wiki Class ====================
export async function getWikiClassList() {
if (USE_MOCK) { await delay(); return { data: mockWikiClasses } }
return post<{ data: PlantWikiClass[] }>('/plant/wikiClass/list', {})
if (USE_MOCK) { await delay(); return mockResponse(mockWikiClasses) }
return post<R<PlantWikiClass[]>>('/plant/wikiClass/list', {})
}
export async function saveWikiClass(data: Partial<PlantWikiClass>) {
if (USE_MOCK) { await delay(); mockWikiClasses.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PlantWikiClass); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/wikiClass/save', data)
if (USE_MOCK) {
await delay()
mockWikiClasses.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PlantWikiClass)
return mockResponse(null)
}
return post<R<null>>('/plant/wikiClass/save', data)
}
export async function deleteWikiClass(id: string) {
if (USE_MOCK) { await delay(); const i = mockWikiClasses.findIndex(c => c.id === id); if (i >= 0) mockWikiClasses.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/wikiClass/delete', { id })
if (USE_MOCK) { await delay(); const i = mockWikiClasses.findIndex(c => c.id === id); if (i >= 0) mockWikiClasses.splice(i, 1); return mockResponse(null) }
return post<R<null>>('/plant/wikiClass/delete', { id })
}
// ==================== Banner ====================
export async function getBannerList(data: PageParams & { title?: string; isActive?: number }) {
if (USE_MOCK) { await delay(); let f = [...mockBanners]; if (data.keyword) f = f.filter(b => b.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
return post<{ data: PageResult<Banner> }>('/plant/banner/list', data)
if (USE_MOCK) {
await delay(); let f = [...mockBanners]
if (data.keyword) f = f.filter(b => b.title.includes(data.keyword!))
return mockResponse(paginate(f, data.current, data.pageSize))
}
return post<R<PageResult<Banner>>>('/plant/banner/list', data)
}
export async function saveBanner(data: Partial<Banner>) {
if (USE_MOCK) { await delay(); mockBanners.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as Banner); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/banner/save', data)
if (USE_MOCK) { await delay(); mockBanners.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as Banner); return mockResponse(null) }
return post<R<null>>('/plant/banner/save', data)
}
export async function updateBanner(data: Partial<Banner>) {
if (USE_MOCK) { await delay(); const i = mockBanners.findIndex(b => b.id === data.id); if (i >= 0) Object.assign(mockBanners[i], data); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/banner/update', data)
if (USE_MOCK) { await delay(); const i = mockBanners.findIndex(b => b.id === data.id); if (i >= 0) Object.assign(mockBanners[i], data); return mockResponse(null) }
return post<R<null>>('/plant/banner/update', data)
}
export async function deleteBanner(id: string) {
if (USE_MOCK) { await delay(); const i = mockBanners.findIndex(b => b.id === id); if (i >= 0) mockBanners.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/banner/delete', { id })
if (USE_MOCK) { await delay(); const i = mockBanners.findIndex(b => b.id === id); if (i >= 0) mockBanners.splice(i, 1); return mockResponse(null) }
return post<R<null>>('/plant/banner/delete', { id })
}
// ==================== Topic ====================
export async function getTopicList(data: PageParams) {
if (USE_MOCK) { await delay(); let f = [...mockTopics]; if (data.keyword) f = f.filter(t => t.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
return post<{ data: PageResult<Topic> }>('/plant/topic/list', data)
if (USE_MOCK) {
await delay(); let f = [...mockTopics]
if (data.keyword) f = f.filter(t => t.title.includes(data.keyword!))
return mockResponse(paginate(f, data.current, data.pageSize))
}
return post<R<PageResult<Topic>>>('/plant/topic/list', data)
}
export async function saveTopic(data: Partial<Topic>) {
if (USE_MOCK) { await delay(); mockTopics.push({ ...data, id: mockId(), postCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as Topic); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/topic/save', data)
if (USE_MOCK) { await delay(); mockTopics.push({ ...data, id: mockId(), postCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as Topic); return mockResponse(null) }
return post<R<null>>('/plant/topic/save', data)
}
export async function deleteTopic(id: string) {
if (USE_MOCK) { await delay(); const i = mockTopics.findIndex(t => t.id === id); if (i >= 0) mockTopics.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/topic/delete', { id })
if (USE_MOCK) { await delay(); const i = mockTopics.findIndex(t => t.id === id); if (i >= 0) mockTopics.splice(i, 1); return mockResponse(null) }
return post<R<null>>('/plant/topic/delete', { id })
}
// ==================== Post ====================
export async function getPostList(data: PageParams & { topicId?: string }) {
if (USE_MOCK) { await delay(); let f = [...mockPosts]; if (data.topicId) f = f.filter(p => p.topicId === data.topicId); if (data.keyword) f = f.filter(p => p.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
return post<{ data: PageResult<Post> }>('/plant/post/list', data)
if (USE_MOCK) {
await delay(); let f = [...mockPosts]
if (data.topicId) f = f.filter(p => p.topicId === data.topicId)
if (data.keyword) f = f.filter(p => p.title.includes(data.keyword!))
return mockResponse(paginate(f, data.current, data.pageSize))
}
return post<R<PageResult<Post>>>('/plant/post/list', data)
}
export async function deletePost(id: string) {
if (USE_MOCK) { await delay(); const i = mockPosts.findIndex(p => p.id === id); if (i >= 0) mockPosts.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/post/delete', { id })
if (USE_MOCK) { await delay(); const i = mockPosts.findIndex(p => p.id === id); if (i >= 0) mockPosts.splice(i, 1); return mockResponse(null) }
return post<R<null>>('/plant/post/delete', { id })
}
// ==================== Exchange ====================
export async function getExchangeItemList(data: PageParams) {
if (USE_MOCK) { await delay(); let f = [...mockExchangeItems]; if (data.keyword) f = f.filter(e => e.name.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
return post<{ data: PageResult<ExchangeItem> }>('/plant/exchange/itemList', data)
if (USE_MOCK) {
await delay(); let f = [...mockExchangeItems]
if (data.keyword) f = f.filter(e => e.name.includes(data.keyword!))
return mockResponse(paginate(f, data.current, data.pageSize))
}
return post<R<PageResult<ExchangeItem>>>('/plant/exchange/itemList', data)
}
export async function saveExchangeItem(data: Partial<ExchangeItem>) {
if (USE_MOCK) { await delay(); mockExchangeItems.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as ExchangeItem); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/exchange/saveItem', data)
if (USE_MOCK) { await delay(); mockExchangeItems.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as ExchangeItem); return mockResponse(null) }
return post<R<null>>('/plant/exchange/saveItem', data)
}
export async function deleteExchangeItem(id: string) {
if (USE_MOCK) { await delay(); const i = mockExchangeItems.findIndex(e => e.id === id); if (i >= 0) mockExchangeItems.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/plant/exchange/deleteItem', { id })
if (USE_MOCK) { await delay(); const i = mockExchangeItems.findIndex(e => e.id === id); if (i >= 0) mockExchangeItems.splice(i, 1); return mockResponse(null) }
return post<R<null>>('/plant/exchange/deleteItem', { id })
}
export async function getExchangeOrderList(data: PageParams) {
if (USE_MOCK) { await delay(); return { data: paginate([...mockExchangeOrders], data.current, data.pageSize) } }
return post<{ data: PageResult<ExchangeOrder> }>('/plant/exchange/orderList', data)
if (USE_MOCK) { await delay(); return mockResponse(paginate([...mockExchangeOrders], data.current, data.pageSize)) }
return post<R<PageResult<ExchangeOrder>>>('/plant/exchange/orderList', data)
}
+25 -25
View File
@@ -1,5 +1,5 @@
import { post, type PageResult, type PageParams } from '@/lib/request'
import { USE_MOCK, delay, paginate, mockId } from '@/mock'
import { USE_MOCK, delay, paginate, mockId, mockResponse } from '@/mock'
import { mockRadioCategories, mockRadioChannels, mockRadioPrograms } from '@/mock/radio/channels'
import { mockSubscriptions } from '@/mock/radio/subscriptions'
import type { RadioCategory, RadioChannel, RadioProgram, RadioSubscription } from '@/mock/radio/channels'
@@ -8,56 +8,56 @@ export type { RadioCategory, RadioChannel, RadioProgram, RadioSubscription }
// ==================== Category ====================
export async function getRadioCategoryList() {
if (USE_MOCK) { await delay(); return { data: [...mockRadioCategories] } }
return post<{ data: RadioCategory[] }>('/radio/category/list', {})
if (USE_MOCK) { await delay(); return mockResponse([...mockRadioCategories]) }
return post<{ code: number; data: RadioCategory[]; msg: string }>('/radio/category/list', {})
}
export async function saveRadioCategory(data: Partial<RadioCategory>) {
if (USE_MOCK) { await delay(); mockRadioCategories.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioCategory); return { msg: 'ok' } }
return post<{ msg: string }>('/radio/category/save', data)
if (USE_MOCK) { await delay(); mockRadioCategories.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioCategory); return mockResponse(null) }
return post<{ code: number; data: null; msg: string }>('/radio/category/save', data)
}
export async function deleteRadioCategory(id: string) {
if (USE_MOCK) { await delay(); const i = mockRadioCategories.findIndex(c => c.id === id); if (i >= 0) mockRadioCategories.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/radio/category/delete', { id })
if (USE_MOCK) { await delay(); const i = mockRadioCategories.findIndex(c => c.id === id); if (i >= 0) mockRadioCategories.splice(i, 1); return mockResponse(null) }
return post<{ code: number; data: null; msg: string }>('/radio/category/delete', { id })
}
// ==================== Channel ====================
export async function getRadioChannelList(data: PageParams & { categoryId?: string }) {
if (USE_MOCK) { await delay(); let f = [...mockRadioChannels]; if (data.categoryId) f = f.filter(c => c.categoryId === data.categoryId); if (data.keyword) f = f.filter(c => c.name.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
return post<{ data: PageResult<RadioChannel> }>('/radio/channel/list', data)
if (USE_MOCK) { await delay(); let f = [...mockRadioChannels]; if (data.categoryId) f = f.filter(c => c.categoryId === data.categoryId); if (data.keyword) f = f.filter(c => c.name.includes(data.keyword!)); return mockResponse(paginate(f, data.current, data.pageSize)) }
return post<{ code: number; data: PageResult<RadioChannel>; msg: string }>('/radio/channel/list', data)
}
export async function saveRadioChannel(data: Partial<RadioChannel>) {
if (USE_MOCK) { await delay(); mockRadioChannels.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioChannel); return { msg: 'ok' } }
return post<{ msg: string }>('/radio/channel/save', data)
if (USE_MOCK) { await delay(); mockRadioChannels.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioChannel); return mockResponse(null) }
return post<{ code: number; data: null; msg: string }>('/radio/channel/save', data)
}
export async function updateRadioChannel(data: Partial<RadioChannel>) {
if (USE_MOCK) { await delay(); const i = mockRadioChannels.findIndex(c => c.id === data.id); if (i >= 0) Object.assign(mockRadioChannels[i], data); return { msg: 'ok' } }
return post<{ msg: string }>('/radio/channel/update', data)
if (USE_MOCK) { await delay(); const i = mockRadioChannels.findIndex(c => c.id === data.id); if (i >= 0) Object.assign(mockRadioChannels[i], data); return mockResponse(null) }
return post<{ code: number; data: null; msg: string }>('/radio/channel/update', data)
}
export async function deleteRadioChannel(id: string) {
if (USE_MOCK) { await delay(); const i = mockRadioChannels.findIndex(c => c.id === id); if (i >= 0) mockRadioChannels.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/radio/channel/delete', { id })
if (USE_MOCK) { await delay(); const i = mockRadioChannels.findIndex(c => c.id === id); if (i >= 0) mockRadioChannels.splice(i, 1); return mockResponse(null) }
return post<{ code: number; data: null; msg: string }>('/radio/channel/delete', { id })
}
// ==================== Program ====================
export async function getRadioProgramList(data: PageParams & { channelId?: string }) {
if (USE_MOCK) { await delay(); let f = [...mockRadioPrograms]; if (data.channelId) f = f.filter(p => p.channelId === data.channelId); if (data.keyword) f = f.filter(p => p.title.includes(data.keyword!)); return { data: paginate(f, data.current, data.pageSize) } }
return post<{ data: PageResult<RadioProgram> }>('/radio/program/list', data)
if (USE_MOCK) { await delay(); let f = [...mockRadioPrograms]; if (data.channelId) f = f.filter(p => p.channelId === data.channelId); if (data.keyword) f = f.filter(p => p.title.includes(data.keyword!)); return mockResponse(paginate(f, data.current, data.pageSize)) }
return post<{ code: number; data: PageResult<RadioProgram>; msg: string }>('/radio/program/list', data)
}
export async function saveRadioProgram(data: Partial<RadioProgram>) {
if (USE_MOCK) { await delay(); mockRadioPrograms.push({ ...data, id: mockId(), audioStatus: 0, duration: 0, playCount: 0, likeCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioProgram); return { msg: 'ok' } }
return post<{ msg: string }>('/radio/program/save', data)
if (USE_MOCK) { await delay(); mockRadioPrograms.push({ ...data, id: mockId(), audioStatus: 0, duration: 0, playCount: 0, likeCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as RadioProgram); return mockResponse(null) }
return post<{ code: number; data: null; msg: string }>('/radio/program/save', data)
}
export async function updateRadioProgram(data: Partial<RadioProgram>) {
if (USE_MOCK) { await delay(); const i = mockRadioPrograms.findIndex(p => p.id === data.id); if (i >= 0) Object.assign(mockRadioPrograms[i], data); return { msg: 'ok' } }
return post<{ msg: string }>('/radio/program/update', data)
if (USE_MOCK) { await delay(); const i = mockRadioPrograms.findIndex(p => p.id === data.id); if (i >= 0) Object.assign(mockRadioPrograms[i], data); return mockResponse(null) }
return post<{ code: number; data: null; msg: string }>('/radio/program/update', data)
}
export async function deleteRadioProgram(id: string) {
if (USE_MOCK) { await delay(); const i = mockRadioPrograms.findIndex(p => p.id === id); if (i >= 0) mockRadioPrograms.splice(i, 1); return { msg: 'ok' } }
return post<{ msg: string }>('/radio/program/delete', { id })
if (USE_MOCK) { await delay(); const i = mockRadioPrograms.findIndex(p => p.id === id); if (i >= 0) mockRadioPrograms.splice(i, 1); return mockResponse(null) }
return post<{ code: number; data: null; msg: string }>('/radio/program/delete', { id })
}
// ==================== Subscription ====================
export async function getSubscriptionList(data: PageParams & { channelId?: string; status?: number }) {
if (USE_MOCK) { await delay(); let f = [...mockSubscriptions]; if (data.channelId) f = f.filter(s => s.channelId === data.channelId); if (data.status !== undefined) f = f.filter(s => s.status === data.status); return { data: paginate(f, data.current, data.pageSize) } }
return post<{ data: PageResult<RadioSubscription> }>('/radio/subscription/list', data)
if (USE_MOCK) { await delay(); let f = [...mockSubscriptions]; if (data.channelId) f = f.filter(s => s.channelId === data.channelId); if (data.status !== undefined) f = f.filter(s => s.status === data.status); return mockResponse(paginate(f, data.current, data.pageSize)) }
return post<{ code: number; data: PageResult<RadioSubscription>; msg: string }>('/radio/subscription/list', data)
}
+7 -7
View File
@@ -1,5 +1,5 @@
import { get, post } from '@/lib/request'
import { USE_MOCK, delay } from '@/mock'
import { USE_MOCK, delay, mockResponse } from '@/mock'
import { mockUsers } from '@/mock/system/users'
// ==================== Types ====================
@@ -51,18 +51,18 @@ export interface LoginResponse { token: string; expiresAt: number; user: SystemU
// ==================== Auth ====================
export async function getCaptcha() {
if (USE_MOCK) { await delay(200); return { data: { captcha: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', captchaId: 'mock-captcha-id' } } }
return get<{ data: CaptchaRes }>('/auth/captcha')
if (USE_MOCK) { await delay(200); return mockResponse({ captcha: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', captchaId: 'mock-captcha-id' }) }
return get<{ code: number; data: CaptchaRes; msg: string }>('/auth/captcha')
}
export async function login(data: LoginParams) {
if (USE_MOCK) { await delay(500); return { data: { token: 'mock-token-xxx', expiresAt: Date.now() + 7200000, user: mockUsers[0] }, msg: '登录成功' } }
return post<{ data: LoginResponse; msg: string }>('/auth/login', data)
if (USE_MOCK) { await delay(500); return mockResponse({ token: 'mock-token-xxx', expiresAt: Date.now() + 7200000, user: mockUsers[0] }, '登录成功') }
return post<{ code: number; data: LoginResponse; msg: string }>('/auth/login', data)
}
export async function logout() {
if (USE_MOCK) { await delay(100); return { msg: '已登出' } }
return get<{ msg: string }>('/auth/logout')
if (USE_MOCK) { await delay(100); return mockResponse(null, '已登出') }
return get<{ code: number; data: null; msg: string }>('/auth/logout')
}
// ==================== 以下在各自文件中实现 ====================
+3 -3
View File
@@ -1,5 +1,5 @@
import { get } from '@/lib/request'
import { USE_MOCK, delay, paginate } from '@/mock'
import { USE_MOCK, delay, paginate, mockResponse } from '@/mock'
import { mockLogs } from '@/mock/system/logs'
import type { OperationLog } from '../system'
@@ -19,7 +19,7 @@ export async function getLogList(params: {
if (params.path) list = list.filter(l => l.path.includes(params.path!))
if (params.status) list = list.filter(l => l.statusCode === params.status)
return { data: paginate(list, params.current, params.pageSize) }
return mockResponse(paginate(list, params.current, params.pageSize))
}
return get<{ data: { list: OperationLog[]; total: number } }>('/system/logs', params)
return get<{ code: number; data: { list: OperationLog[]; total: number }; msg: string }>('/system/logs', params)
}
+9 -12
View File
@@ -1,27 +1,24 @@
import { get, post, put, del } from '@/lib/request'
import { USE_MOCK, delay } from '@/mock'
import { USE_MOCK, delay, mockResponse } from '@/mock'
import { mockMenuTree } from '@/mock/system/menus'
import type { SystemMenu } from '../system'
export async function getMenuTree() {
if (USE_MOCK) {
await delay()
return { data: mockMenuTree }
}
return get<{ data: SystemMenu[] }>('/system/menus/tree')
if (USE_MOCK) { await delay(); return mockResponse(mockMenuTree) }
return get<{ code: number; data: SystemMenu[]; msg: string }>('/system/menus/tree')
}
export async function createMenu(data: Partial<SystemMenu>) {
if (USE_MOCK) { await delay(); return { msg: '创建成功' } }
return post<{ msg: string }>('/system/menus', data)
if (USE_MOCK) { await delay(); return mockResponse(null, '创建成功') }
return post<{ code: number; data: null; msg: string }>('/system/menus', data)
}
export async function updateMenu(id: string, data: Partial<SystemMenu>) {
if (USE_MOCK) { await delay(); return { msg: '更新成功' } }
return put<{ msg: string }>(`/system/menus/${id}`, data)
if (USE_MOCK) { await delay(); return mockResponse(null, '更新成功') }
return put<{ code: number; data: null; msg: string }>(`/system/menus/${id}`, data)
}
export async function deleteMenu(id: string) {
if (USE_MOCK) { await delay(); return { msg: '删除成功' } }
return del<{ msg: string }>(`/system/menus/${id}`)
if (USE_MOCK) { await delay(); return mockResponse(null, '删除成功') }
return del<{ code: number; data: null; msg: string }>(`/system/menus/${id}`)
}
+9 -9
View File
@@ -1,5 +1,5 @@
import { get, post, put, del } from '@/lib/request'
import { USE_MOCK, delay, paginate, mockDate } from '@/mock'
import { USE_MOCK, delay, paginate, mockDate, mockResponse } from '@/mock'
import type { SystemRole } from '../system'
const mockRoles: SystemRole[] = [
@@ -15,22 +15,22 @@ export async function getRoleList(params: { current: number; pageSize: number; n
await delay()
let list = [...mockRoles]
if (params.name) list = list.filter(r => r.name.includes(params.name!))
return { data: paginate(list, params.current, params.pageSize) }
return mockResponse(paginate(list, params.current, params.pageSize))
}
return get<{ data: { list: SystemRole[]; total: number } }>('/system/roles', params)
return get<{ code: number; data: { list: SystemRole[]; total: number }; msg: string }>('/system/roles', params)
}
export async function createRole(data: Partial<SystemRole>) {
if (USE_MOCK) { await delay(); return { msg: '创建成功' } }
return post<{ msg: string }>('/system/roles', data)
if (USE_MOCK) { await delay(); return mockResponse(null, '创建成功') }
return post<{ code: number; data: null; msg: string }>('/system/roles', data)
}
export async function updateRole(id: string, data: Partial<SystemRole>) {
if (USE_MOCK) { await delay(); return { msg: '更新成功' } }
return put<{ msg: string }>(`/system/roles/${id}`, data)
if (USE_MOCK) { await delay(); return mockResponse(null, '更新成功') }
return put<{ code: number; data: null; msg: string }>(`/system/roles/${id}`, data)
}
export async function deleteRole(id: string) {
if (USE_MOCK) { await delay(); return { msg: '删除成功' } }
return del<{ msg: string }>(`/system/roles/${id}`)
if (USE_MOCK) { await delay(); return mockResponse(null, '删除成功') }
return del<{ code: number; data: null; msg: string }>(`/system/roles/${id}`)
}
+9 -9
View File
@@ -1,5 +1,5 @@
import { get, post, put, del } from '@/lib/request'
import { USE_MOCK, delay, paginate } from '@/mock'
import { USE_MOCK, delay, paginate, mockResponse } from '@/mock'
import { mockUsers } from '@/mock/system/users'
import type { SystemUser } from '../system'
@@ -9,22 +9,22 @@ export async function getUserList(params: { current: number; pageSize: number; a
let list = [...mockUsers]
if (params.account) list = list.filter(u => u.account.includes(params.account!))
if (params.name) list = list.filter(u => u.name.includes(params.name!))
return { data: paginate(list, params.current, params.pageSize) }
return mockResponse(paginate(list, params.current, params.pageSize))
}
return get<{ data: { list: SystemUser[]; total: number } }>('/system/users', params)
return get<{ code: number; data: { list: SystemUser[]; total: number }; msg: string }>('/system/users', params)
}
export async function createUser(data: Partial<SystemUser>) {
if (USE_MOCK) { await delay(); return { msg: '创建成功' } }
return post<{ msg: string }>('/system/users', data)
if (USE_MOCK) { await delay(); return mockResponse(null, '创建成功') }
return post<{ code: number; data: null; msg: string }>('/system/users', data)
}
export async function updateUser(id: string, data: Partial<SystemUser>) {
if (USE_MOCK) { await delay(); return { msg: '更新成功' } }
return put<{ msg: string }>(`/system/users/${id}`, data)
if (USE_MOCK) { await delay(); return mockResponse(null, '更新成功') }
return put<{ code: number; data: null; msg: string }>(`/system/users/${id}`, data)
}
export async function deleteUser(id: string) {
if (USE_MOCK) { await delay(); return { msg: '删除成功' } }
return del<{ msg: string }>(`/system/users/${id}`)
if (USE_MOCK) { await delay(); return mockResponse(null, '删除成功') }
return del<{ code: number; data: null; msg: string }>(`/system/users/${id}`)
}
+26 -26
View File
@@ -1,5 +1,5 @@
import { get, post, type PageResult, type PageParams } from '@/lib/request'
import { USE_MOCK, delay, paginate, mockId } from '@/mock'
import { USE_MOCK, delay, paginate, mockId, mockResponse } from '@/mock'
import { mockUsers } from '@/mock/system/users'
import { mockRoles } from '@/mock/system/roles'
import { mockMenuTree } from '@/mock/system/menus'
@@ -15,28 +15,28 @@ export async function getUserList(data: PageParams & { account?: string; phone?:
await delay()
let filtered = [...mockUsers]
if (data.keyword) filtered = filtered.filter(u => u.name.includes(data.keyword!) || u.account.includes(data.keyword!))
return { data: paginate(filtered, data.current, data.pageSize) }
return mockResponse(paginate(filtered, data.current, data.pageSize))
}
return post<{ data: PageResult<SystemUser> }>('/user/getUserList', data)
}
export async function saveUser(data: Partial<SystemUser>) {
if (USE_MOCK) { await delay(); mockUsers.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemUser); return { msg: '创建成功' } }
if (USE_MOCK) { await delay(); mockUsers.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemUser); return mockResponse(null, '创建成功') }
return post<{ msg: string }>('/user/save', data)
}
export async function updateUser(data: Partial<SystemUser>) {
if (USE_MOCK) { await delay(); const i = mockUsers.findIndex(u => u.id === data.id); if (i >= 0) Object.assign(mockUsers[i], data); return { msg: '更新成功' } }
if (USE_MOCK) { await delay(); const i = mockUsers.findIndex(u => u.id === data.id); if (i >= 0) Object.assign(mockUsers[i], data); return mockResponse(null, '更新成功') }
return post<{ msg: string }>('/user/update', data)
}
export async function deleteUser(ids: string[]) {
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockUsers.findIndex(u => u.id === id); if (i >= 0) mockUsers.splice(i, 1) }); return { msg: '删除成功' } }
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockUsers.findIndex(u => u.id === id); if (i >= 0) mockUsers.splice(i, 1) }); return mockResponse(null, '删除成功') }
return post<{ msg: string }>('/user/delete', { ids })
}
export async function changePassword(data: { id: string; newPwd: string }) {
if (USE_MOCK) { await delay(); return { msg: '密码修改成功' } }
if (USE_MOCK) { await delay(); return mockResponse(null, '密码修改成功') }
return post<{ msg: string }>('/user/changePassword', data)
}
@@ -45,7 +45,7 @@ export async function grantRole(data: { userId: string; roleIds: string[] }) {
await delay()
const user = mockUsers.find(u => u.id === data.userId)
if (user) user.roles = mockRoles.filter(r => data.roleIds.includes(r.id)).map(r => ({ ...r }))
return { msg: '角色分配成功' }
return mockResponse(null, '角色分配成功')
}
return post<{ msg: string }>('/user/grantRole', data)
}
@@ -57,28 +57,28 @@ export async function getRoleList(data: PageParams & { name?: string }) {
await delay()
let filtered = [...mockRoles]
if (data.keyword) filtered = filtered.filter(r => r.name.includes(data.keyword!))
return { data: paginate(filtered, data.current, data.pageSize) }
return mockResponse(paginate(filtered, data.current, data.pageSize))
}
return post<{ data: PageResult<SystemRole> }>('/role/getRoleList', data)
}
export async function getAllRoles() {
if (USE_MOCK) { await delay(100); return { data: [...mockRoles] } }
if (USE_MOCK) { await delay(100); return mockResponse([...mockRoles]) }
return post<{ data: SystemRole[] }>('/role/getAllRoles', {})
}
export async function saveRole(data: Partial<SystemRole>) {
if (USE_MOCK) { await delay(); mockRoles.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemRole); return { msg: '创建成功' } }
if (USE_MOCK) { await delay(); mockRoles.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemRole); return mockResponse(null, '创建成功') }
return post<{ msg: string }>('/role/save', data)
}
export async function updateRole(data: Partial<SystemRole>) {
if (USE_MOCK) { await delay(); const i = mockRoles.findIndex(r => r.id === data.id); if (i >= 0) Object.assign(mockRoles[i], data); return { msg: '更新成功' } }
if (USE_MOCK) { await delay(); const i = mockRoles.findIndex(r => r.id === data.id); if (i >= 0) Object.assign(mockRoles[i], data); return mockResponse(null, '更新成功') }
return post<{ msg: string }>('/role/update', data)
}
export async function deleteRole(ids: string[]) {
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockRoles.findIndex(r => r.id === id); if (i >= 0) mockRoles.splice(i, 1) }); return { msg: '删除成功' } }
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockRoles.findIndex(r => r.id === id); if (i >= 0) mockRoles.splice(i, 1) }); return mockResponse(null, '删除成功') }
return post<{ msg: string }>('/role/delete', { ids })
}
@@ -90,7 +90,7 @@ export async function grantMenu(data: { roleId: string; menuIds: string[] }) {
const flatMenus = (items: SystemMenu[]): SystemMenu[] => items.flatMap(m => [m, ...(m.children ? flatMenus(m.children) : [])])
role.menus = flatMenus(mockMenuTree).filter(m => data.menuIds.includes(m.id))
}
return { msg: '菜单授权成功' }
return mockResponse(null, '菜单授权成功')
}
return post<{ msg: string }>('/role/grantMenu', data)
}
@@ -98,27 +98,27 @@ export async function grantMenu(data: { roleId: string; menuIds: string[] }) {
// ==================== Menu ====================
export async function getAllMenuTree() {
if (USE_MOCK) { await delay(); return { data: mockMenuTree } }
if (USE_MOCK) { await delay(); return mockResponse(mockMenuTree) }
return post<{ data: SystemMenu[] }>('/menu/getAllMenuTree', {})
}
export async function getUserMenuTree() {
if (USE_MOCK) { await delay(); return { data: mockMenuTree } }
if (USE_MOCK) { await delay(); return mockResponse(mockMenuTree) }
return get<{ data: SystemMenu[] }>('/menu/getUserMenuTree')
}
export async function saveMenu(data: Partial<SystemMenu>) {
if (USE_MOCK) { await delay(); return { msg: '创建成功' } }
if (USE_MOCK) { await delay(); return mockResponse(null, '创建成功') }
return post<{ msg: string }>('/menu/save', data)
}
export async function updateMenu(data: Partial<SystemMenu>) {
if (USE_MOCK) { await delay(); return { msg: '更新成功' } }
if (USE_MOCK) { await delay(); return mockResponse(null, '更新成功') }
return post<{ msg: string }>('/menu/update', data)
}
export async function deleteMenu(id: string) {
if (USE_MOCK) { await delay(); return { msg: '删除成功' } }
if (USE_MOCK) { await delay(); return mockResponse(null, '删除成功') }
return get<{ msg: string }>('/menu/delete', { id })
}
@@ -129,23 +129,23 @@ export async function getClientList(data: PageParams & { clientId?: string; name
await delay()
let filtered = [...mockClients]
if (data.keyword) filtered = filtered.filter(c => c.name.includes(data.keyword!) || c.clientId.includes(data.keyword!))
return { data: paginate(filtered, data.current, data.pageSize) }
return mockResponse(paginate(filtered, data.current, data.pageSize))
}
return post<{ data: PageResult<SystemClient> }>('/client/getClientList', data)
}
export async function saveClient(data: Partial<SystemClient>) {
if (USE_MOCK) { await delay(); mockClients.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemClient); return { msg: '创建成功' } }
if (USE_MOCK) { await delay(); mockClients.push({ ...data, id: mockId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as SystemClient); return mockResponse(null, '创建成功') }
return post<{ msg: string }>('/client/save', data)
}
export async function updateClient(data: Partial<SystemClient>) {
if (USE_MOCK) { await delay(); const i = mockClients.findIndex(c => c.id === data.id); if (i >= 0) Object.assign(mockClients[i], data); return { msg: '更新成功' } }
if (USE_MOCK) { await delay(); const i = mockClients.findIndex(c => c.id === data.id); if (i >= 0) Object.assign(mockClients[i], data); return mockResponse(null, '更新成功') }
return post<{ msg: string }>('/client/update', data)
}
export async function deleteClient(ids: string[]) {
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockClients.findIndex(c => c.id === id); if (i >= 0) mockClients.splice(i, 1) }); return { msg: '删除成功' } }
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockClients.findIndex(c => c.id === id); if (i >= 0) mockClients.splice(i, 1) }); return mockResponse(null, '删除成功') }
return post<{ msg: string }>('/client/delete', { ids })
}
@@ -156,19 +156,19 @@ export async function getFileList(data: PageParams & { name?: string }) {
await delay()
let filtered = [...mockFiles]
if (data.keyword) filtered = filtered.filter(f => f.name.includes(data.keyword!))
return { data: paginate(filtered, data.current, data.pageSize) }
return mockResponse(paginate(filtered, data.current, data.pageSize))
}
return post<{ data: PageResult<SystemOss> }>('/oss/getFileList', data)
}
export async function uploadFile(_file: File) {
if (USE_MOCK) { await delay(800); const f: SystemOss = { id: mockId(), name: _file.name, key: `uploads/${_file.name}`, url: `https://picsum.photos/seed/${Date.now()}/400/300`, suffix: _file.name.split('.').pop() || '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; mockFiles.unshift(f); return { data: { file: f }, msg: '上传成功' } }
if (USE_MOCK) { await delay(800); const f: SystemOss = { id: mockId(), name: _file.name, key: `uploads/${_file.name}`, url: `https://picsum.photos/seed/${Date.now()}/400/300`, suffix: _file.name.split('.').pop() || '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; mockFiles.unshift(f); return mockResponse({ file: f }, '上传成功') }
const formData = new FormData(); formData.append('file', _file)
return post<{ data: { file: SystemOss }; msg: string }>('/oss/upload', formData)
}
export async function deleteFile(ids: string[]) {
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockFiles.findIndex(f => f.id === id); if (i >= 0) mockFiles.splice(i, 1) }); return { msg: '删除成功' } }
if (USE_MOCK) { await delay(); ids.forEach(id => { const i = mockFiles.findIndex(f => f.id === id); if (i >= 0) mockFiles.splice(i, 1) }); return mockResponse(null, '删除成功') }
return post<{ msg: string }>('/oss/delete', { ids })
}
@@ -182,7 +182,7 @@ export async function getOperationLogList(data: PageParams & { clientId?: string
if (data.method) filtered = filtered.filter(l => l.method === data.method)
if (data.statusCode !== undefined) filtered = filtered.filter(l => l.statusCode === data.statusCode)
if (data.keyword) filtered = filtered.filter(l => l.path.includes(data.keyword!) || l.title.includes(data.keyword!) || l.operatorName.includes(data.keyword!))
return { data: paginate(filtered, data.current, data.pageSize) }
return mockResponse(paginate(filtered, data.current, data.pageSize))
}
return post<{ data: PageResult<OperationLog> }>('/log/getOperationLogList', data)
}
+162
View File
@@ -0,0 +1,162 @@
import React from 'react'
import { motion } from 'framer-motion'
export default function AIBanner() {
// Generate Data Stream particles for the left side (Cyan)
const dataParticles = Array.from({ length: 100 }).map((_, i) => ({
id: `dp-${i}`,
y: 10 + Math.random() * 80, // spread vertically between 10% and 90%
size: Math.random() > 0.8 ? 6 : Math.random() * 3 + 1.5, // slightly larger
isSquare: Math.random() > 0.6,
duration: Math.random() * 2 + 1.5, // faster
delay: Math.random() * 3,
opacity: Math.random() * 0.7 + 0.3
}))
// Generate Neural Network nodes for the right side (Purple/Fuchsia)
const neuralNodes = [
{ x: 10, y: 50 }, { x: 30, y: 25 }, { x: 35, y: 75 },
{ x: 55, y: 45 }, { x: 75, y: 15 }, { x: 70, y: 85 },
{ x: 90, y: 55 }, { x: 80, y: 40 }, { x: 50, y: 90 },
{ x: 20, y: 95 }, { x: 95, y: 20 }, { x: 45, y: 10 },
{ x: 85, y: 75 }, { x: 15, y: 20 }
]
const neuralLines = [
[0, 1], [0, 2], [1, 3], [2, 3], [1, 4], [3, 4],
[3, 6], [2, 5], [3, 5], [5, 6], [4, 7], [6, 7],
[2, 8], [5, 8], [0, 9], [2, 9], [4, 10], [7, 10],
[1, 11], [4, 11], [6, 12], [5, 12], [0, 13], [1, 13]
]
return (
<div className="absolute inset-0 w-full h-full overflow-hidden bg-[#080d17]">
{/* Background glow matching the logo's dual tone */}
<div className="absolute top-1/2 left-[15%] w-[800px] h-[800px] bg-cyan-600/15 rounded-full blur-[150px] -translate-y-1/2 mix-blend-screen pointer-events-none" />
<div className="absolute top-1/2 right-[15%] w-[800px] h-[800px] bg-fuchsia-600/15 rounded-full blur-[150px] -translate-y-1/2 mix-blend-screen pointer-events-none" />
{/* Background dark stone texture (simulated) */}
<div className="absolute inset-0 opacity-20 pointer-events-none mix-blend-overlay" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
backgroundSize: '150px 150px'
}} />
{/* DYNAMIC DATA STREAM: Cyan Data Stream & Pixels (Flowing right and naturally fading out via Mask) */}
<div
className="absolute inset-y-0 left-0 w-[65%] pointer-events-none"
style={{
// This mask ensures absolutely smooth fading. 100% visible on the left, completely 0% visible by the time it reaches the right neural net.
maskImage: 'linear-gradient(to right, black 0%, black 60%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to right, black 0%, black 60%, transparent 100%)'
}}
>
{dataParticles.map(p => (
<motion.div
key={p.id}
className={`absolute bg-cyan-400 ${p.isSquare ? 'rounded-sm' : 'rounded-full'}`}
style={{
width: p.size, height: p.size,
top: `${p.y}%`,
boxShadow: p.isSquare ? '0 0 12px 3px rgba(6,182,212,0.9)' : '0 0 8px 2px rgba(6,182,212,0.6)'
}}
initial={{ x: '-10vw', opacity: 0 }}
animate={{
x: '75vw',
opacity: [0, p.opacity, p.opacity, 0]
}}
transition={{
duration: p.duration * 3.5, // Significantly slower! (approx 7 to 17 seconds)
repeat: Infinity,
delay: p.delay,
ease: "linear"
}}
/>
))}
{/* Horizontal glowing fiber streaks shooting from left to right */}
<div className="absolute top-1/2 left-0 w-full h-[80%] -translate-y-1/2 flex flex-col justify-between opacity-70">
{Array.from({length: 20}).map((_, i) => (
<motion.div
key={`streak-${i}`}
className="h-[2px] bg-gradient-to-r from-transparent via-cyan-400 to-transparent"
style={{ width: `${Math.random() * 40 + 20}%`, filter: 'drop-shadow(0 0 5px rgba(6,182,212,0.8))' }}
initial={{ x: '-50%', opacity: 0 }}
animate={{ x: '150%', opacity: [0, 1, 1, 0] }}
transition={{
duration: 7 + Math.random() * 6, // Significantly slower! (7 to 13 seconds)
repeat: Infinity,
delay: Math.random() * 4,
ease: "linear"
}}
/>
))}
</div>
</div>
{/* RIGHT SIDE: Fuchsia/Purple Neural Network */}
<div className="absolute inset-y-0 right-0 w-[45%] flex items-center justify-end pr-[5%] pointer-events-none">
<svg viewBox="0 0 100 100" className="w-full max-w-[500px] h-[80%] overflow-visible" style={{ filter: 'drop-shadow(0 0 10px rgba(217,70,239,0.6))' }}>
{/* Neural Lines */}
{neuralLines.map((line, i) => {
const n1 = neuralNodes[line[0]]
const n2 = neuralNodes[line[1]]
return (
<motion.line
key={`line-${i}`}
x1={n1.x} y1={n1.y} x2={n2.x} y2={n2.y}
stroke="rgba(217,70,239,0.5)"
strokeWidth="0.5"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: [0, 1, 1, 0], opacity: [0, 1, 1, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: "easeInOut", delay: i * 0.3 }}
/>
)
})}
{/* Neural Nodes */}
{neuralNodes.map((node, i) => (
<motion.circle
key={`node-${i}`}
cx={node.x} cy={node.y} r="1.5"
fill="#d946ef"
animate={{ r: [1.5, 2.5, 1.5], opacity: [0.4, 0.9, 0.4] }}
transition={{ duration: 4.5, repeat: Infinity, delay: i * 0.4 }}
/>
))}
</svg>
</div>
{/* CENTRAL ELEGANT CONNECTION (Subtle, thin flowing lines connecting left and right) */}
<div className="absolute top-1/2 left-0 w-full h-[600px] -translate-y-1/2 pointer-events-none opacity-60 mix-blend-screen flex items-center justify-center">
<svg viewBox="0 0 200 100" preserveAspectRatio="none" className="w-full h-full overflow-visible">
<defs>
<linearGradient id="elegant-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#06b6d4" stopOpacity="0" />
<stop offset="25%" stopColor="#06b6d4" stopOpacity="0.8" />
<stop offset="50%" stopColor="#8b5cf6" stopOpacity="0.5" />
<stop offset="75%" stopColor="#d946ef" stopOpacity="0.8" />
<stop offset="100%" stopColor="#d946ef" stopOpacity="0" />
</linearGradient>
</defs>
{/* Draw a few extremely elegant, thin sweeping curves spanning the entire width */}
{Array.from({length: 4}).map((_, i) => (
<motion.path
key={`line-${i}`}
d={`M -20 ${30 + i*10} C 60 ${10 + i*5}, 140 ${90 - i*5}, 220 ${70 - i*10}`}
fill="none"
stroke="url(#elegant-grad)"
strokeWidth={0.2 + (i * 0.05)}
animate={{
strokeDasharray: ["0, 400", "400, 0"],
opacity: [0.3, 0.7, 0.3]
}}
transition={{
duration: 16 + i * 4, // Very slow sweeping movement! (16 to 28 seconds)
repeat: Infinity,
ease: "easeInOut",
delay: i * 2
}}
/>
))}
</svg>
</div>
</div>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { Command } from 'cmdk'
import { useNavigate } from 'react-router-dom'
import { Search, Monitor, Moon, Sun, Laptop, Palette } from 'lucide-react'
import { useAppStore } from '@/store/app'
import { useAuthStore } from '@/store/auth'
import './cmdk.css' // We'll add some styles for cmdk
export default function CommandPalette() {
const { cmdKOpen, setCmdKOpen, setThemeHue } = useAppStore()
const menus = useAuthStore(s => s.menus)
const navigate = useNavigate()
const [theme, setTheme] = useState(document.documentElement.classList.contains('dark') ? 'dark' : 'light')
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setCmdKOpen(!cmdKOpen)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [cmdKOpen, setCmdKOpen])
const runCommand = (command: () => void) => {
setCmdKOpen(false)
command()
}
const toggleTheme = (mode: 'light' | 'dark' | 'system') => {
if (mode === 'dark') {
document.documentElement.classList.add('dark')
setTheme('dark')
} else if (mode === 'light') {
document.documentElement.classList.remove('dark')
setTheme('light')
} else {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark')
setTheme('dark')
} else {
document.documentElement.classList.remove('dark')
setTheme('light')
}
}
}
// Flatten menus for search
const flatMenus: { title: string, path: string }[] = []
const flatten = (items: any[]) => {
items?.forEach(i => {
if (i.path && !i.children?.length && i.path !== '/dashboard') {
flatMenus.push({ title: i.title || i.name, path: i.path })
}
if (i.children) flatten(i.children)
})
}
if (menus) flatten(menus)
return (
<Command.Dialog open={cmdKOpen} onOpenChange={setCmdKOpen} label="Global Command Menu" className="cmdk-dialog glass-panel">
<div className="flex items-center px-4 border-b border-white/10 dark:border-white/5">
<Search className="w-5 h-5 text-muted-foreground mr-3" />
<Command.Input placeholder="搜索菜单,或输入命令..." className="w-full bg-transparent border-0 h-14 text-sm outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none placeholder:text-muted-foreground text-foreground" />
</div>
<Command.List className="max-h-[300px] overflow-y-auto p-2 scrollbar-none">
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">.</Command.Empty>
<Command.Group heading="页面导航" className="text-xs font-medium text-muted-foreground mb-1 px-2 py-1">
<Command.Item onSelect={() => runCommand(() => navigate('/dashboard'))} className="flex items-center px-3 py-2.5 text-sm rounded-lg cursor-pointer transition-colors aria-selected:bg-primary/10 aria-selected:text-primary text-foreground mt-1">
<Monitor className="w-4 h-4 mr-3" />
</Command.Item>
{flatMenus.map(m => (
<Command.Item key={m.path} onSelect={() => runCommand(() => navigate(m.path))} className="flex items-center px-3 py-2.5 text-sm rounded-lg cursor-pointer transition-colors aria-selected:bg-primary/10 aria-selected:text-primary text-foreground mt-1">
<Search className="w-4 h-4 mr-3 opacity-50" /> {m.title}
</Command.Item>
))}
</Command.Group>
<Command.Group heading="主题模式" className="text-xs font-medium text-muted-foreground mt-4 mb-1 px-2 py-1">
<Command.Item onSelect={() => runCommand(() => toggleTheme('light'))} className="flex items-center px-3 py-2.5 text-sm rounded-lg cursor-pointer transition-colors aria-selected:bg-primary/10 aria-selected:text-primary text-foreground mt-1">
<Sun className="w-4 h-4 mr-3" />
</Command.Item>
<Command.Item onSelect={() => runCommand(() => toggleTheme('dark'))} className="flex items-center px-3 py-2.5 text-sm rounded-lg cursor-pointer transition-colors aria-selected:bg-primary/10 aria-selected:text-primary text-foreground mt-1">
<Moon className="w-4 h-4 mr-3" />
</Command.Item>
<Command.Item onSelect={() => runCommand(() => toggleTheme('system'))} className="flex items-center px-3 py-2.5 text-sm rounded-lg cursor-pointer transition-colors aria-selected:bg-primary/10 aria-selected:text-primary text-foreground mt-1">
<Laptop className="w-4 h-4 mr-3" />
</Command.Item>
</Command.Group>
<Command.Group heading="重置主题色" className="text-xs font-medium text-muted-foreground mt-4 mb-1 px-2 py-1">
<div className="flex gap-2 px-3 py-2 mt-1">
<button onClick={() => runCommand(() => setThemeHue('145'))} style={{ backgroundColor: 'oklch(0.55 0.12 145)' }} className="w-6 h-6 rounded-full ring-2 ring-transparent hover:ring-white/20 transition-all"></button>
<button onClick={() => runCommand(() => setThemeHue('240'))} style={{ backgroundColor: 'oklch(0.55 0.12 240)' }} className="w-6 h-6 rounded-full ring-2 ring-transparent hover:ring-white/20 transition-all"></button>
<button onClick={() => runCommand(() => setThemeHue('280'))} style={{ backgroundColor: 'oklch(0.55 0.12 280)' }} className="w-6 h-6 rounded-full ring-2 ring-transparent hover:ring-white/20 transition-all"></button>
<button onClick={() => runCommand(() => setThemeHue('330'))} style={{ backgroundColor: 'oklch(0.55 0.12 330)' }} className="w-6 h-6 rounded-full ring-2 ring-transparent hover:ring-white/20 transition-all"></button>
<button onClick={() => runCommand(() => setThemeHue('45'))} style={{ backgroundColor: 'oklch(0.65 0.18 45)' }} className="w-6 h-6 rounded-full ring-2 ring-transparent hover:ring-white/20 transition-all"></button>
</div>
</Command.Group>
</Command.List>
</Command.Dialog>
)
}
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useRef } from 'react';
interface Particle {
x: number;
y: number;
size: number;
speedX: number;
speedY: number;
opacity: number;
}
export default function ParticleBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number;
let particles: Particle[] = [];
// Config
const particleCount = 60;
const connectionDistance = 150;
const mouseRadius = 150;
const mouse = { x: -1000, y: -1000 };
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener('resize', resize);
resize();
const handleMouseMove = (e: MouseEvent) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
};
window.addEventListener('mousemove', handleMouseMove);
const handleMouseLeave = () => {
mouse.x = -1000;
mouse.y = -1000;
};
window.addEventListener('mouseleave', handleMouseLeave);
// Initialize particles
for (let i = 0; i < particleCount; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: Math.random() * 2 + 0.5,
speedX: (Math.random() - 0.5) * 0.8,
speedY: (Math.random() - 0.5) * 0.8,
opacity: Math.random() * 0.5 + 0.2,
});
}
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const themeHue = getComputedStyle(document.documentElement).getPropertyValue('--theme-hue') || '145';
const hue = themeHue.trim() ? themeHue : '145';
// Update and draw particles
particles.forEach((p, index) => {
p.x += p.speedX;
p.y += p.speedY;
// Bounce off edges
if (p.x < 0 || p.x > canvas.width) p.speedX *= -1;
if (p.y < 0 || p.y > canvas.height) p.speedY *= -1;
// Interaction with mouse
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < mouseRadius) {
const forceDirectionX = dx / distance;
const forceDirectionY = dy / distance;
const force = (mouseRadius - distance) / mouseRadius;
// push away slightly
p.x -= forceDirectionX * force * 2;
p.y -= forceDirectionY * force * 2;
}
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `oklch(0.65 0.15 ${hue} / ${p.opacity})`;
ctx.fill();
// Connect particles
for (let j = index + 1; j < particles.length; j++) {
const p2 = particles[j];
const pdx = p.x - p2.x;
const pdy = p.y - p2.y;
const pDistance = Math.sqrt(pdx * pdx + pdy * pdy);
if (pDistance < connectionDistance) {
ctx.beginPath();
ctx.strokeStyle = `oklch(0.65 0.15 ${hue} / ${0.15 * (1 - pDistance / connectionDistance)})`;
ctx.lineWidth = 1;
ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
}
});
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseleave', handleMouseLeave);
cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 z-0 pointer-events-none"
/>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useRef, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import { useTabsStore } from '@/store/tabs'
import { useAuthStore } from '@/store/auth'
import { cn } from '@/lib/utils'
import {
ContextMenu, ContextMenuContent, ContextMenuItem,
ContextMenuSeparator, ContextMenuTrigger,
} from '@/components/ui/context-menu'
import type { SystemMenu } from '@/api/system'
/** Resolve page title from menu tree by path */
function resolveTitle(menus: SystemMenu[], path: string): string {
for (const m of menus) {
if (m.path === path) return m.title || m.name
if (m.children?.length) {
const found = resolveTitle(m.children, path)
if (found) return found
}
}
return ''
}
export default function TabBar() {
const navigate = useNavigate()
const location = useLocation()
const menus = useAuthStore(s => s.menus)
const { tabs, activeTab, addTab, removeTab, setActiveTab, closeOthers, closeAll, closeRight, closeLeft } = useTabsStore()
const scrollRef = useRef<HTMLDivElement>(null)
// Auto-register tab on route change
useEffect(() => {
const path = location.pathname
if (path === '/login') return
const title = resolveTitle(menus || [], path)
|| path.split('/').filter(Boolean).pop()?.replace(/^\w/, c => c.toUpperCase()) || 'Page'
addTab({ path, title, closable: path !== '/dashboard' })
}, [location.pathname, menus])
const handleClick = (path: string) => {
setActiveTab(path)
navigate(path)
}
const handleClose = (e: React.MouseEvent, path: string) => {
e.stopPropagation()
const next = removeTab(path)
navigate(next)
}
const handleCloseOthers = (path: string) => {
closeOthers(path)
navigate(path)
}
const handleCloseAll = () => {
const home = closeAll()
navigate(home)
}
const scroll = (dir: 'left' | 'right') => {
scrollRef.current?.scrollBy({ left: dir === 'left' ? -200 : 200, behavior: 'smooth' })
}
const showArrows = tabs.length > 8
return (
<div className="flex items-center h-[38px] bg-white/20 dark:bg-black/10 border-b border-white/20 dark:border-white/5 px-1 gap-0.5 select-none">
{showArrows && (
<button onClick={() => scroll('left')} className="shrink-0 flex items-center justify-center w-6 h-6 rounded hover:bg-white/40 dark:hover:bg-white/10 text-muted-foreground">
<ChevronLeft className="h-3.5 w-3.5" />
</button>
)}
<div ref={scrollRef} className="flex-1 flex items-center gap-0.5 overflow-x-auto scrollbar-none">
{tabs.map(tab => (
<ContextMenu key={tab.path}>
<ContextMenuTrigger>
<button
onClick={() => handleClick(tab.path)}
className={cn(
'group relative flex items-center gap-1.5 h-[28px] px-3 rounded-md text-xs font-medium whitespace-nowrap transition-all duration-200',
activeTab === tab.path
? 'bg-white dark:bg-slate-800 text-foreground shadow-sm border border-white/60 dark:border-white/10'
: 'text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-white/5'
)}
>
{activeTab === tab.path && (
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-[2px] bg-primary rounded-full" />
)}
<span className="max-w-[100px] truncate">{tab.title}</span>
{tab.closable && (
<span
onClick={e => handleClose(e, tab.path)}
className={cn(
'flex items-center justify-center w-4 h-4 rounded-full transition-all',
activeTab === tab.path
? 'opacity-60 hover:opacity-100 hover:bg-red-100 hover:text-red-500 dark:hover:bg-red-900/30'
: 'opacity-0 group-hover:opacity-60 hover:!opacity-100 hover:bg-red-100 hover:text-red-500 dark:hover:bg-red-900/30'
)}
>
<X className="h-2.5 w-2.5" />
</span>
)}
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
{tab.closable && (
<ContextMenuItem onClick={() => { const next = removeTab(tab.path); navigate(next) }}>
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => handleCloseOthers(tab.path)}>
</ContextMenuItem>
<ContextMenuItem onClick={() => { closeRight(tab.path); navigate(tab.path) }}>
</ContextMenuItem>
<ContextMenuItem onClick={() => { closeLeft(tab.path); navigate(tab.path) }}>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCloseAll}>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
{showArrows && (
<button onClick={() => scroll('right')} className="shrink-0 flex items-center justify-center w-6 h-6 rounded hover:bg-white/40 dark:hover:bg-white/10 text-muted-foreground">
<ChevronRight className="h-3.5 w-3.5" />
</button>
)}
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
.cmdk-dialog {
position: fixed;
top: 20%;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 640px;
background: var(--color-background);
border-radius: 1rem;
box-shadow: var(--shadow-soft-lg);
border: 1px solid oklch(0.9 0.01 110 / 0.2);
z-index: 100;
overflow: hidden;
animation: dialogIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
:root.dark .cmdk-dialog {
background: oklch(0.16 0.02 145 / 0.85);
backdrop-filter: blur(16px);
border: 1px solid oklch(1 0 0 / 0.1);
}
@keyframes dialogIn {
from {
opacity: 0;
transform: translate(-50%, -40%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
[data-cmdk-overlay] {
background: rgba(0, 0, 0, 0.4);
position: fixed;
inset: 0;
z-index: 99;
backdrop-filter: blur(4px);
animation: overlayIn 0.2s ease-out forwards;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
[data-cmdk-group-heading] {
user-select: none;
font-size: 12px;
color: oklch(0.55 0.02 145);
padding: 0 8px;
margin-bottom: 8px;
font-weight: 500;
}
[cmdk-input],
[data-cmdk-input] {
outline: none !important;
box-shadow: none !important;
border: none !important;
}
[cmdk-input]:focus,
[data-cmdk-input]:focus,
[cmdk-input]:focus-visible,
[data-cmdk-input]:focus-visible {
outline: none !important;
box-shadow: none !important;
border: none !important;
}
@@ -0,0 +1,223 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import AIBanner from '../AIBanner'
import { Cpu, ArrowRight } from 'lucide-react'
export default function AICoreEffect({ onUnlock }: { onUnlock: () => void }) {
const [synced, setSynced] = useState(false)
const [progress, setProgress] = useState(0)
const handleSync = () => {
if (synced || progress > 0) return
// Auto progress over 2.5 seconds
let p = 0
const interval = setInterval(() => {
p += 1
setProgress(p)
if (p >= 100) {
clearInterval(interval)
setSynced(true)
setTimeout(() => onUnlock(), 800)
}
}, 25)
}
return (
<AnimatePresence>
{!synced && (
<motion.div
className="fixed inset-0 z-[100] bg-[#080d17] flex flex-col items-center justify-center overflow-hidden"
exit={{ opacity: 0, scale: 1.05 }}
transition={{ duration: 0.8, ease: "easeInOut" }}
>
{/* Base Background matching the main page */}
<div className="absolute inset-0 opacity-50">
<AIBanner />
</div>
{/* Overlay to dim the background, focusing attention on the core */}
<div className="absolute inset-0 bg-[#080d17]/70 backdrop-blur-sm z-10" />
{/* MASSIVE GLOWING LOGO (Faithful reproduction of the user's uploaded logo) */}
<div className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] pointer-events-none mix-blend-screen flex items-center justify-center z-[15] transition-all duration-1000 ${progress > 0 ? 'opacity-100 scale-[1.05]' : 'opacity-90 scale-100'}`}>
<svg viewBox="-20 0 140 100" className="w-full h-full overflow-visible">
<defs>
<linearGradient id="logo-cyan" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#06b6d4" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
<linearGradient id="logo-purple" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#d946ef" />
</linearGradient>
<linearGradient id="logo-s" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#06b6d4" />
<stop offset="40%" stopColor="#ffffff" />
<stop offset="60%" stopColor="#ffffff" />
<stop offset="100%" stopColor="#d946ef" />
</linearGradient>
<filter id="logo-glow">
<feGaussianBlur stdDeviation="1.2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
{/* LEFT DATA HALF (Cyan Hexagon & Particles) */}
<g opacity="0.7">
{/* Hexagon Left Outline */}
<path d="M 50 10 L 20 25 L 20 75 L 50 90" fill="none" stroke="url(#logo-cyan)" strokeWidth="0.5" opacity="0.8" />
{/* Grid of cyan dots inside the left hexagon */}
{Array.from({length: 60}).map((_, i) => {
const x = 22 + Math.random() * 25;
const y = 25 + Math.random() * 50;
return (
<motion.circle
key={`dot-${i}`} cx={x} cy={y} r={Math.random() > 0.7 ? 1 : 0.5} fill="#06b6d4"
animate={{ opacity: [0.2, 1, 0.2] }}
transition={{ duration: 1 + Math.random() * 2, repeat: Infinity, delay: Math.random() * 2 }}
/>
)
})}
{/* Incoming Cyan Stream from outside */}
{Array.from({length: 40}).map((_, i) => (
<motion.circle
key={`inc-${i}`} cx={-20} cy={30 + Math.random() * 40} r={Math.random() > 0.5 ? 1.5 : 0.8} fill="#06b6d4"
animate={{ x: [0, 45], opacity: [0, 1, 0] }}
transition={{ duration: 1.5 + Math.random() * 1.5, repeat: Infinity, ease: "linear", delay: Math.random() * 2 }}
/>
))}
</g>
{/* RIGHT NEURAL HALF (Purple Hexagon & Network) */}
<g opacity="0.7">
{/* Hexagon Right Outline */}
<path d="M 50 10 L 80 25 L 80 75 L 50 90" fill="none" stroke="url(#logo-purple)" strokeWidth="0.5" opacity="0.8" />
{/* Neural Net nodes inside the right hexagon */}
{Array.from({length: 20}).map((_, i) => {
const x = 52 + Math.random() * 25;
const y = 25 + Math.random() * 50;
return (
<g key={`n-${i}`}>
<motion.circle
cx={x} cy={y} r="0.8" fill="#d946ef"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5 + Math.random() * 2, repeat: Infinity, delay: Math.random() * 2 }}
/>
<line x1={x} y1={y} x2={50} y2={50} stroke="#d946ef" strokeWidth="0.1" opacity="0.4" />
</g>
)
})}
{/* Outgoing Purple Network lines to outside */}
{Array.from({length: 20}).map((_, i) => {
const startY = 30 + Math.random() * 40;
const endY = 20 + Math.random() * 60;
return (
<motion.g key={`out-${i}`} animate={{ x: [0, 35], opacity: [0, 1, 0] }} transition={{ duration: 2 + Math.random() * 2, repeat: Infinity, ease: "linear", delay: Math.random() * 2 }}>
<circle cx={80} cy={endY} r="1.2" fill="#d946ef" />
<line x1={50} y1={startY} x2={80} y2={endY} stroke="#d946ef" strokeWidth="0.2" opacity="0.5" />
</motion.g>
)
})}
</g>
{/* THE GLOWING S-CORE (The thick central energy paths) */}
<g filter="url(#logo-glow)">
{Array.from({length: 80}).map((_, i) => {
const offset = (i - 40) * 0.25; // 80 lines packed tightly
const isCenter = Math.abs(offset) < 3; // Make the very center lines extremely bright and white
return (
<motion.path
key={`s-${i}`}
d={`M ${70 + offset} 25 L ${45 + offset} 25 C ${25 + offset} 25, ${25 + offset} 45, ${45 + offset} 50 C ${75 + offset} 60, ${75 + offset} 80, ${55 + offset} 80 L ${30 + offset} 80`}
fill="none"
stroke="url(#logo-s)"
strokeWidth={isCenter ? "0.8" : "0.3"}
animate={{ strokeDasharray: ["0, 300", "300, 0"], opacity: [0.1, isCenter ? 1 : 0.5, 0.1] }}
transition={{
duration: progress > 0 ? 0.6 + Math.random() * 0.5 : 2.5 + Math.random() * 2, // Hyper-speed when syncing!
repeat: Infinity, delay: Math.random() * 3, ease: "linear"
}}
/>
)
})}
</g>
</svg>
</div>
{/* Glitchy Scanline Overlay */}
<div className="absolute inset-0 z-20 opacity-10 pointer-events-none" style={{ backgroundImage: 'repeating-linear-gradient(transparent, transparent 2px, #000 2px, #000 4px)' }} />
{/* Sync Interface */}
<div className="relative z-30 flex flex-col items-center mt-[450px]">
<div className="w-32 h-32 relative flex items-center justify-center">
{/* Circular progress SVG */}
<svg className="absolute inset-0 w-full h-full -rotate-90">
<circle cx="64" cy="64" r="60" fill="none" stroke="rgba(6,182,212,0.1)" strokeWidth="1" />
<motion.circle
cx="64" cy="64" r="60" fill="none" stroke="#06b6d4" strokeWidth="2"
strokeDasharray={377}
strokeDashoffset={377 - (377 * progress) / 100}
style={{ filter: 'drop-shadow(0 0 10px rgba(6,182,212,0.8))' }}
/>
</svg>
{/* Hexagon framing the CPU */}
<motion.svg
viewBox="0 0 100 100"
className={`absolute inset-2 w-[calc(100%-16px)] h-[calc(100%-16px)] transition-colors duration-500 ${progress > 0 ? 'text-fuchsia-500' : 'text-cyan-500/30'}`}
animate={{ rotate: progress > 0 ? 360 : 0 }}
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
>
<polygon points="50 5, 95 25, 95 75, 50 95, 5 75, 5 25" fill="none" stroke="currentColor" strokeWidth="1" />
</motion.svg>
<button
onClick={handleSync}
disabled={progress > 0}
className="relative z-10 w-16 h-16 bg-slate-900 rounded-full border border-cyan-500/50 flex items-center justify-center text-cyan-400 hover:scale-110 hover:bg-cyan-950 hover:shadow-[0_0_30px_rgba(6,182,212,0.6)] transition-all cursor-pointer shadow-[0_0_15px_rgba(6,182,212,0.2)] disabled:opacity-100 disabled:cursor-not-allowed group"
>
<Cpu className={`w-8 h-8 transition-transform ${progress > 0 && progress < 100 ? 'animate-pulse text-fuchsia-400' : ''} ${progress === 100 ? 'text-white drop-shadow-[0_0_10px_white]' : ''}`} />
</button>
</div>
<div className="mt-10 font-mono text-center">
<div className="text-cyan-400 text-sm tracking-[0.4em] uppercase mb-4 shadow-cyan-400/50 drop-shadow-md">
{progress === 0 ? 'Core Synchronization Required' : progress < 100 ? 'Establishing Neural Link...' : 'Link Established'}
</div>
{progress === 0 && (
<motion.div
className="text-slate-400 text-xs tracking-widest flex items-center gap-2 justify-center"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
<ArrowRight className="w-3 h-3 text-fuchsia-400" /> INITIATE BOOT SEQUENCE
</motion.div>
)}
{progress > 0 && (
<div className="text-white text-3xl font-bold tracking-[0.2em] tabular-nums drop-shadow-[0_0_10px_rgba(255,255,255,0.5)]">
{progress}%
</div>
)}
</div>
</div>
</motion.div>
)}
{/* Screen flash effect on sync completion */}
{synced && (
<motion.div
className="fixed inset-0 z-[110] pointer-events-none bg-gradient-to-r from-cyan-400 to-fuchsia-400 mix-blend-screen"
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0] }}
transition={{ duration: 1, ease: "easeOut" }}
/>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,105 @@
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Fingerprint } from 'lucide-react'
export default function BiometricEffect({ onUnlock }: { onUnlock: () => void }) {
const [progress, setProgress] = useState(0)
const [unlocked, setUnlocked] = useState(false)
const intervalRef = useRef<any>(null)
const handleStart = () => {
if (unlocked) return
if (intervalRef.current) clearInterval(intervalRef.current)
intervalRef.current = setInterval(() => {
setProgress(p => Math.min(p + 2, 100))
}, 30) // takes about 1.5 seconds to fill
}
const handleStop = () => {
if (unlocked) return
if (intervalRef.current) clearInterval(intervalRef.current)
setProgress(p => {
if (p >= 100) return p
return 0
})
}
useEffect(() => {
if (progress >= 100 && !unlocked) {
if (intervalRef.current) clearInterval(intervalRef.current)
setUnlocked(true)
setTimeout(() => onUnlock(), 800)
}
}, [progress, unlocked, onUnlock])
useEffect(() => {
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [])
return (
<AnimatePresence>
{!unlocked && (
<motion.div
className="fixed inset-0 z-[100] bg-[#0a0f18] flex flex-col items-center justify-center"
exit={{ opacity: 0 }}
>
<div className="relative">
{/* The Fingerprint Button */}
<motion.button
onMouseDown={handleStart}
onMouseUp={handleStop}
onMouseLeave={handleStop}
onTouchStart={handleStart}
onTouchEnd={handleStop}
className={`relative z-10 w-40 h-40 rounded-full flex items-center justify-center transition-all duration-300 ${
progress > 0 ? 'text-cyan-400 scale-105' : 'text-slate-700'
}`}
style={{
boxShadow: progress > 0 ? `0 0 ${progress}px rgba(34,211,238,0.4)` : 'none'
}}
>
<Fingerprint className="w-20 h-20" />
</motion.button>
{/* Circular Progress SVG */}
<svg className="absolute inset-0 w-40 h-40 pointer-events-none -rotate-90">
<circle cx="80" cy="80" r="76" fill="none" stroke="currentColor" strokeWidth="2" className="text-slate-800" />
<circle cx="80" cy="80" r="76" fill="none" stroke="currentColor" strokeWidth="2" className="text-cyan-400 transition-all duration-75"
strokeDasharray={477}
strokeDashoffset={477 - (477 * progress) / 100}
/>
</svg>
{/* Scanning Laser Line */}
{progress > 0 && progress < 100 && (
<motion.div
className="absolute left-0 right-0 h-[2px] bg-cyan-400 z-20 rounded-full pointer-events-none"
style={{ top: '10%', boxShadow: '0 0 10px 2px rgba(34,211,238,0.8)' }}
animate={{ top: ['10%', '90%', '10%'] }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
/>
)}
</div>
<p className={`mt-10 font-mono text-sm tracking-[0.3em] uppercase transition-colors ${progress === 100 ? 'text-emerald-400' : 'text-slate-500'}`}>
{progress > 0 ? (progress === 100 ? 'Access Granted' : 'Scanning...') : 'Hold to Authenticate'}
</p>
</motion.div>
)}
{/* Iris Wipe Effect */}
{unlocked && (
<motion.div
className="fixed inset-0 z-[110] pointer-events-none flex items-center justify-center overflow-hidden"
>
<motion.div
className="bg-cyan-400 mix-blend-screen"
initial={{ clipPath: 'circle(100px at center)', width: '100vw', height: '100vh' }}
animate={{ clipPath: 'circle(150% at center)', opacity: [1, 1, 0] }}
transition={{ duration: 0.8, ease: "easeIn" }}
/>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
export default function HackerEffect({ onUnlock }: { onUnlock: () => void }) {
const [hacked, setHacked] = useState(false)
const [text, setText] = useState('')
const fullText = "SYSTEM LOCKED.\nAWAITING OVERRIDE...\n\n> "
useEffect(() => {
let i = 0
const timer = setInterval(() => {
setText(fullText.slice(0, i))
i++
if (i > fullText.length) clearInterval(timer)
}, 40)
return () => clearInterval(timer)
}, [])
const handleHack = () => {
setHacked(true)
setTimeout(() => onUnlock(), 1200)
}
return (
<AnimatePresence>
{!hacked ? (
<motion.div
className="fixed inset-0 z-[100] bg-black flex flex-col items-center justify-center font-mono"
exit={{ opacity: 0, filter: "blur(20px)", scale: 1.2 }}
transition={{ duration: 0.6, ease: "circIn" }}
>
{/* CRT Scanline Effect */}
<div className="absolute inset-0 pointer-events-none opacity-20" style={{ backgroundImage: 'linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06))', backgroundSize: '100% 2px, 3px 100%' }} />
<div className="text-emerald-500 text-xl md:text-3xl whitespace-pre-wrap text-center mb-10 h-32 tracking-widest" style={{ textShadow: '0 0 10px rgba(16, 185, 129, 0.5)' }}>
{text}<span className="animate-pulse">_</span>
</div>
{text.length >= fullText.length && (
<motion.button
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
onClick={handleHack}
className="px-8 py-3 border-2 border-red-500 text-red-500 hover:bg-red-500 hover:text-black hover:shadow-[0_0_30px_rgba(239,68,68,0.5)] transition-all uppercase tracking-[0.3em] font-bold z-10"
>
[ Initiate Hack ]
</motion.button>
)}
</motion.div>
) : (
<motion.div
className="fixed inset-0 z-[110] pointer-events-none flex items-center justify-center font-mono overflow-hidden"
exit={{ opacity: 0 }}
>
{/* Glitch visuals */}
<motion.div
className="text-red-500 text-4xl md:text-8xl font-black mix-blend-difference tracking-widest whitespace-nowrap"
animate={{ x: [-20, 20, -10, 30, 0], y: [10, -10, 20, -20, 0], opacity: [1, 0.5, 0.8, 0.2, 0], skewX: [0, 20, -20, 10, 0] }}
transition={{ duration: 0.5, repeat: 2 }}
style={{ textShadow: '-2px 0 cyan, 2px 0 red' }}
>
SYSTEM OVERRIDE
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,79 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Power } from 'lucide-react'
export default function LampEffect({ onUnlock }: { onUnlock: () => void }) {
const [mousePos, setMousePos] = useState({ x: -1000, y: -1000 })
const [isHoveringSwitch, setIsHoveringSwitch] = useState(false)
const [unlocked, setUnlocked] = useState(false)
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY })
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [])
const handleTurnOn = () => {
setUnlocked(true)
setTimeout(() => onUnlock(), 800) // Give time for flicker
}
return (
<AnimatePresence>
{!unlocked && (
<motion.div
className="fixed inset-0 z-[100] bg-black cursor-none"
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, delay: 0.3 }} // Fade out after flicker
>
{/* Flashlight mask */}
<div
className="absolute inset-0 pointer-events-none transition-opacity duration-300"
style={{
background: `radial-gradient(circle 180px at ${mousePos.x}px ${mousePos.y}px, transparent 0%, rgba(0,0,0,0.98) 100%)`
}}
/>
{/* The switch */}
<div className="absolute left-10 lg:left-1/4 top-1/3 flex flex-col items-center gap-4 z-10">
<button
onMouseEnter={() => setIsHoveringSwitch(true)}
onMouseLeave={() => setIsHoveringSwitch(false)}
onClick={handleTurnOn}
className={`w-16 h-16 rounded-full border-2 flex items-center justify-center transition-all duration-300 ${
isHoveringSwitch ? 'border-amber-400 bg-amber-400/20 text-amber-400 shadow-[0_0_30px_rgba(251,191,36,0.5)] scale-110' : 'border-white/20 text-white/40'
}`}
style={{ cursor: 'pointer' }}
>
<Power className="w-8 h-8" />
</button>
<p className="text-white/40 font-mono text-xs uppercase tracking-widest pointer-events-none">
Main Power
</p>
</div>
{/* Custom Cursor */}
<div
className="fixed w-4 h-4 rounded-full border border-white/50 pointer-events-none mix-blend-difference transition-transform duration-75"
style={{
left: mousePos.x - 8, top: mousePos.y - 8,
transform: isHoveringSwitch ? 'scale(0)' : 'scale(1)'
}}
/>
</motion.div>
)}
{/* Power On Flicker Effect */}
{unlocked && (
<motion.div
className="fixed inset-0 z-[110] bg-amber-50 mix-blend-overlay pointer-events-none"
animate={{ opacity: [0, 1, 0.2, 1, 0] }}
transition={{ duration: 0.4, times: [0, 0.1, 0.2, 0.3, 1] }}
/>
)}
</AnimatePresence>
)
}
+82
View File
@@ -0,0 +1,82 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuContent = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuSeparator = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuLabel = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuLabel,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuRadioGroup,
}
+55 -52
View File
@@ -3,34 +3,34 @@
@custom-variant dark (&:is(.dark *));
@theme {
--color-background: oklch(0.98 0.01 145);
--color-foreground: oklch(0.15 0.02 145);
--color-background: oklch(0.98 0.01 var(--theme-hue, 145));
--color-foreground: oklch(0.15 0.02 var(--theme-hue, 145));
--color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.15 0.02 145);
--color-card-foreground: oklch(0.15 0.02 var(--theme-hue, 145));
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.15 0.02 145);
--color-primary: oklch(0.55 0.12 145);
--color-popover-foreground: oklch(0.15 0.02 var(--theme-hue, 145));
--color-primary: oklch(var(--theme-l, 0.55) var(--theme-c, 0.12) var(--theme-hue, 145));
--color-primary-foreground: oklch(1 0 0);
--color-secondary: oklch(0.96 0.01 145);
--color-secondary-foreground: oklch(0.35 0.08 145);
--color-muted: oklch(0.96 0.01 145);
--color-muted-foreground: oklch(0.55 0.02 145);
--color-accent: oklch(0.96 0.01 145);
--color-accent-foreground: oklch(0.35 0.08 145);
--color-secondary: oklch(0.96 0.01 var(--theme-hue, 145));
--color-secondary-foreground: oklch(0.35 0.08 var(--theme-hue, 145));
--color-muted: oklch(0.96 0.01 var(--theme-hue, 145));
--color-muted-foreground: oklch(0.55 0.02 var(--theme-hue, 145));
--color-accent: oklch(0.96 0.01 var(--theme-hue, 145));
--color-accent-foreground: oklch(0.35 0.08 var(--theme-hue, 145));
--color-destructive: oklch(0.58 0.18 25);
--color-destructive-foreground: oklch(1 0 0);
--color-border: oklch(0.92 0.01 145);
--color-input: oklch(0.92 0.01 145);
--color-ring: oklch(0.55 0.12 145);
--color-border: oklch(0.92 0.01 var(--theme-hue, 145));
--color-input: oklch(0.92 0.01 var(--theme-hue, 145));
--color-ring: oklch(var(--theme-l, 0.55) var(--theme-c, 0.12) var(--theme-hue, 145));
--color-sidebar-background: oklch(0.985 0.005 100 / 0.8);
--color-sidebar-foreground: oklch(0.30 0.02 140);
--color-sidebar-primary: oklch(0.55 0.12 145);
--color-sidebar-background: oklch(0.985 0.005 var(--theme-hue, 145) / 0.8);
--color-sidebar-foreground: oklch(0.30 0.02 var(--theme-hue, 145));
--color-sidebar-primary: oklch(var(--theme-l, 0.55) var(--theme-c, 0.12) var(--theme-hue, 145));
--color-sidebar-primary-foreground: oklch(0.99 0 0);
--color-sidebar-accent: oklch(0.95 0.02 135);
--color-sidebar-accent-foreground: oklch(0.35 0.08 145);
--color-sidebar-border: oklch(0.90 0.01 110 / 0.3);
--color-sidebar-ring: oklch(0.55 0.12 145);
--color-sidebar-accent: oklch(0.95 0.02 var(--theme-hue, 145));
--color-sidebar-accent-foreground: oklch(0.35 0.08 var(--theme-hue, 145));
--color-sidebar-border: oklch(0.90 0.01 var(--theme-hue, 145) / 0.3);
--color-sidebar-ring: oklch(var(--theme-l, 0.55) var(--theme-c, 0.12) var(--theme-hue, 145));
--radius-lg: 1.5rem;
--radius-md: 1rem;
@@ -41,34 +41,34 @@
}
:root.dark {
--color-background: oklch(0.18 0.02 145);
--color-foreground: oklch(0.96 0.01 130);
--color-card: oklch(0.22 0.02 145);
--color-card-foreground: oklch(0.96 0.01 130);
--color-popover: oklch(0.22 0.02 145);
--color-popover-foreground: oklch(0.96 0.01 130);
--color-primary: oklch(0.65 0.15 145);
--color-primary-foreground: oklch(0.12 0.02 145);
--color-secondary: oklch(0.28 0.03 145);
--color-secondary-foreground: oklch(0.92 0.02 145);
--color-muted: oklch(0.25 0.02 145);
--color-muted-foreground: oklch(0.70 0.02 145);
--color-accent: oklch(0.28 0.03 145);
--color-accent-foreground: oklch(0.92 0.02 145);
--color-background: oklch(0.18 0.02 var(--theme-hue, 145));
--color-foreground: oklch(0.96 0.01 var(--theme-hue, 145));
--color-card: oklch(0.22 0.02 var(--theme-hue, 145));
--color-card-foreground: oklch(0.96 0.01 var(--theme-hue, 145));
--color-popover: oklch(0.22 0.02 var(--theme-hue, 145));
--color-popover-foreground: oklch(0.96 0.01 var(--theme-hue, 145));
--color-primary: oklch(var(--theme-l-dark, 0.65) var(--theme-c-dark, 0.15) var(--theme-hue, 145));
--color-primary-foreground: oklch(0.12 0.02 var(--theme-hue, 145));
--color-secondary: oklch(0.28 0.03 var(--theme-hue, 145));
--color-secondary-foreground: oklch(0.92 0.02 var(--theme-hue, 145));
--color-muted: oklch(0.25 0.02 var(--theme-hue, 145));
--color-muted-foreground: oklch(0.70 0.02 var(--theme-hue, 145));
--color-accent: oklch(0.28 0.03 var(--theme-hue, 145));
--color-accent-foreground: oklch(0.92 0.02 var(--theme-hue, 145));
--color-destructive: oklch(0.58 0.18 25);
--color-destructive-foreground: oklch(0.99 0 0);
--color-border: oklch(0.32 0.03 145);
--color-input: oklch(0.32 0.03 145);
--color-ring: oklch(0.65 0.15 145);
--color-border: oklch(0.32 0.03 var(--theme-hue, 145));
--color-input: oklch(0.32 0.03 var(--theme-hue, 145));
--color-ring: oklch(var(--theme-l-dark, 0.65) var(--theme-c-dark, 0.15) var(--theme-hue, 145));
--color-sidebar-background: oklch(0.16 0.02 145 / 0.8);
--color-sidebar-foreground: oklch(0.85 0.02 145);
--color-sidebar-primary: oklch(0.65 0.15 145);
--color-sidebar-primary-foreground: oklch(0.12 0.02 145);
--color-sidebar-accent: oklch(0.28 0.03 145);
--color-sidebar-accent-foreground: oklch(0.92 0.02 145);
--color-sidebar-border: oklch(0.32 0.03 145 / 0.3);
--color-sidebar-ring: oklch(0.65 0.15 145);
--color-sidebar-background: oklch(0.16 0.02 var(--theme-hue, 145) / 0.8);
--color-sidebar-foreground: oklch(0.85 0.02 var(--theme-hue, 145));
--color-sidebar-primary: oklch(var(--theme-l-dark, 0.65) var(--theme-c-dark, 0.15) var(--theme-hue, 145));
--color-sidebar-primary-foreground: oklch(0.12 0.02 var(--theme-hue, 145));
--color-sidebar-accent: oklch(0.28 0.03 var(--theme-hue, 145));
--color-sidebar-accent-foreground: oklch(0.92 0.02 var(--theme-hue, 145));
--color-sidebar-border: oklch(0.32 0.03 var(--theme-hue, 145) / 0.3);
--color-sidebar-ring: oklch(var(--theme-l-dark, 0.65) var(--theme-c-dark, 0.15) var(--theme-hue, 145));
}
* {
@@ -88,15 +88,15 @@ body {
line-height: 1.6;
letter-spacing: -0.015em;
background-image:
radial-gradient(circle at 15% 50%, oklch(0.55 0.12 145 / 0.05), transparent 25%),
radial-gradient(circle at 85% 30%, oklch(0.65 0.15 170 / 0.06), transparent 25%);
radial-gradient(circle at 15% 50%, oklch(0.55 0.12 var(--theme-hue, 145) / 0.05), transparent 25%),
radial-gradient(circle at 85% 30%, oklch(0.65 0.15 var(--theme-hue, 145) / 0.06), transparent 25%);
background-attachment: fixed;
}
:root.dark body {
background-image:
radial-gradient(circle at 15% 50%, oklch(0.55 0.12 145 / 0.15), transparent 25%),
radial-gradient(circle at 85% 30%, oklch(0.65 0.15 170 / 0.15), transparent 25%);
radial-gradient(circle at 15% 50%, oklch(0.55 0.12 var(--theme-hue, 145) / 0.15), transparent 25%),
radial-gradient(circle at 85% 30%, oklch(0.65 0.15 var(--theme-hue, 145) / 0.15), transparent 25%);
}
/* Scrollbar */
@@ -142,5 +142,8 @@ input:focus, textarea:focus, select:focus { @apply transition-shadow duration-15
.glass-card:hover { @apply shadow-soft-lg -translate-y-1 bg-card/90 border-white/80 dark:border-white/20; }
/* Skeleton */
.skeleton { @apply bg-muted animate-pulse rounded;
}
.skeleton { @apply bg-muted animate-pulse rounded; }
/* Scrollbar hide */
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
.scrollbar-none::-webkit-scrollbar { display: none; }
+71 -12
View File
@@ -1,9 +1,11 @@
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import CommandPalette from '@/components/CommandPalette'
import {
LayoutDashboard, Users, Shield, MessageSquare, FolderTree, Leaf,
LogOut, ChevronDown, Menu, FileText, Settings, Book, Home, Monitor,
Hash, Folder, ChevronRight, Search, Bell, ChevronLeft, Bot, Radio,
Image, Music, ScrollText, Sun, Moon,
Image, Music, ScrollText, Sun, Moon, Trophy, Award, Star, Gift, List,
} from 'lucide-react'
import { useState, useMemo, useEffect } from 'react'
import { useAuthStore } from '@/store/auth'
@@ -12,11 +14,11 @@ import type { SystemMenu } from '@/api/system'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ScrollArea } from '@/components/ui/scroll-area'
import TabBar from '@/components/TabBar'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
// Icon mapping
@@ -51,6 +53,11 @@ const iconMap: Record<string, React.ReactNode> = {
music: <Music className="h-4 w-4" />,
scroll: <ScrollText className="h-4 w-4" />,
log: <ScrollText className="h-4 w-4" />,
trophy: <Trophy className="h-4 w-4" />,
award: <Award className="h-4 w-4" />,
star: <Star className="h-4 w-4" />,
gift: <Gift className="h-4 w-4" />,
list: <List className="h-4 w-4" />,
}
function getIcon(iconName?: string): React.ReactNode {
@@ -195,25 +202,62 @@ export default function AdminLayout() {
// ==================== Shell Render ====================
import { useRef } from 'react'
function LayoutShell({ sidebarOpen, mobileMenuOpen, toggleSidebar, setMobileMenu, navItems, user, onLogout }: {
sidebarOpen: boolean; mobileMenuOpen: boolean; toggleSidebar: () => void; setMobileMenu: (v: boolean) => void
navItems: NavItem[]; user: import('@/api/system').SystemUser | null; onLogout: () => void
}) {
const { setCmdKOpen } = useAppStore()
const location = useLocation()
const spotlightRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (spotlightRef.current) {
spotlightRef.current.style.background = `radial-gradient(600px circle at ${e.clientX}px ${e.clientY}px, rgba(6,182,212,0.25), transparent 40%)`
}
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [])
return (
<div className="flex min-h-screen bg-background p-2 lg:p-3 gap-2 lg:gap-3">
<div className="flex min-h-screen bg-gradient-to-br from-slate-100 via-white to-cyan-50 dark:from-[#080d17] dark:via-[#080d17] dark:to-[#080d17] p-2 lg:p-3 gap-2 lg:gap-3 relative overflow-hidden">
{/* Interactive Cursor Spotlight (Illuminates the glass as you move the mouse) */}
<div
ref={spotlightRef}
className="pointer-events-none fixed inset-0 z-0 transition-opacity duration-300 opacity-100 dark:opacity-60"
style={{
background: `radial-gradient(600px circle at -1000px -1000px, rgba(6,182,212,0.25), transparent 40%)`
}}
/>
{/* Global Ambient Background */}
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
{/* Cyan Glow Top Left */}
<div className="absolute top-[-20%] left-[-10%] w-[50vw] h-[50vw] max-w-[800px] max-h-[800px] bg-cyan-200/40 dark:bg-cyan-600/20 rounded-full blur-[100px] dark:blur-[120px] mix-blend-multiply dark:mix-blend-screen" />
{/* Purple Glow Bottom Right */}
<div className="absolute bottom-[-20%] right-[-10%] w-[50vw] h-[50vw] max-w-[800px] max-h-[800px] bg-fuchsia-200/40 dark:bg-fuchsia-600/20 rounded-full blur-[100px] dark:blur-[120px] mix-blend-multiply dark:mix-blend-screen" />
{/* Subtle Tech Grid */}
<div className="absolute inset-0 opacity-[0.04] dark:opacity-[0.05]" style={{ backgroundImage: 'linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)', backgroundSize: '60px 60px' }} />
</div>
<CommandPalette />
{/* Sidebar */}
<aside className={cn(
'fixed inset-y-2 lg:inset-y-3 left-2 lg:left-3 z-40 flex flex-col rounded-2xl border border-white/40 dark:border-white/5 glass-panel transition-all duration-300 overflow-hidden',
'fixed inset-y-2 lg:inset-y-3 left-2 lg:left-3 z-40 flex flex-col rounded-2xl border border-white/60 dark:border-white/10 bg-white/60 dark:bg-black/40 backdrop-blur-2xl shadow-lg transition-all duration-300 overflow-hidden',
sidebarOpen ? 'w-[260px]' : 'w-20',
mobileMenuOpen ? 'translate-x-0' : '-translate-x-[120%] lg:translate-x-0'
)}>
{/* Logo */}
<div className="flex h-16 items-center justify-between px-5 shrink-0">
<div className={cn("flex items-center gap-3 overflow-hidden transition-all", !sidebarOpen && "justify-center w-full")}>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 text-white shadow-lg shadow-emerald-500/20">
<Leaf className="h-4 w-4" />
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white shadow-sm overflow-hidden border border-slate-200/60 dark:border-white/10">
<img src="/logo.png" alt="Logo" className="w-full h-full object-cover" />
</div>
{sidebarOpen && <span className="font-bold text-lg tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-emerald-600 to-teal-600 truncate">Sundynix Console</span>}
{sidebarOpen && <span className="font-bold text-lg tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-emerald-600 to-teal-600 truncate">Sundynix Cloud</span>}
</div>
</div>
{/* Nav */}
@@ -259,7 +303,7 @@ function LayoutShell({ sidebarOpen, mobileMenuOpen, toggleSidebar, setMobileMenu
{/* Main */}
<div className={cn(
'flex-1 flex flex-col min-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1.5rem)] transition-all duration-300 bg-white/40 dark:bg-slate-900/40 rounded-2xl border border-white/50 dark:border-white/5 shadow-soft overflow-hidden relative backdrop-blur-xl',
'relative z-10 flex-1 flex flex-col min-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1.5rem)] transition-all duration-300 bg-white/60 dark:bg-black/30 rounded-2xl border border-white/60 dark:border-white/10 shadow-xl overflow-hidden backdrop-blur-2xl',
sidebarOpen ? 'lg:ml-[268px]' : 'lg:ml-[88px]'
)}>
{/* Header */}
@@ -277,9 +321,12 @@ function LayoutShell({ sidebarOpen, mobileMenuOpen, toggleSidebar, setMobileMenu
</div>
</div>
<div className="flex items-center gap-3">
<div className="hidden md:flex relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground group-focus-within:text-emerald-500 transition-colors" />
<Input placeholder="全局搜索..." className="w-56 focus:w-72 pl-9 bg-white/50 dark:bg-black/20 border-white/40 dark:border-white/10 focus:bg-white dark:focus:bg-slate-800 focus:border-emerald-500/30 transition-all h-9 text-sm rounded-full shadow-sm" />
<div className="hidden md:flex relative group cursor-pointer" onClick={() => setCmdKOpen(true)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground group-focus-within:text-primary transition-colors" />
<div className="flex items-center w-56 pl-9 pr-3 bg-white/50 dark:bg-black/20 border border-white/40 dark:border-white/10 hover:bg-white dark:hover:bg-slate-800 hover:border-primary/30 transition-all h-9 text-sm rounded-full shadow-sm text-muted-foreground">
<span className="flex-1 text-left">...</span>
<span className="text-[10px] border border-border px-1.5 rounded bg-background/50 leading-tight">K</span>
</div>
</div>
<Button variant="ghost" size="icon" className="relative rounded-full h-9 w-9 hover:bg-white/50 dark:hover:bg-white/10"
onClick={() => document.documentElement.classList.toggle('dark')}>
@@ -292,11 +339,23 @@ function LayoutShell({ sidebarOpen, mobileMenuOpen, toggleSidebar, setMobileMenu
</Button>
</div>
</header>
{/* TabBar */}
<TabBar />
{/* Content */}
<ScrollArea className="flex-1">
<main className="p-4 lg:p-6 relative z-0 min-h-full">
<div className="mx-auto w-full max-w-[1600px] animate-fade-in-up space-y-6">
<div className="mx-auto w-full max-w-[1600px] space-y-6">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.2 }}
>
<Outlet />
</motion.div>
</AnimatePresence>
</div>
</main>
</ScrollArea>
+5
View File
@@ -16,6 +16,11 @@ export function paginate<T>(list: T[], current: number, pageSize: number): { lis
}
}
/** Wrap mock data in the standard backend response format: {code, data, msg} */
export function mockResponse<T>(data: T, msg = '操作成功'): { code: number; data: T; msg: string } {
return { code: 200, data, msg }
}
let _idCounter = 1000
export function mockId(): string {
return String(++_idCounter)
+46 -22
View File
@@ -1,44 +1,68 @@
import { mockDate } from '../index'
import type { SystemMenu } from '@/api/system'
const d = (days: number) => mockDate(days)
export const mockMenuTree: SystemMenu[] = [
{
id: '1', name: 'dashboard', title: '仪表盘', path: '/dashboard', icon: 'dashboard',
sort: 0, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1),
sort: 0, category: 1, createdAt: d(90), updatedAt: d(1),
},
{
id: '10', name: 'system', title: '系统管理', path: '/system', icon: 'settings',
sort: 1, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1),
sort: 1, category: 1, createdAt: d(90), updatedAt: d(1),
children: [
{ id: '11', name: 'users', title: '用户管理', path: '/system/users', icon: 'users', parentId: '10', sort: 0, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '12', name: 'roles', title: '角色管理', path: '/system/roles', icon: 'shield', parentId: '10', sort: 1, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '13', name: 'menus', title: '菜单管理', path: '/system/menus', icon: 'menu', parentId: '10', sort: 2, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '14', name: 'clients', title: '客户端管理', path: '/system/clients', icon: 'monitor', parentId: '10', sort: 3, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '15', name: 'files', title: '文件管理', path: '/system/files', icon: 'folder', parentId: '10', sort: 4, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '16', name: 'logs', title: '操作日志', path: '/system/logs', icon: 'scroll', parentId: '10', sort: 5, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '11', name: 'users', title: '用户管理', path: '/system/users', icon: 'users', parentId: '10', sort: 0, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '12', name: 'roles', title: '角色管理', path: '/system/roles', icon: 'shield', parentId: '10', sort: 1, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '13', name: 'menus', title: '菜单管理', path: '/system/menus', icon: 'menu', parentId: '10', sort: 2, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '14', name: 'clients', title: '客户端管理', path: '/system/clients', icon: 'monitor', parentId: '10', sort: 3, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '15', name: 'files', title: '文件管理', path: '/system/files', icon: 'folder', parentId: '10', sort: 4, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '16', name: 'logs', title: '操作日志', path: '/system/logs', icon: 'scroll', parentId: '10', sort: 5, category: 1, createdAt: d(90), updatedAt: d(1) },
],
},
{
id: '20', name: 'plant', title: 'Plant 服务', path: '/plant', icon: 'leaf',
sort: 2, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1),
id: '20', name: 'plant', title: '植趣', path: '/plant', icon: 'leaf',
sort: 2, category: 1, createdAt: d(90), updatedAt: d(1),
children: [
{ id: '21', name: 'wiki', title: '植物百科', path: '/plant/wiki/wiki', icon: 'book', parentId: '20', sort: 0, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '22', name: 'wikiClass', title: '百科分类', path: '/plant/wiki/class', icon: 'tree', parentId: '20', sort: 1, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '23', name: 'banner', title: '轮播图', path: '/plant/banner', icon: 'image', parentId: '20', sort: 2, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '24', name: 'topic', title: '话题管理', path: '/plant/community/topic', icon: 'message', parentId: '20', sort: 3, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '25', name: 'post', title: '帖子管理', path: '/plant/community/post', icon: 'file-text', parentId: '20', sort: 4, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '26', name: 'exchangeItem', title: '兑换商品', path: '/plant/exchange', icon: 'hash', parentId: '20', sort: 5, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '27', name: 'aiConfig', title: 'AI 配置', path: '/plant/conf/aiconfig', icon: 'bot', parentId: '20', sort: 7, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{
id: '200', name: 'achievement', title: '成就配置', path: '/plant/achievement', icon: 'trophy', parentId: '20', sort: 0, category: 1, createdAt: d(90), updatedAt: d(1),
children: [
{ id: '201', name: 'badge', title: '徽章配置', path: '/plant/achievement/badge', icon: 'award', parentId: '200', sort: 0, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '202', name: 'level', title: '等级配置', path: '/plant/achievement/level', icon: 'star', parentId: '200', sort: 1, category: 1, createdAt: d(90), updatedAt: d(1) },
],
},
{
id: '210', name: 'community', title: '社区管理', path: '/plant/community', icon: 'message', parentId: '20', sort: 1, category: 1, createdAt: d(90), updatedAt: d(1),
children: [
{ id: '211', name: 'topic', title: '话题管理', path: '/plant/community/topic', icon: 'message', parentId: '210', sort: 0, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '212', name: 'post', title: '帖子管理', path: '/plant/community/post', icon: 'file-text', parentId: '210', sort: 1, category: 1, createdAt: d(90), updatedAt: d(1) },
],
},
{
id: '220', name: 'wiki', title: '百科管理', path: '/plant/wiki', icon: 'book', parentId: '20', sort: 2, category: 1, createdAt: d(90), updatedAt: d(1),
children: [
{ id: '221', name: 'wikiClass', title: '分类管理', path: '/plant/wiki/class', icon: 'tree', parentId: '220', sort: 0, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '222', name: 'wikiList', title: '植物百科', path: '/plant/wiki/wiki', icon: 'book', parentId: '220', sort: 1, category: 1, createdAt: d(90), updatedAt: d(1) },
],
},
{
id: '230', name: 'exchange', title: '兑换中心', path: '/plant/exchange', icon: 'gift', parentId: '20', sort: 3, category: 1, createdAt: d(90), updatedAt: d(1),
children: [
{ id: '231', name: 'exchangeOrder', title: '兑换订单', path: '/plant/exchange/order', icon: 'list', parentId: '230', sort: 0, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '232', name: 'exchangeConfig', title: '兑换配置', path: '/plant/exchange/config', icon: 'settings', parentId: '230', sort: 1, category: 1, createdAt: d(90), updatedAt: d(1) },
],
},
{ id: '240', name: 'banner', title: '小程序首页轮播', path: '/plant/banner', icon: 'image', parentId: '20', sort: 4, category: 1, createdAt: d(90), updatedAt: d(1) },
],
},
{
id: '30', name: 'radio', title: 'Radio 服务', path: '/radio', icon: 'radio',
sort: 3, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1),
sort: 3, category: 1, createdAt: d(90), updatedAt: d(1),
children: [
{ id: '31', name: 'channel', title: '频道管理', path: '/radio/channel', icon: 'radio', parentId: '30', sort: 0, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '32', name: 'program', title: '节目管理', path: '/radio/program', icon: 'music', parentId: '30', sort: 1, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '33', name: 'radioCategory', title: '频道分类', path: '/radio/category', icon: 'category', parentId: '30', sort: 2, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '34', name: 'subscription', title: '订阅管理', path: '/radio/subscription', icon: 'user', parentId: '30', sort: 3, category: 1, createdAt: mockDate(90), updatedAt: mockDate(1) },
{ id: '31', name: 'channel', title: '频道管理', path: '/radio/channel', icon: 'radio', parentId: '30', sort: 0, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '32', name: 'program', title: '节目管理', path: '/radio/program', icon: 'music', parentId: '30', sort: 1, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '33', name: 'radioCategory', title: '频道分类', path: '/radio/category', icon: 'category', parentId: '30', sort: 2, category: 1, createdAt: d(90), updatedAt: d(1) },
{ id: '34', name: 'subscription', title: '订阅管理', path: '/radio/subscription', icon: 'user', parentId: '30', sort: 3, category: 1, createdAt: d(90), updatedAt: d(1) },
],
},
]
+38 -17
View File
@@ -1,12 +1,20 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Leaf, Loader2, Eye, EyeOff, Disc3 } from 'lucide-react'
import { Loader2, Eye, EyeOff, Disc3 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAuthStore } from '@/store/auth'
import { login as apiLogin, getCaptcha } from '@/api/system'
import AIBanner from '@/components/AIBanner'
import LoginCharacters from '@/components/LoginCharacters'
import ParticleBackground from '@/components/ParticleBackground'
import Tilt from 'react-parallax-tilt'
import LampEffect from '@/components/login-effects/LampEffect'
import HackerEffect from '@/components/login-effects/HackerEffect'
import BiometricEffect from '@/components/login-effects/BiometricEffect'
import AICoreEffect from '@/components/login-effects/AICoreEffect'
import { MonitorPlay, Fingerprint, Terminal, Lightbulb, Cpu } from 'lucide-react'
export default function LoginPage() {
const [account, setAccount] = useState('admin')
@@ -18,6 +26,7 @@ export default function LoginPage() {
const [pwdFocused, setPwdFocused] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [unlockMode, setUnlockMode] = useState<'none' | 'lamp' | 'hacker' | 'biometric' | 'ai_core'>('ai_core')
const navigate = useNavigate()
const loginStore = useAuthStore(s => s.login)
@@ -48,22 +57,36 @@ export default function LoginPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#f3f4f6] p-6 relative">
{/* Background dot pattern */}
<div className="absolute inset-0 z-0 opacity-[0.03] pointer-events-none" style={{
backgroundImage: 'radial-gradient(circle, #000 1px, transparent 1px)',
backgroundSize: '24px 24px'
}} />
<div className="min-h-screen flex items-center justify-center bg-[#050B14] p-6 relative overflow-hidden">
{/* AI Tech Background replacing the old dot pattern */}
<div className="absolute inset-0 z-0">
<AIBanner />
</div>
<ParticleBackground />
<div className="relative z-10 w-full max-w-[1000px] grid grid-cols-1 lg:grid-cols-2 bg-white rounded-[2rem] shadow-2xl shadow-emerald-900/10 overflow-hidden ring-1 ring-black/5">
{unlockMode === 'lamp' && <LampEffect onUnlock={() => setUnlockMode('none')} />}
{unlockMode === 'hacker' && <HackerEffect onUnlock={() => setUnlockMode('none')} />}
{unlockMode === 'biometric' && <BiometricEffect onUnlock={() => setUnlockMode('none')} />}
{unlockMode === 'ai_core' && <AICoreEffect onUnlock={() => setUnlockMode('none')} />}
{/* Mode Switcher Widget */}
<div className="fixed bottom-4 left-4 z-[200] flex flex-col md:flex-row gap-2 bg-slate-900/50 backdrop-blur-md border border-white/10 p-1.5 md:p-2 rounded-2xl md:rounded-full shadow-lg text-white">
<button onClick={() => setUnlockMode('lamp')} className={`p-2 rounded-full transition-all ${unlockMode === 'lamp' ? 'bg-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.5)]' : 'hover:bg-white/10'}`} title="暗室寻光 (Lamp)"><Lightbulb className="w-5 h-5" /></button>
<button onClick={() => setUnlockMode('hacker')} className={`p-2 rounded-full transition-all ${unlockMode === 'hacker' ? 'bg-red-500 shadow-[0_0_15px_rgba(239,68,68,0.5)]' : 'hover:bg-white/10'}`} title="骇客入侵 (Hacker)"><Terminal className="w-5 h-5" /></button>
<button onClick={() => setUnlockMode('biometric')} className={`p-2 rounded-full transition-all ${unlockMode === 'biometric' ? 'bg-cyan-500 shadow-[0_0_15px_rgba(6,182,212,0.5)]' : 'hover:bg-white/10'}`} title="生物解锁 (Biometric)"><Fingerprint className="w-5 h-5" /></button>
<button onClick={() => setUnlockMode('ai_core')} className={`p-2 rounded-full transition-all ${unlockMode === 'ai_core' ? 'bg-fuchsia-500 shadow-[0_0_15px_rgba(217,70,239,0.5)]' : 'hover:bg-white/10'}`} title="神经链接 (AI Core)"><Cpu className="w-5 h-5" /></button>
<div className="w-px h-6 bg-white/20 mx-1 hidden md:block self-center" />
<div className="h-px w-6 bg-white/20 my-1 md:hidden self-center" />
<button onClick={() => setUnlockMode('none')} className={`p-2 rounded-full transition-all ${unlockMode === 'none' ? 'bg-white/20' : 'hover:bg-white/10'}`} title="直接进入 (None)"><MonitorPlay className="w-5 h-5" /></button>
</div>
<Tilt tiltMaxAngleX={3} tiltMaxAngleY={3} scale={1.02} transitionSpeed={2500} className="relative z-10 w-full max-w-[1000px]">
<div className="grid grid-cols-1 lg:grid-cols-2 bg-transparent rounded-[2rem] shadow-[0_0_50px_rgba(0,0,0,0.5)] overflow-hidden ring-1 ring-white/10">
{/* Left — Characters Banner */}
<div className="relative hidden lg:flex flex-col justify-between bg-slate-800 p-10 text-white overflow-hidden">
<div className="relative hidden lg:flex flex-col justify-between bg-slate-900/40 backdrop-blur-md p-10 text-white overflow-hidden">
<div className="relative z-20 flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Leaf className="w-6 h-6 text-white" />
</div>
<span className="text-xl font-bold tracking-tight">Sundynix Console</span>
<span className="text-xl font-bold tracking-tight">Sundynix Cloud</span>
</div>
<div className="relative z-20 flex items-end justify-center mt-auto" style={{ height: 320 }}>
@@ -84,13 +107,10 @@ export default function LoginPage() {
</div>
{/* Right — Login Form */}
<div className="flex flex-col items-center justify-center px-8 py-16 sm:px-12 bg-white relative">
<div className="flex flex-col items-center justify-center px-8 py-16 sm:px-12 bg-white/95 backdrop-blur-2xl relative">
<div className="w-full max-w-[360px]">
{/* Mobile logo */}
<div className="lg:hidden flex flex-col items-center justify-center gap-3 mb-10">
<div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Leaf className="w-7 h-7 text-white" />
</div>
<span className="text-2xl font-bold tracking-tight text-slate-900">Sundynix Console</span>
</div>
@@ -156,6 +176,7 @@ export default function LoginPage() {
</div>
</div>
</div>
</Tilt>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react'
import { Plus, Search, Edit, Trash2, Award, RefreshCw, MoreHorizontal, Image } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
// Mock data
const mockBadges = [
{ id: '1', name: '绿色新手', icon: '🌱', description: '完成首次浇水', condition: '首次浇水', category: 'beginner', sort: 0, isActive: true },
{ id: '2', name: '植物猎人', icon: '🌿', description: '识别10种植物', condition: '识别10种植物', category: 'explore', sort: 1, isActive: true },
{ id: '3', name: '花园大师', icon: '🌸', description: '养活50株植物', condition: '养活50株植物', category: 'master', sort: 2, isActive: true },
{ id: '4', name: '社区之星', icon: '⭐', description: '帖子获得100赞', condition: '获得100赞', category: 'social', sort: 3, isActive: true },
{ id: '5', name: '百科贡献者', icon: '📚', description: '贡献5篇百科', condition: '贡献5篇百科', category: 'wiki', sort: 4, isActive: false },
{ id: '6', name: '连续打卡7天', icon: '🔥', description: '连续签到7天', condition: '连续7天签到', category: 'checkin', sort: 5, isActive: true },
]
const categoryMap: Record<string, string> = {
beginner: '新手入门', explore: '探索发现', master: '大师成就',
social: '社区互动', wiki: '百科贡献', checkin: '签到打卡',
}
export default function BadgePage() {
const [badges] = useState(mockBadges)
const [dialogOpen, setDialogOpen] = useState(false)
const [search, setSearch] = useState('')
const filtered = badges.filter(b => !search || b.name.includes(search))
return (
<div className="space-y-6 animate-fadeIn">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
<Card className="border-border/60 shadow-soft">
<CardHeader className="pb-3 border-b border-border/40">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<CardTitle className="text-lg flex items-center gap-2">
<Award className="h-5 w-5 text-primary" />
<Badge variant="secondary" className="text-xs">{filtered.length}</Badge>
</CardTitle>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="搜索徽章..." className="pl-9 w-48 h-9" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Button size="sm" onClick={() => setDialogOpen(true)} className="h-9 gap-1.5">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="pl-6 w-[60px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(b => (
<TableRow key={b.id} className="group hover:bg-muted/20">
<TableCell className="pl-6 text-2xl">{b.icon}</TableCell>
<TableCell>
<div><span className="font-medium">{b.name}</span></div>
<div className="text-xs text-muted-foreground">{b.description}</div>
</TableCell>
<TableCell><span className="text-sm bg-muted px-2 py-0.5 rounded font-mono">{b.condition}</span></TableCell>
<TableCell><Badge variant="outline" className="bg-primary/5">{categoryMap[b.category]}</Badge></TableCell>
<TableCell className="font-mono text-muted-foreground">{b.sort}</TableCell>
<TableCell>
{b.isActive
? <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 shadow-none"></Badge>
: <Badge variant="secondary" className="shadow-none"></Badge>}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem><Edit className="mr-2 h-4 w-4" /> </DropdownMenuItem>
<DropdownMenuItem className="text-red-500"><Trash2 className="mr-2 h-4 w-4" /> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" placeholder="如: 绿色新手" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" placeholder="使用 emoji 或上传图片" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" placeholder="如: 首次浇水" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Select defaultValue="beginner">
<SelectTrigger className="col-span-3"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(categoryMap).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={() => setDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useState } from 'react'
import { Plus, Search, Edit, Trash2, Star, MoreHorizontal, ArrowUp } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
const mockLevels = [
{ id: '1', level: 1, name: '种子', icon: '🌱', minExp: 0, maxExp: 100, color: '#a3a3a3', rewards: '解锁基本功能' },
{ id: '2', level: 2, name: '嫩芽', icon: '🌿', minExp: 100, maxExp: 300, color: '#86efac', rewards: '解锁社区发帖' },
{ id: '3', level: 3, name: '绿叶', icon: '🍃', minExp: 300, maxExp: 600, color: '#4ade80', rewards: '解锁识别加速' },
{ id: '4', level: 4, name: '花蕾', icon: '🌷', minExp: 600, maxExp: 1000, color: '#f9a8d4', rewards: '头像框·花蕾' },
{ id: '5', level: 5, name: '盛花', icon: '🌸', minExp: 1000, maxExp: 2000, color: '#f472b6', rewards: '专属称号·花园达人' },
{ id: '6', level: 6, name: '果实', icon: '🍎', minExp: 2000, maxExp: 5000, color: '#ef4444', rewards: '兑换折扣·9折' },
{ id: '7', level: 7, name: '大树', icon: '🌳', minExp: 5000, maxExp: 99999, color: '#22c55e', rewards: '至尊头像框 + 专属客服' },
]
export default function LevelPage() {
const [levels] = useState(mockLevels)
const [dialogOpen, setDialogOpen] = useState(false)
return (
<div className="space-y-6 animate-fadeIn">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
<Card className="border-border/60 shadow-soft">
<CardHeader className="pb-3 border-b border-border/40">
<div className="flex justify-between items-center">
<CardTitle className="text-lg flex items-center gap-2">
<Star className="h-5 w-5 text-primary" />
<Badge variant="secondary" className="text-xs">{levels.length} </Badge>
</CardTitle>
<Button size="sm" onClick={() => setDialogOpen(true)} className="h-9 gap-1.5">
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="pl-6 w-[70px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{levels.map(lv => (
<TableRow key={lv.id} className="group hover:bg-muted/20">
<TableCell className="pl-6">
<div className="flex items-center justify-center w-8 h-8 rounded-full font-bold text-white text-sm" style={{ background: lv.color }}>
{lv.level}
</div>
</TableCell>
<TableCell className="text-2xl">{lv.icon}</TableCell>
<TableCell className="font-semibold">{lv.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-sm">
<span className="font-mono bg-muted px-1.5 py-0.5 rounded">{lv.minExp}</span>
<ArrowUp className="h-3 w-3 text-muted-foreground rotate-90" />
<span className="font-mono bg-muted px-1.5 py-0.5 rounded">{lv.maxExp}</span>
<span className="text-xs text-muted-foreground ml-1">EXP</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">{lv.rewards}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem><Edit className="mr-2 h-4 w-4" /> </DropdownMenuItem>
<DropdownMenuItem className="text-red-500"><Trash2 className="mr-2 h-4 w-4" /> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" type="number" placeholder="如: 8" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" placeholder="如: 花仙" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" type="number" placeholder="10000" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" placeholder="描述升级后获得的奖励" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={() => setDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { useState } from 'react'
import { Plus, Search, Edit, Trash2, Gift, MoreHorizontal } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Textarea } from '@/components/ui/textarea'
const mockItems = [
{ id: '1', name: '多肉植物盆栽', icon: '🪴', points: 500, stock: 50, sold: 23, category: '绿植', isActive: true, description: '精选多肉植物,含陶瓷花盆' },
{ id: '2', name: '园艺工具套装', icon: '🔧', points: 800, stock: 30, sold: 12, category: '工具', isActive: true, description: '包含铲子、剪刀、喷壶' },
{ id: '3', name: '植物生长灯', icon: '💡', points: 1200, stock: 20, sold: 8, category: '设备', isActive: true, description: 'LED全光谱补光灯' },
{ id: '4', name: '有机肥料包', icon: '🌿', points: 300, stock: 100, sold: 67, category: '肥料', isActive: true, description: '天然有机肥料 500g' },
{ id: '5', name: '花盆三件套', icon: '🏺', points: 600, stock: 0, sold: 45, category: '花盆', isActive: false, description: '陶瓷花盆 大中小三件套' },
{ id: '6', name: '种子礼盒', icon: '🌱', points: 200, stock: 200, sold: 156, category: '种子', isActive: true, description: '含向日葵、薄荷、薰衣草种子各一包' },
]
export default function ExchangeConfigPage() {
const [items] = useState(mockItems)
const [dialogOpen, setDialogOpen] = useState(false)
const [search, setSearch] = useState('')
const filtered = items.filter(i => !search || i.name.includes(search))
return (
<div className="space-y-6 animate-fadeIn">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
<Card className="border-border/60 shadow-soft">
<CardHeader className="pb-3 border-b border-border/40">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<CardTitle className="text-lg flex items-center gap-2">
<Gift className="h-5 w-5 text-primary" />
<Badge variant="secondary" className="text-xs">{filtered.length}</Badge>
</CardTitle>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="搜索商品..." className="pl-9 w-48 h-9" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Button size="sm" onClick={() => setDialogOpen(true)} className="h-9 gap-1.5">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="pl-6 w-[60px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> / </TableHead>
<TableHead></TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(item => (
<TableRow key={item.id} className="group hover:bg-muted/20">
<TableCell className="pl-6 text-2xl">{item.icon}</TableCell>
<TableCell>
<div><span className="font-medium">{item.name}</span></div>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">{item.description}</div>
</TableCell>
<TableCell><Badge variant="outline" className="bg-primary/5">{item.category}</Badge></TableCell>
<TableCell><span className="font-mono text-amber-600 font-semibold">{item.points}</span></TableCell>
<TableCell>
<span className={`font-mono text-sm ${item.stock === 0 ? 'text-red-500' : 'text-foreground'}`}>
{item.stock}
</span>
<span className="text-muted-foreground mx-1">/</span>
<span className="font-mono text-sm text-muted-foreground">{item.sold}</span>
</TableCell>
<TableCell>
{item.isActive
? <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 shadow-none"></Badge>
: <Badge variant="secondary" className="shadow-none"></Badge>}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem><Edit className="mr-2 h-4 w-4" /> </DropdownMenuItem>
<DropdownMenuItem className="text-red-500"><Trash2 className="mr-2 h-4 w-4" /> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" placeholder="如: 多肉植物盆栽" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" type="number" placeholder="500" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Input className="col-span-3" type="number" placeholder="100" />
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label className="text-right pt-2"></Label>
<Textarea className="col-span-3" placeholder="商品描述..." rows={3} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={() => setDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useState } from 'react'
import { Search, RefreshCw, Eye, Package, Clock, CheckCircle2, XCircle, Truck } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
const statusMap: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
pending: { label: '待发货', color: 'bg-amber-500/10 text-amber-600 border-amber-200', icon: <Clock className="h-3.5 w-3.5" /> },
shipped: { label: '已发货', color: 'bg-blue-500/10 text-blue-600 border-blue-200', icon: <Truck className="h-3.5 w-3.5" /> },
completed: { label: '已完成', color: 'bg-emerald-500/10 text-emerald-600 border-emerald-200', icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
cancelled: { label: '已取消', color: 'bg-red-500/10 text-red-500 border-red-200', icon: <XCircle className="h-3.5 w-3.5" /> },
}
const mockOrders = [
{ id: 'EX20260401001', userId: 'u1', userName: '张三', itemName: '多肉植物盆栽', itemIcon: '🪴', points: 500, status: 'pending', createdAt: '2026-04-01 10:30', address: '广州市天河区xxx路' },
{ id: 'EX20260402002', userId: 'u2', userName: '李四', itemName: '园艺工具套装', itemIcon: '🔧', points: 800, status: 'shipped', createdAt: '2026-04-02 14:20', address: '深圳市南山区xxx路' },
{ id: 'EX20260403003', userId: 'u3', userName: '王五', itemName: '植物生长灯', itemIcon: '💡', points: 1200, status: 'completed', createdAt: '2026-04-03 09:15', address: '北京市朝阳区xxx路' },
{ id: 'EX20260404004', userId: 'u1', userName: '张三', itemName: '有机肥料包', itemIcon: '🌿', points: 300, status: 'cancelled', createdAt: '2026-04-04 16:45', address: '广州市天河区xxx路' },
{ id: 'EX20260405005', userId: 'u4', userName: '赵六', itemName: '花盆三件套', itemIcon: '🏺', points: 600, status: 'pending', createdAt: '2026-04-05 11:00', address: '上海市浦东新区xxx路' },
{ id: 'EX20260406006', userId: 'u2', userName: '李四', itemName: '种子礼盒', itemIcon: '🌱', points: 200, status: 'completed', createdAt: '2026-04-06 08:30', address: '深圳市南山区xxx路' },
]
export default function ExchangeOrderPage() {
const [orders] = useState(mockOrders)
const [statusFilter, setStatusFilter] = useState('all')
const [search, setSearch] = useState('')
const [detailOpen, setDetailOpen] = useState(false)
const [activeOrder, setActiveOrder] = useState<typeof mockOrders[0] | null>(null)
const filtered = orders.filter(o => {
if (statusFilter !== 'all' && o.status !== statusFilter) return false
if (search && !o.userName.includes(search) && !o.id.includes(search)) return false
return true
})
return (
<div className="space-y-6 animate-fadeIn">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
<Card className="border-border/60 shadow-soft">
<CardHeader className="pb-3 border-b border-border/40">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5 text-primary" />
<Badge variant="secondary" className="text-xs">{filtered.length}</Badge>
</CardTitle>
<div className="flex items-center gap-3">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[130px] h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(statusMap).map(([k, v]) => <SelectItem key={k} value={k}>{v.label}</SelectItem>)}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="订单号/用户名..." className="pl-9 w-48 h-9" value={search} onChange={e => setSearch(e.target.value)} />
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="pl-6"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(o => {
const st = statusMap[o.status]
return (
<TableRow key={o.id} className="group hover:bg-muted/20">
<TableCell className="pl-6 font-mono text-xs">{o.id}</TableCell>
<TableCell className="font-medium">{o.userName}</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<span className="text-lg">{o.itemIcon}</span>{o.itemName}
</span>
</TableCell>
<TableCell><span className="font-mono text-amber-600 font-semibold">{o.points}</span></TableCell>
<TableCell>
<Badge variant="outline" className={`${st.color} shadow-none gap-1`}>{st.icon}{st.label}</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">{o.createdAt}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 text-blue-500"
onClick={() => { setActiveOrder(o); setDetailOpen(true) }}>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>: {activeOrder?.id}</DialogDescription>
</DialogHeader>
{activeOrder && (
<div className="grid gap-3 text-sm p-4 bg-muted/30 rounded-lg border">
<div className="flex justify-between"><span className="text-muted-foreground"></span><span className="font-medium">{activeOrder.userName}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span>{activeOrder.itemIcon} {activeOrder.itemName}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span className="font-mono text-amber-600">{activeOrder.points}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span>{activeOrder.address}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span>{activeOrder.createdAt}</span></div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
+19 -2
View File
@@ -1,15 +1,32 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AppState {
sidebarOpen: boolean
mobileMenuOpen: boolean
themeHue: string
cmdKOpen: boolean
toggleSidebar: () => void
setMobileMenu: (open: boolean) => void
setThemeHue: (hue: string) => void
setCmdKOpen: (open: boolean) => void
}
export const useAppStore = create<AppState>((set) => ({
export const useAppStore = create<AppState>()(
persist(
(set) => ({
sidebarOpen: true,
mobileMenuOpen: false,
themeHue: '145', // Default emerald
cmdKOpen: false,
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
setMobileMenu: (open) => set({ mobileMenuOpen: open }),
}))
setThemeHue: (hue) => set({ themeHue: hue }),
setCmdKOpen: (open) => set({ cmdKOpen: open }),
}),
{
name: 'app-storage',
partialize: (state) => ({ themeHue: state.themeHue, sidebarOpen: state.sidebarOpen }),
}
)
)
+82
View File
@@ -0,0 +1,82 @@
import { create } from 'zustand'
export interface TabItem {
path: string
title: string
closable: boolean // Dashboard tab is not closable
}
interface TabsState {
tabs: TabItem[]
activeTab: string
addTab: (tab: TabItem) => void
removeTab: (path: string) => string // returns next active path
setActiveTab: (path: string) => void
closeOthers: (path: string) => void
closeAll: () => string // returns home path
closeRight: (path: string) => void
closeLeft: (path: string) => void
}
const HOME_TAB: TabItem = { path: '/dashboard', title: '仪表盘', closable: false }
export const useTabsStore = create<TabsState>((set, get) => ({
tabs: [HOME_TAB],
activeTab: '/dashboard',
addTab: (tab) => {
const { tabs } = get()
if (!tabs.find(t => t.path === tab.path)) {
set({ tabs: [...tabs, tab], activeTab: tab.path })
} else {
set({ activeTab: tab.path })
}
},
removeTab: (path) => {
const { tabs, activeTab } = get()
const target = tabs.find(t => t.path === path)
if (!target || !target.closable) return activeTab
const newTabs = tabs.filter(t => t.path !== path)
let nextActive = activeTab
if (activeTab === path) {
const idx = tabs.findIndex(t => t.path === path)
nextActive = newTabs[Math.min(idx, newTabs.length - 1)]?.path || '/dashboard'
}
set({ tabs: newTabs, activeTab: nextActive })
return nextActive
},
setActiveTab: (path) => set({ activeTab: path }),
closeOthers: (path) => {
const { tabs } = get()
set({
tabs: tabs.filter(t => !t.closable || t.path === path),
activeTab: path,
})
},
closeAll: () => {
set({ tabs: [HOME_TAB], activeTab: '/dashboard' })
return '/dashboard'
},
closeRight: (path) => {
const { tabs, activeTab } = get()
const idx = tabs.findIndex(t => t.path === path)
const newTabs = tabs.filter((t, i) => i <= idx || !t.closable)
const stillHasActive = newTabs.find(t => t.path === activeTab)
set({ tabs: newTabs, activeTab: stillHasActive ? activeTab : path })
},
closeLeft: (path) => {
const { tabs, activeTab } = get()
const idx = tabs.findIndex(t => t.path === path)
const newTabs = tabs.filter((t, i) => i >= idx || !t.closable)
const stillHasActive = newTabs.find(t => t.path === activeTab)
set({ tabs: newTabs, activeTab: stillHasActive ? activeTab : path })
},
}))