这篇博客的标题有营销号夸大其辞的感觉——我没有专门学过任何有关产品设计、UI 设计、交互设计或音效的知识,也没有艺术修养。可接下来我要凭我的直觉、感觉和极简的核心原则——我就是一位严苛的用户,诤略参谋首先要让我觉得好用——打着我从未涉足的领域的旗号大放厥词。

如果你是专业人士,你应该会看到一名门外汉用严肃的过程导出了愚蠢的结果。这名愚蠢的门外汉将详细记录他的“心路历程”——希望找茬能带给你乐趣,希望代入门外汉能让你找茬的乐趣翻倍。

我认为用户体验主要受交互样式性能健壮性影响。

  • 交互:
    1. 交互链应尽量简短、符合直觉,避免学习成本。把必然复杂的任务拆分成有逐步引导的多步交互,每一步里只聚焦一个简单的动作。
    2. 交互反馈应全面、清晰、及时,不要让用户花心思怀疑自己的行为是否有效,也竭力避免用户对错误不知所措。
    3. 避免 Modal 等阻塞式的交互。用户讨厌被打断。
  • 样式:
    1. 利用适当的颜色、边框、阴影、动画等突出重点内容,控制用户注意力的流向。不要给非重点内容设置同样花哨的样式,这会模糊重点,用户一眼看不到重点时只能随机选一处信息作为阅读起点,这会让用户感到不确定、迷茫、怀疑和疲惫。
    2. 设计布局、排版、字号、字重、行高和间距,让语义或逻辑相关的交互入口或内容聚集,辅以 icon 等简单注释信息,让内容节奏良好、张弛有度、呼吸顺畅——就是易读。不要用文字墙吓跑用户。
    3. 和谐统一(除非你在整什么实验性艺术或是反向利用不和谐感)。这与突出重点的要求相辅相成,有重点就必然有“平凡”(“非重点”),“平凡”就是与周边的同类融为一体,“同类”和“融为一体”意味着统一。这需要用设计规范或组件库约束。
    4. 网页的气质通常通过堆细节来换。你要是想给人冷硬的感觉,你可能需要联合使用高对比度但较为单一的配色、直角边框、无衬线体、充满暗示意味的 icon 和几何图样等。你若是堆“敷衍”的细节,最后网页就会向用户无声控诉你的敷衍。
    5. 可以给用户一定自主选择权。非必须。
  • 健壮性:
    1. 响应式。用户需要它,团队里用着各式显示配置的开发者也需要它——否则连开发者自己都不能看清自己开发的项目的全貌,那可太扫兴了。
    2. HTML 或 CSS 特性在未适配的浏览器上——请 IE 浏览器出列——fallback。
    3. 减少“未定义行为”,404 页面等都该准备好。
    4. 不要让过长的内容——比如浮点数——破坏页面布局。
  • 性能:
    1. 压缩图像等静态资源,降低文件大小,进而降低加载时间。
    2. 尽量保证无缝刷新,局部刷新而非整页刷新。
    3. 平衡样式和性能开销。例如,假设我正在用 JS 实现复杂动画,我会优先考虑性能,实在不行会放弃动画。
    4. 考虑后端服务器的数据库查询性能等,合理设计一些 API 的行为,让它们把计算成本分摊到日常请求中,或一次性 LAZY 地工作。
    5. 使用缓存等技术。

诤略参谋会刻意忽略上面的很多建议,比如“健壮性第二条”、“性能第一条”。原因——

  • 现阶段能力有限。
  • 这只是一个玩具级别的学校课程设计,不好听的现实就是一旦考核结束它就会被尘封,除了把它视作心血结晶的个别开发者外无人会在意它。我们有很多更重要且时间窗口不等人的事要做——比如准备考研,所以我们拒绝在诤略参谋中投入过多时间精力。
  • 我不喜欢重复。我想做些(至少乍一看)让人觉得新奇的东西。所以我会故意说——不要考虑什么大企业经典排版、配色了,这些大企业不靠网页样式获客,它们最重要的是稳定运行、列出无漏洞的文档,所以这些网页稳定(或者说枯燥)得让人想上吊。工作后有的是机会做这种东西,所以在学生时期还是做点儿新奇的东西吧,哪怕这“新奇的东西”到头来没有“枯燥的东西”好用。不要考虑图像性能等问题,可劲造。

让我们开始吧。

在这里插入图片描述

样式

前情回顾

我们其实已经在前三篇博客中考虑了很多与 UX 设计相关的内容。只作简单回顾,不再详细描述思路与实现。

  • 博客“一、项目伊始”:
    • 绘制了一系列 UI 布局草稿。
  • 博客“二、数据通讯(上)”:
    • 讨论了 Field 组件应该是 A-P 式、oA 式还是模仿 Google 的样式。虽然我之前说要用类似 Google 的样式,我最终还是只留在了 oA 式。原因是类 Google 样式下如果文本框的背景是不透明纯色,那聚焦时向左上角移动的提示文本也需要有同色不透明背景。如果 Field 容器的背景里有复杂纹路,那么 Google 式提示文本自带的纯色背景边缘会很突兀。
    • 考虑了 FieldBtn 的错误提示样式,包括显示时机(失焦且符合固定规则时)、抖动与被禁用的 Btn 关联的不合格 Field 等。
  • 博客“三、数据通讯(下)”:
    • 讨论了弹窗的风格、位置和行为统一问题,实现了全局弹窗系统,确定了弹窗的样式。
    • 确定 Deletor 组件有 btnicon 两种样式。

