引言

在这里插入图片描述

在移动端餐饮经营类应用日益普及的当下,如何用一套代码同时承载"展示、运营、决策"三种业务诉求,成为前端架构师必须面对的命题。本文剖析的案例——「樱町寿司屋」——正是这样一份将品牌美学与工程严谨融合在一起的移动端门店经营看板。它以一家江户前寿司屋的日常经营为业务蓝本,覆盖了寿司菜单、定食套餐、酒水销售、师傅团队、食客评价五大业务版块,并通过统计图表、热力排行、详情弹窗等交互形态,把门店的关键经营数据以可视化、可操作的方式呈现在经营者指尖。

从技术选型角度审视,本项目采用了 ArkUI 声明式开发范式,这是面向全场景多端设备的分布式 UI 开发框架。声明式范式的核心思想是"状态驱动视图":开发者只需描述界面在某状态下的样子,框架负责在状态变化时高效地计算差异并完成最小化刷新。相比传统的命令式编程(手动操作 DOM 节点或控件树),声明式范式大幅降低了状态与视图同步的心智负担,尤其在列表渲染、条件分支、动画切换等高频场景下优势显著。项目大量使用了 @State@Prop@ObjectLink@Observed 等状态管理装饰器,构建出从顶层入口到底层弹窗的完整数据流通链路,这正是声明式范式的精髓所在。

色彩体系是本项目的另一大亮点。设计团队没有采用常见的蓝紫科技风,而是围绕"寿司"这一主题,精心调配了一套以"和食暖调"为主轴的调色板。主色 #C0392B 取自金枪鱼大腹的鲜红,辅色 #3E6B4F 取自山葵与海苔的青绿,点缀色 #D9A441 取自玉子烧的橙黄,背景 #FAF5EC 取自醋饭与木质的米白。整套色彩在视觉上营造出"坐在板前看师傅握寿司"的临场温度。更巧妙的是,色彩并非静态铺陈,而是与业务语义深度绑定:高价菜品用主色红警示注意力,平价菜品用辅色绿传递性价比,警告类信息用橙黄,风险类信息用深红,形成了一套自洽的色彩语义系统。

组件化策略方面,项目遵循"单一职责、数据驱动、组合优先"三大原则。整个应用被拆解为一个入口组件、五个内容页组件、四个弹窗组件、一个标签组件,共计十一个自定义组件。入口组件 SushiApp 充当容器与状态中枢,通过 curTab 状态变量在五个内容页之间做条件路由,通过四个布尔型开关状态控制四个弹窗的显隐。内容页组件通过 @ObjectLink 与全局唯一的 SushiData 实例建立双向绑定,弹窗组件则通过 @Prop 接收单向数据并通过回调函数向上通信。这种"数据下沉、事件上抛"的设计,使得各组件既保持了内部封装,又通过清晰的接口契约实现协作,是中型应用可维护性的关键保障。

在工程考量上,项目还体现了若干值得称道的实践。其一是"纯前端 Mock 数据"策略:所有经营数据(菜品、定食、酒水、师傅、评价)都内置在 @Observed 数据类中,无需后端接口即可跑通完整交互流,便于演示与原型验证。其二是"工具函数收口配色逻辑":不同品类、不同类型、不同标签各自映射到不同主题色,这一映射被抽取为独立函数,避免在视图层散落大量 if-else,提升了复用性与可测试性。其三是"微动效点缀体验":寿司图标点击旋转、樱花图标点击飘落等小动画,用极低成本提升了应用的生命感。本文接下来将对源码逐段拆解,带你完整走读这份看板的实现细节。


一、色彩体系与基础数据契约

在这里插入图片描述

代码段 1:调色板接口定义

interface SushiPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  cardAlt: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  wasabiGreen: string;
  sakuraPink: string;
}

这一段定义了一个名为 SushiPalette 的接口,它是一份"色彩契约",约束了整个应用允许使用的颜色集合。从工程语义看,接口把色彩分为四组:品牌主色族(primary 三阶)、辅助色族(accent 两阶)、背景与卡片色族(bg/cardBg/cardAlt)、文本色族(textPrimary/Secondary/Hint),外加语义色(success/warning/danger)与主题点缀色(wasabiGreen/sakuraPink)。用接口而非直接散用字符串的好处在于:它形成了"白名单"机制,任何组件只能从这份契约里取色,从源头杜绝了随意硬编码十六进制色值导致的视觉碎片化。这是一种典型的"约束优于自由"的设计哲学,在多人协作和长期维护中价值极高。

代码段 2:调色板常量实例

在这里插入图片描述

const COLORS: SushiPalette = {
  primary: '#C0392B',
  primaryLight: '#E08E82',
  primaryDark: '#7B241C',
  accent: '#3E6B4F',
  accentLight: '#A8C6B0',
  bg: '#FAF5EC',
  cardBg: '#FFFFFF',
  cardAlt: '#F5ECDD',
  textPrimary: '#33241C',
  textSecondary: '#8C7A6C',
  textHint: '#C0AF9E',
  border: '#EADCC8',
  line: '#E4D5C0',
  success: '#5E8C61',
  warning: '#D9A441',
  danger: '#C0392B',
  white: '#FFFFFF',
  wasabiGreen: '#7FA65A',
  sakuraPink: '#E8A0A8'
};

这里用 const 声明了一个全局不可变常量 COLORS,作为接口契约的具体实现。注意主色 #C0392B 与 danger 色相同,这是因为寿司屋的"警示红"与"品牌红"在视觉上同源,复用可减少色彩总量。文本色采用三阶棕调(深棕、中棕、浅棕),与暖白背景形成柔和对比,避免了纯黑配纯白那种过于冷硬的数字感。cardAltcardBg 略深一档,用于列表隔行变色,既区分行又不突兀。整套色值都经过明度与饱和度的统一调校,呈现出"和食暖调"的整体气质,这是色彩系统成功的关键。

代码段 3:标签页枚举与导航元数据

在这里插入图片描述

enum SushiTab {
  SUSHI = 0,
  COMBO = 1,
  SAKE = 2,
  CHEF = 3,
  REVIEW = 4
}

interface TabMeta {
  key: string;
  icon: string;
  label: string;
  color: string;
}

const TAB_LIST: TabMeta[] = [
  { key: 'sushi', icon: '🍣', label: '寿司', color: '#C0392B' },
  { key: 'combo', icon: '🍱', label: '定食', color: '#3E6B4F' },
  { key: 'sake', icon: '🍶', label: '酒水', color: '#D9A441' },
  { key: 'chef', icon: '👨‍🍳', label: '师傅', color: '#5E8C61' },
  { key: 'review', icon: '🌸', label: '评价', color: '#E8A0A8' }
];

这段定义了标签页枚举 SushiTab 与导航元数据结构 TabMeta,并用常量数组 TAB_LIST 聚合五个标签的完整描述。枚举用整数 0 到 4 代表五个业务标签页,自解释的语义名称在条件分支中读作 curTab === SushiTab.SUSHI,远比魔法数字 0 更易维护。TAB_LIST 采用"数据驱动渲染"范式:底部导航栏通过 ForEach 遍历此数组动态生成,而非硬编码每一项。每个标签携带独立的 color,使选中态边框色能与该标签的业务主题色呼应,形成"一栏多色"的细腻视觉。用 emoji 充当图标既免去图片资源管理成本,又自带跨平台一致性,是原型阶段的高效选择。

代码段 4:装饰板条序列与寿司/定食数据契约

在这里插入图片描述

const SLAT_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];

interface SushiItem {
  name: string;
  kind: string;
  price: number;
  rating: number;
  orders: number;
  emoji: string;
}

interface ComboItem {
  name: string;
  kind: string;
  price: number;
  calory: number;
  rating: number;
  emoji: string;
}

这里将三个相关的基础定义聚合在一处。SLAT_COL 是长度为 12 的数字数组,承担顶栏装饰条的渲染骨架,将被 ForEach 遍历并通过取模运算决定每根条的高度与配色,模拟日式暖帘的板条纹理。SushiItem 定义单款寿司的数据结构,price 用 number 便于价格区间判断与颜色分级,orders 用 number 支持销量对比。ComboItem 定义定食套餐结构,与寿司相比多了 calory(卡路里)字段、去掉了 orders——这一增一减体现"按业务定结构"原则:定食卖点之一是热量管控,故保留卡路里;而定食不按贯计销量,故去掉日销。每个品类用独立接口保证类型表达的精确性。

代码段 5:酒水、师傅与评价数据契约

在这里插入图片描述

interface SakeItem {
  name: string;
  type: string;
  price: number;
  sales: number;
  degree: number;
  emoji: string;
}

interface ChefItem {
  name: string;
  title: string;
  years: number;
  rating: number;
  orders: number;
  emoji: string;
}

interface ReviewItem {
  name: string;
  score: number;
  date: string;
  content: string;
  tag: string;
  emoji: string;
}

这里定义了酒水、师傅、评价三个条目的数据结构。SakeItemdegree(酒精度数)是酒水区别于其他品类的核心字段,无酒精饮品味数为 0,烧酒可达 25-28 度,使列表能展示"X度 · 已售Y杯"的复合信息。ChefItemyearsorders 两个数量字段分别从"资历"和"产能"两个维度刻画师傅,配合评分形成立体人物画像,职称字段对应日式厨房的科层体系。ReviewItemtag 字段把每条评价提炼为四字短语(手艺精湛、体验极佳等),既便于快速浏览评价主旨,又便于后续做标签聚合与色彩映射。score 用整数配合 "⭐".repeat() 直接生成对应数量的星标。

代码段 6:统计图表与排行榜样板接口

在这里插入图片描述

interface WeekMeta {
  day: string;
  value: number;
}

interface StyleMeta {
  name: string;
  count: number;
  color: string;
}

interface ChefHotMeta {
  name: string;
  orders: number;
  color: string;
}

interface SakeTopMeta {
  name: string;
  sales: number;
  color: string;
}

这四个接口分别服务于两类统计图表与两个排行榜。WeekMeta 用于周营业额柱状图,StyleMeta 用于菜品品类分布条形图,二者将图表数据结构独立定义,使得图表渲染逻辑与原始业务数据解耦。ChefHotMetaSakeTopMeta 结构几乎一致(名称、数量、颜色),仅数量字段语义不同(orders 对 sales),这种"同构异义"的模板化结构使得两个榜单可以复用同一套进度条渲染逻辑。color 字段让每条记录都能携带独立主题色,使榜单在视觉上呈现彩虹般的层次。将榜单数据与列表数据分离定义,是因为榜单强调"排名与对比",而列表强调"详情与操作",业务关注点不同。

代码段 7:周营业额与品类分布静态数据

const WEEK_SALES: WeekMeta[] = [
  { day: '周一', value: 42 },
  { day: '周二', value: 38 },
  { day: '周三', value: 46 },
  { day: '周四', value: 52 },
  { day: '周五', value: 78 },
  { day: '周六', value: 126 },
  { day: '周日', value: 108 }
];

const STYLE_SHARE: StyleMeta[] = [
  { name: '手握寿司', count: 5, color: '#C0392B' },
  { name: '军舰卷', count: 3, color: '#3E6B4F' },
  { name: '刺身拼盘', count: 2, color: '#D9A441' },
  { name: '加州卷', count: 2, color: '#5E8C61' }
];

这两组静态数据分别是周营业额与品类分布的渲染素材。WEEK_SALES 呈现典型餐饮业态曲线:工作日平稳低位(38-52),周五上扬(78),周末冲高(周六 126、周日 108),内嵌了"周末是黄金时段"的业务洞察,经营者据此可做排班备货决策。周六值超过 100,恰好触发渲染逻辑中"Value>=100 用主色"的高亮条件,形成视觉与业务的双重强调。STYLE_SHAREcount 将作为进度条填充比例(count/5),手握寿司 5 款占满整条。每条记录的 color 与品类主题色一致,使得分布图本身就是一份色彩化的品类概览,是"数据即设计"的做法。

代码段 8:师傅热度榜与酒水销量榜静态数据

const CHEF_HOT: ChefHotMeta[] = [
  { name: '藤原师傅', orders: 320, color: '#C0392B' },
  { name: '小林师傅', orders: 286, color: '#3E6B4F' },
  { name: '佐藤师傅', orders: 254, color: '#D9A441' },
  { name: '山田师傅', orders: 228, color: '#5E8C61' },
  { name: '高桥师傅', orders: 196, color: '#E8A0A8' },
  { name: '中村师傅', orders: 168, color: '#A8C6B0' }
];

const SAKE_TOP: SakeTopMeta[] = [
  { name: '清酒·菊正宗', sales: 520, color: '#C0392B' },
  { name: '梅酒·纪州', sales: 460, color: '#3E6B4F' },
  { name: '烧酒·麦烧', sales: 390, color: '#D9A441' },
  { name: '柚子酒', sales: 340, color: '#5E8C61' },
  { name: '抹茶拿铁', sales: 310, color: '#7FA65A' },
  { name: '朝日生啤', sales: 280, color: '#E8A0A8' }
];

这两组数据分别按接单量与销量降序排列。师傅榜以 340 为基准(orders/340),保证榜首藤原 320 单的条接近满格而末位中村仍有可见长度;酒水榜以 560 为基准。每组六条记录配独立色,使六行进度条呈现从红到绿的渐变色谱,兼具信息区分与美学韵律。这种"用色彩梯度暗示排名梯度"的设计,让经营者在扫视间即可捕捉重点。值得关注的是无酒精的抹茶拿铁也进入了酒水榜前六,说明该店客群并非清一色酒客,饮品策略需兼顾不饮酒者。这类静态榜单把"哪些酒水最赚钱、哪位师傅最热门"的决策信息前置到看板首页,体现了经营看板"用数据辅助决策"的核心价值。


