feat: 优化考核调研月度看板前端

This commit is contained in:
chenpei 2026-07-09 18:53:11 +08:00
parent 17182a90a5
commit 1cfffab48a
2 changed files with 285 additions and 195 deletions

View File

@ -368,9 +368,7 @@ function Dashboard() {
const [tab, setTab] = useState(() => (
['personal', 'public', 'manage'].includes(requestedTab) ? requestedTab : 'personal'
))
const surveys = useApi<any>(tab === 'manage' ? '/surveys' : null, [tab])
const me = useApi<any>('/me', [])
const canManage = Boolean(me.data?.permissions?.isDepartmentAdmin)
const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户'
const departmentLabel = me.data?.employee?.departmentName || me.data?.user?.department || '未同步部门'
const positionLabel = [me.data?.employee?.positionTitle, me.data?.employee?.positionLevel].filter(Boolean).join(' · ')
@ -395,12 +393,12 @@ function Dashboard() {
}
return (
<main className="mx-auto max-w-[430px] bg-white px-5 py-6 pb-24 text-[#101828] dark:bg-gray-950 sm:max-w-7xl sm:bg-transparent sm:py-6 md:pb-6">
<section className="mb-5 rounded-[14px] bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-[20px] sm:p-5">
<div className="flex items-start justify-between gap-3">
<h1 className="text-[25px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] dark:text-white sm:text-2xl">我的调研</h1>
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-[14px] font-semibold leading-5 text-[#111827] dark:text-gray-200">
<main className="mx-auto w-full max-w-[430px] bg-white px-4 py-5 pb-24 text-[#101828] dark:bg-gray-950 sm:max-w-[1180px] sm:bg-transparent sm:px-6 sm:py-6 md:pb-8">
<section className="mb-5 rounded-[18px] border border-[#eaedf3] bg-white px-5 py-5 shadow-[0_10px_26px_rgba(15,23,42,0.05)] dark:border-gray-800 dark:bg-gray-900 sm:rounded-[16px] sm:px-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h1 className="text-[26px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] dark:text-white sm:text-[28px]">我的调研</h1>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-[14px] font-semibold leading-5 text-[#111827] dark:text-gray-200">
<span className="max-w-full truncate">{displayName}</span>
<span className="text-[#98a2b3]">·</span>
<span className="max-w-full truncate">{departmentLabel}</span>
@ -411,7 +409,9 @@ function Dashboard() {
</>
)}
</div>
<div className="mt-5 flex gap-2 overflow-x-auto border-b border-[#e5eaf2] pb-0 dark:border-gray-800">
</div>
</div>
<div className="mt-6 flex gap-6 overflow-x-auto border-b border-[#e5eaf2] pb-0 dark:border-gray-800">
{[
{ id: 'personal', label: '个人看板' },
{ id: 'public', label: '公共看板' },
@ -421,7 +421,7 @@ function Dashboard() {
key={item.id}
type="button"
onClick={() => selectTab(item.id)}
className={`shrink-0 border-b-2 px-1 pb-3 text-[14px] font-semibold transition-colors ${
className={`shrink-0 border-b-2 px-0 pb-3 text-[14px] font-semibold transition-colors ${
tab === item.id
? 'border-[#0052d9] text-[#0052d9]'
: 'border-transparent text-[#344054] hover:text-[#0052d9] dark:text-gray-300'
@ -433,18 +433,11 @@ function Dashboard() {
</div>
</section>
{tab === 'manage' && surveys.loading && <Panel>加载中...</Panel>}
{surveys.error && <ErrorPanel message={surveys.error} />}
{tab !== 'manage' && (
<PerformanceDashboardBoard view={tab === 'public' ? 'public' : 'personal'} />
)}
{tab === 'manage' && (
<ManagePanel
canManage={canManage}
onChanged={async () => {
await surveys.refresh()
}}
/>
<ManagePanel />
)}
</main>
)
@ -529,12 +522,12 @@ function PerformanceDashboardBoard({ view }: { view: 'personal' | 'public' }) {
const templates = dashboard.data?.templates || []
const permissions = dashboard.data?.permissions || {}
const personalRows = (dashboard.data?.personal?.rows || []).filter((row: any) => row.hasData)
const publicRows = dashboard.data?.public?.rows || []
const publicRows = (dashboard.data?.public?.rows || []).filter((row: any) => row.hasData)
return (
<section className="mb-5 space-y-4">
{view === 'public' && (
<div className="rounded-[18px] border border-[#e6ebf2] bg-white px-4 py-4 shadow-[0_12px_28px_rgba(15,23,42,0.05)] dark:border-gray-700 dark:bg-gray-900">
<div className="rounded-[16px] border border-[#eaedf3] bg-white px-4 py-4 shadow-[0_8px_22px_rgba(15,23,42,0.04)] dark:border-gray-800 dark:bg-gray-900 sm:px-5">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-[17px] font-semibold text-[#101828] dark:text-white">公共看板</h2>
{(department || employee) && (
@ -544,13 +537,13 @@ function PerformanceDashboardBoard({ view }: { view: 'personal' | 'public' }) {
setDepartment('')
setEmployee(null)
}}
className="rounded-full bg-[#f3f6fb] px-3 py-1.5 text-xs font-semibold text-[#475467] hover:bg-[#e8eef7] dark:bg-gray-800 dark:text-gray-200"
className="rounded-full border border-[#dbe5f2] bg-white px-3 py-1.5 text-xs font-semibold text-[#475467] hover:bg-[#f8fafc] dark:border-gray-700 dark:bg-gray-950 dark:text-gray-200"
>
清除
</button>
)}
</div>
<div className="grid gap-2 lg:grid-cols-[150px_minmax(210px,0.9fr)_minmax(220px,1fr)]">
<div className="grid gap-2 lg:grid-cols-[150px_minmax(230px,0.9fr)_minmax(260px,1fr)]">
<input
type="month"
value={month}
@ -607,17 +600,18 @@ function MonthlyPersonalLedger({
if (rows.length === 0) return <EmptyLedger title="暂无月度考核" />
return (
<div className="space-y-3">
<div>
<div className="space-y-3 sm:hidden">
{rows.map((row) => (
<article
key={row.month}
className="rounded-[16px] border border-[#e5eaf2] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.06)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-[14px]"
className="rounded-[16px] border border-[#e5eaf2] bg-white px-4 py-4 shadow-[0_8px_20px_rgba(15,23,42,0.04)] dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[20px] font-semibold text-[#101828] dark:text-white">{row.month}</div>
<div className="text-xs font-semibold text-[#667085] dark:text-gray-400">满分 120</div>
<div className="text-xs font-semibold text-[#667085] dark:text-gray-400">/120</div>
</div>
<div className="grid gap-2 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-2">
{templates.map((template) => (
<MonthlyScoreCell
key={template.code}
@ -634,6 +628,38 @@ function MonthlyPersonalLedger({
</article>
))}
</div>
<div className="hidden overflow-hidden rounded-[16px] border border-[#eaedf3] bg-white shadow-[0_8px_22px_rgba(15,23,42,0.04)] dark:border-gray-800 dark:bg-gray-900 sm:block">
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className="border-b border-[#edf1f6] bg-[#fbfcfe] text-[12px] font-semibold text-[#667085] dark:border-gray-800 dark:bg-gray-950 dark:text-gray-400">
<th className="w-[160px] px-5 py-3">月份</th>
{templates.map((template) => (
<th key={template.code} className="px-4 py-3">{template.name}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-[#edf1f6] dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.month} className="text-sm text-[#101828] dark:text-white">
<td className="px-5 py-4 text-[15px] font-semibold">{row.month}</td>
{templates.map((template) => (
<td key={template.code} className="px-4 py-4">
<MonthlyTableScoreCell
cell={row.cells?.[template.code]}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
})}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@ -651,20 +677,20 @@ function MonthlyPublicLedger({
if (rows.length === 0) return <EmptyLedger title="暂无人员数据" />
return (
<div className="space-y-2.5">
<div>
<div className="space-y-2.5 sm:hidden">
{rows.map((row) => (
<article
key={row.employee.id}
className="rounded-[18px] border border-[#e6ebf2] bg-white px-4 py-4 shadow-[0_10px_26px_rgba(15,23,42,0.045)] dark:border-gray-700 dark:bg-gray-900"
className="rounded-[18px] border border-[#e6ebf2] bg-white px-4 py-4 shadow-[0_8px_20px_rgba(15,23,42,0.04)] dark:border-gray-700 dark:bg-gray-900"
>
<div className="grid gap-3 xl:grid-cols-[minmax(180px,0.75fr)_minmax(0,2.25fr)] xl:items-center">
<div className="min-w-0">
<div className="truncate text-[18px] font-semibold text-[#101828] dark:text-white">{row.employee.displayName}</div>
<div className="mt-1 truncate text-[12px] font-semibold text-[#667085] dark:text-gray-400">
{[row.employee.departmentName, row.employee.positionLevel || row.employee.levelType].filter(Boolean).join(' · ')}
</div>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
<div className="mt-3 grid grid-cols-2 gap-2">
{templates.map((template) => (
<MonthlyScoreCell
key={template.code}
@ -680,10 +706,47 @@ function MonthlyPublicLedger({
/>
))}
</div>
</div>
</article>
))}
</div>
<div className="hidden overflow-hidden rounded-[16px] border border-[#eaedf3] bg-white shadow-[0_8px_22px_rgba(15,23,42,0.04)] dark:border-gray-800 dark:bg-gray-900 sm:block">
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className="border-b border-[#edf1f6] bg-[#fbfcfe] text-[12px] font-semibold text-[#667085] dark:border-gray-800 dark:bg-gray-950 dark:text-gray-400">
<th className="w-[210px] px-5 py-3">人员</th>
<th className="w-[220px] px-4 py-3">组织 / 职级</th>
{templates.map((template) => (
<th key={template.code} className="px-4 py-3">{template.name}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-[#edf1f6] dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.employee.id} className="text-sm text-[#101828] dark:text-white">
<td className="px-5 py-4 text-[15px] font-semibold">{row.employee.displayName}</td>
<td className="px-4 py-4 text-[13px] font-medium text-[#667085] dark:text-gray-400">
<div className="truncate">{row.employee.departmentName || '-'}</div>
<div className="mt-1 truncate">{row.employee.positionLevel || row.employee.levelType || '-'}</div>
</td>
{templates.map((template) => (
<td key={template.code} className="px-4 py-4">
<MonthlyTableScoreCell
cell={row.cells?.[template.code]}
canOpenOverride={canOpenPublicDetail}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
})}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@ -704,8 +767,8 @@ function MonthlyScoreCell({
const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed))
const className = `${compact ? 'min-h-[72px] px-3 py-3' : 'min-h-[88px] px-3.5 py-3.5'} rounded-[14px] border text-left transition-colors ${
canOpen
? 'border-[#cfe0ff] bg-[#f7fbff] hover:border-[#1677ff] hover:bg-[#eef6ff] dark:border-blue-500/30 dark:bg-blue-500/10'
: 'border-[#e7edf5] bg-[#f8fafc] dark:border-gray-700 dark:bg-gray-800'
? 'border-[#cfe0ff] bg-white hover:border-[#1677ff] hover:bg-[#f7fbff] dark:border-blue-500/30 dark:bg-blue-500/10'
: 'border-[#e7edf5] bg-[#fafbfc] dark:border-gray-700 dark:bg-gray-800'
}`
const content = (
<>
@ -730,9 +793,44 @@ function MonthlyScoreCell({
)
}
function MonthlyTableScoreCell({
cell,
canOpenOverride,
onClick,
}: {
cell: any
canOpenOverride?: boolean
onClick: () => void
}) {
const hasScore = cell && cell.score !== null && cell.score !== undefined
const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed))
const content = (
<>
<span className={`text-[15px] font-semibold ${hasScore ? 'text-[#101828] dark:text-white' : 'text-[#98a2b3] dark:text-gray-500'}`}>
{hasScore ? formatNullableScore(cell.score) : '-'}
</span>
{hasScore && <span className="text-[11px] font-semibold text-[#98a2b3]">/120</span>}
</>
)
if (!canOpen) {
return <div className="inline-flex min-h-9 min-w-[74px] items-center gap-1 rounded-full bg-[#f8fafc] px-3 text-left dark:bg-gray-800">{content}</div>
}
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-9 min-w-[74px] items-center gap-1 rounded-full border border-[#cfe0ff] bg-white px-3 text-left transition-colors hover:border-[#1677ff] hover:bg-[#f7fbff] dark:border-blue-500/30 dark:bg-blue-500/10"
>
{content}
</button>
)
}
function EmptyLedger({ title }: { title: string }) {
return (
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-12 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900">
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-12 text-center shadow-[0_8px_20px_rgba(15,23,42,0.04)] dark:border-gray-700 dark:bg-gray-900">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">{title}</h2>
</div>
@ -756,8 +854,8 @@ function MonthlyScoreDetailModal({
if (!request) return null
return (
<div className="fixed inset-0 z-50 flex items-end bg-black/35 p-0 sm:items-center sm:justify-center sm:p-4">
<div className="max-h-[88vh] w-full overflow-y-auto rounded-t-[22px] bg-white p-5 shadow-[0_-18px_50px_rgba(15,23,42,0.18)] dark:bg-gray-900 sm:max-w-2xl sm:rounded-[18px]">
<div className="fixed inset-0 z-50 flex items-end bg-black/30 p-0 sm:items-stretch sm:justify-end">
<div className="max-h-[88vh] w-full overflow-y-auto rounded-t-[22px] bg-white p-5 shadow-[0_-18px_50px_rgba(15,23,42,0.18)] dark:bg-gray-900 sm:h-full sm:max-h-none sm:w-[520px] sm:rounded-none sm:border-l sm:border-[#e5eaf2] sm:p-6 sm:shadow-[-18px_0_45px_rgba(15,23,42,0.12)] dark:sm:border-gray-800">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold text-[#1677ff]">{detail.data?.month || request.month}</div>
@ -781,7 +879,7 @@ function MonthlyScoreDetailModal({
{detail.error && <ErrorPanel message={detail.error} />}
{detail.data && (
<div className="mt-5 space-y-4">
<div className="rounded-[14px] bg-[#f6f8fb] px-4 py-3 dark:bg-gray-800">
<div className="rounded-[14px] border border-[#e5eaf2] bg-white px-4 py-3 dark:border-gray-700 dark:bg-gray-800">
<div className="text-xs font-semibold text-[#667085] dark:text-gray-400">最终分</div>
<div className="mt-1 text-[30px] font-semibold leading-none text-[#101828] dark:text-white">
{formatNullableScore(detail.data.score)} <span className="text-sm text-[#98a2b3]">/120</span>
@ -912,7 +1010,7 @@ function SingleEmployeeFilter({
onChange={(event) => setKeyword(event.target.value)}
onFocus={() => keyword.trim() && setOpen(true)}
placeholder={value ? value.displayName : '筛选人员'}
className="w-full rounded-lg border border-[#dbe5f2] bg-white px-3 py-2 text-sm font-medium text-[#101828] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
className="h-11 w-full rounded-[12px] border border-[#dbe5f2] bg-white px-3 text-sm font-medium text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
{value && (
<button
@ -927,7 +1025,7 @@ function SingleEmployeeFilter({
</button>
)}
{open && (
<div className="absolute z-20 mt-2 max-h-56 w-full overflow-y-auto rounded-[10px] border border-[#dbe5f2] bg-white shadow-[0_14px_34px_rgba(15,23,42,0.12)] dark:border-gray-700 dark:bg-gray-900">
<div className="absolute z-20 mt-2 max-h-56 w-full overflow-y-auto rounded-[14px] border border-[#dbe5f2] bg-white shadow-[0_14px_34px_rgba(15,23,42,0.10)] dark:border-gray-700 dark:bg-gray-900">
{loading ? (
<div className="px-3 py-2 text-sm text-[#667085]">搜索中...</div>
) : items.length === 0 ? (
@ -1490,57 +1588,26 @@ function MyResults({ items }: { items: any[] }) {
)
}
function ManagePanel({
canManage,
onChanged,
}: {
canManage: boolean
onChanged: () => Promise<void>
}) {
function ManagePanel() {
const templates = useApi<Template[]>('/templates', [])
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
const notifications = useApi<any[]>(canManage ? '/admin/notifications?limit=30' : null, [canManage])
const notificationStatus = useApi<any>(canManage ? '/admin/notifications/status' : null, [canManage])
const [message, setMessage] = useState<string | null>(null)
const [managedRefreshKey, setManagedRefreshKey] = useState(0)
return (
<div className="space-y-4">
<div className="grid gap-4">
<CreateSurveyForm
templates={templates.data || []}
departments={departments.data || []}
onCreated={async () => {
setMessage('已创建考核调研')
setManagedRefreshKey((value) => value + 1)
await onChanged()
if (canManage) await Promise.all([notifications.refresh(), notificationStatus.refresh()])
setMessage('已发布考核')
}}
/>
</div>
{canManage && (
<ManagedSurveyList
canManage={canManage}
departments={departments.data || []}
refreshKey={managedRefreshKey}
/>
)}
<div className={canManage ? 'grid gap-4 xl:grid-cols-[minmax(340px,0.75fr)_minmax(0,1.25fr)]' : 'grid gap-4'}>
<div className="space-y-4">
{canManage && (
<AdminActions
onMessage={setMessage}
/>
)}
{message && (
<div className="rounded-[14px] bg-white px-4 py-3 text-sm font-medium text-[#0052d9] shadow-[0_8px_18px_rgba(45,43,80,0.06)] dark:bg-gray-900 dark:text-blue-200">
<div className="rounded-[14px] border border-[#cfe0ff] bg-white px-4 py-3 text-sm font-semibold text-[#0052d9] shadow-[0_8px_18px_rgba(15,23,42,0.04)] dark:border-blue-500/30 dark:bg-gray-900 dark:text-blue-200">
{message}
</div>
)}
</div>
{canManage && <NotificationList state={notifications} statusState={notificationStatus} />}
</div>
</div>
)
}
@ -1584,8 +1651,8 @@ function CreateSurveyForm({
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [templateCode, setTemplateCode] = useState('')
const [dueAt, setDueAt] = useState('')
const [openNow, setOpenNow] = useState(true)
const [dueAt, setDueAt] = useState(getCurrentMonthValue())
const openNow = true
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState<Employee[]>([])
const [evaluatorDepartmentValues, setEvaluatorDepartmentValues] = useState<string[]>([])
@ -1631,9 +1698,10 @@ function CreateSurveyForm({
if (surveyKind !== 'item_course' && targetEmployees.length === 0) {
throw new Error(`请至少选择 1 个${selectedTemplateInfo?.ownerRoleName || '被考核人'}`)
}
const assessmentMonth = dueAt || getCurrentMonthValue()
const submitTitle = title.trim()
|| targetItemsToSubmit[0]?.name
|| (surveyKind === 'visual_case' ? `${selectedTemplateInfo?.shortName || '视觉评分'}-${getCurrentMonthValue()}` : '')
|| `${selectedTemplateInfo?.shortName || selectedTemplateInfo?.name || '考核'}-${assessmentMonth}`
const created = await apiRequest<any>('/surveys', {
method: 'POST',
body: JSON.stringify({
@ -1641,7 +1709,7 @@ function CreateSurveyForm({
description,
surveyKind,
templateCode: selectedTemplate,
dueAt: dueAt || null,
dueAt: assessmentMonth ? `${assessmentMonth}-01T00:00:00` : null,
openNow,
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
targetItems: targetItemsToSubmit,
@ -1650,6 +1718,7 @@ function CreateSurveyForm({
})
setTitle('')
setDescription('')
setDueAt(getCurrentMonthValue())
setTargetEmployees([])
setManualEvaluatorEmployees([])
setEvaluatorDepartmentValues([])
@ -1666,61 +1735,49 @@ function CreateSurveyForm({
}
return (
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">新建调研</h2>
<div className="mt-4 grid gap-3.5">
<section className="rounded-[16px] border border-[#eaedf3] bg-white px-4 py-4 shadow-[0_8px_22px_rgba(15,23,42,0.04)] dark:border-gray-800 dark:bg-gray-900 sm:px-6 sm:py-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">发起考核</h2>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<TextInput
label="调研标题"
value={title}
onChange={setTitle}
placeholder={selectedTemplateInfo?.itemNamePlaceholder || '填写这次调研的名称'}
label="月份"
value={dueAt}
onChange={setDueAt}
type="month"
/>
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">模板</span>
<select
value={selectedTemplate}
onChange={(event) => setTemplateCode(event.target.value)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
className="h-11 rounded-[12px] border border-[#dbe5f2] bg-white px-3 text-sm font-semibold text-gray-900 outline-none transition-colors focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
>
{templates.map((template) => (
<option key={template.code} value={template.code}>
{template.name}
{template.shortName || template.name}
</option>
))}
</select>
</label>
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
{surveyKind !== 'item_course' ? (
<>
<div className="lg:col-span-2">
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
<TextInput
label={selectedTemplateInfo?.itemNoteName || '备注'}
value={description}
onChange={setDescription}
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
/>
</>
</div>
) : (
<div>
<div className="lg:col-span-2">
<div className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">{selectedTemplateInfo?.itemName || '调研事项'}</div>
<input
value={itemName}
onChange={(event) => setItemName(event.target.value)}
placeholder={selectedTemplateInfo?.itemNamePlaceholder || '填写要评价的课程、事项或活动名称'}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-white"
className="h-11 w-full rounded-[12px] border border-[#dbe5f2] px-3 text-sm font-medium outline-none transition-colors focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
<div className="mt-3">
<TextInput
label={selectedTemplateInfo?.itemNoteName || '备注'}
value={description}
onChange={setDescription}
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
/>
</div>
</div>
)}
<div className="lg:col-span-2">
<EmployeePicker
title="评价人"
purpose="evaluator"
@ -1729,6 +1786,8 @@ function CreateSurveyForm({
onAddEmployee={(employee) => setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))}
onRemoveEmployee={removeEvaluatorEmployee}
/>
</div>
<div className="lg:col-span-2">
<DepartmentPicker
departments={departments}
selected={evaluatorDepartmentValues}
@ -1745,22 +1804,18 @@ function CreateSurveyForm({
})
}}
/>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" checked={openNow} onChange={(event) => setOpenNow(event.target.checked)} />
创建后立即发布
</label>
</div>
{error && <div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-200">{error}</div>}
<button
disabled={saving}
onClick={submit}
className="rounded-[10px] bg-[#0052d9] px-4 py-2.5 text-sm font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.18)] hover:bg-[#003da6] disabled:opacity-50 dark:bg-white dark:text-gray-900"
className="min-h-11 rounded-[12px] bg-[#0052d9] px-5 text-sm font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.16)] hover:bg-[#003da6] disabled:opacity-50 dark:bg-white dark:text-gray-900 lg:col-span-2 lg:w-[220px]"
>
{saving ? '创建中...' : '创建'}
{saving ? '发布中...' : '发布考核'}
</button>
{createdSurvey && (
<div className="rounded-[12px] border border-blue-100 bg-blue-50 px-3 py-2.5 text-sm font-medium text-blue-800 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-100">
已创建,系统会按评价人发送钉钉通知。
<div className="rounded-[12px] border border-blue-100 bg-blue-50 px-3 py-2.5 text-sm font-medium text-blue-800 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-100 lg:col-span-2">
已发布
</div>
)}
</div>
@ -1875,11 +1930,11 @@ function EmployeePicker({
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-white"
className="h-11 rounded-[12px] border border-[#dbe5f2] bg-white px-3 text-sm font-medium text-gray-900 outline-none transition-colors focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
</label>
{open && keyword && (
<div className="mt-2 max-h-56 overflow-y-auto rounded-[10px] border border-gray-200 bg-white shadow-[0_10px_20px_rgba(15,23,42,0.06)] dark:border-gray-700 dark:bg-gray-900">
<div className="mt-2 max-h-56 overflow-y-auto rounded-[14px] border border-[#dbe5f2] bg-white shadow-[0_14px_34px_rgba(15,23,42,0.10)] dark:border-gray-700 dark:bg-gray-900">
{loading ? (
<div className="px-3 py-2 text-sm text-gray-500">搜索中...</div>
) : error ? (
@ -1893,7 +1948,7 @@ function EmployeePicker({
type="button"
disabled={selectedIds.has(employee.id)}
onClick={() => selectEmployee(employee)}
className="w-full px-3 py-2.5 text-left text-sm hover:bg-gray-50 disabled:opacity-50 dark:hover:bg-gray-700"
className="w-full px-3 py-2.5 text-left text-sm transition-colors hover:bg-[#f6f9ff] disabled:opacity-50 dark:hover:bg-gray-800"
>
<div className="font-medium text-gray-900 dark:text-white">{employee.displayName}</div>
<div className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
@ -2058,15 +2113,15 @@ function DepartmentPicker({
return (
<div key={department.id}>
<div
className="flex min-h-9 items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
className="flex min-h-10 items-center gap-2 rounded-[10px] px-2 py-1.5 text-sm transition-colors hover:bg-[#f6f9ff] dark:hover:bg-gray-800"
style={{ paddingLeft: `${8 + depth * 18}px` }}
>
<button
type="button"
onClick={() => hasChildren && toggle(department.id)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border text-xs ${
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-[8px] border text-xs ${
hasChildren
? 'border-gray-300 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600'
? 'border-[#dbe5f2] text-[#667085] hover:bg-[#f3f6fb] dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
: 'border-transparent text-transparent'
}`}
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
@ -2121,9 +2176,9 @@ function DepartmentPicker({
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索部门"
className="mb-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
className="mb-2 h-11 w-full rounded-[12px] border border-[#dbe5f2] bg-white px-3 text-sm font-medium text-gray-900 outline-none transition-colors focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
<div className="max-h-64 overflow-y-auto rounded-[10px] border border-gray-200 p-1.5 dark:border-gray-700">
<div className="max-h-64 overflow-y-auto rounded-[14px] border border-[#dbe5f2] bg-white p-1.5 dark:border-gray-700 dark:bg-gray-950">
{roots.map((department) => renderNode(department))}
</div>
{error && <div className="mt-2 text-xs text-red-600 dark:text-red-300">{error}</div>}
@ -2136,7 +2191,7 @@ function DepartmentPicker({
onClick={() => {
if (department.value) void toggleValue(department.value, false)
}}
className="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
className="rounded-full border border-[#dbe5f2] bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-[#f6f9ff] dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
>
{department.fullPath || department.departmentPath || (
department.parentName && department.parentName !== '全部部门'
@ -3251,7 +3306,7 @@ function TextInput({
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
className="h-11 rounded-[12px] border border-[#dbe5f2] bg-white px-3 text-sm font-medium text-gray-900 outline-none transition-colors focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
</label>
)
@ -3266,7 +3321,7 @@ function SelectedChips({ items, onRemove }: { items: Array<{ id: string; label:
key={item.id}
type="button"
onClick={() => onRemove(item.id)}
className="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
className="rounded-full border border-[#dbe5f2] bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-[#f6f9ff] dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
>
{item.label} ×
</button>

35
design-qa.md Normal file
View File

@ -0,0 +1,35 @@
**视觉来源**
- Stitch 专案:`https://stitch.withgoogle.com/projects/10867034024263334547`
- 本次对齐的设计稿:
- `个人看板桌面端-优化版`
- `公共看板桌面端-优化版`
- `分数详情抽屉桌面端-优化版`
- `发起考核桌面端-优化版`
- 既有移动端稿:`个人看板` / `公共看板` / `详情弹层`
**实现范围**
- 文件:`app/performance-survey/[[...path]]/page.tsx`
- 页面:`/performance-survey`
- 主入口:
- `tab=personal`
- `tab=public`
- `tab=manage`
**验证结果**
- Lint:`npm run lint -- --file "app/performance-survey/[[...path]]/page.tsx"` 通过。
- 空白检查:`git diff --check -- "app/performance-survey/[[...path]]/page.tsx"` 通过。
- 浏览器验证:
- 桌面端个人看板已切换为月度台账外壳,没有旧任务标签。
- 桌面端公共看板已切换为公共筛选 + 台账外壳,没有旧任务标签。
- 桌面端发起考核只保留月份、模板、被考核人、评价人、发布按钮。
- 移动端个人/公共/发起考核为白色画布,不再有淡紫色页面背景。
- 页面未出现二维码、维护、通知记录、旧四标签工作流、模板占比说明等噪音。
**检查项**
- 新看板不再展示 `待处理 / 已处理 / 我发起的 / 我的结果` 旧工作流入口。
- 公共看板渲染前会过滤无数据人员。
- 发起考核页不再请求旧调研列表。
- 表单控件统一为白底、细边框、克制蓝色焦点样式。
- 桌面表格和移动卡片都保留四模板月度台账模型。
最终结果:通过