人物驱动”设计

如果你是 CSDN 塞给我的僵尸号中的忠实僵尸(好吧,能看到这句话的只能是真人读者,我开玩笑的,没有冒犯之意),你应该注意到了我的每篇博客开头都有一张画风格格不入的插图。插图里带着头巾的就是我们的诤略参谋了。我希望用户有一种在与一位“活的”参谋合作商讨的感觉,我希望用户感觉自己并不孤单,我不希望用户认为自己在对着一个非人类 I/O。我希望用户提到“诤略参谋”四个字就想起这个形象,希望项目有趣、有温度一些,奢望用户甚至开发者会不知不觉忘记项目的底层是 DeepSeek-R1。所以整个项目的样式都是为这位参谋定制的舞台。接下来,让我讲讲他的来路。

“诤略参谋”之名

很久之前我就有了与“诤略参谋”类似的想法。我和 GPT-4o 聊天,GPT-4o 给那个系统起了个名字叫“智域司令(Cerebrium)”。我喜欢这个名字,朴实但好听、准确且达意,但我认为这夸大了系统的能力和应用范围,同时“司令”给人一种主导感,我或许过于上纲上线了——我认为这个名字可能暗示用户放弃主导思考、完全依赖 AI。这不太好,所以我封存了我其实很喜欢的这个名字。

几个月后,学校的创新项目实训课来袭,要使用 DeepSeek-R1 API 写项目。于是我取出了封存的系统想法。受“司令”的启发,我想到了“参谋”——辅佐你思考,而非主导或替代你思考,非常合适。但我该直接使用“参谋”一词吗?在个别语境下,它可能与现实中的参谋混淆,路人听到我们聊项目可能会误以为我们在玩军事扮演。于是我又看了一眼“智域司令”,决定追加一个前缀,“××参谋”。我对前缀填什么毫无头绪,但此时我已经定了项目基调:

LLM 可以在我们列计划时扮演一位虎视眈眈的对手和吹毛求疵的参谋。有无心之失时它提醒我们补充,犯有意之错时它迫使我们消除一切马虎。它是对世界知识的有损压缩,因此与它合作将扩展我们的认知边界,补充我们缺失的视角和遗漏的因素,尽可能地识别和纠正我们的误解;它是第三方,因此它没有当局者迷的思维惯性;它忠诚地按提示词行事,于是它殚精竭虑,永不懈怠。我们懈怠时,它为我们兜底。

当时是 2025 年 3 月,考虑到 DeepSeek-R1 在中文创意写作领域一骑绝尘,我拿着项目基调跟(官方网站上的) DeepSeek-R1 聊了一会儿。它提到了“诤策官(Critical Strategy Officer)”这个名字——

“诤”取自历史谏官文化,强化对抗性建议的正当性;军衔式后缀“官”制造专业距离感,避免过度拟人。

我觉得“诤”字除了有些生僻外,确实很合适,于是我把“诤策”拿过来,“策”表“计划”,“诤策参谋”。

  • “诤”取自诤言,意味着直言进谏,有对抗性但出于好意的建议,这符合项目中的对抗性改进的概念。
  • “策”对应策略或计划,点明功能与策略(计划)相关。
  • “参谋”明确了角色定位,作为辅助者而非主导者,避免用户过度依赖 AI。同时他代表盟友,而非敌人,即使他会刻意扮演一位吹毛求疵的对手。

几乎完美,但是有个致命缺点……100% 会被听成“政策参谋”。所以——“诤略参谋”。至于英文名“Critistrat”,你应该已经发现它是“Critical”和“Strategy”合成而来的词。

LOGO

有了名字,接下来自然考虑 LOGO。我们在用 DeepSeek-R1 的能力实现一位参谋。“DeepSeek-R1” 让我想到 AI 与科技,参谋让我想到军人。我实在是受够了低质量设计图中烂大街的科技元素,千篇一律的比特串、无向图、六边形、机器人、芯片、脑子上画着电路的向人伸着白手的人脸人工智能……我不是说这些元素有原罪,但我实在厌倦了它们。“参谋”让我想到军人——人!我还是觉得人比 AI 更亲切,而且正好排除了烂大街的科技元素。

不用科技元素,我该找什么元素呢?我把项目基调交给 GPT-4o,让它随便谈谈它的感受。我想从它的输出里翻出些好主意——真翻到了——军旗、地图、战术符号。

图-4.1_利用 GPT-4o 获取启发
2025 年 3 月 12 日,Google 开放了 Gemini 2.0 Flash (Image Generation) Experimental(下文简称“Gemini”);14 天后的 3 月 26 日,OpenAI 发布了 GPT-4o 的图像生成功能……我、GPT-4o 和 Gemini 各有各的无能为力,但我们这三个臭皮匠或许能顶掉十分之一个诸葛亮——对于学校项目实训来说,够用了。

  • 我不会画图,GPT-4o 会,所以 GPT-4o 负责根据描述画出图像原型。
  • GPT-4o 改图时保持细节一致性的能力很差,Gemini 好一些,我最好。因此局部微调图像、需要保留绝大部分原图细节时找 Gemini,实在不行我就亲自 PS,把上一张图里我想保留的细节融入下一张图里。
  • 两个模型都不擅长绘制汉字(即使是英文也经常出错),因此文本完全交给我负责。
  • 两个模型遵循精细指令的能力非常差,如果我糟糕的 PS 能力能应付细节需求,我上场;否则放弃。

