@omit-design/preset-mobile 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PATTERNS.md +134 -0
- package/README.md +29 -0
- package/catalog.tsx +365 -0
- package/components/OmAppBar.tsx +63 -0
- package/components/OmButton.tsx +41 -0
- package/components/OmCard.tsx +28 -0
- package/components/OmCouponCard.tsx +71 -0
- package/components/OmDialog.tsx +96 -0
- package/components/OmEmptyState.tsx +32 -0
- package/components/OmHeader.tsx +21 -0
- package/components/OmInput.tsx +38 -0
- package/components/OmListRow.tsx +30 -0
- package/components/OmMenuCard.tsx +47 -0
- package/components/OmNumpad.tsx +82 -0
- package/components/OmOrderFooter.tsx +78 -0
- package/components/OmPage.tsx +30 -0
- package/components/OmProductCard.tsx +75 -0
- package/components/OmSearchBar.tsx +51 -0
- package/components/OmSelect.tsx +47 -0
- package/components/OmSettingRow.tsx +95 -0
- package/components/OmSheet.tsx +49 -0
- package/components/OmStatCard.tsx +30 -0
- package/components/OmTabBar.tsx +26 -0
- package/components/OmTag.tsx +28 -0
- package/components/index.ts +30 -0
- package/components/inspect-attrs.ts +34 -0
- package/components/om-app-bar.css +83 -0
- package/components/om-coupon-card.css +107 -0
- package/components/om-dialog.css +81 -0
- package/components/om-empty-state.css +55 -0
- package/components/om-input.css +43 -0
- package/components/om-menu-card.css +68 -0
- package/components/om-numpad.css +49 -0
- package/components/om-order-footer.css +121 -0
- package/components/om-page.css +43 -0
- package/components/om-product-card.css +124 -0
- package/components/om-search-bar.css +39 -0
- package/components/om-select.css +28 -0
- package/components/om-setting-row.css +82 -0
- package/components/om-sheet.css +73 -0
- package/components/om-stat-card.css +40 -0
- package/components/om-tag.css +51 -0
- package/index.ts +14 -0
- package/package.json +48 -0
- package/preset.manifest.ts +62 -0
- package/templates/dashboard.tmpl.tsx +90 -0
- package/templates/detail-view.tmpl.tsx +60 -0
- package/templates/dialog-view.tmpl.tsx +34 -0
- package/templates/form-view.tmpl.tsx +52 -0
- package/templates/list-view.tmpl.tsx +58 -0
- package/templates/sheet-action.tmpl.tsx +52 -0
- package/templates/tab-view.tmpl.tsx +51 -0
- package/templates/welcome-view.tmpl.tsx +38 -0
- package/theme/baseline.ts +32 -0
- package/theme/presets/light.ts +8 -0
- package/theme/variables.css +183 -0
- package/tokens/index.ts +51 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IonIcon } from "@ionic/react";
|
|
3
|
+
import { checkmark } from "ionicons/icons";
|
|
4
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
5
|
+
import "./om-coupon-card.css";
|
|
6
|
+
|
|
7
|
+
interface OmCouponCardProps {
|
|
8
|
+
/** 面额 label,如 "¥50" / "8.5" / "15% OFF" */
|
|
9
|
+
valueLabel: string;
|
|
10
|
+
/** 单位 label,如 "代金券" / "折"。省略则只显示面额 */
|
|
11
|
+
unitLabel?: string;
|
|
12
|
+
title: string;
|
|
13
|
+
/** 次要文本:使用门槛说明 */
|
|
14
|
+
condition?: string;
|
|
15
|
+
/** 有效期文案 */
|
|
16
|
+
expireDate?: string;
|
|
17
|
+
/** 选中态 —— 蓝描边 + 右侧 check 圆 */
|
|
18
|
+
selected?: boolean;
|
|
19
|
+
/** 右侧自定义 slot(替换默认 radio),如 rewards 页把 condition 挪到这里作为 tag */
|
|
20
|
+
trailing?: ReactNode;
|
|
21
|
+
onClick?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 优惠券 / 奖励卡 —— 左侧徽章(面额)+ 中部标题 + 右侧单选圆。
|
|
26
|
+
* 点击整张卡即选中;`selected` 控制高亮。
|
|
27
|
+
*/
|
|
28
|
+
export function OmCouponCard({
|
|
29
|
+
valueLabel,
|
|
30
|
+
unitLabel,
|
|
31
|
+
title,
|
|
32
|
+
condition,
|
|
33
|
+
expireDate,
|
|
34
|
+
selected,
|
|
35
|
+
trailing,
|
|
36
|
+
onClick,
|
|
37
|
+
}: OmCouponCardProps) {
|
|
38
|
+
const interactive = !!onClick;
|
|
39
|
+
const className = `om-coupon${selected ? " pos-coupon--selected" : ""}`;
|
|
40
|
+
|
|
41
|
+
const inner = (
|
|
42
|
+
<>
|
|
43
|
+
<div className="om-coupon__badge">
|
|
44
|
+
<span className="om-coupon__badge-value">{valueLabel}</span>
|
|
45
|
+
{unitLabel && <span className="om-coupon__badge-unit">{unitLabel}</span>}
|
|
46
|
+
</div>
|
|
47
|
+
<div className="om-coupon__body">
|
|
48
|
+
<p className="om-coupon__title">{title}</p>
|
|
49
|
+
{condition && <p className="om-coupon__condition">{condition}</p>}
|
|
50
|
+
{expireDate && <p className="om-coupon__expire">⏳ {expireDate}</p>}
|
|
51
|
+
</div>
|
|
52
|
+
{trailing ?? (
|
|
53
|
+
<div className="om-coupon__radio" aria-hidden>
|
|
54
|
+
{selected && <IonIcon icon={checkmark} />}
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const inspect = inspectAttrs("OmCouponCard", { bg: "background", radius: "md", spacing: "md" });
|
|
61
|
+
|
|
62
|
+
return interactive ? (
|
|
63
|
+
<button type="button" className={className} onClick={onClick} {...inspect}>
|
|
64
|
+
{inner}
|
|
65
|
+
</button>
|
|
66
|
+
) : (
|
|
67
|
+
<div className={className} {...inspect}>
|
|
68
|
+
{inner}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IonIcon } from "@ionic/react";
|
|
3
|
+
import { useNavigate } from "react-router-dom";
|
|
4
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
5
|
+
import { OmButton } from "./OmButton";
|
|
6
|
+
import type { ColorTokenName } from "../tokens";
|
|
7
|
+
import "./om-dialog.css";
|
|
8
|
+
|
|
9
|
+
interface OmDialogProps {
|
|
10
|
+
/** 顶部图标(ionicons 名) */
|
|
11
|
+
icon?: string;
|
|
12
|
+
iconColor?: ColorTokenName;
|
|
13
|
+
title: string;
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
/** 主按钮文案,默认"知道了" */
|
|
16
|
+
confirmText?: string;
|
|
17
|
+
/** 主按钮颜色 */
|
|
18
|
+
confirmColor?: ColorTokenName;
|
|
19
|
+
/** 主按钮跳转目标(确定流向 —— 跟 confirmHref 互斥,设计稿首选这个以保持 URL 语义) */
|
|
20
|
+
confirmHref?: string;
|
|
21
|
+
/** 主按钮回调(与 confirmHref 二选一;回调式适合纯状态弹窗) */
|
|
22
|
+
onConfirm?: () => void;
|
|
23
|
+
/** 取消按钮 —— 传入任何一个(cancelHref / onCancel / cancelText)都会启用 */
|
|
24
|
+
cancelText?: string;
|
|
25
|
+
cancelHref?: string;
|
|
26
|
+
onCancel?: () => void;
|
|
27
|
+
/** 自定义主体 —— 代替 subtitle,塞更复杂内容(比如金额详情) */
|
|
28
|
+
body?: ReactNode;
|
|
29
|
+
/** 覆盖整个 actions 区 —— 少数场景需要 3 个以上按钮 */
|
|
30
|
+
actions?: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 状态对话框 / 确认对话框 —— 全屏 scrim + 居中卡片。
|
|
35
|
+
* 默认 1 按钮;传入 cancelText/cancelHref/onCancel 任一个就切成 2 按钮(左取消右确认)。
|
|
36
|
+
*/
|
|
37
|
+
export function OmDialog({
|
|
38
|
+
icon,
|
|
39
|
+
iconColor = "primary",
|
|
40
|
+
title,
|
|
41
|
+
subtitle,
|
|
42
|
+
confirmText = "知道了",
|
|
43
|
+
confirmColor = "primary",
|
|
44
|
+
confirmHref,
|
|
45
|
+
onConfirm,
|
|
46
|
+
cancelText,
|
|
47
|
+
cancelHref,
|
|
48
|
+
onCancel,
|
|
49
|
+
body,
|
|
50
|
+
actions,
|
|
51
|
+
}: OmDialogProps) {
|
|
52
|
+
const navigate = useNavigate();
|
|
53
|
+
const hasCancel = !!cancelText || !!cancelHref || !!onCancel;
|
|
54
|
+
|
|
55
|
+
const handleConfirm = () => {
|
|
56
|
+
if (onConfirm) return onConfirm();
|
|
57
|
+
if (confirmHref) navigate(confirmHref);
|
|
58
|
+
};
|
|
59
|
+
const handleCancel = () => {
|
|
60
|
+
if (onCancel) return onCancel();
|
|
61
|
+
if (cancelHref) navigate(cancelHref);
|
|
62
|
+
else navigate(-1);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="om-dialog" {...inspectAttrs("OmDialog", { radius: "xl", bg: "background" })}>
|
|
67
|
+
<div className="om-dialog__scrim" aria-hidden="true" />
|
|
68
|
+
<div className="om-dialog__card" role="dialog" aria-labelledby="pos-dialog-title">
|
|
69
|
+
{icon && (
|
|
70
|
+
<div className="om-dialog__icon">
|
|
71
|
+
<IonIcon icon={icon} color={iconColor} />
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
<h2 className="om-dialog__title" id="pos-dialog-title">
|
|
75
|
+
{title}
|
|
76
|
+
</h2>
|
|
77
|
+
{subtitle && <p className="om-dialog__subtitle">{subtitle}</p>}
|
|
78
|
+
{body && <div className="om-dialog__body">{body}</div>}
|
|
79
|
+
<div className={`pos-dialog__actions ${hasCancel ? "pos-dialog__actions--two" : ""}`}>
|
|
80
|
+
{actions ?? (
|
|
81
|
+
<>
|
|
82
|
+
{hasCancel && (
|
|
83
|
+
<OmButton variant="outline" color="medium" onClick={handleCancel}>
|
|
84
|
+
{cancelText ?? "取消"}
|
|
85
|
+
</OmButton>
|
|
86
|
+
)}
|
|
87
|
+
<OmButton color={confirmColor} onClick={handleConfirm}>
|
|
88
|
+
{confirmText}
|
|
89
|
+
</OmButton>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IonIcon } from "@ionic/react";
|
|
3
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
4
|
+
import "./om-empty-state.css";
|
|
5
|
+
|
|
6
|
+
interface OmEmptyStateProps {
|
|
7
|
+
/** 大图标(ionicons) —— 显示在圆形浅色背景内 */
|
|
8
|
+
icon?: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** 主操作 + 次要操作(1~2 个 OmButton 传入) */
|
|
12
|
+
actions?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 空状态 —— 列表 / 搜索 / 会员查无结果。
|
|
17
|
+
* 圆形 icon + 标题 + 描述 + actions。
|
|
18
|
+
*/
|
|
19
|
+
export function OmEmptyState({ icon, title, description, actions }: OmEmptyStateProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="om-empty" {...inspectAttrs("OmEmptyState", { bg: "background", spacing: "xl" })}>
|
|
22
|
+
{icon && (
|
|
23
|
+
<div className="om-empty__icon" aria-hidden>
|
|
24
|
+
<IonIcon icon={icon} color="medium" />
|
|
25
|
+
</div>
|
|
26
|
+
)}
|
|
27
|
+
<h2 className="om-empty__title">{title}</h2>
|
|
28
|
+
{description && <p className="om-empty__desc">{description}</p>}
|
|
29
|
+
{actions && <div className="om-empty__actions">{actions}</div>}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IonButtons, IonHeader, IonTitle, IonToolbar } from "@ionic/react";
|
|
3
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
4
|
+
|
|
5
|
+
interface OmHeaderProps {
|
|
6
|
+
title: string;
|
|
7
|
+
start?: ReactNode;
|
|
8
|
+
end?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function OmHeader({ title, start, end }: OmHeaderProps) {
|
|
12
|
+
return (
|
|
13
|
+
<IonHeader {...inspectAttrs("OmHeader", { bg: "background" })}>
|
|
14
|
+
<IonToolbar>
|
|
15
|
+
{start && <IonButtons slot="start">{start}</IonButtons>}
|
|
16
|
+
<IonTitle>{title}</IonTitle>
|
|
17
|
+
{end && <IonButtons slot="end">{end}</IonButtons>}
|
|
18
|
+
</IonToolbar>
|
|
19
|
+
</IonHeader>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { IonInput, IonItem, IonLabel, IonNote } from "@ionic/react";
|
|
2
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
3
|
+
import "./om-input.css";
|
|
4
|
+
|
|
5
|
+
interface OmInputProps {
|
|
6
|
+
label: string;
|
|
7
|
+
value?: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
type?: "text" | "number" | "tel" | "email" | "password";
|
|
10
|
+
onChange?: (value: string) => void;
|
|
11
|
+
/** 内联错误文案;有值时整项进入错误态(红框 + 红色辅助文本) */
|
|
12
|
+
errorText?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function OmInput({
|
|
16
|
+
label,
|
|
17
|
+
value,
|
|
18
|
+
placeholder,
|
|
19
|
+
type = "text",
|
|
20
|
+
onChange,
|
|
21
|
+
errorText,
|
|
22
|
+
}: OmInputProps) {
|
|
23
|
+
const hasError = !!errorText;
|
|
24
|
+
return (
|
|
25
|
+
<div className={hasError ? "pos-input pos-input--error" : "pos-input"}>
|
|
26
|
+
<IonItem {...inspectAttrs("OmInput", { spacing: "md" })}>
|
|
27
|
+
<IonLabel position="stacked">{label}</IonLabel>
|
|
28
|
+
<IonInput
|
|
29
|
+
type={type}
|
|
30
|
+
value={value}
|
|
31
|
+
placeholder={placeholder}
|
|
32
|
+
onIonInput={(e) => onChange?.(e.detail.value ?? "")}
|
|
33
|
+
/>
|
|
34
|
+
</IonItem>
|
|
35
|
+
{hasError && <IonNote color="danger">{errorText}</IonNote>}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IonItem, IonLabel, IonNote } from "@ionic/react";
|
|
3
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
4
|
+
|
|
5
|
+
interface OmListRowProps {
|
|
6
|
+
title: string;
|
|
7
|
+
detail?: string;
|
|
8
|
+
trailing?: ReactNode;
|
|
9
|
+
leading?: ReactNode;
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
href?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function OmListRow({ title, detail, trailing, leading, onClick, href }: OmListRowProps) {
|
|
15
|
+
return (
|
|
16
|
+
<IonItem
|
|
17
|
+
button={!!onClick || !!href}
|
|
18
|
+
onClick={onClick}
|
|
19
|
+
routerLink={href}
|
|
20
|
+
{...inspectAttrs("OmListRow", { spacing: "lg" })}
|
|
21
|
+
>
|
|
22
|
+
{leading}
|
|
23
|
+
<IonLabel>
|
|
24
|
+
<h2>{title}</h2>
|
|
25
|
+
{detail && <p>{detail}</p>}
|
|
26
|
+
</IonLabel>
|
|
27
|
+
{trailing && <IonNote slot="end">{trailing}</IonNote>}
|
|
28
|
+
</IonItem>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { IonIcon } from "@ionic/react";
|
|
2
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
3
|
+
import "./om-menu-card.css";
|
|
4
|
+
|
|
5
|
+
interface OmMenuCardProps {
|
|
6
|
+
/** ionicons 图标 */
|
|
7
|
+
icon: string;
|
|
8
|
+
label: string;
|
|
9
|
+
/** 右上角 badge(数字 / 短 token) */
|
|
10
|
+
badge?: string | number;
|
|
11
|
+
/** 整卡禁用态(二期 / 灰态) */
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** 点击或路由 */
|
|
14
|
+
href?: string;
|
|
15
|
+
onClick?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 工作台宫格入口卡 —— 左上 icon chip + 下方 label + 右上可选 badge。
|
|
20
|
+
* 设计意图:所有 workstation 主入口视觉统一,disabled 态清晰区分二期。
|
|
21
|
+
*/
|
|
22
|
+
export function OmMenuCard({ icon, label, badge, disabled, href, onClick }: OmMenuCardProps) {
|
|
23
|
+
const className = `om-menu${disabled ? " pos-menu--disabled" : ""}`;
|
|
24
|
+
const inspect = inspectAttrs("OmMenuCard", { bg: "background", radius: "lg", shadow: "sm", spacing: "md" });
|
|
25
|
+
const body = (
|
|
26
|
+
<>
|
|
27
|
+
<div className="om-menu__icon" aria-hidden>
|
|
28
|
+
<IonIcon icon={icon} />
|
|
29
|
+
</div>
|
|
30
|
+
<span className="om-menu__label">{label}</span>
|
|
31
|
+
{typeof badge !== "undefined" && <span className="om-menu__badge">{badge}</span>}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (href && !disabled) {
|
|
36
|
+
return (
|
|
37
|
+
<a className={className} href={href} {...inspect}>
|
|
38
|
+
{body}
|
|
39
|
+
</a>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return (
|
|
43
|
+
<button type="button" className={className} disabled={disabled} onClick={onClick} {...inspect}>
|
|
44
|
+
{body}
|
|
45
|
+
</button>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { IonIcon } from "@ionic/react";
|
|
2
|
+
import { backspaceOutline } from "ionicons/icons";
|
|
3
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
4
|
+
import "./om-numpad.css";
|
|
5
|
+
|
|
6
|
+
interface OmNumpadProps {
|
|
7
|
+
/** 按键回调:数字("0"~"9")、"." 或 "abc" */
|
|
8
|
+
onKey?: (key: string) => void;
|
|
9
|
+
/** 退格 */
|
|
10
|
+
onBackspace?: () => void;
|
|
11
|
+
/** 清空("清空" 按钮) */
|
|
12
|
+
onClear?: () => void;
|
|
13
|
+
/** 左下角:支持 "abc" 切换字母输入(登录密码数字键盘) 或 "." 小数点(金额) */
|
|
14
|
+
leftSlot?: "abc" | "dot" | "none";
|
|
15
|
+
/** 右下角退格(默认 true) */
|
|
16
|
+
showBackspace?: boolean;
|
|
17
|
+
/** 是否显示「清空」按钮替换右下角(会员登录页) */
|
|
18
|
+
clearMode?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KEYS: string[] = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 通用数字键盘(4 列 / 3 行 + 底排)—— 用于:
|
|
25
|
+
* - 登录页 "密码数字输入"(左下 "abc" 可切换字母键盘)
|
|
26
|
+
* - 会员登录 "手机号输入"(左下 "0"、右下 "清空")
|
|
27
|
+
* - 金额输入(左下 ".")
|
|
28
|
+
*/
|
|
29
|
+
export function OmNumpad({
|
|
30
|
+
onKey,
|
|
31
|
+
onBackspace,
|
|
32
|
+
onClear,
|
|
33
|
+
leftSlot = "none",
|
|
34
|
+
showBackspace = true,
|
|
35
|
+
clearMode = false,
|
|
36
|
+
}: OmNumpadProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="om-numpad" {...inspectAttrs("OmNumpad", { bg: "light", radius: "md", spacing: "sm" })}>
|
|
39
|
+
{KEYS.map((k) => (
|
|
40
|
+
<button key={k} className="om-numpad__key" type="button" onClick={() => onKey?.(k)}>
|
|
41
|
+
{k}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
|
|
45
|
+
{/* 左下角 */}
|
|
46
|
+
{leftSlot === "abc" && (
|
|
47
|
+
<button className="om-numpad__key pos-numpad__key--ghost" type="button" onClick={() => onKey?.("abc")}>
|
|
48
|
+
abc
|
|
49
|
+
</button>
|
|
50
|
+
)}
|
|
51
|
+
{leftSlot === "dot" && (
|
|
52
|
+
<button className="om-numpad__key" type="button" onClick={() => onKey?.(".")}>
|
|
53
|
+
.
|
|
54
|
+
</button>
|
|
55
|
+
)}
|
|
56
|
+
{leftSlot === "none" && <span className="om-numpad__spacer" aria-hidden />}
|
|
57
|
+
|
|
58
|
+
{/* 中间 0 */}
|
|
59
|
+
<button className="om-numpad__key" type="button" onClick={() => onKey?.("0")}>
|
|
60
|
+
0
|
|
61
|
+
</button>
|
|
62
|
+
|
|
63
|
+
{/* 右下角 */}
|
|
64
|
+
{clearMode ? (
|
|
65
|
+
<button className="om-numpad__key pos-numpad__key--clear" type="button" onClick={() => onClear?.()}>
|
|
66
|
+
清空
|
|
67
|
+
</button>
|
|
68
|
+
) : showBackspace ? (
|
|
69
|
+
<button
|
|
70
|
+
className="om-numpad__key pos-numpad__key--ghost"
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => onBackspace?.()}
|
|
73
|
+
aria-label="退格"
|
|
74
|
+
>
|
|
75
|
+
<IonIcon icon={backspaceOutline} />
|
|
76
|
+
</button>
|
|
77
|
+
) : (
|
|
78
|
+
<span className="om-numpad__spacer" aria-hidden />
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { IonIcon } from "@ionic/react";
|
|
2
|
+
import { cartOutline, chevronForward } from "ionicons/icons";
|
|
3
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
4
|
+
import "./om-order-footer.css";
|
|
5
|
+
|
|
6
|
+
interface OmOrderFooterProps {
|
|
7
|
+
/** 购物车计数(badge);0 / undefined 不展示 badge */
|
|
8
|
+
cartCount?: number;
|
|
9
|
+
/** 主要金额文案:"¥999.00" / "¥68.00" */
|
|
10
|
+
primaryAmount: string;
|
|
11
|
+
/** 右侧 CTA 文案,如 "结算" / "¥999.00"(稿里两种 pattern) */
|
|
12
|
+
ctaLabel?: string;
|
|
13
|
+
/** 副信息:"-¥124.00 优惠明细 >" */
|
|
14
|
+
discountLabel?: string;
|
|
15
|
+
/** 副信息 tap */
|
|
16
|
+
onDiscountClick?: () => void;
|
|
17
|
+
/** CTA tap */
|
|
18
|
+
onCta?: () => void;
|
|
19
|
+
onCartClick?: () => void;
|
|
20
|
+
/** 控制 CTA 文案与主金额的关系:
|
|
21
|
+
* - "split" :左主金额 + 右 CTA 按钮(如销售主页的 ¥999.00 按钮本身) */
|
|
22
|
+
layout?: "amount-in-cta" | "amount-split";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 销售主页 / 购物车底部栏。
|
|
27
|
+
* 左:购物车图标 + badge + 可选优惠明细行;右:金额 CTA 按钮。
|
|
28
|
+
* 通过 `<OmPage>` 下的 absolute 定位,不跟随滚动。
|
|
29
|
+
*/
|
|
30
|
+
export function OmOrderFooter({
|
|
31
|
+
cartCount,
|
|
32
|
+
primaryAmount,
|
|
33
|
+
ctaLabel,
|
|
34
|
+
discountLabel,
|
|
35
|
+
onDiscountClick,
|
|
36
|
+
onCta,
|
|
37
|
+
onCartClick,
|
|
38
|
+
layout = "amount-in-cta",
|
|
39
|
+
}: OmOrderFooterProps) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="om-order-footer" {...inspectAttrs("OmOrderFooter", { bg: "background", spacing: "md" })}>
|
|
42
|
+
<button
|
|
43
|
+
className="om-order-footer__cart"
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={onCartClick}
|
|
46
|
+
aria-label="购物车"
|
|
47
|
+
>
|
|
48
|
+
<IonIcon icon={cartOutline} />
|
|
49
|
+
{typeof cartCount === "number" && cartCount > 0 && (
|
|
50
|
+
<span className="om-order-footer__badge">{cartCount > 99 ? "99+" : cartCount}</span>
|
|
51
|
+
)}
|
|
52
|
+
</button>
|
|
53
|
+
|
|
54
|
+
{discountLabel && (
|
|
55
|
+
<button className="om-order-footer__discount" type="button" onClick={onDiscountClick}>
|
|
56
|
+
<span className="om-order-footer__discount-amount">{discountLabel}</span>
|
|
57
|
+
<span className="om-order-footer__discount-label">
|
|
58
|
+
优惠明细
|
|
59
|
+
<IonIcon icon={chevronForward} aria-hidden />
|
|
60
|
+
</span>
|
|
61
|
+
</button>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
{layout === "amount-in-cta" ? (
|
|
65
|
+
<button className="om-order-footer__cta" type="button" onClick={onCta}>
|
|
66
|
+
{ctaLabel ?? primaryAmount}
|
|
67
|
+
</button>
|
|
68
|
+
) : (
|
|
69
|
+
<div className="om-order-footer__split">
|
|
70
|
+
<span className="om-order-footer__amount">{primaryAmount}</span>
|
|
71
|
+
<button className="om-order-footer__cta pos-order-footer__cta--compact" type="button" onClick={onCta}>
|
|
72
|
+
{ctaLabel}
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IonContent, IonPage } from "@ionic/react";
|
|
3
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
4
|
+
import "./om-page.css";
|
|
5
|
+
|
|
6
|
+
interface OmPageProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** 顶部固定 header(通常是 `<OmHeader />`)。传入后会作为 IonPage 直接子渲染,
|
|
9
|
+
* 从而与 IonContent 同级 —— header 不参与滚动。 */
|
|
10
|
+
header?: ReactNode;
|
|
11
|
+
/** 主体内边距 token(默认 lg) */
|
|
12
|
+
padding?: "none" | "sm" | "md" | "lg" | "xl";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PADDING_CLASS = {
|
|
16
|
+
none: "pos-page-pad-none",
|
|
17
|
+
sm: "pos-page-pad-sm",
|
|
18
|
+
md: "pos-page-pad-md",
|
|
19
|
+
lg: "pos-page-pad-lg",
|
|
20
|
+
xl: "pos-page-pad-xl",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function OmPage({ children, header, padding = "lg" }: OmPageProps) {
|
|
24
|
+
return (
|
|
25
|
+
<IonPage {...inspectAttrs("OmPage")}>
|
|
26
|
+
{header}
|
|
27
|
+
<IonContent className={PADDING_CLASS[padding]}>{children}</IonContent>
|
|
28
|
+
</IonPage>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { IonIcon } from "@ionic/react";
|
|
2
|
+
import { add } from "ionicons/icons";
|
|
3
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
4
|
+
import "./om-product-card.css";
|
|
5
|
+
|
|
6
|
+
interface OmProductCardProps {
|
|
7
|
+
/** 图片 emoji / 占位文字 */
|
|
8
|
+
emoji?: string;
|
|
9
|
+
name: string;
|
|
10
|
+
sku: string;
|
|
11
|
+
/** 单价(元) */
|
|
12
|
+
price: number;
|
|
13
|
+
/** 单位:瓶/盒/件 */
|
|
14
|
+
unit: string;
|
|
15
|
+
/** 可售库存 */
|
|
16
|
+
stock: number;
|
|
17
|
+
onAdd?: () => void;
|
|
18
|
+
onClick?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 商品卡(销售主页 / 搜索结果)。
|
|
23
|
+
* 左侧 80×80 图,右侧名称 + SKU + 价格 + 库存,右下角 "加购" FAB。
|
|
24
|
+
*/
|
|
25
|
+
export function OmProductCard({
|
|
26
|
+
emoji,
|
|
27
|
+
name,
|
|
28
|
+
sku,
|
|
29
|
+
price,
|
|
30
|
+
unit,
|
|
31
|
+
stock,
|
|
32
|
+
onAdd,
|
|
33
|
+
onClick,
|
|
34
|
+
}: OmProductCardProps) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className="om-product"
|
|
38
|
+
onClick={onClick}
|
|
39
|
+
role={onClick ? "button" : undefined}
|
|
40
|
+
tabIndex={onClick ? 0 : undefined}
|
|
41
|
+
{...inspectAttrs("OmProductCard", { bg: "background", radius: "md", shadow: "sm", spacing: "md" })}
|
|
42
|
+
>
|
|
43
|
+
<div className="om-product__image" aria-hidden>
|
|
44
|
+
{emoji && <span className="om-product__emoji">{emoji}</span>}
|
|
45
|
+
</div>
|
|
46
|
+
<div className="om-product__body">
|
|
47
|
+
<div className="om-product__titles">
|
|
48
|
+
<p className="om-product__name">{name}</p>
|
|
49
|
+
<p className="om-product__sku">{sku}</p>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="om-product__foot">
|
|
52
|
+
<div className="om-product__price-group">
|
|
53
|
+
<span className="om-product__price">
|
|
54
|
+
<span className="om-product__price-sign">¥</span>
|
|
55
|
+
<span className="om-product__price-value">{price.toFixed(2)}</span>
|
|
56
|
+
</span>
|
|
57
|
+
<span className="om-product__unit">/{unit}</span>
|
|
58
|
+
<span className="om-product__stock">库存{stock}</span>
|
|
59
|
+
</div>
|
|
60
|
+
<button
|
|
61
|
+
className="om-product__add"
|
|
62
|
+
type="button"
|
|
63
|
+
aria-label={`加购 ${name}`}
|
|
64
|
+
onClick={(e) => {
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
onAdd?.();
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<IonIcon icon={add} />
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IonIcon } from "@ionic/react";
|
|
3
|
+
import { searchOutline } from "ionicons/icons";
|
|
4
|
+
import { inspectAttrs } from "./inspect-attrs";
|
|
5
|
+
import "./om-search-bar.css";
|
|
6
|
+
|
|
7
|
+
interface OmSearchBarProps {
|
|
8
|
+
value?: string;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
onChange?: (value: string) => void;
|
|
11
|
+
onFocus?: () => void;
|
|
12
|
+
/** 右侧附加元素(如过滤 / 取消按钮) */
|
|
13
|
+
trailing?: ReactNode;
|
|
14
|
+
/** 只读视觉(列表页用来做"跳到搜索页"的入口) */
|
|
15
|
+
readOnly?: boolean;
|
|
16
|
+
onClick?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* POS 搜索输入框 —— 圆角胶囊 + 左放大镜 + 可选右侧 slot。
|
|
21
|
+
* 既作为真实输入(搜索页 focus 态),也作为入口(列表页 readOnly + onClick 跳转)。
|
|
22
|
+
*/
|
|
23
|
+
export function OmSearchBar({
|
|
24
|
+
value,
|
|
25
|
+
placeholder = "搜索",
|
|
26
|
+
onChange,
|
|
27
|
+
onFocus,
|
|
28
|
+
trailing,
|
|
29
|
+
readOnly,
|
|
30
|
+
onClick,
|
|
31
|
+
}: OmSearchBarProps) {
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className="om-search-bar"
|
|
35
|
+
onClick={onClick}
|
|
36
|
+
{...inspectAttrs("OmSearchBar", { bg: "background", radius: "full", spacing: "md" })}
|
|
37
|
+
>
|
|
38
|
+
<IonIcon icon={searchOutline} className="om-search-bar__icon" aria-hidden />
|
|
39
|
+
<input
|
|
40
|
+
className="om-search-bar__input"
|
|
41
|
+
type="search"
|
|
42
|
+
value={value ?? ""}
|
|
43
|
+
placeholder={placeholder}
|
|
44
|
+
readOnly={readOnly}
|
|
45
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
46
|
+
onFocus={onFocus}
|
|
47
|
+
/>
|
|
48
|
+
{trailing && <div className="om-search-bar__trailing">{trailing}</div>}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|