mosaicmap/app/me/page.tsx
2025-08-19 22:49:28 +08:00

763 lines
26 KiB
TypeScript

"use client";
import * as React from "react";
import { useState, useRef, useEffect } from "react";
import { useUser } from "../user-context";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import {
Settings,
User,
Bell,
Shield,
Database,
Mail,
Globe,
Palette,
Monitor,
Moon,
Sun,
Check,
ChevronDown,
Search,
X,
Save,
RotateCcw
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
interface Tag {
id: string;
label: string;
color?: string;
}
interface TagInputProps {
onChange?: (tags: Array<Tag>) => void;
defaultTags?: Array<Tag>;
suggestions?: Array<Tag>;
maxTags?: number;
label?: string;
placeholder?: string;
error?: string;
}
function useTags({
onChange,
defaultTags = [],
maxTags = 10,
}: {
onChange?: (tags: Tag[]) => void;
defaultTags?: Tag[];
maxTags?: number;
}) {
const [tags, setTags] = useState<Tag[]>(defaultTags);
function addTag(tag: Tag) {
if (tags.length >= maxTags) return;
const newTags = [...tags, tag];
setTags(newTags);
onChange?.(newTags);
return newTags;
}
function removeTag(tagId: string) {
const newTags = tags.filter((t) => t.id !== tagId);
setTags(newTags);
onChange?.(newTags);
return newTags;
}
function removeLastTag() {
if (tags.length === 0) return;
return removeTag(tags[tags.length - 1].id);
}
return {
tags,
setTags,
addTag,
removeTag,
removeLastTag,
hasReachedMax: tags.length >= maxTags,
};
}
function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: React.RefObject<T>,
handler: (event: MouseEvent | TouchEvent) => void,
mouseEvent: 'mousedown' | 'mouseup' = 'mousedown'
): void {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
const el = ref?.current;
const target = event.target;
if (!el || !target || el.contains(target as Node)) {
return;
}
handler(event);
};
document.addEventListener(mouseEvent, listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener(mouseEvent, listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler, mouseEvent]);
}
const tagStyles = {
base: "inline-flex items-center gap-1.5 px-2 py-0.5 text-sm rounded-md transition-colors duration-150",
colors: {
blue: "bg-blue-50 text-blue-700 border border-blue-200 hover:border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700/30 dark:hover:border-blue-600/50",
purple: "bg-purple-50 text-purple-700 border border-purple-200 hover:border-purple-300 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-700/30 dark:hover:border-purple-600/50",
green: "bg-green-50 text-green-700 border border-green-200 hover:border-green-300 dark:bg-green-900/30 dark:text-green-300 dark:border-green-700/30 dark:hover:border-green-600/50",
},
};
function TagInput({
onChange,
defaultTags = [],
suggestions = [
{ id: "nextjs", label: "Next.js" },
{ id: "react", label: "React" },
{ id: "tailwind", label: "Tailwind" },
],
maxTags = 10,
label = "Tags",
placeholder = "Add tags...",
error,
}: TagInputProps) {
const { tags, addTag, removeTag, removeLastTag } = useTags({
onChange,
defaultTags,
maxTags,
});
const [input, setInput] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const filteredSuggestions = suggestions
.filter(
(suggestion: Tag) =>
typeof suggestion.label === "string" &&
typeof input === "string" &&
suggestion.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 &&
!tags.some((tag: Tag) => tag.id === suggestion.id)
)
.slice(0, 5);
const canAddNewTag =
input.length > 0 &&
!suggestions.some((s) => s.label.toLowerCase() === input.toLowerCase());
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Backspace" && input === "" && tags.length > 0) {
removeLastTag();
} else if (e.key === "Enter" && input) {
e.preventDefault();
if (isOpen && filteredSuggestions[selectedIndex]) {
addTag(filteredSuggestions[selectedIndex]);
setInput("");
setIsOpen(false);
} else if (canAddNewTag) {
addTag({ id: input, label: input });
setInput("");
setIsOpen(false);
}
} else if (e.key === "Escape") {
setIsOpen(false);
}
}
useClickOutside(containerRef as React.RefObject<HTMLElement>, () => setIsOpen(false));
return (
<div className="w-full space-y-2" ref={containerRef}>
{label && (
<Label className="text-sm font-medium text-foreground" htmlFor={label}>
{label}
</Label>
)}
<div
className={cn(
"min-h-[2.5rem] p-1.5 rounded-lg border border-border bg-background",
"focus-within:ring-2 focus-within:ring-ring/20",
"flex items-center flex-row flex-wrap gap-1.5 relative"
)}
>
{tags.map((tag) => (
<span
key={tag.id}
className={cn(
tagStyles.base,
tag.color || tagStyles.colors.blue
)}
>
{tag.label}
<button
type="button"
onClick={() => removeTag(tag.id)}
className="text-current/60 hover:text-current transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setIsOpen(true);
setSelectedIndex(0);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={tags.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] bg-transparent h-7 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
{isOpen && (input || filteredSuggestions.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-[300px] overflow-y-auto bg-popover border border-border rounded-lg shadow-lg overflow-hidden">
<div className="px-2 py-1.5 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">
</span>
</div>
<div className="p-1.5 flex flex-wrap gap-1.5">
{filteredSuggestions.map((suggestion, index) => (
<button
type="button"
key={suggestion.id}
onClick={() => {
addTag(suggestion);
setInput("");
setIsOpen(false);
}}
className={cn(
tagStyles.base,
selectedIndex === index
? tagStyles.colors.blue
: "bg-muted text-muted-foreground border border-border hover:border-border/80"
)}
>
{suggestion.label}
{selectedIndex === index && <Check className="w-3.5 h-3.5" />}
</button>
))}
{canAddNewTag && (
<button
type="button"
onClick={() => {
const colorKeys = Object.keys(tagStyles.colors) as Array<keyof typeof tagStyles.colors>;
const randomColor = tagStyles.colors[colorKeys[Math.floor(Math.random() * colorKeys.length)]];
addTag({
id: input,
label: input,
color: randomColor,
});
setInput("");
setIsOpen(false);
}}
className={cn(
tagStyles.base,
"bg-muted text-muted-foreground border border-border hover:border-border/80"
)}
>
"{input}"
</button>
)}
</div>
</div>
)}
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
);
}
interface SettingsSectionProps {
icon: React.ReactNode;
title: string;
description: string;
children: React.ReactNode;
}
function SettingsSection({ icon, title, description, children }: SettingsSectionProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
{icon}
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{children}
</CardContent>
</Card>
);
}
interface SettingItemProps {
label: string;
description?: string;
children: React.ReactNode;
badge?: string;
}
function SettingItem({ label, description, children, badge }: SettingItemProps) {
return (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{label}</Label>
{badge && <Badge variant="secondary" className="text-xs">{badge}</Badge>}
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<div className="flex items-center space-x-2">
{children}
</div>
</div>
);
}
export default function MePage() {
const { isLoading, user } = useUser();
const router = useRouter();
const [settings, setSettings] = useState({
emailNotifications: true,
pushNotifications: false,
marketingEmails: true,
twoFactorAuth: false,
sessionTimeout: "30",
theme: "system",
language: "zh",
timezone: "Asia/Shanghai",
autoSave: true,
dataRetention: "90",
apiAccess: false,
debugMode: false,
});
const [profile, setProfile] = useState({
name: user?.name || "",
email: user?.email || "",
role: "用户",
department: "IT",
bio: "热爱技术的开发者,专注于创造优秀的用户体验。",
});
const [tags, setTags] = useState<Tag[]>([
{ id: "frontend", label: "前端开发", color: tagStyles.colors.blue },
{ id: "react", label: "React", color: tagStyles.colors.green },
]);
// 认证检查已在 layout.tsx 中处理,无需在此重复检查
useEffect(() => {
if (user) {
setProfile(prev => ({
...prev,
name: user.name || "",
email: user.email || "",
}));
}
}, [user]);
const handleSettingChange = (key: string, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const handleProfileChange = (key: string, value: string) => {
setProfile(prev => ({ ...prev, [key]: value }));
};
const handleSave = () => {
// console.log("Settings saved:", settings);
// console.log("Profile saved:", profile);
// console.log("Tags saved:", tags);
// TODO: 实际保存到后端
};
const handleReset = () => {
setSettings({
emailNotifications: true,
pushNotifications: false,
marketingEmails: true,
twoFactorAuth: false,
sessionTimeout: "30",
theme: "system",
language: "zh",
timezone: "Asia/Shanghai",
autoSave: true,
dataRetention: "90",
apiAccess: false,
debugMode: false,
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto p-6 max-w-6xl">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="text-muted-foreground mt-2">
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSave}>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Profile Section */}
<div className="lg:col-span-1">
<SettingsSection
icon={<User className="w-5 h-5 text-blue-600" />}
title="个人资料"
description="管理您的个人信息和账户设置"
>
<div className="flex items-center space-x-4 mb-6">
<Avatar className="w-16 h-16">
<AvatarImage src={user?.avatar || "/placeholder-avatar.jpg"} />
<AvatarFallback className="text-lg font-semibold">
{profile.name.split(' ').map(n => n[0] || '用').join('').slice(0, 2)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-lg">{profile.name || '用户'}</h3>
<p className="text-sm text-muted-foreground">{profile.role}</p>
<Badge variant="outline" className="mt-1">{profile.department}</Badge>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="name"></Label>
<Input
id="name"
value={profile.name}
onChange={(e) => handleProfileChange('name', e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={profile.email}
onChange={(e) => handleProfileChange('email', e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="department"></Label>
<Select value={profile.department} onValueChange={(value) => handleProfileChange('department', value)}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IT"></SelectItem>
<SelectItem value="HR"></SelectItem>
<SelectItem value="Finance"></SelectItem>
<SelectItem value="Marketing"></SelectItem>
<SelectItem value="Design"></SelectItem>
<SelectItem value="Product"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="bio"></Label>
<Textarea
id="bio"
value={profile.bio}
onChange={(e) => handleProfileChange('bio', e.target.value)}
className="mt-1"
rows={3}
/>
</div>
<div>
<TagInput
label="技能标签"
placeholder="添加技能标签..."
defaultTags={tags}
suggestions={[
{ id: "javascript", label: "JavaScript" },
{ id: "typescript", label: "TypeScript" },
{ id: "python", label: "Python" },
{ id: "react", label: "React" },
{ id: "nextjs", label: "Next.js" },
{ id: "nodejs", label: "Node.js" },
{ id: "docker", label: "Docker" },
{ id: "kubernetes", label: "Kubernetes" },
{ id: "aws", label: "AWS" },
{ id: "ui-design", label: "UI设计" },
]}
onChange={setTags}
maxTags={8}
/>
</div>
</div>
</SettingsSection>
</div>
{/* Settings Sections */}
<div className="lg:col-span-2 space-y-6">
{/* Notification Settings */}
<SettingsSection
icon={<Bell className="w-5 h-5 text-green-600" />}
title="通知设置"
description="配置您希望接收的通知类型"
>
<SettingItem
label="邮件通知"
description="接收重要系统更新的邮件通知"
>
<Switch
checked={settings.emailNotifications}
onCheckedChange={(checked) => handleSettingChange('emailNotifications', checked)}
/>
</SettingItem>
<SettingItem
label="推送通知"
description="在浏览器中接收实时推送通知"
>
<Switch
checked={settings.pushNotifications}
onCheckedChange={(checked) => handleSettingChange('pushNotifications', checked)}
/>
</SettingItem>
<SettingItem
label="营销邮件"
description="接收产品更新和营销信息"
>
<Switch
checked={settings.marketingEmails}
onCheckedChange={(checked) => handleSettingChange('marketingEmails', checked)}
/>
</SettingItem>
</SettingsSection>
{/* Security Settings */}
<SettingsSection
icon={<Shield className="w-5 h-5 text-red-600" />}
title="安全设置"
description="管理您的账户安全和访问控制"
>
<SettingItem
label="双因素认证"
description="为您的账户添加额外的安全层"
badge="推荐"
>
<Switch
checked={settings.twoFactorAuth}
onCheckedChange={(checked) => handleSettingChange('twoFactorAuth', checked)}
/>
</SettingItem>
<SettingItem
label="会话超时"
description="设置自动登出的时间间隔"
>
<Select value={settings.sessionTimeout} onValueChange={(value) => handleSettingChange('sessionTimeout', value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">15</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="60">1</SelectItem>
<SelectItem value="120">2</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="API访问"
description="允许第三方应用访问您的数据"
>
<Switch
checked={settings.apiAccess}
onCheckedChange={(checked) => handleSettingChange('apiAccess', checked)}
/>
</SettingItem>
</SettingsSection>
{/* System Preferences */}
<SettingsSection
icon={<Settings className="w-5 h-5 text-purple-600" />}
title="系统偏好"
description="自定义您的系统界面和行为"
>
<SettingItem
label="主题"
description="选择您偏好的界面主题"
>
<Select value={settings.theme} onValueChange={(value) => handleSettingChange('theme', value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">
<div className="flex items-center gap-2">
<Sun className="w-4 h-4" />
</div>
</SelectItem>
<SelectItem value="dark">
<div className="flex items-center gap-2">
<Moon className="w-4 h-4" />
</div>
</SelectItem>
<SelectItem value="system">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4" />
</div>
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="语言"
description="选择界面显示语言"
>
<Select value={settings.language} onValueChange={(value) => handleSettingChange('language', value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh"></SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="ja"></SelectItem>
<SelectItem value="ko"></SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="时区"
description="设置您的本地时区"
>
<Select value={settings.timezone} onValueChange={(value) => handleSettingChange('timezone', value)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo</SelectItem>
<SelectItem value="America/New_York">America/New_York</SelectItem>
<SelectItem value="Europe/London">Europe/London</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="自动保存"
description="自动保存您的工作进度"
>
<Switch
checked={settings.autoSave}
onCheckedChange={(checked) => handleSettingChange('autoSave', checked)}
/>
</SettingItem>
</SettingsSection>
{/* Data Management */}
<SettingsSection
icon={<Database className="w-5 h-5 text-orange-600" />}
title="数据管理"
description="管理您的数据存储和隐私设置"
>
<SettingItem
label="数据保留期"
description="设置数据自动删除的时间"
>
<Select value={settings.dataRetention} onValueChange={(value) => handleSettingChange('dataRetention', value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30</SelectItem>
<SelectItem value="90">90</SelectItem>
<SelectItem value="180">180</SelectItem>
<SelectItem value="365">1</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="调试模式"
description="启用详细的系统日志记录"
badge="开发"
>
<Switch
checked={settings.debugMode}
onCheckedChange={(checked) => handleSettingChange('debugMode', checked)}
/>
</SettingItem>
</SettingsSection>
</div>
</div>
</div>
</div>
);
}