feat: 发布 v0.3.0 移动端答题优化
This commit is contained in:
@@ -6,24 +6,24 @@
|
|||||||
|
|
||||||
## 当前版本
|
## 当前版本
|
||||||
|
|
||||||
**v0.2.0**,发布于 2026-06-23。
|
**v0.3.0**,发布于 2026-06-23。
|
||||||
|
|
||||||
本次版本从单次浏览器测评升级为可长期使用的本地测评档案:
|
本次版本专门优化 iPhone、Android 手机和 iPad 的答题体验,桌面端功能保持不变:
|
||||||
|
|
||||||
- 新增多人档案,可分别保存自己、家人或朋友的测评
|
- 中文题目在手机端使用 17px 字号和更宽松行高
|
||||||
- 使用 IndexedDB 保存全部历史,同一量表重复测评不会覆盖旧记录
|
- 选项整行可点击,触控高度不低于 48px
|
||||||
- 新增 PHQ-9、GAD-7、WHO-5、DASS-21、PSS-10、BDI-II、SDS 和 ISI 趋势追踪
|
- 手机和平板使用固定底部翻页栏,并适配 iOS 底部安全区
|
||||||
- 新增 Big Five、HEXACO、RIASEC 和 Schwartz 统一画像
|
- 题号导航在窄屏下默认折叠,减少对正文的遮挡
|
||||||
- 新增单次 MD、个人完整 MD、全量 JSON 导出与恢复
|
- 档案选择框使用 16px 字号,避免 iPhone Safari 自动放大
|
||||||
- 新增基于 PBKDF2 和 AES-GCM 的密码加密备份
|
- 页面使用动态视口高度并适配刘海屏左右安全区
|
||||||
- 新增重测周期提示和一键清除本地数据
|
- 保留双指缩放和系统字体回退,改善中文阅读与无障碍体验
|
||||||
- 新增 Vitest 自动测试,覆盖计分临界值、反向计分和加密备份
|
- 已验证 320px、390px、768px 和 1280px 视口无横向溢出
|
||||||
- 结果答案继续保留在浏览器本地,不写入 URL,也不发送给 AI 或第三方分析服务
|
|
||||||
|
|
||||||
### 版本记录
|
### 版本记录
|
||||||
|
|
||||||
| 版本 | 日期 | 主要变化 |
|
| 版本 | 日期 | 主要变化 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| v0.3.0 | 2026-06-23 | iOS、手机和平板答题阅读、触控、折叠导航和安全区优化 |
|
||||||
| v0.2.0 | 2026-06-23 | 多人档案、完整历史、趋势、统一画像、MD/JSON 导出、加密备份及自动测试 |
|
| v0.2.0 | 2026-06-23 | 多人档案、完整历史、趋势、统一画像、MD/JSON 导出、加密备份及自动测试 |
|
||||||
| v0.1.0 | 2026-06-22 | 中文量表列表、浏览器答题、本地计分、草稿保存和单次结果导出 |
|
| v0.1.0 | 2026-06-22 | 中文量表列表、浏览器答题、本地计分、草稿保存和单次结果导出 |
|
||||||
|
|
||||||
|
|||||||
@@ -113,10 +113,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
}
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
min-height: 100dvh;
|
||||||
|
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont,
|
||||||
|
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.safe-x {
|
||||||
|
padding-left: max(1rem, env(safe-area-inset-left));
|
||||||
|
padding-right: max(1rem, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import './globals.css';
|
|||||||
import { Navbar } from '@/components/Navbar';
|
import { Navbar } from '@/components/Navbar';
|
||||||
import { I18nProviderClient } from '@/locales/client';
|
import { I18nProviderClient } from '@/locales/client';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import type { Viewport } from 'next';
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
};
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: '--font-geist-sans',
|
variable: '--font-geist-sans',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function Navbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b">
|
<header className="border-b">
|
||||||
<div className="container flex items-center justify-between h-14 px-4 max-w-6xl mx-auto">
|
<div className="safe-x container mx-auto flex h-14 max-w-6xl items-center justify-between">
|
||||||
<Link href="/" className="text-lg font-medium flex items-center gap-2">
|
<Link href="/" className="text-lg font-medium flex items-center gap-2">
|
||||||
<span className="w-8 h-8 border rounded flex items-center justify-center">
|
<span className="w-8 h-8 border rounded flex items-center justify-center">
|
||||||
<ClipboardList className="h-4 w-4" />
|
<ClipboardList className="h-4 w-4" />
|
||||||
|
|||||||
@@ -228,8 +228,8 @@ export function Questionnaire({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto py-8 px-4">
|
<div className="safe-x mx-auto max-w-3xl py-5 pb-32 md:py-8 md:pb-8">
|
||||||
<h1 className="text-2xl font-bold mb-8">{questionnaire.title}</h1>
|
<h1 className="mb-5 text-xl font-bold leading-8 md:mb-8 md:text-2xl">{questionnaire.title}</h1>
|
||||||
|
|
||||||
<ProfilePicker onChange={setActiveProfile} />
|
<ProfilePicker onChange={setActiveProfile} />
|
||||||
|
|
||||||
|
|||||||
@@ -19,23 +19,25 @@ export function Navigation({
|
|||||||
}: NavigationProps) {
|
}: NavigationProps) {
|
||||||
const t = useScopedI18n('component.questionnaire.test.public.navigation');
|
const t = useScopedI18n('component.questionnaire.test.public.navigation');
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center mt-8">
|
<div className="fixed inset-x-0 bottom-0 z-30 flex items-center justify-between gap-3 border-t bg-background/95 px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] shadow-[0_-4px_16px_rgba(0,0,0,0.06)] backdrop-blur lg:static lg:mt-8 lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none lg:backdrop-blur-none">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="h-11 min-w-24 text-base lg:h-9 lg:min-w-0 lg:text-sm"
|
||||||
onClick={() => goToPage(currentPage - 1)}
|
onClick={() => goToPage(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
>
|
>
|
||||||
{t('previousPage')}
|
{t('previousPage')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="text-sm text-gray-500">
|
<span className="shrink-0 text-xs text-gray-500 sm:text-sm">
|
||||||
{t('pageInfo', { currentPage, totalPages })}
|
{t('pageInfo', { currentPage, totalPages })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{isLastPage ? (
|
{isLastPage ? (
|
||||||
<Button onClick={onSubmit}>{t('submit')}</Button>
|
<Button className="h-11 min-w-24 text-base lg:h-9 lg:min-w-0 lg:text-sm" onClick={onSubmit}>{t('submit')}</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
className="h-11 min-w-24 text-base lg:h-9 lg:min-w-0 lg:text-sm"
|
||||||
onClick={() => goToPage(currentPage + 1)}
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface ProgressBarProps {
|
|||||||
|
|
||||||
export function ProgressBar({ completionPercentage }: ProgressBarProps) {
|
export function ProgressBar({ completionPercentage }: ProgressBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden mb-2">
|
<div className="mb-4 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 md:mb-2 md:h-2">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-green-500 transition-all duration-300"
|
className="h-full bg-green-500 transition-all duration-300"
|
||||||
style={{ width: `${completionPercentage}%` }}
|
style={{ width: `${completionPercentage}%` }}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ListChecks,
|
||||||
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useScopedI18n } from '@/locales/client';
|
import { useScopedI18n } from '@/locales/client';
|
||||||
import { Option } from '@/types';
|
import { Option } from '@/types';
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
id: number;
|
id: number;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -26,68 +36,89 @@ export function ProgressPanel({
|
|||||||
completionPercentage,
|
completionPercentage,
|
||||||
}: ProgressPanelProps) {
|
}: ProgressPanelProps) {
|
||||||
const t = useScopedI18n('component.questionnaire.test.public.progressPanel');
|
const t = useScopedI18n('component.questionnaire.test.public.progressPanel');
|
||||||
|
const [showMobileQuestions, setShowMobileQuestions] = useState(false);
|
||||||
|
|
||||||
|
const questionGrid = (
|
||||||
|
<div className="grid grid-cols-5 gap-2 overflow-y-auto sm:grid-cols-8 lg:mb-4 lg:max-h-[400px] lg:grid-cols-5 lg:gap-1">
|
||||||
|
{questions.map((_, index) => {
|
||||||
|
const questionNumber = index + 1;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={questionNumber}
|
||||||
|
className={`flex h-11 min-w-11 items-center justify-center rounded-md text-sm lg:h-9 lg:w-9 lg:min-w-0 lg:text-xs
|
||||||
|
${answers[questionNumber] ? 'border-2 border-green-600 bg-green-50' : 'border bg-white'}
|
||||||
|
${questionNumber === activePanelQuestion ? 'border-green-700 bg-green-200' : ''}
|
||||||
|
active:bg-gray-100 lg:hover:bg-gray-100`}
|
||||||
|
onClick={() => {
|
||||||
|
goToQuestion(questionNumber);
|
||||||
|
setShowMobileQuestions(false);
|
||||||
|
}}
|
||||||
|
title={t('jumpToQuestion', { questionNumber })}
|
||||||
|
>
|
||||||
|
{questionNumber}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="mb-4 lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-h-12 w-full items-center justify-between rounded-md border bg-muted/30 px-4 text-base"
|
||||||
|
aria-expanded={showMobileQuestions}
|
||||||
|
onClick={() => setShowMobileQuestions((current) => !current)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ListChecks className="h-5 w-5" />
|
||||||
|
题目进度 {Object.keys(answers).length} / {questions.length}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-5 w-5 transition-transform ${showMobileQuestions ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{showMobileQuestions && (
|
||||||
|
<div className="mt-3 border bg-muted/20 p-3">{questionGrid}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="fixed right-4 top-10 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-2 z-40 transition-all duration-300"
|
type="button"
|
||||||
|
className="fixed right-4 top-16 z-40 hidden h-11 w-11 items-center justify-center rounded-full bg-blue-600 text-white transition-colors hover:bg-blue-700 lg:flex"
|
||||||
onClick={toggleProgressPanel}
|
onClick={toggleProgressPanel}
|
||||||
|
aria-label={showProgressPanel ? t('hideNav') : t('showNav')}
|
||||||
title={showProgressPanel ? t('hideNav') : t('showNav')}
|
title={showProgressPanel ? t('hideNav') : t('showNav')}
|
||||||
>
|
>
|
||||||
<svg
|
{showProgressPanel ? (
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<PanelRightClose className="h-5 w-5" />
|
||||||
className={`h-6 w-6 transition-transform duration-300 ${showProgressPanel ? 'rotate-180' : ''
|
) : (
|
||||||
}`}
|
<PanelRightOpen className="h-5 w-5" />
|
||||||
fill="none"
|
)}
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`fixed right-4 top-20 w-64 bg-white rounded-lg shadow-lg p-4 transition-transform duration-300 transform ${showProgressPanel ? 'translate-x-0' : 'translate-x-full'
|
className={`fixed right-4 top-28 z-30 hidden w-64 rounded-lg border bg-white p-4 shadow-lg transition-transform duration-300 lg:block ${
|
||||||
}`}
|
showProgressPanel
|
||||||
|
? 'translate-x-0'
|
||||||
|
: 'translate-x-[calc(100%+2rem)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="text-sm font-medium mb-2">
|
<div className="mb-2 text-sm font-medium">
|
||||||
{t('completionProgress')}
|
{t('completionProgress')}
|
||||||
{Math.round(completionPercentage)}%
|
{Math.round(completionPercentage)}%
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="h-2 w-full rounded-full bg-gray-200">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 rounded-full h-2 transition-all duration-300"
|
className="h-2 rounded-full bg-blue-500 transition-all duration-300"
|
||||||
style={{ width: `${completionPercentage}%` }}
|
style={{ width: `${completionPercentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{questionGrid}
|
||||||
<div className="grid grid-cols-5 gap-1 mb-4 max-h-[400px] overflow-y-auto">
|
|
||||||
{questions.map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={`w-9 h-9 flex items-center justify-center rounded-md text-xs
|
|
||||||
${answers[i + 1]
|
|
||||||
? 'bg-green-100 border-green-500 border-2'
|
|
||||||
: 'border'
|
|
||||||
}
|
|
||||||
${i + 1 === activePanelQuestion
|
|
||||||
? 'bg-green-300 border-green-600'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
hover:bg-gray-100 transition-colors duration-200`}
|
|
||||||
onClick={() => goToQuestion(i + 1)}
|
|
||||||
title={t('jumpToQuestion', { questionNumber: i + 1 })}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,20 +12,22 @@ export const Question = forwardRef<HTMLDivElement, QuestionProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="mb-8 p-6 bg-white rounded-lg shadow-sm"
|
className="mb-4 scroll-mt-20 rounded-md border bg-white p-4 shadow-sm md:mb-8 md:p-6"
|
||||||
id={`question-${question.id}`}
|
id={`question-${question.id}`}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-medium mb-4">
|
<h3 className="mb-4 text-[17px] font-medium leading-[1.65] md:text-lg md:leading-7">
|
||||||
{question.id}. {question.content}
|
{question.id}. {question.content}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{question.options.map((option) => (
|
{question.options.map((option) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={answer === option.value}
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={`w-full text-left p-3 rounded-lg transition-colors duration-200
|
className={`min-h-12 w-full touch-manipulation rounded-md px-4 py-3 text-left text-base leading-6 transition-colors duration-200
|
||||||
${answer === option.value
|
${answer === option.value
|
||||||
? 'bg-blue-100 border-blue-500 border-2'
|
? 'border-2 border-blue-600 bg-blue-50 font-medium text-blue-950'
|
||||||
: 'border hover:bg-gray-50'
|
: 'border bg-white active:bg-gray-100 md:hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onSelect(question.id, option.value)}
|
onClick={() => onSelect(question.id, option.value)}
|
||||||
>
|
>
|
||||||
@@ -38,4 +40,4 @@ export const Question = forwardRef<HTMLDivElement, QuestionProps>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Question.displayName = 'Question';
|
Question.displayName = 'Question';
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function ProfilePicker({ onChange }: ProfilePickerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-col gap-2 border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-6 flex flex-col gap-3 border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<UserRound className="h-4 w-4" />
|
<UserRound className="h-4 w-4" />
|
||||||
本次记录到
|
本次记录到
|
||||||
@@ -49,7 +49,7 @@ export function ProfilePicker({ onChange }: ProfilePickerProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
aria-label="选择测评档案"
|
aria-label="选择测评档案"
|
||||||
className="h-9 min-w-32 border bg-background px-3 text-sm"
|
className="h-11 min-w-32 border bg-background px-3 text-base md:h-9 md:text-sm"
|
||||||
value={active}
|
value={active}
|
||||||
onChange={(event) => select(event.target.value)}
|
onChange={(event) => select(event.target.value)}
|
||||||
>
|
>
|
||||||
@@ -57,7 +57,7 @@ export function ProfilePicker({ onChange }: ProfilePickerProps) {
|
|||||||
<option key={profile.id} value={profile.id}>{profile.name}</option>
|
<option key={profile.id} value={profile.id}>{profile.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Link href="/records" className="text-sm text-primary underline-offset-4 hover:underline">
|
<Link href="/records" className="flex min-h-11 items-center text-sm text-primary underline-offset-4 hover:underline md:min-h-9">
|
||||||
管理档案
|
管理档案
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mindscope",
|
"name": "mindscope",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user