diff --git a/web/package.json b/web/package.json
index ef146ae..6d856f6 100644
--- a/web/package.json
+++ b/web/package.json
@@ -38,6 +38,7 @@
"field-conv": "^1.0.9",
"highlight.js": "^11.11.1",
"lucide-react": "^0.525.0",
+ "md5": "^2.3.0",
"motion": "^12.23.12",
"next": "15.4.1",
"next-intl": "^4.3.4",
@@ -56,6 +57,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/md5": "^2.3.5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 4af77bd..1282d97 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -95,6 +95,9 @@ importers:
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@19.1.0)
+ md5:
+ specifier: ^2.3.0
+ version: 2.3.0
motion:
specifier: ^12.23.12
version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -144,6 +147,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.11
+ '@types/md5':
+ specifier: ^2.3.5
+ version: 2.3.5
'@types/node':
specifier: ^20
version: 20.19.8
@@ -1216,6 +1222,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/md5@2.3.5':
+ resolution: {integrity: sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==}
+
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -1545,6 +1554,9 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+ charenc@0.0.2:
+ resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
+
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@@ -1590,6 +1602,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ crypt@0.0.2:
+ resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
+
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -2158,6 +2173,9 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'}
+ is-buffer@1.1.6:
+ resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
+
is-bun-module@2.0.0:
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
@@ -2407,6 +2425,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ md5@2.3.0:
+ resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
+
mdast-util-from-markdown@2.0.2:
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
@@ -4269,6 +4290,8 @@ snapshots:
'@types/json5@0.0.29': {}
+ '@types/md5@2.3.5': {}
+
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -4620,6 +4643,8 @@ snapshots:
character-reference-invalid@2.0.1: {}
+ charenc@0.0.2: {}
+
chownr@3.0.0: {}
class-variance-authority@0.7.1:
@@ -4664,6 +4689,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ crypt@0.0.2: {}
+
csstype@3.1.3: {}
d3-array@3.2.4:
@@ -5429,6 +5456,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-buffer@1.1.6: {}
+
is-bun-module@2.0.0:
dependencies:
semver: 7.7.2
@@ -5653,6 +5682,12 @@ snapshots:
math-intrinsics@1.1.0: {}
+ md5@2.3.0:
+ dependencies:
+ charenc: 0.0.2
+ crypt: 0.0.2
+ is-buffer: 1.1.6
+
mdast-util-from-markdown@2.0.2:
dependencies:
'@types/mdast': 4.0.4
diff --git a/web/src/app/console/comment/page.tsx b/web/src/app/console/comment/page.tsx
new file mode 100644
index 0000000..75d603b
--- /dev/null
+++ b/web/src/app/console/comment/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return
评论管理
;
+}
\ No newline at end of file
diff --git a/web/src/app/console/data.json b/web/src/app/console/data.json
deleted file mode 100644
index ec08736..0000000
--- a/web/src/app/console/data.json
+++ /dev/null
@@ -1,614 +0,0 @@
-[
- {
- "id": 1,
- "header": "Cover page",
- "type": "Cover page",
- "status": "In Process",
- "target": "18",
- "limit": "5",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 2,
- "header": "Table of contents",
- "type": "Table of contents",
- "status": "Done",
- "target": "29",
- "limit": "24",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 3,
- "header": "Executive summary",
- "type": "Narrative",
- "status": "Done",
- "target": "10",
- "limit": "13",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 4,
- "header": "Technical approach",
- "type": "Narrative",
- "status": "Done",
- "target": "27",
- "limit": "23",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 5,
- "header": "Design",
- "type": "Narrative",
- "status": "In Process",
- "target": "2",
- "limit": "16",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 6,
- "header": "Capabilities",
- "type": "Narrative",
- "status": "In Process",
- "target": "20",
- "limit": "8",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 7,
- "header": "Integration with existing systems",
- "type": "Narrative",
- "status": "In Process",
- "target": "19",
- "limit": "21",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 8,
- "header": "Innovation and Advantages",
- "type": "Narrative",
- "status": "Done",
- "target": "25",
- "limit": "26",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 9,
- "header": "Overview of EMR's Innovative Solutions",
- "type": "Technical content",
- "status": "Done",
- "target": "7",
- "limit": "23",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 10,
- "header": "Advanced Algorithms and Machine Learning",
- "type": "Narrative",
- "status": "Done",
- "target": "30",
- "limit": "28",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 11,
- "header": "Adaptive Communication Protocols",
- "type": "Narrative",
- "status": "Done",
- "target": "9",
- "limit": "31",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 12,
- "header": "Advantages Over Current Technologies",
- "type": "Narrative",
- "status": "Done",
- "target": "12",
- "limit": "0",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 13,
- "header": "Past Performance",
- "type": "Narrative",
- "status": "Done",
- "target": "22",
- "limit": "33",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 14,
- "header": "Customer Feedback and Satisfaction Levels",
- "type": "Narrative",
- "status": "Done",
- "target": "15",
- "limit": "34",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 15,
- "header": "Implementation Challenges and Solutions",
- "type": "Narrative",
- "status": "Done",
- "target": "3",
- "limit": "35",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 16,
- "header": "Security Measures and Data Protection Policies",
- "type": "Narrative",
- "status": "In Process",
- "target": "6",
- "limit": "36",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 17,
- "header": "Scalability and Future Proofing",
- "type": "Narrative",
- "status": "Done",
- "target": "4",
- "limit": "37",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 18,
- "header": "Cost-Benefit Analysis",
- "type": "Plain language",
- "status": "Done",
- "target": "14",
- "limit": "38",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 19,
- "header": "User Training and Onboarding Experience",
- "type": "Narrative",
- "status": "Done",
- "target": "17",
- "limit": "39",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 20,
- "header": "Future Development Roadmap",
- "type": "Narrative",
- "status": "Done",
- "target": "11",
- "limit": "40",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 21,
- "header": "System Architecture Overview",
- "type": "Technical content",
- "status": "In Process",
- "target": "24",
- "limit": "18",
- "reviewer": "Maya Johnson"
- },
- {
- "id": 22,
- "header": "Risk Management Plan",
- "type": "Narrative",
- "status": "Done",
- "target": "15",
- "limit": "22",
- "reviewer": "Carlos Rodriguez"
- },
- {
- "id": 23,
- "header": "Compliance Documentation",
- "type": "Legal",
- "status": "In Process",
- "target": "31",
- "limit": "27",
- "reviewer": "Sarah Chen"
- },
- {
- "id": 24,
- "header": "API Documentation",
- "type": "Technical content",
- "status": "Done",
- "target": "8",
- "limit": "12",
- "reviewer": "Raj Patel"
- },
- {
- "id": 25,
- "header": "User Interface Mockups",
- "type": "Visual",
- "status": "In Process",
- "target": "19",
- "limit": "25",
- "reviewer": "Leila Ahmadi"
- },
- {
- "id": 26,
- "header": "Database Schema",
- "type": "Technical content",
- "status": "Done",
- "target": "22",
- "limit": "20",
- "reviewer": "Thomas Wilson"
- },
- {
- "id": 27,
- "header": "Testing Methodology",
- "type": "Technical content",
- "status": "In Process",
- "target": "17",
- "limit": "14",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 28,
- "header": "Deployment Strategy",
- "type": "Narrative",
- "status": "Done",
- "target": "26",
- "limit": "30",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 29,
- "header": "Budget Breakdown",
- "type": "Financial",
- "status": "In Process",
- "target": "13",
- "limit": "16",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 30,
- "header": "Market Analysis",
- "type": "Research",
- "status": "Done",
- "target": "29",
- "limit": "32",
- "reviewer": "Sophia Martinez"
- },
- {
- "id": 31,
- "header": "Competitor Comparison",
- "type": "Research",
- "status": "In Process",
- "target": "21",
- "limit": "19",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 32,
- "header": "Maintenance Plan",
- "type": "Technical content",
- "status": "Done",
- "target": "16",
- "limit": "23",
- "reviewer": "Alex Thompson"
- },
- {
- "id": 33,
- "header": "User Personas",
- "type": "Research",
- "status": "In Process",
- "target": "27",
- "limit": "24",
- "reviewer": "Nina Patel"
- },
- {
- "id": 34,
- "header": "Accessibility Compliance",
- "type": "Legal",
- "status": "Done",
- "target": "18",
- "limit": "21",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 35,
- "header": "Performance Metrics",
- "type": "Technical content",
- "status": "In Process",
- "target": "23",
- "limit": "26",
- "reviewer": "David Kim"
- },
- {
- "id": 36,
- "header": "Disaster Recovery Plan",
- "type": "Technical content",
- "status": "Done",
- "target": "14",
- "limit": "17",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 37,
- "header": "Third-party Integrations",
- "type": "Technical content",
- "status": "In Process",
- "target": "25",
- "limit": "28",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 38,
- "header": "User Feedback Summary",
- "type": "Research",
- "status": "Done",
- "target": "20",
- "limit": "15",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 39,
- "header": "Localization Strategy",
- "type": "Narrative",
- "status": "In Process",
- "target": "12",
- "limit": "19",
- "reviewer": "Maria Garcia"
- },
- {
- "id": 40,
- "header": "Mobile Compatibility",
- "type": "Technical content",
- "status": "Done",
- "target": "28",
- "limit": "31",
- "reviewer": "James Wilson"
- },
- {
- "id": 41,
- "header": "Data Migration Plan",
- "type": "Technical content",
- "status": "In Process",
- "target": "19",
- "limit": "22",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 42,
- "header": "Quality Assurance Protocols",
- "type": "Technical content",
- "status": "Done",
- "target": "30",
- "limit": "33",
- "reviewer": "Priya Singh"
- },
- {
- "id": 43,
- "header": "Stakeholder Analysis",
- "type": "Research",
- "status": "In Process",
- "target": "11",
- "limit": "14",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 44,
- "header": "Environmental Impact Assessment",
- "type": "Research",
- "status": "Done",
- "target": "24",
- "limit": "27",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 45,
- "header": "Intellectual Property Rights",
- "type": "Legal",
- "status": "In Process",
- "target": "17",
- "limit": "20",
- "reviewer": "Sarah Johnson"
- },
- {
- "id": 46,
- "header": "Customer Support Framework",
- "type": "Narrative",
- "status": "Done",
- "target": "22",
- "limit": "25",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 47,
- "header": "Version Control Strategy",
- "type": "Technical content",
- "status": "In Process",
- "target": "15",
- "limit": "18",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 48,
- "header": "Continuous Integration Pipeline",
- "type": "Technical content",
- "status": "Done",
- "target": "26",
- "limit": "29",
- "reviewer": "Michael Chen"
- },
- {
- "id": 49,
- "header": "Regulatory Compliance",
- "type": "Legal",
- "status": "In Process",
- "target": "13",
- "limit": "16",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 50,
- "header": "User Authentication System",
- "type": "Technical content",
- "status": "Done",
- "target": "28",
- "limit": "31",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 51,
- "header": "Data Analytics Framework",
- "type": "Technical content",
- "status": "In Process",
- "target": "21",
- "limit": "24",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 52,
- "header": "Cloud Infrastructure",
- "type": "Technical content",
- "status": "Done",
- "target": "16",
- "limit": "19",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 53,
- "header": "Network Security Measures",
- "type": "Technical content",
- "status": "In Process",
- "target": "29",
- "limit": "32",
- "reviewer": "Lisa Wong"
- },
- {
- "id": 54,
- "header": "Project Timeline",
- "type": "Planning",
- "status": "Done",
- "target": "14",
- "limit": "17",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 55,
- "header": "Resource Allocation",
- "type": "Planning",
- "status": "In Process",
- "target": "27",
- "limit": "30",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 56,
- "header": "Team Structure and Roles",
- "type": "Planning",
- "status": "Done",
- "target": "20",
- "limit": "23",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 57,
- "header": "Communication Protocols",
- "type": "Planning",
- "status": "In Process",
- "target": "15",
- "limit": "18",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 58,
- "header": "Success Metrics",
- "type": "Planning",
- "status": "Done",
- "target": "30",
- "limit": "33",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 59,
- "header": "Internationalization Support",
- "type": "Technical content",
- "status": "In Process",
- "target": "23",
- "limit": "26",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 60,
- "header": "Backup and Recovery Procedures",
- "type": "Technical content",
- "status": "Done",
- "target": "18",
- "limit": "21",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 61,
- "header": "Monitoring and Alerting System",
- "type": "Technical content",
- "status": "In Process",
- "target": "25",
- "limit": "28",
- "reviewer": "Daniel Park"
- },
- {
- "id": 62,
- "header": "Code Review Guidelines",
- "type": "Technical content",
- "status": "Done",
- "target": "12",
- "limit": "15",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 63,
- "header": "Documentation Standards",
- "type": "Technical content",
- "status": "In Process",
- "target": "27",
- "limit": "30",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 64,
- "header": "Release Management Process",
- "type": "Planning",
- "status": "Done",
- "target": "22",
- "limit": "25",
- "reviewer": "Assign reviewer"
- },
- {
- "id": 65,
- "header": "Feature Prioritization Matrix",
- "type": "Planning",
- "status": "In Process",
- "target": "19",
- "limit": "22",
- "reviewer": "Emma Davis"
- },
- {
- "id": 66,
- "header": "Technical Debt Assessment",
- "type": "Technical content",
- "status": "Done",
- "target": "24",
- "limit": "27",
- "reviewer": "Eddie Lake"
- },
- {
- "id": 67,
- "header": "Capacity Planning",
- "type": "Planning",
- "status": "In Process",
- "target": "21",
- "limit": "24",
- "reviewer": "Jamik Tashpulatov"
- },
- {
- "id": 68,
- "header": "Service Level Agreements",
- "type": "Legal",
- "status": "Done",
- "target": "26",
- "limit": "29",
- "reviewer": "Assign reviewer"
- }
-]
diff --git a/web/src/app/console/file/page.tsx b/web/src/app/console/file/page.tsx
new file mode 100644
index 0000000..52395fa
--- /dev/null
+++ b/web/src/app/console/file/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return 文件管理
;
+}
\ No newline at end of file
diff --git a/web/src/app/console/layout.tsx b/web/src/app/console/layout.tsx
index aae6e6c..60f06c4 100644
--- a/web/src/app/console/layout.tsx
+++ b/web/src/app/console/layout.tsx
@@ -1,11 +1,50 @@
-export default function RootLayout({
+"use client"
+import { AppSidebar } from "@/components/console/app-sidebar"
+import { SiteHeader } from "@/components/console/site-header"
+import {
+ SidebarInset,
+ SidebarProvider,
+} from "@/components/ui/sidebar"
+
+import { useToLogin } from "@/hooks/use-route"
+import { useEffect, useState } from "react"
+import { User } from "@/models/user"
+import { getLoginUser } from "@/api/user"
+
+export default function ConsoleLayout({
children,
}: Readonly<{
- children: React.ReactNode
+ children: React.ReactNode;
}>) {
+ const [user, setUser] = useState(null);
+ const toLogin = useToLogin();
+
+ useEffect(() => {
+ getLoginUser().then(res => {
+ setUser(res.data);
+ }).catch(() => {
+ setUser(null);
+ toLogin();
+ });
+ }, [toLogin]);
+ if (user === null) {
+ return null;
+ }
+
return (
- <>
- {children}
- >
+
+
+
+
+ {children}
+
+
)
}
diff --git a/web/src/app/console/page.tsx b/web/src/app/console/page.tsx
index e59e1ba..fe67e77 100644
--- a/web/src/app/console/page.tsx
+++ b/web/src/app/console/page.tsx
@@ -1,45 +1,3 @@
-"use client"
-import { AppSidebar } from "@/components/console/app-sidebar"
-import { SiteHeader } from "@/components/console/site-header"
-import {
- SidebarInset,
- SidebarProvider,
-} from "@/components/ui/sidebar"
-
-import { useToLogin } from "@/hooks/use-route"
-import { useEffect, useState } from "react"
-import { User } from "@/models/user"
-import { getLoginUser } from "@/api/user"
-
export default function Page() {
- const [user, setUser] = useState(null);
- const toLogin = useToLogin();
-
- useEffect(() => {
- getLoginUser().then(res => {
- setUser(res.data);
- }).catch(() => {
- setUser(null);
- toLogin();
- });
- }, []);
- if (user === null) {
- return null;
- }
-
- return (
-
-
-
-
-
-
- )
-}
+ return Console
;
+}
\ No newline at end of file
diff --git a/web/src/app/console/post/page.tsx b/web/src/app/console/post/page.tsx
new file mode 100644
index 0000000..d612c1f
--- /dev/null
+++ b/web/src/app/console/post/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return 文章管理
;
+}
\ No newline at end of file
diff --git a/web/src/app/console/user/page.tsx b/web/src/app/console/user/page.tsx
new file mode 100644
index 0000000..b3f8732
--- /dev/null
+++ b/web/src/app/console/user/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return 用户管理
;
+}
\ No newline at end of file
diff --git a/web/src/components/blog-home/blog-home.tsx b/web/src/components/blog-home/blog-home.tsx
index f568bcc..86b9ed4 100644
--- a/web/src/components/blog-home/blog-home.tsx
+++ b/web/src/components/blog-home/blog-home.tsx
@@ -38,7 +38,7 @@ export default function BlogHome() {
const [sortBy, setSortBy, isSortByLoaded] = useStoredState(QueryKey.SortBy, DEFAULT_SORTBY);
useEffect(() => {
- if (!isSortByLoaded) return; // wait for stored state loaded
+ if (!isSortByLoaded) return;
setLoading(true);
listPosts(
{
diff --git a/web/src/components/blog/blog-sidebar-card.tsx b/web/src/components/blog/blog-sidebar-card.tsx
index 940ee38..8cd49e9 100644
--- a/web/src/components/blog/blog-sidebar-card.tsx
+++ b/web/src/components/blog/blog-sidebar-card.tsx
@@ -1,6 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Heart, TrendingUp, Eye } from "lucide-react";
-import GravatarAvatar from "@/components/common/gravatar";
import { Badge } from "@/components/ui/badge";
import type { Label } from "@/models/label";
import type { Post } from "@/models/post";
@@ -9,6 +8,9 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { getPostHref } from "@/utils/common/post";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { getGravatarUrl } from "@/utils/common/gravatar";
+import { getFallbackAvatarFromUsername } from "@/utils/common/username";
// 侧边栏父组件,接收卡片组件列表
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
@@ -34,7 +36,10 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
-
+
+
+ {getFallbackAvatarFromUsername(config.owner.name)}
+
{config.owner.name}
{config.owner.motto}
@@ -66,23 +71,23 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
- {post.title}
-
-
-
-
- {post.viewCount}
-
-
-
- {post.likeCount}
-
+ {post.title}
+
+
+
+
+ {post.viewCount}
+
+
+
+ {post.likeCount}
+
+
-
-
+
- ))}
+ ))}
);
diff --git a/web/src/components/comment/comment-input.tsx b/web/src/components/comment/comment-input.tsx
index 3e28573..66f0f5f 100644
--- a/web/src/components/comment/comment-input.tsx
+++ b/web/src/components/comment/comment-input.tsx
@@ -3,11 +3,13 @@ import { User } from "@/models/user";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
-import GravatarAvatar from "@/components/common/gravatar";
import { CircleUser } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { getGravatarUrl } from "@/utils/common/gravatar";
+import { getFirstCharFromUser } from "@/utils/common/username";
export function CommentInput(
@@ -65,7 +67,10 @@ export function CommentInput(
clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
- {user &&
}
+ {user &&
+
+ {getFirstCharFromUser(user)}
+ }
{!user &&
}
diff --git a/web/src/components/comment/comment-item.tsx b/web/src/components/comment/comment-item.tsx
index b9d8926..423ad80 100644
--- a/web/src/components/comment/comment-item.tsx
+++ b/web/src/components/comment/comment-item.tsx
@@ -3,7 +3,6 @@ import { User } from "@/models/user";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
-import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
import { Comment } from "@/models/comment";
import { TargetType } from "@/models/types";
@@ -13,6 +12,9 @@ import { CommentInput } from "./comment-input";
import { createComment, deleteComment, getComment, listComments, updateComment } from "@/api/comment";
import { OrderBy } from "@/models/common";
import { formatDateTime } from "@/utils/common/datetime";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { getGravatarUrl } from "@/utils/common/gravatar";
+import { getFirstCharFromUser } from "@/utils/common/username";
export function CommentItem(
@@ -157,7 +159,10 @@ export function CommentItem(
clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12">
-
+
+
+ {getFirstCharFromUser(commentState.user)}
+
diff --git a/web/src/components/comment/index.tsx b/web/src/components/comment/index.tsx
index 445cb21..deaa820 100644
--- a/web/src/components/comment/index.tsx
+++ b/web/src/components/comment/index.tsx
@@ -59,7 +59,7 @@ export function CommentSection(
}).then(response => {
setComments(response.data.comments);
});
- }, [])
+ }, [page, targetId, targetType]);
const onCommentSubmitted = ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => {
createComment({
diff --git a/web/src/components/common/captcha/index.tsx b/web/src/components/common/captcha/index.tsx
index 174df66..2210a52 100644
--- a/web/src/components/common/captcha/index.tsx
+++ b/web/src/components/common/captcha/index.tsx
@@ -3,7 +3,6 @@
import { useEffect } from "react";
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import HCaptcha from "@hcaptcha/react-hcaptcha";
-import { Turnstile } from "@marsidev/react-turnstile";
import { CaptchaProvider } from "@/models/captcha";
import "./captcha.css";
import { TurnstileWidget } from "./turnstile";
diff --git a/web/src/components/common/captcha/turnstile.tsx b/web/src/components/common/captcha/turnstile.tsx
index 2a2ecf2..f2d6c21 100644
--- a/web/src/components/common/captcha/turnstile.tsx
+++ b/web/src/components/common/captcha/turnstile.tsx
@@ -52,13 +52,13 @@ export function TurnstileWidget(props: CaptchaProps) {
const handleSuccess = (token: string) => {
setStatus('success');
- props.onSuccess(token);
+ return props.onSuccess && props.onSuccess(token);
};
const handleError = (error: string) => {
setStatus('error');
setError(error);
- props.onError && props.onError(error);
+ return props.onError && props.onError(error);
};
useEffect(() => {
@@ -66,11 +66,11 @@ export function TurnstileWidget(props: CaptchaProps) {
if (status === 'loading') {
setStatus('error');
setError('timeout');
- props.onError && props.onError('timeout');
+ return props.onError && props.onError('timeout');
}
}, TURNSTILE_TIMEOUT * 1000);
return () => clearTimeout(timer);
- })
+ }, [status, props]);
return (
diff --git a/web/src/components/common/gravatar.tsx b/web/src/components/common/gravatar.tsx
deleted file mode 100644
index f4ccfd8..0000000
--- a/web/src/components/common/gravatar.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client";
-
-import React from "react";
-import Image from "next/image";
-import crypto from "crypto";
-
-// 生成 Gravatar URL 的函数
-function getGravatarUrl(email: string, size: number = 200, defaultType: string = "identicon"): string {
- const hash = crypto.createHash('md5').update(email.toLowerCase().trim()).digest('hex');
- return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultType}`;
-}
-
-interface GravatarAvatarProps {
- email: string;
- size?: number;
- className?: string;
- alt?: string;
- url?: string;
- defaultType?: 'mm' | 'identicon' | 'monsterid' | 'wavatar' | 'retro' | 'robohash' | 'blank';
-}
-
-const GravatarAvatar: React.FC
= ({
- email,
- size = 200,
- className = "",
- alt = "avatar",
- url,
- defaultType = "identicon"
-}) => {
- // 把尺寸控制交给父组件的 wrapper(父组件通过 tailwind 的 w-.. h-.. 控制)
- const gravatarUrl = url && url.trim() !== "" ? url : getGravatarUrl(email, size , defaultType);
-
- return (
-
-
-
- );
-};
-
-// 用户类型定义(如果还没有的话)
-interface User {
- email?: string;
- displayName?: string;
- name?: string;
- avatarUrl?: string;
-}
-
-export function getGravatarByUser({user, className="", size=640}:{user?: User, className?: string, size?: number}): React.ReactElement {
- if (!user) {
- return ;
- }
- return (
-
- );
-}
-
-export default GravatarAvatar;
diff --git a/web/src/components/common/image-placeholder.tsx b/web/src/components/common/image-placeholder.tsx
deleted file mode 100644
index a743816..0000000
--- a/web/src/components/common/image-placeholder.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function ImagePlaceholder() {
- return (
-
-
-
- );
-}
\ No newline at end of file
diff --git a/web/src/components/common/markdown-codeblock.tsx b/web/src/components/common/markdown-codeblock.tsx
index 3fd993e..78a33a4 100644
--- a/web/src/components/common/markdown-codeblock.tsx
+++ b/web/src/components/common/markdown-codeblock.tsx
@@ -46,7 +46,7 @@ export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">)
codeContent = extractText(child.props.children);
}
- async function handleCopy(e: React.MouseEvent) {
+ async function handleCopy() {
try {
const ok = await copyToClipboard(codeContent);
if (ok) toast.success(t("copy_success"));
diff --git a/web/src/components/common/pagination.tsx b/web/src/components/common/pagination.tsx
index 1ec5c77..726da68 100644
--- a/web/src/components/common/pagination.tsx
+++ b/web/src/components/common/pagination.tsx
@@ -21,15 +21,10 @@ export function PaginationController({
buttons?: number
onPageChange?: (page: number) => void
} & React.HTMLAttributes) {
- // normalize buttons
const btns = Math.max(5, buttons ?? 7);
const buttonsToShow = totalPages < btns ? totalPages : btns;
- // rely on shadcn buttonVariants and PaginationLink's isActive prop for styling
const [currentPage, setCurrentPage] = useState(() => Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages)));
- const [direction, setDirection] = useState(0) // 1 = forward (right->left), -1 = backward
-
- // sync when initialPage or totalPages props change
useEffect(() => {
const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages));
setCurrentPage(p);
@@ -37,10 +32,9 @@ export function PaginationController({
const handleSetPage = useCallback((p: number) => {
const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages));
- setDirection(next > currentPage ? 1 : next < currentPage ? -1 : 0);
setCurrentPage(next);
if (typeof onPageChange === 'function') onPageChange(next);
- }, [onPageChange, totalPages, currentPage]);
+ }, [onPageChange, totalPages]);
// helper to render page link
const renderPage = (pageNum: number) => (
diff --git a/web/src/components/console/app-sidebar.tsx b/web/src/components/console/app-sidebar.tsx
index 9c43737..8356f8d 100644
--- a/web/src/components/console/app-sidebar.tsx
+++ b/web/src/components/console/app-sidebar.tsx
@@ -1,29 +1,16 @@
"use client"
-
-import * as React from "react"
+import { useEffect, useState } from "react"
import {
- IconCamera,
IconChartBar,
IconDashboard,
- IconDatabase,
- IconFileAi,
- IconFileDescription,
- IconFileWord,
IconFolder,
- IconHelp,
IconInnerShadowTop,
IconListDetails,
- IconReport,
- IconSearch,
- IconSettings,
IconUsers,
} from "@tabler/icons-react"
-import { NavDocuments } from "@/components/console/nav-documents"
import { NavMain } from "@/components/console/nav-main"
-import { NavSecondary } from "@/components/console/nav-secondary"
import { NavUser } from "@/components/console/nav-user"
-import { metadata } from '../../app/layout';
import {
Sidebar,
SidebarContent,
@@ -34,125 +21,53 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar"
import config from "@/config"
+import Link from "next/link"
+import { getLoginUser } from "@/api/user"
+import { User } from "@/models/user"
const data = {
- user: {
- name: "shadcn",
- email: "m@example.com",
- avatar: "/avatars/shadcn.jpg",
- },
navMain: [
{
- title: "Dashboard",
- url: "#",
+ title: "大石坝",
+ url: "/console",
icon: IconDashboard,
},
{
- title: "Lifecycle",
- url: "#",
+ title: "文章管理",
+ url: "/console/post",
icon: IconListDetails,
},
{
- title: "Analytics",
- url: "#",
+ title: "评论管理",
+ url: "/console/comment",
icon: IconChartBar,
},
{
- title: "Projects",
- url: "#",
+ title: "文件管理",
+ url: "/console/file",
icon: IconFolder,
},
{
- title: "Team",
- url: "#",
+ title: "用户管理",
+ url: "/console/user",
icon: IconUsers,
},
- ],
- navClouds: [
- {
- title: "Capture",
- icon: IconCamera,
- isActive: true,
- url: "#",
- items: [
- {
- title: "Active Proposals",
- url: "#",
- },
- {
- title: "Archived",
- url: "#",
- },
- ],
- },
- {
- title: "Proposal",
- icon: IconFileDescription,
- url: "#",
- items: [
- {
- title: "Active Proposals",
- url: "#",
- },
- {
- title: "Archived",
- url: "#",
- },
- ],
- },
- {
- title: "Prompts",
- icon: IconFileAi,
- url: "#",
- items: [
- {
- title: "Active Proposals",
- url: "#",
- },
- {
- title: "Archived",
- url: "#",
- },
- ],
- },
- ],
- navSecondary: [
- {
- title: "Settings",
- url: "#",
- icon: IconSettings,
- },
- {
- title: "Get Help",
- url: "#",
- icon: IconHelp,
- },
- {
- title: "Search",
- url: "#",
- icon: IconSearch,
- },
- ],
- documents: [
- {
- name: "Data Library",
- url: "#",
- icon: IconDatabase,
- },
- {
- name: "Reports",
- url: "#",
- icon: IconReport,
- },
- {
- name: "Word Assistant",
- url: "#",
- icon: IconFileWord,
- },
- ],
+ ]
}
export function AppSidebar({ ...props }: React.ComponentProps) {
+ const [loginUser, setLoginUser] = useState(null);
+
+ useEffect(() => {
+ getLoginUser().then(resp => {
+ setLoginUser(resp.data);
+ });
+ }, [])
+
+ if (!loginUser) {
+ return null; // 或者返回一个加载指示器
+ }
+
return (
@@ -162,21 +77,19 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
-
+
{config.metadata.name}
-
+
-
-
-
+
)
diff --git a/web/src/components/console/nav-main.tsx b/web/src/components/console/nav-main.tsx
index 4ea34d8..fa8a3c4 100644
--- a/web/src/components/console/nav-main.tsx
+++ b/web/src/components/console/nav-main.tsx
@@ -1,8 +1,7 @@
"use client"
-import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
+import { type Icon } from "@tabler/icons-react"
-import { Button } from "@/components/ui/button"
import {
SidebarGroup,
SidebarGroupContent,
@@ -10,6 +9,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
+import Link from "next/link"
export function NavMain({
items,
@@ -26,10 +26,12 @@ export function NavMain({
{items.map((item) => (
-
- {item.icon && }
- {item.title}
-
+
+
+ {item.icon && }
+ {item.title}
+
+
))}
diff --git a/web/src/components/console/nav-user.tsx b/web/src/components/console/nav-user.tsx
index 7c49dc7..bb38069 100644
--- a/web/src/components/console/nav-user.tsx
+++ b/web/src/components/console/nav-user.tsx
@@ -28,15 +28,14 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
+import { User } from "@/models/user"
+import { getGravatarFromUser } from "@/utils/common/gravatar"
+import { getFallbackAvatarFromUsername } from "@/utils/common/username"
export function NavUser({
user,
}: {
- user: {
- name: string
- email: string
- avatar: string
- }
+ user: User
}) {
const { isMobile } = useSidebar()
@@ -49,12 +48,12 @@ export function NavUser({
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
-
-
- CN
+
+
+ {getFallbackAvatarFromUsername(user.nickname || user.username)}
-
{user.name}
+
{user.nickname}({user.username})
{user.email}
@@ -70,12 +69,12 @@ export function NavUser({
>
-
-
- CN
+
+
+ {getFallbackAvatarFromUsername(user.nickname || user.username)}
-
{user.name}
+
{user.nickname}({user.username})
{user.email}
diff --git a/web/src/components/layout/avatar-with-dropdown-menu.tsx b/web/src/components/layout/avatar-with-dropdown-menu.tsx
index 1897ccc..f7e9869 100644
--- a/web/src/components/layout/avatar-with-dropdown-menu.tsx
+++ b/web/src/components/layout/avatar-with-dropdown-menu.tsx
@@ -5,15 +5,9 @@ import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
- DropdownMenuPortal,
DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
-import GravatarAvatar from "../common/gravatar"
import { User } from "@/models/user";
import { useEffect, useState } from "react";
import { getLoginUser, userLogout } from "@/api/user";
@@ -21,6 +15,9 @@ import Link from "next/link";
import { toast } from "sonner";
import { useToLogin } from "@/hooks/use-route";
import { CircleUser } from "lucide-react";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { getGravatarFromUser } from "@/utils/common/gravatar";
+import { getFallbackAvatarFromUsername } from "@/utils/common/username";
export function AvatarWithDropdownMenu() {
const [user, setUser] = useState
(null);
@@ -44,7 +41,10 @@ export function AvatarWithDropdownMenu() {
- {user ? : }
+ {user ?
+
+ {getFallbackAvatarFromUsername(user.nickname || user.username)}
+ : }
@@ -58,29 +58,6 @@ export function AvatarWithDropdownMenu() {
-
- Team
-
- Invite users
-
-
- Email
- Message
-
- More...
-
-
-
-
- New Team
- ⌘+T
-
-
-
- GitHub
- Support
- API
-
{user ? `Logout (${user.username})` : "Login"}
diff --git a/web/src/components/layout/navbar-or-side.tsx b/web/src/components/layout/navbar-or-side.tsx
index 4e5590f..ece03ff 100644
--- a/web/src/components/layout/navbar-or-side.tsx
+++ b/web/src/components/layout/navbar-or-side.tsx
@@ -46,7 +46,7 @@ const navbarMenuComponents = [
]
export function Navbar() {
- const { navbarAdditionalClassName, setMode, mode } = useDevice()
+ const { navbarAdditionalClassName} = useDevice()
return (
diff --git a/web/src/components/login/login-form.tsx b/web/src/components/login/login-form.tsx
index 43d8017..0a0f823 100644
--- a/web/src/components/login/login-form.tsx
+++ b/web/src/components/login/login-form.tsx
@@ -34,7 +34,6 @@ export function LoginForm({
url?: string
} | null>(null)
const [captchaToken, setCaptchaToken] = useState
(null)
- const [captchaError, setCaptchaError] = useState(null)
const [isLogging, setIsLogging] = useState(false)
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
@@ -51,7 +50,7 @@ export function LoginForm({
toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : ""))
setOidcConfigs([]) // 错误时设置为空数组
})
- }, [])
+ }, [t])
useEffect(() => {
getCaptchaConfig()
@@ -62,7 +61,7 @@ export function LoginForm({
toast.error(t("fetch_captcha_config_failed") + (error?.message ? `: ${error.message}` : ""))
setCaptchaProps(null)
})
- }, [refreshCaptchaKey])
+ }, [refreshCaptchaKey, t])
const handleLogin = async (e: React.FormEvent) => {
setIsLogging(true)
@@ -84,8 +83,7 @@ export function LoginForm({
}
const handleCaptchaError = (error: string) => {
- setCaptchaError(error);
- // 刷新验证码
+ toast.error(t("captcha_error") + (error ? `: ${error}` : ""));
setTimeout(() => {
setRefreshCaptchaKey(k => k + 1);
}, 1500);
diff --git a/web/src/components/user/user-header.tsx b/web/src/components/user/user-header.tsx
index 5b7956f..6f85865 100644
--- a/web/src/components/user/user-header.tsx
+++ b/web/src/components/user/user-header.tsx
@@ -1,7 +1,9 @@
"use client"
import { User } from "@/models/user";
-import GravatarAvatar from "@/components/common/gravatar";
import { Mail, User as UserIcon, Shield } from 'lucide-react';
+import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
+import { getGravatarUrl } from "@/utils/common/gravatar";
+import { getFirstCharFromUser } from "@/utils/common/username";
export function UserHeader({ user }: { user: User }) {
return (
@@ -10,7 +12,10 @@ export function UserHeader({ user }: { user: User }) {
{/* wrapper 控制显示大小,父组件给具体 w/h */}
-
+
+
+ {getFirstCharFromUser(user)}
+
diff --git a/web/src/contexts/device-context.tsx b/web/src/contexts/device-context.tsx
index 6c89a89..c699981 100644
--- a/web/src/contexts/device-context.tsx
+++ b/web/src/contexts/device-context.tsx
@@ -88,7 +88,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// 监听系统主题变动
const media = window.matchMedia("(prefers-color-scheme: dark)");
- const handleChange = (e: MediaQueryListEvent) => {
+ const handleChange = () => {
if (!localStorage.getItem("theme") || localStorage.getItem("theme") === "system") {
applyTheme("system");
}
diff --git a/web/src/i18n/request.ts b/web/src/i18n/request.ts
index 9be1eca..2f4da60 100644
--- a/web/src/i18n/request.ts
+++ b/web/src/i18n/request.ts
@@ -9,7 +9,7 @@ export default getRequestConfig(async () => {
locales.map(async (locale) => {
try {
return (await import(`@/locales/${locale}.json`)).default;
- } catch (err) {
+ } catch {
return {};
}
})
diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts
index 62e3a21..1a6608e 100644
--- a/web/src/lib/clipboard.ts
+++ b/web/src/lib/clipboard.ts
@@ -12,7 +12,7 @@ export async function copyToClipboard(text: string): Promise {
await navigator.clipboard.writeText(text);
return true;
}
- } catch (err) {
+ } catch {
}
if (typeof document === 'undefined') return false;
@@ -42,7 +42,7 @@ export async function copyToClipboard(text: string): Promise {
if (originalRange) selection.addRange(originalRange);
}
return Boolean(successful);
- } catch (err) {
+ } catch {
document.body.removeChild(textarea);
if (selection) {
selection.removeAllRanges();
diff --git a/web/src/utils/common/gravatar.ts b/web/src/utils/common/gravatar.ts
new file mode 100644
index 0000000..c579e0a
--- /dev/null
+++ b/web/src/utils/common/gravatar.ts
@@ -0,0 +1,21 @@
+
+import md5 from "md5";
+import type { User } from '@/models/user';
+
+// 32 小图标、评论列表
+// 64 移动端评论头像
+// 80 默认显示尺寸(Gravatar 默认)
+// 96 WordPress 默认头像尺寸
+// 128 侧边栏作者头像
+// 256 用户资料页
+// 512 最大支持尺寸(上传原图也限于此)
+
+export function getGravatarUrl({ email, size, proxy }: { email: string, size?: number, proxy?: string }): string {
+ const hash = md5(email.trim().toLowerCase());
+ console.log(`https://${proxy ? proxy : "www.gravatar.com"}/avatar/${hash}?s=${size}&d=identicon`)
+ return `https://${proxy ? proxy : "www.gravatar.com"}/avatar/${hash}?s=${size}&d=identicon`;
+}
+
+export function getGravatarFromUser({ user, size = 120 }: { user: User, size?: number }): string {
+ return user.avatarUrl || getGravatarUrl({ email: user.email, size: size });
+}
\ No newline at end of file
diff --git a/web/src/utils/common/username.ts b/web/src/utils/common/username.ts
new file mode 100644
index 0000000..31a5db1
--- /dev/null
+++ b/web/src/utils/common/username.ts
@@ -0,0 +1,22 @@
+import { User } from "@/models/user";
+
+export function getFallbackAvatarFromUsername(username: string): string {
+ if (!username) {
+ return "N";
+ }
+ const firstChar = username.charAt(0);
+ if (/[a-zA-Z]/.test(firstChar)) {
+ return firstChar.toUpperCase();
+ }
+ return firstChar;
+}
+
+export function getFirstCharFromUser(user: User): string {
+ if (user.nickname) {
+ return getFallbackAvatarFromUsername(user.nickname);
+ }
+ if (user.username) {
+ return getFallbackAvatarFromUsername(user.username);
+ }
+ return "N";
+}
\ No newline at end of file