二、可观察数据模型与配色工具函数

代码段 9:可观察数据类——寿司与定食列表

@Observed
export class SushiData {
  sushes: SushiItem[] = [
    { name: '三文鱼手握', kind: '手握', price: 28, rating: 9.6, orders: 420, emoji: '🐟' },
    { name: '金枪鱼大腹', kind: '手握', price: 68, rating: 9.8, orders: 180, emoji: '🍣' },
    { name: '鳗鱼手握', kind: '手握', price: 38, rating: 9.5, orders: 360, emoji: '🫧' },
    { name: '玉子烧手握', kind: '手握', price: 12, rating: 9.0, orders: 520, emoji: '🍳' },
    { name: '甜虾军舰', kind: '军舰', price: 22, rating: 9.2, orders: 300, emoji: '🦐' },
    { name: '海胆军舰', kind: '军舰', price: 58, rating: 9.7, orders: 150, emoji: '⭐' },
    { name: '鱼籽军舰', kind: '军舰', price: 32, rating: 9.3, orders: 240, emoji: '🔴' },
    { name: '三文鱼刺身', kind: '刺身', price: 78, rating: 9.4, orders: 260, emoji: '🍽️' },
    { name: '北极贝刺身', kind: '刺身', price: 48, rating: 9.1, orders: 190, emoji: '🦪' },
    { name: '加州卷', kind: '卷物', price: 26, rating: 8.9, orders: 480, emoji: '🥑' },
    { name: '天妇罗卷', kind: '卷物', price: 32, rating: 9.0, orders: 220, emoji: '🍤' },
    { name: '樱花卷', kind: '卷物', price: 36, rating: 9.3, orders: 310, emoji: '🌸' }
  ];

这是数据模型的核心。@Observed 装饰器把这个类标记为"可观察对象",框架会深度代理其属性,使得任何子组件通过 @ObjectLink 引用该实例时,实例内部数组或对象的变化都能被自动追踪并触发刷新。这是声明式 UI 处理"跨组件共享可变状态"的关键机制——相比 @State 只能在组件内单向向下传递,@Observed+@ObjectLink 组合支持任意层级的双向响应。sushes 数组内置 12 款寿司,覆盖手握、军舰、刺身、卷物四大品类,价格从 12 元的玉子烧到 78 元的三文鱼刺身,跨度设计便于渲染时做价格分级的色彩判断。rating 用小数支持 9.6、9.8 等精细评分展示。

代码段 10:可观察数据类——定食列表

  combos: ComboItem[] = [
    { name: '招牌寿司定食', kind: '午市', price: 68, calory: 620, rating: 9.5, emoji: '🍱' },
    { name: '鳗鱼饭定食', kind: '招牌', price: 78, calory: 680, rating: 9.6, emoji: '🍚' },
    { name: '刺身定食', kind: '午市', price: 88, calory: 540, rating: 9.4, emoji: '🐟' },
    { name: '天妇罗定食', kind: '午市', price: 72, calory: 700, rating: 9.1, emoji: '🍤' },
    { name: '亲子丼定食', kind: '经典', price: 48, calory: 590, rating: 8.9, emoji: '🥚' },
    { name: '牛丼定食', kind: '经典', price: 52, calory: 640, rating: 9.0, emoji: '🥩' },
    { name: '寿喜烧定食', kind: '晚市', price: 98, calory: 780, rating: 9.3, emoji: '🍲' },
    { name: '拉面定食', kind: '晚市', price: 58, calory: 660, rating: 8.8, emoji: '🍜' },
    { name: '烤物定食', kind: '晚市', price: 82, calory: 720, rating: 9.2, emoji: '🍢' },
    { name: '儿童寿司餐', kind: '儿童', price: 38, calory: 480, rating: 9.0, emoji: '🧒' },
    { name: '怀石迷你套餐', kind: '套餐', price: 168, calory: 850, rating: 9.7, emoji: '🎎' },
    { name: '双人刺身船', kind: '套餐', price: 198, calory: 980, rating: 9.6, emoji: '⛵' }
  ];

combos 数组内置 12 款定食,按"午市/招牌/经典/晚市/儿童/套餐"六类组织,对应一天不同时段与场景。价格从 38 元的儿童餐到 198 元的双人刺身船,跨度更大,渲染时以 100 元为界做颜色分级(>=100 用 warning 橙,否则用 primary 红)。卡路里字段从 480 到 980,为关注热量的食客提供参考。把定食数据与寿司数据并列放在同一个 SushiData 类中,是因为它们共享同一门店上下文,集中管理便于在多页面间同步状态——如新增寿司后切到定食页仍可见同一实例的最新数据,这是可观察对象跨页面一致性的价值体现。

代码段 11:可观察数据类——酒水与师傅列表

  sakes: SakeItem[] = [
    { name: '清酒·菊正宗', type: '清酒', price: 88, sales: 520, degree: 15, emoji: '🍶' },
    { name: '梅酒·纪州', type: '果酒', price: 68, sales: 460, degree: 12, emoji: '🍑' },
    { name: '烧酒·麦烧', type: '烧酒', price: 58, sales: 390, degree: 25, emoji: '🥃' },
    { name: '柚子酒', type: '果酒', price: 72, sales: 340, degree: 10, emoji: '🍊' },
    { name: '抹茶拿铁', type: '无酒精', price: 28, sales: 310, degree: 0, emoji: '🍵' },
    { name: '朝日生啤', type: '啤酒', price: 32, sales: 280, degree: 5, emoji: '🍺' },
    { name: '大吟酿·獭祭', type: '清酒', price: 268, sales: 120, degree: 16, emoji: '💎' },
    { name: '桂花酒', type: '果酒', price: 48, sales: 210, degree: 8, emoji: '🌼' },
    { name: '乌龙茶', type: '无酒精', price: 18, sales: 380, degree: 0, emoji: '🫖' },
    { name: '麒麟一番榨', type: '啤酒', price: 30, sales: 240, degree: 5, emoji: '🍻' },
    { name: '烧酒·芋烧', type: '烧酒', price: 62, sales: 160, degree: 28, emoji: '🍠' },
    { name: '气泡清酒', type: '清酒', price: 98, sales: 130, degree: 6, emoji: '🫧' }
  ];

  chefs: ChefItem[] = [
    { name: '藤原师傅', title: '料理长', years: 28, rating: 9.8, orders: 320, emoji: '🎩' },
    { name: '小林师傅', title: '副料理长', years: 18, rating: 9.5, orders: 286, emoji: '🧑‍🍳' },
    { name: '佐藤师傅', title: '寿司师傅', years: 15, rating: 9.3, orders: 254, emoji: '🍣' },
    { name: '山田师傅', title: '烤物师傅', years: 12, rating: 9.1, orders: 228, emoji: '🍢' },
    { name: '高桥师傅', title: '天妇罗师傅', years: 10, rating: 9.0, orders: 196, emoji: '🍤' },
    { name: '中村师傅', title: '刺身师傅', years: 14, rating: 9.2, orders: 168, emoji: '🔪' },
    { name: '铃木师傅', title: '甜品师傅', years: 8, rating: 8.8, orders: 148, emoji: '🍰' },
    { name: '伊藤师傅', title: '酒水师', years: 9, rating: 8.7, orders: 132, emoji: '🍶' },
    { name: '渡边师傅', title: '汤品师傅', years: 11, rating: 8.9, orders: 120, emoji: '🍲' },
    { name: '长谷川', title: '学徒', years: 3, rating: 8.2, orders: 86, emoji: '👦' },
    { name: '木村师傅', title: '寿司师傅', years: 16, rating: 9.4, orders: 210, emoji: '🐟' },
    { name: '冈田师傅', title: '拉面师傅', years: 13, rating: 9.0, orders: 176, emoji: '🍜' }
  ];

sakes 数组内置 12 款酒水,度数从 0 到 28 跨度极大,渲染时展示"X度"帮助判断烈度;价格>=100 用 warning 橙(如獭祭 268)。酒水列表支持"下架"操作,体现"破坏性操作需二次确认"的交互安全规范。chefs 数组内置 12 位师傅,从料理长藤原(28 年、9.8 分、320 单)到学徒长谷川(3 年、8.2 分、86 单),呈现完整厨房科层体系。渲染时评分以 9.5 为界做颜色分级(>=9.5 用 primary 红,否则 warning 橙),使顶尖师傅在列表中视觉突出。每位师傅右侧有"眼睛"图标触发简介弹窗,是"列表-详情"经典交互在人员管理上的应用。

代码段 12:可观察数据类——评价列表与类闭合

  reviews: ReviewItem[] = [
    { name: '寿司迷', score: 5, date: '08-15', content: '金枪鱼大腹入口即化,藤原师傅的手法太绝了!', tag: '手艺精湛', emoji: '🍣' },
    { name: '老饕', score: 5, date: '08-14', content: '怀石迷你套餐从头到尾都是享受,摆盘像艺术品。', tag: '体验极佳', emoji: '🎎' },
    { name: '上班族', score: 4, date: '08-13', content: '午市定食性价比高,鳗鱼饭分量足,出餐快。', tag: '价格实惠', emoji: '🍱' },
    { name: '抹茶控', score: 5, date: '08-12', content: '抹茶拿铁不甜腻,配刺身刚刚好。', tag: '饮品出色', emoji: '🍵' },
    { name: '情侣', score: 5, date: '08-11', content: '双人刺身船很丰盛,樱花卷颜值满分,约会首选!', tag: '氛围满分', emoji: '⛵' },
    { name: '亲子家庭', score: 4, date: '08-10', content: '儿童餐有小飞机造型,孩子吃得很开心。', tag: '亲子友好', emoji: '🧒' },
    { name: '深夜食客', score: 4, date: '08-09', content: '晚市的寿喜烧暖心暖胃,汤头很鲜。', tag: '味道不错', emoji: '🍲' },
    { name: '细节控', score: 3, date: '08-08', content: '周五晚高峰等位太久,翻台有点慢。', tag: '排队较长', emoji: '⏳' },
    { name: '清酒爱好者', score: 5, date: '08-07', content: '獭祭大吟酿配刺身,风味层次太丰富了。', tag: '酒水专业', emoji: '💎' },
    { name: '第一次来', score: 5, date: '08-06', content: '服务员很耐心介绍菜品,体验超出预期。', tag: '服务贴心', emoji: '🙇' },
    { name: '回头客', score: 4, date: '08-05', content: '每次来都点加州卷,稳定发挥从不翻车。', tag: '味道不错', emoji: '🥑' },
    { name: '摄影党', score: 5, date: '08-04', content: '樱花季限定卷太好拍了,出片率极高。', tag: '颜值在线', emoji: '🌸' }
  ];
}

reviews 数组内置 12 条评价,评分从 3 到 5 分,日期从 08-04 到 08-15 倒序排列,模拟最新优先展示。每条评价的 tag 覆盖手艺、体验、价格、饮品、氛围、亲子、排队、酒水、服务、颜值等多维度,便于经营者快速扫描口碑画像。值得注意的是有一条 3 分差评(排队较长),保留了真实性——纯好评反而失真。content 字段是自然语言正文,配合星标与标签形成"概览+详情"的评价呈现。类定义在此闭合,整个 SushiData 成为应用的唯一数据中枢,所有内容页通过 @ObjectLink 共享同一实例,保证了跨页面数据一致性。

代码段 13:寿司品类与酒水类型配色函数

function getKindColor(kind: string): string {
  if (kind === '手握' || kind === '军舰') {
    return '#C0392B';
  } else if (kind === '刺身') {
    return '#3E6B4F';
  }
  return '#D9A441';
}

function getSakeTypeColor(type: string): string {
  if (type === '清酒' || type === '烧酒') {
    return '#C0392B';
  } else if (type === '果酒') {
    return '#D9A441';
  } else if (type === '无酒精') {
    return '#5E8C61';
  }
  return '#3E6B4F';
}

这两个工具函数分别把寿司品类与酒水类型映射为主题色。getKindColor 把手握与军舰归为红色(鱼肉鲜红),刺身归为绿(自然生鲜),卷物归为橙黄(温和熟食)。getSakeTypeColor 把清酒与烧酒归为红(烈酒警示),果酒归为橙黄(甜美),无酒精归为绿(安全清新),啤酒归为辅色绿。这套映射服务于"用色彩暗示属性"的视觉策略:红色提醒这是含酒精烈饮或鲜鱼生食,绿色暗示可以放心畅饮或自然原味。把映射逻辑收口到函数,使得列表项的图标背景色、标签色、边框色都调用同一函数获取,保证了一致性。若未来新增品类或调整配色,只需改这一处,是"单一数据源"的工程价值。

代码段 14:评价标签与定食品类配色函数

function getTagColor(tag: string): string {
  if (tag === '手艺精湛' || tag === '体验极佳' || tag === '味道不错') {
    return '#C0392B';
  } else if (tag === '氛围满分' || tag === '服务贴心' || tag === '亲子友好') {
    return '#3E6B4F';
  } else if (tag === '价格实惠') {
    return '#5E8C61';
  } else if (tag === '排队较长') {
    return '#7B241C';
  }
  return '#E8A0A8';
}

function getComboColor(kind: string): string {
  if (kind === '招牌' || kind === '套餐') {
    return '#C0392B';
  } else if (kind === '午市') {
    return '#5E8C61';
  } else if (kind === '晚市') {
    return '#3E6B4F';
  }
  return '#D9A441';
}

