diff --git a/apps/miniprogram/src/components/StepIndicator/index.scss b/apps/miniprogram/src/components/StepIndicator/index.scss
new file mode 100644
index 0000000..acd9c70
--- /dev/null
+++ b/apps/miniprogram/src/components/StepIndicator/index.scss
@@ -0,0 +1,71 @@
+@import '../../styles/variables.scss';
+
+.step-indicator {
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 24px 32px;
+ background: $card;
+}
+
+.step-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 1;
+ position: relative;
+}
+
+.step-line {
+ position: absolute;
+ top: 24px;
+ left: -50%;
+ right: 50%;
+ height: 4px;
+ background: $bd-l;
+ transition: background 0.3s ease;
+
+ &.step-line-done {
+ background: $acc;
+ }
+}
+
+.step-dot {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: $bd-l;
+ color: $tx3;
+ font-size: 24px;
+ transition: all 0.3s ease;
+ z-index: 1;
+
+ &.step-current {
+ background: $pri;
+ color: white;
+ }
+
+ &.step-done {
+ background: $acc;
+ color: white;
+ }
+}
+
+.step-label {
+ font-size: 22px;
+ color: $tx3;
+ margin-top: 8px;
+ text-align: center;
+
+ &.step-current {
+ color: $pri;
+ font-weight: bold;
+ }
+
+ &.step-done {
+ color: $acc;
+ }
+}
diff --git a/apps/miniprogram/src/components/StepIndicator/index.tsx b/apps/miniprogram/src/components/StepIndicator/index.tsx
new file mode 100644
index 0000000..a4bd659
--- /dev/null
+++ b/apps/miniprogram/src/components/StepIndicator/index.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { View, Text } from '@tarojs/components';
+import './index.scss';
+
+interface Step {
+ label: string;
+}
+
+interface StepIndicatorProps {
+ steps: Step[];
+ current: number;
+ onChange?: (index: number) => void;
+}
+
+export default function StepIndicator({ steps, current, onChange }: StepIndicatorProps) {
+ return (
+
+ {steps.map((step, idx) => {
+ const isCurrent = idx === current;
+ const isDone = idx < current;
+ const isClickable = isDone && !!onChange;
+
+ return (
+
+ {idx > 0 && (
+
+ )}
+ onChange(idx) : undefined}
+ >
+ {isDone ? ✓ : {idx + 1}}
+
+
+ {step.label}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/miniprogram/src/components/WeekCalendar/index.scss b/apps/miniprogram/src/components/WeekCalendar/index.scss
new file mode 100644
index 0000000..5eeeff0
--- /dev/null
+++ b/apps/miniprogram/src/components/WeekCalendar/index.scss
@@ -0,0 +1,84 @@
+@import '../../styles/variables.scss';
+
+.week-calendar {
+ background: $card;
+ border-radius: $r;
+ padding: 16px;
+}
+
+.week-nav {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.week-arrow {
+ font-size: 28px;
+ color: $pri;
+ padding: 0 16px;
+}
+
+.week-label {
+ font-size: 24px;
+ color: $tx;
+ font-weight: bold;
+}
+
+.week-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 8px;
+ text-align: center;
+}
+
+.week-cell {
+ padding: 8px 4px;
+ border-radius: $r-sm;
+ position: relative;
+}
+
+.cell-weekday {
+ font-size: 20px;
+ color: $tx3;
+ display: block;
+}
+
+.cell-date {
+ font-size: 26px;
+ color: $tx;
+ display: block;
+ margin-top: 4px;
+}
+
+.cell-today {
+ color: $pri;
+ font-weight: bold;
+}
+
+.cell-dot {
+ position: absolute;
+ bottom: 2px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 8px;
+ height: 8px;
+ background: $acc;
+ border-radius: 50%;
+}
+
+.cell-selected {
+ background: $pri;
+ border-radius: $r-sm;
+
+ .cell-date { color: white; }
+ .cell-dot { background: white; }
+}
+
+.cell-empty .cell-date {
+ color: $bd;
+}
+
+.cell-past {
+ opacity: 0.4;
+}
diff --git a/apps/miniprogram/src/components/WeekCalendar/index.tsx b/apps/miniprogram/src/components/WeekCalendar/index.tsx
new file mode 100644
index 0000000..f777419
--- /dev/null
+++ b/apps/miniprogram/src/components/WeekCalendar/index.tsx
@@ -0,0 +1,63 @@
+import React, { useState } from 'react';
+import { View, Text } from '@tarojs/components';
+import './index.scss';
+
+interface WeekCalendarProps {
+ scheduledDates: Set;
+ selectedDate: string;
+ onSelectDate: (date: string) => void;
+}
+
+function getWeekDates(offset: number): string[] {
+ const now = new Date();
+ const monday = new Date(now);
+ monday.setDate(now.getDate() - now.getDay() + 1 + offset * 7);
+ const dates: string[] = [];
+ for (let i = 0; i < 7; i++) {
+ const d = new Date(monday);
+ d.setDate(monday.getDate() + i);
+ dates.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
+ }
+ return dates;
+}
+
+const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
+
+export default function WeekCalendar({ scheduledDates, selectedDate, onSelectDate }: WeekCalendarProps) {
+ const [weekOffset, setWeekOffset] = useState(0);
+ const dates = getWeekDates(weekOffset);
+ const today = (() => {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ })();
+
+ return (
+
+
+ setWeekOffset(weekOffset - 1)}>◀
+ {dates[0].slice(5)} ~ {dates[6].slice(5)}
+ setWeekOffset(weekOffset + 1)}>▶
+
+
+ {WEEKDAYS.map((day, idx) => {
+ const dateStr = dates[idx];
+ const isScheduled = scheduledDates.has(dateStr);
+ const isSelected = dateStr === selectedDate;
+ const isToday = dateStr === today;
+ const isPast = dateStr < today;
+ return (
+ onSelectDate(dateStr) : undefined}
+ >
+ {day}
+ {parseInt(dateStr.slice(8))}
+ {isScheduled && }
+
+ );
+ })}
+
+
+ );
+}