6238b86dcb
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服 pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验; 登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段, 已记 CONTRACT,screens.mjs 留存根) - ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/ DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态 - 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、 h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、 BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast) - 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后 失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览 - 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/ SelectProductDialog/tabStateProvider - golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打; 修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
91 lines
3.3 KiB
Dart
91 lines
3.3 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../core/auth/auth_state.dart';
|
||
import '../core/config/license_copy.dart';
|
||
import '../providers/license_provider.dart';
|
||
import 'ds/ds_toast.dart';
|
||
|
||
/// 写操作守卫:按「角色只读」和「授权过期」两种受限场景**区分处理**被包裹的写控件
|
||
/// (新增/编辑/删除/审核/提交/结清/导入…):
|
||
///
|
||
/// - **角色只读**(role == 'readonly')→ 隐藏(不显示)。
|
||
/// - **授权过期 >7 天**(phase == 'readonly' | 'locked')→ 显示但置灰禁用,
|
||
/// 点击弹提示说明原因并引导去激活。
|
||
/// - **其它**(normal / grace 宽限期)→ 原样显示且可点(宽限期后端仍允许写)。
|
||
///
|
||
/// 统一入口,避免在各页面散落 `if (!ref.watch(isReadonlyProvider))` 判断。
|
||
/// 后端 `middleware.ReadOnly()` / `LicenseGuard()` 仍会对写请求兜底返回 403,
|
||
/// 本控件只负责「不误导」:只读不显示、过期显示但不可点。
|
||
///
|
||
/// 用法:
|
||
/// ```dart
|
||
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
|
||
/// ```
|
||
/// 列表 children 里可用 collection-if 配合 [isReadonly] 直接剔除分隔符;
|
||
/// 过期置灰仍由内层 [WriteGuard] 负责:
|
||
/// ```dart
|
||
/// if (!WriteGuard.isReadonly(ref)) ...[WriteGuard(child: button), const SizedBox(width: 8)]
|
||
/// ```
|
||
class WriteGuard extends ConsumerWidget {
|
||
final Widget child;
|
||
|
||
/// 只读角色时显示的占位控件,默认完全隐藏(不占位)。
|
||
final Widget placeholder;
|
||
|
||
const WriteGuard({
|
||
super.key,
|
||
required this.child,
|
||
this.placeholder = const SizedBox.shrink(),
|
||
});
|
||
|
||
/// 当前是否只读角色(写按钮应**隐藏**)。供 collection-if / 复合条件直接调用。
|
||
static bool isReadonly(WidgetRef ref) => ref.watch(isReadonlyProvider);
|
||
|
||
/// 当前是否因授权过期 >7 天而应**禁用**写操作(phase readonly/locked)。
|
||
static bool licenseBlocked(WidgetRef ref) {
|
||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||
return lic != null && lic.isReadOnlyPhase;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
// 1) 角色只读:隐藏
|
||
if (ref.watch(isReadonlyProvider)) return placeholder;
|
||
|
||
// 2) 授权过期 >7 天:显示但置灰禁用 + 点击弹提示
|
||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||
if (lic != null && lic.isReadOnlyPhase) {
|
||
return _DisabledByLicense(lic: lic, child: child);
|
||
}
|
||
|
||
// 3) normal / grace:原样可点
|
||
return child;
|
||
}
|
||
}
|
||
|
||
/// 授权过期态的写控件包装:视觉置灰 + 拦截点击并提示。
|
||
class _DisabledByLicense extends StatelessWidget {
|
||
final LicenseInfo lic;
|
||
final Widget child;
|
||
const _DisabledByLicense({required this.lic, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Stack(
|
||
children: [
|
||
// 置灰且不响应子控件自身的点击
|
||
IgnorePointer(child: Opacity(opacity: 0.45, child: child)),
|
||
// 覆盖一层透明手势层:捕获点击弹出原因说明
|
||
Positioned.fill(
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () {
|
||
showDsToast(context, LicenseCopy.writeBlockedToast(lic));
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|