getTagColor 把评价标签分桶配色:正向口味类(手艺、体验、味道)用主色红,正向服务类(氛围、服务、亲子)用辅色绿,价格实惠用成功色绿,负面标签(排队较长)用深红警示,其余用樱花粉。这套配色把"情感极性"编码进色彩,让经营者通过标签颜色即可感知口碑健康度。负面标签单独用深红与主色红区分,避免被误读为好评。getComboColor 把定食品类映射到颜色:招牌与套餐用主色红(高价值强调),午市用成功色绿(性价比走量),晚市用辅色绿(沉稳正式),经典类用橙黄。四个配色函数共同构成应用的"色彩语义层",是连接数据与视觉的桥梁。

原始业务数据

配色工具函数层

视图渲染层

SushiItem.kind

SakeItem.type

ReviewItem.tag

ComboItem.kind

getKindColor

getSakeTypeColor

getTagColor

getComboColor

上图展示了配色工具函数在架构中的桥梁位置:原始业务数据经过四个映射函数转换为色彩值,再流入视图层驱动渲染,实现了关注点的彻底分离。


三、主入口组件与状态中枢

代码段 15:入口组件声明与状态定义

@Entry
@Component
struct SushiApp {
  @State curTab: number = 0;
  @State data: SushiData = new SushiData();
  @State showAddSushi: boolean = false;
  @State showDeleteSake: boolean = false;
  @State showEditCombo: boolean = false;
  @State showDetail: boolean = false;
  @State delSakeName: string = '';
  @State detailChefName: string = '';
  @State sushiSpin: boolean = false;
  @State petalFall: boolean = false;

@Entry 标记这是应用入口组件,@Component 声明它是一个自定义组件。这里集中定义了应用的所有顶层状态:curTab 控制当前激活的标签页(0-4),data 是全局唯一的可观察数据实例,四个布尔开关控制四个弹窗的显隐,两个字符串暂存弹窗所需的上下文(待删酒水名、待查师傅名),两个布尔变量驱动顶栏的微动效(寿司旋转、樱花飘落)。把所有状态收口在入口组件,形成了"单一状态中枢"的架构。子组件不持有自己的持久状态,只通过 @ObjectLink@Prop 从中枢获取数据,通过回调函数向中枢汇报事件,这是一种自上而下的单向数据流模式,极大降低了状态同步的复杂度。

代码段 16:模态遮罩构建器

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
    }
    .width(1)
    .height(1)
  }

@Builder 是一个可复用的 UI 构建片段,这里定义了一个名为 modalOverlay 的构建器,接收一个 onClose 回调。它构建了一个几乎不可见的 1x1 像素按钮,作为弹窗背景的点击关闭区域。之所以用 1x1 而非全屏遮罩,是因为本项目的弹窗采用条件渲染(if (this.showAddSushi))而非系统模态,弹窗容器自身已处理布局。这个构建器保留了扩展性——未来若要加全屏半透明遮罩,只需在此处扩充即可。@Builder 的价值在于把可复用片段从 build 主流程中抽出,提升主 build 的可读性,是组件内复用的轻量级手段。

代码段 17:顶栏标题区构建

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('🍣 樱町寿司屋')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('Sakura Sushi · 江户前风味')
                .fontSize(10)
                .fontColor('#FFFFFFB3')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

build 方法是组件的视图入口。这里开启了最外层 Column,其内嵌一个满屏 Column(内容区)和一个底部导航 Row。内容区顶部是一个深红背景的标题栏,左侧是品牌名"樱町寿司屋"与英文副标"Sakura Sushi · 江户前风味"。主标题用 20 号粗体白色,副标用 10 号半透明白(#FFFFFFB3 即白色 70% 不透明),形成主次分明的层级。layoutWeight(1) 让标题列占据左侧剩余空间,为右侧的互动图标腾位。整个顶栏背景用 COLORS.primaryDark(深红),奠定了应用的品牌色调基调。

代码段 18:顶栏互动图标区

            Row() {
              Text('🍣')
                .fontSize(20)
                .onClick(() => {
                  this.sushiSpin = !this.sushiSpin;
                })
                .rotate({ angle: this.sushiSpin ? 360 : 0 })
                .animation({ duration: 900, curve: Curve.EaseInOut })
              Text('🌸')
                .fontSize(18)
                .margin({ left: 10 })
                .onClick(() => {
                  this.petalFall = !this.petalFall;
                })
                .translate({ y: this.petalFall ? 8 : 0 })
                .animation({ duration: 600, curve: Curve.EaseInOut })
              Text('🍶')
                .fontSize(16)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor('#FFFFFF26')
            .borderRadius(20)
            .border({ width: 1, color: '#FFFFFF40' })

右侧是三个可交互的 emoji 图标,包裹在一个半透明白底圆角胶囊里。寿司图标点击触发 sushiSpin 状态翻转,配合 rotateanimation 实现 900 毫秒的旋转动画;樱花图标点击触发 petalFall 翻转,配合 translate 实现下落动画。这里体现了声明式动画的精髓:开发者声明"终态角度/位移",框架自动在状态变化时插值过渡。Curve.EaseInOut 缓动曲线让动画起止柔和。这些微动效虽不承载业务,却显著提升了应用的"活感",是体验设计的点睛之笔。胶囊容器用 #FFFFFF26(白 15%)半透明背景,与深红顶栏形成玻璃质感。

代码段 19:暖帘装饰板条

          Row() {
            ForEach(SLAT_COL, (c: number) => {
              Column()
                .layoutWeight(1)
                .height(c % 2 === 0 ? 8 : 5)
                .margin({ left: 1, right: 1 })
                .backgroundColor(c % 4 === 0 ? COLORS.wasabiGreen : c % 4 === 2 ? COLORS.primaryLight : COLORS.white)
                .borderRadius(2)
            }, (c: number) => 'slat' + c)
          }
          .width('100%')
          .height(8)
          .margin({ top: 10 })
          .borderRadius(4)
          .clip(true)

这段渲染了顶栏下方的"暖帘"装饰条,遍历 12 个数字索引生成 12 根竖条。通过取模运算实现视觉节奏:c % 2 决定高度(偶数 8、奇数 5,形成高低错落),c % 4 决定颜色(0 用山葵绿、2 用主浅红、其余用白),形成"绿-白-红-白"循环的三色节奏。整个 Row 高 8、圆角 4、clip 裁剪,使竖条顶部呈连贯的弧形剪影,模拟日式暖帘的悬挂质感。这段纯装饰代码展示了用最简数据(一个数字数组)+ 取模逻辑生成丰富视觉的能力,是"数据驱动装饰"的范例。clip(true) 保证圆角外的部分被裁掉,视觉更干净。

代码段 20:一期一会分隔条

          Row() {
            Column()
              .layoutWeight(1)
              .height(4)
              .backgroundColor('#FFFFFF66')
              .borderRadius(2)
            Text('🌸 一期一会 🌸')
              .fontSize(9)
              .fontColor('#FFFFFFCC')
              .margin({ left: 8, right: 8 })
            Column()
              .layoutWeight(1)
              .height(4)
              .backgroundColor('#FFFFFF66')
              .borderRadius(2)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.primaryDark)

这是一条"两侧横线 + 中间文字"的分隔条,中间嵌着"一期一会"四字——日本茶道与待客之道的核心精神,意为"珍惜每一次相会"。两侧的细线用半透明白(#FFFFFF66)渲染,高度 4、圆角 2,通过 layoutWeight(1) 自适应撑开,使文字始终居中。整个标题栏用 COLORS.primaryDark 深红背景,padding 16/12 营造舒适呼吸感。这一细节把品牌文化(一期一会)与技术结构(自适应分隔条)融合,体现了"文化即设计"的理念。深红背景配半透明白线与文字,呈现出和风器物般的沉静质感。

代码段 21:标签页条件路由

        if (this.curTab === SushiTab.SUSHI) {
          SushiContent({ data: this.data, onAdd: () => {
            this.showAddSushi = true;
          } })
        } else if (this.curTab === SushiTab.COMBO) {
          ComboContent({ data: this.data, onEdit: () => {
            this.showEditCombo = true;
          } })
        } else if (this.curTab === SushiTab.SAKE) {
          SakeContent({ data: this.data, onDel: (n: string) => {
            this.delSakeName = n;
            this.showDeleteSake = true;
          } })
        } else if (this.curTab === SushiTab.CHEF) {
          ChefContent({ data: this.data, onDetail: (n: string) => {
            this.detailChefName = n;
            this.showDetail = true;
          } })
        } else {
          ReviewContent({ data: this.data })
        }

这段是应用的内容路由核心。根据 curTab 的值,条件渲染五个内容页组件之一。每个内容页接收两个参数:data(通过 @ObjectLink 双向绑定全局数据实例)和一个事件回调(onAdd/onEdit/onDel/onDetail)。回调在内容页内部被触发时,会修改入口组件的状态(打开对应弹窗、暂存上下文名)。这种"子组件触发事件、父组件管理状态"的模式,是声明式框架推崇的"状态提升"思想。父组件作为唯一状态拥有者,保证了弹窗显隐与上下文的一致性。条件渲染而非 Stack 堆叠,使得非当前页不占用渲染资源,性能更优。

渲染错误: Mermaid 渲染失败: Parse error on line 15: ...hiData 可观察实例)] -.-> |@ObjectLink| SC -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

上图清晰呈现了入口组件作为状态中枢的角色:向下通过 @ObjectLink 向五个内容页共享数据实例,向上接收内容页的事件回调并据此控制四个弹窗的显隐。数据流单向、事件流反向,构成了清晰的闭环。

代码段 22:底部导航栏构建

      Row() {
        ForEach(TAB_LIST, (t: TabMeta) => {
          Column() {
            Text(t.icon)
              .fontSize(19)
            Text(t.label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
              .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.primaryDark : COLORS.cardBg)
          .borderRadius(20)
          .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '88' } : { width: 1, color: COLORS.line })
          .onClick(() => {
            this.curTab = TAB_LIST.indexOf(t);
          })
        }, (t: TabMeta) => t.key)
      }
      .width('100%')
      .height(60)
      .padding({ left: 8, right: 8 })
      .backgroundColor(COLORS.cardBg)
      .border({ width: 1, color: COLORS.line })

底部导航栏通过 ForEach 遍历 TAB_LIST 动态生成五个标签。每个标签是图标+文字的纵向排列,选中态通过三重视觉差异区分:文字变白色粗体、背景变深红、边框变为该标签主题色加半透明(t.color + '88')。这种"一栏多色边框"让每个选中标签都有自己的色彩身份。点击标签修改 curTab,触发上方内容区的条件路由刷新。TAB_LIST.indexOf(t) 把对象转为索引以与 curTab 数字比较,是一个实用的模式匹配技巧。整个导航栏高 60、圆角 20,视觉上像五个浮在白底上的胶囊按钮,符合移动端拇指操作的人体工学。

代码段 23:弹窗条件渲染区

      if (this.showAddSushi) {
        AddSushiModal({
          onClose: () => {
            this.showAddSushi = false;
          }
        })
      }
      if (this.showDeleteSake) {
        DeleteSakeModal({
          title: this.delSakeName, onClose: () => {
            this.showDeleteSake = false;
          }
        })
      }
      if (this.showEditCombo) {
        EditComboModal({
          onClose: () => {
            this.showEditCombo = false;
          }
        })
      }
      if (this.showDetail) {
        DetailChefModal({
          name: this.detailChefName, onClose: () => {
            this.showDetail = false;
          }
        })
      }
    }
  }
}

四个弹窗通过四个布尔状态条件渲染。每个弹窗组件接收必要的上下文数据(待删酒水名、待查师傅名)和一个 onClose 回调。回调的实现就是把对应状态置回 false,从而卸载弹窗。这种"状态即显隐"的模式,让弹窗的生命周期完全由父组件状态掌管,子弹窗无需自管理 open/close 状态,避免了父子状态不同步的隐患。弹窗内部点击取消或确认按钮都会调用 onClose,统一收口关闭逻辑。条件渲染意味着弹窗不显示时不参与布局与渲染,性能优于常驻 DOM 的 display:none 方案。至此入口组件的 build 闭合,完成了"顶栏+内容路由+底栏+弹窗"的完整骨架。


四、标签组件与寿司内容页

代码段 24:标签组件

@Component
struct SushiTag {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .backgroundColor(this.color + '1F')
      .borderRadius(8)
      .border({ width: 1, color: this.color + '40' })
  }
}

SushiTag 是一个高复用的标签胶囊组件,用 @Prop 接收文字与颜色两个单向只读属性。它的精妙之处在于"色彩透明度叠加":文字用原色,背景用原色加 1F(约 12% 不透明)做浅底,边框用原色加 40(25% 不透明)做描边。这样同一个色值能衍生出"深字-浅底-中边"三层视觉,却只需传一个色值参数。这个组件被寿司页、定食页、酒水页、师傅页、评价页全部复用,是"一次编写、处处复用"的典范。@Prop 而非 @State 表明这是只读属性,父组件变化会单向同步下来,子组件不能反向修改,保证了数据流的单向性。

代码段 25:寿司页——周营业额图表标题

@Component
struct SushiContent {
  @ObjectLink data: SushiData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 周营业额')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六¥126千')
              .fontSize(9)
              .fontColor(COLORS.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)

