Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/actions/build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ runs:
if: ${{ inputs.target == 'linux' }}
run: flutter analyze --no-pub
shell: bash
- name: Test
if: ${{ inputs.target == 'linux' && runner.arch == 'X64' }}
run: flutter test --no-pub
shell: bash
- name: Decrypt Android signing
if: >-
${{ inputs.target == 'android' &&
Expand Down
72 changes: 72 additions & 0 deletions docs/eclass-exams.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 延河课堂测试导入

## 接口与认证

测试列表与学习活动是两个独立的数据源。课程列表仍复用现有的
`POST /api/my-courses`;每门课额外请求:

```http
GET /api/courses/{courseId}/exam-list?page=1&page_size=100
Accept: application/json
```

复用 `SchoolSession` 的 Cookie 存储、请求并发上限、取消和登录失效处理。
不得在源码、测试或文档中保存真实的 Cookie、令牌、姓名或原始抓包。

实测省略前端的 `conditions`、`reloadPage` 参数仍能获取列表;默认排序可能
与前端章节排序不同,导入不依赖返回顺序。

响应为:

```json
{
"page": 1,
"page_size": 100,
"pages": 1,
"total": 1,
"start": 1,
"end": 1,
"exams": [
{
"id": 123,
"type": "exam",
"title": "示例测试",
"start_time": "2026-11-01T00:00:00Z",
"end_time": "2026-11-08T14:30:00Z",
"submission_count": 0
}
]
}
```

从第一页开始,按 `pages` 结束;缺失时使用每页条数收敛,空页总是结束。
没有测试的课程返回 `exams: []`。活动和测试分别容错,不因其中一路普通
接口错误丢掉另一路的结果;取消和登录失效仍向上传递。

## 映射规则

- 测试 ID 使用 `eclass-exam-{id}`;保留既有活动 ID `eclass-{id}`,避免两种
资源编号相同导致覆盖,也避免迁移时重建已有作业。
- `end_time` 非空时作为截止时间,沿用北京时间墙上时钟转换。
- 无 `end_time`、有 `start_time` 时导入 `startOnly: true` 的日程,日期是
开放时间而不是伪造的截止时间;不应显示逾期。
- 开始和截止时间均缺失时跳过,不能用同步时间制造不稳定日期。
- `submission_count > 0` 表示已提交;不使用 `is_closed`、成绩或
`submit_times` 代替提交状态。补考字段的完成语义尚未验证,不作假设。
- 链接指向 `/course/{courseId}/exam`;未验证单个测试的安全详情跳转路径,
不自动进入答题或启动限时测试。
- 无截止日期日程使用与普通 DDL 相同的占位及渲染宽度规则,但以开始时间
为左边界,标线在左侧,背景向右侧淡出。
- 时间轴纵向滚动范围取当前筛选数据的全局最高泳道;横向平移不改变
纵向范围,稀疏时间段允许保留空白泳道。卡片仍只按二维视口裁剪创建。
筛选、缩放或窗口尺寸改变导致范围缩小时,裁剪使用新范围内的有效偏移。

## 验证范围

接口分析时验证了课程级列表、分页参数、UTC 时间与页面北京时间显示的
对应关系,以及提交计数与页面“未交”状态的对应关系。未假定匿名访问、
独立 Cookie 的必要性或补考行为;认证继续完全交由现有会话实现。

自动化回归覆盖接口分页、资源 ID 碰撞、完成状态、无截止时间、空列表、
独立容错,以及日程模型和时间轴布局。无本地 Flutter 环境时,由 GitHub
Actions 的 Linux x64 测试步骤运行;静态检查或推送成功不等于测试通过。
4 changes: 3 additions & 1 deletion lib/agenda/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -308,5 +308,7 @@ bool _sameDeadlineMetadata(AgendaEntry left, AgendaEntry right) {
return left.course == right.course &&
left.title == right.title &&
left.startsAt == right.startsAt &&
left.allDay == right.allDay;
left.allDay == right.allDay &&
// When IDs differ, an opening time is not equivalent to a due time.
left.startOnly == right.startOnly;
}
19 changes: 18 additions & 1 deletion lib/agenda/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ class AgendaEntry {
this.url,
this.details,
this.tailDays = defaultTailDays,
this.startOnly = false,
}) {
if (id.isEmpty || title.trim().isEmpty) {
throw const FormatException('日程标题或编号缺失');
}
if (startOnly && kind != AgendaKind.deadline) {
throw const FormatException('只有截止日期可以只标记开放时间');
}
if (kind != AgendaKind.deadline &&
(endsAt == null || !endsAt!.isAfter(startsAt))) {
throw const FormatException('结束时间必须晚于开始时间');
Expand Down Expand Up @@ -87,12 +91,20 @@ class AgendaEntry {
/// at minimum zoom and keeps its screen width as the view zooms in.
final int tailDays;

/// A deadline that only knows when it opened, with no due date at all —
/// an open-ended quiz, for instance. It can never be overdue, and the
/// timeline anchors its card at [startsAt] and grows it to the right instead
/// of hanging a tail to the left of a due time. Labels must not present it
/// as due.
final bool startOnly;

bool get isDeadline => kind == AgendaKind.deadline;

DateTime get dueAt =>
allDay ? calendarDate(startsAt).add(const Duration(days: 1)) : startsAt;

bool overdue(DateTime now) => isDeadline && !done && dueAt.isBefore(now);
bool overdue(DateTime now) =>
isDeadline && !startOnly && !done && dueAt.isBefore(now);

bool isFinished(DateTime now) => isDeadline ? done : !endsAt!.isAfter(now);

Expand Down Expand Up @@ -121,6 +133,7 @@ class AgendaEntry {
url: url,
details: details,
tailDays: tailDays,
startOnly: startOnly,
);

Map<String, dynamic> toJson() => {
Expand All @@ -141,6 +154,7 @@ class AgendaEntry {
'url': url?.toString(),
'details': details?.toJson(),
'tailDays': tailDays,
'startOnly': startOnly,
};

factory AgendaEntry.fromJson(Map<String, dynamic> json) {
Expand Down Expand Up @@ -181,6 +195,9 @@ class AgendaEntry {
? null
: Uri.tryParse(rawUrl) ?? (throw FormatException('url 格式不正确')),
tailDays: jsonIntFieldOrNull(json, 'tailDays') ?? defaultTailDays,
// Entries written before this flag existed simply had no open-ended
// deadlines, so a missing key means an ordinary deadline.
startOnly: jsonBoolField(json, 'startOnly', fallback: false),
details: rawDetails == null
? null
: AgendaEntryDetails.fromJson(jsonObject(rawDetails, 'details')),
Expand Down
18 changes: 14 additions & 4 deletions lib/agenda/timeline_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,25 @@ List<AgendaTimelinePlacement> layoutAgendaTimeline(
Iterable<AgendaEntry> entries, {
required Duration minimumEventSpan,
}) {
// An ordinary deadline hangs `tailDays` to the left of its due time. One
// that only records an opening time reserves the same span forwards from
// that opening time, so both reserve `tailDays` of lane space.
DateTime reservationStart(AgendaEntry entry) =>
entry.isDeadline && !entry.startOnly
? entry.dueAt.subtract(Duration(days: entry.tailDays))
: entry.startsAt;
DateTime reservationEnd(AgendaEntry entry) => entry.isDeadline
? entry.startOnly
? entry.startsAt.add(Duration(days: entry.tailDays))
: entry.dueAt
: entry.endsAt!;
final intervals =
[
for (final entry in entries.where((entry) => !entry.deleted))
(
entry: entry,
start: entry.isDeadline
? entry.dueAt.subtract(Duration(days: entry.tailDays))
: entry.startsAt,
end: entry.isDeadline ? entry.dueAt : entry.endsAt!,
start: reservationStart(entry),
end: reservationEnd(entry),
),
]..sort((a, b) {
final byStart = a.start.compareTo(b.start);
Expand Down
19 changes: 13 additions & 6 deletions lib/agenda/ui/agenda_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,18 @@ class _EntryTime extends StatelessWidget {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final color = entry.overdue(now) ? scheme.error : scheme.onSurface;
final time = entry.isDeadline
? entry.allDay
? l10n.agendaDueBeforeEndOfDay
: clockText(entry.startsAt)
: localizedAgendaTime(l10n, entry);
// An open-ended deadline has no due date: it reads like a timed entry and
// is labelled as opening rather than as due.
final String time;
if (!entry.isDeadline) {
time = localizedAgendaTime(l10n, entry);
} else if (entry.startOnly) {
time = entry.allDay ? l10n.allDay : clockText(entry.startsAt);
} else {
time = entry.allDay
? l10n.agendaDueBeforeEndOfDay
: clockText(entry.startsAt);
}
final lastDay = entry.allDay && !entry.isDeadline
? entry.endsAt!.subtract(const Duration(days: 1))
: entry.startsAt;
Expand Down Expand Up @@ -291,7 +298,7 @@ class _EntryTime extends StatelessWidget {
TextSpan(text: time),
if (entry.isDeadline && !entry.allDay)
TextSpan(
text: ' ${l10n.due}',
text: ' ${entry.startOnly ? l10n.start : l10n.due}',
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
],
Expand Down
11 changes: 10 additions & 1 deletion lib/agenda/ui/agenda_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ class _AgendaEditorState extends State<_AgendaEditor> {
// localized presentation must not keep overriding the edited values.
details: null,
tailDays: _tailDays,
// Editing keeps the open-ended flag, but only while the entry stays a
// deadline: an event or exam always has an end time.
startOnly:
_kind == AgendaKind.deadline &&
(widget.entry?.startOnly ?? false),
),
);
if (!mounted) return;
Expand Down Expand Up @@ -320,7 +325,11 @@ class _AgendaEditorState extends State<_AgendaEditor> {
],
),
_when(
_kind == AgendaKind.deadline ? l10n.due : l10n.start,
// An imported open-ended deadline records an opening time, so the
// field must not be labelled as a due time.
_kind == AgendaKind.deadline && widget.entry?.startOnly != true
? l10n.due
: l10n.start,
_start,
true,
),
Expand Down
57 changes: 37 additions & 20 deletions lib/agenda/ui/agenda_timeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,31 @@ class _AgendaTimelineState extends State<AgendaTimeline>
(deadlineWidth(entry) / pixelsPerHour * _microsecondsPerHour)
.ceil(),
);
// An ordinary deadline paints leftwards from its due time; one that
// only has an opening time paints rightwards from that time. Both are
// `deadlineWidth` wide, so card culling follows the painted box rather
// than the wider reservation. This does not change the scroll range.
DateTime paintedStart(AgendaTimelinePlacement placement) =>
placement.entry.isDeadline && !placement.entry.startOnly
? placement.end.subtract(deadlineDuration(placement.entry))
: placement.start;
DateTime paintedEnd(AgendaTimelinePlacement placement) =>
placement.entry.isDeadline && placement.entry.startOnly
? placement.start.add(deadlineDuration(placement.entry))
: placement.layoutEnd;
final minimumEventSpan = Duration(
// The visual floor is fixed and does not scale with accessibility text.
microseconds:
(_minimumEventWidth / pixelsPerHour * _microsecondsPerHour)
.ceil(),
);
final placements = _layout(minimumEventSpan);
// Panning in time must not resize the vertical scroll range. Reserve
// all lanes in the filtered data, even when their cards are off screen.
final laneCount = placements.fold<int>(
0,
(count, placement) => math.max(count, placement.lane + 1),
);
final left = _timeAt(0, width, pixelsPerHour);
final right = _timeAt(width, width, pixelsPerHour);
final painter = _TimelineGridPainter(
Expand Down Expand Up @@ -508,33 +526,28 @@ class _AgendaTimelineState extends State<AgendaTimeline>
child: LayoutBuilder(
builder: (context, laneConstraints) {
final height = laneConstraints.maxHeight;
final contentHeight = math.max(
height,
laneCount * laneHeight + 12,
);
// Filtering, zooming or resizing can still
// shrink the range. Cull at the offset that the
// viewport will clamp to during this layout,
// not the stale offset from its previous size.
final offset = _lanes.hasClients
? _lanes.offset
.clamp(0.0, contentHeight - height)
.toDouble()
: 0.0;
final placementsInTime = placements
.where(
(placement) =>
placement.layoutEnd.isAfter(left) &&
(placement.entry.isDeadline
? placement.end.subtract(
deadlineDuration(
placement.entry,
),
)
: placement.start)
.isBefore(right),
paintedEnd(placement).isAfter(left) &&
paintedStart(placement).isBefore(
right,
),
)
.toList(growable: false);
final displayedLaneCount = placementsInTime
.fold<int>(
0,
(count, placement) =>
math.max(count, placement.lane + 1),
);
final contentHeight = math.max(
height,
displayedLaneCount * laneHeight + 12,
);
final visible = placementsInTime
.where(
(placement) =>
Expand Down Expand Up @@ -642,7 +655,11 @@ class _AgendaTimelineState extends State<AgendaTimeline>
time.difference(_center).inMicroseconds /
_microsecondsPerHour *
pixelsPerHour;
final actualRight = x(placement.layoutEnd);
// A start-only deadline keeps the usual painted width but grows rightwards
// from its opening time, so its right edge is derived instead of its left.
final actualRight = entry.isDeadline && entry.startOnly
? x(placement.start) + deadlineWidth
: x(placement.layoutEnd);
final actualLeft = entry.isDeadline
? actualRight - deadlineWidth
: x(placement.start);
Expand Down
Loading
Loading