feat: 炫酷的登录页
This commit is contained in:
+19
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
// ==================== 以下在各自文件中实现 ====================
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
+72
-13
@@ -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">
|
||||
<Outlet />
|
||||
<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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+23
-6
@@ -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) => ({
|
||||
sidebarOpen: true,
|
||||
mobileMenuOpen: false,
|
||||
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
setMobileMenu: (open) => set({ mobileMenuOpen: open }),
|
||||
}))
|
||||
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 }),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user