SushiContent 是寿司标签页的内容组件,通过 @ObjectLink 与全局 SushiData 实例双向绑定,同时声明一个 onAdd 回调用于触发上新弹窗。整个页面用一个 Scroll 包裹,保证内容超出屏幕时可滚动。第一张卡片是"周营业额"图表,标题行用 SpaceBetween 两端对齐:左侧粗体标题,右侧用 danger 红色小字标注峰值"周六¥126千",形成"标题+亮点"的导读模式。@ObjectLink 使得当全局数据实例的 sushes 数组变化时,本页列表会自动刷新,这是可观察数据的核心价值。onAdd 给了默认空函数,保证即使父组件不传也能安全调用。

代码段 26:寿司页——周营业额柱状图

          Row() {
            ForEach(WEEK_SALES, (w: WeekMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 100 ? COLORS.primary : COLORS.textSecondary)
                Column()
                  .width(20)
                  .height(w.value * 1.1)
                  .backgroundColor(w.value >= 100 ? COLORS.primary : COLORS.accent)
                  .borderRadius(6)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekMeta) => w.day)
          }
          .width('100%')
          .height(140)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })

这段用 ForEach 遍历 WEEK_SALES 渲染了一个纯组件柱状图。每根柱子是一个 Column(数值文字+柱体+星期文字),柱体高度由 w.value * 1.1 计算,把数据值线性映射为像素高度。关键设计有两处:其一,数值>=100 的柱子用主色红、文字也变红,形成"破百即高亮"的视觉强调,让经营者一眼锁定周末高峰;其二,整个 Row 用 alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐,模拟坐标轴的基线效果。这是用声明式组件"拼"出图表的经典手法,无需引入图表库即可实现数据可视化,适合轻量看板场景。layoutWeight(1) 让七根柱子等宽分布。