先画 LOGO:

图-4.2_制作 LOGO 的过程摘要
图-4.3_LOGO 成果展示

  • 使用剪影,符合网页类似扁平化的风格、便于融入各种背景。隐藏面容会带来一丝捉摸不透的神秘感(也是规避西方面容的取巧手段),简洁、锐利的配色方案和轮廓能带来一些强硬、严肃、冷峻的感觉。
  • 利用战术符号和铅笔告知用户这是“动脑子、想方案”的“参谋/智囊”类角色。
  • 带有一些硝烟气,故意让头发和胡茬相对其他部分较不规则,头巾借鉴了《我的团长我的团》中进行沙盘推演时的死啦死啦。这些是在营造氛围,更重要的是暗示用户参谋实践经验丰富、脚踏实地,不是纸上空谈。

首页

GPT-4o 画的脸很西方,我没意见,但诤略参谋面向国内,可能让用户感到有些陌生。因此我试着让 GPT-4o 绘制东方面孔,很费劲,而且出来的大部分是华裔脸,照样感到陌生。所以……西方就西方吧(啊!真相是面向课程答辩)。

上文提到,我希望做一些相对新奇有趣的东西,哪怕它们的质量比不过久经验证的平常。我还说,我希望用户有一种在与一位“活的”参谋合作商讨的感觉,我希望用户感觉自己并不孤单。所以我要在用户打开网页的第一眼上做文章了。我的初步设想是让“登录/注册”面板作为一块板子位于欢迎页的中央,让参谋“抱着或扶着”它,看向用户,示意用户填写面板——这样就有了互动感。GPT-4o 听了我的构想觉得很有趣,然后给了我更有趣的输出:

图-4.4_令人忍俊不禁的首页
有趣,着实有趣!我当时没有告诉 GPT-4o 要写什么文字,“你要进战账,先交个底”、“入伍”和“清算”等文字全是 GPT-4o 自己加的。瞧瞧这措辞、打光和脸色……氛围营造着实用力过猛,用户要被吓跑啦。第一张图片加载在我眼前时成功让我忍俊不禁。但是有趣!着实有趣,我要的就是有趣,让我能忍俊不禁的有趣。

用力过猛是可以逐渐调回来的,更大的问题是我已经决定网页主要服从现代简洁风格而非写实风格。我不喜欢卡通化人物,因为这很大程度上削弱了我想营造的参谋的气质。但是写实会导致与网页其他部分的风格很割裂……我一度毫无头绪。下面几张先不论过于严肃的问题,整体上很有意思,可是背景要么硝烟气过重,(相比其他部分)显得脏、乱、差,要么有很深的雾,容易让用户感到压抑。这些问题交织,我甚至考虑过放弃。

图-4.5_我挺喜欢但是画风不符的首页
虽然暂时搁置了首页设计,我并没有停止骚扰 GPT-4o。三月末有一批人痴迷于用 GPT-4o 把一切吉卜力化,我没干这种事,但我对新的图像生成能力……一样痴迷。我画了很多画,中途……灵光一现!

  • 走一条半写实的路线。让参谋本身的风格类似沙画(写实、高细节,但是非常风格化)。
  • “白色背景上一片黑”是混乱,那纯黑的背景呢?“乌鸦在黑夜里飞”,很干净。而且结合沙画风格,我可以让大片区域随意地过渡到纯黑,这非常有利于后续与背景的融合。

图-4.6_半写实且面板坚持拟物

此时我的思维仍在坚持把“登录/注册”面板拟物。我总想着让它作为被参谋拿着的牌子或本子。这个思路本身没问题,只是在编写具体 CSS 样式时我们不方便把真正的组件和图上画的框对齐。考虑响应式的情况时特别不方便。

灵光二现!我不再执着于把面板拟物了。哪怕面板不是参谋手中握着的牌子或本子,而是与普通网页无异的扁平面板,我照样能把它和写实的参谋融在一起,我照样有办法让参谋和面板交互。

图-4.7_一步之遥

让参谋的手倚着或指着面板,用眼神和动作示意用户,这能同时做到:

  • 让参谋保持半写实的同时让面板变成 CSS 代码易实现的常规扁平样式。因为两者的风格差距大得超过了阈值,你反而不会觉得样式混乱。你会认为二者泾渭分明。
  • 在泾渭分明的同时保持参谋与面板的交互感。参谋没有拿着实际的东西,可他“穿透了第四面墙”,依旧能指着或靠着这个面板。
  • 半写实的、大画幅的参谋会吸引用户的视线,用户实现被吸引后关注到参谋的眼神和动作,注意力顺着参谋的目光和手指流向面板,我们用人物驱动了“登录/注册/忘记密码”的任务。
  • 参谋与用户交互。

容我恬不知耻地自夸一句——这方案简直完美。

当时我还没有开始写代码。三个臭皮匠继续完善首页草稿。首先,我要抹去 GPT-4o 自动帮我画上的面板。这个面板挡住了参谋的胳膊,我试图用 PS 抹去它,可是我的 PS 能力实在惨不忍睹,PS 后参谋的胳膊出现了色差(我没法很好地抹除遮盖了参谋胳膊的那一部分面板)。于是我对着胳膊和面板重叠的部分截图——模型不擅长保持细节?这一部分在原图里是细节、是局部,可在截图中就是全部!我把“全部”交给 Gemini,让它抹去了“全部”中的面板,再把 Gemini 给出的结果作为局部 P 到原图中,抹去面板同时保证光影正常。

