chenpei 6c6c282203 feat: 全站视觉统一白皮书语言 + 个人档案改造 + 顶栏平台化
视觉统一(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 对齐)
2026-08-15 14:16:20 +08:00

487 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 个人档案页(原个人中心 · 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>
)
}