代码段 27:寿司页——品类分布标题与条形图

        Column() {
          Row() {
            Text('🍣 菜单品类分布')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('手握寿司最多')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(STYLE_SHARE, (n: StyleMeta) => {
            Row() {
              Text(n.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(76)
              Row() {
                Row()
                  .layoutWeight(n.count / 5)
                  .height(10)
                  .backgroundColor(n.color)
                  .borderRadius(5)
                Row()
                  .layoutWeight(1 - n.count / 5)
                  .height(10)
                  .backgroundColor(COLORS.cardAlt)
                  .borderRadius(5)
              }
              .layoutWeight(1)
              .height(10)
              Text(n.count + '款')
                .fontSize(9)
                .fontColor(n.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (n: StyleMeta) => n.name)
        }

品类分布卡片的标题行沿用"粗体标题+提示小字"范式,右侧"手握寿司最多"用 hint 浅色传递数据洞察,让经营者在看图前先获得一句话摘要。下方进度条用两个 Row 拼接:前段用 layoutWeight(n.count/5) 按占比撑开并填品类色,后段用 layoutWeight(1-n.count/5) 填浅米色作背景,两者合起来正好铺满。这种"前景+背景双 Row"的进度条实现,是声明式 UI 在无原生进度组件时的通用解法。数量文字用品类色,与进度条同色,形成"色-量-数"三位一体的视觉编码。手握寿司 5 款占满整条,视觉上即知其为主力品类。

代码段 28:寿司页——招牌寿司列表标题

        Column() {
          Row() {
            Text('🍣 招牌寿司')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕上新')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })

第三张卡片是招牌寿司列表,标题行除了"标题+提示"外,多了一个可点击的"➕"图标,点击触发 this.onAdd() 回调,最终打开寿司上新弹窗。这是"列表内嵌新增入口"的设计——把新增动作放在列表标题旁,符合用户"看到列表就想新增"的心理预期。加号用 13 号字、左侧 8 间距,与提示文字"点击➕上新"形成"文字引导+图标操作"的组合。整个标题行用 SpaceBetween 两端对齐,使标题在左、操作在右,布局清晰。margin({ bottom: 10 }) 为下方列表留出呼吸空间。

代码段 29:寿司页——寿司列表项左侧与中间

          ForEach(this.data.sushes, (s: SushiItem, i: number) => {
            Row() {
              Column()
                .width(44)
                .height(44)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getKindColor(s.kind) + '22')
                .borderRadius(14)
                .border({ width: 2, color: getKindColor(s.kind) + '55' })
              Text(s.emoji)
                .fontSize(19)
                .width(30)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(s.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  SushiTag({ text: s.kind, color: getKindColor(s.kind) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('评分' + s.rating + ' · 日售' + s.orders + '份')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })

这段渲染每款寿西的列表项左侧与中间部分。左侧是一个 44x44 的圆角图标框,背景色与边框色都由 getKindColor(s.kind) 派生(加 22 与 55 调透明度),使不同品类的图标框呈现不同色调。中间是名称行(粗体名+品类标签胶囊)与副信息行(评分与日销)。SushiTag 复用前面定义的标签组件,传入品类色。副信息用 textSecondary 中棕色,与主名称形成明暗层级。layoutWeight(1) 让中间内容列占据图标与价格之间的剩余空间。这种"图标-内容-价格"三段式列表项是移动端列表的通用范式,兼顾信息密度与可读性。

代码段 30:寿司页——列表项价格与容器

              Column() {
                Text('¥' + s.price)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(s.price >= 50 ? COLORS.primary : COLORS.accent)
                Text('每贯')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(16)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (s: SushiItem, i: number) => s.name + i)

列表项右侧是价格列,价格用 14 号粗体,并以 50 元为界做颜色分级(>=50 用主色红,否则用辅色绿),让高价寿司在视觉上更醒目,辅助经营者识别高毛利单品。下方"每贯"小字用 hint 浅色,说明计价单位。整个列表项用 i % 2 做隔行变色(偶数白底、奇数米底),提升长列表的可读性。圆角 16、细边框、底部 8 间距,形成卡片化的行间分隔。ForEach 的键生成器用 s.name + i 保证唯一性,避免同名寿司导致的 diff 错误。整个寿司页至此构建完成,覆盖了营业额图表、品类分布、招牌列表三大信息模块。


五、定食内容页

代码段 31:定食页——师傅热度榜标题

@Component
struct ComboContent {
  @ObjectLink data: SushiData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🏆 师傅热度榜')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('藤原师傅居首')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })

ComboContent 是定食标签页,同样用 @ObjectLink 绑定全局数据,并声明 onEdit 回调用于触发定食编辑弹窗。页面的第一张卡片是"师傅热度榜"——注意这里把师傅榜单放在定食页而非师傅页,是一种跨版块的信息编排:经营者在浏览定食菜单时,能同时看到哪位师傅最热门,便于将热门师傅与定食套餐做关联营销。标题右侧提示"藤原师傅居首"用辅色绿,传递榜首结论。卡片边框用 COLORS.accent + '66'(辅色绿 40%),与寿司页的红色边框形成版块色彩区分,让经营者通过边框色即可识别当前所在版块。

代码段 32:定食页——师傅热度榜进度条

          ForEach(CHEF_HOT, (h: ChefHotMeta, i: number) => {
            Row() {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? COLORS.primary : COLORS.textHint)
                .width(22)
              Text(h.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(76)
              Row() {
                Row()
                  .layoutWeight(h.orders / 340)
                  .height(10)
                  .backgroundColor(h.color)
                  .borderRadius(5)
                Row()
                  .layoutWeight(1 - h.orders / 340)
                  .height(10)
                  .backgroundColor(COLORS.cardAlt)
                  .borderRadius(5)
              }
              .layoutWeight(1)
              .height(10)
              Text(h.orders + '单')
                .fontSize(9)
                .fontColor(h.color)
                .width(40)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (h: ChefHotMeta, i: number) => h.name + i)

师傅热度榜的每行是"排名-名称-进度条-单量"四段式。排名序号 (i+1) 用粗体,前三名(i<3)用主色红,其后用 hint 浅色,形成"前三甲高亮"的视觉激励。进度条比例以 340 为基准(榜首藤原 320/340 接近满格),前景填师傅专属色,背景填浅米。单量文字用师傅色与进度条同色呼应。这种排行榜的渲染模式高度模板化,与酒水销量榜完全同构,只是数据源与单位不同。若后续抽象,可封装一个通用的 RankingList 组件接收数据与单位即可复用,是后续重构的可优化点。当前保持内联是为了避免过度抽象增加理解成本。

代码段 33:定食页——定食菜单标题

        Column() {
          Row() {
            Text('🍱 定食菜单')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✏️调整')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('✏️')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onEdit();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })

第二张卡片是定食菜单列表,标题行结构与寿司页的招牌列表一致:粗体标题+提示小字+可点击操作图标。"✏️"图标点击触发 this.onEdit() 回调,最终打开定食编辑弹窗。这种"标题行内嵌操作入口"的模式在五个内容页中保持一致(寿司➕、定食✏️、酒水🗑️、师傅👁️),形成了统一的交互语法——用户学会一个页面的操作模式就能迁移到其他页面,降低了学习成本。卡片背景用 COLORS.cardAlt(浅米)与上一张白底热度榜形成层次,边框用普通 line 色,作为常规列表容器。

代码段 34:定食页——定食列表项左侧与中间

          ForEach(this.data.combos, (c: ComboItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getComboColor(c.kind) + '22')
                .borderRadius(13)
              Text(c.emoji)
                .fontSize(18)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  SushiTag({ text: c.kind, color: getComboColor(c.kind) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(c.calory + '卡 · 评分' + c.rating)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })

定食列表项左侧与中间与前两页同构:图标框调用 getComboColor 取色,副信息展示"卡路里+评分"而非"评分+日销"。图标框背景与边框用 +22+55 调透明度的同色派生,与寿司、定食页的图标框风格统一。SushiTag 标签展示定食品类(午市/晚市/招牌等),颜色由品类映射函数决定。整个列表项的视觉模板与寿司、酒水完全一致,仅数据字段不同——这种"统一模板+差异化数据"的策略,使得用户在五个版块间切换时视觉体验连贯,认知负担最低。卡路里字段是定食页独有,帮助关注热量的食客做选择。

代码段 35:定食页——列表项价格与容器

              Column() {
                Text('¥' + c.price)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(c.price >= 100 ? COLORS.warning : COLORS.primary)
                Text('整份')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(16)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (c: ComboItem, i: number) => c.name + i)

定食列表项右侧价格分级阈值改为 100(>=100 用 warning 橙,否则 primary 红),单位改为"整份",这些差异都源于 ComboItemSushiItem 的字段差异。价格分级、隔行变色、圆角边框、间距等样式参数完全一致,体现了设计系统的严格执行。ForEach 键用 c.name + i 保证唯一。定食页至此构建完成,涵盖师傅热度榜与定食菜单两个模块。把师傅热度榜放在定食页而非师傅页,是一种"关联信息就近展示"的信息编排策略——经营者在管理定食时能看到热门师傅,便于做人员与套餐的关联营销决策。


六、酒水内容页

代码段 36:酒水页——销量榜标题

@Component
struct SakeContent {
  @ObjectLink data: SushiData;
  onDel: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🍶 酒水销量TOP6')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('菊正宗夺冠')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })

SakeContent 是酒水标签页,@ObjectLink 绑定数据,onDel 回调接收一个酒水名参数(注意是带参回调,与寿司页的无参 onAdd 不同),用于触发下架确认弹窗并传递待删酒水名。第一张卡片是"酒水销量TOP6"榜单,标题右侧提示"菊正宗夺冠"用辅色绿。卡片边框用 COLORS.warning + '66'(橙黄 40%),与前两个页的红、绿边框共同构成"寿司红-定食绿-酒水黄"的版块色彩标识系统。经营者仅凭卡片边框色就能判断当前浏览的业务版块,这是色彩系统的又一应用。

代码段 37:酒水页——销量榜进度条

          ForEach(SAKE_TOP, (p: SakeTopMeta, i: number) => {
            Row() {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? COLORS.primary : COLORS.textHint)
                .width(22)
              Text(p.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(92)
              Row() {
                Row()
                  .layoutWeight(p.sales / 560)
                  .height(10)
                  .backgroundColor(p.color)
                  .borderRadius(5)
                Row()
                  .layoutWeight(1 - p.sales / 560)
                  .height(10)
                  .backgroundColor(COLORS.cardAlt)
                  .borderRadius(5)
              }
              .layoutWeight(1)
              .height(10)
              Text(p.sales + '杯')
                .fontSize(9)
                .fontColor(p.color)
                .width(44)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (p: SakeTopMeta, i: number) => p.name + i)

酒水销量榜与师傅热度榜渲染逻辑完全同构,差异仅在:名称列宽 92(酒水名更长)、进度条基准 560(榜首 520/560 接近满格)、单位"杯"。前三名序号同样用主色红高亮。这种高度同构的榜单,是模板化复用的典型场景——若项目演进,可抽取一个 RankBar 组件,接收 name/value/max/unit/color 五个参数即可渲染任意排行。当前内联实现虽略有重复,但保持了每段代码的自包含可读性,是"适度复用"的平衡选择。榜单把"哪些酒水最畅销"的决策信息前置,辅助采购与备货。

代码段 38:酒水页——酒水菜单标题

        Column() {
          Row() {
            Text('🍶 酒水单')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击🗑️下架')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })

第二张卡片是酒水菜单列表,标题行只有"标题+提示",没有操作图标——因为下架操作放在了每条酒水记录的右侧(🗑️图标),而非标题行。这是"行级操作"与"全局操作"的取舍:酒水下架是针对具体某款酒水的操作,放在行内更符合上下文;而寿司上新、定食编辑是全局性操作,放在标题行更合适。这种区分体现了交互设计对"操作粒度"的考量。提示文字"点击🗑️下架"用 hint 浅色,引导用户注意行内的删除图标。卡片背景用 cardAlt 浅米、边框 line 色,与销量榜白底形成层次。

代码段 39:酒水页——酒水列表项左侧与中间

          ForEach(this.data.sakes, (s: SakeItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getSakeTypeColor(s.type) + '22')
                .borderRadius(13)
              Text(s.emoji)
                .fontSize(18)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(s.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  SushiTag({ text: s.type, color: getSakeTypeColor(s.type) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(s.degree + '度 · 已售' + s.sales + '杯')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })

酒水列表项左侧与中间与前两页同构:图标框调用 getSakeTypeColor 取色,副信息展示"度数+已售"。度数字段是酒水页独有,帮助判断酒性烈度。图标框背景与边框用 +22+55 调透明度的同色派生,与寿司、定食页的图标框风格统一。SushiTag 标签展示酒水类型(清酒/果酒/烧酒/无酒精/啤酒),颜色由类型映射函数决定。整个列表项的视觉模板与寿司、定食完全一致,仅数据字段不同——这种"统一模板+差异化数据"的策略,使得用户在五个版块间切换时视觉体验连贯,认知负担最低。

代码段 40:酒水页——列表项价格与下架操作

              Column() {
                Text('¥' + s.price)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(s.price >= 100 ? COLORS.warning : COLORS.primary)
                Text('每壶')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
                Text('🗑️')
                  .fontSize(12)
                  .margin({ top: 4 })
                  .onClick(() => {
                    this.onDel(s.name);
                  })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(16)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (s: SakeItem, i: number) => s.name + i)

酒水列表项右侧除了价格(分级阈值 100,单位"每壶")外,多了一个"🗑️"删除图标,点击调用 this.onDel(s.name) 把酒水名作为参数传给父组件回调。父组件在回调中把这个名暂存到 delSakeName 状态并打开下架确认弹窗。这种"行内触发+父组件确认"的两步交互,是破坏性操作的标准范式——先在行内便捷发起,再由弹窗二次确认,防止误删。价格分级、隔行变色、圆角边框等样式与其他页一致,保持了设计系统的统一。至此酒水页构建完成,涵盖销量榜与酒水菜单两个模块。


七、师傅内容页与评价内容页

代码段 41:师傅页——标题与第一统计卡

@Component
struct ChefContent {
  @ObjectLink data: SushiData;
  onDetail: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('👨‍🍳 板前师傅')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('点击查看简介')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 10 })

        Row() {
          Column() {
            Text('12')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('在职师傅')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(18)
          .border({ width: 1, color: COLORS.primary + '66' })

ChefContent 是师傅标签页,@ObjectLink 绑定数据,onDetail 回调接收师傅名参数用于触发简介弹窗。页面顶部标题用 15 号字(比其他页的 13 略大),因为师傅页没有统计图表卡片,标题承担更多视觉权重。下方是三联统计卡片的第一张——在职师傅数 12。大数字 26 号粗体用主色红,下方小字"在职师傅"用中棕。整个卡片白底、圆角 18、主色半透明边框,居中布局。onDetail 是带参回调,与酒水页的 onDel 同模式——行内操作携带具体实体名上抛给父组件。师傅页设计重点是"人",用统计卡片+人物列表呈现团队画像。

代码段 42:师傅页——第二第三统计卡

          Column() {
            Text('28年')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.warning)
            Text('最长资历')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(18)
          .border({ width: 1, color: COLORS.warning + '66' })
          .margin({ left: 10 })
          Column() {
            Text('9.8')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('最高评分')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(18)
          .border({ width: 1, color: COLORS.accent + '66' })
          .margin({ left: 10 })
        }
        .width('100%')
        .margin({ bottom: 12 })

第二、第三张统计卡片分别展示"最长资历 28 年"(warning 橙)与"最高评分 9.8"(accent 绿)。三张卡片的数字色与边框色形成"红-橙-绿"的彩虹渐变,既区分维度又和谐统一。这三张卡片把师傅团队的关键指标(规模、经验、质量)一屏呈现,是经营者评估团队实力的速览仪表。值得注意的是这些统计值是硬编码的(12、28年、9.8),而非从 data 动态计算——在真实工程中应改为 this.data.chefs.length 等动态计算,以保证数据一致性。当前硬编码适合原型演示,但存在数据不同步的风险,是工程化阶段需改进的点。layoutWeight(1) 让三张卡片等宽分布,中间用 margin({ left: 10 }) 间隔。

代码段 43:师傅页——师傅列表项左侧与中间

        ForEach(this.data.chefs, (c: ChefItem, i: number) => {
          Row() {
            Column()
              .width(42)
              .height(42)
              .justifyContent(FlexAlign.Center)
              .backgroundColor(COLORS.sakuraPink + '22')
              .borderRadius(14)
            Text(c.emoji)
              .fontSize(18)
              .width(28)
              .textAlign(TextAlign.Center)
            Column() {
              Row() {
                Text(c.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                SushiTag({ text: c.title, color: COLORS.accent })
                  .margin({ left: 6 })
              }
              .width('100%')
              Text('从业' + c.years + '年 · 日接' + c.orders + '单')
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 8 })

师傅列表项的图标框统一用樱花粉(sakuraPink + '22')做底,因为师傅是"人"而非品类,用粉色传递人物的温度感,与其他页按品类取色的逻辑区分。职称标签统一用辅色绿。副信息展示"从业年限+日接单量",从资历和产能两个维度刻画师傅。layoutWeight(1) 让中间内容列占据剩余空间。整个列表项的布局模板与其他页完全一致——"图标框-内容列-右侧数据列"三段式,保证了跨版块的视觉统一。职称字段的取值覆盖料理长、副料理长、各专职工师傅、酒水师、学徒,分工体系清晰。

代码段 44:师傅页——列表项评分与查看入口

            Column() {
              Text(c.rating + '')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(c.rating >= 9.5 ? COLORS.primary : COLORS.warning)
              Text('评分')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
              Text('👁️')
                .fontSize(12)
                .margin({ top: 4 })
                .onClick(() => {
                  this.onDetail(c.name);
                })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding({ left: 10, right: 12, top: 9, bottom: 9 })
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(16)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (c: ChefItem, i: number) => c.name + i)

师傅列表项右侧除了评分(>=9.5 用主色红,否则 warning 橙)外,多了"👁️"眼睛图标,点击调用 this.onDetail(c.name) 触发师傅简介弹窗。评分分级阈值 9.5 比寿司的 50 元、酒水的 100 元更精细,因为评分是小数制,9.5 是"顶尖"与"优秀"的自然分界。列表项样式与其他页完全一致,保证了跨版块的视觉统一。点击眼睛图标把师傅名作为参数传给父组件回调,父组件暂存名并打开简介弹窗,是"列表-详情"交互模式的标准实现。ForEach 键用 c.name + i 保证唯一性,避免同名师傅导致的 diff 错误。师傅页至此构建完成。

代码段 45:评价页——标题

@Component
struct ReviewContent {
  @ObjectLink data: SushiData;

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🌸 食客评价')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('平均4.5分 · 共12条')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 10 })

ReviewContent 是评价标签页,注意它只有 @ObjectLink 数据绑定,没有事件回调——因为评价页是纯展示页,没有新增/编辑/下架等操作,是五个内容页中最轻量的。标题右侧提示"平均4.5分 · 共12条"用 hint 浅色,给出评价的总量与均分概览。这种"标题带统计摘要"的写法,让经营者在浏览具体评价前先获得整体画像。评价页的设计哲学是"倾听",所以没有任何写操作入口,保持了阅读的纯粹性。这也体现了组件按需声明的原则——不需要的回调就不声明,接口最小化。

代码段 46:评价页——评价列表项上半部分

        ForEach(this.data.reviews, (r: ReviewItem, i: number) => {
          Column() {
            Row() {
              Column()
                .width(36)
                .height(36)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getTagColor(r.tag) + '22')
                .borderRadius(12)
              Text(r.emoji)
                .fontSize(15)
                .width(26)
                .textAlign(TextAlign.Center)
              Column() {
                Text(r.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text('⭐'.repeat(Math.round(r.score)) + ' · ' + r.date)
                  .fontSize(9)
                  .fontColor(COLORS.warning)
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              SushiTag({ text: r.tag, color: getTagColor(r.tag) })
            }
            .width('100%')

评价列表项的布局与其他页不同——是纵向 Column 而非横向 Row,因为评价有正文需要换行展示。首行是"图标框-昵称+星标+日期-标签胶囊"的横向排列,图标框背景色由 getTagColor(r.tag) 派生,使不同情感标签的评价图标框色调不同。星标用 '⭐'.repeat(Math.round(r.score)) 动态生成对应数量的星星,配合 warning 橙色,直观传达评分。标签胶囊复用 SushiTag,色彩由情感标签映射函数决定,让经营者通过标签颜色即可感知评价的情感极性。

代码段 47:评价页——评价正文与容器

            Text(r.content)
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .lineHeight(18)
              .width('100%')
              .margin({ top: 8 })
          }
          .width('100%')
          .padding(12)
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(16)
          .border({ width: 1, color: COLORS.line })
          .alignItems(HorizontalAlign.Start)
          .margin({ bottom: 8 })
        }, (r: ReviewItem, i: number) => r.name + i)
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

评价卡的首行下方是评价正文,11 号字、行高 18、中棕色,保证多行文本的可读性。整个评价卡用左对齐(alignItems(HorizontalAlign.Start)),符合从左到右的阅读习惯。隔行变色、圆角、边框、间距等样式参数与其他列表页完全一致,体现了设计系统的严格执行。ForEach 键用 r.name + i 保证唯一性。constraintSize({ maxHeight: '100%' }) 配合外层 Scroll 保证内容区不超出屏幕可见区域。评价页至此构建完成,是五个内容页中最轻量的纯展示页,没有写操作入口,保持了"倾听"的纯粹性。

评价卡布局

首行 Row: 图标框 + 昵称/星标 + 标签

正文 Text: 多行评价内容

上图展示了评价卡的两段式纵向布局:首行横向排列头像与元信息,下方是正文,形成"概览+详情"的阅读层次。


八、弹窗组件群

代码段 48:寿司上新弹窗——头部与字段

@Component
struct AddSushiModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🍣')
            .fontSize(26)
          Column() {
            Text('寿司上新')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('录入新菜品')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('菜品名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('炙烤三文鱼 · 手握')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 14 })

AddSushiModal 是寿司上新弹窗,通过 onClose 回调关闭(由父组件控制显隐)。弹窗头部是"图标+标题+副标+关闭按钮"的横向排列,图标用 26 号大字,标题 16 号粗体,副标 10 号 hint 色,关闭按钮"✕"在最右侧。这种头部布局是四个弹窗的统一范式,保持了一致的弹窗语法。layoutWeight(1) 让标题列撑开,使关闭按钮靠右。关闭按钮点击调用 this.onClose(),把关闭逻辑交回父组件。弹窗本身不持有显隐状态,是"无状态组件",显隐完全由父组件的布尔状态掌管,避免了弹窗自管理与父组件不同步的问题。下方第一个字段行展示菜品名称,用 cardAlt 浅米背景圆角卡呈现。

代码段 49:寿司上新弹窗——更多字段与提示

        Row() {
          Text('食材产地')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('挪威三文鱼 · 空运')
            .fontSize(12)
            .fontColor(COLORS.accent)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('建议售价')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('¥32 · 限量每日30贯')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('上架安排')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('周末限定 · 明日起')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Text('✅ 新菜品须经料理长试菜通过,食材检验单随货附上。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

弹窗正文是多个"标签-值"的字段行,每行用 cardAlt 浅米背景、圆角 12、padding 12 形成卡片化的字段展示。左侧标签固定宽 70、11 号中棕色;右侧值用 12 号,颜色按字段语义区分(食材产地用辅色绿强调品质,上架安排用 warning 橙暗示限定)。字段值的色彩语义化让经营者在扫视间即可捕捉关键字段的重要性等级。底部是一条业务提示"新菜品须经料理长试菜通过…",用 10 号 hint 浅色传达业务规则约束。这种在表单底部放置业务提示的做法,既不干扰主流程又能传达约束信息。注意当前值是硬编码的示例文本,在真实工程中应绑定到输入控件的状态变量。

代码段 50:寿司上新弹窗——操作按钮与容器

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('确认上新')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.primary)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.primary + '66' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

弹窗底部是"取消+确认上新"双按钮,均分宽度(layoutWeight(1))。取消按钮用 cardAlt 浅米底+中棕字,是次要操作;确认上新用主色红底+白粗体字,是主要操作,通过色彩与字重明确主次。两个按钮都调用 this.onClose() 关闭弹窗——取消即放弃,确认即提交(原型阶段都简化为关闭)。整个弹窗用 cardBg 白底、圆角 18、主色半透明边框,靠底部对齐(justifyContent(FlexAlign.End)),最大高度 78%,模拟从底部滑出的半屏弹窗形态。这种底部弹窗符合移动端单手操作的 ergonomics。至此寿司上新弹窗构建完成。

代码段 51:下架酒水弹窗

@Component
struct DeleteSakeModal {
  @Prop title: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Column() {
          Text('🗑️')
            .fontSize(34)
          Text('确认下架酒水')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ top: 10 })
          Text('「' + this.title + '」将从酒水单移除,已开瓶存货转入内部品鉴。')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .textAlign(TextAlign.Center)
            .width('100%')
            .margin({ top: 8 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('确认下架')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.danger)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.danger + '66' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

DeleteSakeModal 是酒水下架确认弹窗,用 @Prop 接收单向只读的 title(待删酒水名)。与上新弹窗的"字段卡"布局不同,下架弹窗采用"居中图标+标题+说明"的确认对话布局,因为下架是破坏性操作,需要更聚焦的注意力。大号垃圾桶图标(34 号)居中,下方动态嵌入 this.title 显示具体酒水名,说明文本还补充了业务规则"已开瓶存货转入内部品鉴",让经营者了解下架后的后续处理。确认按钮用 COLORS.danger(红)而非 primary,因为 danger 与 primary 同色值但语义独立——这里用 danger 语义标记破坏性操作。整个弹窗边框也用 danger 半透明,强化警示氛围。@Prop title 保证了酒水名从父组件单向流入。

代码段 52:编辑定食弹窗——头部与字段

@Component
struct EditComboModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🍱')
            .fontSize(26)
          Column() {
            Text('编辑定食菜单')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('调整价格与配菜')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('定食名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('招牌寿司定食 · 午市')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 14 })

EditComboModal 是定食编辑弹窗,结构与上新弹窗几乎一致,仅图标与标题文案不同。头部是"图标+标题+副标+关闭"的统一范式。编辑弹窗的业务场景是"调整价格与配菜",对应字段区会展示价格变化与配菜调整。编辑弹窗与上新弹窗共享同一套布局模板,体现了弹窗组件的模板化复用——四个弹窗虽业务不同,但布局骨架一致,降低了维护成本。字段区第一个字段行展示定食名称,用主色文字。字段卡布局与上新弹窗完全一致,保持视觉统一。若未来弹窗数量增加,可抽取一个 ModalShell(头部+内容槽+按钮槽)的基础弹窗组件进一步统一。

代码段 53:编辑定食弹窗——价格配菜字段与按钮

        Row() {
          Text('价格调整')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('¥68 → ¥72')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('配菜调整')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('加赠味噌汤+茶碗蒸')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Text('⚠️ 菜单调整同步更新外卖平台,会员日打九折不变。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(12)
            .onClick(() => { this.onClose(); })
          Text('保存修改')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.accent)
            .borderRadius(12)
            .onClick(() => { this.onClose(); })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.accent + '66' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

编辑弹窗的价格调整字段用"原价→新价"的箭头表达变化,warning 橙色提示涨价变化方向,让经营者在确认前感知到变化的影响。配菜调整用主色文字。底部业务提示"菜单调整同步更新外卖平台"传达调整的外部影响。确认按钮用 COLORS.accent(辅色绿)而非主色红,因为"保存修改"是中性偏正向操作,用绿色传递"保存成功"的心理暗示。四个弹窗的确认按钮色彩形成了一套语义系统:上新红、下架红、编辑绿、详情红,让经营者通过按钮色即可预判操作性质。整个弹窗边框用 accent 半透明绿。至此编辑弹窗构建完成。

代码段 54:师傅简介弹窗——头部与职级字段

@Component
struct DetailChefModal {
  @Prop name: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('👨‍🍳')
            .fontSize(26)
          Column() {
            Text('师傅简介')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('师傅「' + this.name + '」')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => { this.onClose(); })
        }
        .width('100%')

        Row() {
          Text('职级职称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('料理长 · 从业28年')
            .fontSize(12)
            .fontColor(COLORS.primary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 14 })

DetailChefModal 是师傅简介详情弹窗,用 @Prop 接收师傅名。头部副标动态嵌入 this.name 显示具体师傅名。这是四个弹窗中唯一的"详情查看"弹窗(其他三个是写操作),所以底部只有单按钮"知道了"而非双按钮。详情弹窗的信息是只读的,不涉及修改,所以交互更轻。职级职称字段用主色红,因为这是师傅的核心身份标识。字段卡布局与编辑、上新弹窗一致。@Prop name 保证了师傅名从父组件单向流入,弹窗内不修改。详情弹窗的存在使得师傅列表项可以保持简洁(只展示核心信息),详细信息按需展开,是"渐进式披露"信息架构的体现。

代码段 55:师傅简介弹窗——擅长评分与个人介绍

        Row() {
          Text('擅长领域')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('江户前寿司 · 切付')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('顾客评分')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('9.8分 · 日接320单')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Column() {
          Text('个人介绍')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('师承东京银座名店,握寿司手法传承三代。坚持每日凌晨市场亲自选鱼,对醋饭的温度与酸度有近乎苛刻的要求,被食客称为「板前艺术家」。')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .lineHeight(20)
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Start)
        .margin({ top: 8 })

        Text('🍣 板前座位需提前一天预约,可观摩师傅现场制作。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Text('知道了')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS.primary)
          .borderRadius(12)
          .margin({ top: 16 })
          .onClick(() => { this.onClose(); })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.primaryLight + '66' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

师傅简介弹窗的特色是"个人介绍"字段卡——这是一个纵向 Column,上方是字段标签"个人介绍",下方是多行正文,行高 20 保证可读性。这段介绍文案极具人物色彩(师承银座名店、三代传承、凌晨选鱼、醋饭苛刻),让师傅形象立体生动。顾客评分子段用 warning 橙,因为是量化指标。下方还有一条预约提示"板前座位需提前一天预约",把人物介绍与经营信息结合。底部是单按钮"知道了"(主色红),点击关闭弹窗。详情弹窗用单按钮而非双按钮,因为这是纯展示无决策,"知道了"即完成阅读确认。整个弹窗边框用 primaryLight 半透明,柔和呼应主色。至此四个弹窗全部构建完成,源码也至此结束。

弹窗矩阵

录入态 字段卡+双按钮红

编辑态 字段卡+双按钮绿

确认态 居中图标+双按钮红

详情态 字段卡+单按钮红

AddSushiModal 寿司上新

EditComboModal 定食编辑

DeleteSakeModal 酒水下架

DetailChefModal 师傅简介

新增操作

修改操作

删除操作

查看操作

上图归纳了四个弹窗的形态与操作语义对应关系:不同形态对应不同操作性质,让经营者通过弹窗外观即可预判操作影响,是交互设计的成熟体现。


技术对比总结表

技术要点 实现方式 优势 适用场景
色彩系统 SushiPalette 接口加 COLORS 常量白名单 从源头杜绝硬编码色值,保证视觉统一 多人协作的中大型应用视觉规范
状态管理 @State 入口中枢加 @Observed/@ObjectLink 共享 单向数据流可预测,跨组件自动刷新 多组件共享可变状态的声明式应用
弹窗数据流 @Prop 单向流入加 onClose 回调上抛 弹窗无状态,显隐由父组件掌管,避免不同步 表单录入、确认对话、详情查看
标签路由 curTab 数字加 if/else 条件渲染 非当前页不占渲染资源,性能优 底部导航的多 Tab 内容切换
色彩语义映射 四个独立工具函数按业务字段取色 业务规则收口,视图层零条件分支,可测试 按品类或类型做视觉区分的列表
列表项模板 图标加内容加价格三段式配 SushiTag 复用 五个版块视觉统一,认知负担最低 同构数据的批量展示
榜单进度条 双 Row layoutWeight 拼接前景与背景 无需图表库即可实现比例条,轻量 轻量数据可视化的排行展示
柱状图 ForEach 加 Column 高度映射数值 纯组件拼装,零依赖,可定制 移动端轻量统计图表
隔行变色 i 百分之二切换 cardBg 与 cardAlt 提升长列表可读性,零成本 数据行较多的列表场景
价格分级 阈值判断切换 primary 与 warning 高价项视觉醒目,辅助决策 需要突出高价值项的列表
微动效 @State 翻转配 rotate 与 translate 加 animation 声明终态自动插值,低成本提升活感 顶栏图标与卡片交互的趣味点缀
弹窗形态区分 字段卡录入或居中确认或单按钮详情 通过形态预判操作性质,降低误操作 录入编辑删除查看四类弹窗
暖帘装饰 数字数组加取模运算生成节奏条 最简数据撬动丰富视觉,纯装饰 品牌调性的氛围装饰
数据驱动导航 TAB_LIST 数组加 ForEach 动态生成 新增标签只需改数据,遵循开闭原则 可配置的底部导航栏
Mock 数据层 @Observed 类内置数组 无需后端即可跑通交互,便于原型 演示验证与开发期联调
单按钮详情 纯展示弹窗只配知道了按钮 纯阅读无决策,交互最简 只读信息的详情展开
统计卡片 三联卡 layoutWeight 等宽加大数字 最少面积传递关键指标 仪表盘式的核心指标速览

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

interface SushiPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  cardAlt: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  wasabiGreen: string;
  sakuraPink: string;
}

const COLORS: SushiPalette = {
  primary: '#C0392B',
  primaryLight: '#E08E82',
  primaryDark: '#7B241C',
  accent: '#3E6B4F',
  accentLight: '#A8C6B0',
  bg: '#FAF5EC',
  cardBg: '#FFFFFF',
  cardAlt: '#F5ECDD',
  textPrimary: '#33241C',
  textSecondary: '#8C7A6C',
  textHint: '#C0AF9E',
  border: '#EADCC8',
  line: '#E4D5C0',
  success: '#5E8C61',
  warning: '#D9A441',
  danger: '#C0392B',
  white: '#FFFFFF',
  wasabiGreen: '#7FA65A',
  sakuraPink: '#E8A0A8'
};

enum SushiTab {
  SUSHI = 0,
  COMBO = 1,
  SAKE = 2,
  CHEF = 3,
  REVIEW = 4
}

interface TabMeta {
  key: string;
  icon: string;
  label: string;
  color: string;
}

const TAB_LIST: TabMeta[] = [
  { key: 'sushi', icon: '🍣', label: '寿司', color: '#C0392B' },
  { key: 'combo', icon: '🍱', label: '定食', color: '#3E6B4F' },
  { key: 'sake', icon: '🍶', label: '酒水', color: '#D9A441' },
  { key: 'chef', icon: '👨‍🍳', label: '师傅', color: '#5E8C61' },
  { key: 'review', icon: '🌸', label: '评价', color: '#E8A0A8' }
];

const SLAT_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];

interface SushiItem {
  name: string;
  kind: string;
  price: number;
  rating: number;
  orders: number;
  emoji: string;
}

interface ComboItem {
  name: string;
  kind: string;
  price: number;
  calory: number;
  rating: number;
  emoji: string;
}

interface SakeItem {
  name: string;
  type: string;
  price: number;
  sales: number;
  degree: number;
  emoji: string;
}

interface ChefItem {
  name: string;
  title: string;
  years: number;
  rating: number;
  orders: number;
  emoji: string;
}

interface ReviewItem {
  name: string;
  score: number;
  date: string;
  content: string;
  tag: string;
  emoji: string;
}

interface WeekMeta {
  day: string;
  value: number;
}

interface StyleMeta {
  name: string;
  count: number;
  color: string;
}

interface ChefHotMeta {
  name: string;
  orders: number;
  color: string;
}

interface SakeTopMeta {
  name: string;
  sales: number;
  color: string;
}

const WEEK_SALES: WeekMeta[] = [
  { day: '周一', value: 42 },
  { day: '周二', value: 38 },
  { day: '周三', value: 46 },
  { day: '周四', value: 52 },
  { day: '周五', value: 78 },
  { day: '周六', value: 126 },
  { day: '周日', value: 108 }
];

const STYLE_SHARE: StyleMeta[] = [
  { name: '手握寿司', count: 5, color: '#C0392B' },
  { name: '军舰卷', count: 3, color: '#3E6B4F' },
  { name: '刺身拼盘', count: 2, color: '#D9A441' },
  { name: '加州卷', count: 2, color: '#5E8C61' }
];

const CHEF_HOT: ChefHotMeta[] = [
  { name: '藤原师傅', orders: 320, color: '#C0392B' },
  { name: '小林师傅', orders: 286, color: '#3E6B4F' },
  { name: '佐藤师傅', orders: 254, color: '#D9A441' },
  { name: '山田师傅', orders: 228, color: '#5E8C61' },
  { name: '高桥师傅', orders: 196, color: '#E8A0A8' },
  { name: '中村师傅', orders: 168, color: '#A8C6B0' }
];

const SAKE_TOP: SakeTopMeta[] = [
  { name: '清酒·菊正宗', sales: 520, color: '#C0392B' },
  { name: '梅酒·纪州', sales: 460, color: '#3E6B4F' },
  { name: '烧酒·麦烧', sales: 390, color: '#D9A441' },
  { name: '柚子酒', sales: 340, color: '#5E8C61' },
  { name: '抹茶拿铁', sales: 310, color: '#7FA65A' },
  { name: '朝日生啤', sales: 280, color: '#E8A0A8' }
];

@Observed
export class SushiData {
  sushes: SushiItem[] = [
    { name: '三文鱼手握', kind: '手握', price: 28, rating: 9.6, orders: 420, emoji: '🐟' },
    { name: '金枪鱼大腹', kind: '手握', price: 68, rating: 9.8, orders: 180, emoji: '🍣' },
    { name: '鳗鱼手握', kind: '手握', price: 38, rating: 9.5, orders: 360, emoji: '🫧' },
    { name: '玉子烧手握', kind: '手握', price: 12, rating: 9.0, orders: 520, emoji: '🍳' },
    { name: '甜虾军舰', kind: '军舰', price: 22, rating: 9.2, orders: 300, emoji: '🦐' },
    { name: '海胆军舰', kind: '军舰', price: 58, rating: 9.7, orders: 150, emoji: '⭐' },
    { name: '鱼籽军舰', kind: '军舰', price: 32, rating: 9.3, orders: 240, emoji: '🔴' },
    { name: '三文鱼刺身', kind: '刺身', price: 78, rating: 9.4, orders: 260, emoji: '🍽️' },
    { name: '北极贝刺身', kind: '刺身', price: 48, rating: 9.1, orders: 190, emoji: '🦪' },
    { name: '加州卷', kind: '卷物', price: 26, rating: 8.9, orders: 480, emoji: '🥑' },
    { name: '天妇罗卷', kind: '卷物', price: 32, rating: 9.0, orders: 220, emoji: '🍤' },
    { name: '樱花卷', kind: '卷物', price: 36, rating: 9.3, orders: 310, emoji: '🌸' }
  ];

  combos: ComboItem[] = [
    { name: '招牌寿司定食', kind: '午市', price: 68, calory: 620, rating: 9.5, emoji: '🍱' },
    { name: '鳗鱼饭定食', kind: '招牌', price: 78, calory: 680, rating: 9.6, emoji: '🍚' },
    { name: '刺身定食', kind: '午市', price: 88, calory: 540, rating: 9.4, emoji: '🐟' },
    { name: '天妇罗定食', kind: '午市', price: 72, calory: 700, rating: 9.1, emoji: '🍤' },
    { name: '亲子丼定食', kind: '经典', price: 48, calory: 590, rating: 8.9, emoji: '🥚' },
    { name: '牛丼定食', kind: '经典', price: 52, calory: 640, rating: 9.0, emoji: '🥩' },
    { name: '寿喜烧定食', kind: '晚市', price: 98, calory: 780, rating: 9.3, emoji: '🍲' },
    { name: '拉面定食', kind: '晚市', price: 58, calory: 660, rating: 8.8, emoji: '🍜' },
    { name: '烤物定食', kind: '晚市', price: 82, calory: 720, rating: 9.2, emoji: '🍢' },
    { name: '儿童寿司餐', kind: '儿童', price: 38, calory: 480, rating: 9.0, emoji: '🧒' },
    { name: '怀石迷你套餐', kind: '套餐', price: 168, calory: 850, rating: 9.7, emoji: '🎎' },
    { name: '双人刺身船', kind: '套餐', price: 198, calory: 980, rating: 9.6, emoji: '⛵' }
  ];

  sakes: SakeItem[] = [
    { name: '清酒·菊正宗', type: '清酒', price: 88, sales: 520, degree: 15, emoji: '🍶' },
    { name: '梅酒·纪州', type: '果酒', price: 68, sales: 460, degree: 12, emoji: '🍑' },
    { name: '烧酒·麦烧', type: '烧酒', price: 58, sales: 390, degree: 25, emoji: '🥃' },
    { name: '柚子酒', type: '果酒', price: 72, sales: 340, degree: 10, emoji: '🍊' },
    { name: '抹茶拿铁', type: '无酒精', price: 28, sales: 310, degree: 0, emoji: '🍵' },
    { name: '朝日生啤', type: '啤酒', price: 32, sales: 280, degree: 5, emoji: '🍺' },
    { name: '大吟酿·獭祭', type: '清酒', price: 268, sales: 120, degree: 16, emoji: '💎' },
    { name: '桂花酒', type: '果酒', price: 48, sales: 210, degree: 8, emoji: '🌼' },
    { name: '乌龙茶', type: '无酒精', price: 18, sales: 380, degree: 0, emoji: '🫖' },
    { name: '麒麟一番榨', type: '啤酒', price: 30, sales: 240, degree: 5, emoji: '🍻' },
    { name: '烧酒·芋烧', type: '烧酒', price: 62, sales: 160, degree: 28, emoji: '🍠' },
    { name: '气泡清酒', type: '清酒', price: 98, sales: 130, degree: 6, emoji: '🫧' }
  ];

  chefs: ChefItem[] = [
    { name: '藤原师傅', title: '料理长', years: 28, rating: 9.8, orders: 320, emoji: '🎩' },
    { name: '小林师傅', title: '副料理长', years: 18, rating: 9.5, orders: 286, emoji: '🧑‍🍳' },
    { name: '佐藤师傅', title: '寿司师傅', years: 15, rating: 9.3, orders: 254, emoji: '🍣' },
    { name: '山田师傅', title: '烤物师傅', years: 12, rating: 9.1, orders: 228, emoji: '🍢' },
    { name: '高桥师傅', title: '天妇罗师傅', years: 10, rating: 9.0, orders: 196, emoji: '🍤' },
    { name: '中村师傅', title: '刺身师傅', years: 14, rating: 9.2, orders: 168, emoji: '🔪' },
    { name: '铃木师傅', title: '甜品师傅', years: 8, rating: 8.8, orders: 148, emoji: '🍰' },
    { name: '伊藤师傅', title: '酒水师', years: 9, rating: 8.7, orders: 132, emoji: '🍶' },
    { name: '渡边师傅', title: '汤品师傅', years: 11, rating: 8.9, orders: 120, emoji: '🍲' },
    { name: '长谷川', title: '学徒', years: 3, rating: 8.2, orders: 86, emoji: '👦' },
    { name: '木村师傅', title: '寿司师傅', years: 16, rating: 9.4, orders: 210, emoji: '🐟' },
    { name: '冈田师傅', title: '拉面师傅', years: 13, rating: 9.0, orders: 176, emoji: '🍜' }
  ];

  reviews: ReviewItem[] = [
    { name: '寿司迷', score: 5, date: '08-15', content: '金枪鱼大腹入口即化,藤原师傅的手法太绝了!', tag: '手艺精湛', emoji: '🍣' },
    { name: '老饕', score: 5, date: '08-14', content: '怀石迷你套餐从头到尾都是享受,摆盘像艺术品。', tag: '体验极佳', emoji: '🎎' },
    { name: '上班族', score: 4, date: '08-13', content: '午市定食性价比高,鳗鱼饭分量足,出餐快。', tag: '价格实惠', emoji: '🍱' },
    { name: '抹茶控', score: 5, date: '08-12', content: '抹茶拿铁不甜腻,配刺身刚刚好。', tag: '饮品出色', emoji: '🍵' },
    { name: '情侣', score: 5, date: '08-11', content: '双人刺身船很丰盛,樱花卷颜值满分,约会首选!', tag: '氛围满分', emoji: '⛵' },
    { name: '亲子家庭', score: 4, date: '08-10', content: '儿童餐有小飞机造型,孩子吃得很开心。', tag: '亲子友好', emoji: '🧒' },
    { name: '深夜食客', score: 4, date: '08-09', content: '晚市的寿喜烧暖心暖胃,汤头很鲜。', tag: '味道不错', emoji: '🍲' },
    { name: '细节控', score: 3, date: '08-08', content: '周五晚高峰等位太久,翻台有点慢。', tag: '排队较长', emoji: '⏳' },
    { name: '清酒爱好者', score: 5, date: '08-07', content: '獭祭大吟酿配刺身,风味层次太丰富了。', tag: '酒水专业', emoji: '💎' },
    { name: '第一次来', score: 5, date: '08-06', content: '服务员很耐心介绍菜品,体验超出预期。', tag: '服务贴心', emoji: '🙇' },
    { name: '回头客', score: 4, date: '08-05', content: '每次来都点加州卷,稳定发挥从不翻车。', tag: '味道不错', emoji: '🥑' },
    { name: '摄影党', score: 5, date: '08-04', content: '樱花季限定卷太好拍了,出片率极高。', tag: '颜值在线', emoji: '🌸' }
  ];
}

function getKindColor(kind: string): string {
  if (kind === '手握' || kind === '军舰') {
    return '#C0392B';
  } else if (kind === '刺身') {
    return '#3E6B4F';
  }
  return '#D9A441';
}

function getSakeTypeColor(type: string): string {
  if (type === '清酒' || type === '烧酒') {
    return '#C0392B';
  } else if (type === '果酒') {
    return '#D9A441';
  } else if (type === '无酒精') {
    return '#5E8C61';
  }
  return '#3E6B4F';
}

function getTagColor(tag: string): string {
  if (tag === '手艺精湛' || tag === '体验极佳' || tag === '味道不错') {
    return '#C0392B';
  } else if (tag === '氛围满分' || tag === '服务贴心' || tag === '亲子友好') {
    return '#3E6B4F';
  } else if (tag === '价格实惠') {
    return '#5E8C61';
  } else if (tag === '排队较长') {
    return '#7B241C';
  }
  return '#E8A0A8';
}

function getComboColor(kind: string): string {
  if (kind === '招牌' || kind === '套餐') {
    return '#C0392B';
  } else if (kind === '午市') {
    return '#5E8C61';
  } else if (kind === '晚市') {
    return '#3E6B4F';
  }
  return '#D9A441';
}

@Entry
@Component
struct SushiApp {
  @State curTab: number = 0;
  @State data: SushiData = new SushiData();
  @State showAddSushi: boolean = false;
  @State showDeleteSake: boolean = false;
  @State showEditCombo: boolean = false;
  @State showDetail: boolean = false;
  @State delSakeName: string = '';
  @State detailChefName: string = '';
  @State sushiSpin: boolean = false;
  @State petalFall: boolean = false;

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
    }
    .width(1)
    .height(1)
  }

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('🍣 樱町寿司屋')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('Sakura Sushi · 江户前风味')
                .fontSize(10)
                .fontColor('#FFFFFFB3')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Row() {
              Text('🍣')
                .fontSize(20)
                .onClick(() => {
                  this.sushiSpin = !this.sushiSpin;
                })
                .rotate({ angle: this.sushiSpin ? 360 : 0 })
                .animation({ duration: 900, curve: Curve.EaseInOut })
              Text('🌸')
                .fontSize(18)
                .margin({ left: 10 })
                .onClick(() => {
                  this.petalFall = !this.petalFall;
                })
                .translate({ y: this.petalFall ? 8 : 0 })
                .animation({ duration: 600, curve: Curve.EaseInOut })
              Text('🍶')
                .fontSize(16)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor('#FFFFFF26')
            .borderRadius(20)
            .border({ width: 1, color: '#FFFFFF40' })
          }
          .width('100%')

          Row() {
            ForEach(SLAT_COL, (c: number) => {
              Column()
                .layoutWeight(1)
                .height(c % 2 === 0 ? 8 : 5)
                .margin({ left: 1, right: 1 })
                .backgroundColor(c % 4 === 0 ? COLORS.wasabiGreen : c % 4 === 2 ? COLORS.primaryLight : COLORS.white)
                .borderRadius(2)
            }, (c: number) => 'slat' + c)
          }
          .width('100%')
          .height(8)
          .margin({ top: 10 })
          .borderRadius(4)
          .clip(true)

          Row() {
            Column()
              .layoutWeight(1)
              .height(4)
              .backgroundColor('#FFFFFF66')
              .borderRadius(2)
            Text('🌸 一期一会 🌸')
              .fontSize(9)
              .fontColor('#FFFFFFCC')
              .margin({ left: 8, right: 8 })
            Column()
              .layoutWeight(1)
              .height(4)
              .backgroundColor('#FFFFFF66')
              .borderRadius(2)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.primaryDark)

        if (this.curTab === SushiTab.SUSHI) {
          SushiContent({ data: this.data, onAdd: () => {
            this.showAddSushi = true;
          } })
        } else if (this.curTab === SushiTab.COMBO) {
          ComboContent({ data: this.data, onEdit: () => {
            this.showEditCombo = true;
          } })
        } else if (this.curTab === SushiTab.SAKE) {
          SakeContent({ data: this.data, onDel: (n: string) => {
            this.delSakeName = n;
            this.showDeleteSake = true;
          } })
        } else if (this.curTab === SushiTab.CHEF) {
          ChefContent({ data: this.data, onDetail: (n: string) => {
            this.detailChefName = n;
            this.showDetail = true;
          } })
        } else {
          ReviewContent({ data: this.data })
        }
      }
      .width('100%')
      .height('100%')

      Row() {
        ForEach(TAB_LIST, (t: TabMeta) => {
          Column() {
            Text(t.icon)
              .fontSize(19)
            Text(t.label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
              .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.primaryDark : COLORS.cardBg)
          .borderRadius(20)
          .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '88' } : { width: 1, color: COLORS.line })
          .onClick(() => {
            this.curTab = TAB_LIST.indexOf(t);
          })
        }, (t: TabMeta) => t.key)
      }
      .width('100%')
      .height(60)
      .padding({ left: 8, right: 8 })
      .backgroundColor(COLORS.cardBg)
      .border({ width: 1, color: COLORS.line })

      if (this.showAddSushi) {
        AddSushiModal({
          onClose: () => {
            this.showAddSushi = false;
          }
        })
      }
      if (this.showDeleteSake) {
        DeleteSakeModal({
          title: this.delSakeName, onClose: () => {
            this.showDeleteSake = false;
          }
        })
      }
      if (this.showEditCombo) {
        EditComboModal({
          onClose: () => {
            this.showEditCombo = false;
          }
        })
      }
      if (this.showDetail) {
        DetailChefModal({
          name: this.detailChefName, onClose: () => {
            this.showDetail = false;
          }
        })
      }
    }
  }
}

@Component
struct SushiTag {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .backgroundColor(this.color + '1F')
      .borderRadius(8)
      .border({ width: 1, color: this.color + '40' })
  }
}

@Component
struct SushiContent {
  @ObjectLink data: SushiData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 周营业额')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六¥126千')
              .fontSize(9)
              .fontColor(COLORS.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_SALES, (w: WeekMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 100 ? COLORS.primary : COLORS.textSecondary)
                Column()
                  .width(20)
                  .height(w.value * 1.1)
                  .backgroundColor(w.value >= 100 ? COLORS.primary : COLORS.accent)
                  .borderRadius(6)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekMeta) => w.day)
          }
          .width('100%')
          .height(140)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.primary + '66' })

        Column() {
          Row() {
            Text('🍣 菜单品类分布')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('手握寿司最多')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(STYLE_SHARE, (n: StyleMeta) => {
            Row() {
              Text(n.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(76)
              Row() {
                Row()
                  .layoutWeight(n.count / 5)
                  .height(10)
                  .backgroundColor(n.color)
                  .borderRadius(5)
                Row()
                  .layoutWeight(1 - n.count / 5)
                  .height(10)
                  .backgroundColor(COLORS.cardAlt)
                  .borderRadius(5)
              }
              .layoutWeight(1)
              .height(10)
              Text(n.count + '款')
                .fontSize(9)
                .fontColor(n.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (n: StyleMeta) => n.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('🍣 招牌寿司')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕上新')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.sushes, (s: SushiItem, i: number) => {
            Row() {
              Column()
                .width(44)
                .height(44)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getKindColor(s.kind) + '22')
                .borderRadius(14)
                .border({ width: 2, color: getKindColor(s.kind) + '55' })
              Text(s.emoji)
                .fontSize(19)
                .width(30)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(s.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  SushiTag({ text: s.kind, color: getKindColor(s.kind) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('评分' + s.rating + ' · 日售' + s.orders + '份')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text('¥' + s.price)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(s.price >= 50 ? COLORS.primary : COLORS.accent)
                Text('每贯')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(16)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (s: SushiItem, i: number) => s.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.primary + '66' })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct ComboContent {
  @ObjectLink data: SushiData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🏆 师傅热度榜')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('藤原师傅居首')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(CHEF_HOT, (h: ChefHotMeta, i: number) => {
            Row() {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? COLORS.primary : COLORS.textHint)
                .width(22)
              Text(h.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(76)
              Row() {
                Row()
                  .layoutWeight(h.orders / 340)
                  .height(10)
                  .backgroundColor(h.color)
                  .borderRadius(5)
                Row()
                  .layoutWeight(1 - h.orders / 340)
                  .height(10)
                  .backgroundColor(COLORS.cardAlt)
                  .borderRadius(5)
              }
              .layoutWeight(1)
              .height(10)
              Text(h.orders + '单')
                .fontSize(9)
                .fontColor(h.color)
                .width(40)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (h: ChefHotMeta, i: number) => h.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.accent + '66' })

        Column() {
          Row() {
            Text('🍱 定食菜单')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✏️调整')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('✏️')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onEdit();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.combos, (c: ComboItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getComboColor(c.kind) + '22')
                .borderRadius(13)
              Text(c.emoji)
                .fontSize(18)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  SushiTag({ text: c.kind, color: getComboColor(c.kind) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(c.calory + '卡 · 评分' + c.rating)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text('¥' + c.price)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(c.price >= 100 ? COLORS.warning : COLORS.primary)
                Text('整份')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(16)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (c: ComboItem, i: number) => c.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct SakeContent {
  @ObjectLink data: SushiData;
  onDel: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🍶 酒水销量TOP6')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('菊正宗夺冠')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(SAKE_TOP, (p: SakeTopMeta, i: number) => {
            Row() {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? COLORS.primary : COLORS.textHint)
                .width(22)
              Text(p.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(92)
              Row() {
                Row()
                  .layoutWeight(p.sales / 560)
                  .height(10)
                  .backgroundColor(p.color)
                  .borderRadius(5)
                Row()
                  .layoutWeight(1 - p.sales / 560)
                  .height(10)
                  .backgroundColor(COLORS.cardAlt)
                  .borderRadius(5)
              }
              .layoutWeight(1)
              .height(10)
              Text(p.sales + '杯')
                .fontSize(9)
                .fontColor(p.color)
                .width(44)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (p: SakeTopMeta, i: number) => p.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.warning + '66' })

        Column() {
          Row() {
            Text('🍶 酒水单')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击🗑️下架')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.sakes, (s: SakeItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getSakeTypeColor(s.type) + '22')
                .borderRadius(13)
              Text(s.emoji)
                .fontSize(18)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(s.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  SushiTag({ text: s.type, color: getSakeTypeColor(s.type) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(s.degree + '度 · 已售' + s.sales + '杯')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text('¥' + s.price)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(s.price >= 100 ? COLORS.warning : COLORS.primary)
                Text('每壶')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
                Text('🗑️')
                  .fontSize(12)
                  .margin({ top: 4 })
                  .onClick(() => {
                    this.onDel(s.name);
                  })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(16)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (s: SakeItem, i: number) => s.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct ChefContent {
  @ObjectLink data: SushiData;
  onDetail: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('👨‍🍳 板前师傅')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('点击查看简介')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 10 })

        Row() {
          Column() {
            Text('12')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('在职师傅')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(18)
          .border({ width: 1, color: COLORS.primary + '66' })
          Column() {
            Text('28年')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.warning)
            Text('最长资历')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(18)
          .border({ width: 1, color: COLORS.warning + '66' })
          .margin({ left: 10 })
          Column() {
            Text('9.8')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('最高评分')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(18)
          .border({ width: 1, color: COLORS.accent + '66' })
          .margin({ left: 10 })
        }
        .width('100%')
        .margin({ bottom: 12 })

        ForEach(this.data.chefs, (c: ChefItem, i: number) => {
          Row() {
            Column()
              .width(42)
              .height(42)
              .justifyContent(FlexAlign.Center)
              .backgroundColor(COLORS.sakuraPink + '22')
              .borderRadius(14)
            Text(c.emoji)
              .fontSize(18)
              .width(28)
              .textAlign(TextAlign.Center)
            Column() {
              Row() {
                Text(c.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                SushiTag({ text: c.title, color: COLORS.accent })
                  .margin({ left: 6 })
              }
              .width('100%')
              Text('从业' + c.years + '年 · 日接' + c.orders + '单')
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 8 })
            Column() {
              Text(c.rating + '')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(c.rating >= 9.5 ? COLORS.primary : COLORS.warning)
              Text('评分')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
              Text('👁️')
                .fontSize(12)
                .margin({ top: 4 })
                .onClick(() => {
                  this.onDetail(c.name);
                })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding({ left: 10, right: 12, top: 9, bottom: 9 })
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(16)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (c: ChefItem, i: number) => c.name + i)
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct ReviewContent {
  @ObjectLink data: SushiData;

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🌸 食客评价')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('平均4.5分 · 共12条')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 10 })

        ForEach(this.data.reviews, (r: ReviewItem, i: number) => {
          Column() {
            Row() {
              Column()
                .width(36)
                .height(36)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getTagColor(r.tag) + '22')
                .borderRadius(12)
              Text(r.emoji)
                .fontSize(15)
                .width(26)
                .textAlign(TextAlign.Center)
              Column() {
                Text(r.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text('⭐'.repeat(Math.round(r.score)) + ' · ' + r.date)
                  .fontSize(9)
                  .fontColor(COLORS.warning)
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              SushiTag({ text: r.tag, color: getTagColor(r.tag) })
            }
            .width('100%')
            Text(r.content)
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .lineHeight(18)
              .width('100%')
              .margin({ top: 8 })
          }
          .width('100%')
          .padding(12)
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(16)
          .border({ width: 1, color: COLORS.line })
          .alignItems(HorizontalAlign.Start)
          .margin({ bottom: 8 })
        }, (r: ReviewItem, i: number) => r.name + i)
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct AddSushiModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🍣')
            .fontSize(26)
          Column() {
            Text('寿司上新')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('录入新菜品')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('菜品名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('炙烤三文鱼 · 手握')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 14 })

        Row() {
          Text('食材产地')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('挪威三文鱼 · 空运')
            .fontSize(12)
            .fontColor(COLORS.accent)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('建议售价')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('¥32 · 限量每日30贯')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('上架安排')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('周末限定 · 明日起')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Text('✅ 新菜品须经料理长试菜通过,食材检验单随货附上。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('确认上新')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.primary)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.primary + '66' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct DeleteSakeModal {
  @Prop title: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Column() {
          Text('🗑️')
            .fontSize(34)
          Text('确认下架酒水')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ top: 10 })
          Text('「' + this.title + '」将从酒水单移除,已开瓶存货转入内部品鉴。')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .textAlign(TextAlign.Center)
            .width('100%')
            .margin({ top: 8 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('确认下架')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.danger)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.danger + '66' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct EditComboModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🍱')
            .fontSize(26)
          Column() {
            Text('编辑定食菜单')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('调整价格与配菜')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('定食名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('招牌寿司定食 · 午市')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 14 })

        Row() {
          Text('价格调整')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('¥68 → ¥72')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('配菜调整')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('加赠味噌汤+茶碗蒸')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('调整说明')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('应对食材成本上涨')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Text('⚠️ 菜单调整同步更新外卖平台,会员日打九折不变。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
          Text('保存修改')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.accent)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.accent + '66' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct DetailChefModal {
  @Prop name: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('👨‍🍳')
            .fontSize(26)
          Column() {
            Text('师傅简介')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('师傅「' + this.name + '」')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('职级职称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('料理长 · 从业28年')
            .fontSize(12)
            .fontColor(COLORS.primary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 14 })

        Row() {
          Text('擅长领域')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('江户前寿司 · 切付')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Row() {
          Text('顾客评分')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('9.8分 · 日接320单')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .margin({ top: 8 })

        Column() {
          Text('个人介绍')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('师承东京银座名店,握寿司手法传承三代。坚持每日凌晨市场亲自选鱼,对醋饭的温度与酸度有近乎苛刻的要求,被食客称为「板前艺术家」。')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .lineHeight(20)
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Start)
        .margin({ top: 8 })

        Text('🍣 板前座位需提前一天预约,可观摩师傅现场制作。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Text('知道了')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS.primary)
          .borderRadius(12)
          .margin({ top: 16 })
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.primaryLight + '66' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}


结尾总结

在这里插入图片描述

纵观这份樱町寿司屋门店经营看板,我们能看到一套兼顾美学表达与工程严谨的完整架构。从设计理念维度看,它把"和食暖调"的色彩美学贯彻到了每一个组件——从顶栏深红到卡片米白,从品类色映射到按钮语义色,色彩不是装饰而是信息的载体。主色红取自金枪鱼、辅色绿取自山葵、点缀橙取自玉子烧,这套色板既是视觉系统也是品牌叙事,让经营者在看数据时仿佛坐在板前。更难得的是,色彩与业务语义深度绑定:高价红、平价绿、破坏性操作红、保存操作绿,形成了自洽的色彩语义系统,这是许多应用未做到的层次。

从技术选型维度看,项目充分运用了 ArkUI 声明式范式的状态管理能力。@State 管控入口组件的显隐与路由状态,@Observed+@ObjectLink 实现跨组件的可观察数据共享,@Prop 实现弹窗的单向数据流入,@Builder 提供组件内复用片段。四类装饰器各司其职,构建出从顶层入口到底层弹窗的完整数据流通链路。"数据下沉、事件上抛"的单向数据流模式,使得状态可预测、可追溯,避免了双向绑定的复杂性。条件渲染做标签路由、条件渲染做弹窗显隐,都是声明式范式"状态驱动视图"的典型应用。

从组件化维度看,项目遵循"单一职责、数据驱动、组合优先"三大原则。入口组件充当状态中枢,五个内容页各管一个业务版块,四个弹窗各管一类写操作,一个标签组件全局复用。组件间通过清晰的接口(数据属性+事件回调)协作,内部封装各自实现。这种划分使得每个组件可独立理解、独立测试、独立变更。内容页的列表项模板高度统一(图标-内容-价格三段式),弹窗的布局骨架一致(头部-字段-按钮),通过模板化复用降低了维护成本又保持了视觉统一。若后续演进,可进一步抽取 RankBarModalShellListItem 等基础组件,提升复用边界。

从交互设计维度看,项目体现了若干成熟实践。"列表-详情"模式(师傅列表→简介弹窗)、"行内触发+弹窗确认"的破坏性操作范式(酒水下架)、"标题内嵌操作入口"的便捷入口(寿司上新➕)、"渐进式披露"的信息架构(列表简+详情全),都是经过验证的交互模式。四个弹窗的形态与操作性质对应(录入字段卡、编辑字段卡、确认居中图、详情字段卡),让用户通过弹窗外观即可预判操作影响。微动效(寿司旋转、樱花飘落)用极低成本提升生命感,是体验的点睛之笔。

从工程考量维度看,项目也有可改进之处。其一,统计卡片的数值(12、28年、9.8)与弹窗字段值(料理长·28年等)是硬编码,应改为从数据实例动态计算或查找,以保证一致性。其二,四个配色函数虽收口了色彩逻辑,但榜单渲染逻辑高度同构,可抽象为通用 RankBar 组件。其三,纯前端 Mock 数据便于演示,但接入真实后端时需补充分层的数据服务。这些改进点不影响当前架构的有效性,反而印证了架构的可演进性——正是因为分层清晰,这些改进都可在局部完成而不牵动全局。

综合而言,这份看板示范了如何用声明式 UI 范式构建一个"美观、可用、可维护"的中型移动端应用。它的色彩系统、状态管理、组件划分、交互模式都有值得借鉴的设计决策,是餐饮经营类应用的一个高质量参考实现。希望本文的逐段拆解能帮助读者不仅理解"怎么写",更能体会"为什么这样写"的架构思考。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