图-4.8_首页草稿加工过程

另一个值得一提的点是提示词不只有“文本提示词”一种。随着这两个模型到来,我们该向自己的世界观里加入“图像提示词”了。我想让 GPT-4o 以固定位置画一支固定尺寸和朝向的铅笔,可这已经属于细节问题,GPT-4o 在细节上总是听不懂人话,于是我在图上粗略画出了红色的铅笔剪影,让 GPT-4o 自己看图修改红色剪影部分。这个能力还可以用来把草稿一键转为真正可用的设计稿。有人这样评价——“神笔马良是科幻故事”。

图-4.9_首页草稿
到这个时候,我已经确定了要用“帮你定计划?可以。先报上名来”这句话了,它既指出了核心服务——“定计划”,又在眼神示意和用手指示外再次暗示用户去填写面板。此外,我故意用对话风格强化参谋的气质以及用户与参谋的交互感。我已经想好要给这句话设置“逐个打字”的效果了。

把时间快进到写代码时。我有些贪心——我又想把背景改回较为明亮的风格了。我先让 GPT-4o 生成了一版亮色的战术地图,我把参谋从首页草稿中截出来,放到了新战术地图里。

图-4.10_首页落地-1

使用了之前同样的想法——把差距拉得够大,泾渭分明就会成为一种风格,而非一种格格不入。我给黑块加了内阴影,一方面是增加层次感和和谐程度,另一方面可以让人感到参谋从阴影世界中缓缓现身,有一种神秘和稳重感。我会在右侧设置同为黑块背景的面板,这种同色暗示用户“参谋正在邀请我进入他所在的黑块世界(项目核心功能区),我也应该在同是黑块的面板上填写信息”。另外,因为在很多分辨率设置下图像不能合理填满 viewport,我在最底下叠了一层施加了模糊效果的、铺满整个屏幕的战术地图。可是这么做会导致实际首页图像的边缘很突兀。那么……把实际首页图像的背景删掉,直接完全利用模糊层不就好了?

图-4.11_首页落地-2

此外,我让 Gemini 2.5 Pro Preview 03-25 帮我写了 TypeWriter 组件,这个组件接收一个字符串参数,它遇到 @ 时会短停顿,遇到 @@ 时会长停顿,遇到 ^ 时会高亮被 ^ 包裹的文字。我给 TypeWriter 传递的字符串是——“"帮你@^定计划^?@@可以。@@报上名来——"”。你会发现我在重点前插入短停顿,在新句子前插入长停顿,进而模拟人的说话节奏。我还为首页添加了两个缓慢转动的主色虚线圆圈作为装饰,它们也可以作为视觉引导线暗示用户的目光转向面板。首页搞定!

图-4.12_首页

我已经详细演示了怎样与模型合作制作艺术图片。接下来的小节里我不再讲述制作过程,直接呈现最终结果。

404 NOT FOUND

图-4.13_404

节选废案:

图-4.14_404 废案

作者信息

我自认为想到了比单纯陈列文字外更有趣的方案。下图中的“×××”是被隐去的姓名。

图-4.15_作者信息-low
你大概会认为这是用几句话、几分钟就能产出的图片,实际上……我可能在这一张图上投入了二十个小时。这张图对人物的布局和神态等细节要求(相对)很高,我大概用了三百多份模型生成的图片拼出了这张图。两个模型的细节理解与遵循能力差的要命,它们无法理解任何带多个定语的要求,它们连上下左右都分不清。我被 Gemini 2.0 Flash (Image Generation) Experimental 折磨坏了,我决定直接给它牢牢扣上“听不懂人话”这顶大帽子。

Banner

每篇博客的开头都会有一张不是技术示意图的插图。只是为了好玩。

图-4.16_Banner

人物肖像

博客不是按照真实开发的时间顺序写的。在完成了 LOGO 后,我接着用 GPT-4o 画了一系列同风格的方形人物头像。这些头像里只有一小部分会用于“人格”功能,有很多只是为了好玩顺手画的。

图-4.17_人物肖像

海报

这个学期我还在上“软件项目管理”这门课。这门课要求在课堂上围绕一个案例进行分析,还要求给那个案例做海报。我喜欢一箭双雕,所以我会选择分析诤略参谋——不仅能完成那门课的要求,还能给项目实训成果增加分析文档。所以,做海报吧。

图-4.18_备选海报

