Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c82553594 | |||
| dfa1920dda | |||
| 48e282dd97 |
@@ -46,6 +46,12 @@ jobs:
|
|||||||
run: sh scripts/ci/provision-mac.sh
|
run: sh scripts/ci/provision-mac.sh
|
||||||
|
|
||||||
- name: Compile (Flutter macOS)
|
- name: Compile (Flutter macOS)
|
||||||
|
env:
|
||||||
|
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }}
|
||||||
|
MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_PASSWORD }}
|
||||||
|
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
|
||||||
|
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||||
|
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
|
||||||
run: sh scripts/ci/compile-macos.sh "${{ gitea.ref_name }}"
|
run: sh scripts/ci/compile-macos.sh "${{ gitea.ref_name }}"
|
||||||
|
|
||||||
- name: Upload macos artifacts
|
- name: Upload macos artifacts
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.56] - 2026-06-17
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 库存查询在手机上支持「表格 / 列表」两种视图,可一键切换
|
||||||
|
- 入库单、出库单、库存、财务的「显示字段」选择会被记住,下次进入自动保持
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 库存查询搜索框移到工具栏最前,查找更顺手
|
||||||
|
- 手机端列表页工具栏重新排布,按钮不再拥挤
|
||||||
|
- 「退出登录」统一收到左侧导航栏,界面更清爽
|
||||||
|
- macOS 安装包已签名并通过 Apple 公证,安装与自动更新不再弹安全警告
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 修复入库单 / 出库单「显示字段」勾选后部分列仍不显示的问题
|
||||||
|
|
||||||
## [1.0.55] - 2026-06-17
|
## [1.0.55] - 2026-06-17
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// 列表屏「显示字段」(列显隐)的本地持久化。
|
||||||
|
///
|
||||||
|
/// 每个屏用各自的 [id](如 `inventory_list` / `stock_in_list`)独立存储。
|
||||||
|
/// 存的是「隐藏列的 key 集合」。[load] 返回 null 表示该屏从未保存过,
|
||||||
|
/// 调用方据此回退到按 minWidth 计算的首次默认隐藏集。
|
||||||
|
class ColumnPrefs {
|
||||||
|
static String _key(String id) => 'hidden_cols:$id';
|
||||||
|
|
||||||
|
static Future<Set<String>?> load(String id) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getStringList(_key(id))?.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(String id, Set<String> hidden) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_key(id), hidden.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import '../../providers/finance_provider.dart';
|
|||||||
import '../../widgets/data_table_card.dart';
|
import '../../widgets/data_table_card.dart';
|
||||||
import '../../widgets/mobile_list_card.dart';
|
import '../../widgets/mobile_list_card.dart';
|
||||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||||
|
import '../../core/storage/column_prefs.dart';
|
||||||
import '../../widgets/page_scaffold.dart';
|
import '../../widgets/page_scaffold.dart';
|
||||||
import '../../providers/connectivity_provider.dart';
|
import '../../providers/connectivity_provider.dart';
|
||||||
import '../../core/utils/export_util.dart';
|
import '../../core/utils/export_util.dart';
|
||||||
@@ -53,7 +54,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|||||||
|
|
||||||
Set<String> _filterType = {};
|
Set<String> _filterType = {};
|
||||||
Set<String> _filterPartner = {};
|
Set<String> _filterPartner = {};
|
||||||
Set<String> _hiddenCols = {};
|
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
||||||
|
|
||||||
|
static const _screenId = 'finance';
|
||||||
|
|
||||||
static const _colDefs = [
|
static const _colDefs = [
|
||||||
ColDef('date', '日期', required: true),
|
ColDef('date', '日期', required: true),
|
||||||
@@ -73,6 +76,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_month = '${now.year}-${now.month.toString().padLeft(2, '0')}';
|
_month = '${now.year}-${now.month.toString().padLeft(2, '0')}';
|
||||||
_fetch();
|
_fetch();
|
||||||
|
ColumnPrefs.load(_screenId).then((saved) {
|
||||||
|
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fetch() {
|
void _fetch() {
|
||||||
@@ -230,11 +236,15 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|||||||
..sort();
|
..sort();
|
||||||
|
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
final visibleCols = _colDefs
|
// 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。
|
||||||
.where((c) =>
|
final hidden = _hiddenCols ??
|
||||||
!_hiddenCols.contains(c.key) &&
|
_colDefs
|
||||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
.where((c) => c.minWidth != null && screenWidth < c.minWidth!)
|
||||||
.toList();
|
.map((c) => c.key)
|
||||||
|
.toSet();
|
||||||
|
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
|
||||||
|
final visibleCols =
|
||||||
|
_colDefs.where((c) => !hidden.contains(c.key)).toList();
|
||||||
|
|
||||||
final columns = visibleCols.map((c) {
|
final columns = visibleCols.map((c) {
|
||||||
final label = switch (c.key) {
|
final label = switch (c.key) {
|
||||||
@@ -455,8 +465,11 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ColumnToggleButton(
|
ColumnToggleButton(
|
||||||
columns: _colDefs,
|
columns: _colDefs,
|
||||||
hidden: _hiddenCols,
|
hidden: hidden,
|
||||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
onChanged: (v) {
|
||||||
|
setState(() => _hiddenCols = v);
|
||||||
|
ColumnPrefs.save(_screenId, v);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../providers/inventory_provider.dart';
|
|||||||
import '../../widgets/data_table_card.dart';
|
import '../../widgets/data_table_card.dart';
|
||||||
import '../../widgets/mobile_list_card.dart';
|
import '../../widgets/mobile_list_card.dart';
|
||||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||||
|
import '../../core/storage/column_prefs.dart';
|
||||||
import '../../widgets/page_scaffold.dart';
|
import '../../widgets/page_scaffold.dart';
|
||||||
import '../../core/utils/export_util.dart';
|
import '../../core/utils/export_util.dart';
|
||||||
import '../../core/utils/print_util.dart';
|
import '../../core/utils/print_util.dart';
|
||||||
@@ -37,7 +38,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
Set<String> _filterWarehouse = {};
|
Set<String> _filterWarehouse = {};
|
||||||
Set<String> _filterSpec = {};
|
Set<String> _filterSpec = {};
|
||||||
Set<String> _filterSeries = {};
|
Set<String> _filterSeries = {};
|
||||||
Set<String> _hiddenCols = {};
|
Set<String>? _hiddenCols; // null = 尚未载入本地存档
|
||||||
|
bool _mobileTableView = false; // 移动端:false=卡片列表,true=表格(横向滚动)
|
||||||
|
|
||||||
|
static const _screenId = 'inventory_list';
|
||||||
|
|
||||||
static const _colDefs = [
|
static const _colDefs = [
|
||||||
ColDef('code', '商品编码', required: true),
|
ColDef('code', '商品编码', required: true),
|
||||||
@@ -57,6 +61,14 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
ColumnPrefs.load(_screenId).then((saved) {
|
||||||
|
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
@@ -499,8 +511,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
? items
|
? items
|
||||||
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
|
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
|
||||||
|
|
||||||
|
final hidden = _hiddenCols ?? <String>{};
|
||||||
final visibleCols =
|
final visibleCols =
|
||||||
_colDefs.where((c) => !_hiddenCols.contains(c.key)).toList();
|
_colDefs.where((c) => !hidden.contains(c.key)).toList();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -553,18 +566,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
ref.read(inventoryListProvider.notifier).setPage(p),
|
ref.read(inventoryListProvider.notifier).setPage(p),
|
||||||
onPageSizeChanged: (s) =>
|
onPageSizeChanged: (s) =>
|
||||||
ref.read(inventoryListProvider.notifier).setPageSize(s),
|
ref.read(inventoryListProvider.notifier).setPageSize(s),
|
||||||
toolbar: Row(
|
toolbar: Builder(builder: (ctx) {
|
||||||
children: [
|
final isMobile = ctx.isMobile;
|
||||||
if (!WriteGuard.isReadonly(ref)) ...[
|
void doExport() => exportExcel(
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => context.go('/inventory/check'),
|
|
||||||
icon: const Icon(Icons.fact_check, size: 16),
|
|
||||||
label: const Text('发起盘点'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => exportExcel(
|
|
||||||
filename: '库存查询',
|
filename: '库存查询',
|
||||||
headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'],
|
headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'],
|
||||||
rows: filteredItems.map((item) {
|
rows: filteredItems.map((item) {
|
||||||
@@ -589,38 +593,112 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
status,
|
status,
|
||||||
];
|
];
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final searchField = TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '名称/编码/拼音,回车搜索',
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 16),
|
||||||
|
hintStyle: const TextStyle(fontSize: 12),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.search, size: 16),
|
||||||
|
tooltip: '搜索',
|
||||||
|
onPressed: _triggerSearch,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.download, size: 16),
|
|
||||||
label: const Text('导出'),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
onSubmitted: (_) => _triggerSearch(),
|
||||||
ColumnToggleButton(
|
);
|
||||||
columns: _colDefs,
|
|
||||||
hidden: _hiddenCols,
|
final canCheck = !WriteGuard.isReadonly(ref);
|
||||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
|
||||||
),
|
if (isMobile) {
|
||||||
const Spacer(),
|
// 移动端:搜索独占一行 + 紧凑图标操作行(含 表格/列表 切换)
|
||||||
SizedBox(
|
return Column(
|
||||||
width: 220,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: TextField(
|
children: [
|
||||||
controller: _searchCtrl,
|
searchField,
|
||||||
decoration: InputDecoration(
|
const SizedBox(height: 8),
|
||||||
hintText: '名称/编码/拼音,回车搜索',
|
Row(
|
||||||
prefixIcon: const Icon(Icons.search, size: 16),
|
children: [
|
||||||
hintStyle: const TextStyle(fontSize: 12),
|
if (canCheck)
|
||||||
suffixIcon: IconButton(
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.search, size: 16),
|
onPressed: () => context.go('/inventory/check'),
|
||||||
tooltip: '搜索',
|
icon: const Icon(Icons.fact_check, size: 16),
|
||||||
onPressed: _triggerSearch,
|
label: const Text('盘点'),
|
||||||
),
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 6),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
tooltip: _mobileTableView ? '卡片视图' : '表格视图',
|
||||||
|
icon: Icon(
|
||||||
|
_mobileTableView
|
||||||
|
? Icons.view_agenda_outlined
|
||||||
|
: Icons.table_rows_outlined,
|
||||||
|
size: 20),
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _mobileTableView = !_mobileTableView),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '导出',
|
||||||
|
icon: const Icon(Icons.download, size: 20),
|
||||||
|
onPressed: doExport,
|
||||||
|
),
|
||||||
|
ColumnToggleButton(
|
||||||
|
columns: _colDefs,
|
||||||
|
hidden: hidden,
|
||||||
|
compact: true,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _hiddenCols = v);
|
||||||
|
ColumnPrefs.save(_screenId, v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _triggerSearch(),
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 桌面:搜索置于最前,按钮成组靠左
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 220, child: searchField),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (canCheck) ...[
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => context.go('/inventory/check'),
|
||||||
|
icon: const Icon(Icons.fact_check, size: 16),
|
||||||
|
label: const Text('发起盘点'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: doExport,
|
||||||
|
icon: const Icon(Icons.download, size: 16),
|
||||||
|
label: const Text('导出'),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
],
|
ColumnToggleButton(
|
||||||
),
|
columns: _colDefs,
|
||||||
mobileCards:
|
hidden: hidden,
|
||||||
filteredItems.map(_inventoryCard).toList(),
|
onChanged: (v) {
|
||||||
|
setState(() => _hiddenCols = v);
|
||||||
|
ColumnPrefs.save(_screenId, v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
mobileCards: _mobileTableView
|
||||||
|
? null
|
||||||
|
: filteredItems.map(_inventoryCard).toList(),
|
||||||
columns: visibleCols.map((c) {
|
columns: visibleCols.map((c) {
|
||||||
final label = switch (c.key) {
|
final label = switch (c.key) {
|
||||||
'spec' => FilterableColumnHeader(
|
'spec' => FilterableColumnHeader(
|
||||||
|
|||||||
@@ -79,28 +79,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 退出登录(常驻底部;登出后路由 redirect 自动跳 /login)
|
|
||||||
const Divider(height: 1),
|
|
||||||
SafeArea(
|
|
||||||
top: false,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () =>
|
|
||||||
ref.read(authStateProvider.notifier).logout(),
|
|
||||||
icon: const Icon(Icons.logout, size: 18),
|
|
||||||
label: const Text('退出登录'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppTheme.danger,
|
|
||||||
side: const BorderSide(color: AppTheme.danger),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -289,16 +289,6 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
label: '个人设置',
|
label: '个人设置',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuDivider(height: 1),
|
|
||||||
const PopupMenuItem<String>(
|
|
||||||
value: 'logout',
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
child: _HoverMenuItem(
|
|
||||||
icon: Icons.logout_outlined,
|
|
||||||
label: '退出登录',
|
|
||||||
danger: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -334,6 +324,48 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 退出登录(侧栏底部常驻;统一为左侧导航唯一退出入口)
|
||||||
|
const Divider(height: 1, color: Colors.white24),
|
||||||
|
SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(authStateProvider.notifier)
|
||||||
|
.logout();
|
||||||
|
context.go('/login');
|
||||||
|
},
|
||||||
|
hoverColor: Colors.white.withAlpha(13),
|
||||||
|
splashColor: Colors.white.withAlpha(26),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: _sidebarExpanded ? 16 : 3),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: _sidebarExpanded
|
||||||
|
? MainAxisAlignment.start
|
||||||
|
: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.logout,
|
||||||
|
color: Colors.white60, size: 20),
|
||||||
|
if (_sidebarExpanded) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('退出登录',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -930,12 +962,10 @@ class _InfoRow extends StatelessWidget {
|
|||||||
class _HoverMenuItem extends StatefulWidget {
|
class _HoverMenuItem extends StatefulWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final bool danger;
|
|
||||||
|
|
||||||
const _HoverMenuItem({
|
const _HoverMenuItem({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
this.danger = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -947,12 +977,8 @@ class _HoverMenuItemState extends State<_HoverMenuItem> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = widget.danger
|
const color = Color(0xFF333333);
|
||||||
? const Color(0xFFE53935)
|
const hoverBg = Color(0xFFF0F4FF);
|
||||||
: const Color(0xFF333333);
|
|
||||||
final hoverBg = widget.danger
|
|
||||||
? const Color(0xFFFFF0F0)
|
|
||||||
: const Color(0xFFF0F4FF);
|
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../repositories/stock_in_repository.dart';
|
|||||||
import '../../widgets/data_table_card.dart';
|
import '../../widgets/data_table_card.dart';
|
||||||
import '../../widgets/mobile_list_card.dart';
|
import '../../widgets/mobile_list_card.dart';
|
||||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||||
|
import '../../core/storage/column_prefs.dart';
|
||||||
import '../../widgets/page_scaffold.dart';
|
import '../../widgets/page_scaffold.dart';
|
||||||
import '../../widgets/status_badge.dart';
|
import '../../widgets/status_badge.dart';
|
||||||
import '../../core/utils/export_util.dart';
|
import '../../core/utils/export_util.dart';
|
||||||
@@ -36,7 +37,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
DateTimeRange? _dateRange;
|
DateTimeRange? _dateRange;
|
||||||
Set<String> _filterWarehouse = {};
|
Set<String> _filterWarehouse = {};
|
||||||
Set<String> _filterSupplier = {};
|
Set<String> _filterSupplier = {};
|
||||||
Set<String> _hiddenCols = {};
|
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
||||||
|
|
||||||
|
static const _screenId = 'stock_in_list';
|
||||||
|
|
||||||
static const _colDefs = [
|
static const _colDefs = [
|
||||||
ColDef('order_no', '入库单号', required: true),
|
ColDef('order_no', '入库单号', required: true),
|
||||||
@@ -51,6 +54,14 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
ColDef('actions', '操作', required: true),
|
ColDef('actions', '操作', required: true),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
ColumnPrefs.load(_screenId).then((saved) {
|
||||||
|
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
String? get _startDate => _dateRange != null
|
String? get _startDate => _dateRange != null
|
||||||
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
||||||
: null;
|
: null;
|
||||||
@@ -177,11 +188,15 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
required List<String> supplierOptions,
|
required List<String> supplierOptions,
|
||||||
}) {
|
}) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
final visibleCols = _colDefs
|
// 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。
|
||||||
.where((c) =>
|
final hidden = _hiddenCols ??
|
||||||
!_hiddenCols.contains(c.key) &&
|
_colDefs
|
||||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
.where((c) => c.minWidth != null && screenWidth < c.minWidth!)
|
||||||
.toList();
|
.map((c) => c.key)
|
||||||
|
.toSet();
|
||||||
|
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
|
||||||
|
final visibleCols =
|
||||||
|
_colDefs.where((c) => !hidden.contains(c.key)).toList();
|
||||||
|
|
||||||
final columns = visibleCols.map((c) {
|
final columns = visibleCols.map((c) {
|
||||||
final label = switch (c.key) {
|
final label = switch (c.key) {
|
||||||
@@ -279,64 +294,123 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
ref.read(stockInListProvider.notifier).setPage(p),
|
ref.read(stockInListProvider.notifier).setPage(p),
|
||||||
onPageSizeChanged: (s) =>
|
onPageSizeChanged: (s) =>
|
||||||
ref.read(stockInListProvider.notifier).setPageSize(s),
|
ref.read(stockInListProvider.notifier).setPageSize(s),
|
||||||
toolbar: Row(
|
toolbar: Builder(builder: (ctx) {
|
||||||
children: [
|
final isMobile = ctx.isMobile;
|
||||||
if (showNewButton && !WriteGuard.isReadonly(ref))
|
void doExport() => exportExcel(
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () => context.go('/stock-in/new'),
|
|
||||||
icon: const Icon(Icons.add, size: 16),
|
|
||||||
label: const Text('新建入库审核单'),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (showStatusFilter) ...[
|
|
||||||
_StatusFilterDropdown(
|
|
||||||
value: _statusFilter,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _statusFilter = v ?? '');
|
|
||||||
ref.read(stockInListProvider.notifier).setStatus(v ?? '');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _pickDateRange,
|
|
||||||
icon: const Icon(Icons.date_range, size: 16),
|
|
||||||
label: Text(
|
|
||||||
_dateRange == null ? '选择日期' : '$_startDate ~ $_endDate',
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_dateRange != null) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.clear, size: 16),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _dateRange = null);
|
|
||||||
ref.read(stockInListProvider.notifier).setDateRange(null, null);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => exportExcel(
|
|
||||||
filename: '入库单列表',
|
filename: '入库单列表',
|
||||||
headers: ['单号', '仓库', '供应商', '状态', '日期', '总金额'],
|
headers: ['单号', '仓库', '供应商', '状态', '日期', '总金额'],
|
||||||
rows: orders.map((o) => [
|
rows: orders.map((o) => [
|
||||||
o.orderNo, o.warehouseName ?? '', o.partnerName ?? '',
|
o.orderNo, o.warehouseName ?? '', o.partnerName ?? '',
|
||||||
o.status, o.orderDate, o.totalAmount,
|
o.status, o.orderDate, o.totalAmount,
|
||||||
]).toList(),
|
]).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||||
|
? ElevatedButton.icon(
|
||||||
|
onPressed: () => context.go('/stock-in/new'),
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: Text(isMobile ? '新建' : '新建入库审核单'),
|
||||||
|
style: isMobile
|
||||||
|
? ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 6),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final statusFilter = showStatusFilter
|
||||||
|
? _StatusFilterDropdown(
|
||||||
|
value: _statusFilter,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _statusFilter = v ?? '');
|
||||||
|
ref.read(stockInListProvider.notifier).setStatus(v ?? '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final dateBtn = OutlinedButton.icon(
|
||||||
|
onPressed: _pickDateRange,
|
||||||
|
icon: const Icon(Icons.date_range, size: 16),
|
||||||
|
label: Text(
|
||||||
|
_dateRange == null ? '选择日期' : '$_startDate ~ $_endDate',
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final clearDate = _dateRange != null
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 16),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _dateRange = null);
|
||||||
|
ref
|
||||||
|
.read(stockInListProvider.notifier)
|
||||||
|
.setDateRange(null, null);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (newBtn != null) newBtn,
|
||||||
|
if (statusFilter != null) statusFilter,
|
||||||
|
dateBtn,
|
||||||
|
if (clearDate != null) clearDate,
|
||||||
|
IconButton(
|
||||||
|
tooltip: '导出',
|
||||||
|
icon: const Icon(Icons.download, size: 20),
|
||||||
|
onPressed: doExport,
|
||||||
|
),
|
||||||
|
ColumnToggleButton(
|
||||||
|
columns: _colDefs,
|
||||||
|
hidden: hidden,
|
||||||
|
compact: true,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _hiddenCols = v);
|
||||||
|
ColumnPrefs.save(_screenId, v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (newBtn != null) newBtn,
|
||||||
|
const Spacer(),
|
||||||
|
if (statusFilter != null) ...[
|
||||||
|
statusFilter,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
dateBtn,
|
||||||
|
if (clearDate != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
clearDate,
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: doExport,
|
||||||
|
icon: const Icon(Icons.download, size: 16),
|
||||||
|
label: const Text('导出'),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.download, size: 16),
|
const SizedBox(width: 8),
|
||||||
label: const Text('导出'),
|
ColumnToggleButton(
|
||||||
),
|
columns: _colDefs,
|
||||||
const SizedBox(width: 8),
|
hidden: hidden,
|
||||||
ColumnToggleButton(
|
onChanged: (v) {
|
||||||
columns: _colDefs,
|
setState(() => _hiddenCols = v);
|
||||||
hidden: _hiddenCols,
|
ColumnPrefs.save(_screenId, v);
|
||||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
columns: columns,
|
columns: columns,
|
||||||
rows: rows,
|
rows: rows,
|
||||||
mobileCards: orders.map((o) => _orderCard(context, o)).toList(),
|
mobileCards: orders.map((o) => _orderCard(context, o)).toList(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../repositories/stock_out_repository.dart';
|
|||||||
import '../../widgets/data_table_card.dart';
|
import '../../widgets/data_table_card.dart';
|
||||||
import '../../widgets/mobile_list_card.dart';
|
import '../../widgets/mobile_list_card.dart';
|
||||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||||
|
import '../../core/storage/column_prefs.dart';
|
||||||
import '../../widgets/page_scaffold.dart';
|
import '../../widgets/page_scaffold.dart';
|
||||||
import '../../widgets/status_badge.dart';
|
import '../../widgets/status_badge.dart';
|
||||||
import '../../core/utils/export_util.dart';
|
import '../../core/utils/export_util.dart';
|
||||||
@@ -35,7 +36,17 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
DateTimeRange? _dateRange;
|
DateTimeRange? _dateRange;
|
||||||
Set<String> _filterWarehouse = {};
|
Set<String> _filterWarehouse = {};
|
||||||
Set<String> _filterCustomer = {};
|
Set<String> _filterCustomer = {};
|
||||||
Set<String> _hiddenCols = {};
|
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
||||||
|
|
||||||
|
static const _screenId = 'stock_out_list';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
ColumnPrefs.load(_screenId).then((saved) {
|
||||||
|
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static const _colDefs = [
|
static const _colDefs = [
|
||||||
ColDef('order_no', '出库单号', required: true),
|
ColDef('order_no', '出库单号', required: true),
|
||||||
@@ -177,11 +188,15 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
required List<String> customerOptions,
|
required List<String> customerOptions,
|
||||||
}) {
|
}) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
final visibleCols = _colDefs
|
// 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。
|
||||||
.where((c) =>
|
final hidden = _hiddenCols ??
|
||||||
!_hiddenCols.contains(c.key) &&
|
_colDefs
|
||||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
.where((c) => c.minWidth != null && screenWidth < c.minWidth!)
|
||||||
.toList();
|
.map((c) => c.key)
|
||||||
|
.toSet();
|
||||||
|
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
|
||||||
|
final visibleCols =
|
||||||
|
_colDefs.where((c) => !hidden.contains(c.key)).toList();
|
||||||
|
|
||||||
final columns = visibleCols.map((c) {
|
final columns = visibleCols.map((c) {
|
||||||
final label = switch (c.key) {
|
final label = switch (c.key) {
|
||||||
@@ -285,64 +300,123 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
ref.read(stockOutListProvider.notifier).setPage(p),
|
ref.read(stockOutListProvider.notifier).setPage(p),
|
||||||
onPageSizeChanged: (s) =>
|
onPageSizeChanged: (s) =>
|
||||||
ref.read(stockOutListProvider.notifier).setPageSize(s),
|
ref.read(stockOutListProvider.notifier).setPageSize(s),
|
||||||
toolbar: Row(
|
toolbar: Builder(builder: (ctx) {
|
||||||
children: [
|
final isMobile = ctx.isMobile;
|
||||||
if (showNewButton && !WriteGuard.isReadonly(ref))
|
void doExport() => exportExcel(
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () => context.go('/stock-out/new'),
|
|
||||||
icon: const Icon(Icons.add, size: 16),
|
|
||||||
label: const Text('新建出库审核单'),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (showStatusFilter) ...[
|
|
||||||
_StatusFilterDropdown(
|
|
||||||
value: _statusFilter,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _statusFilter = v ?? '');
|
|
||||||
ref.read(stockOutListProvider.notifier).setStatus(v ?? '');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _pickDateRange,
|
|
||||||
icon: const Icon(Icons.date_range, size: 16),
|
|
||||||
label: Text(
|
|
||||||
_dateRange == null ? '选择日期' : '$_startDate ~ $_endDate',
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_dateRange != null) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.clear, size: 16),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _dateRange = null);
|
|
||||||
ref.read(stockOutListProvider.notifier).setDateRange(null, null);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => exportExcel(
|
|
||||||
filename: '出库单列表',
|
filename: '出库单列表',
|
||||||
headers: ['单号', '仓库', '客户', '状态', '日期', '总金额'],
|
headers: ['单号', '仓库', '客户', '状态', '日期', '总金额'],
|
||||||
rows: orders.map((o) => [
|
rows: orders.map((o) => [
|
||||||
o.orderNo, o.warehouseName ?? '', o.partnerName ?? '',
|
o.orderNo, o.warehouseName ?? '', o.partnerName ?? '',
|
||||||
o.status, o.orderDate, o.totalAmount,
|
o.status, o.orderDate, o.totalAmount,
|
||||||
]).toList(),
|
]).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||||
|
? ElevatedButton.icon(
|
||||||
|
onPressed: () => context.go('/stock-out/new'),
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: Text(isMobile ? '新建' : '新建出库审核单'),
|
||||||
|
style: isMobile
|
||||||
|
? ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 6),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final statusFilter = showStatusFilter
|
||||||
|
? _StatusFilterDropdown(
|
||||||
|
value: _statusFilter,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _statusFilter = v ?? '');
|
||||||
|
ref.read(stockOutListProvider.notifier).setStatus(v ?? '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final dateBtn = OutlinedButton.icon(
|
||||||
|
onPressed: _pickDateRange,
|
||||||
|
icon: const Icon(Icons.date_range, size: 16),
|
||||||
|
label: Text(
|
||||||
|
_dateRange == null ? '选择日期' : '$_startDate ~ $_endDate',
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final clearDate = _dateRange != null
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 16),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _dateRange = null);
|
||||||
|
ref
|
||||||
|
.read(stockOutListProvider.notifier)
|
||||||
|
.setDateRange(null, null);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (newBtn != null) newBtn,
|
||||||
|
if (statusFilter != null) statusFilter,
|
||||||
|
dateBtn,
|
||||||
|
if (clearDate != null) clearDate,
|
||||||
|
IconButton(
|
||||||
|
tooltip: '导出',
|
||||||
|
icon: const Icon(Icons.download, size: 20),
|
||||||
|
onPressed: doExport,
|
||||||
|
),
|
||||||
|
ColumnToggleButton(
|
||||||
|
columns: _colDefs,
|
||||||
|
hidden: hidden,
|
||||||
|
compact: true,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _hiddenCols = v);
|
||||||
|
ColumnPrefs.save(_screenId, v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (newBtn != null) newBtn,
|
||||||
|
const Spacer(),
|
||||||
|
if (statusFilter != null) ...[
|
||||||
|
statusFilter,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
dateBtn,
|
||||||
|
if (clearDate != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
clearDate,
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: doExport,
|
||||||
|
icon: const Icon(Icons.download, size: 16),
|
||||||
|
label: const Text('导出'),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.download, size: 16),
|
const SizedBox(width: 8),
|
||||||
label: const Text('导出'),
|
ColumnToggleButton(
|
||||||
),
|
columns: _colDefs,
|
||||||
const SizedBox(width: 8),
|
hidden: hidden,
|
||||||
ColumnToggleButton(
|
onChanged: (v) {
|
||||||
columns: _colDefs,
|
setState(() => _hiddenCols = v);
|
||||||
hidden: _hiddenCols,
|
ColumnPrefs.save(_screenId, v);
|
||||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
columns: columns,
|
columns: columns,
|
||||||
rows: rows,
|
rows: rows,
|
||||||
mobileCards: orders.map((o) => _orderCard(context, o)).toList(),
|
mobileCards: orders.map((o) => _orderCard(context, o)).toList(),
|
||||||
|
|||||||
@@ -304,15 +304,26 @@ class ColumnToggleButton extends StatelessWidget {
|
|||||||
final Set<String> hidden;
|
final Set<String> hidden;
|
||||||
final ValueChanged<Set<String>> onChanged;
|
final ValueChanged<Set<String>> onChanged;
|
||||||
|
|
||||||
|
/// 紧凑模式:只显示图标(移动端用,省空间)。
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
const ColumnToggleButton({
|
const ColumnToggleButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.columns,
|
required this.columns,
|
||||||
required this.hidden,
|
required this.hidden,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (compact) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: '显示字段',
|
||||||
|
icon: const Icon(Icons.view_column_outlined, size: 20),
|
||||||
|
onPressed: () => _show(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
return OutlinedButton(
|
return OutlinedButton(
|
||||||
onPressed: () => _show(context),
|
onPressed: () => _show(context),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# macOS 分发(Developer ID + 公证)— 一次性配置
|
||||||
|
|
||||||
|
macOS 客户端通过**官网下载 zip + 应用内自动更新**分发(非 Mac App Store)。要让用户双击/自更新重启时不被 Gatekeeper 拦截(「已损坏」「无法验证开发者」),CI 构建出的 `.app` 必须 **用 Developer ID Application 证书签名 + 提交 Apple 公证(Notarization)+ staple 票据**。
|
||||||
|
|
||||||
|
所有凭证通过 Forgejo Secrets 注入,**不入库**。macOS **强制签名+公证**:未配置证书 secret 时 `build-macos` job 会**直接报错、流水线红**,绝不产出未签名包(避免把会被 Gatekeeper 拦截的包发出去)。
|
||||||
|
|
||||||
|
> 与 iOS 的关系:公证复用 iOS 那套 **App Store Connect API key**,无需新建。只需**额外**做一张 macOS 专用的 **Developer ID Application** 证书(iOS 的 Apple Distribution 证书在这里不可用)。
|
||||||
|
|
||||||
|
## 前置
|
||||||
|
|
||||||
|
- Apple Developer Program 账号(与 iOS 同一个,Team ID `BYL4KQHMTN`)。
|
||||||
|
- 已按 `docs/ios-signing.md` 配好 `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` / `APPSTORE_API_KEY_P8_BASE64`(公证直接复用,本文不再重复)。
|
||||||
|
|
||||||
|
## 1. 创建 Developer ID Application 证书
|
||||||
|
|
||||||
|
**为什么不是 Apple Distribution**:Apple Distribution 证书只用于 App Store / TestFlight;官网自分发必须用 **Developer ID Application**,否则公证会失败。
|
||||||
|
|
||||||
|
两种方式任选:
|
||||||
|
|
||||||
|
- **Xcode(推荐,最省事)**:Xcode → Settings → Accounts → 选中团队 → Manage Certificates → 左下 `+` → **Developer ID Application**。生成后证书带私钥落在「钥匙串访问」的「登录」里。
|
||||||
|
- **开发者后台**:Certificates, Identifiers & Profiles → Certificates → `+` → **Developer ID Application** → 用钥匙串「证书助理」生成的 CSR 上传 → 下载 `.cer` 双击导入钥匙串。
|
||||||
|
|
||||||
|
## 2. 导出为 .p12
|
||||||
|
|
||||||
|
「钥匙串访问」→「我的证书」里找到 **Developer ID Application: …(BYL4KQHMTN)**,**连同私钥**一起右键导出为 `.p12`,设一个导出密码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 -i devid.p12 | pbcopy # 复制 base64 到剪贴板
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 在 Forgejo 仓库配置 Secrets
|
||||||
|
|
||||||
|
仓库 → Settings → Actions → Secrets,**新增 2 项**:
|
||||||
|
|
||||||
|
| Secret 名 | 值 |
|
||||||
|
|-----------|-----|
|
||||||
|
| `MACOS_DEVELOPER_ID_CERT_P12_BASE64` | 第 2 步 `.p12` 的 base64 |
|
||||||
|
| `MACOS_DEVELOPER_ID_CERT_PASSWORD` | `.p12` 导出密码 |
|
||||||
|
|
||||||
|
公证用的 `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` / `APPSTORE_API_KEY_P8_BASE64` 已在 iOS 配置时存在,**复用即可**。
|
||||||
|
|
||||||
|
## 4. 验证
|
||||||
|
|
||||||
|
打 `client-v*` tag 触发流水线,确认 `build-macos` job:
|
||||||
|
- 日志出现 `signing + notarizing`(非 `SKIP`);
|
||||||
|
- 出现 `signed + notarized + stapled`;
|
||||||
|
- 在一台**没装过本 app 的干净 Mac** 上双击官网下载的 `jiu-macos-x64.zip` 解出的 `.app`,**不弹** Gatekeeper 警告直接打开;
|
||||||
|
- 断网再双击仍能打开(票据已 staple 进 bundle)。
|
||||||
|
|
||||||
|
本地手动验证已签名包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codesign --verify --deep --strict --verbose=2 Jiu.app # 应无报错
|
||||||
|
xcrun stapler validate Jiu.app # The validate action worked!
|
||||||
|
spctl -a -vvv -t install Jiu.app # source=Notarized Developer ID, accepted
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
- **公证失败**:`xcrun notarytool log <submission-id> --key … --key-id … --issuer …` 看详细原因。最常见三类:
|
||||||
|
1. 没用 `--options runtime`(hardened runtime 未启用);
|
||||||
|
2. 没带 `--timestamp`(安全时间戳缺失);
|
||||||
|
3. 用了 Apple Distribution 而非 **Developer ID Application** 证书。
|
||||||
|
- **`security import` 报私钥缺失**:导出 `.p12` 时没勾选私钥,重新从「我的证书」节点(不是单独的证书条目)导出。
|
||||||
|
- **`spctl` 显示 rejected**:通常是 staple 没成功或公证未通过——先确认 notarytool 返回 `Accepted`。
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
- `scripts/ci/compile-macos.sh`:开头先校验证书/API key secret,缺失立即 `exit 1`(fail-fast,不浪费一次 build);`flutter build macos` 后建临时 keychain 导入 Developer ID 证书 → inside-out `codesign`(hardened runtime + timestamp + Release.entitlements)→ `notarytool submit --wait`(复用 App Store Connect API key)→ `stapler staple` → 重新 `ditto` 打 `dist/jiu-macos-x64.zip`。**强制签名+公证,不产出未签名包。**
|
||||||
|
- `.gitea/workflows/deploy-client.yml` 的 `build-macos` job:通过 `env` 注入上述 secrets。
|
||||||
|
- 自更新链路:`client/lib/core/update/app_updater_io.dart` 下载该 zip → `ditto` 解压 → 替换 `.app` → `open` 重启;因票据已 staple,重启的新 app 离线也通过 Gatekeeper。
|
||||||
@@ -10,6 +10,20 @@ VER="${TAG#client-v}"
|
|||||||
|
|
||||||
echo "==> compile-macos: version=${VER}"
|
echo "==> compile-macos: version=${VER}"
|
||||||
|
|
||||||
|
# macOS 通过官网下载 + 应用内自更新分发,未签名/未公证的包会被 Gatekeeper 拦截,
|
||||||
|
# 因此【强制】签名+公证:缺任一关键 secret 立即报错、流水线红,绝不产出未签名包。
|
||||||
|
# 在跑昂贵的 flutter build 之前先做这一检查,快速失败。详见 docs/macos-signing.md。
|
||||||
|
#
|
||||||
|
# 需要的 CI secrets:
|
||||||
|
# MACOS_DEVELOPER_ID_CERT_P12_BASE64 Developer ID Application 证书 (.p12) 的 base64
|
||||||
|
# MACOS_DEVELOPER_ID_CERT_PASSWORD .p12 导出密码
|
||||||
|
# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64 复用 iOS 的 App Store Connect API key(公证用)
|
||||||
|
if [ -z "${MACOS_DEVELOPER_ID_CERT_P12_BASE64:-}" ] || [ -z "${APPSTORE_API_KEY_P8_BASE64:-}" ]; then
|
||||||
|
echo "ERROR: macOS 签名/公证所需 secret 未配置,拒绝产出未签名包。" >&2
|
||||||
|
echo " 需要 MACOS_DEVELOPER_ID_CERT_P12_BASE64 + APPSTORE_API_KEY_P8_BASE64,详见 docs/macos-signing.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Sync Flutter pubspec version (BSD sed on macOS)
|
# Sync Flutter pubspec version (BSD sed on macOS)
|
||||||
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
|
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
|
||||||
|
|
||||||
@@ -21,11 +35,70 @@ flutter build macos --release \
|
|||||||
"--dart-define=APP_VERSION=v${VER}"
|
"--dart-define=APP_VERSION=v${VER}"
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Package .app bundle into zip
|
APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app"
|
||||||
|
|
||||||
|
# --- 代码签名 + 公证(Developer ID + Notarization)---
|
||||||
|
# secret 已在脚本开头校验过(缺失会 fail-fast),此处必有证书与 API key。
|
||||||
|
echo "==> compile-macos: signing + notarizing"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
KEYCHAIN="$WORK/jiu-mac.keychain-db"
|
||||||
|
cleanup_mac() {
|
||||||
|
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup_mac EXIT
|
||||||
|
|
||||||
|
# 1. 临时 keychain 导入 Developer ID Application 证书
|
||||||
|
KEYCHAIN_PWD="ci-temp-$$"
|
||||||
|
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
|
||||||
|
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
|
||||||
|
echo "${MACOS_DEVELOPER_ID_CERT_P12_BASE64}" | base64 --decode > "$WORK/devid.p12"
|
||||||
|
security import "$WORK/devid.p12" -k "$KEYCHAIN" \
|
||||||
|
-P "${MACOS_DEVELOPER_ID_CERT_PASSWORD:-}" -T /usr/bin/codesign -T /usr/bin/security
|
||||||
|
# 允许 codesign 非交互访问私钥
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
|
||||||
|
# 把临时 keychain 加入搜索列表(保留默认 login keychain)
|
||||||
|
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
|
||||||
|
|
||||||
|
# 解出签名身份 "Developer ID Application: NAME (TEAMID)"
|
||||||
|
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" \
|
||||||
|
| grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)"/\1/')"
|
||||||
|
if [ -z "$IDENTITY" ]; then
|
||||||
|
echo "ERROR: Developer ID Application identity not found in keychain" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> compile-macos: signing identity '${IDENTITY}'"
|
||||||
|
|
||||||
|
# 2. inside-out 签名:先签嵌套 framework/dylib,再签 app(均启用 hardened runtime + 安全时间戳)
|
||||||
|
if [ -d "${APP_SRC}/Contents/Frameworks" ]; then
|
||||||
|
find "${APP_SRC}/Contents/Frameworks" \( -name '*.framework' -o -name '*.dylib' \) -print0 \
|
||||||
|
| xargs -0 -I{} codesign --force --options runtime --timestamp --sign "$IDENTITY" {}
|
||||||
|
fi
|
||||||
|
codesign --force --options runtime --timestamp \
|
||||||
|
--entitlements client/macos/Runner/Release.entitlements \
|
||||||
|
--sign "$IDENTITY" "${APP_SRC}"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "${APP_SRC}"
|
||||||
|
|
||||||
|
# 3. 提交公证(复用 App Store Connect API key),阻塞等待结果
|
||||||
|
ditto -c -k --keepParent "${APP_SRC}" "$WORK/notarize.zip"
|
||||||
|
echo "${APPSTORE_API_KEY_P8_BASE64}" | base64 --decode > "$WORK/AuthKey.p8"
|
||||||
|
xcrun notarytool submit "$WORK/notarize.zip" \
|
||||||
|
--key "$WORK/AuthKey.p8" \
|
||||||
|
--key-id "${APPSTORE_API_KEY_ID}" \
|
||||||
|
--issuer "${APPSTORE_API_ISSUER_ID}" \
|
||||||
|
--wait
|
||||||
|
|
||||||
|
# 4. staple 公证票据进 .app(离线也能通过 Gatekeeper),并核验
|
||||||
|
xcrun stapler staple "${APP_SRC}"
|
||||||
|
xcrun stapler validate "${APP_SRC}"
|
||||||
|
spctl -a -vvv -t install "${APP_SRC}" || true # 期望 source=Notarized Developer ID
|
||||||
|
echo "==> compile-macos: signed + notarized + stapled"
|
||||||
|
|
||||||
|
# Package .app bundle into zip(此时 APP_SRC 已签名+stapled)
|
||||||
# ditto preserves symlinks, Unix permissions, and extended attributes (code-signing).
|
# ditto preserves symlinks, Unix permissions, and extended attributes (code-signing).
|
||||||
# Python's zipfile follows symlinks and drops permissions — do NOT use it for .app bundles.
|
# Python's zipfile follows symlinks and drops permissions — do NOT use it for .app bundles.
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app"
|
|
||||||
ditto -c -k --keepParent "${APP_SRC}" dist/jiu-macos-x64.zip
|
ditto -c -k --keepParent "${APP_SRC}" dist/jiu-macos-x64.zip
|
||||||
echo "Packaged dist/jiu-macos-x64.zip ($(du -sh dist/jiu-macos-x64.zip | cut -f1))"
|
echo "Packaged dist/jiu-macos-x64.zip ($(du -sh dist/jiu-macos-x64.zip | cut -f1))"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user