564 lines
18 KiB
Python
564 lines
18 KiB
Python
# coding=utf-8
|
||
"""菜品识别客户端视觉主题(工业工具风)。
|
||
|
||
设计令牌分四层:
|
||
COLORS —— 颜色(含语义色与状态色,全部为浅色底 + 深色字,适配亮色主题)
|
||
FONTS —— 字体阶梯
|
||
RADIUS —— 圆角
|
||
SPACING —— 间距
|
||
|
||
约定:
|
||
- 旧版 key 一律保留(`core/common_classifier.py` 的 BaseClassifierUI 仍在使用),
|
||
新增 key 只做增量扩展,不做重命名。
|
||
- 所有颜色为十六进制字符串,可直接用于 tk / ttk / PIL。
|
||
"""
|
||
from tkinter import ttk
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 颜色
|
||
# ---------------------------------------------------------------------------
|
||
COLORS = {
|
||
# 底色层次:页面 < 侧栏 < 卡片
|
||
"bg": "#F6F7F9",
|
||
"surface": "#FFFFFF",
|
||
"surface_alt": "#FAFBFC",
|
||
"sidebar": "#F2F4F7",
|
||
"header_bg": "#FFFFFF",
|
||
"threshold_bg": "#F7F8FA",
|
||
"overlay": "#F2F4F7",
|
||
|
||
# 品牌主色(结算机绿)
|
||
"primary": "#2EAB6F",
|
||
"primary_dark": "#24965F",
|
||
"primary_soft": "#E8F8EF",
|
||
"primary_border": "#A8E0C0",
|
||
|
||
# 语义色(成功 / 警告 / 危险 / 中性)
|
||
"success": "#2EAB6F",
|
||
"success_soft": "#E8F8EF",
|
||
"success_border": "#A8E0C0",
|
||
"warn": "#FF7D00",
|
||
"warn_soft": "#FFF7E8",
|
||
"warn_border": "#FFCF8B",
|
||
"danger": "#E53935",
|
||
"danger_dark": "#C62828",
|
||
"danger_soft": "#FFEBEE",
|
||
"danger_border": "#FFCDD2",
|
||
"neutral": "#86909C",
|
||
"neutral_soft": "#F2F3F5",
|
||
"sidebar_muted": "#F2F4F7",
|
||
|
||
# 文字层次
|
||
"text": "#1D2129",
|
||
"text_body": "#4E5969",
|
||
"text_sub": "#6B7280",
|
||
"muted": "#86909C",
|
||
"disabled_text": "#A9AEB8",
|
||
"text_inv": "#FFFFFF",
|
||
|
||
# 描边 / 分隔
|
||
"border": "#E5E6EB",
|
||
"border_strong": "#D8DEE6",
|
||
"divider": "#EEF0F3",
|
||
"focus_ring": "#8FD6B0",
|
||
"shadow": "#D8DEE6",
|
||
|
||
# 交互态
|
||
"row_hover": "#E8F8EF",
|
||
"row_selected": "#2EAB6F",
|
||
"disabled_bg": "#C9CDD4",
|
||
|
||
# 预览区(结算机浅色托盘区)
|
||
"preview_bg": "#F0F2F5",
|
||
"preview_mid": "#ECEFF3",
|
||
"preview_hi": "#E2E6EB",
|
||
"preview_fg": "#6B7785",
|
||
"preview_line": "#C9CDD4",
|
||
|
||
# 结算机专用
|
||
"pos_tip_bar": "#2EAB6F",
|
||
"pos_tip_fg": "#FFFFFF",
|
||
"pos_total_bg": "#E53935",
|
||
"pos_total_fg": "#FFFFFF",
|
||
"pos_row_alt": "#F7F8FA",
|
||
"pos_bottom_bar": "#FFFFFF",
|
||
"pos_btn": "#3CB878",
|
||
"pos_btn_dark": "#2EAB6F",
|
||
|
||
# 滚动条
|
||
"scroll_track": "#F2F4F7",
|
||
"scroll_thumb": "#C9CED6",
|
||
"scroll_thumb_hover": "#A9AEB8",
|
||
|
||
# 浮层
|
||
"tooltip_bg": "#2A3342",
|
||
"tooltip_fg": "#F2F3F5",
|
||
|
||
# 徽章实心底
|
||
"chip_ok_bg": "#2EAB6F",
|
||
"chip_bad_bg": "#E53935",
|
||
"chip_warn_bg": "#FF7D00",
|
||
"chip_info_bg": "#2EAB6F",
|
||
|
||
# 别名(历史代码兼容)
|
||
"accent": "#2EAB6F",
|
||
"accent_dark": "#24965F",
|
||
"accent_soft": "#E8F8EF",
|
||
"info": "#2EAB6F",
|
||
"info_soft": "#E8F8EF",
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 字体
|
||
# ---------------------------------------------------------------------------
|
||
_FAMILY = "Microsoft YaHei UI"
|
||
_FAMILY_MONO = "Consolas"
|
||
|
||
FONTS = {
|
||
# 历史 key(勿删)
|
||
"ui": (_FAMILY, 10),
|
||
"ui_bold": (_FAMILY, 10, "bold"),
|
||
"title": (_FAMILY, 20, "bold"),
|
||
"subtitle": (_FAMILY, 10),
|
||
"section": (_FAMILY, 14, "bold"),
|
||
"card_title": (_FAMILY, 14, "bold"),
|
||
"button": (_FAMILY, 10),
|
||
"button_lg": (_FAMILY, 10, "bold"),
|
||
"small": (_FAMILY, 9),
|
||
"result": (_FAMILY, 10),
|
||
"preview_hint": (_FAMILY, 11),
|
||
# 新增阶梯
|
||
"display": (_FAMILY, 22, "bold"),
|
||
"h1": (_FAMILY, 16, "bold"),
|
||
"h2": (_FAMILY, 13, "bold"),
|
||
"h3": (_FAMILY, 11, "bold"),
|
||
"body": (_FAMILY, 10),
|
||
"caption": (_FAMILY, 9),
|
||
"metric": (_FAMILY, 20, "bold"),
|
||
"mono": (_FAMILY_MONO, 9),
|
||
"badge": (_FAMILY, 9),
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 圆角 / 间距
|
||
# ---------------------------------------------------------------------------
|
||
RADIUS = {
|
||
"sm": 6,
|
||
"md": 8,
|
||
"lg": 12,
|
||
"xl": 16,
|
||
"pill": 999,
|
||
}
|
||
|
||
SPACING = {
|
||
"xs": 4,
|
||
"sm": 8,
|
||
"md": 12,
|
||
"lg": 16,
|
||
"xl": 20,
|
||
"xxl": 24,
|
||
}
|
||
|
||
# 常用控件高度,统一视觉节奏
|
||
SIZES = {
|
||
"header_h": 52,
|
||
"sidebar_w": 268,
|
||
"cart_w": 320,
|
||
"button_h": 44,
|
||
"button_sm_h": 28,
|
||
"notice_h": 36,
|
||
"badge_h": 28,
|
||
"preview_min_h": 480,
|
||
"wrap": 980,
|
||
"pos_bottom_h": 64,
|
||
}
|
||
|
||
# 菜品缩略图渐变色板(按菜名关键词 / hash)
|
||
DISH_GRADIENTS = [
|
||
("#FFE08A", "#F7BA1E"), # 土豆黄
|
||
("#FF9A8B", "#F53F3F"), # 鸡肉红
|
||
("#9BE7A3", "#00B42A"), # 青菜绿
|
||
("#8FD3FF", "#2EAB6F"), # 海鲜青绿
|
||
("#E8C4FF", "#8D4EDA"), # 蒸蛋紫
|
||
("#FFD4A3", "#FF7D00"), # 炒肉橙
|
||
("#B7F0E5", "#0FC6C2"), # 清汤青
|
||
("#FFC0D9", "#F5319D"), # 虾仁粉
|
||
]
|
||
|
||
|
||
def dish_gradient_for(name: str):
|
||
n = name or ""
|
||
if any(k in n for k in ("土豆", "薯", "丝")):
|
||
return DISH_GRADIENTS[0]
|
||
if any(k in n for k in ("鸡", "肉", "鸭", "排骨")):
|
||
return DISH_GRADIENTS[1]
|
||
if any(k in n for k in ("青菜", "菜", "芹", "笋", "菇", "瓜")):
|
||
return DISH_GRADIENTS[2]
|
||
if any(k in n for k in ("虾", "鱼", "蟹", "海")):
|
||
return DISH_GRADIENTS[3]
|
||
if any(k in n for k in ("蛋", "蒸")):
|
||
return DISH_GRADIENTS[4]
|
||
return DISH_GRADIENTS[sum(ord(ch) for ch in n) % len(DISH_GRADIENTS)]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 颜色工具
|
||
# ---------------------------------------------------------------------------
|
||
def hex_to_rgb(value):
|
||
v = (value or "#000000").lstrip("#")
|
||
if len(v) == 3:
|
||
v = "".join(ch * 2 for ch in v)
|
||
return tuple(int(v[i:i + 2], 16) for i in (0, 2, 4))
|
||
|
||
|
||
def rgb_to_hex(rgb):
|
||
return "#{:02X}{:02X}{:02X}".format(*(max(0, min(255, int(c))) for c in rgb))
|
||
|
||
|
||
def mix(color_a, color_b, t=0.5):
|
||
"""在两个颜色之间线性插值,t=0 取 a,t=1 取 b。"""
|
||
t = max(0.0, min(1.0, t))
|
||
ra, rb = hex_to_rgb(color_a), hex_to_rgb(color_b)
|
||
return rgb_to_hex(tuple(ra[i] * (1 - t) + rb[i] * t for i in range(3)))
|
||
|
||
|
||
def tone_pair(tone):
|
||
"""tone -> (浅底, 主色) 二元组,供徽章 / 提示条统一取色。"""
|
||
c = COLORS
|
||
return {
|
||
"ok": (c["success_soft"], c["success"]),
|
||
"success": (c["success_soft"], c["success"]),
|
||
"warn": (c["warn_soft"], c["warn"]),
|
||
"err": (c["danger_soft"], c["danger"]),
|
||
"danger": (c["danger_soft"], c["danger"]),
|
||
"busy": (c["primary_soft"], c["primary"]),
|
||
"info": (c["primary_soft"], c["primary"]),
|
||
"mute": (c["neutral_soft"], c["neutral"]),
|
||
"neutral": (c["neutral_soft"], c["neutral"]),
|
||
}.get(tone or "info", (c["primary_soft"], c["primary"]))
|
||
|
||
|
||
def tone_text(tone):
|
||
"""tone -> 深色文字,保证浅底上的对比度。"""
|
||
c = COLORS
|
||
return {
|
||
"ok": c["success"],
|
||
"success": c["success"],
|
||
"warn": c["warn"],
|
||
"err": c["danger"],
|
||
"danger": c["danger"],
|
||
"busy": c["primary"],
|
||
"info": c["primary"],
|
||
"mute": c["text_body"],
|
||
"neutral": c["text_body"],
|
||
}.get(tone or "info", c["primary"])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ttk 主题
|
||
# ---------------------------------------------------------------------------
|
||
def apply_theme(root=None):
|
||
style = ttk.Style()
|
||
try:
|
||
style.theme_use("clam")
|
||
except Exception:
|
||
pass
|
||
|
||
c = COLORS
|
||
f = FONTS
|
||
|
||
style.configure(".", font=f["ui"], background=c["bg"], foreground=c["text_body"])
|
||
style.configure("TFrame", background=c["bg"])
|
||
style.configure("Surface.TFrame", background=c["surface"])
|
||
style.configure("Sidebar.TFrame", background=c["sidebar"])
|
||
style.configure("Raised.TFrame", background=c["surface"], relief="flat")
|
||
|
||
# ---- 标签 ----
|
||
style.configure("TLabel", background=c["bg"], foreground=c["text_body"], font=f["ui"])
|
||
style.configure("Surface.TLabel", background=c["surface"], foreground=c["text_body"], font=f["ui"])
|
||
style.configure("Title.TLabel", background=c["bg"], foreground=c["text"], font=f["section"])
|
||
style.configure("SurfaceTitle.TLabel", background=c["surface"], foreground=c["text"], font=f["card_title"])
|
||
style.configure("Medium.TLabel", background=c["bg"], foreground=c["text_body"], font=f["subtitle"])
|
||
style.configure("Small.TLabel", background=c["bg"], foreground=c["muted"], font=f["small"])
|
||
style.configure("SurfaceSmall.TLabel", background=c["surface"], foreground=c["muted"], font=f["small"])
|
||
style.configure("Muted.TLabel", background=c["surface"], foreground=c["muted"], font=f["small"])
|
||
style.configure("CardTitle.TLabel", background=c["surface"], foreground=c["text"], font=f["card_title"])
|
||
style.configure("CardMeta.TLabel", background=c["surface"], foreground=c["muted"], font=f["small"])
|
||
style.configure("SidebarTitle.TLabel", background=c["sidebar"], foreground=c["text"], font=f["section"])
|
||
style.configure("SidebarHint.TLabel", background=c["sidebar"], foreground=c["muted"], font=f["small"])
|
||
style.configure("Metric.TLabel", background=c["surface"], foreground=c["primary"], font=f["metric"])
|
||
style.configure("Danger.TLabel", background=c["surface"], foreground=c["danger"], font=f["small"])
|
||
|
||
# ---- 兜底按钮(主路径走 Canvas PillButton)----
|
||
style.configure(
|
||
"Primary.TButton",
|
||
font=f["button_lg"],
|
||
background=c["primary"],
|
||
foreground="#FFFFFF",
|
||
bordercolor=c["primary"],
|
||
lightcolor=c["primary"],
|
||
darkcolor=c["primary"],
|
||
focusthickness=0,
|
||
padding=(18, 8),
|
||
)
|
||
style.map(
|
||
"Primary.TButton",
|
||
background=[("active", c["primary_dark"]), ("pressed", c["primary_dark"]), ("disabled", c["disabled_bg"])],
|
||
foreground=[("disabled", "#FFFFFF")],
|
||
)
|
||
style.configure(
|
||
"Secondary.TButton",
|
||
font=f["button"],
|
||
background=c["surface"],
|
||
foreground=c["primary"],
|
||
bordercolor=c["primary_border"],
|
||
lightcolor=c["primary"],
|
||
darkcolor=c["primary"],
|
||
relief="solid",
|
||
borderwidth=1,
|
||
focusthickness=0,
|
||
padding=(16, 8),
|
||
)
|
||
style.map(
|
||
"Secondary.TButton",
|
||
background=[("active", c["primary_soft"]), ("pressed", c["primary_soft"]), ("disabled", c["neutral_soft"])],
|
||
foreground=[("active", c["primary_dark"]), ("disabled", c["disabled_text"])],
|
||
)
|
||
style.configure(
|
||
"Ghost.TButton",
|
||
font=f["button"],
|
||
background=c["neutral_soft"],
|
||
foreground=c["text_body"],
|
||
bordercolor=c["border"],
|
||
focusthickness=0,
|
||
padding=(14, 8),
|
||
)
|
||
style.map(
|
||
"Ghost.TButton",
|
||
background=[("active", c["border"]), ("disabled", c["neutral_soft"])],
|
||
foreground=[("disabled", c["disabled_text"])],
|
||
)
|
||
style.configure(
|
||
"Sidebar.TButton",
|
||
font=f["button"],
|
||
background=c["surface"],
|
||
foreground=c["text_body"],
|
||
bordercolor=c["border"],
|
||
focusthickness=0,
|
||
padding=(10, 8),
|
||
)
|
||
style.map(
|
||
"Sidebar.TButton",
|
||
background=[("active", c["primary_soft"])],
|
||
foreground=[("active", c["primary"])],
|
||
)
|
||
style.configure(
|
||
"Danger.TButton",
|
||
font=f["button"],
|
||
background=c["surface"],
|
||
foreground=c["danger"],
|
||
bordercolor=c["danger_border"],
|
||
focusthickness=0,
|
||
padding=(10, 8),
|
||
)
|
||
style.map(
|
||
"Danger.TButton",
|
||
background=[("active", c["danger"]), ("pressed", c["danger_dark"])],
|
||
foreground=[("active", "#FFFFFF"), ("pressed", "#FFFFFF")],
|
||
)
|
||
style.configure(
|
||
"GhostDanger.TButton",
|
||
font=f["small"],
|
||
background=c["surface"],
|
||
foreground=c["danger"],
|
||
bordercolor=c["surface"],
|
||
focusthickness=0,
|
||
padding=(6, 2),
|
||
)
|
||
style.map(
|
||
"GhostDanger.TButton",
|
||
background=[("active", c["danger_soft"])],
|
||
foreground=[("active", c["danger"])],
|
||
)
|
||
style.configure("Large.TButton", font=f["button"], padding=(12, 8))
|
||
|
||
# ---- 单选 / 复选 ----
|
||
style.configure(
|
||
"TRadiobutton",
|
||
background=c["bg"],
|
||
foreground=c["text_body"],
|
||
font=f["ui"],
|
||
focuscolor=c["bg"],
|
||
indicatorcolor=c["surface"],
|
||
bordercolor=c["border"],
|
||
)
|
||
style.map(
|
||
"TRadiobutton",
|
||
background=[("active", c["bg"])],
|
||
foreground=[("active", c["primary"]), ("disabled", c["disabled_text"])],
|
||
indicatorcolor=[("selected", c["primary"]), ("pressed", c["primary_dark"])],
|
||
)
|
||
style.configure(
|
||
"TCheckbutton",
|
||
background=c["bg"],
|
||
foreground=c["text_body"],
|
||
font=f["ui"],
|
||
focuscolor=c["bg"],
|
||
indicatorcolor=c["surface"],
|
||
bordercolor=c["border"],
|
||
)
|
||
style.map(
|
||
"TCheckbutton",
|
||
background=[("active", c["bg"])],
|
||
foreground=[("active", c["primary"]), ("disabled", c["disabled_text"])],
|
||
indicatorcolor=[("selected", c["primary"])],
|
||
)
|
||
|
||
# ---- 输入控件 ----
|
||
style.configure(
|
||
"Large.TEntry",
|
||
font=f["ui"],
|
||
fieldbackground="#FFFFFF",
|
||
background="#FFFFFF",
|
||
foreground=c["text"],
|
||
bordercolor=c["border"],
|
||
lightcolor=c["focus_ring"],
|
||
darkcolor=c["border"],
|
||
insertcolor=c["text"],
|
||
padding=6,
|
||
)
|
||
style.map(
|
||
"Large.TEntry",
|
||
bordercolor=[("focus", c["primary"])],
|
||
lightcolor=[("focus", c["primary"])],
|
||
)
|
||
style.configure(
|
||
"Large.TSpinbox",
|
||
font=f["ui"],
|
||
fieldbackground="#FFFFFF",
|
||
background="#FFFFFF",
|
||
foreground=c["text"],
|
||
bordercolor=c["border"],
|
||
arrowcolor=c["text_body"],
|
||
arrowsize=13,
|
||
padding=4,
|
||
)
|
||
style.map("Large.TSpinbox", bordercolor=[("focus", c["primary"])])
|
||
style.configure(
|
||
"TCombobox",
|
||
font=f["ui"],
|
||
fieldbackground="#FFFFFF",
|
||
background="#FFFFFF",
|
||
foreground=c["text"],
|
||
bordercolor=c["border"],
|
||
arrowcolor=c["text_body"],
|
||
padding=4,
|
||
)
|
||
style.map(
|
||
"TCombobox",
|
||
fieldbackground=[("readonly", "#FFFFFF"), ("disabled", c["neutral_soft"])],
|
||
foreground=[("disabled", c["disabled_text"])],
|
||
bordercolor=[("focus", c["primary"])],
|
||
)
|
||
try:
|
||
root_option_db = root.option_get if root is not None else None
|
||
if root_option_db is not None:
|
||
root.option_add("*TCombobox*Listbox.background", "#FFFFFF")
|
||
root.option_add("*TCombobox*Listbox.foreground", c["text"])
|
||
root.option_add("*TCombobox*Listbox.selectBackground", c["primary_soft"])
|
||
root.option_add("*TCombobox*Listbox.selectForeground", c["primary"])
|
||
root.option_add("*TCombobox*Listbox.font", f["ui"])
|
||
except Exception:
|
||
pass
|
||
|
||
# ---- 滑块 ----
|
||
style.configure(
|
||
"Threshold.Horizontal.TScale",
|
||
background=c["threshold_bg"],
|
||
troughcolor=c["border"],
|
||
bordercolor=c["threshold_bg"],
|
||
lightcolor=c["primary"],
|
||
darkcolor=c["primary"],
|
||
sliderthickness=14,
|
||
)
|
||
style.configure(
|
||
"Horizontal.TScale",
|
||
background=c["surface"],
|
||
troughcolor=c["border"],
|
||
bordercolor=c["surface"],
|
||
lightcolor=c["primary"],
|
||
darkcolor=c["primary"],
|
||
sliderthickness=14,
|
||
)
|
||
|
||
# ---- 表格 ----
|
||
style.configure(
|
||
"Treeview",
|
||
background=c["surface"],
|
||
fieldbackground=c["surface"],
|
||
foreground=c["text_body"],
|
||
rowheight=30,
|
||
font=f["ui"],
|
||
borderwidth=0,
|
||
relief="flat",
|
||
)
|
||
style.configure(
|
||
"Treeview.Heading",
|
||
background=c["bg"],
|
||
foreground=c["text"],
|
||
font=f["ui_bold"],
|
||
relief="flat",
|
||
padding=(8, 6),
|
||
)
|
||
style.map(
|
||
"Treeview",
|
||
background=[("selected", c["primary_soft"])],
|
||
foreground=[("selected", c["text"])],
|
||
)
|
||
style.map("Treeview.Heading", background=[("active", c["border"])])
|
||
|
||
# ---- 滚动条 ----
|
||
for orient in ("Vertical", "Horizontal"):
|
||
style.configure(
|
||
f"{orient}.TScrollbar",
|
||
background=c["scroll_thumb"],
|
||
troughcolor=c["scroll_track"],
|
||
bordercolor=c["scroll_track"],
|
||
arrowcolor=c["muted"],
|
||
lightcolor=c["scroll_thumb"],
|
||
darkcolor=c["scroll_thumb"],
|
||
relief="flat",
|
||
borderwidth=0,
|
||
arrowsize=12,
|
||
width=10,
|
||
)
|
||
style.map(
|
||
f"{orient}.TScrollbar",
|
||
background=[("active", c["scroll_thumb_hover"]), ("pressed", c["scroll_thumb_hover"])],
|
||
)
|
||
|
||
# ---- 容器类 ----
|
||
style.configure("TLabelframe", background=c["surface"], bordercolor=c["border"], borderwidth=1, relief="solid")
|
||
style.configure(
|
||
"TLabelframe.Label", background=c["surface"], foreground=c["text"], font=f["ui_bold"],
|
||
)
|
||
style.configure("TNotebook", background=c["bg"], borderwidth=0, tabmargins=(0, 4, 0, 0))
|
||
style.configure(
|
||
"TNotebook.Tab", background=c["neutral_soft"], foreground=c["text_body"],
|
||
font=f["ui"], padding=(16, 8), borderwidth=0,
|
||
)
|
||
style.map(
|
||
"TNotebook.Tab",
|
||
background=[("selected", c["surface"])],
|
||
foreground=[("selected", c["primary"])],
|
||
)
|
||
style.configure("TSeparator", background=c["divider"])
|
||
style.configure("TProgressbar", background=c["primary"], troughcolor=c["border"], borderwidth=0)
|
||
|
||
if root is not None:
|
||
try:
|
||
root.configure(bg=c["bg"])
|
||
except Exception:
|
||
pass
|
||
return style
|