与两个模型合作的技巧
  • 牢记——模型擅长“从零到一”,非常不擅长基于已有图像修改细节。
    • 这种“擅长与不擅长”的影响因素之一是模型保持图像一致性的能力。擅长“从零到一”是因为在模型给出第一张图片前,我们的脑中对想要的图像只有模糊的感觉,在细节上的回旋余地非常大,模型可以把细节画成甲也可以把细节画成乙。换句话说,我们仍在确定需求,模型的输出决定了我们的目标。此时我们没有给模型施加“保持前面的图片的××细节”这类要求。
    • 可是一旦我们有了初始图片,我们对细节的要求就会变得清晰,我们会要求保持我们已满意的细节不变。现在我们想用我们的目标去决定模型的输出。模型在生成后续图片时相比第一张图要额外接受图像一致性的考验,然后……它就拉胯了。
  • 先让模型聊聊自己的感觉,聊够一定感觉后再让模型画图。这是在增加上下文和细节精确度(虽然模型通常不会遵守)。DALL · E 时代就在用的技巧。
  • 可以显式命令 GPT-4o,“先反思,然后再画”,那么 GPT-4o 会先生成文本,之后自动生图。
  • 通过局部放大、截取,让原图的“局部”成为一份“完整的图像”,然后要求模型保持一致性处理“完整图像”,最后把处理后的“完整图像”作为“局部” PS 回原图中,实现对原图细节的微调。模型非常不擅长处理细节,而细节被放大后就不再是模型不擅长处理的“细节”。
  • 图像提示词(“神笔马良是科幻故事”)。手动在图中画标记,让模型根据标记生图。这个范式非常新,绝大多数人没有意识到,还沉浸在“提示词就是一段文本”的世界里。
  • GPT-4o 对“用第一张图的艺术风格转绘第二张图”这一指令遵循较好。
  • 截止 2025 年 5 月初,GPT-4o 和 Gemini 2.0 Flash (Image Generation) Experimental 对细节指令的遵循能力非常差(后者更是完全听不懂人话)。不建议用它们做高要求的任务。
  • 两个模型分不清上下左右,Gemini 分不清图片的顺序。如果想让 GPT-4o 更改人物的朝向,你不该用文字指令,更好的办法是给一张参考图,参考图中的人物朝向与你的目标一致,然后让 GPT-4o 模仿参考图中的人物朝向。
  • GPT-4o 能建立具体图像及文件名之间的对应关系,你可以说修改“××.png”、参考“××.jpg”,借此避免 GPT-4o 混淆不同输入图片的职责。
  • GPT-4o 会把生成的图像存到沙箱内存里,如果网络异常导致 GPT-4o 生成了图像却不给你展示,你可以要求“利用 Python 工具提供图片的下载链接”。

路由

我们利用 Vue Router 处理单页应用程序的路由。我认为我们不该把路由理解成“(点击 RouterLink 后)跳转到了新页面”,而该把路由理解成“我们(通过点击 RouterLink 等手段)改变了 URL 这一全局状态的值,不同的 RouterView 时刻关注着这一全局状态的不同部分,根据所关注的部分的当前取值,决定此刻自己要渲染什么内容。这些 RouterView 一直都待在那里,它们只是在不同时刻根据状态端上不同的菜”。

诤略参谋的 Vue 组件分为 layoutviewcomponent 三级,layout 对应顶层 RouterView 会渲染的组件,view 对应非顶层 RouterView 会渲染的组件,component 用于组成 viewlayoutrouter/index.js 中的路径嵌套关系完全对应各个含有 RouterView 的组件的嵌套关系。

图-4.19_路由分层

