2025-12-29 First Commit

This commit is contained in:
DATAEXPRESS\4734
2025-12-29 15:20:40 +08:00
commit fb2603c6f0
92 changed files with 6006 additions and 0 deletions
@@ -0,0 +1,18 @@
import './announcement_model.dart';
// 引入剛剛建立的共用服務 (請依實際路徑調整)
import '../services/generic_api_service.dart';
class AnnouncementApiService {
// 實例化共用服務
final GenericApiService _apiService = GenericApiService();
Future<List<Announcement>> fetchAnnouncements() async {
return await _apiService.fetchList<Announcement>(
tableName: "eipbbs_m",
pk: "uniqueno",
queryFilter: "1^10^uniqueno^*^^pmsm02^^",
// 將 Announcement 的轉換方法傳進去
fromJson: (json) => Announcement.fromJson(json),
);
}
}
@@ -0,0 +1,81 @@
import 'package:http/http.dart' as http;
import 'dart:convert';
import './announcement_model.dart';
import '../auth_manager.dart'; // 確保引入 AuthManager
// API 相關常數 (假設與登入 API 使用相同的 Base URL 結構)
const String BASE_IP = "https://api.gex.com.tw:8033";
// **請替換成您實際獲取公告列表的 API 路徑**
const String ANNOUNCEMENT_LIST_ENDPOINT = "xapi/v2/eis_demo/orm_api/3/";
const String ANNOUNCEMENT_LIST_URL = "$BASE_IP/$ANNOUNCEMENT_LIST_ENDPOINT";
class AnnouncementApiService {
Future<List<Announcement>> fetchAnnouncements() async {
// 1. 讀取儲存的 Token
String? token = await AuthManager.getToken();
if (token == null) {
print("錯誤:未找到登入 Token,請重新登入!");
// 這裡通常會導航回登入頁面
// return;
}
// 建立 API 參數 (假設獲取公告列表需要類似的 token/身份驗證參數)
final Map<String, String> params = {
"token": '$token',
//"page": "${page}",
//"perPage": "${perPage}",
//"keywords": "${keywords}",
"_\$_tableName": "eipbbs_m",
"_\$_pk": "uniqueno",
"_\$_action": "P",
"_\$_query_filter": "1^10^uniqueno^*^^pmsm02^^"
};
try {
final response = await http.post(
Uri.parse(ANNOUNCEMENT_LIST_URL),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
// 檢查狀態碼
if (response.statusCode == 200) {
// 解析 API 回應
final Map<String, dynamic> responseData = json.decode(response.body);
// 1. 檢查 code 是否為 0
if (responseData['code'] == 0) {
// 2. 根據截圖,'data' 是一個 Map,裡面包著 'items'
final dynamic dataObject = responseData['data'];
// 檢查 dataObject 是否為 Map,且裡面是否有 'items' 並且是 List
if (dataObject is Map && dataObject['items'] is List) {
// 3. 取得真正的列表 'items'
List<dynamic> listJson = dataObject['items'];
// 將 JSON 列表轉換為 Announcement 物件列表
return listJson.map((json) => Announcement.fromJson(json)).toList();
} else {
// 雖然 code=0,但資料結構不符合預期 (例如 data 是 null 或沒有 items)
return []; // 或拋出異常,視您的需求而定
}
} else {
// API 邏輯失敗 (例如 code 不為 0)
throw Exception(responseData['msg'] ?? 'API 呼叫失敗,但連線成功。');
}
} else {
// HTTP 錯誤
throw Exception('HTTP 錯誤: ${response.statusCode}');
}
} catch (e) {
// 網路或解析錯誤
print('公告獲取錯誤: $e');
throw Exception('無法載入公告列表,請檢查網路。');
}
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import './announcement_model.dart';
// 引入 url_launcher 來處理附件 (可選,需要 pubspec.yaml 增加 url_launcher 套件)
// import 'package:url_launcher/url_launcher.dart';
class AnnouncementDetail extends StatelessWidget {
final Announcement announcement;
const AnnouncementDetail({required this.announcement, super.key});
// 處理附件下載的函數 (需要 url_launcher 套件)
/*
void _launchAttachment(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
// 處理錯誤
print('無法開啟附件連結: $url');
}
}
*/
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(announcement.title, overflow: TextOverflow.ellipsis),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 標題
Text(
announcement.title,
style: const TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 資訊列
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('發布日期: ${announcement.formattedBillDate}',
style: const TextStyle(color: Colors.grey, fontSize: 14)),
Text('作者: ${announcement.createdBy}',
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
const Divider(height: 32.0),
// 附件按鈕 (如果有)
if (announcement.attachment != null && announcement.attachment!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextButton.icon(
icon: const Icon(Icons.attach_file),
label: const Text('查看附件'),
onPressed: () {
// TODO: 實作附件下載/開啟邏輯
// _launchAttachment(announcement.attachment!);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('附件功能待實作。')),
);
},
),
),
// 公告詳細內容 (使用 Text 顯示)
Text(
announcement.description,
style: const TextStyle(
fontSize: 16.0,
height: 1.5,
),
textAlign: TextAlign.justify,
),
],
),
),
);
}
}
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import './announcement_api.dart';
import './announcement_model.dart';
import './announcement_detail.dart'; // 稍後創建
class AnnouncementManager extends StatefulWidget {
const AnnouncementManager({super.key});
@override
State<StatefulWidget> createState() {
return _AnnouncementManagerState();
}
}
class _AnnouncementManagerState extends State<AnnouncementManager> {
final AnnouncementApiService _apiService = AnnouncementApiService();
late Future<List<Announcement>> _announcementsFuture;
@override
void initState() {
super.initState();
// 頁面加載時自動開始獲取資料
_announcementsFuture = _apiService.fetchAnnouncements();
}
// 刷新資料的函數
void _refreshAnnouncements() {
setState(() {
_announcementsFuture = _apiService.fetchAnnouncements();
});
}
// 導航回主頁(與您 NewsManager 中的邏輯相似)
void _backToHome() {
// 假設 MainMenu 在 main.dart 中
// import './main.dart';
// Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (context) => const MainMenu()), (route) => false,);
// 在這裡我們假設它作為子頁面,使用 pop 即可返回
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('企業公告'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshAnnouncements,
),
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回主頁',
onPressed: _backToHome,
),
],
),
body: FutureBuilder<List<Announcement>>(
future: _announcementsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
// 資料載入中
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
// 資料載入失敗 (顯示錯誤訊息)
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshAnnouncements, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
// 資料載入成功 (顯示列表)
return AnnouncementList(announcements: snapshot.data!);
} else {
// 沒有資料
return const Center(child: Text('目前沒有企業公告。'));
}
},
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (取代原有的 News 類別)
// -----------------------------------------------------------
class AnnouncementList extends StatelessWidget {
final List<Announcement> announcements;
const AnnouncementList({required this.announcements, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: announcements.length,
itemBuilder: (BuildContext context, int index) {
final item = announcements[index];
return Card(
elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AnnouncementDetail(announcement: item),
),
);
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 公告標題
Text(
item.title,
style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// 發布日期和作者
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.calendar_today, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
item.formattedBillDate,
style: const TextStyle(fontSize: 14.0, color: Colors.grey),
),
],
),
Row(
children: [
const Icon(Icons.person, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
item.createdBy,
style: const TextStyle(fontSize: 14.0, color: Colors.grey),
),
],
),
],
),
],
),
),
),
);
},
);
}
}
@@ -0,0 +1,51 @@
import 'package:intl/intl.dart';
class Announcement {
final int uniqueNo;
final String title; // bbs_title
final String description; // bbs_desc (詳細內容)
final String createdBy; // create_user (作者)
final DateTime billDate; // billdate (發布日期)
final DateTime startDate; // startdate
final DateTime endDate; // end_date
final String? attachment; // bbs_attach
Announcement({
required this.uniqueNo,
required this.title,
required this.description,
required this.createdBy,
required this.billDate,
required this.startDate,
required this.endDate,
this.attachment,
});
// Factory 構造函數:從 API 返回的 JSON (Map) 創建 Announcement 物件
factory Announcement.fromJson(Map<String, dynamic> json) {
// 輔助函數:安全解析日期字串
DateTime? parseDate(dynamic date) {
if (date is String && date.isNotEmpty) {
// 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss
return DateTime.tryParse(date);
}
return DateTime.now(); // 預設為當前時間或您可以選擇 null
}
return Announcement(
uniqueNo: json['uniqueno'] as int? ?? 0,
title: json['bbs_title'] as String? ?? '無標題',
description: json['bbs_desc'] as String? ?? '無詳細內容',
createdBy: json['create_user'] as String? ?? '未知作者',
billDate: parseDate(json['billdate'])!,
startDate: parseDate(json['startdate'])!,
endDate: parseDate(json['end_date'])!,
attachment: json['bbs_attach'] as String?,
);
}
// 格式化日期,用於列表顯示
String get formattedBillDate {
return DateFormat('yyyy/MM/dd HH:mm').format(billDate);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
// 1. 確保引入 news_detail.dart
import '../News/news_detail.dart';
class News extends StatelessWidget {
final List<String> news;
// 構造函數保持不變
const News(this.news, {super.key});
@override
Widget build(BuildContext context) {
// 使用 ListView.builder 替代 Column 和 map().toList()
// 這樣可以確保內容可以捲動,並且只構建可見的項目,效率更高。
return ListView.builder(
// 設置列表的長度,即 news 列表的項目數
itemCount: news.length,
// 根據索引來構建每個列表項目
itemBuilder: (BuildContext context, int index) {
final String currentNewsTitle = news[index]; // 提取當前新聞標題
// 使用 news[index] 來取得對應的新聞文本
return Card(
elevation: 5, // 可選: 增加卡片陰影
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), // 增加邊距
// 在這裡可以選擇添加 Key
key: ValueKey(currentNewsTitle + index.toString()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, // 讓圖片和文字可以寬度延伸
children: <Widget>[
// --- 點擊功能的修改在這裡 ---
GestureDetector(
onTap: () {
// 點擊事件:導航到 NewsDetail 畫面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NewsDetail(
// 2. 將當前新聞的標題/內容傳遞給詳細頁面
newsTitle: currentNewsTitle,
),
),
);
},
child: Image.asset(
'assets/F16-02.jpg',
fit: BoxFit.cover,
height: 200,
),
),
// --------------------------
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
currentNewsTitle,
style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
),
),
],
),
);
},
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
// 這是一個 StatelessWidget,用於顯示單條新聞的詳細內容
class NewsDetail extends StatelessWidget {
final String newsTitle; // 接收從前一個畫面傳遞過來的新聞標題
// 為了演示,我們假設詳細內容是一個較長的文本
final String newsDetailContent =
"這是新聞的詳細內容。它包含了更深入的分析、更多的數據和完整的背景資訊。點擊圖片後,使用者可以在這個頁面仔細閱讀。由於這是一個範例,我們使用了一些重複的文本來模擬文章長度,以確保頁面可以滾動。在這個頁面,您可以加入更多的元素,例如不同的圖片、作者資訊、發布日期等。";
const NewsDetail({required this.newsTitle, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// 設置 App Bar,標題可以是傳入的新聞標題
appBar: AppBar(
title: Text(newsTitle, overflow: TextOverflow.ellipsis), // 防止標題過長
),
// 使用 SingleChildScrollView 確保頁面內容超出螢幕時可以滾動
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 再次顯示圖片 (可選)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.asset(
'assets/F16-02.jpg',
fit: BoxFit.cover,
height: 250,
width: double.infinity, // 佔滿寬度
),
),
const SizedBox(height: 16.0),
// 顯示新聞標題
Text(
newsTitle,
style: const TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.w800,
),
),
const Divider(height: 32.0), // 分隔線
// 顯示新聞詳細內容
Text(
newsDetailContent * 5, // 重複內容 5 次來模擬長文章
style: const TextStyle(
fontSize: 16.0,
height: 1.5, // 增加行高,改善閱讀體驗
),
textAlign: TextAlign.justify, // 內容兩端對齊
),
],
),
),
),
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
// 確保你的 news.dart 文件和 News 類已經被正確引入
import 'news.dart';
// 確保引入 MainMenu 所在的 main.dart 文件,以便導航
import '../main.dart';
class NewsManager extends StatefulWidget {
const NewsManager({super.key});
@override
State<StatefulWidget> createState() {
return _NewsManagerState();
}
}
class _NewsManagerState extends State<NewsManager> {
List<String> news = ['第一筆資料: F-16 戰機升級計畫', '第二筆資料: 台積電研發新突破'];
void _addNews() {
setState(() {
news.add('新增資料:最新動態 ${DateTime.now().second}');
});
}
// 新增的導航函數:返回到主選單
void _backToHome() {
// Navigator.popUntil 會一直彈出 (Pop) 路由堆棧中的頁面,直到遇到一個
// 滿足條件 (predicte) 的路由。在這裡,我們使用 (route) => route.isFirst
// 來返回到路由堆棧中的第一個頁面,也就是 MainMenu (如果您的啟動順序是 Login -> MainMenu)。
// 如果 MainMenu 不是第一個頁面,您可以考慮使用:
// Navigator.popUntil(context, ModalRoute.withName('/main_menu'));
// 但這需要您在 MaterialApp 中為 MainMenu 設定路由名稱。
// 最保險且簡單的方法是直接彈出所有,然後重新推入 MainMenu(確保 MainMenu 不會有歷史記錄)
// 或者我們假設 MainMenu 是 Login 成功後的第一個頁面,使用 pushReplacement 清理過的堆棧。
// 在本例中,我們直接導航回 MainMenu,並清除所有在它之上的頁面。
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const MainMenu()),
(Route<dynamic> route) => false, // 移除所有前面的路由
);
// 註:若 NewsManager 已經是 Scaffold 的子頁面,使用 Navigator.pop(context)
// 即可返回上一個頁面(即 MainMenu)。但如果想確保是回主頁,用 pushAndRemoveUntil 更明確。
}
@override
Widget build(BuildContext context) {
// *** 關鍵修改:將整個頁面包裹在 Scaffold 中以包含 AppBar ***
return Scaffold(
appBar: AppBar(
title: const Text('公告事項'),
// ----------------------------------------------------
// 新增的返回主頁按鈕
actions: <Widget>[
IconButton(
icon: const Icon(Icons.home), // 使用一個 Home 圖標
tooltip: '返回主頁', // 長按時的提示
onPressed: _backToHome, // 點擊時呼叫導航函數
),
],
// ----------------------------------------------------
),
body: Column(
children: <Widget>[
// 1. 固定高度的按鈕部分
Container(
margin: const EdgeInsets.all(10),
child: ElevatedButton(
onPressed: _addNews,
child: const Text(
'新增資料',
style: TextStyle(fontSize: 20, color: Colors.redAccent),
),
),
),
// 2. 佔據剩餘空間的列表部分
Expanded(
child: News(news),
),
],
),
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:shared_preferences/shared_preferences.dart';
class AuthManager {
// 定義鍵值常數,避免硬編碼字串出錯
static const String _tokenKey = 'auth_token';
static const String _userIdKey = 'user_id'; // [新增] 用於儲存工號的鍵值
/// 儲存登入資訊:包含 Token 與工號
/// 當登入成功時,同時呼叫此方法
static Future<void> saveLoginInfo(String token, String userId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_userIdKey, userId);
print('Login info saved: UserID=$userId');
}
/// 讀取儲存的工號 (userid)
/// 如果沒找到則回傳 null,可用於判斷是否需重新登入
static Future<String?> getUserId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_userIdKey);
}
/// 讀取儲存的 Token
static Future<String?> getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
/// 移除所有登入資訊 (用於登出)
/// 確保 Token 與工號同步清除,保障資安
static Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_userIdKey);
print('User logged out, auth info cleared.');
}
/// 檢查是否已登入 (Token 與 UserID 皆存在)
static Future<bool> isLoggedIn() async {
final token = await getToken();
final userId = await getUserId();
return token != null && userId != null;
}
}
+39
View File
@@ -0,0 +1,39 @@
import './calendar_model.dart';
import '../services/generic_api_service.dart';
import 'package:intl/intl.dart';
class CalendarApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取指定月份或範圍的行程
Future<List<CalendarEvent>> fetchEvents(String userId, DateTime month) async {
// 企業實作建議:僅抓取特定月份資料,減少手機負載
final sDate = DateTime(month.year, month.month, 1);
final eDate = DateTime(month.year, month.month + 1, 0);
String queryFilter = "1^500^start_time^*^personid^=^$userId";
return await _apiService.fetchList<CalendarEvent>(
tableName: "eip_new_calendar",
pk: "uuid",
queryFilter: queryFilter,
fromJson: (json) => CalendarEvent.fromJson(json),
);
}
// 新增/更新行程
Future<bool> saveEvent(CalendarEvent event) async {
final data = {
"uuid": event.uuid,
"title": event.title,
"cal_description": event.description,
"start_time": event.startTime?.toIso8601String(),
"end_time": event.endTime?.toIso8601String(),
"personid": event.personId,
"cal_finished": event.isFinished ? 'Y' : 'N',
"update_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
// return await _apiService.saveData("eip_new_calendar", data);
return true;
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import './calendar_model.dart';
class CalendarDetail extends StatelessWidget {
final CalendarEvent event;
const CalendarDetail({required this.event, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('行程詳情')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Row(
children: [
CircleAvatar(backgroundColor: event.levelColor, radius: 8),
const SizedBox(width: 12),
Expanded(
child: Text(event.title, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
),
],
),
const Divider(height: 40),
_buildDetailItem(Icons.access_time, '時間範圍', event.timeRangeText),
_buildDetailItem(Icons.repeat, '重複設定', event.repeatedFlag ?? '無重複'),
_buildDetailItem(Icons.folder_open, '關聯專案', event.projectId ?? '無相關專案'),
_buildDetailItem(Icons.category_outlined, '類型', event.type ?? '一般行程'),
const SizedBox(height: 24),
const Text('行程描述', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
child: Text(event.description ?? '無詳細描述內容', style: const TextStyle(height: 1.5)),
),
],
),
);
}
Widget _buildDetailItem(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
children: [
Icon(icon, color: Colors.blueGrey, size: 20),
const SizedBox(width: 15),
Text(label, style: const TextStyle(color: Colors.grey)),
const Spacer(),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart'; // 建議引入此套件
import 'package:intl/intl.dart';
import './calendar_model.dart';
import './calendar_api.dart';
import './calendar_detail.dart';
class CalendarManager extends StatefulWidget {
final String userId;
const CalendarManager({required this.userId, super.key});
@override
State<CalendarManager> createState() => _CalendarManagerState();
}
class _CalendarManagerState extends State<CalendarManager> {
final CalendarApiService _apiService = CalendarApiService();
// 狀態控制
CalendarFormat _calendarFormat = CalendarFormat.month;
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
// 資料儲存:將事件按日期分類,方便日曆標點
Map<DateTime, List<CalendarEvent>> _eventsMap = {};
bool _isLoading = false;
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
_fetchMonthEvents(_focusedDay);
}
// 核心:抓取整個月的資料並進行分類
Future<void> _fetchMonthEvents(DateTime month) async {
setState(() => _isLoading = true);
try {
final events = await _apiService.fetchEvents(widget.userId, month);
// 將 List 轉換為 Map<DateTime, List<Event>>
Map<DateTime, List<CalendarEvent>> newMap = {};
for (var event in events) {
if (event.startTime != null) {
// 只取日期部分作為 Key
final dateKey = DateTime(event.startTime!.year, event.startTime!.month, event.startTime!.day);
if (newMap[dateKey] == null) newMap[dateKey] = [];
newMap[dateKey]!.add(event);
}
}
setState(() {
_eventsMap = newMap;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("載入失敗: $e")));
}
}
// 獲取選定日期的行程
List<CalendarEvent> _getEventsForDay(DateTime day) {
final dateKey = DateTime(day.year, day.month, day.day);
return _eventsMap[dateKey] ?? [];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('企業行事曆'),
actions: [
IconButton(icon: const Icon(Icons.today), onPressed: () => setState(() => _focusedDay = DateTime.now())),
],
),
body: Column(
children: [
// 1. 日曆組件
TableCalendar<CalendarEvent>(
firstDay: DateTime.utc(2020, 1, 1),
lastDay: DateTime.utc(2030, 12, 31),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
eventLoader: _getEventsForDay, // 在有行程的日期顯示小點點
// 樣式設定
calendarStyle: CalendarStyle(
todayDecoration: BoxDecoration(color: Colors.blue.withOpacity(0.5), shape: BoxShape.circle),
selectedDecoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle),
markerDecoration: const BoxDecoration(color: Colors.orange, shape: BoxShape.circle),
),
// 互動事件
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() => _calendarFormat = format);
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
_fetchMonthEvents(focusedDay); // 切換月份時自動重新抓取 API
},
),
const Divider(height: 1),
// 2. 下方行程列表
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _buildEventList(),
),
],
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () { /* 實重新增行程邏輯 */ },
),
);
}
Widget _buildEventList() {
final dayEvents = _getEventsForDay(_selectedDay!);
if (dayEvents.isEmpty) {
return Center(
child: Text(
"${DateFormat('MM/dd').format(_selectedDay!)} 沒有行程",
style: const TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: dayEvents.length,
itemBuilder: (context, index) {
final event = dayEvents[index];
return Card(
elevation: 2,
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
leading: Container(
width: 4,
height: 40,
decoration: BoxDecoration(
color: event.levelColor,
borderRadius: BorderRadius.circular(2),
),
),
title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(event.timeRangeText),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => CalendarDetail(event: event))
),
),
);
},
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class CalendarEvent {
final String uuid;
final String title;
final String? description;
final DateTime? startTime;
final DateTime? endTime;
final String? type; // cal_type
final String? level; // cal_level (重要程度)
final String? personId;
final bool isFinished; // cal_finished: 'Y' or 'N'
final String? repeatedFlag; // cal_repeated_flag
final String? projectId;
CalendarEvent({
required this.uuid,
required this.title,
this.description,
this.startTime,
this.endTime,
this.type,
this.level,
this.personId,
this.isFinished = false,
this.repeatedFlag,
this.projectId,
});
factory CalendarEvent.fromJson(Map<String, dynamic> json) {
return CalendarEvent(
uuid: json['uuid'] as String,
title: json['title'] as String? ?? '未命名行程',
description: json['cal_description'] as String?,
startTime: json['start_time'] != null ? DateTime.tryParse(json['start_time']) : null,
endTime: json['end_time'] != null ? DateTime.tryParse(json['end_time']) : null,
type: json['cal_type'] as String?,
level: json['cal_level'] as String?,
personId: json['personid'] as String?,
isFinished: json['cal_finished'] == 'Y',
repeatedFlag: json['cal_repeated_flag'] as String?,
projectId: json['projectid'] as String?,
);
}
// UI 輔助屬性
Color get levelColor {
switch (level?.toUpperCase()) {
case 'URGENT': return Colors.red;
case 'HIGH': return Colors.orange;
case 'NORMAL': return Colors.blue;
default: return Colors.grey;
}
}
String get timeRangeText {
if (startTime == null) return "未定時";
final df = DateFormat('HH:mm');
return "${df.format(startTime!)}${endTime != null ? ' - ${df.format(endTime!)}' : ''}";
}
}
+48
View File
@@ -0,0 +1,48 @@
import './clock_in_model.dart';
import '../services/generic_api_service.dart';
import 'package:intl/intl.dart';
class ClockInApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取個人打卡歷史 (預設按時間降冪)
Future<List<ClockInRecord>> fetchHistory(String userId) async {
String queryFilter = "1^100^ClockInDateTime^*^ClockInUserId^=^$userId";
return await _apiService.fetchList<ClockInRecord>(
tableName: "hrs_ClockInRecord",
pk: "ClockInId",
queryFilter: queryFilter,
fromJson: (json) => ClockInRecord.fromJson(json),
);
}
// 新增打卡紀錄 (POST)
Future<bool> postClockIn(ClockInRecord record) async {
// 假設後端有一個通用保存 API
final data = {
"ClockInUserId": record.userId,
"ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
"ClockInLatitude": record.latitude,
"ClockInLongitude": record.longitude,
"ClockInType": record.type,
"ClockInStoreId": record.storeId,
"CreatorId": record.userId,
"CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
// 呼叫 API 並回傳結果
// return await _apiService.saveData("hrs_ClockInRecord", data);
return true; // 模擬成功
}
// 在 ClockInApiService 類別中新增:
Future<List<ClockInStore>> fetchStores() async {
return await _apiService.fetchList<ClockInStore>(
tableName: "hrs_ClockInStore",
pk: "StoreId",
queryFilter: "1^100^StoreId^*^^^Stat^Y", // 僅抓取啟用狀態為 Y 的店點
fromJson: (json) => ClockInStore.fromJson(json),
);
}
}
+256
View File
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'dart:async';
import './clock_in_model.dart';
import './clock_in_api.dart';
import 'package:intl/intl.dart';
class ClockInManager extends StatefulWidget {
final String userId;
const ClockInManager({required this.userId, super.key});
@override
State<ClockInManager> createState() => _ClockInManagerState();
}
class _ClockInManagerState extends State<ClockInManager> {
final ClockInApiService _apiService = ClockInApiService();
late Future<List<ClockInRecord>> _historyFuture;
// --- 新增:店點相關變數 ---
List<ClockInStore> _stores = [];
ClockInStore? _selectedStore;
bool _isLoadingStores = true;
Position? _currentPosition;
double _distanceInMeters = -1;
String _currentTime = "";
late Timer _timer;
@override
void initState() {
super.initState();
_initData();
_startClock();
}
// 初始化資料:先抓店點,再抓歷史紀錄
Future<void> _initData() async {
try {
final stores = await _apiService.fetchStores();
setState(() {
_stores = stores;
if (_stores.isNotEmpty) _selectedStore = _stores.first;
_isLoadingStores = false;
});
_refreshHistory();
_getCurrentLocation();
} catch (e) {
_showMsg("初始化店點失敗: $e");
}
}
void _startClock() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) setState(() => _currentTime = DateFormat('HH:mm:ss').format(DateTime.now()));
});
}
// 修改:根據「目前選中店點」計算距離
Future<void> _getCurrentLocation() async {
if (_selectedStore == null) return;
final position = await Geolocator.getCurrentPosition();
// 使用所選店點的經緯度進行計算
final distance = Geolocator.distanceBetween(
position.latitude,
position.longitude,
_selectedStore!.latitude,
_selectedStore!.longitude
);
if (mounted) {
setState(() {
_currentPosition = position;
_distanceInMeters = distance;
});
}
}
void _refreshHistory() {
final future = _apiService.fetchHistory(widget.userId);
setState(() => _historyFuture = future);
}
// 修改:打卡動作加入 StoreId 與動態距離判斷
Future<void> _handleClockIn(String type) async {
if (_selectedStore == null) {
_showMsg("錯誤:請先選擇打卡店點");
return;
}
await _getCurrentLocation();
// 使用資料表中的 Distance 欄位作為判斷標準
if (_distanceInMeters > _selectedStore!.distance || _distanceInMeters == -1) {
_showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 已超過 ${_selectedStore!.distance} 公尺 (目前: ${_distanceInMeters.toInt()}m)");
return;
}
final newRecord = ClockInRecord(
userId: int.tryParse(widget.userId),
type: type,
latitude: _currentPosition?.latitude,
longitude: _currentPosition?.longitude,
storeId: _selectedStore!.storeId, // 使用動態 ID
);
bool success = await _apiService.postClockIn(newRecord);
if (success) {
_showMsg("[$type] 打卡成功!地點: ${_selectedStore!.storeName}");
_refreshHistory();
}
}
void _showMsg(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 動態判斷是否在選中店點的範圍內
bool isWithinRange = _selectedStore != null &&
_distanceInMeters != -1 &&
_distanceInMeters <= _selectedStore!.distance;
return Scaffold(
appBar: AppBar(title: const Text('員工行動打卡')),
body: _isLoadingStores
? const Center(child: CircularProgressIndicator())
: Column(
children: [
// 新增:店點選擇下拉選單區
_buildStoreSelector(),
_buildStatusCard(isWithinRange),
const Divider(height: 1),
// ... 歷史紀錄標題與清單部分維持不變 (同附件)
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('今日打卡紀錄', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
IconButton(onPressed: _refreshHistory, icon: const Icon(Icons.refresh)),
],
),
),
Expanded(
child: FutureBuilder<List<ClockInRecord>>(
future: _historyFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (!snapshot.hasData || snapshot.data!.isEmpty) return const Center(child: Text('查無紀錄'));
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (ctx, i) => _buildHistoryItem(snapshot.data![i]),
);
},
),
),
],
),
);
}
// 新增:店點選擇器 UI
Widget _buildStoreSelector() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
color: Colors.white,
child: DropdownButtonFormField<ClockInStore>(
decoration: const InputDecoration(
labelText: "選擇打卡店點",
prefixIcon: Icon(Icons.store),
border: OutlineInputBorder(),
),
value: _selectedStore,
items: _stores.map((s) => DropdownMenuItem(
value: s,
child: Text(s.storeName),
)).toList(),
onChanged: (val) {
setState(() {
_selectedStore = val;
_distanceInMeters = -1; // 切換時重置距離,等待下次定位
});
_getCurrentLocation(); // 切換後立即重新計算距離
},
),
);
}
// 修改:狀態卡片顯示
Widget _buildStatusCard(bool isWithinRange) {
return Container(
padding: const EdgeInsets.all(20),
color: Colors.blue.shade50,
child: Column(
children: [
Text(_currentTime, style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.blue)),
const SizedBox(height: 10),
if (_selectedStore != null) ...[
Text(_selectedStore!.storeAddress ?? "", style: const TextStyle(color: Colors.blueGrey)),
const SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red),
Text(
isWithinRange
? "已進入 ${_selectedStore!.storeName} 範圍"
: "距離過遠: ${_distanceInMeters.toInt()}m (限制: ${_selectedStore!.distance}m)",
style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
),
],
),
],
const SizedBox(height: 20),
Row(
children: [
Expanded(child: _actionBtn("上班打卡", Colors.blue, () => _handleClockIn("上班"))),
const SizedBox(width: 15),
Expanded(child: _actionBtn("下班打卡", Colors.orange, () => _handleClockIn("下班"))),
],
)
],
),
);
}
// _actionBtn 與 _buildHistoryItem 維持與附件原程式相同...
Widget _actionBtn(String label, Color color, VoidCallback onPressed) {
return ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: color, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15)),
onPressed: onPressed,
child: Text(label, style: const TextStyle(fontSize: 18)),
);
}
Widget _buildHistoryItem(ClockInRecord record) {
return ListTile(
leading: CircleAvatar(backgroundColor: record.typeColor, child: Text(record.type?[0] ?? '', style: const TextStyle(color: Colors.white))),
title: Text("${record.type} - ${record.formattedTime}"),
subtitle: Text(record.formattedDate),
trailing: const Icon(Icons.check_circle, color: Colors.green, size: 16),
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class ClockInRecord {
final int? clockInId;
final int? userId;
final DateTime? dateTime;
final double? latitude;
final double? longitude;
final String? type; // 上班/下班/加班...
final String? storeId;
ClockInRecord({
this.clockInId,
this.userId,
this.dateTime,
this.latitude,
this.longitude,
this.type,
this.storeId,
});
factory ClockInRecord.fromJson(Map<String, dynamic> json) {
return ClockInRecord(
clockInId: json['ClockInId'] as int?,
userId: json['ClockInUserId'] as int?,
dateTime: json['ClockInDateTime'] != null ? DateTime.tryParse(json['ClockInDateTime']) : null,
latitude: double.tryParse(json['ClockInLatitude']?.toString() ?? '0'),
longitude: double.tryParse(json['ClockInLongitude']?.toString() ?? '0'),
type: json['ClockInType'] as String?,
storeId: json['ClockInStoreId'] as String?,
);
}
// Helper: 格式化顯示時間
String get formattedTime => dateTime != null ? DateFormat('HH:mm:ss').format(dateTime!) : '--:--';
String get formattedDate => dateTime != null ? DateFormat('yyyy-MM-dd').format(dateTime!) : 'N/A';
// Helper: 根據打卡類型回傳顏色
Color get typeColor {
if (type == '上班') return Colors.blue;
if (type == '下班') return Colors.orange;
return Colors.grey;
}
}
class ClockInStore {
final String storeId;
final String storeName;
final String? storeAddress;
final double latitude;
final double longitude;
final int distance; // 允許打卡公尺數
ClockInStore({
required this.storeId,
required this.storeName,
this.storeAddress,
required this.latitude,
required this.longitude,
required this.distance,
});
factory ClockInStore.fromJson(Map<String, dynamic> json) {
return ClockInStore(
storeId: json['StoreId'] as String,
storeName: json['StoreName'] as String? ?? '',
storeAddress: json['StoreAddress'] as String?,
// 強制將 decimal/String 轉為 double
latitude: double.tryParse(json['StoreLatitude'].toString()) ?? 0.0,
longitude: double.tryParse(json['StoreLongitude'].toString()) ?? 0.0,
distance: int.tryParse(json['Distance'].toString()) ?? 100,
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import './person_model.dart';
import '../services/generic_api_service.dart'; // 假設您有這個共用服務
class PersonApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取所有人員列表,增加可選的 searchName 參數
Future<List<Person>> fetchPeople({String? searchName}) async {
// 預設參數:每頁 100 筆,按中文姓名排序
String filterPart = "";
// 如果提供了 searchName,則增加模糊查詢過濾條件
if (searchName != null && searchName.isNotEmpty) {
filterPart = "personcname^%$searchName%";
}
String v_queryFilter = "1^100^personid^*^^^$filterPart";
return await _apiService.fetchList<Person>(
tableName: "basperson", // 對應到 basperson 表格
pk: "personid", // 主鍵為 personid
queryFilter: v_queryFilter,
fromJson: (json) => Person.fromJson(json),
);
}
}
+157
View File
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import './person_model.dart';
import 'package:url_launcher/url_launcher.dart'; // 用於撥打電話和發送郵件
class PersonDetail extends StatelessWidget {
final Person person;
const PersonDetail({required this.person, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(person.personCName ?? '人員詳細資訊'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
// 姓名與職稱
CircleAvatar(
radius: 50,
backgroundColor: person.sexColor.withOpacity(0.2),
child: Icon(person.sexIcon, size: 60, color: person.sexColor),
),
const SizedBox(height: 10),
Text(
person.personCName ?? 'N/A',
style: const TextStyle(fontSize: 28.0, fontWeight: FontWeight.bold, color: Colors.black87),
),
Text(
person.jobName ?? 'N/A',
style: const TextStyle(fontSize: 18.0, color: Colors.black54),
),
const Divider(height: 30.0, thickness: 1),
// 聯絡資訊列表
_buildInfoTile(
Icons.badge,
'工號 / ID',
person.personId,
),
_buildInfoTile(
Icons.business,
'部門代號',
person.departmentId ?? 'N/A',
),
_buildActionTile(
context,
Icons.phone,
'公司電話',
person.tel ?? 'N/A',
person.tel,
'tel:',
),
_buildActionTile(
context,
Icons.smartphone,
'手機號碼',
person.cellphone ?? 'N/A',
person.cellphone,
'tel:',
),
_buildActionTile(
context,
Icons.email,
'電子郵件',
person.email ?? 'N/A',
person.email,
'mailto:',
),
],
),
),
);
}
// 靜態資訊欄位
Widget _buildInfoTile(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(icon, color: Colors.grey[700], size: 24),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
Text(
value,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
],
),
),
],
),
);
}
// 可操作(點擊撥號/發送郵件)的欄位
Widget _buildActionTile(
BuildContext context, IconData icon, String label, String displayValue,
String? actionValue, String protocol) {
final canLaunch = actionValue != null && actionValue.isNotEmpty;
Future<void> _launchUrl() async {
if (canLaunch) {
final uri = Uri.parse('$protocol$actionValue');
if (!await launchUrl(uri)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('無法打開 $displayValue')),
);
}
}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: InkWell(
onTap: canLaunch ? _launchUrl : null,
child: Row(
children: [
Icon(icon, color: canLaunch ? Colors.deepPurple : Colors.grey[700], size: 24),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
Text(
displayValue,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: canLaunch ? Colors.deepPurple : Colors.black,
decoration: canLaunch ? TextDecoration.underline : TextDecoration.none,
),
),
],
),
),
],
),
),
);
}
}
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import './person_api.dart';
import './person_model.dart';
import './person_detail.dart'; // 稍後創建
class PersonManager extends StatefulWidget {
const PersonManager({super.key});
@override
State<StatefulWidget> createState() {
return _PersonManagerState();
}
}
class _PersonManagerState extends State<PersonManager> {
late PersonApiService _apiService;
late Future<List<Person>> _peopleFuture;
// 新增:搜尋文字控制器
final TextEditingController _searchController = TextEditingController();
// 新增:用於記錄當前的搜尋關鍵字
String _currentSearchTerm = '';
@override
void initState() {
super.initState();
_apiService = PersonApiService();
// 頁面加載時自動開始獲取資料
_peopleFuture = _apiService.fetchPeople();
// 新增:監聽搜尋框文字變更
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
// 釋放資源
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
super.dispose();
}
// 搜尋文字變更時觸發的邏輯
void _onSearchChanged() {
final newTerm = _searchController.text;
// 只有當關鍵字真正改變時才刷新
if (newTerm != _currentSearchTerm) {
_currentSearchTerm = newTerm;
_refreshPeople();
}
}
// 刷新資料的函數
void _refreshPeople() {
setState(() {
// 調用 API 時傳入目前的搜尋關鍵字
_peopleFuture = _apiService.fetchPeople(searchName: _currentSearchTerm);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('公司通訊錄'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshPeople,
),
],
// 新增:底部放置搜尋框
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '輸入姓名進行查詢...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _currentSearchTerm.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear(); // 清空文字會觸發 _onSearchChanged
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10.0),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.white,
),
),
),
),
),
body: FutureBuilder<List<Person>>(
future: _peopleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshPeople, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return PersonList(people: snapshot.data!);
} else {
return const Center(child: Text('查無人員資料。'));
}
},
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (PersonList)
// -----------------------------------------------------------
class PersonList extends StatelessWidget {
final List<Person> people;
const PersonList({required this.people, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: people.length,
itemBuilder: (BuildContext context, int index) {
final person = people[index];
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PersonDetail(person: person),
),
);
},
child: ListTile(
// 左側性別指示器
leading: Icon(
person.sexIcon,
color: person.sexColor,
size: 32,
),
title: Text(
person.personCName ?? '姓名未知',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${person.departmentId ?? '無部門'} | ${person.jobName ?? '無職稱'}',
style: const TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey),
),
),
);
},
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
class Person {
final String personId; // personid
final String? personCName; // personcname (中文姓名)
final String? jobName; // jobname (職稱)
final String? departmentId; // departmentid (部門代號)
final String? tel; // tel (公司電話)
final String? cellphone; // cellphone (手機)
final String? email; // email (電子郵件)
final String? sex; // sex (性別)
Person({
required this.personId,
this.personCName,
this.jobName,
this.departmentId,
this.tel,
this.cellphone,
this.email,
this.sex,
});
// Factory 構造函數:從 API 返回的 JSON (Map) 創建 Person 物件
factory Person.fromJson(Map<String, dynamic> json) {
return Person(
personId: json['personid'] as String? ?? 'N/A', //
personCName: json['personcname'] as String?, //
jobName: json['jobname'] as String?, //
departmentId: json['departmentid'] as String?, //
tel: json['tel'] as String?, //
cellphone: json['cellphone'] as String?, //
email: json['email'] as String?, //
sex: json['sex'] as String?, //
);
}
// 輔助屬性:獲取性別圖示
IconData get sexIcon {
switch (sex?.toUpperCase()) {
case 'M':
return Icons.male;
case 'F':
return Icons.female;
default:
return Icons.person;
}
}
// 輔助屬性:獲取性別顏色
Color get sexColor {
switch (sex?.toUpperCase()) {
case 'M':
return Colors.blue;
case 'F':
return Colors.pink;
default:
return Colors.grey;
}
}
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:crypto/crypto.dart';
// 引入九宮格主選單,登入成功後跳轉
import 'main.dart';
// API 相關常數
const String BASE_IP = "https://api.gex.com.tw:8033";
const String API_ENDPOINT = "xapi/v1/eis_demo/checklogin/2/";
const String API_URL = "$BASE_IP/$API_ENDPOINT";
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
// 用於獲取輸入框內容
final TextEditingController _userController = TextEditingController(text: 'admin'); // 預設帳號
final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼
bool _isLoading = false;
String? _errorMessage;
// 輔助函數:將字串轉換為 MD5 雜湊值
String _generateMd5(String input) {
return md5.convert(utf8.encode(input)).toString();
}
// 登入邏輯,模擬您的 JS logon() 函數
Future<void> _logon() async {
if (_userController.text.isEmpty || _pwdController.text.isEmpty) {
setState(() {
_errorMessage = '請輸入帳號和密碼';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final String userId = _userController.text;
final String password = _pwdController.text;
// 對密碼進行 MD5 加密
final String md5Password = _generateMd5(password);
// 建立 API 參數,使用您的邏輯
final Map<String, String> params = {
'token': 'xxx',
'para01': userId,
'para02': md5Password,
'para03': 'web',
};
try {
// 執行 POST 請求。使用 application/x-www-form-urlencoded 格式
final response = await http.post(
Uri.parse(API_URL),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
// 解析 API 回應
final Map<String, dynamic> responseData = json.decode(response.body);
// 根據您的 API 邏輯:res.data.code === 0 為成功
if (responseData['code'] == 0) {
// 登入成功:導航至 MainMenu 並替換登入頁面(防止按返回鍵回到登入)
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainMenu()),
);
}
} else {
// 登入失敗:顯示錯誤訊息
setState(() {
_errorMessage = '登入失敗,請重新輸入正確的帳號及密碼!';
});
}
} catch (e) {
// 網路或伺服器錯誤
setState(() {
_errorMessage = '網路錯誤或伺服器無法連線。';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('用戶登入')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Icon(Icons.lock_open, size: 80, color: Colors.blue),
const SizedBox(height: 48.0),
// 帳號輸入框
TextField(
controller: _userController,
decoration: const InputDecoration(
labelText: '用戶帳號',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
keyboardType: TextInputType.text,
),
const SizedBox(height: 16.0),
// 密碼輸入框
TextField(
controller: _pwdController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密碼',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
onSubmitted: (_) => _logon(), // 允許按 Enter 鍵登入
),
const SizedBox(height: 24.0),
// 錯誤訊息顯示
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
// 登入按鈕
ElevatedButton(
onPressed: _isLoading ? null : _logon,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 3.0),
)
: const Text(
'登入',
style: TextStyle(fontSize: 18),
),
),
],
),
),
),
);
}
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import './news_manager.dart';
import './placeholder_screens.dart';
import './login_page.dart'; // 確保引入登入頁面
/*
您的 Dart HTTP 客戶端可能無法完成與伺服器的 SSL/TLS 握手。這需要將您的整個 App 結構調整為使用 io.HttpClient
警告: 這是一個臨時且不安全的解決方案。 它會強制 Dart 客戶端信任所有憑證。只建議在無法控制伺服器憑證或開發環境中臨時使用。
還需要找時間查一下錯誤原因 20251214 modify by Gemini 3.0
*/
import 'dart:io';
class MyHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true; // 總是回傳 true (忽略 SSL 錯誤)
}
}
void main() {
// 在 main 函數中加入這行
HttpOverrides.global = MyHttpOverrides();
runApp(const ZenApp());
}
class ZenApp extends StatelessWidget {
const ZenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '企業應用主選單',
theme: ThemeData(
primarySwatch: Colors.blue,
// 使用 Material 3 風格
useMaterial3: true,
),
// 設置主選單畫面為首頁
// home: const MainMenu(),
home: const LoginPage(),
);
}
}
// ------------------------------------
// 主選單的資料結構 (Data Model)
// ------------------------------------
class MenuItem {
final String title;
final IconData icon;
final Widget targetScreen;
const MenuItem({
required this.title,
required this.icon,
required this.targetScreen,
});
}
// 所有選單項目的列表
const List<MenuItem> menuItems = [
// 1. 新聞資訊 (使用您的 NewsManager)
MenuItem(
title: '公告事項',
icon: Icons.article,
targetScreen: NewsManager(),
),
// 2. 員工打卡
MenuItem(
title: '員工打卡',
icon: Icons.access_time_filled,
targetScreen: PlaceholderScreen(title: '員工打卡'),
),
// 2. 員工打卡
MenuItem(
title: '請假作業',
icon: Icons.access_time_filled,
targetScreen: PlaceholderScreen(title: '請假作業'),
), // 3. 待簽核事項
MenuItem(
title: '待簽核事項',
icon: Icons.pending_actions,
targetScreen: PlaceholderScreen(title: '待簽核事項'),
),
// 4. 待辦事項
MenuItem(
title: '待辦事項',
icon: Icons.checklist,
targetScreen: PlaceholderScreen(title: '待辦事項'),
),
// 5. 行事曆
MenuItem(
title: '行事曆',
icon: Icons.calendar_today,
targetScreen: PlaceholderScreen(title: '行事曆'),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '會議通知',
icon: Icons.folder,
targetScreen: PlaceholderScreen(title: '會議通知'),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '產品型錄',
icon: Icons.folder,
targetScreen: PlaceholderScreen(title: '產品型錄'),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '通訊錄',
icon: Icons.folder,
targetScreen: PlaceholderScreen(title: '通訊錄'),
),
MenuItem(
title: '業績查詢',
icon: Icons.bar_chart,
targetScreen: PlaceholderScreen(title: '報告查詢'),
),
MenuItem(
title: 'Issue List',
icon: Icons.bar_chart,
targetScreen: PlaceholderScreen(title: 'Issue List'),
),
MenuItem(
title: '設定',
icon: Icons.settings,
targetScreen: PlaceholderScreen(title: '設定'),
),
];
// ------------------------------------
// 主選單畫面 (MainMenu) - 九宮格實作
// ------------------------------------
class MainMenu extends StatelessWidget {
const MainMenu({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('企業應用主選單'),
// 增加 App Bar 的高度,使其更符合 Material 3 風格 (可選)
toolbarHeight: 70,
// 可以在這裡加入登出按鈕
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
// 登出:導航回登入頁面並清除所有歷史堆棧
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginPage()),
(Route<dynamic> route) => false,
);
},
),
],
),
// 使用 Padding 讓 GridView 內容與邊緣保持距離
body: Padding(
padding: const EdgeInsets.all(12.0),
// 核心:使用 GridView.count 建立九宮格佈局
child: GridView.count(
// 每行顯示 3 個項目(實現九宮格的第一條件)
crossAxisCount: 3,
// 項目間的間距
crossAxisSpacing: 10.0,
mainAxisSpacing: 10.0,
// 遍歷 menuItems 列表來構建每個選單項目
children: menuItems.map((item) {
return MainMenuItemTile(item: item);
}).toList(),
),
),
);
}
}
// ------------------------------------
// 單個選單項目的小部件 (MainMenuItemTile)
// ------------------------------------
class MainMenuItemTile extends StatelessWidget {
final MenuItem item;
const MainMenuItemTile({required this.item, super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4.0, // 增加陰影,更有質感
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
child: InkWell(
// 使用 InkWell 獲得點擊時的漣漪效果
onTap: () {
// 點擊後導航到對應的目標畫面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => item.targetScreen,
),
);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 選單圖標
Icon(
item.icon,
size: 48.0,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 8.0),
// 選單名稱
Text(
item.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
// 1. 確保引入 news_detail.dart
import './news_detail.dart';
class News extends StatelessWidget {
final List<String> news;
// 構造函數保持不變
const News(this.news, {super.key});
@override
Widget build(BuildContext context) {
// 使用 ListView.builder 替代 Column 和 map().toList()
// 這樣可以確保內容可以捲動,並且只構建可見的項目,效率更高。
return ListView.builder(
// 設置列表的長度,即 news 列表的項目數
itemCount: news.length,
// 根據索引來構建每個列表項目
itemBuilder: (BuildContext context, int index) {
final String currentNewsTitle = news[index]; // 提取當前新聞標題
// 使用 news[index] 來取得對應的新聞文本
return Card(
elevation: 5, // 可選: 增加卡片陰影
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), // 增加邊距
// 在這裡可以選擇添加 Key
key: ValueKey(currentNewsTitle + index.toString()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, // 讓圖片和文字可以寬度延伸
children: <Widget>[
// --- 點擊功能的修改在這裡 ---
GestureDetector(
onTap: () {
// 點擊事件:導航到 NewsDetail 畫面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NewsDetail(
// 2. 將當前新聞的標題/內容傳遞給詳細頁面
newsTitle: currentNewsTitle,
),
),
);
},
child: Image.asset(
'assets/F16-02.jpg',
fit: BoxFit.cover,
height: 200,
),
),
// --------------------------
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
currentNewsTitle,
style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
),
),
],
),
);
},
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
// 這是一個 StatelessWidget,用於顯示單條新聞的詳細內容
class NewsDetail extends StatelessWidget {
final String newsTitle; // 接收從前一個畫面傳遞過來的新聞標題
// 為了演示,我們假設詳細內容是一個較長的文本
final String newsDetailContent =
"這是新聞的詳細內容。它包含了更深入的分析、更多的數據和完整的背景資訊。點擊圖片後,使用者可以在這個頁面仔細閱讀。由於這是一個範例,我們使用了一些重複的文本來模擬文章長度,以確保頁面可以滾動。在這個頁面,您可以加入更多的元素,例如不同的圖片、作者資訊、發布日期等。";
const NewsDetail({required this.newsTitle, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// 設置 App Bar,標題可以是傳入的新聞標題
appBar: AppBar(
title: Text(newsTitle, overflow: TextOverflow.ellipsis), // 防止標題過長
),
// 使用 SingleChildScrollView 確保頁面內容超出螢幕時可以滾動
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 再次顯示圖片 (可選)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.asset(
'assets/F16-02.jpg',
fit: BoxFit.cover,
height: 250,
width: double.infinity, // 佔滿寬度
),
),
const SizedBox(height: 16.0),
// 顯示新聞標題
Text(
newsTitle,
style: const TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.w800,
),
),
const Divider(height: 32.0), // 分隔線
// 顯示新聞詳細內容
Text(
newsDetailContent * 5, // 重複內容 5 次來模擬長文章
style: const TextStyle(
fontSize: 16.0,
height: 1.5, // 增加行高,改善閱讀體驗
),
textAlign: TextAlign.justify, // 內容兩端對齊
),
],
),
),
),
);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
// 確保你的 news.dart 文件和 News 類已經被正確引入
import './news.dart';
// 確保引入 MainMenu 所在的 main.dart 文件,以便導航
import './main.dart';
class NewsManager extends StatefulWidget {
const NewsManager({super.key});
@override
State<StatefulWidget> createState() {
return _NewsManagerState();
}
}
class _NewsManagerState extends State<NewsManager> {
List<String> news = ['第一筆資料: F-16 戰機升級計畫', '第二筆資料: 台積電研發新突破'];
void _addNews() {
setState(() {
news.add('新增資料:最新動態 ${DateTime.now().second}');
});
}
// 新增的導航函數:返回到主選單
void _backToHome() {
// Navigator.popUntil 會一直彈出 (Pop) 路由堆棧中的頁面,直到遇到一個
// 滿足條件 (predicte) 的路由。在這裡,我們使用 (route) => route.isFirst
// 來返回到路由堆棧中的第一個頁面,也就是 MainMenu (如果您的啟動順序是 Login -> MainMenu)。
// 如果 MainMenu 不是第一個頁面,您可以考慮使用:
// Navigator.popUntil(context, ModalRoute.withName('/main_menu'));
// 但這需要您在 MaterialApp 中為 MainMenu 設定路由名稱。
// 最保險且簡單的方法是直接彈出所有,然後重新推入 MainMenu(確保 MainMenu 不會有歷史記錄)
// 或者我們假設 MainMenu 是 Login 成功後的第一個頁面,使用 pushReplacement 清理過的堆棧。
// 在本例中,我們直接導航回 MainMenu,並清除所有在它之上的頁面。
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const MainMenu()),
(Route<dynamic> route) => false, // 移除所有前面的路由
);
// 註:若 NewsManager 已經是 Scaffold 的子頁面,使用 Navigator.pop(context)
// 即可返回上一個頁面(即 MainMenu)。但如果想確保是回主頁,用 pushAndRemoveUntil 更明確。
}
@override
Widget build(BuildContext context) {
// *** 關鍵修改:將整個頁面包裹在 Scaffold 中以包含 AppBar ***
return Scaffold(
appBar: AppBar(
title: const Text('新聞管理'),
// ----------------------------------------------------
// 新增的返回主頁按鈕
actions: <Widget>[
IconButton(
icon: const Icon(Icons.home), // 使用一個 Home 圖標
tooltip: '返回主頁', // 長按時的提示
onPressed: _backToHome, // 點擊時呼叫導航函數
),
],
// ----------------------------------------------------
),
body: Column(
children: <Widget>[
// 1. 固定高度的按鈕部分
Container(
margin: const EdgeInsets.all(10),
child: ElevatedButton(
onPressed: _addNews,
child: const Text(
'新增資料',
style: TextStyle(fontSize: 20, color: Colors.redAccent),
),
),
),
// 2. 佔據剩餘空間的列表部分
Expanded(
child: News(news),
),
],
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
// 通用的佔位畫面
class PlaceholderScreen extends StatelessWidget {
final String title;
const PlaceholderScreen({required this.title, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Text(
'$title 功能正在開發中...',
style: const TextStyle(fontSize: 24, color: Colors.grey),
),
),
);
}
}
// 各功能頁面 (繼承自 PlaceholderScreen,方便未來替換成真實頁面)
class AttendanceScreen extends PlaceholderScreen {
AttendanceScreen({super.key}) : super(title: '員工打卡');
}
class ApprovalScreen extends PlaceholderScreen {
ApprovalScreen({super.key}) : super(title: '待簽核事項');
}
class TodoScreen extends PlaceholderScreen {
TodoScreen({super.key}) : super(title: '待辦事項');
}
class CalendarScreen extends PlaceholderScreen {
CalendarScreen({super.key}) : super(title: '行事曆');
}
+28
View File
@@ -0,0 +1,28 @@
import './issue_model.dart';
import '../services/generic_api_service.dart'; // 引入共用服務
import '../auth_manager.dart'; // 確保引入 AuthManager
class IssueApiService {
final GenericApiService _apiService = GenericApiService();
final String currentUserId; // 應由登入頁面傳入
IssueApiService({this.currentUserId = 'admin'});
Future<List<Issue>> fetchIssues() async {
// 篩選條件:指派給當前使用者 (responsible = currentUserId) 且狀態非 '已結案' (Status != 4)
// 格式: 1^100^raised_date^*^responsible^=^$currentUserId^issue_status^!=^4
// 讀取 user_id
String? user_id = await AuthManager.getUserId();
String filterPart = "responsible^$user_id";
// 完整的 queryFilter 格式:Page^PageSize^SortColumn^SortOrder^Filter...
String queryFilter = "1^100^issueid^*^^^$filterPart";
return await _apiService.fetchList<Issue>(
tableName: "pms_issuelog", // 對應到問題追蹤表格
pk: "issueid", // 主鍵為 issueid
queryFilter: queryFilter,
fromJson: (json) => Issue.fromJson(json),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import './issue_model.dart';
import 'package:intl/intl.dart';
class IssueDetail extends StatelessWidget {
final Issue issue;
const IssueDetail({required this.issue, super.key});
// 輔助函式:建立屬性列
Widget _buildAttributeRow(BuildContext context, String label, String? value, {Color color = Colors.black}) {
// 處理日期欄位
String displayValue = value ?? 'N/A';
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black54),
),
),
Expanded(
child: Text(
displayValue,
style: TextStyle(fontWeight: FontWeight.w600, color: color),
),
),
],
),
);
}
// 輔助函式:格式化日期時間
String _formatDateTime(DateTime? dateTime) {
if (dateTime == null) return 'N/A';
return DateFormat('yyyy/MM/dd HH:mm').format(dateTime);
}
@override
Widget build(BuildContext context) {
// 完整描述 (Model中可能被截斷,這裡使用原始欄位)
final fullDescription = issue.description ?? '無詳細描述。';
final hasSolution = issue.solution != null && issue.solution!.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text('問題 #${issue.issueId}'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 標題與 ID
Text(
'問題 ID: #${issue.issueId}',
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87),
),
const Divider(height: 24.0),
// 核心屬性
_buildAttributeRow(context, '狀態', issue.statusText, color: issue.statusColor),
_buildAttributeRow(context, '優先級', issue.priority, color: issue.priorityColor),
_buildAttributeRow(context, '預計完成日', issue.formattedExpectedDate, color: Colors.blue),
_buildAttributeRow(context, '指派對象', issue.responsible),
_buildAttributeRow(context, '提出人', issue.raisedBy),
_buildAttributeRow(context, '專案 ID', issue.projectId),
_buildAttributeRow(context, '功能碼', issue.functionCode),
_buildAttributeRow(context, '提出時間', _formatDateTime(issue.raisedDate)),
_buildAttributeRow(context, '解決說明', hasSolution ? '詳見下方' : '尚未解決', color: hasSolution ? Colors.green : Colors.red),
const Divider(height: 32.0),
// 問題詳細描述
const Text(
'問題描述 (Issue Description):',
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
fullDescription,
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black87),
textAlign: TextAlign.justify,
),
),
const Divider(height: 32.0),
// 解決說明/備註
Text(
'解決說明 (Solution Explanation):',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w600,
color: hasSolution ? Colors.black87 : Colors.grey.shade500
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
width: double.infinity,
decoration: BoxDecoration(
color: hasSolution ? Colors.lightGreen.shade50 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: hasSolution ? Colors.green.shade200 : Colors.transparent)
),
child: Text(
issue.solution ?? '尚無解決說明或備註。',
style: TextStyle(
fontSize: 16.0,
height: 1.5,
color: hasSolution ? Colors.black87 : Colors.grey.shade600
),
textAlign: TextAlign.justify,
),
),
const SizedBox(height: 40),
// 底部按鈕
Center(
child: ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待實作處理問題流程(例如:變更狀態)。')),
);
},
icon: const Icon(Icons.build),
label: const Text('處理問題'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 18),
),
),
),
],
),
),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import './issue_api.dart';
import './issue_model.dart';
import './issue_detail.dart';
class IssueManager extends StatefulWidget {
// 實際應用中,這裡應該傳入當前用戶 ID
final String currentUserId;
const IssueManager({this.currentUserId = 'admin', super.key});
@override
State<StatefulWidget> createState() {
return _IssueManagerState();
}
}
class _IssueManagerState extends State<IssueManager> {
late IssueApiService _apiService;
late Future<List<Issue>> _issuesFuture;
@override
void initState() {
super.initState();
_apiService = IssueApiService(currentUserId: widget.currentUserId);
_issuesFuture = _apiService.fetchIssues();
}
void _refreshIssues() {
setState(() {
_issuesFuture = _apiService.fetchIssues();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('指派給我的問題清單 (${widget.currentUserId})'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshIssues,
),
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回主頁',
onPressed: () => Navigator.pop(context),
),
],
),
body: FutureBuilder<List<Issue>>(
future: _issuesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshIssues, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return IssueList(issues: snapshot.data!);
} else {
return const Center(child: Text('目前沒有指派給您的問題。'));
}
},
),
// 底部浮動按鈕:新增問題
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待實作新增問題頁面。')),
);
},
icon: const Icon(Icons.add),
label: const Text('新增問題'),
backgroundColor: Colors.redAccent,
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (IssueList)
// -----------------------------------------------------------
class IssueList extends StatelessWidget {
final List<Issue> issues;
const IssueList({required this.issues, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: issues.length,
itemBuilder: (BuildContext context, int index) {
final item = issues[index];
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => IssueDetail(issue: item),
),
);
},
child: ListTile(
// 左側顯示優先級
leading: Icon(
item.priorityIcon,
color: item.priorityColor,
size: 30,
),
title: Text(
'#${item.issueId} ${item.description ?? '無描述'}',
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'專案: ${item.projectId ?? 'N/A'} | 提出人: ${item.raisedBy ?? 'N/A'}',
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
item.statusText,
style: TextStyle(fontSize: 12, color: item.statusColor, fontWeight: FontWeight.bold),
),
Text(
item.formattedExpectedDate,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
),
);
},
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class Issue {
final int issueId; // issueid (Primary Key)
final String? projectId; // projectid
final String? functionCode; // functioncode
final String? description; // issue_description
final String? priority; // issue_priority (High, Medium, Low)
final int? status; // issue_status (1=New, 2=InProgress, 3=Resolved, 4=Closed)
final String? raisedBy; // raised_by
final String? responsible; // responsible
final DateTime? raisedDate; // raised_date
final DateTime? expectedDate; // excepted_date
final String? solution; // solution_explanation
Issue({
required this.issueId,
this.projectId,
this.functionCode,
this.description,
this.priority,
this.status,
this.raisedBy,
this.responsible,
this.raisedDate,
this.expectedDate,
this.solution,
});
factory Issue.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic date) {
if (date is String && date.isNotEmpty) {
return DateTime.tryParse(date);
}
return null;
}
// 由於 issue_priority 是 VARCHAR,我們假設它直接就是 'High', 'Medium', 'Low'
// 或是一個代號,此處保留為 String
final rawPriority = json['issue_priority'] as String?;
// 假設 issue_description 欄位是問題標題/簡述
final rawDescription = json['issue_description'] as String?;
final descriptionLength = rawDescription?.length ?? 0;
final truncatedDescription = (descriptionLength > 50)
? rawDescription!.substring(0, 50) + '...' // 列表截斷
: rawDescription;
return Issue(
issueId: json['issueid'] as int? ?? 0,
projectId: json['projectid'] as String?,
functionCode: json['functioncode'] as String?,
description: truncatedDescription, // 使用修正後的變數
priority: rawPriority,
status: json['issue_status'] as int?,
raisedBy: json['raised_by'] as String?,
responsible: json['responsible'] as String?,
raisedDate: parseDate(json['raised_date']),
expectedDate: parseDate(json['excepted_date']),
solution: json['solution_explanation'] as String?,
);
}
// Helper: 格式化預計完成日期
String get formattedExpectedDate {
if (expectedDate == null) return 'N/A';
return DateFormat('yyyy/MM/dd').format(expectedDate!);
}
// Helper: 獲取狀態文字
String get statusText {
switch (status) {
case 1:
return '新建';
case 2:
return '進行中';
case 3:
return '已解決';
case 4:
return '已結案';
default:
return '未知';
}
}
// Helper: 獲取狀態顏色
Color get statusColor {
switch (status) {
case 1:
return Colors.red; // 新建
case 2:
return Colors.orange; // 進行中
case 3:
return Colors.blue; // 已解決
case 4:
return Colors.green; // 已結案
default:
return Colors.grey;
}
}
// Helper: 獲取優先級圖示
IconData get priorityIcon {
switch (priority?.toLowerCase()) {
case 'high':
return Icons.arrow_upward;
case 'medium':
return Icons.remove;
case 'low':
return Icons.arrow_downward;
default:
return Icons.sort;
}
}
// Helper: 獲取優先級顏色
Color get priorityColor {
switch (priority?.toLowerCase()) {
case 'high':
return Colors.red.shade700;
case 'medium':
return Colors.orange.shade700;
case 'low':
return Colors.blue.shade700;
default:
return Colors.grey;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import './leave_model.dart';
import '../services/generic_api_service.dart';
import 'package:intl/intl.dart';
class LeaveApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取個人請假紀錄
Future<List<Leave>> fetchLeaves(String personId) async {
// 排序:按單據日期降冪
String queryFilter = "1^100^billdate^*^personid^=^$personId";
return await _apiService.fetchList<Leave>(
tableName: "hrs_leave",
pk: "billno",
queryFilter: queryFilter,
fromJson: (json) => Leave.fromJson(json),
);
}
// 提交請假單 (新增)
Future<bool> createLeave(Leave leave) async {
final Map<String, dynamic> data = {
"billdate": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
"billno": "LV${DateTime.now().millisecondsSinceEpoch}", // 範例編號
"personid": leave.personId,
"agentid": leave.agentId,
"leavetype": leave.leaveType,
"starttime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.startTime!),
"endtime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.endTime!),
"days": leave.days,
"hours": leave.hours,
"leave_note": leave.leaveNote,
"flow_status": "1", // 提交即進入審核中
"create_user": leave.personId,
"create_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
// 呼叫底層 saveData 實作
// return await _apiService.saveData("hrs_leave", data);
print("提交假單: $data");
return true;
}
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import './leave_model.dart';
class LeaveDetail extends StatelessWidget {
final Leave leave;
const LeaveDetail({required this.leave, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('請假單詳情'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0.5,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 頂部狀態區塊
_buildHeaderStatus(),
const SizedBox(height: 24),
// 主要資訊區塊 (使用卡片包裝)
_buildInfoCard(context),
const SizedBox(height: 24),
// 請假事由區塊
const Text(
'請假事由',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
leave.leaveNote ?? '未填寫事由',
style: const TextStyle(fontSize: 15, height: 1.5),
),
),
const SizedBox(height: 32),
// 底部操作按鈕 (例如:若為草稿可編輯,或撤回)
if (leave.flowStatus == '0' || leave.flowStatus == '1')
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
// 實作撤回或取消邏輯
},
icon: const Icon(Icons.history_outlined),
label: const Text('撤回申請'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
);
}
// 頂部狀態顯示:呈現單號與醒目的狀態標籤
Widget _buildHeaderStatus() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'單號: ${leave.billNo}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 4),
Text(
leave.leaveType,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
],
),
Chip(
backgroundColor: leave.statusColor.withOpacity(0.1),
side: BorderSide(color: leave.statusColor),
label: Text(
leave.statusText,
style: TextStyle(color: leave.statusColor, fontWeight: FontWeight.bold),
),
),
],
);
}
// 核心資訊卡片
Widget _buildInfoCard(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
side: BorderSide(color: Colors.grey.shade200),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildDetailRow(Icons.calendar_month, '請假期間', leave.formattedRange),
const Divider(height: 30),
_buildDetailRow(Icons.timer_outlined, '請假時數', '${leave.days}${leave.hours} 小時'),
const Divider(height: 30),
_buildDetailRow(Icons.person_outline, '代理人', leave.agentId),
const Divider(height: 30),
_buildDetailRow(Icons.edit_calendar, '申請日期',
leave.billDate != null ? DateFormat('yyyy-MM-dd').format(leave.billDate!) : 'N/A'),
],
),
),
);
}
// 輔助元件:建立細節列
Widget _buildDetailRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 20, color: Colors.blueAccent),
const SizedBox(width: 12),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
const Spacer(),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
],
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import './leave_model.dart';
import './leave_api.dart';
import 'package:intl/intl.dart';
class LeaveForm extends StatefulWidget {
final String userId;
const LeaveForm({required this.userId, super.key});
@override
State<LeaveForm> createState() => _LeaveFormState();
}
class _LeaveFormState extends State<LeaveForm> {
final _formKey = GlobalKey<FormState>();
String _selectedType = '事假';
String _agentId = '';
String _note = '';
DateTime _start = DateTime.now();
DateTime _end = DateTime.now().add(const Duration(hours: 8));
final List<String> _types = ['事假', '病假', '特休', '婚假', '喪假'];
void _submit() async {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
final newLeave = Leave(
billNo: '', // API 端生成
personId: widget.userId,
agentId: _agentId,
leaveType: _selectedType,
startTime: _start,
endTime: _end,
days: 1.0, // 簡化處理,實際可依 start/end 計算
hours: 8.0,
leaveNote: _note,
);
final success = await LeaveApiService().createLeave(newLeave);
if (success && mounted) {
Navigator.pop(context, true);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新增請假申請')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
DropdownButtonFormField<String>(
value: _selectedType,
decoration: const InputDecoration(labelText: '請假類別'),
items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
onChanged: (v) => setState(() => _selectedType = v!),
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: '代理人工號'),
validator: (v) => v!.isEmpty ? '必填' : null,
onSaved: (v) => _agentId = v!,
),
const SizedBox(height: 16),
ListTile(
title: const Text('開始時間'),
subtitle: Text(DateFormat('yyyy/MM/dd HH:mm').format(_start)),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
// 這裡簡化,實務上可串接 showDatePicker + showTimePicker
},
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: '事由說明'),
maxLines: 3,
onSaved: (v) => _note = v!,
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)),
child: const Text('提交申請'),
),
],
),
),
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import './leave_model.dart';
import './leave_api.dart';
import './leave_form.dart'; // 稍後定義的新增頁面
import './leave_detail.dart';
class LeaveManager extends StatefulWidget {
final String currentUserId;
const LeaveManager({required this.currentUserId, super.key});
@override
State<LeaveManager> createState() => _LeaveManagerState();
}
class _LeaveManagerState extends State<LeaveManager> {
late LeaveApiService _apiService;
late Future<List<Leave>> _leaveFuture;
@override
void initState() {
super.initState();
_apiService = LeaveApiService();
_refreshList();
}
void _refreshList() {
setState(() {
_leaveFuture = _apiService.fetchLeaves(widget.currentUserId);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('請假紀錄')),
body: FutureBuilder<List<Leave>>(
future: _leaveFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text('尚無請假紀錄'));
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (ctx, i) => _buildLeaveCard(snapshot.data![i]),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => LeaveForm(userId: widget.currentUserId)),
);
if (result == true) _refreshList();
},
label: const Text('申請請假'),
icon: const Icon(Icons.add),
),
);
}
Widget _buildLeaveCard(Leave item) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => LeaveDetail(leave: item)),
);
},
title: Text('${item.leaveType} (${item.days}${item.hours}時)'),
subtitle: Text(item.formattedRange),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: item.statusColor, borderRadius: BorderRadius.circular(5)),
child: Text(item.statusText, style: const TextStyle(color: Colors.white, fontSize: 12)),
),
),
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class Leave {
final String billNo; // billno (Primary Key)
final DateTime? billDate; // billdate
final String personId; // personid
final String agentId; // agentid (代理人)
final String leaveType; // leavetype (假別:事假、病假等)
final DateTime? startTime; // starttime
final DateTime? endTime; // endtime
final double days; // days
final double hours; // hours
final String? leaveNote; // leave_note
final String? flowStatus; // flow_status (0:草稿, 1:審核中, 2:已核准, X:駁回)
Leave({
required this.billNo,
this.billDate,
required this.personId,
required this.agentId,
required this.leaveType,
this.startTime,
this.endTime,
this.days = 0,
this.hours = 0,
this.leaveNote,
this.flowStatus,
});
factory Leave.fromJson(Map<String, dynamic> json) {
return Leave(
billNo: json['billno'] as String? ?? '',
billDate: json['billdate'] != null ? DateTime.tryParse(json['billdate']) : null,
personId: json['personid'] as String? ?? '',
agentId: json['agentid'] as String? ?? '',
leaveType: json['leavetype'] as String? ?? '',
startTime: json['starttime'] != null ? DateTime.tryParse(json['starttime']) : null,
endTime: json['endtime'] != null ? DateTime.tryParse(json['endtime']) : null,
days: double.tryParse(json['days']?.toString() ?? '0') ?? 0,
hours: double.tryParse(json['hours']?.toString() ?? '0') ?? 0,
leaveNote: json['leave_note'] as String?,
flowStatus: json['flow_status'] as String?,
);
}
// 格式化顯示
String get formattedRange {
if (startTime == null || endTime == null) return '時間未定';
final df = DateFormat('yyyy/MM/dd HH:mm');
return '${df.format(startTime!)} ~ ${df.format(endTime!)}';
}
// 狀態顏色映射
Color get statusColor {
switch (flowStatus) {
case '1': return Colors.orange; // 審核中
case '2': return Colors.green; // 已核准
case 'X': return Colors.red; // 駁回
default: return Colors.grey; // 草稿
}
}
String get statusText {
switch (flowStatus) {
case '1': return '審核中';
case '2': return '已核准';
case 'X': return '已駁回';
default: return '草稿';
}
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:crypto/crypto.dart';
// 引入九宮格主選單,登入成功後跳轉
import 'main.dart';
import './auth_manager.dart'; // 確保路徑正確
// API 相關常數
const String BASE_IP = "https://api.gex.com.tw:8033";
const String API_ENDPOINT = "xapi/v1/eis_demo/checklogin/2/";
const String API_URL = "$BASE_IP/$API_ENDPOINT";
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
// 用於獲取輸入框內容
final TextEditingController _userController = TextEditingController(text: 'admin'); // 預設帳號
final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼
bool _isLoading = false;
String? _errorMessage;
// 輔助函數:將字串轉換為 MD5 雜湊值
String _generateMd5(String input) {
return md5.convert(utf8.encode(input)).toString();
}
// 登入邏輯,模擬您的 JS logon() 函數
Future<void> _logon() async {
if (_userController.text.isEmpty || _pwdController.text.isEmpty) {
setState(() {
_errorMessage = '請輸入帳號和密碼';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final String userId = _userController.text;
final String password = _pwdController.text;
// 對密碼進行 MD5 加密
final String md5Password = _generateMd5(password);
// 建立 API 參數,使用您的邏輯
final Map<String, String> params = {
'token': 'xxx',
'para01': userId,
'para02': md5Password,
'para03': 'web',
};
try {
// 執行 POST 請求。使用 application/x-www-form-urlencoded 格式
final response = await http.post(
Uri.parse(API_URL),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
// 解析 API 回應
final Map<String, dynamic> responseData = json.decode(response.body);
// 根據您的 API 邏輯:res.data.code === 0 為成功
if (responseData['code'] == 0) {
// 1. 預期 responseData['data'] 是一個 List
final List<dynamic>? dataList = responseData['data'] as List<dynamic>?;
// 2. 檢查 List 不為空,並從第一個元素中提取 'token'
final String? token = (dataList != null && dataList.isNotEmpty)
? (dataList[0] as Map<String, dynamic>)['token'] as String?
: null;
if (token != null && token.isNotEmpty) {
await AuthManager.saveLoginInfo(token, userId); // 呼叫 AuthManager 儲存 Token
} else {
print("警告: 登入成功但未收到 Token,將不儲存。");
}
// 登入成功:導航至 MainMenu 並替換登入頁面(防止按返回鍵回到登入)
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainMenu()),
);
}
} else {
// 登入失敗:顯示錯誤訊息
setState(() {
_errorMessage = '登入失敗,請重新輸入正確的帳號及密碼!';
});
}
} catch (e) {
// 網路或伺服器錯誤
setState(() {
_errorMessage = '網路錯誤或伺服器無法連線。';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('用戶登入')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Icon(Icons.lock_open, size: 80, color: Colors.blue),
const SizedBox(height: 48.0),
// 帳號輸入框
TextField(
controller: _userController,
decoration: const InputDecoration(
labelText: '用戶帳號',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
keyboardType: TextInputType.text,
),
const SizedBox(height: 16.0),
// 密碼輸入框
TextField(
controller: _pwdController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密碼',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
onSubmitted: (_) => _logon(), // 允許按 Enter 鍵登入
),
const SizedBox(height: 24.0),
// 錯誤訊息顯示
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
// 登入按鈕
ElevatedButton(
onPressed: _isLoading ? null : _logon,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 3.0),
)
: const Text(
'登入',
style: TextStyle(fontSize: 18),
),
),
],
),
),
),
);
}
}
+309
View File
@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'News/news_manager.dart';
import './placeholder_screens.dart';
import './login_page.dart'; // 確保引入登入頁面
import './auth_manager.dart';
/*
您的 Dart HTTP 客戶端可能無法完成與伺服器的 SSL/TLS 握手。這需要將您的整個 App 結構調整為使用 io.HttpClient
警告: 這是一個臨時且不安全的解決方案。 它會強制 Dart 客戶端信任所有憑證。只建議在無法控制伺服器憑證或開發環境中臨時使用。
還需要找時間查一下錯誤原因 20251214 modify by Gemini 3.0
*/
import 'dart:io';
// 確保引入新的公告管理器
import './AnnouncementManager/announcement_manager.dart';
// 確保引入新的待辦事項管理器
import './todo/todo_manager.dart';
// 通訊錄
import './employee/person_manager.dart';
// 會議通知
import './meeting/meeting_manager.dart';
// Issue
import './issue/issue_manager.dart';
// setting
import './setup/settings_page.dart';
import './clockin/clock_in_manager.dart';
import './leave/leave_manager.dart';
import './calendar/calendar_manager.dart';
class MyHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true; // 總是回傳 true (忽略 SSL 錯誤)
}
}
void main() {
// 在 main 函數中加入這行
HttpOverrides.global = MyHttpOverrides();
runApp(const ZenApp());
}
class ZenApp extends StatelessWidget {
const ZenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '企業應用主選單',
theme: ThemeData(
primarySwatch: Colors.blue,
// 使用 Material 3 風格
useMaterial3: true,
),
// 設置主選單畫面為首頁
// home: const MainMenu(),
home: const LoginPage(),
);
}
}
// ------------------------------------
// 主選單的資料結構 (Data Model)
// ------------------------------------
class MenuItem {
final String title;
final IconData icon;
final Widget targetScreen;
const MenuItem({
required this.title,
required this.icon,
required this.targetScreen,
});
}
// 所有選單項目的列表
const List<MenuItem> menuItems = [
// 1. 新聞資訊 (使用您的 NewsManager)
MenuItem(
title: '公告事項',
icon: Icons.article,
//targetScreen: NewsManager(),
targetScreen: AnnouncementManager(),
),
// 2. 員工打卡
MenuItem(
title: '員工打卡',
icon: Icons.access_time_filled,
targetScreen: ClockInManager(userId: "admin"),
),
// 2. 員工打卡
MenuItem(
title: '請假作業',
icon: Icons.event_available,
targetScreen: LeaveManager(currentUserId: "admin"),
), // 3. 待簽核事項
MenuItem(
title: '待簽核事項',
icon: Icons.pending_actions,
targetScreen: PlaceholderScreen(title: '待簽核事項'),
),
// 4. 待辦事項
MenuItem(
title: '待辦事項',
icon: Icons.checklist,
targetScreen: TodoManager(),
),
// 5. 行事曆
MenuItem(
title: '行事曆',
icon: Icons.calendar_today,
targetScreen: CalendarManager(userId: "120102"),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '會議通知',
icon: Icons.people_alt,
targetScreen: MeetingManager(),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '產品型錄',
icon: Icons.local_mall,
targetScreen: PlaceholderScreen(title: '產品型錄'),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '通訊錄',
icon: Icons.contact_phone,
targetScreen: PersonManager(),
),
MenuItem(
title: '業績查詢',
icon: Icons.query_stats,
targetScreen: PlaceholderScreen(title: '報告查詢'),
),
MenuItem(
title: 'Issue List',
icon: Icons.bug_report,
targetScreen: IssueManager(),
),
MenuItem(
title: '設定',
icon: Icons.settings,
targetScreen: SettingsPage(),
),
MenuItem(
title: '新聞(Demo)',
icon: Icons.article,
targetScreen: NewsManager(),
//targetScreen: AnnouncementManager(),
),
];
// ------------------------------------
// 主選單畫面 (MainMenu) - 九宮格實作
// ------------------------------------
class MainMenu extends StatelessWidget {
const MainMenu({super.key});
// 公司資訊變數
final String companyName = "中華開放原始碼應用推廣協會";
final String slogan = "推動開源 × 數位轉型 × 企業創新";
final String copyrightYear = "2024";
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('企業應用主選單'),
// 增加 App Bar 的高度,使其更符合 Material 3 風格 (可選)
toolbarHeight: 70,
// 可以在這裡加入登出按鈕
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await AuthManager.logout(); // <-- 記得加入此行
// 登出:導航回登入頁面並清除所有歷史堆棧
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginPage()),
(Route<dynamic> route) => false,
);
},
),
],
),
// 使用 Column 將 GridView 和底部資訊垂直堆疊
body: Column(
children: <Widget>[
// 1. 主要內容區 (Grid View) - 使用 Expanded 佔滿剩餘空間
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
// 核心:使用 GridView.count 建立九宮格佈局
child: GridView.count(
// 每行顯示 3 個項目(實現九宮格的第一條件)
crossAxisCount: 3,
// 項目間的間距
crossAxisSpacing: 10.0,
mainAxisSpacing: 10.0,
// 遍歷 menuItems 列表來構建每個選單項目
children: menuItems.map((item) {
return MainMenuItemTile(item: item);
}).toList(),
),
),
),
// 2. 底部公司資訊、Slogan 和版權宣告 (固定在最下方)
Container(
padding: const EdgeInsets.only(top: 8.0, bottom: 16.0),
width: double.infinity, // 佔滿寬度
child: Column(
children: <Widget>[
// 公司名稱
Text(
companyName,
style: TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4.0),
// Slogan
Text(
slogan,
style: TextStyle(
fontSize: 12.0,
fontStyle: FontStyle.italic,
color: Colors.grey[600],
),
),
const SizedBox(height: 8.0),
// 版權宣告
/*
Text(
'Copyright © $copyrightYear $companyName. All rights reserved.',
style: TextStyle(
fontSize: 10.0,
color: Colors.grey[500],
),
),
*/
],
),
),
],
),
);
}
}
// ------------------------------------
// 單個選單項目的小部件 (MainMenuItemTile)
// ------------------------------------
class MainMenuItemTile extends StatelessWidget {
final MenuItem item;
const MainMenuItemTile({required this.item, super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4.0, // 增加陰影,更有質感
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
child: InkWell(
// 使用 InkWell 獲得點擊時的漣漪效果
onTap: () {
// 點擊後導航到對應的目標畫面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => item.targetScreen,
),
);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 選單圖標
Icon(
item.icon,
size: 48.0,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 8.0),
// 選單名稱
Text(
item.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import './meeting_model.dart';
import '../services/generic_api_service.dart'; // 引入共用服務
class MeetingApiService {
final GenericApiService _apiService = GenericApiService();
final String currentUserId; // 應由登入頁面傳入
MeetingApiService({this.currentUserId = 'admin'});
Future<List<Meeting>> fetchMeetings() async {
// 目標:獲取與當前用戶 (currentUserId) 相關的會議通知。
// 採用過濾邏輯:篩選參與者名單 (meeting_users) 中包含 currentUserId 的記錄。
// 預設參數:頁碼1,每頁100筆,按 startdate 降冪排序 (*表示降冪)
// 格式: 1^100^startdate^*^meeting_users^LIKE^%currentUserId%
String filterPart = "startdate^*^meeting_users^LIKE^%$currentUserId%";
// 完整的 queryFilter 格式:Page^PageSize^SortColumn^SortOrder^FilterColumn^Operator^FilterValue...
String queryFilter = "1^100^$filterPart";
return await _apiService.fetchList<Meeting>(
tableName: "eipmeetingrec_m", // 對應到會議通知表格
pk: "uniqueno", // 主鍵為 uniqueno
queryFilter: queryFilter,
fromJson: (json) => Meeting.fromJson(json),
);
}
}
+138
View File
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import './meeting_model.dart';
import 'package:intl/intl.dart';
class MeetingDetail extends StatelessWidget {
final Meeting meeting;
const MeetingDetail({required this.meeting, super.key});
// 輔助函式:建立屬性列 (從 todo_detail.dart 最佳化而來)
Widget _buildAttributeRow(BuildContext context, String label, String value, {Color color = Colors.black}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black54),
),
),
Expanded(
child: Text(
value,
style: TextStyle(fontWeight: FontWeight.w600, color: color),
),
),
],
),
);
}
// 輔助函式:格式化日期時間範圍
String _formatDateRange() {
final dateFormat = DateFormat('yyyy/MM/dd');
final startDateStr = meeting.startDate != null ? dateFormat.format(meeting.startDate!) : 'N/A';
final endDateStr = meeting.endDate != null ? dateFormat.format(meeting.endDate!) : startDateStr;
// 檢查日期是否相同
final isSameDate = meeting.startDate != null && meeting.endDate != null &&
meeting.startDate!.day == meeting.endDate!.day &&
meeting.startDate!.month == meeting.endDate!.month &&
meeting.startDate!.year == meeting.endDate!.year;
String dateRange;
if (isSameDate) {
dateRange = startDateStr;
} else {
dateRange = '$startDateStr$endDateStr';
}
final timeRange = (meeting.startTime ?? '') + ' ~ ' + (meeting.endTime ?? '');
return '$dateRange ${timeRange.trim()}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(meeting.meetingTitle, overflow: TextOverflow.ellipsis),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 會議標題
Text(
meeting.meetingTitle,
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87),
),
const Divider(height: 24.0),
// 會議屬性表格
_buildAttributeRow(context, '狀態', meeting.statusText, color: meeting.statusColor),
_buildAttributeRow(context, '地點', meeting.meetingPlace ?? '待定'),
_buildAttributeRow(context, '時間範圍', _formatDateRange(), color: Colors.blue),
_buildAttributeRow(context, '主持人 ID', meeting.bossPersonId ?? 'N/A'),
_buildAttributeRow(context, '記錄人 ID', meeting.recPersonId ?? 'N/A'),
_buildAttributeRow(context, '參與者名單', meeting.meetingUsers ?? ''),
const Divider(height: 32.0),
// 會議說明
const Text(
'會議說明:',
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 8),
Text(
meeting.meetingDesc ?? '無詳細說明。',
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54),
textAlign: TextAlign.justify,
),
const Divider(height: 32.0),
// 會議決議
const Text(
'會議決議:',
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 8),
Text(
meeting.resolution ?? '無決議事項。',
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54),
textAlign: TextAlign.justify,
),
const SizedBox(height: 40),
// 底部按鈕 (範例:下載文件)
Center(
child: ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待實作下載會議文件功能。')),
);
},
icon: const Icon(Icons.file_download),
label: const Text('下載會議文件'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 18),
),
),
),
],
),
),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import './meeting_api.dart';
import './meeting_model.dart';
import './meeting_detail.dart';
class MeetingManager extends StatefulWidget {
// 實際應用中,這裡應該傳入當前用戶 ID
final String currentUserId;
const MeetingManager({this.currentUserId = 'admin', super.key});
@override
State<StatefulWidget> createState() {
return _MeetingManagerState();
}
}
class _MeetingManagerState extends State<MeetingManager> {
late MeetingApiService _apiService;
late Future<List<Meeting>> _meetingsFuture;
@override
void initState() {
super.initState();
// 服務初始化時傳入當前用戶 ID
_apiService = MeetingApiService(currentUserId: widget.currentUserId);
_meetingsFuture = _apiService.fetchMeetings();
}
// 刷新資料的函數
void _refreshMeetings() {
setState(() {
_meetingsFuture = _apiService.fetchMeetings();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的會議通知'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshMeetings,
),
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回主頁',
onPressed: () => Navigator.pop(context),
),
],
),
body: FutureBuilder<List<Meeting>>(
future: _meetingsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshMeetings, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return MeetingList(meetings: snapshot.data!);
} else {
return const Center(child: Text('目前沒有相關會議通知。'));
}
},
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (MeetingList)
// -----------------------------------------------------------
class MeetingList extends StatelessWidget {
final List<Meeting> meetings;
const MeetingList({required this.meetings, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: meetings.length,
itemBuilder: (BuildContext context, int index) {
final item = meetings[index];
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MeetingDetail(meeting: item),
),
);
},
child: ListTile(
// 左側圖示顯示
leading: Icon(
Icons.calendar_month,
color: item.statusColor,
size: 32,
),
title: Text(
item.meetingTitle,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'地點: ${item.meetingPlace ?? '待定'} | 主持: ${item.bossPersonId ?? 'N/A'}',
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
item.statusText,
style: TextStyle(fontSize: 10, color: item.statusColor),
),
Text(
item.formattedStartDateTime,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
),
),
);
},
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class Meeting {
final int uniqueNo; // uniqueno (Primary Key)
final String meetingTitle; // meeting_title (會議標題)
final String? meetingPlace; // meeting_place (會議地點)
final String? meetingDesc; // meeting_desc (會議說明)
final String? bossPersonId; // boss_personid (主持人 ID)
final String? recPersonId; // rec_personid (記錄人 ID)
final DateTime? startDate; // startdate (開始日期)
final String? startTime; // starttime (開始時間, e.g., '14:00')
final DateTime? endDate; // end_date (結束日期)
final String? endTime; // end_time (結束時間, e.g., '16:00')
final String? resolution; // meeting_resolution (會議決議)
final String? meetingUsers; // meeting_users (參與者名單)
final String? flowFlag; // flowflag (狀態/流程旗標)
Meeting({
required this.uniqueNo,
required this.meetingTitle,
this.meetingPlace,
this.meetingDesc,
this.bossPersonId,
this.recPersonId,
this.startDate,
this.startTime,
this.endDate,
this.endTime,
this.resolution,
this.meetingUsers,
this.flowFlag,
});
// Factory 構造函數:從 API 返回的 JSON (Map) 創建 Meeting 物件
factory Meeting.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic date) {
if (date is String && date.isNotEmpty) {
// 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss 或 YYYY-MM-DD
return DateTime.tryParse(date);
}
return null;
}
return Meeting(
uniqueNo: json['uniqueno'] as int? ?? 0,
meetingTitle: json['meeting_title'] as String? ?? '無標題會議',
meetingPlace: json['meeting_place'] as String?,
meetingDesc: json['meeting_desc'] as String?,
bossPersonId: json['boss_personid'] as String?,
recPersonId: json['rec_personid'] as String?,
startDate: parseDate(json['startdate']),
startTime: json['starttime'] as String?,
endDate: parseDate(json['end_date']),
endTime: json['end_time'] as String?,
resolution: json['meeting_resolution'] as String?,
meetingUsers: json['meeting_users'] as String?,
flowFlag: json['flowflag'] as String?,
);
}
// Helper: 格式化開始日期和時間
String get formattedStartDateTime {
if (startDate == null) return 'N/A';
final datePart = DateFormat('MM/dd').format(startDate!);
final timePart = startTime ?? '';
return '$datePart ${timePart.isNotEmpty ? timePart : ''}'.trim();
}
// Helper: 獲取會議狀態顏色
Color get statusColor {
// 假設 '1' 表示已定案/已批准, '0' 表示審核中, 其他表示草稿/待開始
switch (flowFlag) {
case '1':
return Colors.green; // 已定案
case '0':
return Colors.orange; // 審核中
default:
return Colors.blue; // 待開始
}
}
// Helper: 獲取會議狀態文字
String get statusText {
switch (flowFlag) {
case '1':
return '已定案';
case '0':
return '審核中';
default:
return '待開始';
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
// 通用的佔位畫面
class PlaceholderScreen extends StatelessWidget {
final String title;
const PlaceholderScreen({required this.title, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Text(
'$title 功能正在開發中...',
style: const TextStyle(fontSize: 24, color: Colors.grey),
),
),
);
}
}
// 各功能頁面 (繼承自 PlaceholderScreen,方便未來替換成真實頁面)
class AttendanceScreen extends PlaceholderScreen {
AttendanceScreen({super.key}) : super(title: '員工打卡');
}
class ApprovalScreen extends PlaceholderScreen {
ApprovalScreen({super.key}) : super(title: '待簽核事項');
}
class TodoScreen extends PlaceholderScreen {
TodoScreen({super.key}) : super(title: '待辦事項');
}
class CalendarScreen extends PlaceholderScreen {
CalendarScreen({super.key}) : super(title: '行事曆');
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../auth_manager.dart'; // 確保引入 AuthManager
// 共用 API 常數
const String BASE_IP = "https://api.gex.com.tw:8033";
const String COMMON_API_ENDPOINT = "xapi/v2/eis_demo/orm_api_v2/3/";
const String COMMON_API_URL = "$BASE_IP/$COMMON_API_ENDPOINT";
class GenericApiService {
/// 通用的獲取列表方法
/// [T] : 目標資料模型 (例如 Announcement 或 Todo)
/// [tableName] : 資料庫表名 (API 參數)
/// [pk] : 主鍵欄位名稱 (API 參數)
/// [queryFilter] : 查詢過濾條件 (API 參數)
/// [fromJson] : 將 Map 轉換為物件的工廠方法 (例如 Announcement.fromJson)
Future<List<T>> fetchList<T>({
required String tableName,
required String pk,
required String queryFilter,
required T Function(Map<String, dynamic>) fromJson,
Map<String, String>? additionalParams, // 允許傳入額外參數 (如分頁)
}) async {
// 1. 讀取 Token
String? token = await AuthManager.getToken();
if (token == null) {
print("錯誤:未找到登入 Token");
// 根據需求決定是否拋出錯誤或回傳空陣列
throw Exception('未登入');
}
// 2. 建立 API 參數
final Map<String, String> params = {
"token": token,
"_\$_tableName": tableName,
"_\$_pk": pk,
"_\$_action": "P",
"_\$_query_filter": queryFilter,
};
// 如果有額外參數,合併進去
if (additionalParams != null) {
params.addAll(additionalParams);
}
try {
final response = await http.post(
Uri.parse(COMMON_API_URL),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
if (response.statusCode == 200) {
final Map<String, dynamic> responseData = json.decode(response.body);
// 檢查 code
if (responseData['code'] == 0) {
final dynamic dataObject = responseData['data'];
// 檢查結構是否正確 (data -> items List)
if (dataObject is Map && dataObject['items'] is List) {
List<dynamic> listJson = dataObject['items'];
// 核心魔法:使用傳入的 fromJson 函數將資料轉換為 T 類型
return listJson.map((json) => fromJson(json as Map<String, dynamic>)).toList();
} else {
return []; // 結構不符或無資料
}
} else {
throw Exception(responseData['msg'] ?? 'API 邏輯錯誤');
}
} else {
throw Exception('HTTP 錯誤: ${response.statusCode}');
}
} catch (e) {
print('API 請求錯誤 ($tableName): $e');
throw Exception('無法載入資料: $e');
}
}
}
+157
View File
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import '../auth_manager.dart'; // 引入剛才修正的 AuthManager
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
String _currentUserId = '載入中...';
bool _isNotificationEnabled = true;
@override
void initState() {
super.initState();
_loadUserInfo();
}
// 從本地儲存載入使用者工號
Future<void> _loadUserInfo() async {
final userId = await AuthManager.getUserId();
setState(() {
_currentUserId = userId ?? '未登入';
});
}
// 處理登出邏輯
void _handleLogout() async {
// 顯示確認對話框
bool? confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('確認登出'),
content: const Text('您確定要登出系統嗎?登出後將清除本地緩存資訊。'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('確定', style: TextStyle(color: Colors.red))
),
],
),
);
if (confirm == true) {
await AuthManager.logout();
// 導向登入頁面並清空路由棧
if (mounted) {
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('系統設定'),
centerTitle: true,
),
body: ListView(
children: [
// 區塊一:個人資訊
_buildSectionHeader('個人帳戶'),
ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: const Text('當前使用者'),
subtitle: Text('工號:$_currentUserId'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () { /* 導向個人詳細資料 */ },
),
const Divider(),
// 區塊二:系統設定
_buildSectionHeader('偏好設定'),
SwitchListTile(
secondary: const Icon(Icons.notifications_active_outlined),
title: const Text('推播通知'),
subtitle: const Text('接收即時會議與 Issue 通知'),
value: _isNotificationEnabled,
onChanged: (bool value) {
setState(() {
_isNotificationEnabled = value;
});
},
),
ListTile(
leading: const Icon(Icons.dark_mode_outlined),
title: const Text('深色模式'),
trailing: const Text('跟隨系統'),
onTap: () { /* 實作切換主題邏輯 */ },
),
ListTile(
leading: const Icon(Icons.language),
title: const Text('語系設定'),
trailing: const Text('繁體中文'),
onTap: () { /* 實作切換語言邏輯 */ },
),
const Divider(),
// 區塊三:關於與支援
_buildSectionHeader('其他'),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('版本資訊'),
trailing: const Text('v1.0.2'),
),
ListTile(
leading: const Icon(Icons.help_outline),
title: const Text('使用手冊'),
onTap: () { /* 開啟 PDF 或網頁 */ },
),
const SizedBox(height: 30),
// 登出按鈕
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton.icon(
onPressed: _handleLogout,
icon: const Icon(Icons.logout),
label: const Text('安全登出'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade50,
foregroundColor: Colors.red,
side: BorderSide(color: Colors.red.shade200),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))
),
),
),
const SizedBox(height: 20),
],
),
);
}
// 輔助函式:建立區塊標題
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
);
}
}
+22
View File
@@ -0,0 +1,22 @@
import './todo_model.dart';
import '../services/generic_api_service.dart'; // 引入共用服務
class TodoApiService {
final GenericApiService _apiService = GenericApiService();
final String currentUserId;
TodoApiService({this.currentUserId = 'admin'});
Future<List<Todo>> fetchTodos() async {
return await _apiService.fetchList<Todo>(
tableName: "eip_todolist",
pk: "id",
// 如果 queryFilter 需要動態包含使用者 ID,可以在這裡字串插值
// 假設原程式碼邏輯是 "1^10^id^*^^pmsm02^^"
queryFilter: "1^10^id^*^^pmsm02^^",
fromJson: (json) => Todo.fromJson(json),
// 如果未來需要傳遞其他參數 (如 userID),可以用 additionalParams
// additionalParams: { "userId": currentUserId },
);
}
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import './todo_model.dart';
class TodoDetail extends StatelessWidget {
final Todo todo;
const TodoDetail({required this.todo, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(todo.taskName, overflow: TextOverflow.ellipsis),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 任務標題
Text(
todo.taskName,
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87),
),
const Divider(height: 24.0),
// 任務屬性表格 (簡潔顯示)
_buildAttributeRow(context, '狀態', todo.status ?? 'N/A', todo.statusColor),
_buildAttributeRow(context, '優先級', todo.priority ?? 'N/A', Colors.red),
_buildAttributeRow(context, '截止日期', todo.formattedEndDate, Colors.blue),
_buildAttributeRow(context, '建立者', todo.createdBy ?? 'N/A', Colors.grey),
_buildAttributeRow(context, '分類', todo.className, Colors.purple),
const Divider(height: 32.0),
// 詳細說明
const Text(
'詳細說明:',
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 8),
Text(
todo.description ?? '無詳細說明。',
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54),
textAlign: TextAlign.justify,
),
const SizedBox(height: 40),
// 底部按鈕 (例如:標記完成)
Center(
child: ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已標記任務 "${todo.taskName}" 待實作更新狀態。')),
);
// 實際應用中,這裡會呼叫 API 更新 pbi_status 為 'DONE'
},
icon: const Icon(Icons.check_circle_outline),
label: const Text('標記為已完成'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
backgroundColor: Colors.green,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 18),
),
),
),
],
),
),
);
}
Widget _buildAttributeRow(BuildContext context, String label, String value, Color color) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black),
),
),
Expanded(
child: Text(
value,
style: TextStyle(fontWeight: FontWeight.w600, color: color),
),
),
],
),
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import './todo_api.dart';
import './todo_model.dart';
import './todo_detail.dart'; // 稍後創建
// import './main.dart'; // 確保可以訪問 MainMenu
class TodoManager extends StatefulWidget {
// 實際應用中,這裡應該傳入當前用戶 ID
final String currentUserId;
const TodoManager({this.currentUserId = 'admin', super.key});
@override
State<StatefulWidget> createState() {
return _TodoManagerState();
}
}
class _TodoManagerState extends State<TodoManager> {
late TodoApiService _apiService;
late Future<List<Todo>> _todosFuture;
@override
void initState() {
super.initState();
_apiService = TodoApiService(currentUserId: widget.currentUserId);
// 頁面加載時自動開始獲取資料
_todosFuture = _apiService.fetchTodos();
}
// 刷新資料的函數
void _refreshTodos() {
setState(() {
_todosFuture = _apiService.fetchTodos();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的待辦事項'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshTodos,
),
// 假設這裡使用 Navigator.pop(context) 即可返回 MainMenu
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回主頁',
onPressed: () => Navigator.pop(context),
),
],
),
body: FutureBuilder<List<Todo>>(
future: _todosFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshTodos, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return TodoList(todos: snapshot.data!);
} else {
return const Center(child: Text('目前沒有待辦事項。工作很輕鬆!'));
}
},
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (TodoList)
// -----------------------------------------------------------
class TodoList extends StatelessWidget {
final List<Todo> todos;
const TodoList({required this.todos, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: todos.length,
itemBuilder: (BuildContext context, int index) {
final item = todos[index];
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TodoDetail(todo: item),
),
);
},
child: ListTile(
// 左側狀態指示器
leading: Container(
width: 10,
decoration: BoxDecoration(
color: item.statusColor,
borderRadius: BorderRadius.circular(5),
),
),
title: Text(
item.taskName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'分類: ${item.className} | 優先級: ${item.priority ?? '一般'}',
style: const TextStyle(fontSize: 12),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'截止日',
style: TextStyle(fontSize: 10, color: item.statusColor),
),
Text(
item.formattedEndDate,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
),
),
);
},
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class Todo {
final int id;
final String className; // todolist_class (類別/分類)
final String taskName; // task_name (任務名稱/標題)
final String? description; // task_desc (詳細說明)
final String? priority; // issue_priority (優先級)
final String? status; // pbi_status (狀態)
final DateTime? endDate; // end_date (預計完成日期)
final String? createdBy; // create_user (建立者)
final DateTime? createDate; // create_date (建立日期)
Todo({
required this.id,
required this.className,
required this.taskName,
this.description,
this.priority,
this.status,
this.endDate,
this.createdBy,
this.createDate,
});
// Factory 構造函數:從 API 返回的 JSON (Map) 創建 Todo 物件
factory Todo.fromJson(Map<String, dynamic> json) {
// 輔助函數:安全解析日期字串
DateTime? parseDate(dynamic date) {
if (date is String && date.isNotEmpty) {
// 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss 或 YYYY-MM-DD
return DateTime.tryParse(date);
}
return null;
}
return Todo(
id: json['id'] as int? ?? 0,
className: json['todolist_class'] as String? ?? '未分類',
taskName: json['task_name'] as String? ?? '無任務標題',
description: json['task_desc'] as String?,
priority: json['issue_priority'] as String?,
status: json['pbi_status'] as String?,
endDate: parseDate(json['end_date']),
createdBy: json['create_user'] as String?,
createDate: parseDate(json['create_date']),
);
}
// 格式化日期,用於列表顯示
String get formattedEndDate {
if (endDate == null) return 'N/A';
return DateFormat('yyyy/MM/dd').format(endDate!);
}
// 根據狀態獲取顏色 (例如:已完成/進行中)
Color get statusColor {
switch (status?.toUpperCase()) {
case 'WIP': // Work In Progress
return Colors.blue;
case 'DONE': // Completed
return Colors.green;
case 'HOLD': // On Hold
return Colors.orange;
default:
return Colors.grey;
}
}
}