학생 계정 엑셀 일괄 등록 기능 추가 (신입생 등)
- 학생 계정 관리 화면에 드래그앤드롭/클릭 업로드 영역 추가 (excel, desktop_drop, file_picker 패키지 도입) - .xlsx 파싱 → 미리보기 팝업 → 확인 후 한 명씩 순서대로 계정 생성, 결과(성공/실패)를 요약해서 보여줌. 초기 비밀번호는 1234 고정 - file_picker의 웹 구현체가 pickFile(s)를 아직 구현하지 않아서 (UnimplementedError) 웹에서는 package:web으로 직접 <input type=file>을 다뤄서 우회 (lib/function/excel_file_picker_web.dart), 다른 플랫폼은 기존 file_picker 사용 (excel_file_picker_io.dart) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
// 📄 엑셀 파일 하나를 골라 바이트로 돌려주는 진입점. 실제 구현은 플랫폼별로 갈라진다
|
||||
// (web은 file_picker의 웹 구현체에 pickFile(s)가 아직 없어서 dart:html로 직접 처리).
|
||||
export 'excel_file_picker_io.dart'
|
||||
if (dart.library.html) 'excel_file_picker_web.dart';
|
||||
@@ -0,0 +1,13 @@
|
||||
// 📄 [비웹 플랫폼] file_picker로 엑셀 파일 하나를 고른다.
|
||||
import 'dart:typed_data';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
/// 사용자가 엑셀(.xlsx) 파일을 고르면 바이트를 돌려준다. 취소하면 null.
|
||||
Future<Uint8List?> pickExcelFileBytes() async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['xlsx'],
|
||||
);
|
||||
if (files.isEmpty) return null;
|
||||
return files.first.readAsBytes();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 📄 [웹 전용] file_picker의 웹 구현체에 pickFile(s)가 아직 없어서(UnimplementedError),
|
||||
// 숨겨진 <input type="file">를 직접 만들어 처리한다.
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:typed_data';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
/// 사용자가 엑셀(.xlsx) 파일을 고르면 바이트를 돌려준다. 취소하면 null.
|
||||
Future<Uint8List?> pickExcelFileBytes() async {
|
||||
final completer = Completer<web.File?>();
|
||||
final input = web.HTMLInputElement()
|
||||
..type = 'file'
|
||||
..accept = '.xlsx'
|
||||
..style.display = 'none';
|
||||
// 문서에 실제로 붙어있지 않으면 일부 브라우저/자동화 환경에서 change 이벤트가
|
||||
// 안정적으로 안 잡힌다 — 그래서 body에 잠깐 붙였다가 끝나면 반드시 제거한다.
|
||||
web.document.body!.append(input);
|
||||
|
||||
input.onchange = (web.Event event) {
|
||||
final file = input.files?.item(0);
|
||||
input.remove();
|
||||
completer.complete(file);
|
||||
}.toJS;
|
||||
|
||||
input.click();
|
||||
final file = await completer.future;
|
||||
if (file == null) return null;
|
||||
|
||||
final buffer = await file.arrayBuffer().toDart;
|
||||
return buffer.toDart.asUint8List();
|
||||
}
|
||||
@@ -1,13 +1,115 @@
|
||||
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:excel/excel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
/// 📄 엑셀 한 줄에서 뽑아낸 학생 정보 (아직 서버에 보내기 전, 미리보기용).
|
||||
class BulkStudentRow {
|
||||
final String studentId;
|
||||
final String name;
|
||||
final int? grade;
|
||||
|
||||
const BulkStudentRow({
|
||||
required this.studentId,
|
||||
required this.name,
|
||||
this.grade,
|
||||
});
|
||||
}
|
||||
|
||||
/// 📊 일괄 등록 결과. [failures]는 "학번 이름: 실패사유" 형태의 문자열 목록.
|
||||
class BulkImportResult {
|
||||
final int successCount;
|
||||
final List<String> failures;
|
||||
|
||||
const BulkImportResult({required this.successCount, required this.failures});
|
||||
}
|
||||
|
||||
class TeacherStudentManagementController extends ChangeNotifier {
|
||||
bool _isWorking = false;
|
||||
bool get isWorking => _isWorking;
|
||||
|
||||
int _bulkTotal = 0;
|
||||
int _bulkDone = 0;
|
||||
int get bulkTotal => _bulkTotal;
|
||||
int get bulkDone => _bulkDone;
|
||||
|
||||
/// 📄 엑셀 파일 바이트를 파싱한다. 1행은 머리글로 보고 건너뛰며,
|
||||
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
|
||||
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
|
||||
List<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
|
||||
final excel = Excel.decodeBytes(bytes);
|
||||
if (excel.tables.isEmpty) return [];
|
||||
|
||||
final sheet = excel.tables.values.first;
|
||||
final rows = <BulkStudentRow>[];
|
||||
|
||||
for (final row in sheet.rows.skip(1)) {
|
||||
final studentId = _cellText(row.isNotEmpty ? row[0]?.value : null);
|
||||
final name = _cellText(row.length > 1 ? row[1]?.value : null);
|
||||
if (studentId == null || name == null) continue;
|
||||
|
||||
final grade = _cellToGrade(row.length > 2 ? row[2]?.value : null);
|
||||
rows.add(BulkStudentRow(studentId: studentId, name: name, grade: grade));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
String? _cellText(CellValue? value) {
|
||||
if (value == null) return null;
|
||||
String text;
|
||||
if (value is IntCellValue) {
|
||||
text = value.value.toString();
|
||||
} else if (value is DoubleCellValue) {
|
||||
text = value.value == value.value.roundToDouble()
|
||||
? value.value.toInt().toString()
|
||||
: value.value.toString();
|
||||
} else {
|
||||
text = value.toString().trim();
|
||||
}
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
int? _cellToGrade(CellValue? value) {
|
||||
if (value == null) return null;
|
||||
if (value is IntCellValue) return value.value;
|
||||
if (value is DoubleCellValue) return value.value.round();
|
||||
return int.tryParse(value.toString().trim());
|
||||
}
|
||||
|
||||
/// ➕ 엑셀에서 뽑아낸 학생 목록을 한 명씩 순서대로 서버에 등록한다 (기본 비밀번호 1234).
|
||||
/// 한 명 실패해도 나머지는 계속 진행하고, 마지막에 성공/실패를 모아서 돌려준다.
|
||||
Future<BulkImportResult> bulkAddStudents(List<BulkStudentRow> rows) async {
|
||||
_isWorking = true;
|
||||
_bulkTotal = rows.length;
|
||||
_bulkDone = 0;
|
||||
notifyListeners();
|
||||
|
||||
int successCount = 0;
|
||||
final failures = <String>[];
|
||||
|
||||
for (final row in rows) {
|
||||
final (success, message) = await _createStudentRequest(
|
||||
studentId: row.studentId,
|
||||
name: row.name,
|
||||
password: "1234",
|
||||
grade: row.grade,
|
||||
);
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failures.add('${row.studentId} ${row.name}: $message');
|
||||
}
|
||||
_bulkDone++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
_isWorking = false;
|
||||
notifyListeners();
|
||||
return BulkImportResult(successCount: successCount, failures: failures);
|
||||
}
|
||||
|
||||
/// ➕ 학생 계정 추가. (성공여부, 메시지)를 반환한다 — UI가 성공했을 때만 입력칸을 비운다.
|
||||
Future<(bool success, String message)> addStudentAccount({
|
||||
required String studentId,
|
||||
@@ -17,6 +119,28 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
}) async {
|
||||
_isWorking = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
return await _createStudentRequest(
|
||||
studentId: studentId,
|
||||
name: name,
|
||||
password: password,
|
||||
grade: grade,
|
||||
);
|
||||
} finally {
|
||||
_isWorking = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 실제 계정 생성 HTTP 요청. isWorking 상태는 건드리지 않는다 —
|
||||
/// 단건 등록(addStudentAccount)과 일괄 등록(bulkAddStudents) 양쪽에서
|
||||
/// 각자 알맞은 시점에 isWorking을 관리하기 위해 분리했다.
|
||||
Future<(bool success, String message)> _createStudentRequest({
|
||||
required String studentId,
|
||||
required String name,
|
||||
required String password,
|
||||
int? grade,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse('$baseUrl/api/users/create');
|
||||
final response = await http.post(
|
||||
@@ -38,9 +162,6 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
}
|
||||
} catch (e) {
|
||||
return (false, '🚨 네트워크 에러: $e');
|
||||
} finally {
|
||||
_isWorking = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user