create-fullstack-scaffold 0.4.9 → 0.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +499 -100
- package/dist/cli/index.js.map +1 -1
- package/package.json +2 -2
- package/template/CLAUDE.md +18 -6
- package/template/drizzle/0004_add_merchants_products.sql +30 -0
- package/template/drizzle/meta/_journal.json +7 -0
- package/template/eslint-rules/module-boundary.js +6 -2
- package/template/eslint-rules/no-cross-module-service-import.js +105 -0
- package/template/eslint-rules/no-disable-type-safe-client.js +5 -0
- package/template/eslint.config.js +9 -0
- package/template/lint-scripts/config/project.config.ts +21 -0
- package/template/lint-scripts/validate-all.ts +48 -12
- package/template/lint-scripts/validators/index.ts +29 -0
- package/template/lint-scripts/validators/module-public-api.validator.ts +103 -0
- package/template/lint-scripts/validators/schema-uniqueness.validator.ts +147 -0
- package/template/modules.config.ts +2 -0
- package/template/package.json +2 -0
- package/template/playwright.config.ts +14 -0
- package/template/src/admin/App.tsx +40 -3
- package/template/src/admin/components/LanguageSwitcher.tsx +22 -0
- package/template/src/admin/components/NotificationDrawer.tsx +108 -38
- package/template/src/admin/components/StatsCard.tsx +3 -1
- package/template/src/admin/components/ThemeToggle.tsx +28 -0
- package/template/src/admin/hooks/useRoles.ts +2 -2
- package/template/src/admin/i18n/index.ts +18 -0
- package/template/src/admin/i18n/locales/en-US.json +381 -0
- package/template/src/admin/i18n/locales/zh-CN.json +381 -0
- package/template/src/admin/i18n/useLanguage.ts +21 -0
- package/template/src/admin/layouts/Header.tsx +37 -16
- package/template/src/admin/layouts/Layout.tsx +7 -3
- package/template/src/admin/layouts/Sidebar.tsx +70 -68
- package/template/src/admin/main.tsx +1 -0
- package/template/src/admin/pages/ContentPage.tsx +68 -56
- package/template/src/admin/pages/DashboardPage.tsx +82 -76
- package/template/src/admin/pages/DisputesPage.tsx +61 -53
- package/template/src/admin/pages/LoginPage.tsx +41 -33
- package/template/src/admin/pages/OrdersPage.tsx +69 -64
- package/template/src/admin/pages/PermissionsPage.tsx +10 -8
- package/template/src/admin/pages/PluginDashboardPage.tsx +3 -1
- package/template/src/admin/pages/PluginManagementPage.tsx +3 -1
- package/template/src/admin/pages/RegisterPage.tsx +18 -16
- package/template/src/admin/pages/RolesPage.tsx +51 -49
- package/template/src/admin/pages/SettingsPage.tsx +22 -22
- package/template/src/admin/pages/SystemLogsPage.tsx +32 -30
- package/template/src/admin/pages/TicketsPage.tsx +67 -59
- package/template/src/admin/pages/UsersPage.tsx +63 -53
- package/template/src/admin/pages/__tests__/RolesPage.test.tsx +2 -2
- package/template/src/admin/stores/themeStore.ts +32 -0
- package/template/src/cli/modules/admin/index.ts +200 -0
- package/template/src/cli/modules/captcha/index.ts +40 -0
- package/template/src/cli/modules/chat/index.ts +21 -0
- package/template/src/cli/modules/content/index.ts +144 -0
- package/template/src/cli/modules/dispute/index.ts +150 -0
- package/template/src/cli/modules/file/index.ts +55 -0
- package/template/src/cli/modules/index.ts +30 -5
- package/template/src/cli/modules/order/index.ts +170 -0
- package/template/src/cli/modules/permission/index.ts +195 -0
- package/template/src/cli/modules/tenant/index.ts +142 -0
- package/template/src/cli/modules/ticket/index.ts +167 -0
- package/template/src/cli/rpc/client.ts +2 -2
- package/template/src/client/index.css +73 -0
- package/template/src/merchant/App.tsx +2 -0
- package/template/src/merchant/components/MerchantGuard.tsx +2 -6
- package/template/src/merchant/components/__tests__/MerchantGuard.test.tsx +117 -0
- package/template/src/merchant/layouts/Header.tsx +2 -2
- package/template/src/merchant/pages/DashboardPage.tsx +2 -2
- package/template/src/merchant/pages/DisputesPage.tsx +14 -10
- package/template/src/merchant/pages/LoginPage.tsx +49 -0
- package/template/src/merchant/pages/OrdersPage.tsx +17 -10
- package/template/src/merchant/pages/ProductsPage.tsx +31 -8
- package/template/src/merchant/pages/SettingsPage.tsx +3 -7
- package/template/src/merchant/stores/__tests__/merchantStore.test.ts +317 -0
- package/template/src/merchant/stores/merchantStore.ts +24 -27
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/merchants.ts +26 -0
- package/template/src/server/db/schema/products.ts +25 -0
- package/template/src/server/db/test-setup.ts +237 -0
- package/template/src/server/middleware/__tests__/audit-log.test.ts +144 -0
- package/template/src/server/middleware/__tests__/cors.test.ts +84 -0
- package/template/src/server/middleware/__tests__/logger.test.ts +117 -0
- package/template/src/server/middleware/__tests__/rate-limit.test.ts +73 -0
- package/template/src/server/middleware/__tests__/realtime-env.test.ts +56 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +150 -0
- package/template/src/server/module-admin/__tests__/admin-routes.test.ts +1 -1
- package/template/src/server/module-admin/module.ts +4 -0
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +3 -3
- package/template/src/server/module-admin/routes/user-management-routes.ts +2 -2
- package/template/src/server/module-auth/__tests__/auth-routes.test.ts +315 -0
- package/template/src/server/module-auth/__tests__/profile-routes.test.ts +83 -0
- package/template/src/server/module-auth/module.ts +2 -0
- package/template/src/server/module-content/module.ts +1 -0
- package/template/src/server/module-content/routes/content-routes.ts +2 -2
- package/template/src/server/module-dispute/module.ts +1 -0
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -2
- package/template/src/server/module-merchant/__tests__/merchant-routes.test.ts +218 -0
- package/template/src/server/module-merchant/__tests__/merchant-service.test.ts +183 -0
- package/template/src/server/module-merchant/index.ts +10 -0
- package/template/src/server/module-merchant/module.ts +33 -0
- package/template/src/server/module-merchant/routes/merchant-routes.ts +138 -0
- package/template/src/server/module-merchant/services/merchant-service.ts +292 -0
- package/template/src/server/module-notifications/index.ts +18 -0
- package/template/src/server/module-notifications/module.ts +2 -0
- package/template/src/server/module-order/module.ts +1 -0
- package/template/src/server/module-order/routes/order-routes.ts +2 -2
- package/template/src/server/module-permission/routes/role-routes.ts +3 -3
- package/template/src/server/module-plugin/__tests__/admin-category-service.test.ts +181 -0
- package/template/src/server/module-plugin/__tests__/admin-plugin-service.test.ts +277 -0
- package/template/src/server/module-plugin/__tests__/admin-stats-service.test.ts +170 -0
- package/template/src/server/module-plugin/__tests__/plugin-review-service.test.ts +260 -0
- package/template/src/server/module-plugin/__tests__/plugin-seed-service.test.ts +170 -0
- package/template/src/server/module-plugin/module.ts +3 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +142 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +152 -0
- package/template/src/server/module-tenant/module.ts +1 -0
- package/template/src/server/module-tenant/services/tenant-service.ts +1 -37
- package/template/src/server/module-ticket/module.ts +1 -0
- package/template/src/server/module-ticket/routes/ticket-routes.ts +2 -2
- package/template/src/server/module-todos/module.ts +3 -0
- package/template/src/server/route-registry.ts +4 -0
- package/template/src/server/utils/__tests__/date.test.ts +130 -0
- package/template/src/server/utils/__tests__/env.test.ts +25 -0
- package/template/src/server/utils/__tests__/generate.test.ts +82 -0
- package/template/src/server/utils/__tests__/id-helpers.test.ts +29 -0
- package/template/src/server/utils/__tests__/json.test.ts +49 -0
- package/template/src/server/utils/__tests__/permission-utils.test.ts +26 -0
- package/template/src/server/utils/__tests__/uuid.test.ts +25 -0
- package/template/src/shared/core/module-manifest.ts +15 -0
- package/template/src/shared/modules/admin/schemas.ts +1 -1
- package/template/src/shared/modules/content/schemas.ts +2 -2
- package/template/src/shared/modules/dispute/schemas.ts +2 -2
- package/template/src/shared/modules/index.ts +181 -1
- package/template/src/shared/modules/merchant/index.ts +12 -0
- package/template/src/shared/modules/merchant/schemas.ts +63 -0
- package/template/src/shared/modules/order/schemas.ts +2 -2
- package/template/src/shared/modules/permission/index.ts +1 -1
- package/template/src/shared/modules/permission/schemas.ts +1 -1
- package/template/src/shared/modules/role/schemas.ts +2 -2
- package/template/src/shared/modules/ticket/schemas.ts +2 -2
- package/template/src/shared/schemas/index.ts +74 -2
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, basename } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface SchemaUniquenessConfig {
|
|
5
|
+
modulesDir: string
|
|
6
|
+
checkDirs: string[]
|
|
7
|
+
ignoreDirs: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SchemaUniquenessError {
|
|
11
|
+
exportName: string
|
|
12
|
+
modules: string[]
|
|
13
|
+
suggestion: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SCHEMA_EXPORT_RE = /export\s+(?:const|type|function)\s+([A-Z][A-Za-z0-9]*Schema)\b/g
|
|
17
|
+
const TYPE_EXPORT_RE = /export\s+type\s+([A-Z][A-Za-z0-9]*)\b/g
|
|
18
|
+
const RE_EXPORT_SCHEMA_RE = /\b([A-Z][A-Za-z0-9]*Schema)\b/g
|
|
19
|
+
const RE_EXPORT_TYPE_RE = /type\s+([A-Z][A-Za-z0-9]*)\b/g
|
|
20
|
+
|
|
21
|
+
function capitalize(s: string): string {
|
|
22
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractExportsFromFile(filePath: string): Set<string> {
|
|
26
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
27
|
+
const names = new Set<string>()
|
|
28
|
+
|
|
29
|
+
let m: RegExpExecArray | null
|
|
30
|
+
SCHEMA_EXPORT_RE.lastIndex = 0
|
|
31
|
+
while ((m = SCHEMA_EXPORT_RE.exec(content)) !== null) {
|
|
32
|
+
names.add(m[1])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
TYPE_EXPORT_RE.lastIndex = 0
|
|
36
|
+
while ((m = TYPE_EXPORT_RE.exec(content)) !== null) {
|
|
37
|
+
if (!m[1].endsWith('Schema')) {
|
|
38
|
+
names.add(m[1])
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return names
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractReExportsFromFile(filePath: string): Set<string> {
|
|
46
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
47
|
+
const names = new Set<string>()
|
|
48
|
+
|
|
49
|
+
if (!/^export\s*\{/m.test(content) && !/^export\s+\*\s+from/m.test(content)) {
|
|
50
|
+
return names
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let m: RegExpExecArray | null
|
|
54
|
+
RE_EXPORT_SCHEMA_RE.lastIndex = 0
|
|
55
|
+
while ((m = RE_EXPORT_SCHEMA_RE.exec(content)) !== null) {
|
|
56
|
+
names.add(m[1])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
RE_EXPORT_TYPE_RE.lastIndex = 0
|
|
60
|
+
while ((m = RE_EXPORT_TYPE_RE.exec(content)) !== null) {
|
|
61
|
+
if (!m[1].endsWith('Schema')) {
|
|
62
|
+
names.add(m[1])
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return names
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateSchemaUniqueness(
|
|
70
|
+
config: SchemaUniquenessConfig,
|
|
71
|
+
rootPath: string
|
|
72
|
+
): SchemaUniquenessError[] {
|
|
73
|
+
const modulesPath = join(rootPath, config.modulesDir)
|
|
74
|
+
|
|
75
|
+
if (!existsSync(modulesPath)) {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const exportMap = new Map<string, string[]>()
|
|
80
|
+
|
|
81
|
+
const entries = readdirSync(modulesPath, { withFileTypes: true })
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.isDirectory()) continue
|
|
84
|
+
if (config.ignoreDirs.includes(entry.name)) continue
|
|
85
|
+
|
|
86
|
+
const moduleName = entry.name
|
|
87
|
+
const moduleDir = join(modulesPath, entry.name)
|
|
88
|
+
|
|
89
|
+
const schemasFile = join(moduleDir, 'schemas.ts')
|
|
90
|
+
if (existsSync(schemasFile)) {
|
|
91
|
+
const exports = extractExportsFromFile(schemasFile)
|
|
92
|
+
for (const name of exports) {
|
|
93
|
+
const existing = exportMap.get(name) || []
|
|
94
|
+
if (!existing.includes(moduleName)) {
|
|
95
|
+
existing.push(moduleName)
|
|
96
|
+
}
|
|
97
|
+
exportMap.set(name, existing)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const indexFile = join(moduleDir, 'index.ts')
|
|
102
|
+
if (existsSync(schemasFile) && existsSync(indexFile)) {
|
|
103
|
+
const reExports = extractReExportsFromFile(indexFile)
|
|
104
|
+
for (const name of reExports) {
|
|
105
|
+
if (exportMap.has(name)) continue
|
|
106
|
+
const existing = exportMap.get(name) || []
|
|
107
|
+
if (!existing.includes(moduleName)) {
|
|
108
|
+
existing.push(moduleName)
|
|
109
|
+
}
|
|
110
|
+
exportMap.set(name, existing)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const errors: SchemaUniquenessError[] = []
|
|
116
|
+
for (const [exportName, modules] of exportMap) {
|
|
117
|
+
if (modules.length >= 2) {
|
|
118
|
+
const suggestions = modules.map(mod => `${capitalize(mod)}${exportName}`)
|
|
119
|
+
errors.push({
|
|
120
|
+
exportName,
|
|
121
|
+
modules,
|
|
122
|
+
suggestion: suggestions.join(' or '),
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
errors.sort((a, b) => a.exportName.localeCompare(b.exportName))
|
|
128
|
+
return errors
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function formatSchemaUniquenessErrors(errors: SchemaUniquenessError[]): string {
|
|
132
|
+
if (errors.length === 0) return ''
|
|
133
|
+
|
|
134
|
+
let output = `❌ Found ${errors.length} cross-module schema naming collision(s):\n\n`
|
|
135
|
+
|
|
136
|
+
for (const err of errors) {
|
|
137
|
+
output += ` "${err.exportName}" defined in: ${err.modules.join(', ')}\n`
|
|
138
|
+
output += ` → Suggestion: rename to ${err.suggestion}\n\n`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
output += '📋 Guidelines:\n'
|
|
142
|
+
output += ' Each module should use unique schema names to avoid collisions\n'
|
|
143
|
+
output += ' when unified via src/shared/schemas/index.ts.\n'
|
|
144
|
+
output += ' Recommended pattern: {ModuleName}{SchemaName} (e.g. TodoSchema, OrderSchema)\n'
|
|
145
|
+
|
|
146
|
+
return output
|
|
147
|
+
}
|
package/template/package.json
CHANGED
|
@@ -58,5 +58,19 @@ export default defineConfig({
|
|
|
58
58
|
: {}),
|
|
59
59
|
},
|
|
60
60
|
},
|
|
61
|
+
{
|
|
62
|
+
name: 'firefox',
|
|
63
|
+
use: {
|
|
64
|
+
...devices['Desktop Firefox'],
|
|
65
|
+
ignoreHTTPSErrors: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'webkit',
|
|
70
|
+
use: {
|
|
71
|
+
...devices['Desktop Safari'],
|
|
72
|
+
ignoreHTTPSErrors: true,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
61
75
|
],
|
|
62
76
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
|
2
|
-
import { ConfigProvider } from 'antd'
|
|
2
|
+
import { ConfigProvider, theme } from 'antd'
|
|
3
3
|
import { Layout } from './layouts/Layout'
|
|
4
4
|
import { DashboardPage } from './pages/DashboardPage'
|
|
5
5
|
import { LoginPage } from './pages/LoginPage'
|
|
@@ -14,13 +14,50 @@ import { TicketsPage } from './pages/TicketsPage'
|
|
|
14
14
|
import { DisputesPage } from './pages/DisputesPage'
|
|
15
15
|
import { ContentPage } from './pages/ContentPage'
|
|
16
16
|
import { ProtectedRoute, CaptchaModal } from './components'
|
|
17
|
+
import { useThemeStore } from './stores/themeStore'
|
|
18
|
+
import { useLanguage } from './i18n/useLanguage'
|
|
19
|
+
|
|
20
|
+
const ComingSoonPage: React.FC<{ title: string }> = ({ title }) => {
|
|
21
|
+
const { t } = useLanguage()
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex items-center justify-center h-full min-h-[400px]">
|
|
24
|
+
<div className="text-center">
|
|
25
|
+
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
|
|
26
|
+
<p className="text-gray-400">{t('monitor.comingSoon')}</p>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
17
31
|
|
|
18
32
|
export const App: React.FC<{ basePath?: string }> = ({ basePath = '/admin' }) => {
|
|
33
|
+
const mode = useThemeStore(state => state.mode)
|
|
34
|
+
const isDark = mode === 'dark'
|
|
35
|
+
|
|
19
36
|
return (
|
|
20
37
|
<ConfigProvider
|
|
21
38
|
theme={{
|
|
39
|
+
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
|
22
40
|
token: {
|
|
23
|
-
colorPrimary: '#
|
|
41
|
+
colorPrimary: '#6366f1',
|
|
42
|
+
},
|
|
43
|
+
components: {
|
|
44
|
+
Menu: {
|
|
45
|
+
colorItemBg: 'transparent',
|
|
46
|
+
colorItemText: 'rgba(255, 255, 255, 0.7)',
|
|
47
|
+
colorItemTextHover: 'rgba(255, 255, 255, 0.9)',
|
|
48
|
+
colorItemBgHover: 'rgba(255, 255, 255, 0.08)',
|
|
49
|
+
colorItemBgSelected: 'rgba(99, 102, 241, 0.15)',
|
|
50
|
+
colorItemTextSelected: '#6366f1',
|
|
51
|
+
colorSubItemBg: '#000c17',
|
|
52
|
+
itemBorderRadius: 8,
|
|
53
|
+
itemMarginBlock: 2,
|
|
54
|
+
itemHeight: 40,
|
|
55
|
+
},
|
|
56
|
+
Layout: {
|
|
57
|
+
siderBg: '#001529',
|
|
58
|
+
headerBg: isDark ? '#141414' : '#ffffff',
|
|
59
|
+
bodyBg: isDark ? '#1f1f1f' : '#f5f6fa',
|
|
60
|
+
},
|
|
24
61
|
},
|
|
25
62
|
}}
|
|
26
63
|
>
|
|
@@ -43,7 +80,7 @@ export const App: React.FC<{ basePath?: string }> = ({ basePath = '/admin' }) =>
|
|
|
43
80
|
<Route path="/content" element={<ContentPage />} />
|
|
44
81
|
<Route path="/system/settings" element={<SettingsPage />} />
|
|
45
82
|
<Route path="/system/logs" element={<SystemLogsPage />} />
|
|
46
|
-
<Route path="/system/monitor" element={<
|
|
83
|
+
<Route path="/system/monitor" element={<ComingSoonPage title="System Monitor" />} />
|
|
47
84
|
<Route path="/system/permissions" element={<PermissionsPage />} />
|
|
48
85
|
<Route path="/system/roles" element={<RolesPage />} />
|
|
49
86
|
</Routes>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Select } from 'antd'
|
|
2
|
+
import { useLanguage, type Language } from '../i18n/useLanguage'
|
|
3
|
+
|
|
4
|
+
const LANGUAGE_OPTIONS = [
|
|
5
|
+
{ label: '中文', value: 'zh-CN' as Language },
|
|
6
|
+
{ label: 'English', value: 'en-US' as Language },
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
export const LanguageSwitcher: React.FC = () => {
|
|
10
|
+
const { currentLanguage, changeLanguage } = useLanguage()
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Select
|
|
14
|
+
size="small"
|
|
15
|
+
value={currentLanguage}
|
|
16
|
+
options={LANGUAGE_OPTIONS}
|
|
17
|
+
onChange={changeLanguage}
|
|
18
|
+
style={{ width: 90 }}
|
|
19
|
+
data-testid="language-switcher"
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Bell, CheckCheck, Info, AlertTriangle, XCircle, CheckCircle } from 'lucide-react'
|
|
2
|
-
import { Badge, Button, Drawer, Empty, Spin, Tabs } from 'antd'
|
|
2
|
+
import { Badge, Button, Drawer, Empty, Spin, Tabs, theme } from 'antd'
|
|
3
3
|
import type { AppNotification, NotificationType } from '@shared/schemas'
|
|
4
|
+
import { useLanguage } from '../i18n/useLanguage'
|
|
4
5
|
|
|
5
6
|
interface NotificationDrawerProps {
|
|
6
7
|
open: boolean
|
|
@@ -14,31 +15,43 @@ interface NotificationDrawerProps {
|
|
|
14
15
|
|
|
15
16
|
const typeConfig: Record<
|
|
16
17
|
NotificationType,
|
|
17
|
-
{
|
|
18
|
+
{
|
|
19
|
+
colorToken: 'colorInfo' | 'colorWarning' | 'colorError' | 'colorSuccess'
|
|
20
|
+
bgToken:
|
|
21
|
+
| 'colorInfoBg'
|
|
22
|
+
| 'colorWarningBg'
|
|
23
|
+
| 'colorErrorBg'
|
|
24
|
+
| 'colorSuccessBg'
|
|
25
|
+
icon: React.ReactNode
|
|
26
|
+
}
|
|
18
27
|
> = {
|
|
19
28
|
info: {
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
colorToken: 'colorInfo',
|
|
30
|
+
bgToken: 'colorInfoBg',
|
|
22
31
|
icon: <Info className="w-4 h-4" />,
|
|
23
32
|
},
|
|
24
33
|
warning: {
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
colorToken: 'colorWarning',
|
|
35
|
+
bgToken: 'colorWarningBg',
|
|
27
36
|
icon: <AlertTriangle className="w-4 h-4" />,
|
|
28
37
|
},
|
|
29
38
|
error: {
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
colorToken: 'colorError',
|
|
40
|
+
bgToken: 'colorErrorBg',
|
|
32
41
|
icon: <XCircle className="w-4 h-4" />,
|
|
33
42
|
},
|
|
34
43
|
success: {
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
colorToken: 'colorSuccess',
|
|
45
|
+
bgToken: 'colorSuccessBg',
|
|
37
46
|
icon: <CheckCircle className="w-4 h-4" />,
|
|
38
47
|
},
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
function formatTime(
|
|
50
|
+
function formatTime(
|
|
51
|
+
dateString: string,
|
|
52
|
+
locale: string,
|
|
53
|
+
t: (key: string, options?: Record<string, unknown>) => string,
|
|
54
|
+
): string {
|
|
42
55
|
const date = new Date(dateString)
|
|
43
56
|
const now = new Date()
|
|
44
57
|
const diff = now.getTime() - date.getTime()
|
|
@@ -47,11 +60,11 @@ function formatTime(dateString: string): string {
|
|
|
47
60
|
const hours = Math.floor(diff / 3600000)
|
|
48
61
|
const days = Math.floor(diff / 86400000)
|
|
49
62
|
|
|
50
|
-
if (minutes < 1) return '
|
|
51
|
-
if (minutes < 60) return
|
|
52
|
-
if (hours < 24) return
|
|
53
|
-
if (days < 7) return
|
|
54
|
-
return date.toLocaleDateString(
|
|
63
|
+
if (minutes < 1) return t('notification.justNow')
|
|
64
|
+
if (minutes < 60) return t('notification.minutesAgo', { count: minutes })
|
|
65
|
+
if (hours < 24) return t('notification.hoursAgo', { count: hours })
|
|
66
|
+
if (days < 7) return t('notification.daysAgo', { count: days })
|
|
67
|
+
return date.toLocaleDateString(locale)
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
export const NotificationDrawer: React.FC<NotificationDrawerProps> = ({
|
|
@@ -63,31 +76,65 @@ export const NotificationDrawer: React.FC<NotificationDrawerProps> = ({
|
|
|
63
76
|
onMarkAllAsRead,
|
|
64
77
|
loading,
|
|
65
78
|
}) => {
|
|
79
|
+
const { t, currentLanguage } = useLanguage()
|
|
80
|
+
const { token } = theme.useToken()
|
|
81
|
+
|
|
66
82
|
const renderNotificationItem = (notif: AppNotification) => {
|
|
67
83
|
const config = typeConfig[notif.type]
|
|
84
|
+
const bgColor = token[config.bgToken]
|
|
85
|
+
const fgColor = token[config.colorToken]
|
|
68
86
|
|
|
69
87
|
return (
|
|
70
88
|
<div
|
|
71
89
|
key={notif.id}
|
|
72
|
-
className=
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
className="p-4 cursor-pointer transition-colors"
|
|
91
|
+
style={{
|
|
92
|
+
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
|
93
|
+
backgroundColor: !notif.read ? token.colorInfoBg : undefined,
|
|
94
|
+
}}
|
|
75
95
|
onClick={() => !notif.read && onMarkAsRead(notif.id)}
|
|
96
|
+
onMouseEnter={e =>
|
|
97
|
+
(e.currentTarget.style.backgroundColor = token.colorBgTextHover)
|
|
98
|
+
}
|
|
99
|
+
onMouseLeave={e =>
|
|
100
|
+
(e.currentTarget.style.backgroundColor =
|
|
101
|
+
!notif.read ? token.colorInfoBg : undefined)
|
|
102
|
+
}
|
|
76
103
|
>
|
|
77
104
|
<div className="flex items-start gap-3">
|
|
78
105
|
<div
|
|
79
106
|
className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center"
|
|
80
|
-
style={{ backgroundColor:
|
|
107
|
+
style={{ backgroundColor: bgColor, color: fgColor }}
|
|
81
108
|
>
|
|
82
109
|
{config.icon}
|
|
83
110
|
</div>
|
|
84
111
|
<div className="flex-1 min-w-0">
|
|
85
112
|
<div className="flex items-center gap-2">
|
|
86
|
-
<span
|
|
87
|
-
|
|
113
|
+
<span
|
|
114
|
+
className="font-medium truncate"
|
|
115
|
+
style={{ color: token.colorText }}
|
|
116
|
+
>
|
|
117
|
+
{notif.title}
|
|
118
|
+
</span>
|
|
119
|
+
{!notif.read && (
|
|
120
|
+
<span
|
|
121
|
+
className="w-2 h-2 rounded-full flex-shrink-0"
|
|
122
|
+
style={{ backgroundColor: token.colorPrimary }}
|
|
123
|
+
/>
|
|
124
|
+
)}
|
|
88
125
|
</div>
|
|
89
|
-
<p
|
|
90
|
-
|
|
126
|
+
<p
|
|
127
|
+
className="text-sm mt-1 line-clamp-2"
|
|
128
|
+
style={{ color: token.colorTextSecondary }}
|
|
129
|
+
>
|
|
130
|
+
{notif.message}
|
|
131
|
+
</p>
|
|
132
|
+
<span
|
|
133
|
+
className="text-xs mt-1 block"
|
|
134
|
+
style={{ color: token.colorTextTertiary }}
|
|
135
|
+
>
|
|
136
|
+
{formatTime(notif.createdAt, currentLanguage, t)}
|
|
137
|
+
</span>
|
|
91
138
|
</div>
|
|
92
139
|
</div>
|
|
93
140
|
</div>
|
|
@@ -100,15 +147,22 @@ export const NotificationDrawer: React.FC<NotificationDrawerProps> = ({
|
|
|
100
147
|
const tabItems = [
|
|
101
148
|
{
|
|
102
149
|
key: 'all',
|
|
103
|
-
label:
|
|
150
|
+
label: `${t('notification.all')} (${notifications.length})`,
|
|
104
151
|
children: (
|
|
105
|
-
<div
|
|
152
|
+
<div
|
|
153
|
+
className="overflow-y-auto"
|
|
154
|
+
style={{ maxHeight: 'calc(100vh - 200px)' }}
|
|
155
|
+
>
|
|
106
156
|
{loading ? (
|
|
107
157
|
<div className="flex items-center justify-center py-12">
|
|
108
158
|
<Spin />
|
|
109
159
|
</div>
|
|
110
160
|
) : notifications.length === 0 ? (
|
|
111
|
-
<Empty
|
|
161
|
+
<Empty
|
|
162
|
+
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
163
|
+
description={t('notification.noNotifications')}
|
|
164
|
+
className="py-12"
|
|
165
|
+
/>
|
|
112
166
|
) : (
|
|
113
167
|
notifications.map(renderNotificationItem)
|
|
114
168
|
)}
|
|
@@ -117,13 +171,16 @@ export const NotificationDrawer: React.FC<NotificationDrawerProps> = ({
|
|
|
117
171
|
},
|
|
118
172
|
{
|
|
119
173
|
key: 'unread',
|
|
120
|
-
label:
|
|
174
|
+
label: `${t('notification.unread')} (${unreadNotifications.length})`,
|
|
121
175
|
children: (
|
|
122
|
-
<div
|
|
176
|
+
<div
|
|
177
|
+
className="overflow-y-auto"
|
|
178
|
+
style={{ maxHeight: 'calc(100vh - 200px)' }}
|
|
179
|
+
>
|
|
123
180
|
{unreadNotifications.length === 0 ? (
|
|
124
181
|
<Empty
|
|
125
182
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
126
|
-
description=
|
|
183
|
+
description={t('notification.noUnread')}
|
|
127
184
|
className="py-12"
|
|
128
185
|
/>
|
|
129
186
|
) : (
|
|
@@ -134,13 +191,16 @@ export const NotificationDrawer: React.FC<NotificationDrawerProps> = ({
|
|
|
134
191
|
},
|
|
135
192
|
{
|
|
136
193
|
key: 'read',
|
|
137
|
-
label:
|
|
194
|
+
label: `${t('notification.read')} (${readNotifications.length})`,
|
|
138
195
|
children: (
|
|
139
|
-
<div
|
|
196
|
+
<div
|
|
197
|
+
className="overflow-y-auto"
|
|
198
|
+
style={{ maxHeight: 'calc(100vh - 200px)' }}
|
|
199
|
+
>
|
|
140
200
|
{readNotifications.length === 0 ? (
|
|
141
201
|
<Empty
|
|
142
202
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
143
|
-
description=
|
|
203
|
+
description={t('notification.noRead')}
|
|
144
204
|
className="py-12"
|
|
145
205
|
/>
|
|
146
206
|
) : (
|
|
@@ -155,7 +215,7 @@ export const NotificationDrawer: React.FC<NotificationDrawerProps> = ({
|
|
|
155
215
|
<Drawer
|
|
156
216
|
title={
|
|
157
217
|
<div className="flex items-center justify-between">
|
|
158
|
-
<span
|
|
218
|
+
<span>{t('notification.center')}</span>
|
|
159
219
|
{unreadCount > 0 && (
|
|
160
220
|
<Button
|
|
161
221
|
type="link"
|
|
@@ -164,7 +224,7 @@ export const NotificationDrawer: React.FC<NotificationDrawerProps> = ({
|
|
|
164
224
|
onClick={onMarkAllAsRead}
|
|
165
225
|
className="text-xs"
|
|
166
226
|
>
|
|
167
|
-
|
|
227
|
+
{t('notification.markAllRead')}
|
|
168
228
|
</Button>
|
|
169
229
|
)}
|
|
170
230
|
</div>
|
|
@@ -174,7 +234,7 @@ export const NotificationDrawer: React.FC<NotificationDrawerProps> = ({
|
|
|
174
234
|
open={open}
|
|
175
235
|
width={400}
|
|
176
236
|
styles={{
|
|
177
|
-
header: { borderBottom:
|
|
237
|
+
header: { borderBottom: `1px solid ${token.colorBorderSecondary}` },
|
|
178
238
|
body: { padding: 0 },
|
|
179
239
|
}}
|
|
180
240
|
>
|
|
@@ -187,12 +247,22 @@ export const NotificationBell: React.FC<{
|
|
|
187
247
|
unreadCount: number
|
|
188
248
|
onClick: () => void
|
|
189
249
|
}> = ({ unreadCount, onClick }) => {
|
|
250
|
+
const { token } = theme.useToken()
|
|
251
|
+
|
|
190
252
|
return (
|
|
191
253
|
<button
|
|
192
|
-
|
|
254
|
+
data-testid="notification-bell"
|
|
255
|
+
className="p-2 rounded-lg transition-colors relative"
|
|
256
|
+
style={{ color: token.colorTextSecondary }}
|
|
193
257
|
onClick={onClick}
|
|
258
|
+
onMouseEnter={e =>
|
|
259
|
+
(e.currentTarget.style.backgroundColor = token.colorBgTextHover)
|
|
260
|
+
}
|
|
261
|
+
onMouseLeave={e =>
|
|
262
|
+
(e.currentTarget.style.backgroundColor = 'transparent')
|
|
263
|
+
}
|
|
194
264
|
>
|
|
195
|
-
<Bell className="w-5 h-5
|
|
265
|
+
<Bell className="w-5 h-5" />
|
|
196
266
|
{unreadCount > 0 && (
|
|
197
267
|
<Badge
|
|
198
268
|
count={unreadCount > 99 ? '99+' : unreadCount}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Card, Statistic } from 'antd'
|
|
2
2
|
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
|
|
3
3
|
import type { ReactNode } from 'react'
|
|
4
|
+
import { useLanguage } from '../i18n/useLanguage'
|
|
4
5
|
|
|
5
6
|
interface StatsCardProps {
|
|
6
7
|
title: string
|
|
@@ -22,6 +23,7 @@ export const StatsCard: React.FC<StatsCardProps> = ({
|
|
|
22
23
|
trend,
|
|
23
24
|
loading,
|
|
24
25
|
}) => {
|
|
26
|
+
const { t } = useLanguage()
|
|
25
27
|
return (
|
|
26
28
|
<Card loading={loading}>
|
|
27
29
|
<Statistic
|
|
@@ -36,7 +38,7 @@ export const StatsCard: React.FC<StatsCardProps> = ({
|
|
|
36
38
|
<span style={{ color: trend.isUp ? '#3f8600' : '#cf1322' }}>
|
|
37
39
|
{trend.isUp ? <ArrowUpOutlined /> : <ArrowDownOutlined />} {Math.abs(trend.value)}%
|
|
38
40
|
</span>
|
|
39
|
-
<span className="text-gray-400 ml-2">
|
|
41
|
+
<span className="text-gray-400 ml-2">{t('common.vsLastMonth')}</span>
|
|
40
42
|
</div>
|
|
41
43
|
)}
|
|
42
44
|
</Card>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Sun, Moon } from 'lucide-react'
|
|
2
|
+
import { theme } from 'antd'
|
|
3
|
+
import { useThemeStore } from '../stores/themeStore'
|
|
4
|
+
import { useLanguage } from '../i18n/useLanguage'
|
|
5
|
+
|
|
6
|
+
export const ThemeToggle: React.FC = () => {
|
|
7
|
+
const { mode, toggleTheme } = useThemeStore()
|
|
8
|
+
const { t } = useLanguage()
|
|
9
|
+
const { token } = theme.useToken()
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
onClick={toggleTheme}
|
|
14
|
+
className="p-2 rounded-lg transition-colors cursor-pointer border-none bg-transparent"
|
|
15
|
+
style={{ color: token.colorText }}
|
|
16
|
+
onMouseEnter={(e) => {
|
|
17
|
+
e.currentTarget.style.backgroundColor = token.colorFillQuaternary
|
|
18
|
+
}}
|
|
19
|
+
onMouseLeave={(e) => {
|
|
20
|
+
e.currentTarget.style.backgroundColor = 'transparent'
|
|
21
|
+
}}
|
|
22
|
+
title={mode === 'light' ? t('theme.toggleDark') : t('theme.toggleLight')}
|
|
23
|
+
data-testid="theme-toggle"
|
|
24
|
+
>
|
|
25
|
+
{mode === 'light' ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
|
26
|
+
</button>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { create } from 'zustand'
|
|
2
2
|
import { apiClient } from '../services/apiClient'
|
|
3
|
-
import type {
|
|
3
|
+
import type { RoleDataType, CreateRoleType, UpdateRoleType } from '@shared/modules/role/schemas'
|
|
4
4
|
|
|
5
5
|
interface RoleState {
|
|
6
|
-
roles:
|
|
6
|
+
roles: RoleDataType[]
|
|
7
7
|
loading: boolean
|
|
8
8
|
error: string | null
|
|
9
9
|
fetchRoles: () => Promise<void>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import i18n from 'i18next'
|
|
2
|
+
import { initReactI18next } from 'react-i18next'
|
|
3
|
+
import zhCN from './locales/zh-CN.json'
|
|
4
|
+
import enUS from './locales/en-US.json'
|
|
5
|
+
|
|
6
|
+
i18n.use(initReactI18next).init({
|
|
7
|
+
resources: {
|
|
8
|
+
'zh-CN': { translation: zhCN },
|
|
9
|
+
'en-US': { translation: enUS },
|
|
10
|
+
},
|
|
11
|
+
lng: 'zh-CN',
|
|
12
|
+
fallbackLng: 'zh-CN',
|
|
13
|
+
interpolation: {
|
|
14
|
+
escapeValue: false,
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export default i18n
|