优化考核调研移动端问卷体验

This commit is contained in:
chenpei 2026-07-07 19:35:25 +08:00
parent 3e42ca3104
commit ab2e31f01a
5 changed files with 535 additions and 168 deletions

View File

@ -6,6 +6,61 @@ import { buildDingtalkAutoLoginPath } from '@/lib/safe-redirect'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const SURVEY_OPENING_PAGE_STYLE = `
html,body{margin:0;min-height:100%;background:#f7fbff;color:#111827;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
body{min-height:100vh;min-height:100dvh;overflow:hidden}
.topbar{height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;color:#fff;background:linear-gradient(90deg,#1677ff,#4f9cff);box-shadow:0 10px 24px rgba(22,119,255,.18)}
.topbar span:first-child{font-size:28px;line-height:1}
.topbar span:nth-child(2){font-size:15px;font-weight:600}
.topbar span:last-child{font-size:20px;line-height:1}
main{box-sizing:border-box;width:min(390px,100%);margin:0 auto;padding:48px 28px 0}
.brand{text-align:center}
.brand img{width:42px;height:42px;border-radius:10px;object-fit:cover}
.brand h1{margin:12px 0 0;font-size:23px;font-weight:600;line-height:1}
.brand p{margin:8px 0 0;color:#8a94a6;font-size:14px;font-weight:500}
.open-pill{min-height:58px;margin-top:40px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;border-radius:999px;background:#fff;box-shadow:0 12px 30px rgba(15,23,42,.12)}
.open-pill strong{display:flex;align-items:center;gap:12px;font-size:16px;font-weight:600}
.open-pill em{font-style:normal;color:#1677ff;font-size:15px;font-weight:600}
.spinner{width:28px;height:28px;border:4px solid #d7e8ff;border-top-color:#1677ff;border-radius:999px;animation:spin .85s linear infinite}
.message{margin:28px 0 0;text-align:center;color:#8a94a6;font-size:13px;font-weight:500;line-height:1.7}
.ghost-card{margin-top:44px;padding:20px;border-radius:18px;background:rgba(255,255,255,.9);box-shadow:0 18px 42px rgba(15,23,42,.08)}
.ghost-title{font-size:15px;font-weight:600;color:#7b8494}
.ghost-line{height:12px;border-radius:999px;background:#eef3f9}
.ghost-line.one{margin-top:24px}
.ghost-line.two{width:74%;margin-top:12px}
.ghost-list{margin-top:32px;display:grid;gap:20px}
.ghost-row{display:flex;align-items:center;gap:12px}
.ghost-dot{width:16px;height:16px;border-radius:999px;border:1px solid #e3eaf3}
.ghost-row .ghost-line{flex:1;margin:0}
.ghost-row:last-child .ghost-line{flex:.67}
@keyframes spin{to{transform:rotate(360deg)}}
`
const SURVEY_OPENING_PAGE_MARKUP = `
<div class="topbar"><span>‹</span><span>素材库</span><span>•••</span></div>
<main>
<div class="brand">
<img src="/api/favicon" alt="素材库" />
<h1>素材库</h1>
<p>内部素材协作平台</p>
</div>
<div class="open-pill">
<strong><span class="spinner" aria-hidden="true"></span>正在打开问卷</strong>
<em>马上进入</em>
</div>
<p class="message">正在打开问卷...</p>
<div class="ghost-card" aria-hidden="true">
<div class="ghost-title">绩效考核问卷</div>
<div class="ghost-line one"></div>
<div class="ghost-line two"></div>
<div class="ghost-list">
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
</div>
</div>
</main>`
function appendLoginSetCookie(target: NextResponse, source: Response) {
const getSetCookie = (source.headers as any).getSetCookie
const cookies = typeof getSetCookie === 'function'
@ -42,17 +97,10 @@ function createCookieLandingResponse(redirectPath: string, loginResponse: Respon
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>正在打开问卷</title>
<meta http-equiv="Cache-Control" content="no-store" />
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{padding:28px;text-align:center}
p{margin:8px 0 0;color:#64748b}
</style>
<style>${SURVEY_OPENING_PAGE_STYLE}</style>
</head>
<body>
<main>
<h1>正在打开问卷</h1>
<p>正在打开问卷...</p>
</main>
${SURVEY_OPENING_PAGE_MARKUP}
<script>
window.location.replace(${escapedPath});
</script>

View File

@ -10,6 +10,61 @@ import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const SURVEY_OPENING_PAGE_STYLE = `
html,body{margin:0;min-height:100%;background:#f7fbff;color:#111827;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
body{min-height:100vh;min-height:100dvh;overflow:hidden}
.topbar{height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;color:#fff;background:linear-gradient(90deg,#1677ff,#4f9cff);box-shadow:0 10px 24px rgba(22,119,255,.18)}
.topbar span:first-child{font-size:28px;line-height:1}
.topbar span:nth-child(2){font-size:15px;font-weight:600}
.topbar span:last-child{font-size:20px;line-height:1}
main{box-sizing:border-box;width:min(390px,100%);margin:0 auto;padding:48px 28px 0}
.brand{text-align:center}
.brand img{width:42px;height:42px;border-radius:10px;object-fit:cover}
.brand h1{margin:12px 0 0;font-size:23px;font-weight:600;line-height:1}
.brand p{margin:8px 0 0;color:#8a94a6;font-size:14px;font-weight:500}
.open-pill{min-height:58px;margin-top:40px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;border-radius:999px;background:#fff;box-shadow:0 12px 30px rgba(15,23,42,.12)}
.open-pill strong{display:flex;align-items:center;gap:12px;font-size:16px;font-weight:600}
.open-pill em{font-style:normal;color:#1677ff;font-size:15px;font-weight:600}
.spinner{width:28px;height:28px;border:4px solid #d7e8ff;border-top-color:#1677ff;border-radius:999px;animation:spin .85s linear infinite}
#message{margin:28px 0 0;text-align:center;color:#8a94a6;font-size:13px;font-weight:500;line-height:1.7}
.ghost-card{margin-top:44px;padding:20px;border-radius:18px;background:rgba(255,255,255,.9);box-shadow:0 18px 42px rgba(15,23,42,.08)}
.ghost-title{font-size:15px;font-weight:600;color:#7b8494}
.ghost-line{height:12px;border-radius:999px;background:#eef3f9}
.ghost-line.one{margin-top:24px}
.ghost-line.two{width:74%;margin-top:12px}
.ghost-list{margin-top:32px;display:grid;gap:20px}
.ghost-row{display:flex;align-items:center;gap:12px}
.ghost-dot{width:16px;height:16px;border-radius:999px;border:1px solid #e3eaf3}
.ghost-row .ghost-line{flex:1;margin:0}
.ghost-row:last-child .ghost-line{flex:.67}
@keyframes spin{to{transform:rotate(360deg)}}
`
const SURVEY_OPENING_PAGE_MARKUP = `
<div class="topbar"><span>‹</span><span>素材库</span><span>•••</span></div>
<main>
<div class="brand">
<img src="/api/favicon" alt="素材库" />
<h1>素材库</h1>
<p>内部素材协作平台</p>
</div>
<div class="open-pill">
<strong><span class="spinner" aria-hidden="true"></span>正在打开问卷</strong>
<em>马上进入</em>
</div>
<p id="message">正在打开问卷...</p>
<div class="ghost-card" aria-hidden="true">
<div class="ghost-title">绩效考核问卷</div>
<div class="ghost-line one"></div>
<div class="ghost-line two"></div>
<div class="ghost-list">
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
</div>
</div>
</main>`
function getLoginSetCookies(source: Response): string[] {
const getSetCookie = (source.headers as any).getSetCookie
const cookies = typeof getSetCookie === 'function'
@ -78,21 +133,10 @@ function createSessionTicketTransferPage({
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>正在打开问卷</title>
<meta http-equiv="Cache-Control" content="no-store" />
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f172a;color:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(420px,calc(100vw - 48px));padding:32px 24px;text-align:center}
h1{margin:0 0 12px;font-size:22px}
p{margin:0;color:#cbd5e1;line-height:1.7}
.spinner{width:38px;height:38px;margin:0 auto 20px;border:4px solid rgba(255,255,255,.24);border-top-color:#60a5fa;border-radius:999px;animation:spin .85s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
<style>${SURVEY_OPENING_PAGE_STYLE}</style>
</head>
<body>
<main>
<div class="spinner" aria-hidden="true"></div>
<h1>正在打开问卷</h1>
<p id="message">正在打开问卷...</p>
</main>
${SURVEY_OPENING_PAGE_MARKUP}
<script>
(function () {
var targetUrl = ${stringifyForInlineScript(redirectUrl)};
@ -185,21 +229,10 @@ function createPerformanceSurveySessionTransferPage({
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>正在打开问卷</title>
<meta http-equiv="Cache-Control" content="no-store" />
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f172a;color:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(420px,calc(100vw - 48px));padding:32px 24px;text-align:center}
h1{margin:0 0 12px;font-size:22px}
p{margin:0;color:#cbd5e1;line-height:1.7}
.spinner{width:38px;height:38px;margin:0 auto 20px;border:4px solid rgba(255,255,255,.24);border-top-color:#60a5fa;border-radius:999px;animation:spin .85s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
<style>${SURVEY_OPENING_PAGE_STYLE}</style>
</head>
<body>
<main>
<div class="spinner" aria-hidden="true"></div>
<h1>正在打开问卷</h1>
<p id="message">正在打开问卷...</p>
</main>
${SURVEY_OPENING_PAGE_MARKUP}
<script>
(function () {
var targetUrl = ${stringifyForInlineScript(redirectUrl)};

View File

@ -5,6 +5,61 @@ import { consumeDingtalkSessionTicket } from '@/lib/dingtalk-session-ticket'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const SURVEY_OPENING_PAGE_STYLE = `
html,body{margin:0;min-height:100%;background:#f7fbff;color:#111827;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
body{min-height:100vh;min-height:100dvh;overflow:hidden}
.topbar{height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;color:#fff;background:linear-gradient(90deg,#1677ff,#4f9cff);box-shadow:0 10px 24px rgba(22,119,255,.18)}
.topbar span:first-child{font-size:28px;line-height:1}
.topbar span:nth-child(2){font-size:15px;font-weight:600}
.topbar span:last-child{font-size:20px;line-height:1}
main{box-sizing:border-box;width:min(390px,100%);margin:0 auto;padding:48px 28px 0}
.brand{text-align:center}
.brand img{width:42px;height:42px;border-radius:10px;object-fit:cover}
.brand h1{margin:12px 0 0;font-size:23px;font-weight:600;line-height:1}
.brand p{margin:8px 0 0;color:#8a94a6;font-size:14px;font-weight:500}
.open-pill{min-height:58px;margin-top:40px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;border-radius:999px;background:#fff;box-shadow:0 12px 30px rgba(15,23,42,.12)}
.open-pill strong{display:flex;align-items:center;gap:12px;font-size:16px;font-weight:600}
.open-pill em{font-style:normal;color:#1677ff;font-size:15px;font-weight:600}
.spinner{width:28px;height:28px;border:4px solid #d7e8ff;border-top-color:#1677ff;border-radius:999px;animation:spin .85s linear infinite}
#message{margin:28px 0 0;text-align:center;color:#8a94a6;font-size:13px;font-weight:500;line-height:1.7}
.ghost-card{margin-top:44px;padding:20px;border-radius:18px;background:rgba(255,255,255,.9);box-shadow:0 18px 42px rgba(15,23,42,.08)}
.ghost-title{font-size:15px;font-weight:600;color:#7b8494}
.ghost-line{height:12px;border-radius:999px;background:#eef3f9}
.ghost-line.one{margin-top:24px}
.ghost-line.two{width:74%;margin-top:12px}
.ghost-list{margin-top:32px;display:grid;gap:20px}
.ghost-row{display:flex;align-items:center;gap:12px}
.ghost-dot{width:16px;height:16px;border-radius:999px;border:1px solid #e3eaf3}
.ghost-row .ghost-line{flex:1;margin:0}
.ghost-row:last-child .ghost-line{flex:.67}
@keyframes spin{to{transform:rotate(360deg)}}
`
const SURVEY_OPENING_PAGE_MARKUP = `
<div class="topbar"><span>‹</span><span>素材库</span><span>•••</span></div>
<main>
<div class="brand">
<img src="/api/favicon" alt="素材库" />
<h1>素材库</h1>
<p>内部素材协作平台</p>
</div>
<div class="open-pill">
<strong><span class="spinner" aria-hidden="true"></span>正在打开问卷</strong>
<em>马上进入</em>
</div>
<p id="message">正在打开问卷...</p>
<div class="ghost-card" aria-hidden="true">
<div class="ghost-title">绩效考核问卷</div>
<div class="ghost-line one"></div>
<div class="ghost-line two"></div>
<div class="ghost-list">
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
<div class="ghost-row"><span class="ghost-dot"></span><span class="ghost-line"></span></div>
</div>
</div>
</main>`
function stringifyForInlineScript(value: string) {
return JSON.stringify(value).replace(/</g, '\\u003c')
}
@ -94,21 +149,10 @@ function createSessionRedirectResponse({
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>正在打开问卷</title>
<meta http-equiv="Cache-Control" content="no-store" />
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f172a;color:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(420px,calc(100vw - 48px));padding:32px 24px;text-align:center}
h1{margin:0 0 12px;font-size:22px}
p{margin:0;color:#cbd5e1;line-height:1.7}
.spinner{width:38px;height:38px;margin:0 auto 20px;border:4px solid rgba(255,255,255,.24);border-top-color:#60a5fa;border-radius:999px;animation:spin .85s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
<style>${SURVEY_OPENING_PAGE_STYLE}</style>
</head>
<body>
<main>
<div class="spinner" aria-hidden="true"></div>
<h1>正在打开问卷</h1>
<p id="message">正在打开问卷...</p>
</main>
${SURVEY_OPENING_PAGE_MARKUP}
<script>
(function () {
var targetUrl = ${stringifyForInlineScript(redirectUrl)};

View File

@ -1,5 +1,6 @@
'use client'
import Image from 'next/image'
import { useEffect, useRef, useState } from 'react'
declare global {
@ -132,12 +133,12 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
const run = async () => {
if (!corpId) {
setMessage('暂时无法打开问卷,请联系管理员检查钉钉应用配置。')
setMessage('请返回钉钉,从最新通知重新打开问卷。')
return
}
if (!/DingTalk/i.test(window.navigator.userAgent || '')) {
setMessage('请在钉钉客户端内打开这条通知。')
setMessage('请在钉钉客户端内打开通知。')
return
}
@ -155,7 +156,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
navigateDingtalkH5Login(authCode, redirectPath)
} catch (error) {
console.error('[Dingtalk Entry] 打开问卷失败:', error)
setMessage('暂时无法打开问卷,请返回钉钉从最新通知重新进入。')
setMessage('请返回钉钉,从最新通知重新打开问卷。')
}
}
@ -163,11 +164,55 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
}, [corpId, redirectPath])
return (
<main className="flex min-h-screen items-center justify-center bg-slate-950 px-6 text-slate-50">
<section className="w-full max-w-sm text-center">
<div className="mx-auto mb-5 h-10 w-10 animate-spin rounded-full border-4 border-white/25 border-t-sky-400" />
<h1 className="mb-3 text-2xl font-semibold">正在打开问卷</h1>
<p className="text-sm leading-7 text-slate-300">{message}</p>
<main className="fixed inset-0 min-h-[100dvh] overflow-hidden bg-[#f7fbff] text-[#111827]">
<div className="flex h-14 items-center justify-between bg-gradient-to-r from-[#1677ff] to-[#4f9cff] px-5 text-white shadow-[0_10px_24px_rgba(22,119,255,0.18)]">
<span className="text-2xl leading-none">‹</span>
<span className="text-[15px] font-semibold">素材库</span>
<span className="text-xl leading-none">•••</span>
</div>
<section className="mx-auto max-w-[390px] px-7 pt-12">
<div className="text-center">
<Image
src="/api/favicon"
alt="素材库"
width={42}
height={42}
unoptimized
className="mx-auto h-[42px] w-[42px] rounded-[10px] object-cover"
/>
<h1 className="mt-3 text-[23px] font-semibold leading-none">素材库</h1>
<p className="mt-2 text-[14px] font-medium text-[#8a94a6]">内部素材协作平台</p>
</div>
<div className="mt-10 flex min-h-[58px] items-center justify-between rounded-full bg-white px-5 shadow-[0_12px_30px_rgba(15,23,42,0.12)]">
<div className="flex items-center gap-3">
<span className="h-7 w-7 animate-spin rounded-full border-[4px] border-[#d7e8ff] border-t-[#1677ff]" />
<span className="text-[16px] font-semibold">正在打开问卷</span>
</div>
<span className="text-[15px] font-semibold text-[#1677ff]">马上进入</span>
</div>
<p className="mt-7 text-center text-[13px] font-medium leading-6 text-[#8a94a6]">{message}</p>
<div className="mt-11 rounded-[18px] bg-white/90 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.08)]">
<div className="text-[15px] font-semibold text-[#7b8494]">绩效考核问卷</div>
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
<div className="mt-8 space-y-5">
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
</div>
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
</div>
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 w-2/3 rounded-full bg-[#f1f5f9]" />
</div>
</div>
</div>
</section>
</main>
)

View File

@ -1,6 +1,7 @@
'use client'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
@ -258,14 +259,23 @@ export default function PerformanceSurveyPage() {
},
} as any)
const isSurveySubmitPage = path[0] === 'survey' && Boolean(path[1])
let content = <Dashboard />
if (path[0] === 'survey' && path[1]) {
content = <SurveySubmit surveyId={path[1]} />
if (isSurveySubmitPage && path[1]) {
content = <SurveySubmit surveyId={path[1]} session={effectiveSession} />
}
if (path[0] === 'results' && path[1]) {
content = <SurveyResults surveyId={path[1]} />
}
if (isSurveySubmitPage) {
return (
<div className="min-h-screen bg-white">
{content}
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={effectiveSession} />
@ -1211,7 +1221,7 @@ function NotificationList({
)
}
function SurveySubmit({ surveyId }: { surveyId: string }) {
function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) {
const searchParams = useSearchParams()
const targetIdParam = searchParams.get('targetId')
const detail = useApi<any>(`/surveys/${surveyId}`, [surveyId])
@ -1260,7 +1270,7 @@ function SurveySubmit({ surveyId }: { surveyId: string }) {
}
}
if (detail.loading) return <MainPanel>正在打开问卷...</MainPanel>
if (detail.loading) return <SurveyOpeningScreen />
if (detail.error) return <MainPanel><ErrorPanel message={detail.error} /></MainPanel>
if (!detail.data) return null
@ -1268,53 +1278,50 @@ function SurveySubmit({ surveyId }: { surveyId: string }) {
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
const scoreValues = questions.map((question) => Number(scores[question.id] ?? 16))
const scoreTotal = scoreValues.reduce((sum, value) => sum + value, 0)
const averageScore = questions.length > 0 ? (scoreTotal / questions.length).toFixed(1) : '-'
const ratedCount = questions.filter((question) => typeof scores[question.id] === 'number').length
const progressPercent = questions.length > 0 ? Math.round((ratedCount / questions.length) * 100) : 0
const viewer = session?.user || {}
const viewerName = String(viewer.name || '当前用户')
const viewerDepartment = String(viewer.department || viewer.managedDepartment || '素材库成员')
const viewerAvatar = typeof viewer.avatar === 'string' && viewer.avatar
? viewer.avatar
: typeof viewer.image === 'string'
? viewer.image
: ''
const surveyStatusLabel = getSurveyStatusLabel(detail.data.status)
const dueLabel = detail.data.dueAt ? `截止 ${formatDate(detail.data.dueAt)}` : '无截止时间'
return (
<main className="mx-auto max-w-3xl px-3 pb-24 pt-4 sm:px-4 sm:pb-8 sm:pt-6">
<div className="mb-4">
<Link href="/performance-survey" className="text-sm text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">返回考核调研</Link>
<h1 className="mt-3 text-xl font-semibold leading-snug text-gray-900 dark:text-white sm:text-2xl">{detail.data.title}</h1>
<div className="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<StatusBadge status={detail.data.status} />
{detail.data.dueAt && <span>截止 {formatDate(detail.data.dueAt)}</span>}
</div>
</div>
<section className="mb-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs text-gray-500 dark:text-gray-400">评价对象</div>
<div className="mt-1 truncate text-lg font-semibold text-gray-900 dark:text-white">
{activeTarget?.targetName || '待选择'}
<main className="min-h-[100dvh] bg-white text-[#111827]">
<MobileSurveyRangeStyles />
<div className="mx-auto flex min-h-[100dvh] max-w-[430px] flex-col bg-white px-5 pb-[calc(92px+env(safe-area-inset-bottom))] pt-6 sm:my-6 sm:min-h-0 sm:rounded-[28px] sm:border sm:border-[#e5eaf2] sm:shadow-[0_24px_80px_rgba(15,23,42,0.14)]">
<div className="mb-6 flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<SurveyUserAvatar src={viewerAvatar} name={viewerName} />
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold leading-5 text-[#111827]">{viewerName}</div>
<div className="mt-0.5 truncate text-[12px] font-medium leading-4 text-[#9aa3b2]">{viewerDepartment}</div>
</div>
</div>
<div className="rounded-lg bg-gray-50 px-3 py-2 text-right dark:bg-gray-900/40">
<div className="text-xs text-gray-500 dark:text-gray-400">平均分</div>
<div className="mt-0.5 text-lg font-semibold text-gray-900 dark:text-white">{averageScore}</div>
</div>
</div>
<div className="mt-4">
<div className="mb-1.5 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>评分进度</span>
<span>{ratedCount}/{questions.length || 0}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div
className="h-full rounded-full bg-gray-900 transition-all dark:bg-white"
style={{ width: `${progressPercent}%` }}
/>
<span className="shrink-0 rounded-[7px] bg-[#eaf3ff] px-3 py-1.5 text-[13px] font-semibold leading-none text-[#1677ff]">
{surveyStatusLabel}
</span>
</div>
<div className="mb-7">
<h1 className="text-[22px] font-semibold leading-[1.25] tracking-[-0.01em] text-[#111827]">{detail.data.title}</h1>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] font-medium leading-5 text-[#7b8494]">
<span>评价对象:{activeTarget?.targetName || '待选择'}</span>
<span>{dueLabel}</span>
</div>
</div>
{targets.length > 1 && (
<label className="mt-4 grid gap-1.5 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">评价对象</span>
<label className="mb-4 grid gap-2 text-sm">
<span className="font-semibold text-[#4b5565]">切换评价对象</span>
<select
value={activeTargetId}
onChange={(event) => setTargetId(event.target.value)}
className="min-h-11 rounded-lg border border-gray-300 bg-white px-3 py-2 text-base text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white sm:text-sm"
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
>
{targets.map((target: any) => (
<option key={target.id} value={target.id}>{target.targetName}</option>
@ -1322,70 +1329,152 @@ function SurveySubmit({ surveyId }: { surveyId: string }) {
</select>
</label>
)}
</section>
{submitted ? (
<EmptyState title="你已提交该评价" />
) : detail.data.status !== 'open' ? (
<EmptyState title="该调研未开放或已关闭" />
) : (
<div className="space-y-4">
{questions.map((question, index) => (
<section key={question.id} className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium text-gray-400 dark:text-gray-500">问题 {index + 1}</div>
<div className="mt-1 font-medium leading-snug text-gray-900 dark:text-white">{question.title}</div>
{question.description && <div className="mt-1 text-sm leading-6 text-gray-500 dark:text-gray-400">{question.description}</div>}
</div>
<span className="shrink-0 rounded-md bg-gray-900 px-2.5 py-1 text-sm font-semibold text-white dark:bg-white dark:text-gray-900">{scores[question.id] ?? 16}/20</span>
</div>
<input
type="range"
min={0}
max={20}
value={scores[question.id] ?? 16}
onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))}
className="h-8 w-full accent-gray-900 dark:accent-white"
/>
<div className="mt-1 flex justify-between text-[11px] text-gray-400 dark:text-gray-500">
<span>0</span>
<span>10</span>
<span>20</span>
</div>
</section>
))}
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<label className="grid gap-1.5 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">补充评价</span>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={4}
placeholder="可以补充具体建议或说明"
className="min-h-28 rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white sm:text-sm"
/>
</label>
</section>
{message && <div className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-200">{message}</div>}
<div className="sticky bottom-0 -mx-3 border-t border-gray-200 bg-gray-50/95 p-3 backdrop-blur dark:border-gray-700 dark:bg-gray-900/95 sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:p-0 sm:backdrop-blur-none">
<button
disabled={submitting || !activeTargetId}
onClick={submit}
className="min-h-11 w-full rounded-lg bg-gray-900 px-4 py-2.5 text-base font-medium text-white hover:bg-gray-700 disabled:opacity-50 dark:bg-white dark:text-gray-900 sm:w-auto sm:text-sm"
>
{submitting ? '提交中...' : '提交评价'}
</button>
{submitted ? (
<div className="rounded-[12px] border border-[#dce6f2] bg-[#f8fbff] px-4 py-10 text-center">
<div className="text-[18px] font-semibold text-[#111827]">你已提交该评价</div>
<Link href="/performance-survey" className="mt-4 inline-flex rounded-[10px] bg-[#1677ff] px-4 py-2 text-sm font-semibold text-white">
返回考核调研
</Link>
</div>
</div>
)}
) : detail.data.status !== 'open' ? (
<div className="rounded-[12px] border border-[#dce6f2] bg-[#f8fbff] px-4 py-10 text-center text-[16px] font-semibold text-[#4b5565]">
该调研未开放或已关闭
</div>
) : (
<div className="space-y-3">
{questions.map((question, index) => {
const value = scores[question.id] ?? 16
const scorePercent = Math.max(0, Math.min(100, (value / 20) * 100))
const accentColor = getQuestionAccentColor(index)
return (
<section key={question.id} className="rounded-[10px] border border-[#dfe6f0] bg-white px-3.5 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
<div className="grid grid-cols-[32px_minmax(0,1fr)_auto] items-start gap-3">
<span
className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-[14px] font-semibold leading-none text-white"
style={{ backgroundColor: accentColor }}
>
{index + 1}
</span>
<div className="min-w-0">
<h2 className="text-[15px] font-semibold leading-6 text-[#111827]">
{question.title}
<span className="ml-1 text-[11px] font-medium text-[#98a2b3]">满分20分</span>
</h2>
{question.description && (
<p className="mt-1 text-[13px] font-medium leading-5 text-[#7b8494]">{question.description}</p>
)}
</div>
<div className="pt-1 text-right">
<span className="text-[18px] font-semibold leading-none" style={{ color: accentColor }}>{value}</span>
<span className="ml-1 text-[11px] font-medium text-[#98a2b3]">/ 20分</span>
</div>
</div>
<input
type="range"
min={0}
max={20}
value={value}
onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))}
className="survey-score-range mt-4 w-full"
style={{ '--score-percent': `${scorePercent}%`, '--score-color': accentColor } as CSSProperties}
/>
<div className="mt-2 grid grid-cols-5 text-[11px] font-medium text-[#9aa3b2]">
<span>0</span>
<span className="text-center">5</span>
<span className="text-center">10</span>
<span className="text-center">15</span>
<span className="text-right">20</span>
</div>
</section>
)
})}
<section className="rounded-[10px] border border-[#dfe6f0] bg-white px-3.5 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
<label className="grid gap-3">
<span className="text-[15px] font-semibold text-[#111827]">补充评价</span>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value.slice(0, 300))}
rows={4}
placeholder="可以从优点、待改进或建议等方面补充说明..."
className="min-h-[92px] resize-none rounded-[9px] border border-[#d9e0ea] px-3 py-2.5 text-[14px] leading-6 text-[#111827] outline-none transition placeholder:text-[#c0c7d2] focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
/>
<span className="text-right text-[11px] font-medium text-[#9aa3b2]">{comment.length} / 300</span>
</label>
</section>
{message && <div className="rounded-[10px] bg-[#eef6ff] px-3 py-2 text-sm font-medium text-[#1677ff]">{message}</div>}
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-[#e5edf6] bg-white/95 px-5 pb-[calc(14px+env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:sticky sm:-mx-5 sm:mt-1">
<div className="mx-auto max-w-[390px]">
<button
disabled={submitting || !activeTargetId || ratedCount !== questions.length}
onClick={submit}
className="min-h-[50px] w-full rounded-[9px] bg-[#1677ff] px-4 py-2.5 text-[17px] font-semibold text-white shadow-[0_10px_22px_rgba(22,119,255,0.28)] transition active:scale-[0.99] disabled:bg-[#9ec9ff] disabled:shadow-none"
>
{submitting ? '提交中...' : '提交评价'}
</button>
</div>
</div>
</div>
)}
</div>
</main>
)
}
function SurveyUserAvatar({ src, name }: { src?: string; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name || '用户头像'}
width={44}
height={44}
unoptimized
className="h-11 w-11 rounded-full border border-[#eef2f7] object-cover shadow-[0_4px_12px_rgba(15,23,42,0.08)]"
/>
)
}
return (
<div className="flex h-11 w-11 items-center justify-center rounded-full border border-[#eef2f7] bg-[#f1f6ff] text-[16px] font-semibold text-[#1677ff] shadow-[0_4px_12px_rgba(15,23,42,0.08)]">
{getSurveyInitials(name)}
</div>
)
}
function getSurveyInitials(name: string) {
const text = String(name || '').trim()
if (!text) return '用'
const chars = Array.from(text)
if (/^[A-Za-z\s]+$/.test(text)) {
return text
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((item) => item[0]?.toUpperCase())
.join('')
}
return chars.slice(0, 2).join('')
}
function getQuestionAccentColor(index: number) {
const colors = ['#1677ff', '#16a34a', '#1677ff', '#7c3aed', '#f59e0b']
return colors[index % colors.length]
}
function getSurveyStatusLabel(status: string) {
const map: Record<string, string> = {
draft: '草稿',
open: '进行中',
closed: '已关闭',
}
return map[status] || status
}
function SurveyResults({ surveyId }: { surveyId: string }) {
const searchParams = useSearchParams()
const query = searchParams.get('targetId') ? `?targetId=${encodeURIComponent(searchParams.get('targetId') || '')}` : ''
@ -1534,23 +1623,131 @@ function EmptyState({ title }: { title: string }) {
)
}
function LoadingPage() {
function MobileSurveyRangeStyles() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-6 text-gray-500 dark:bg-gray-900 dark:text-gray-300">
<div className="w-full max-w-xs text-center">
<div className="mx-auto mb-5 h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-gray-900 dark:border-gray-700 dark:border-t-white" />
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">正在打开问卷</h1>
<p className="mt-2 text-sm">请稍候</p>
<div className="mt-6 space-y-2 rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
<div className="h-3 w-2/3 rounded bg-gray-100 dark:bg-gray-700" />
<div className="h-3 w-full rounded bg-gray-100 dark:bg-gray-700" />
<div className="h-3 w-5/6 rounded bg-gray-100 dark:bg-gray-700" />
</div>
</div>
</div>
<style>{`
.survey-score-range {
height: 24px;
appearance: none;
-webkit-appearance: none;
background: transparent;
}
.survey-score-range:focus {
outline: none;
}
.survey-score-range::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--score-color, #1677ff) 0%,
var(--score-color, #1677ff) var(--score-percent),
#e5eaf2 var(--score-percent),
#e5eaf2 100%
);
}
.survey-score-range::-webkit-slider-thumb {
width: 18px;
height: 18px;
margin-top: -7px;
appearance: none;
-webkit-appearance: none;
border: 3px solid var(--score-color, #1677ff);
border-radius: 999px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22);
}
.survey-score-range::-moz-range-track {
height: 4px;
border-radius: 999px;
background: #e5eaf2;
}
.survey-score-range::-moz-range-progress {
height: 4px;
border-radius: 999px;
background: var(--score-color, #1677ff);
}
.survey-score-range::-moz-range-thumb {
width: 18px;
height: 18px;
border: 3px solid var(--score-color, #1677ff);
border-radius: 999px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22);
}
`}</style>
)
}
function SurveyOpeningScreen() {
return (
<main className="fixed inset-0 z-[90] min-h-[100dvh] overflow-hidden bg-[#f7fbff] text-[#111827]">
<div className="flex h-14 items-center justify-between bg-gradient-to-r from-[#1677ff] to-[#4f9cff] px-5 text-white shadow-[0_10px_24px_rgba(22,119,255,0.18)]">
<span className="text-2xl leading-none">‹</span>
<span className="text-[15px] font-semibold">素材库</span>
<span className="text-xl leading-none">•••</span>
</div>
<section className="mx-auto max-w-[390px] px-7 pt-12">
<div className="text-center">
<Image
src="/api/favicon"
alt="素材库"
width={42}
height={42}
unoptimized
className="mx-auto h-[42px] w-[42px] rounded-[10px] object-cover"
/>
<h1 className="mt-3 text-[23px] font-semibold leading-none">素材库</h1>
<p className="mt-2 text-[14px] font-medium text-[#8a94a6]">内部素材协作平台</p>
</div>
<div className="mt-10 flex min-h-[58px] items-center justify-between rounded-full bg-white px-5 shadow-[0_12px_30px_rgba(15,23,42,0.12)]">
<div className="flex items-center gap-3">
<span className="h-7 w-7 animate-spin rounded-full border-[4px] border-[#d7e8ff] border-t-[#1677ff]" />
<span className="text-[16px] font-semibold">正在打开问卷</span>
</div>
<span className="text-[15px] font-semibold text-[#1677ff]">马上进入</span>
</div>
<div className="mt-7 flex items-center justify-center gap-2 text-[13px] font-medium text-[#8a94a6]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#eaf3ff] text-[12px] font-semibold text-[#1677ff]">✓</span>
<span>安全访问中,请放心使用</span>
</div>
<div className="mt-11 rounded-[18px] bg-white/90 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.08)]">
<div className="text-[15px] font-semibold text-[#7b8494]">绩效考核问卷</div>
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
<div className="mt-8 space-y-5">
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
</div>
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
</div>
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 w-2/3 rounded-full bg-[#f1f5f9]" />
</div>
</div>
</div>
</section>
</main>
)
}
function LoadingPage() {
return <SurveyOpeningScreen />
}
function formatDate(value: string | Date) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'