feat(ui): refine profile workflows and visual system

This commit is contained in:
2026-07-21 20:50:52 +08:00
parent 12a000845a
commit 04e80b184c
16 changed files with 1105 additions and 334 deletions

View File

@@ -1,3 +1,3 @@
{
"navigationBarTitleText": "学生档案预设项"
"navigationBarTitleText": "设置预设项"
}

View File

@@ -3,6 +3,10 @@ import type { StudentProfileDefaults } from '../../utils/types'
const grades = ['初一', '初二', '初三']
const subjects = ['美术', '书法', '音乐', '舞蹈', '语文', '数学', '英语']
const durationPresets = [45, 60, 90, 120]
const durationStep = 15
const minimumDuration = 15
const maximumDuration = 360
const initialDefaults: StudentProfileDefaults = {
grade: '初一',
subject: '数学',
@@ -10,11 +14,10 @@ const initialDefaults: StudentProfileDefaults = {
}
type PickerEvent = { detail: { value: string | number } }
type InputEvent = { detail: { value: string } }
function normalizeDuration(value: number): number {
if (Number.isNaN(value)) return 60
return Math.min(360, Math.max(15, Math.round(value / 15) * 15))
return Math.min(maximumDuration, Math.max(minimumDuration, Math.round(value / durationStep) * durationStep))
}
Page({
@@ -22,6 +25,9 @@ Page({
defaults: initialDefaults,
grades,
subjects,
durationPresets,
minimumDuration,
maximumDuration,
loading: false,
saving: false,
errorMessage: ''
@@ -34,7 +40,13 @@ Page({
async loadDefaults(showSuccess = false) {
this.setData({ loading: true, errorMessage: '' })
try {
this.setData({ defaults: await getProfileDefaults() })
const defaults = await getProfileDefaults()
this.setData({
defaults: {
...defaults,
lessonDurationMinutes: normalizeDuration(defaults.lessonDurationMinutes)
}
})
if (showSuccess) wx.showToast({ title: '预设已刷新', icon: 'success' })
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error) })
@@ -55,12 +67,25 @@ Page({
this.setData({ 'defaults.subject': subjects[Number(event.detail.value)] })
},
onDurationInput(event: InputEvent) {
this.setData({ 'defaults.lessonDurationMinutes': normalizeDuration(Number(event.detail.value)) })
decreaseDuration() {
this.setData({
'defaults.lessonDurationMinutes': normalizeDuration(
this.data.defaults.lessonDurationMinutes - durationStep
)
})
},
onDurationChange(event: PickerEvent) {
this.setData({ 'defaults.lessonDurationMinutes': normalizeDuration(Number(event.detail.value)) })
increaseDuration() {
this.setData({
'defaults.lessonDurationMinutes': normalizeDuration(
this.data.defaults.lessonDurationMinutes + durationStep
)
})
},
selectDurationPreset(event: WechatMiniprogram.TouchEvent) {
const { value } = event.currentTarget.dataset as { value: number | string }
this.setData({ 'defaults.lessonDurationMinutes': normalizeDuration(Number(value)) })
},
async save() {

View File

@@ -3,12 +3,13 @@
<view>
<text class="eyebrow">档案预设</text>
<view class="page-heading">
<text class="heading-icon"></text>
<text class="page-title">学生档案预设项</text>
<text class="page-title">新增学生默认值</text>
</view>
<text class="page-subtitle">用于新增学生时自动带出</text>
<text class="page-subtitle">新增学生时自动带出以下信息</text>
</view>
<button class="reload-button secondary-button" loading="{{loading}}" disabled="{{loading}}" bindtap="onReload">↻ 刷新</button>
<button class="reload-button {{loading ? 'reload-button-loading' : ''}}" disabled="{{loading || saving}}" bindtap="onReload" aria-label="刷新预设项">
<text class="reload-icon">↻</text>
</button>
</view>
<view wx:if="{{errorMessage}}" class="status-banner">
@@ -18,41 +19,70 @@
<view class="defaults-card surface">
<view class="defaults-grid">
<picker class="select-field" mode="selector" range="{{grades}}" bindchange="onGradeChange">
<text class="field-label">年级</text>
<text class="field-value">{{defaults.grade}}</text>
<picker class="select-field" mode="selector" range="{{grades}}" disabled="{{loading || saving}}" bindchange="onGradeChange" aria-label="默认年级,当前为 {{defaults.grade}}">
<view class="select-field-content">
<view class="select-copy">
<text class="field-label">年级</text>
<text class="field-value">{{defaults.grade}}</text>
</view>
<text class="select-chevron" aria-hidden="true"></text>
</view>
</picker>
<picker class="select-field" mode="selector" range="{{subjects}}" bindchange="onSubjectChange">
<text class="field-label">学科</text>
<text class="field-value">{{defaults.subject}}</text>
<picker class="select-field" mode="selector" range="{{subjects}}" disabled="{{loading || saving}}" bindchange="onSubjectChange" aria-label="默认学科,当前为 {{defaults.subject}}">
<view class="select-field-content">
<view class="select-copy">
<text class="field-label">学科</text>
<text class="field-value">{{defaults.subject}}</text>
</view>
<text class="select-chevron" aria-hidden="true"></text>
</view>
</picker>
</view>
<view class="duration-field">
<text class="field-label">上课时长</text>
<view class="duration-input-row">
<input class="duration-input" type="number" value="{{defaults.lessonDurationMinutes}}" bindblur="onDurationInput" />
<text class="duration-unit">分钟</text>
<view class="duration-heading">
<text class="field-label">上课时长</text>
<text class="duration-step-label">15 分钟间隔</text>
</view>
<slider
class="duration-slider"
min="15"
max="360"
step="15"
value="{{defaults.lessonDurationMinutes}}"
activeColor="#2B67E8"
backgroundColor="#DBE7F5"
blockColor="#2B67E8"
blockSize="26"
bindchange="onDurationChange"
/>
<view class="duration-bounds">
<text>15 分钟</text>
<text>360 分钟</text>
<view class="duration-stepper">
<button
class="duration-step-button"
disabled="{{loading || saving || defaults.lessonDurationMinutes <= minimumDuration}}"
bindtap="decreaseDuration"
aria-label="减少 15 分钟"
></button>
<view class="duration-value" aria-label="当前上课时长 {{defaults.lessonDurationMinutes}} 分钟">
<text class="duration-number">{{defaults.lessonDurationMinutes}}</text>
<text class="duration-unit">分钟</text>
</view>
<button
class="duration-step-button"
disabled="{{loading || saving || defaults.lessonDurationMinutes >= maximumDuration}}"
bindtap="increaseDuration"
aria-label="增加 15 分钟"
></button>
</view>
<view class="duration-presets" aria-label="常用上课时长">
<button
wx:for="{{durationPresets}}"
wx:key="*this"
class="duration-preset {{defaults.lessonDurationMinutes === item ? 'duration-preset-active' : ''}}"
disabled="{{loading || saving}}"
data-value="{{item}}"
bindtap="selectDurationPreset"
aria-label="设置为 {{item}} 分钟"
>{{item}}</button>
</view>
</view>
<view class="time-help">新增学生时会按开始时间自动推算结束时间。</view>
<button class="save-defaults primary-button" loading="{{saving}}" disabled="{{saving || loading}}" bindtap="save">保存</button>
<view class="time-help">
<icon class="time-help-icon" type="info" size="16" color="#6E6E73" />
<text>新增学生时会根据开始时间自动推算结束时间</text>
</view>
<view class="save-area">
<button class="save-defaults primary-button" loading="{{saving}}" disabled="{{saving || loading}}" bindtap="save">保存</button>
</view>
</view>
</view>

View File

@@ -1,5 +1,5 @@
.defaults-page {
padding-top: 38rpx;
padding-top: 44rpx;
}
.top-row {
@@ -27,93 +27,287 @@
}
.reload-button {
display: flex;
flex: none;
width: 148rpx;
min-width: 148rpx;
max-width: 148rpx;
min-height: 70rpx;
align-items: center;
justify-content: center;
width: 72rpx;
min-width: 72rpx;
max-width: 72rpx;
height: 72rpx;
margin: 0;
padding: 0;
font-size: 26rpx;
border: 0;
border-radius: 50%;
background: #f1f3f6;
color: #007aff;
line-height: 1;
transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.reload-button:active {
transform: scale(0.94);
background: #e5e5ea;
}
.reload-button[disabled] {
opacity: 1;
}
.reload-icon {
display: block;
font-size: 36rpx;
line-height: 1;
}
.reload-button-loading .reload-icon {
animation: reload-spin 800ms linear infinite;
}
@keyframes reload-spin {
to {
transform: rotate(360deg);
}
}
.defaults-card {
margin-top: 34rpx;
padding: 24rpx;
margin-top: 28rpx;
padding: 0;
}
.defaults-grid {
display: flex;
gap: 16rpx;
gap: 0;
}
.defaults-grid .select-field {
width: calc((100% - 16rpx) / 2);
width: 50%;
}
.select-field,
.duration-field {
padding: 24rpx;
border: 2rpx solid #d8e5f2;
border-radius: 14rpx;
background: #fbfdff;
background: transparent;
}
.select-field {
min-height: 158rpx;
min-height: 112rpx;
transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.select-field-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
min-height: 112rpx;
padding: 18rpx 20rpx;
}
.select-field + .select-field {
border-left: 1rpx solid #e5e5ea;
}
.select-field:active {
opacity: 0.68;
}
.select-copy {
min-width: 0;
}
.select-field .field-label,
.select-field .field-value {
display: block;
}
.select-field .field-label {
color: #6e6e73;
font-size: 22rpx;
line-height: 1.25;
}
.select-field .field-value {
margin-top: 7rpx;
color: #1c1c1e;
font-size: 29rpx;
font-weight: 700;
line-height: 1.25;
}
.select-chevron {
flex: none;
color: #8e8e93;
font-size: 36rpx;
line-height: 1;
}
.duration-field {
margin-top: 16rpx;
padding: 24rpx;
border-top: 1rpx solid #e5e5ea;
}
.duration-input-row {
.duration-heading {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 20rpx;
justify-content: space-between;
gap: 16rpx;
}
.duration-input {
width: 150rpx;
height: 64rpx;
padding: 0 16rpx;
border: 2rpx solid #bed4e8;
border-radius: 12rpx;
background: #ffffff;
color: #17233a;
.duration-step-label {
color: #8e8e93;
font-size: 22rpx;
}
.duration-stepper {
display: grid;
grid-template-columns: 72rpx minmax(0, 1fr) 72rpx;
width: 330rpx;
height: 76rpx;
margin: 18rpx auto 0;
overflow: hidden;
border: 1rpx solid #e5e5ea;
border-radius: 14rpx;
background: #f1f3f6;
}
.duration-step-button {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
min-width: 72rpx;
max-width: 72rpx;
height: 76rpx;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: #007aff;
font-size: 38rpx;
font-weight: 500;
line-height: 1;
transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.duration-step-button:active {
transform: scale(0.9);
}
.duration-step-button[disabled] {
color: #c7c7cc;
opacity: 1;
}
.duration-value {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
min-width: 0;
border-right: 1rpx solid #d1d1d6;
border-left: 1rpx solid #d1d1d6;
}
.duration-number {
color: #1c1c1e;
font-size: 34rpx;
font-weight: 800;
text-align: center;
line-height: 1;
}
.duration-unit {
color: #65758a;
font-size: 28rpx;
color: #6e6e73;
font-size: 22rpx;
}
.duration-slider {
margin: 12rpx -12rpx 0;
}
.duration-bounds {
.duration-presets {
display: flex;
justify-content: space-between;
color: #8a97a6;
font-size: 23rpx;
align-items: stretch;
gap: 4rpx;
margin-top: 18rpx;
padding: 4rpx;
border-radius: 12rpx;
background: #f1f3f6;
}
.duration-preset {
display: flex;
flex: 1 1 0;
align-items: center;
justify-content: center;
width: 0;
min-width: 0;
max-width: none;
min-height: 58rpx;
margin: 0;
padding: 0;
border: 0;
border-radius: 9rpx;
background: transparent;
color: #6e6e73;
font-size: 24rpx;
font-weight: 600;
line-height: 1;
transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.duration-preset-active {
background: #ffffff;
box-shadow: 0 2rpx 8rpx rgba(60, 60, 67, 0.12);
color: #007aff;
}
.duration-preset:active {
transform: scale(0.96);
}
.time-help {
margin-top: 18rpx;
padding: 20rpx;
border: 2rpx dashed #c6ddea;
border-radius: 12rpx;
color: #65758a;
font-size: 25rpx;
display: flex;
align-items: flex-start;
gap: 10rpx;
margin: 0 24rpx;
padding: 2rpx 0 0;
color: #6e6e73;
font-size: 24rpx;
line-height: 1.5;
}
.save-defaults {
width: 420rpx;
margin: 26rpx auto 4rpx;
.time-help-icon {
flex: none;
margin-top: 3rpx;
}
.save-area {
padding: 24rpx;
}
.save-defaults {
width: 100%;
min-width: 0;
max-width: none;
margin: 0;
}
@media (prefers-reduced-motion: reduce) {
.reload-button,
.select-field,
.duration-step-button,
.duration-preset {
transition: none;
}
.reload-button:active,
.duration-step-button:active,
.duration-preset:active {
transform: none;
}
.select-field:active {
opacity: 1;
}
.reload-button-loading .reload-icon {
animation: none;
}
}