为了便于开发管理,诤略参谋使用如下规定:

  • 为每一个路径命名(name: ×。一方面这便于维护、router.push,另一方面任务系统的一键路由功能依赖命名、下文将提到的“未保存提醒”也依赖命名运转。
  • 凭资源和行为之间的逻辑所属关系设计路径。
    • “所有我的项目的列表”:/app/projects。使用复数资源名。
    • “创建项目”:/app/project/create单数资源名/围绕此类资源的行动
      • 路由自上而下依次匹配,匹配成功后不会继续检查其余 path。一定要把 /app/××/yy 写在 /app/××/:××Id 前,否则 /app/××/yy 会被认为对应路径 /app/××/:××Id,××Id = yy
    • “我的指定项目的详细信息”:/app/project/:projectId单数资源名/资源主键
    • “所有该项目下计划构成的列表”:/app/project/:projectId/plans
    • “我的指定计划的详细信息”:/app/project/:projectId/plan/:planId
      • 同理,必须把这个写在 /app/project/:projectId 之前,否则会错误匹配,认为 projectId = :projectId/plan/:planId
  • 凭渲染层级设计路径的嵌套层次(谁在谁的 children 里)。不要根据逻辑关系设计层次。
    • 只要想使用 Workspace 里的非侧边栏区域,就把路径写到 /appchildren 里。比如,“项目详情”和“计划详情”都想用那块区域,于是 /app/project/:projectId/plan/:planId/app/project/:projectId 都放到 /appchildren 里,而不是把 /app/project/:projectId 放到 /appchildren、把 /app/project/:projectId/plan/:planId 放到 /app/project/:projectIdchildren。记住,路径的嵌套关系对应的不是逻辑所属关系,而是渲染层次关系。
  • 把长路径写在短路径前,把静态路径写在带参路径前。路由匹配是“先到先得”的,详细理由上文已讲。

核心布局

图-4.20_核心布局

  • 侧边栏和核心 RouterView 同属一个 flexRouterViewflex1。当收起侧边栏时,本质是侧边栏的宽度渐变到 0,而不是用 v-if 等手段把侧边栏从 DOM 中删除(这是为了防止其他组件的布局受删除影响发生跳变)。
  • 如果是重量级的添加/编辑任务,需要专门在 /appchildren 下另开路径。
  • 如果是轻量级的添加/编辑任务(比如只需要输入两个短字段),建议使用 modal.open 而不是写新的路径和 view
  • 使用 Deletor 组件完成删除工作。
  • 使用 Txt 组件完成长篇文本的输入工作。

白天/黑夜主题切换

图-4.21_主题切换

  1. main.scss 中写两套规则 :root[data-theme="night"],分别代表白天(默认、首页和 404 页)和黑夜两套主题的配色。两个选择器里声明同名不同值的变量
    • 未来,<html> 元素要么只被 :root 选中,应用其中声明的变量值,<html> 元素上有 bg-color: white 这个属性,而用了 color: var(--bg-color)<html> 的子元素会从父元素中找到这个变量和它的值,并应用这个值,结果就是子元素 color: white。这时程序处于白天主题。
    • 要么 <html> 元素同时被 :root[data-theme="night"] 选中。同名声明 --bg-color: white(来自 :root)和 --bg-color: black(来自 [data-theme="night"])发生冲突,后者获胜,于是 <html> 上有 bg-color: black。子元素与上一种情况一样找变量,结果却是子元素 color: black。这时程序处于黑夜主题。更通俗的说法是两个选择器在争着让别人采用它们对同一份经的不同解读,两种解读水火不容
    • 子元素里的代码 var(--bg-color) 永远不变、在 <html> 上要寻找的变量名永远不变,没有类似 if 语句的东西(“如果选用白天方案则 color: var(--d-bg-color),如果选用黑夜方案则 color: var(--n-bg-color)”),只是被寻找的变量 bg-color 的值会改变(类似指针不变但指向的内容改变)。我们为子元素编写样式时不必考虑白天黑夜之分,这些 CSS 变量名也没有白天黑夜的语义之分。
    • 这些 CSS 变量名通常体现“组件部分”的语义,比如 --field-border-color 这个名字说明这个变量对应“Field 组件的边框颜色”。它们不涉及白天、黑夜或色彩原理(“主色”、“辅色”、“对比色”……)上的语义!
  2. _variables.scss 中定义 SCSS 静态变量,这些变量类似 C++ 中的宏展开,在运行时会被抹去。我们把这些 SCSS 静态变量当全局常量用,修改时只需修改一处,构成一个开发者临时手动调试用的“控制面板”。
    • 如果 --bg-color: #777--field-bg-color: #777……我们想把 #777 换掉时需要改好几处;如果 $primary-color: #777(定义于 _variables.scss)、--bg-color: #{$primary-color}--field-bg-color: #{$primary-color},我们只需要改 $primary-color: #777 一处。
    • 这些 SCSS 静态变量名通常体现“色彩原理”的语义,比如“主色”、“辅色”。如果有些颜色有跨越组件的全局语义(比如,不管是前端校验不合格、错误弹窗、删除提醒,我都要用相同的“警告色”警告你),也应该用 SCSS 静态变量记录它(比如 $warn-color: red)——总之,语义基本不考虑组件。此外,还要在最开头加上 d-n- 前缀,表示白天或黑夜语义。
  3. Switch 的钩子或初始化部分执行一条语句就能实现切换主题——document.documentElement.setAttribute('data-theme', 'night')


    我会在面板上给用户“终为白日(x.userSetting.theme.alwaysDay)”、“终为黑夜(x.userSetting.theme.alwaysNight)”、“跟随时间(x.userSetting.theme.followingTime)”和“跟随系统(x.userSetting.theme.followingSystem)”四个主题选项,四个选项在任意时刻有且仅有一个为 trueuser store 中的 init 方法会在登录成功后被调用,用来初始化 user store 中各状态的值(详见博客“二、数据通讯(上)”)。在 init 的结尾部分、初始化 task store 后,我们调用 initTheme,根据这四个选项的值选择主题。
initTheme() {
  // 1. 如果同时开着跟随系统和跟随时间,先冲突消解
  if (
    this.me.userSetting.theme.followingSystem &&
    this.me.userSetting.theme.followingTime
  )
  this.me.userSetting.theme.followingSystem = false;
  // 2. 初始配置
  // 2.1 如果选择跟随系统
  if (this.me.userSetting.theme.followingSystem) {
    const mql = window.matchMedia("(prefers-color-scheme: dark)");
    mql.removeEventListener("change", this._systemFollower);
    this._systemFollower(mql);
    mql.addEventListener("change", this._systemFollower);
  }
  // 2.2 如果选择跟随时间
  // 只依据初始加载时的时间,因为人会适应主题,中间还是不要自动切了
  else if (this.me.userSetting.theme.followingTime) {
    const hour = new Date().getHours();
    if (hour >= NIGHT_BEGIN || hour < NIGHT_END) this._useTheme("night");
  }
  // 2.3 如果选择终为黑夜
  else if (this.me.userSetting.theme.alwaysNight) this._useTheme("night");
},

_systemFollower(e) {
  if (e.matches) {
    this._useTheme("night");
  } else {
    this._useTheme("day");
  }
},

_useTheme(theme) {
  document.documentElement.setAttribute("data-theme", theme);
  try {
    if (theme === "day") this.logoURL = LOGO_DAY_URL;
      else if (theme === "night") this.logoURL = LOGO_NIGHT_URL;
  } catch (e) {
    console.log(e);
  }
}
  • 通过设置 <html>data-theme 值、进而让不同的选择器(里的变量声明)生效实现基本的主题切换。
  • 通过添加事件监听器监听浏览器主题的变化实现“跟随系统”选项。
  • 对于“跟随时间”,我刻意设计成“只根据初次加载时的时间选择主题”,而不是“设置一个循环计时器、每隔一段时间检查当前时间并据此选择主题”。因为我认为用户在切换时刻到来前已经花了一段时间面对主题甲,他已经适应主题甲了,不应该在他已经适应主题甲后再自动换成主题乙。当然自动切换的方案也很有道理,我不是在说两种方案谁对谁错。
  • 我们的核心工作区里有一个 <img> 负责显示 LOGO 图片。我们准备了适合白天和黑夜色调的两份 LOGO 图片。当切换主题时,我们要一并更换 <img>src 值。我的做法是在 user store 中加入新状态 logoURL,让 <img> 元素的 src 动态绑定这个状态。
  • 在“欢迎页(name: "home")”和“404 页(name: "notfound")”两个特殊页面里,我希望无论用户的设置如何,这两页都永远保持白天配色方案。我的方法是利用路由守卫,进入这两个页面时强制应用白天主题(只设置 data-theme 等,不改变 user store 中的用户状态),离开这两个页面时调用 initTheme 重新根据用户状态选择主题。
router.beforeEach(async (to, from) => {
  // ------ 主题相关 ------
  const user = useUserStore();
  const navigatingToSpecial = DAY_THEME_FORCED.includes(to.name);
  const navigatingFromSpecial = DAY_THEME_FORCED.includes(from.name);
  if (navigatingToSpecial) {
    // 进入特殊页面,强制应用白天主题
    user.forceDayTheme();
  } else if (navigatingFromSpecial) {
    // 离开特殊页面,恢复用户设置的主题
    user.initTheme();
  }
  return true;
})
  • 用户状态通过 user store 中定义的其他 actions 修改。这些函数会作为 Switchonoff 钩子,在用户拨动 Switch 时被调用。

图-4.22_白日主题
图-4.23_黑夜主题

配色方案

图-4.24_配色方案

字体

考虑到版权,我选用 Google Fonts;考虑到我想营造现代、稳重、简洁的感觉,我选用无衬线体。

  • 中文使用 Noto Sans SC
  • 英文使用 Staatliches。Staatliches 非常适合我想塑造的参谋的感觉,不过估计也只有 LOGO 会用到英文。
  • 顺便说了,icon 统一使用 Phosphor Icons。诤略参谋主要使用 boldfill 样式,偶尔也会用 regular

图-4.25_利用 Typescale 确定字号

我利用 Typescale 确定了字号系统(Perfect Fourth 和 Perfect Fifth 的并集)。我们只会从这个集合里选字号,而不是每次都乱试。

我也在为以后的响应式设计作准备。

  • body { font-size: 1.6rem }html { font-size: 62.5% }
  • 在圆角半径、阴影和位移动画等在各式显示设置下几乎不变的样式上允许使用 px 单位。
  • 其他场景一律使用 rem 代替 px,开发时可以认为 1rem = 10px

其他细节

图-4.26_icon 悬浮提示

  • 给各个 icon (形式的按钮)增加鼠标悬浮提示。告诉用户这是什么意思。
  • 我正在考虑要不要把最近的时间显示为“刚刚”、“2 分钟前”、“3 小时前”、“昨天”、“3 天前”。人对这些相对时间的感知确实强于固定格式的时间。很好实现,把时间扔进一个简单函数里就行。不过诤略参谋设计的时间都是计划或项目的最后更改时间,这种时间很严肃、要求很精确,所以我大概最终不会采用这种设计。
  • 需要考虑 LLM 的 markdown 输出问题,如果允许 LLM 输出 markdown,是否该允许用户也以 markdown 形式输入信息?这个问题是其他队员负责的,所以言尽于此。
  • SCSS 使用 BEM 规范。
    • 我认为不该永远都为了少写几个单词而选用嵌套选择器。因为嵌套多了后可读性会下降,你过几天回来看代码时需要从头捋一遍各级 & 代表的是什么。我倾向于根据使用频率、声明体量(小则全嵌套大则只嵌套直接子级)决定是嵌套还是跳出嵌套新写一遍完整类名另立门户。
    • 我建议这样命名:
      • 组件 x 的顶层子组件都以 x__y 格式命名,意思是这些子组件充当 x 的一个元素。把 x 当成全世界。
      • 子组件的子组件以 y__z 格式命名,意思是子组件 z 充当 y 的一个元素。这时我们刻意忽略组件 y 的上级 x,就把 y 当成全世界。scoped 在,不用担心破坏其他 Vue 组件的样式。
  • 如果没有相关条目,显示“空提示”,而不是让用户对着真正意义上的一片空白瞪眼。我实现了 Empty 组件,专用于写这种提示。

交互

前情回顾

  • 博客“一、项目伊始”:
    • 先绘制初始页面 P 0 P_0 P0 的草稿,之后代入用户视角,设想自己期望在页面上发生什么交互,根据这些期望组织路由和其他页面的草稿。这种“交互流程驱动的网页设计”天然按照用户的交互直觉设计网页。
  • 博客“二、项目通讯(上)”:
    • 文章开篇就抽象地分析了两类交互的流程,指出“交互的同质决定数据流统一”。为了让这种统一带来开发便利,我们设计了 FieldBtnFormWrapper、统一数据格式 StdResponse、全局异常处理器和 axios 响应拦截器等组件和机制。我们基于对交互的认识和要求(比如应该提示用户,于是在 StdResponse 中有 msg 字段)设计了这些东西,我们设计这些东西是为了复用,而我们复用它们时,曾经融入设计中的交互认识会持续生效。
    • validatorField 的属性,errorMsg 会被渲染,Btn 在发送请求前会确保注册到 FormWrapperfieldStates 中的 isValid 以及联动时的 isLinkValid 均为 true。通过给组件设计这些参数,我们在要求未来复用这些组件的自己时刻考虑输入校验和校验失败时的提示信息——换句话说,及时对用户的输入行为给出反馈。
  • 博客“三、项目通讯(下)”:
    • 任务系统让任务在后台执行,使得用户不被阻塞、不需要对着 LOADING 面壁思过。
    • 利用三个 store 实现了全局弹窗系统,其中 Modal 是阻塞的、用于要求用户立刻交互的弹窗,Toast 和 Notification 是对历史交互的结果反馈。axios 响应拦截器根据 StdResponse.code 落入的区间自动调用弹窗,在弹窗中显示 StdResponse.msg 提示用户。我们对反馈的要求渗入了 StdResponse 的骨髓里——它在业内常见的 codemsgdata 外还有 userVisibility,axios 响应拦截器据此判断是否需要弹窗。因为我们要求 API 总是返回 StdResponse 对象,所以对每一个响应,我们都在考虑 userVisibility 等与交互反馈相关的设置。
    • Notification 支持一键路由,考虑了交互需求和便利性。

未保存提醒

随着细节一加再加,Field 变得越来越“重”,内部的分支语句越来越多。于是我对 Field 进行拆分——复制一份出来改成 Txt,专门对应之前的 type="textarea" 情况,原来的 Field 则只考虑 type="text"type="password" 的情况。

  • TxtFieldFormWrapper 中的表现完全相同,都会自动与 Btn 关联,被 Btn 收集数据。
  • Txt 会有 props enableSaveCheck,默认为 false。当值为 true 时,如果 Txt 的内容被修改却未保存修改,用户在路由时会被提醒。

经过了前两篇博客的锻炼,很容易想出下图方案——让 Txt 当个双面人,一边按照 Field 的行为向 FormWrapperfieldStates 注册,一边按照 Txt 的行为向 txt store 的 txtStates 注册。路由守卫凭 txt store 提供的信息行事即可。

图-4.27_未保存提醒的核心思路

SaveModal 组件有“暂不离开”和“立刻离开”两个按钮,被点击时分别调用 props.onCancelprops.onConfirm 钩子。我们为 modal store 编写了新方法 alert,它会立刻返回一个 Promise 类对象。如何控制这个对象何时被 fulfilled?我们利用函数闭包,把 () => resolve(false)() => resolve(true) 作为两个钩子属性的值,这样用户就能在点击按钮时根据不同的行为用不同实参调用 resolve 函数,进而用 truefalse fulfill 这个 Promise 类对象。

function alert() {
  contentComponent.value = SaveModal;
  isGlobalModalWrapperVisible.value = true;
  playSound("modal", "slide");
  return new Promise((resolve) => {
    componentProps.value = {
      onCancel: () => resolve(false),
      onConfirm: () => resolve(true),
    };
  });
}

路由守卫会 await 这个 Promise 类对象的 fulfilled 值,如果用户点击了“立刻离开”,则 resolve(true)userConfirmedLeave 值为 true,放行。点击“暂不离开”的情况同理。

router.beforeEach(async (to, from) => {
  // ...
  // ------ 跨页未保存提示相关 ------
  let fkeysToCheck = [];
  let isAllSaved = true;
  // 1. 检查是否正在试图从 enableSaveCheck 的 Txt 相关页面离开
  // 1.1 根据不同来源配置不同的待检查 fkey 数组
  if (from.name === "create-project")
    fkeysToCheck = ["prompt"]; // TODO 此例仅供测试
  else if (from.name === "project-detail"); // ... 以此类推
  // 1.2 正式检查
  isAllSaved = useTxtStore().isAllSaved(fkeysToCheck);

  // 2. 放行的情况(全保存了或没有 Txt)
  if (isAllSaved) return true;

  // 3. 如果存在 Txt 没保存,弹出确认窗口
  if (!isAllSaved) {
    // 调用 modal.alert 弹出确认 Modal
    try {
      const userConfirmedLeave = await useModalStore().alert();
      if (userConfirmedLeave) {
        // 3.1 用户在 Modal 中铁了心要离开,放弃保存
        useTxtStore().save(fkeysToCheck); // 把对应 isSaved 全部设为 true
        return true; // 放行
      } else {
        // 3.2 用户在 Modal 中决定放弃这次路由
        return false;
      }
    } catch (_) {
      return false;
    }
  }
});

图-4.28_未保存提醒-测试

音效

音效考虑了版权问题,绝大部分来自 Mixkit。音效位于 /public/sounds 下,sounds 下有多个文件夹,文件夹的设置规则是:

  • 有一个 ambience 文件夹,存储氛围音效。
  • 如果 Vue 组件要使用音效,在 sounds 下用组件名(不要求严格一致)建立一个文件夹,在那个文件夹里存储该组件使用的音效。

全局设置里有一个 Switch,它的 onoff 钩子会设置 user store 中的 enableSounds 值,从而控制是否启用音效。/utilities/tools.js 下有 playSound 函数,这个函数会先检查用户的 enableSounds 设置,再决定是否按照实参指示播放对应音效。诤略参谋规定只准通过调用 playSound 的方式为组件设计音效,这是为了避免出现不受 enableSounds 控制的音效。


展望

  • 可以加入自定义主题功能,自定义主题需要更清晰的颜色语义设计,还需要拾色器、预览组件。只是说说,我不会加入自定义主题功能,因为精力有限。
  • 开发更多组件。这主要是为了复用以提高效率。复用也有利于样式一致。
  • 编写小组使用的样式和路由设计规范。
  • 将诤略参谋改进为响应式网站。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