视觉统一(Zen Minimal × Future Organic 白皮书语言,全站浅色单主题): - 登录页:品牌→由壹点·品牌中心,白纸底+发丝线+柔光光晕,黑按钮 - 作品过稿:深灰头→白皮书,返回链接→返回创意资产库 - 绩效考核:桌面+移动双头部换白皮书,标题改绩效考核,返回改链接 - 组织架构:BoardShell+页面 76 处调色板全量替换(#eef1f4→#fafafa 等) - admin 后台:MaterialsHeader(面包屑治理与设置),Tab 栏墨色高亮 - 深色主题废弃:globals.css 移除媒体查询,tailwind darkMode:'class' 不挂 .dark - 设计文档同步更新:AGENTS.md UI 基线/DESIGN.md/PRODUCT.md/README.md/design-qa.md 个人档案(原个人中心): - API 合并权威人员档案(getEmployeeByUserId → 花名/工号/部门/职位/M-P等级/主管/状态) - 页面白皮书化:权威档案卡(只读 HR 字段)+ 账号区(头像上传)+ 素材/收藏 顶栏平台化: - MaterialsHeader 双模式:首页(五板块导航+全套工具+菜单)/板块(面包屑) - 菜单重排:我的档案/绩效考核/作品过稿看板‖更新记录/提交反馈‖治理与设置‖退出 - 菜单头身份行:花名+职位·部门(权威档案) - 首页排序:01 创意资产库(全员)→ 02 组织管理(管理员)→ 03-05 规划中 - 素材库改名创意资产库,全站同步(索引/导航/卡片/按钮/页脚) - 排行榜换白皮书(白底发丝线+琥珀前三名) 构建修复: - tsconfig 排除 output/(本地审计临时文件不再卡类型检查) - 存储边界门禁豁免 output/(与 .dockerignore 对齐)
487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
/**
|
||
* 个人档案页(原个人中心 · 2026-08-15 改造)
|
||
*
|
||
* 定位:从"账号资料页"升级为「我的档案」——
|
||
* 权威人员档案(HR 花名册:花名/工号/部门路径/职位/M-P 等级/直属主管/在职状态,只读)
|
||
* + 账号区(头像上传/用户名/邮箱/注册时间)
|
||
* + 我的素材/我的收藏(保留原有功能,视觉统一白皮书语言)。
|
||
* 权威数据经 /api/user/profile 内部调用 lib/employee-directory 的 getEmployeeByUserId 合并返回。
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { useState, useEffect } from 'react'
|
||
import Image from 'next/image'
|
||
import MaterialsHeader from '@/components/board/MaterialsHeader'
|
||
import { useToast } from '@/hooks/useToast'
|
||
import ToastBubble from '@/components/ToastBubble'
|
||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||
|
||
interface EmployeeRecord {
|
||
displayName: string
|
||
realName: string | null
|
||
flowerName: string | null
|
||
jobNumber: string | null
|
||
departmentPath: string | null
|
||
positionTitle: string | null
|
||
positionLevel: string | null
|
||
levelType: string | null
|
||
managerName: string | null
|
||
employmentStatus: string | null
|
||
}
|
||
|
||
interface UserProfile {
|
||
id: string
|
||
username: string
|
||
email: string
|
||
avatar: string | null
|
||
realName?: string | null
|
||
createdAt?: string
|
||
employee: EmployeeRecord | null
|
||
}
|
||
|
||
interface Material {
|
||
id: string
|
||
userId: string
|
||
productModel: string
|
||
previewUrl: string
|
||
materialType?: string
|
||
downloadCount: number
|
||
likeCount: number
|
||
createdAt: string
|
||
videoCoverUrl?: string | null
|
||
videoPreview540Url?: string | null
|
||
videoPreviewClipUrl?: string | null
|
||
videoDuration?: number | null
|
||
}
|
||
|
||
const LEVEL_TYPE_LABEL: Record<string, string> = { M: '管理', P: '专业', S: '支持' }
|
||
|
||
function StatusDot({ active }: { active: boolean }) {
|
||
return (
|
||
<span
|
||
aria-hidden="true"
|
||
className={`inline-block h-[7px] w-[7px] shrink-0 rounded-full ${active ? 'bg-[#157a4b]' : 'bg-[#9aa0a8]'}`}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div>
|
||
<p className="text-[11px] uppercase tracking-[0.14em] text-[#86898e]">{label}</p>
|
||
<p className="mt-1.5 text-[15px] leading-relaxed text-[#1a1c1d]">{children}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function ProfilePage() {
|
||
const { data: session, status, update } = useSession()
|
||
const router = useRouter()
|
||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||
const [materials, setMaterials] = useState<Material[]>([])
|
||
const [favorites, setFavorites] = useState<Material[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showAvatarModal, setShowAvatarModal] = useState(false)
|
||
const { toast, showToast } = useToast()
|
||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||
const [avatarPreview, setAvatarPreview] = useState<string>('')
|
||
const [uploading, setUploading] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated') {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
|
||
if (status === 'authenticated' && session) {
|
||
fetchProfile()
|
||
fetchMaterials()
|
||
fetchFavorites()
|
||
}
|
||
}, [status, session, router])
|
||
|
||
const fetchProfile = async () => {
|
||
try {
|
||
const res = await fetch('/api/user/profile')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setProfile(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch profile:', error)
|
||
}
|
||
}
|
||
|
||
const fetchMaterials = async () => {
|
||
try {
|
||
const res = await fetch('/api/user/materials')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setMaterials(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch materials:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const fetchFavorites = async () => {
|
||
try {
|
||
const res = await fetch('/api/user/favorites')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setFavorites(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch favorites:', error)
|
||
}
|
||
}
|
||
|
||
const formatDuration = (seconds: number) => {
|
||
const mins = Math.floor(seconds / 60)
|
||
const secs = seconds % 60
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
if (!file.type.startsWith('image/')) {
|
||
showToast('请选择图片文件', { variant: 'error' })
|
||
return
|
||
}
|
||
setAvatarFile(file)
|
||
setAvatarPreview(URL.createObjectURL(file))
|
||
setShowAvatarModal(true)
|
||
}
|
||
}
|
||
|
||
const handleAvatarUpload = async () => {
|
||
if (!avatarFile) return
|
||
|
||
setUploading(true)
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('avatar', avatarFile)
|
||
|
||
const res = await fetch('/api/user/avatar', {
|
||
method: 'POST',
|
||
body: formData,
|
||
})
|
||
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
|
||
setProfile(prev => (prev ? { ...prev, avatar: data.avatarUrl } : null))
|
||
|
||
setShowAvatarModal(false)
|
||
setAvatarFile(null)
|
||
setAvatarPreview('')
|
||
|
||
await update({ avatar: data.avatarUrl })
|
||
showToast('头像已更新', { variant: 'success' })
|
||
} else {
|
||
const { parseFetchError } = await import('@/lib/error-handler')
|
||
const parsedError = await parseFetchError(res)
|
||
showToast(parsedError.message, { variant: 'error' })
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to upload avatar:', error)
|
||
const { handleError } = await import('@/lib/error-handler')
|
||
handleError(error, showToast)
|
||
} finally {
|
||
setUploading(false)
|
||
}
|
||
}
|
||
|
||
if (status === 'loading' || loading) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center bg-[#fafafa]">
|
||
<p className="text-[13px] text-[#86898e]">加载中...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!session) {
|
||
return null
|
||
}
|
||
|
||
const employee = profile?.employee ?? null
|
||
const displayName = employee?.displayName || profile?.username || session.user?.name || '成员'
|
||
|
||
/** 素材卡片(我的素材/我的收藏共用) */
|
||
const renderMaterialCard = (material: Material, favorited = false) => {
|
||
const isVideo = isVideoMaterialType(material.materialType)
|
||
const clipUrl = material.videoPreviewClipUrl || material.videoPreview540Url
|
||
const poster = material.videoCoverUrl || material.previewUrl
|
||
|
||
return (
|
||
<div
|
||
key={material.id}
|
||
className="group relative aspect-[3/4] cursor-pointer overflow-hidden rounded-xl border border-[#e7e8ea] bg-white transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-0.5"
|
||
onClick={() => router.push(`/share/${material.id}`)}
|
||
>
|
||
<div className="relative h-full w-full overflow-hidden bg-[#f0f1f3]">
|
||
{isVideo && clipUrl ? (
|
||
<video
|
||
src={clipUrl}
|
||
poster={poster || undefined}
|
||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||
muted
|
||
loop
|
||
playsInline
|
||
preload="metadata"
|
||
/>
|
||
) : material.previewUrl ? (
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={material.productModel}
|
||
fill
|
||
unoptimized
|
||
className="object-cover transition-transform group-hover:scale-105"
|
||
sizes="(max-width: 768px) 100vw, 300px"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center bg-[#f0f1f3]">
|
||
<span className="text-[12px] text-[#86898e]">暂无预览图</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="absolute left-2 top-2 rounded-sm bg-black/70 px-2 py-1 text-xs font-medium text-white">
|
||
{material.productModel}
|
||
</div>
|
||
|
||
{isVideo && material.videoDuration && (
|
||
<div className="absolute bottom-2 left-2 rounded-sm bg-black/70 px-2 py-1 text-xs font-medium text-white">
|
||
{formatDuration(material.videoDuration)}
|
||
</div>
|
||
)}
|
||
|
||
{favorited && (
|
||
<div className="absolute bottom-2 right-2 rounded-full bg-[#c0483c] p-1.5 text-white">
|
||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
||
<p className="mb-1 truncate text-sm font-medium text-white">{material.productModel}</p>
|
||
<div className="flex items-center justify-between text-xs text-white/80">
|
||
<span>下载 {material.downloadCount}</span>
|
||
<span>点赞 {material.likeCount}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||
<MaterialsHeader session={session} sectionLabel="个人档案" />
|
||
|
||
<main className="mx-auto max-w-[1280px] px-6 pb-16">
|
||
{/* ===== 页头 ===== */}
|
||
<section className="pb-10 pt-14 md:pt-20">
|
||
<p className="text-[11px] uppercase tracking-[0.14em] text-[#86898e]">Employee Record · 我的档案</p>
|
||
<h1 className="mt-4 text-[clamp(40px,6vw,72px)] font-semibold leading-[1.08] tracking-[-0.03em]">
|
||
{displayName}
|
||
</h1>
|
||
<p className="mt-4 max-w-xl text-[14px] text-[#6b7075]">
|
||
{employee
|
||
? `${employee.positionTitle ?? '职位未记录'}${employee.departmentPath ? ` · ${employee.departmentPath}` : ''}`
|
||
: '账号未绑定权威人员档案'}
|
||
</p>
|
||
</section>
|
||
|
||
{/* ===== 档案卡 ===== */}
|
||
<section className="rounded-xl border border-[#e7e8ea] bg-white p-8 md:p-10">
|
||
<div className="grid gap-10 md:grid-cols-[1fr_300px] md:gap-14">
|
||
{/* 权威档案区(只读,来自 HR 花名册) */}
|
||
<div>
|
||
<div className="mb-6 flex items-center gap-3">
|
||
<h2 className="text-[16px] font-semibold">人员档案</h2>
|
||
<span className="rounded-sm bg-[#eef0f3] px-2 py-0.5 text-[11px] text-[#6d7075]">HR 花名册 · 只读</span>
|
||
</div>
|
||
{employee ? (
|
||
<div className="grid grid-cols-1 gap-x-10 gap-y-6 sm:grid-cols-2">
|
||
<Field label="花名 / 真名">
|
||
{employee.displayName}
|
||
{employee.realName && employee.realName !== employee.displayName && (
|
||
<span className="text-[#6b7075]">({employee.realName})</span>
|
||
)}
|
||
</Field>
|
||
<Field label="工号">{employee.jobNumber ?? '未记录'}</Field>
|
||
<Field label="部门路径">{employee.departmentPath ?? '未确定'}</Field>
|
||
<Field label="职位">{employee.positionTitle ?? '未记录'}</Field>
|
||
<Field label="等级">
|
||
{employee.positionLevel ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<span className="rounded-sm bg-[#e3ecf5] px-2 py-0.5 text-[13px] font-semibold text-[#3a7dc4]">
|
||
{employee.positionLevel}
|
||
{employee.levelType ? ` · ${LEVEL_TYPE_LABEL[employee.levelType] ?? employee.levelType}` : ''}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
'未记录'
|
||
)}
|
||
</Field>
|
||
<Field label="直属主管">{employee.managerName ?? '未记录'}</Field>
|
||
<Field label="在职状态">
|
||
<span className="inline-flex items-center gap-2">
|
||
<StatusDot active={employee.employmentStatus !== 'RESIGNED'} />
|
||
{employee.employmentStatus === 'RESIGNED' ? '已离职' : '在职'}
|
||
</span>
|
||
</Field>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed border-[#c9ccd1] px-5 py-8 text-[13px] leading-relaxed text-[#6b7075]">
|
||
该账号尚未绑定权威人员档案(需要钉钉唯一值绑定)。<br />
|
||
绑定后此处将展示花名、工号、部门、职位、等级与直属主管。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 账号区 */}
|
||
<div className="border-t border-[#e7e8ea] pt-8 md:border-l md:border-t-0 md:pl-10 md:pt-0">
|
||
<h2 className="mb-6 text-[16px] font-semibold">账号</h2>
|
||
<div className="flex items-center gap-5">
|
||
<div className="relative">
|
||
{profile?.avatar ? (
|
||
<div className="h-16 w-16 overflow-hidden rounded-full border border-[#e7e8ea]">
|
||
<Image
|
||
src={profile.avatar}
|
||
alt="头像"
|
||
width={64}
|
||
height={64}
|
||
className="h-full w-full object-cover"
|
||
unoptimized
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[#1a1c1d] text-xl font-semibold text-white">
|
||
{displayName.charAt(0).toUpperCase()}
|
||
</div>
|
||
)}
|
||
<label className="absolute bottom-0 right-0 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border border-[#e7e8ea] bg-white text-[#6b7075] transition-colors hover:text-[#1a1c1d]">
|
||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
<input type="file" accept="image/*" onChange={handleAvatarSelect} className="hidden" />
|
||
</label>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="truncate text-[14px] font-medium">{profile?.username ?? '—'}</p>
|
||
<p className="mt-0.5 truncate text-[12px] text-[#6b7075]">{profile?.email ?? session.user?.email}</p>
|
||
{profile?.createdAt && (
|
||
<p className="mt-0.5 text-[12px] text-[#86898e]">
|
||
注册于 {new Date(profile.createdAt).toLocaleDateString()}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="mt-8 grid grid-cols-2 gap-6">
|
||
<div>
|
||
<p className="text-[28px] font-semibold tabular-nums leading-none">{materials.length}</p>
|
||
<p className="mt-1.5 text-[12px] text-[#86898e]">我的素材</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-[28px] font-semibold tabular-nums leading-none">{favorites.length}</p>
|
||
<p className="mt-1.5 text-[12px] text-[#86898e]">我的收藏</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ===== 我的素材 ===== */}
|
||
<section className="mt-14">
|
||
<div className="mb-6 flex items-baseline justify-between border-b border-[#e7e8ea] pb-4">
|
||
<h2 className="text-[22px] font-semibold tracking-tight">我的素材</h2>
|
||
<span className="text-[12px] tabular-nums text-[#86898e]">{materials.length} 个</span>
|
||
</div>
|
||
{materials.length === 0 ? (
|
||
<p className="py-10 text-center text-[13px] text-[#86898e]">还没有上传过素材</p>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||
{materials.map((m) => renderMaterialCard(m))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* ===== 我的收藏 ===== */}
|
||
<section className="mt-14">
|
||
<div className="mb-6 flex items-baseline justify-between border-b border-[#e7e8ea] pb-4">
|
||
<h2 className="text-[22px] font-semibold tracking-tight">我的收藏</h2>
|
||
<span className="text-[12px] tabular-nums text-[#86898e]">{favorites.length} 个</span>
|
||
</div>
|
||
{favorites.length === 0 ? (
|
||
<p className="py-10 text-center text-[13px] text-[#86898e]">还没有收藏素材</p>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||
{favorites.map((m) => renderMaterialCard(m, true))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</main>
|
||
|
||
{/* 头像上传模态框 */}
|
||
{showAvatarModal && (
|
||
<div
|
||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
|
||
onClick={() => setShowAvatarModal(false)}
|
||
>
|
||
<div
|
||
className="w-full max-w-sm rounded-xl border border-[#e7e8ea] bg-white p-6"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 className="mb-5 text-[16px] font-semibold">上传头像</h3>
|
||
{avatarPreview && (
|
||
<div className="mb-5 flex justify-center">
|
||
<div className="h-32 w-32 overflow-hidden rounded-full border border-[#e7e8ea]">
|
||
<Image
|
||
src={avatarPreview}
|
||
alt="预览"
|
||
width={128}
|
||
height={128}
|
||
className="h-full w-full object-cover"
|
||
unoptimized
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setShowAvatarModal(false)
|
||
setAvatarFile(null)
|
||
setAvatarPreview('')
|
||
}}
|
||
className="min-h-11 flex-1 rounded-lg border border-[#1a1c1d] bg-white px-4 text-[13px] font-medium transition-colors hover:bg-[#1a1c1d] hover:text-white"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleAvatarUpload}
|
||
disabled={uploading}
|
||
className="min-h-11 flex-1 rounded-lg bg-[#1a1c1d] px-4 text-[13px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.98] disabled:opacity-50"
|
||
>
|
||
{uploading ? '上传中...' : '确认上传'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Toast */}
|
||
{toast && toast.show && <ToastBubble message={toast.message} variant={toast.variant} />}
|
||
</div>
|
||
)
|
||
}
|