feat: 登录过期自动续登并校验同一账号
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
# 智绘微刻小程序设计规范
|
||||
|
||||
> 版本:v1(草案)
|
||||
> 适用范围:Taro + React + TypeScript 微信小程序的全部页面与组件
|
||||
> 交付物:本规范 `DESIGN.md` + 交互式样例页 `design-system.html`
|
||||
> 原则:一套 token、两套主题、统一组件行为,让设计语言先于页面实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档定位
|
||||
|
||||
这份规范把「智绘微刻」的小程序从现有的手账感装饰风,收敛为一套更现代、更克制、但保留品牌浪漫感的设计语言。
|
||||
|
||||
它同时承担三种角色:
|
||||
|
||||
1. **设计 Token 定义**:颜色、字体、间距、圆角、阴影、毛玻璃等全部由变量驱动。
|
||||
2. **组件行为定义**:按钮、卡片、表单、弹窗、导航等组件的尺寸、状态和交互。
|
||||
3. **微信适配约束**:rpx、安全区、自定义导航、深浅色、触控目标等微信小程序硬约束。
|
||||
|
||||
后续修改页面时,只允许从本规范取值,不允许继续引入新的硬编码颜色、字号或组件形态。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则
|
||||
|
||||
### 2.1 去装饰化
|
||||
|
||||
- 不再使用 `3px dashed` 虚线卡片作为默认容器。
|
||||
- 移除 `star-badge` 星星贴纸装饰。
|
||||
- 装饰只承担功能:状态、层级、动效提示,不承担“氛围”。
|
||||
|
||||
### 2.2 留白优先
|
||||
|
||||
- 用间距和字重建立信息层级,而不是用边框和卡片嵌套。
|
||||
- 页面横向统一 32rpx 留白,内容区不贴边。
|
||||
- 卡片之间用间距分隔,卡片内部用 padding 分隔。
|
||||
|
||||
### 2.3 克制色彩
|
||||
|
||||
- 珊瑚粉只用于主操作、选中态和价格等关键信息。
|
||||
- 大面积背景使用暖白/深墨等中性色。
|
||||
- 蓝紫只做次级信息色(链接、地址、聚焦状态)。
|
||||
|
||||
### 2.4 现代质感
|
||||
|
||||
- 毛玻璃只出现在需要浮层的场景:TabBar、吸顶栏、弹窗、底部操作区。
|
||||
- 阴影保持低饱和、低扩散,强调层级而不是“发光”。
|
||||
- 支持 160-240ms 的轻量过渡,不做夸张动画。
|
||||
|
||||
### 2.5 微信原生一致性
|
||||
|
||||
- 全局使用 `navigationStyle: custom` 自定义导航,自行留出状态栏和胶囊区域。
|
||||
- 全部使用系统字体栈,不打包自定义字体。
|
||||
- 全部尺寸基于 750rpx 设计稿,最终在微信中由 rpx 转换。
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计 Token 总览
|
||||
|
||||
```css
|
||||
/* 浅色主题 */
|
||||
.theme-light {
|
||||
--bg-page: #faf7f2;
|
||||
--bg-card: #ffffff;
|
||||
--bg-muted: #f3eee7;
|
||||
--bg-input: #f5f1eb;
|
||||
--text-primary: #2e2622;
|
||||
--text-secondary: #8a7e76;
|
||||
--text-muted: #b3a89e;
|
||||
--accent-primary: #d96c6a;
|
||||
--accent-primary-strong: #c75b59;
|
||||
--accent-primary-soft: #f6e0dc;
|
||||
--accent-secondary: #6c7bdb;
|
||||
--accent-secondary-soft: rgba(108, 123, 219, 0.12);
|
||||
--success: #4fa477;
|
||||
--warning: #d99a4e;
|
||||
--danger: #d85d5d;
|
||||
--border: #ede5dc;
|
||||
--border-strong: #e0d5c8;
|
||||
--glass: rgba(255, 255, 255, 0.72);
|
||||
--shadow-card: 0 8rpx 24rpx rgba(46, 38, 34, 0.08);
|
||||
--shadow-elevated: 0 12rpx 40rpx rgba(46, 38, 34, 0.12);
|
||||
}
|
||||
|
||||
/* 深色主题 */
|
||||
.theme-dark {
|
||||
--bg-page: #191919;
|
||||
--bg-card: #23201d;
|
||||
--bg-muted: #2b2622;
|
||||
--bg-input: #2b2622;
|
||||
--text-primary: #f5efe8;
|
||||
--text-secondary: #b3a79c;
|
||||
--text-muted: #82766c;
|
||||
--accent-primary: #f09198;
|
||||
--accent-primary-strong: #ffa7ad;
|
||||
--accent-primary-soft: rgba(240, 145, 152, 0.14);
|
||||
--accent-secondary: #8e97e8;
|
||||
--accent-secondary-soft: rgba(142, 151, 232, 0.14);
|
||||
--success: #67c58e;
|
||||
--warning: #e0ad68;
|
||||
--danger: #ef7a7a;
|
||||
--border: #37312c;
|
||||
--border-strong: #463e37;
|
||||
--glass: rgba(25, 25, 25, 0.72);
|
||||
--shadow-card: 0 8rpx 24rpx rgba(0, 0, 0, 0.35);
|
||||
--shadow-elevated: 0 12rpx 40rpx rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
```
|
||||
|
||||
`design-system.html` 中使用的 Web 版本会把这些值转为 `px`,但命名、层级和语义完全一致。
|
||||
|
||||
---
|
||||
|
||||
## 4. 色彩系统
|
||||
|
||||
### 4.1 中性色(占视觉面积最大)
|
||||
|
||||
| Token | 浅色 | 深色 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--bg-page` | `#FAF7F2` 暖白 | `#191919` | 页面背景 |
|
||||
| `--bg-card` | `#FFFFFF` | `#23201D` | 卡片、弹窗、浮层表面 |
|
||||
| `--bg-muted` | `#F3EEE7` | `#2B2622` | 分组背景、表格表头 |
|
||||
| `--bg-input` | `#F5F1EB` | `#2B2622` | 输入框、搜索框 |
|
||||
| `--text-primary` | `#2E2622` 深墨 | `#F5EFE8` | 主标题、正文 |
|
||||
| `--text-secondary` | `#8A7E76` | `#B3A79C` | 说明、标签、辅助信息 |
|
||||
| `--text-muted` | `#B3A89E` | `#82766C` | 弱提示、占位符 |
|
||||
|
||||
### 4.2 品牌色(珊瑚粉)
|
||||
|
||||
| Token | 浅色 | 深色 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--accent-primary` | `#D96C6A` | `#F09198` | 主按钮、价格、选中态 |
|
||||
| `--accent-primary-strong` | `#C75B59` | `#FFA7AD` | 按压态、强调文本 |
|
||||
| `--accent-primary-soft` | `#F6E0DC` | `rgba(240,145,152,.14)` | 标签底、浅色选中背景 |
|
||||
|
||||
### 4.3 辅助色
|
||||
|
||||
| Token | 浅色 | 深色 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--accent-secondary` | `#6C7BDB` | `#8E97E8` | 二级链接、地址、聚焦 |
|
||||
| `--success` | `#4FA477` | `#67C58E` | 完成、成功、已下单 |
|
||||
| `--warning` | `#D99A4E` | `#E0AD68` | 待处理、提醒 |
|
||||
| `--danger` | `#D85D5D` | `#EF7A7A` | 删除、错误、取消 |
|
||||
|
||||
### 4.4 边框与阴影
|
||||
|
||||
- 常规边框:`1rpx solid var(--border)`
|
||||
- 强调边框:`1rpx solid var(--border-strong)`
|
||||
- 聚焦边框:`1rpx solid var(--accent-primary)`
|
||||
- 卡片阴影:`var(--shadow-card)`
|
||||
- 弹窗/吸顶:`var(--shadow-elevated)`
|
||||
|
||||
### 4.5 禁止事项
|
||||
|
||||
- 禁止整页彩色渐变背景。
|
||||
- 禁止大面积使用超过 2 种高饱和色。
|
||||
- 禁止在浅色页使用 `#000` 纯黑作为正文。
|
||||
- 禁止深色页使用 `#fff` 纯白作为正文。
|
||||
|
||||
---
|
||||
|
||||
## 5. 字体与排版
|
||||
|
||||
### 5.1 字体栈
|
||||
|
||||
```
|
||||
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Noto Sans SC', 'Microsoft YaHei', 'Segoe UI', sans-serif
|
||||
```
|
||||
|
||||
不引入包内字体,控制小程序包体积并保证 iOS/Android 渲染一致。
|
||||
|
||||
### 5.2 字号阶梯(750rpx 设计稿)
|
||||
|
||||
| 名称 | 字号 | 字重 | 行高 | 用途 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Display | 56rpx | 800 | 1.15 | 品牌大字、营销页面主标题 |
|
||||
| Title-LG | 44rpx | 700 | 1.2 | 页面主标题 |
|
||||
| Title | 36rpx | 700 | 1.25 | 区块标题、卡片标题 |
|
||||
| Heading | 32rpx | 600 | 1.3 | 小节标题、列表项名称 |
|
||||
| Body | 28rpx | 400 | 1.6 | 正文、表单内容 |
|
||||
| Caption | 24rpx | 400 | 1.4 | 说明文字、辅助信息 |
|
||||
| Micro | 20rpx | 500 | 1.2 | 标签、角标、时间 |
|
||||
|
||||
### 5.3 字重用例
|
||||
|
||||
- `400`:正文
|
||||
- `500`:次级强调、输入内容
|
||||
- `600`:按钮、小标题
|
||||
- `700`:页面标题、卡片标题
|
||||
- `800`:仅 Display 使用
|
||||
|
||||
### 5.4 排版规则
|
||||
|
||||
- 标题不换行时使用 `ellipsis` 省略。
|
||||
- 正文行高默认 1.6,长条款可用 1.8。
|
||||
- 数字与货币使用 `tabular-nums` 风格,避免跳动。
|
||||
- 段落间距 16-24rpx,不靠空行制造层级。
|
||||
- 页面顶部大标题与内容之间至少保留 24rpx。
|
||||
|
||||
---
|
||||
|
||||
## 6. 间距与布局
|
||||
|
||||
### 6.1 间距梯度
|
||||
|
||||
所有间距从以下数值取:
|
||||
|
||||
```
|
||||
4rpx · 8rpx · 12rpx · 16rpx · 24rpx · 32rpx · 48rpx · 64rpx · 96rpx
|
||||
```
|
||||
|
||||
### 6.2 页面布局
|
||||
|
||||
- 页面左右留白:32rpx
|
||||
- 页面底部安全区:`calc(24rpx + env(safe-area-inset-bottom))`
|
||||
- 卡片之间间隙:24rpx
|
||||
- 卡片内边距:24-32rpx
|
||||
- 分组标题与内容间距:24rpx
|
||||
|
||||
### 6.3 安全区
|
||||
|
||||
- 顶部:用 `Taro.getMenuButtonBoundingClientRect()` 计算状态栏和胶囊位置,Header 下方再留 16-24rpx。
|
||||
- 底部:`env(safe-area-inset-bottom)`,TabBar/操作栏必须避让。
|
||||
|
||||
---
|
||||
|
||||
## 7. 圆角、边框、阴影与毛玻璃
|
||||
|
||||
### 7.1 圆角
|
||||
|
||||
| 场景 | 圆角 |
|
||||
| --- | --- |
|
||||
| 输入框、小标签 | 16rpx |
|
||||
| 普通卡片 | 24rpx |
|
||||
| 弹窗、底部弹层 | 32rpx |
|
||||
| 按钮、胶囊 | 999rpx |
|
||||
|
||||
### 7.2 边框
|
||||
|
||||
- 只有一种默认边框:`1rpx solid var(--border)`。
|
||||
- 弹窗和吸顶层可以换成 `1rpx solid rgba(255,255,255,.16)`(深色同理)。
|
||||
- 不再使用 3px 虚线卡片。
|
||||
|
||||
### 7.3 毛玻璃
|
||||
|
||||
```css
|
||||
.glass-surface {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24rpx);
|
||||
-webkit-backdrop-filter: blur(24rpx);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
```
|
||||
|
||||
深色主题自动由 `--glass` 和 border 变量接管。
|
||||
|
||||
毛玻璃使用场景:
|
||||
|
||||
- 自定义 TabBar
|
||||
- 页面吸顶信息条
|
||||
- 底部操作栏
|
||||
- 底部弹窗背景
|
||||
- 需要盖在图片上的卡片
|
||||
|
||||
---
|
||||
|
||||
## 8. 图标与图片
|
||||
|
||||
- 图标统一放 `src/icon/`,构建时随包输出。
|
||||
- 图标尺寸只允许 24 / 32 / 40 / 48 / 56 / 80rpx。
|
||||
- 功能图标优先使用线性风格,选中态使用填充变体。
|
||||
- 禁止在 UI 中使用 emoji 代替图标。
|
||||
- 商品大图走 OSS,`/icon/*` 小图标继续本地打包。
|
||||
- 图片统一 `aspectFill`,商品缩略图使用 `aspect-ratio` 固定比例,避免加载后跳动。
|
||||
|
||||
---
|
||||
|
||||
## 9. 组件规范
|
||||
|
||||
### 9.1 按钮
|
||||
|
||||
| 变体 | 背景 | 文字 | 边框 |
|
||||
| --- | --- | --- | --- |
|
||||
| Primary | `--accent-primary` | 白 | 无 |
|
||||
| Secondary | `--bg-card` | `--text-primary` | 1rpx `--border-strong` |
|
||||
| Ghost | 透明 | `--accent-primary` | 无 |
|
||||
| Destructive | `--danger` | 白 | 无 |
|
||||
|
||||
尺寸:
|
||||
|
||||
- 大按钮:高度 96rpx,圆角 999rpx,字号 32rpx/600
|
||||
- 中按钮:高度 80rpx
|
||||
- 小按钮:高度 64rpx,字号 26rpx/600
|
||||
|
||||
状态:
|
||||
|
||||
- 按压:`scale(0.98)` + 主色加深 4%
|
||||
- 禁用:`opacity: 0.45`,无点击反馈
|
||||
- Loading:按钮内显示 24rpx 环形 loading 后再显示文案
|
||||
- 危险操作必须二次确认(`showModal`)
|
||||
|
||||
### 9.2 卡片
|
||||
|
||||
- **Surface Card**:默认内容容器,`--bg-card` + 1rpx border + `--shadow-card`。
|
||||
- **Glass Card**:信息浮层/图片上覆盖,使用毛玻璃。
|
||||
- **Media Card**:图片在上或居左,使用 `aspect-ratio`,文字区域带留白。
|
||||
- 卡片禁止再次嵌套 `dashed-card`,同一个视觉层最多出现一张卡片。
|
||||
|
||||
### 9.3 表单
|
||||
|
||||
- 输入框高度 88rpx,圆角 16rpx,内边距 0 24rpx。
|
||||
- 占位符用 `--text-muted`,输入文字用 `--text-primary`。
|
||||
- 聚焦时边框 `--accent-primary`,并保留 1px 位移/轻阴影反馈。
|
||||
- 错误信息:`--danger`,24rpx,显示在控件下方 8rpx。
|
||||
- `textarea` 最小高度 240rpx,内边距 24rpx,行高 1.6。
|
||||
|
||||
### 9.4 列表行
|
||||
|
||||
- 最小高度 112rpx。
|
||||
- 左侧图标 48rpx,标题 32rpx/600,辅助信息 24rpx。
|
||||
- 右侧箭头 28rpx,使用 `--text-muted`。
|
||||
- 分隔线:`1rpx solid var(--border)`,默认左右留 32rpx。
|
||||
|
||||
### 9.5 标签与徽标
|
||||
|
||||
- 高度 40rpx,圆角 999rpx,字号 22rpx/600。
|
||||
- 底色使用功能色的 12-15% 透明底,文字用对应功能色。
|
||||
- 徽标数字使用深色底或主色底,白字,最小宽高 28rpx。
|
||||
|
||||
### 9.6 Tabs 与分段控件
|
||||
|
||||
- Tab 高度 88rpx,字号 28rpx/500。
|
||||
- 选中态:`--accent-primary` + 600 字重 + 48rpx 宽圆角指示条。
|
||||
- 分段控件置于 `--bg-muted` 容器,选中项为 `--bg-card` 浮起。
|
||||
|
||||
### 9.7 弹窗与底部弹层
|
||||
|
||||
- 遮罩:`rgba(0,0,0,0.5)`
|
||||
- 居中弹窗:圆角 32rpx,内边距 40rpx,最大宽度 640rpx。
|
||||
- 底部弹层:顶部圆角 32rpx,底部延伸到安全区,顶部带 64rpx 宽拖动条。
|
||||
- 内容超过一屏时允许滚动,操作按钮固定在弹层底部。
|
||||
|
||||
### 9.8 空状态
|
||||
|
||||
- 图标 96rpx,主题色 12% 透明圆形底。
|
||||
- 标题 32rpx/700,说明 26rpx/`--text-secondary`。
|
||||
- 主 CTA 默认 80rpx 高度。
|
||||
- 空状态必须给出下一步动作,不让用户停在死胡同。
|
||||
|
||||
### 9.9 步进器与滑块
|
||||
|
||||
- 步进器按钮 60rpx,圆角 16rpx,中缝数字宽度不小于 48rpx。
|
||||
- 滑块轨道高度 8rpx,圆角 999rpx。
|
||||
- 滑块圆点 32rpx,主色描边,拖动时 40rpx。
|
||||
- 滑块值实时显示在右侧,格式如 `80%`、`+12`。
|
||||
|
||||
### 9.10 Toast 与骨架屏
|
||||
|
||||
- Toast 使用 `rgba(40,35,32,0.92)` 深色底、白字,圆角 24rpx。
|
||||
- 短文案优先 `icon: 'none'`,成功才使用 `icon: 'success'`。
|
||||
- 骨架屏用 `--bg-muted`,以 1.4s 透明度呼吸动画,不闪烁。
|
||||
|
||||
---
|
||||
|
||||
## 10. 页面结构规范
|
||||
|
||||
### 10.1 自定义导航
|
||||
|
||||
所有页面使用自定义导航,不再依赖系统导航栏背景:
|
||||
|
||||
```ts
|
||||
// app.config.ts
|
||||
window: {
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTextStyle: 'black'
|
||||
}
|
||||
```
|
||||
|
||||
页面 Header 结构:
|
||||
|
||||
```
|
||||
[状态栏空白]
|
||||
[返回/标题/右侧操作](与胶囊按钮同一水平线,左右留 24rpx)
|
||||
[16-24rpx 内容间距]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 返回按钮 48rpx 触控区,旧页面 `←` 字符统一替换为线性返回图标。
|
||||
- 标题 34rpx/600,居中,最多 8 个字。
|
||||
- 右侧操作与左侧占位保持同宽,保证标题真正居中。
|
||||
|
||||
### 10.2 页面层级
|
||||
|
||||
```
|
||||
自定义导航 Header
|
||||
页面内容区(左右 32rpx)
|
||||
固定操作栏 / TabBar(毛玻璃 + 安全区)
|
||||
```
|
||||
|
||||
### 10.3 固定操作栏
|
||||
|
||||
- 高度 112rpx + 安全区,圆角仅保留顶部 24rpx。
|
||||
- 背景使用毛玻璃,不透明底 `--bg-card` 作为降级。
|
||||
- 主 CTA 永远在屏幕右下角或通栏。
|
||||
|
||||
### 10.4 自定义 TabBar
|
||||
|
||||
- 高度 96rpx + `env(safe-area-inset-bottom)`。
|
||||
- 毛玻璃半透明底。
|
||||
- 图标 44rpx,选中态填充 + 轻放大;文字 20rpx/600。
|
||||
- 5 个 tab 均分,最大宽度 160rpx。
|
||||
|
||||
---
|
||||
|
||||
## 11. 微交互动效
|
||||
|
||||
- 时长:160-240ms
|
||||
- 缓动:`cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
- 只动画 `opacity` 和 `transform`,避免重排。
|
||||
- 按压反馈:按钮 `scale(0.98)`,卡片 `scale(0.975)`。
|
||||
- 页面进入:内容区上移 24rpx + 淡入,不要做成满屏位移动画。
|
||||
- Tab 切换、列表筛选不添加横向滑动动画,除非用户手势触发。
|
||||
|
||||
---
|
||||
|
||||
## 12. 深浅色模式
|
||||
|
||||
- 沿用现有 `theme.json` + `darkmode: true` 作为系统探测通道。
|
||||
- 页面根节点统一 `theme-light` / `theme-dark`。
|
||||
- 顶部状态栏文字只有黑/白两种,以 `useStatusBar` 控制。
|
||||
- 页面背景由 `applyPageBackground` 实时同步,避免顶部闪白。
|
||||
- 所有颜色必须走 token,不允许页面内再出现 `#b08d8d`、`#fce4ec` 等硬编码。
|
||||
|
||||
---
|
||||
|
||||
## 13. 微信官方约束与应用
|
||||
|
||||
本项目相关约束都来自微信官方文档,引用如下:
|
||||
|
||||
| 约束 | 官方说明 | 链接 |
|
||||
| --- | --- | --- |
|
||||
| 小程序设计指南 | 官方对导航、触控、反馈等设计建议 | https://developers.weixin.qq.com/miniprogram/design/ |
|
||||
| rpx 尺寸单位 | 750rpx 屏幕宽度设计稿 | https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxss.html |
|
||||
| app.json 全局配置 | `navigationStyle`、`darkmode`、`themeLocation` | https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/app.html |
|
||||
| 深色模式适配 | `theme.json`、`onThemeChange` | https://developers.weixin.qq.com/miniprogram/dev/framework/ability/darkmode.html |
|
||||
| 自定义 tabBar | `custom: true` 与组件实现 | https://developers.weixin.qq.com/miniprogram/dev/framework/ability/custom-tabbar.html |
|
||||
|
||||
本规范落地时必须满足:
|
||||
|
||||
1. **尺寸**:一律使用 rpx,基于 750 设计稿。
|
||||
2. **触控**:主要点击目标不小于 88rpx(约 44pt)。
|
||||
3. **导航**:`navigationStyle: custom`,自行避让胶囊按钮和状态栏。
|
||||
4. **背景**:系统顶栏文字只有黑/白,因此所有页面背景必须通过自定义导航的留白与 `backgroundColorTop/Bottom` 协同控制。
|
||||
5. **包体积**:小于 2MB,图标本地打包,大图走 OSS。
|
||||
6. **字体**:使用系统字体,降低排版与体积风险。
|
||||
7. **深色模式**:所有颜色走 `theme-light/theme-dark` token。
|
||||
|
||||
---
|
||||
|
||||
## 14. 当前代码映射
|
||||
|
||||
将现有 `src/app.scss` 变量迁到新规范:
|
||||
|
||||
| 现有变量 | 新规范 |
|
||||
| --- | --- |
|
||||
| `--bg-page: #ffffff` | `--bg-page: #faf7f2` |
|
||||
| `--bg-card: #ffffff` | `--bg-card: #ffffff` |
|
||||
| `--line-card: 3px dashed #ffb7c5` | 删除,卡片改 `1rpx solid var(--border)` |
|
||||
| `--line-star: #ffb7c5` | 删除,统一用 `--border` |
|
||||
| `--text-primary: #5c3a3a` | `--text-primary: #2e2622` |
|
||||
| `--text-secondary: #b08d8d` | `--text-secondary: #8a7e76` |
|
||||
| `--text-muted: #d28a8a` | `--text-muted: #b3a89e` |
|
||||
| `--accent-pink: #ff9a9e` | `--accent-primary: #d96c6a` |
|
||||
| `--accent-blue: #5b8cff` | `--accent-secondary: #6c7bdb` |
|
||||
| `--btn-gradient` | Primary 按钮改实色 `--accent-primary`,可保留极淡渐变 |
|
||||
| `--shadow-card` | `--shadow-card: 0 8rpx 24rpx rgba(46,38,34,.08)` |
|
||||
| `--bg-input: #f8f9fa` | `--bg-input: #f5f1eb` |
|
||||
|
||||
页面迁移检查项:
|
||||
|
||||
- 删除 `.dashed-card` 与 `.star-badge` 的视觉依赖。
|
||||
- `src/pages/orders/index.scss` 等硬编码颜色改为 token。
|
||||
- 全项目统一在 SCSS 中直接书写 `rpx`,减少 `px`/`rpx` 混用。
|
||||
- 弹窗、TabBar、底部操作栏统一使用毛玻璃。
|
||||
- 空状态、Toast、骨架屏按第 9.8/9.10 节统一。
|
||||
|
||||
---
|
||||
|
||||
## 15. 验收清单
|
||||
|
||||
- 所有页面使用同一套 CSS 变量,无新增硬编码颜色。
|
||||
- 页面没有 `3px dashed` 卡片和星星装饰。
|
||||
- 自定义导航在所有机型避让状态栏与胶囊按钮。
|
||||
- 深浅色切换后所有页面文字与背景可读。
|
||||
- 主要点击目标 ≥ 88rpx。
|
||||
- 表单有聚焦、错误、禁用状态。
|
||||
- 空状态都有下一步动作。
|
||||
- TabBar、吸顶栏、底部弹层为毛玻璃。
|
||||
- 页面横向留白统一 32rpx。
|
||||
- 图片有固定比例,加载前后不跳动。
|
||||
|
||||
---
|
||||
|
||||
## 16. 下一步
|
||||
|
||||
1. 打开 `design-system.html` 检查样例细节。
|
||||
2. 按反馈修订本规范。
|
||||
3. 将 token 落地到 `src/app.scss`。
|
||||
4. 按页面迁移清单逐页替换,最后真机回归深浅色与安全区。
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff9a9e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.5L14.8 9.2 21.5 12 14.8 14.8 12 21.5 9.2 14.8 2.5 12 9.2 9.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 239 B |
Vendored
+1
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff9a9e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5l4.5 4.5"/></svg>
|
||||
|
After Width: | Height: | Size: 227 B |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
.udb-page{min-height:100vh;padding:0 24rpx 40rpx}.udb-lock-card{-ms-flex-align:center;-webkit-align-items:center;align-items:center;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;padding:60rpx 40rpx;text-align:center}.udb-lock-icon{font-size:80rpx;margin-bottom:24rpx}.udb-lock-title{color:var(--text-primary);display:block;font-size:36rpx;font-weight:700;margin-bottom:12rpx}.udb-lock-desc{color:var(--text-secondary);display:block;font-size:26rpx;margin-bottom:32rpx}.udb-password-input{background:var(--bg-input);border:2rpx solid var(--line-star);border-radius:16rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:var(--text-primary);font-size:30rpx;height:80rpx;margin-bottom:24rpx;padding:0 24rpx;text-align:center;width:100%}.udb-error{color:#e64340;display:block;font-size:24rpx;margin-bottom:20rpx}.udb-btn{font-size:30rpx;padding:20rpx 0;width:100%}.udb-toolbar{-ms-flex-pack:end;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-justify-content:flex-end;justify-content:flex-end;margin:20rpx 0}.udb-add-btn{font-size:26rpx;padding:14rpx 28rpx}.udb-table{background:var(--bg-card);border:var(--line-card);border-radius:24rpx;overflow:hidden}.udb-row{-ms-flex-align:center;-webkit-align-items:center;align-items:center;border-bottom:1rpx solid rgba(0,0,0,.04);display:-webkit-flex;display:-ms-flexbox;display:flex;padding:20rpx 16rpx}.udb-row:last-child{border-bottom:none}.udb-header{background:var(--bg-input);padding:16rpx}.udb-header .udb-col{color:var(--text-primary);font-size:24rpx;font-weight:700}.udb-col{-ms-flex-negative:0;color:var(--text-secondary);-webkit-flex-shrink:0;flex-shrink:0;font-size:24rpx;overflow:hidden;padding:0 6rpx;text-overflow:ellipsis;white-space:nowrap}.col-nick{-webkit-flex:0 0 22%;-ms-flex:0 0 22%;flex:0 0 22%}.col-id{-webkit-flex:0 0 36%;-ms-flex:0 0 36%;flex:0 0 36%}.col-stats{-webkit-flex:0 0 18%;-ms-flex:0 0 18%;flex:0 0 18%;text-align:center}.col-action{-ms-flex-align:center;-ms-flex-pack:end;-webkit-align-items:center;align-items:center;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex:1;-ms-flex:1;flex:1;gap:12rpx;-webkit-justify-content:flex-end;justify-content:flex-end}.udb-row.active{background:rgba(91,140,255,.06)}.udb-link{background:rgba(91,140,255,.08);border-radius:8rpx;color:var(--accent-blue);font-size:22rpx;padding:6rpx 14rpx}.udb-danger{background:rgba(230,67,64,.08);color:#e64340}.udb-current{color:var(--accent-blue);font-size:22rpx;font-weight:600}.udb-empty{color:var(--text-secondary);font-size:28rpx;padding:40rpx;text-align:center}.udb-add-card{padding:48rpx 36rpx;width:80%}.udb-add-title{color:var(--text-primary);display:block;font-size:32rpx;font-weight:700;margin-bottom:24rpx;text-align:center}.udb-add-input{background:var(--bg-input);border:2rpx solid var(--line-star);border-radius:16rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:var(--text-primary);font-size:28rpx;height:80rpx;margin-bottom:32rpx;padding:0 24rpx;width:100%}.udb-add-actions{-ms-flex-align:center;-webkit-align-items:center;align-items:center;display:-webkit-flex;display:-ms-flexbox;display:flex;gap:20rpx}.udb-add-actions .btn-gradient,.udb-add-actions .btn-outline{-webkit-flex:1;-ms-flex:1;flex:1;padding:18rpx 0;text-align:center}
|
||||
.udb-page{min-height:100vh;padding:0 24rpx 40rpx}.udb-lock-card{-ms-flex-align:center;-webkit-align-items:center;align-items:center;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;padding:60rpx 40rpx;text-align:center}.udb-lock-icon{height:80rpx;margin-bottom:24rpx;width:80rpx}.udb-lock-title{color:var(--text-primary);display:block;font-size:36rpx;font-weight:700;margin-bottom:12rpx}.udb-lock-desc{color:var(--text-secondary);display:block;font-size:26rpx;margin-bottom:32rpx}.udb-password-input{background:var(--bg-input);border:2rpx solid var(--line-star);border-radius:16rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:var(--text-primary);font-size:30rpx;height:80rpx;margin-bottom:24rpx;padding:0 24rpx;text-align:center;width:100%}.udb-error{color:#e64340;display:block;font-size:24rpx;margin-bottom:20rpx}.udb-btn{font-size:30rpx;padding:20rpx 0;width:100%}.udb-toolbar{-ms-flex-pack:end;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-justify-content:flex-end;justify-content:flex-end;margin:20rpx 0}.udb-add-btn{font-size:26rpx;padding:14rpx 28rpx}.udb-table{background:var(--bg-card);border:var(--line-card);border-radius:24rpx;overflow:hidden}.udb-row{-ms-flex-align:center;-webkit-align-items:center;align-items:center;border-bottom:1rpx solid rgba(0,0,0,.04);display:-webkit-flex;display:-ms-flexbox;display:flex;padding:20rpx 16rpx}.udb-row:last-child{border-bottom:none}.udb-header{background:var(--bg-input);padding:16rpx}.udb-header .udb-col{color:var(--text-primary);font-size:24rpx;font-weight:700}.udb-col{-ms-flex-negative:0;color:var(--text-secondary);-webkit-flex-shrink:0;flex-shrink:0;font-size:24rpx;overflow:hidden;padding:0 6rpx;text-overflow:ellipsis;white-space:nowrap}.col-nick{-webkit-flex:0 0 22%;-ms-flex:0 0 22%;flex:0 0 22%}.col-id{-webkit-flex:0 0 36%;-ms-flex:0 0 36%;flex:0 0 36%}.col-stats{-webkit-flex:0 0 18%;-ms-flex:0 0 18%;flex:0 0 18%;text-align:center}.col-action{-ms-flex-align:center;-ms-flex-pack:end;-webkit-align-items:center;align-items:center;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex:1;-ms-flex:1;flex:1;gap:12rpx;-webkit-justify-content:flex-end;justify-content:flex-end}.udb-row.active{background:rgba(91,140,255,.06)}.udb-link{background:rgba(91,140,255,.08);border-radius:8rpx;color:var(--accent-blue);font-size:22rpx;padding:6rpx 14rpx}.udb-danger{background:rgba(230,67,64,.08);color:#e64340}.udb-current{color:var(--accent-blue);font-size:22rpx;font-weight:600}.udb-empty{color:var(--text-secondary);font-size:28rpx;padding:40rpx;text-align:center}.udb-add-card{padding:48rpx 36rpx;width:80%}.udb-add-title{color:var(--text-primary);display:block;font-size:32rpx;font-weight:700;margin-bottom:24rpx;text-align:center}.udb-add-input{background:var(--bg-input);border:2rpx solid var(--line-star);border-radius:16rpx;-webkit-box-sizing:border-box;box-sizing:border-box;color:var(--text-primary);font-size:28rpx;height:80rpx;margin-bottom:32rpx;padding:0 24rpx;width:100%}.udb-add-actions{-ms-flex-align:center;-webkit-align-items:center;align-items:center;display:-webkit-flex;display:-ms-flexbox;display:flex;gap:20rpx}.udb-add-actions .btn-gradient,.udb-add-actions .btn-outline{-webkit-flex:1;-ms-flex:1;flex:1;padding:18rpx 0;text-align:center}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -2,12 +2,17 @@ import { Component, PropsWithChildren } from 'react'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import { getTheme, resolveTheme } from './utils/store'
|
||||
import { applyPageBackground } from './utils/themeBackground'
|
||||
import { setSessionRefreshHandler } from './utils/request'
|
||||
import { refreshSession, ensureSessionFresh } from './utils/session'
|
||||
import './app.scss'
|
||||
|
||||
class App extends Component<PropsWithChildren> {
|
||||
componentDidMount() {
|
||||
// 启动最早时机同步页面背景,避免自定义导航区在深色模式下闪白
|
||||
applyPageBackground(resolveTheme(getTheme()))
|
||||
// 全局 401 自动续登 + 启动时主动校验登录态
|
||||
setSessionRefreshHandler(refreshSession)
|
||||
ensureSessionFresh()
|
||||
}
|
||||
|
||||
componentDidShow() {}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
}
|
||||
|
||||
.login-guard-icon {
|
||||
font-size: 80px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import { isAuthVerified } from '../../utils/authState'
|
||||
import { THEME_CHANGE_EVENT } from '../../context/ThemeContext'
|
||||
import { assetUrl } from '../../utils/asset'
|
||||
|
||||
type GuardState = 'checking' | 'ok' | 'denied'
|
||||
|
||||
@@ -51,7 +52,7 @@ export default function LoginGuard(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<View className='login-guard'>
|
||||
<View className='login-guard-inner'>
|
||||
<Text className='login-guard-icon'>⏳</Text>
|
||||
<Image className='login-guard-icon' src={assetUrl('/icon/设置.png')} mode='aspectFit' />
|
||||
<Text className='login-guard-title'>校验登录状态</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -62,7 +63,7 @@ export default function LoginGuard(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<View className='login-guard'>
|
||||
<View className='login-guard-inner'>
|
||||
<Text className='login-guard-icon'>🔒</Text>
|
||||
<Image className='login-guard-icon' src={assetUrl('/icon/个人.png')} mode='aspectFit' />
|
||||
<Text className='login-guard-title'>请先登录</Text>
|
||||
<Text className='login-guard-desc'>登录后查看您的专属内容</Text>
|
||||
<View className='btn-gradient login-guard-btn' onTap={() => Taro.switchTab({ url: '/pages/profile/index' })}>
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
font-size: 36px;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function ThemeToggle() {
|
||||
|
||||
return (
|
||||
<View className='theme-toggle' onTap={handleToggle}>
|
||||
<Text className='theme-icon'>{theme === 'light' ? '🌙' : '☀️'}</Text>
|
||||
<Text className='theme-icon'>{theme === 'light' ? '深色' : '浅色'}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff9a9e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.5L14.8 9.2 21.5 12 14.8 14.8 12 21.5 9.2 14.8 2.5 12 9.2 9.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff9a9e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5l4.5 4.5"/></svg>
|
||||
|
After Width: | Height: | Size: 227 B |
@@ -124,9 +124,9 @@ export default function AddressPage() {
|
||||
</Text>
|
||||
</View>
|
||||
<View className='address-actions'>
|
||||
<Text className='address-act' onTap={() => openEdit(item)}>✏️ 编辑</Text>
|
||||
<Text className='address-act' onTap={() => handleCopy(item)}>📋 复制</Text>
|
||||
<Text className='address-act delete' onTap={() => handleDelete(item.id)}>🗑️ 删除</Text>
|
||||
<Text className='address-act' onTap={() => openEdit(item)}>编辑</Text>
|
||||
<Text className='address-act' onTap={() => handleCopy(item)}>复制</Text>
|
||||
<Text className='address-act delete' onTap={() => handleDelete(item.id)}>删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function DesignListPage() {
|
||||
const style = STATUS_STYLE[item.status]
|
||||
const rawIcon = item.productIcon?.startsWith('/icon/') || /^https?:/i.test(item.productIcon || '')
|
||||
? item.productIcon
|
||||
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.png'
|
||||
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
|
||||
const iconSrc = assetUrl(rawIcon)
|
||||
const isSelected = selected.has(item.id)
|
||||
return (
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function DIYPage() {
|
||||
<Text className='add-label'>添加</Text>
|
||||
</View>
|
||||
</View>
|
||||
{hasOverlap && <Text className='overlap-hint'>⚠️ 有贴纸重叠,请调整位置</Text>}
|
||||
{hasOverlap && <Text className='overlap-hint'>有贴纸重叠,请调整位置</Text>}
|
||||
</View>
|
||||
|
||||
{/* 控制工具栏 */}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function Index() {
|
||||
<View className='search-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<View className='search-inner'>
|
||||
<Image className='search-icon-img' src={assetUrl('/icon/搜索.png')} mode='aspectFit' />
|
||||
<Image className='search-icon-img' src={assetUrl('/icon/搜索.svg')} mode='aspectFit' />
|
||||
<Input
|
||||
className='search-input'
|
||||
type='text'
|
||||
@@ -132,7 +132,7 @@ export default function Index() {
|
||||
<View className='category-img-wrapper'>
|
||||
<Image
|
||||
className='category-img-real'
|
||||
src={PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.png')}
|
||||
src={PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
</View>
|
||||
@@ -144,7 +144,7 @@ export default function Index() {
|
||||
</View>
|
||||
{filteredCategories.length === 0 && (
|
||||
<View className='empty-category dashed-card'>
|
||||
<Image className='empty-icon-img' src={assetUrl('/icon/搜索.png')} mode='aspectFit' />
|
||||
<Image className='empty-icon-img' src={assetUrl('/icon/搜索.svg')} mode='aspectFit' />
|
||||
<Text className='empty-text'>未找到匹配的品类</Text>
|
||||
<Text className='empty-sub'>尝试搜索“笔记本”、“杯垫”等</Text>
|
||||
</View>
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function OrderDetailPage() {
|
||||
<View className='product-icon-wrapper'>
|
||||
<Image
|
||||
className='product-icon-img'
|
||||
src={assetUrl(order.productIcon?.startsWith('/icon/') || /^https?:/i.test(order.productIcon || '') ? order.productIcon : PRODUCT_ICON_MAP[order.productId] || '/icon/四角星.png')}
|
||||
src={assetUrl(order.productIcon?.startsWith('/icon/') || /^https?:/i.test(order.productIcon || '') ? order.productIcon : PRODUCT_ICON_MAP[order.productId] || '/icon/四角星.svg')}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function OrdersPage() {
|
||||
const status = STATUS_MAP[order.statusCode] || STATUS_MAP.pending
|
||||
const rawIcon = order.productIcon?.startsWith('/icon/') || /^https?:/i.test(order.productIcon || '')
|
||||
? order.productIcon
|
||||
: '/icon/四角星.png'
|
||||
: '/icon/四角星.svg'
|
||||
const iconSrc = assetUrl(rawIcon)
|
||||
return (
|
||||
<View key={order.id} className='order-card dashed-card' onTap={() => goDetail(order)}>
|
||||
|
||||
@@ -22,7 +22,7 @@ const ICON_MAP: Record<string, string> = {
|
||||
|
||||
const STATUS_MAP = [
|
||||
{ key: 'toDesign', label: '待设计', icon: assetUrl('/icon/三角尺.png') },
|
||||
{ key: 'pending', label: '待付款', icon: assetUrl('/icon/四角星.png') },
|
||||
{ key: 'pending', label: '待付款', icon: assetUrl('/icon/四角星.svg') },
|
||||
{ key: 'paid', label: '待发货', icon: assetUrl('/icon/包裹.png') },
|
||||
{ key: 'shipping', label: '待收货', icon: assetUrl('/icon/杯子.png') },
|
||||
{ key: 'done', label: '已完成', icon: assetUrl('/icon/书本.png') }
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
}
|
||||
|
||||
.udb-lock-icon {
|
||||
font-size: 80px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, Input, Button } from '@tarojs/components'
|
||||
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './index.scss'
|
||||
@@ -14,6 +14,7 @@ import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import ThemedPageMeta from '../../components/ThemedPageMeta'
|
||||
import { assetUrl } from '../../utils/asset'
|
||||
|
||||
export default function UserDatabasePage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
@@ -87,7 +88,7 @@ export default function UserDatabasePage() {
|
||||
<View className='udb-page'>
|
||||
<View className='udb-lock-card dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<Text className='udb-lock-icon'>🔐</Text>
|
||||
<Image className='udb-lock-icon' src={assetUrl('/icon/账号管理.png')} mode='aspectFit' />
|
||||
<Text className='udb-lock-title'>管理员验证</Text>
|
||||
<Text className='udb-lock-desc'>请输入密码以访问用户数据库</Text>
|
||||
<Input
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 80px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -190,7 +191,8 @@
|
||||
}
|
||||
|
||||
.generating-icon {
|
||||
font-size: 120px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@@ -277,7 +279,8 @@
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import ThemedPageMeta from '../../components/ThemedPageMeta'
|
||||
import { assetUrl } from '../../utils/asset'
|
||||
|
||||
export default function WordCloudPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
@@ -92,7 +93,7 @@ export default function WordCloudPage() {
|
||||
<Image className='preview-image' src={baseImage} mode='aspectFit' />
|
||||
) : (
|
||||
<View className='upload-placeholder'>
|
||||
<Text className='upload-icon'>📷</Text>
|
||||
<Image className='upload-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
|
||||
<Text className='upload-title'>点击上传底图</Text>
|
||||
<Text className='upload-desc'>支持照片、Logo、图案等</Text>
|
||||
<Text className='upload-tip'>系统将用名字填充到深色区域</Text>
|
||||
@@ -148,7 +149,7 @@ export default function WordCloudPage() {
|
||||
<View className='step-content'>
|
||||
{isGenerating ? (
|
||||
<View className='generating-panel'>
|
||||
<Text className='generating-icon'>✨</Text>
|
||||
<Image className='generating-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
|
||||
<Text className='generating-title'>正在生成词云...</Text>
|
||||
<Text className='generating-subtitle'>AI正在将名字融入图案</Text>
|
||||
<View className='progress-bar'>
|
||||
@@ -166,15 +167,15 @@ export default function WordCloudPage() {
|
||||
</View>
|
||||
<View className='result-actions'>
|
||||
<View className='action-btn' onTap={handleExportImage}>
|
||||
<Text className='action-icon'>💾</Text>
|
||||
<Image className='action-icon' src={assetUrl('/icon/设置.png')} mode='aspectFit' />
|
||||
<Text className='action-label'>保存图片</Text>
|
||||
</View>
|
||||
<View className='action-btn' onTap={handleGenerate}>
|
||||
<Text className='action-icon'>🔄</Text>
|
||||
<Image className='action-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
|
||||
<Text className='action-label'>重新生成</Text>
|
||||
</View>
|
||||
<View className='action-btn' onTap={() => setStep(2)}>
|
||||
<Text className='action-icon'>✏️</Text>
|
||||
<Image className='action-icon' src={assetUrl('/icon/调色盘.png')} mode='aspectFit' />
|
||||
<Text className='action-label'>修改名单</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ export function assetUrl(path?: string | null): string {
|
||||
/** 从旧存储中解析商品图标:兼容本地 /icon、远程 URL 和老版 emoji 缺省值 */
|
||||
export function resolveStoredIcon(
|
||||
value?: string,
|
||||
fallback = '/icon/四角星.png',
|
||||
fallback = '/icon/四角星.svg',
|
||||
): string {
|
||||
if (!value) return assetUrl(fallback)
|
||||
if (value.startsWith('/icon/') || /^https?:/i.test(value)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 产品品类配置文件
|
||||
*
|
||||
* ⚠️ 重要:当实际产品尺寸确定后,请修改本文件中的 mask 参数
|
||||
* 注意:当实际产品尺寸确定后,请修改本文件中的 mask 参数
|
||||
* 修改后页面会自动生效,无需改动其他代码
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
|
||||
id: 'notebook-small',
|
||||
name: '微雕笔记本(小)',
|
||||
desc: '便携随行,记录点滴',
|
||||
icon: '📓',
|
||||
icon: '/icon/书本.png',
|
||||
iconImg: '/icon/书本.png',
|
||||
price: 12,
|
||||
originalPrice: 18,
|
||||
@@ -45,7 +45,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
|
||||
id: 'notebook-large',
|
||||
name: '微雕笔记本(大)',
|
||||
desc: '大开本,更多创作空间',
|
||||
icon: '📔',
|
||||
icon: '/icon/书本_大.png',
|
||||
iconImg: '/icon/书本_大.png',
|
||||
price: 45,
|
||||
originalPrice: 58,
|
||||
@@ -77,7 +77,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
|
||||
id: 'coaster',
|
||||
name: '铜质杯垫',
|
||||
desc: '金属质感,桌面艺术',
|
||||
icon: '☕',
|
||||
icon: '/icon/杯子.png',
|
||||
iconImg: '/icon/杯子.png',
|
||||
price: 25,
|
||||
originalPrice: 35,
|
||||
@@ -108,7 +108,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
|
||||
id: 'penbox',
|
||||
name: '竹制笔盒',
|
||||
desc: '自然竹纹,文房雅器',
|
||||
icon: '✏️',
|
||||
icon: '/icon/笔盒.png',
|
||||
iconImg: '/icon/笔盒.png',
|
||||
price: 75,
|
||||
originalPrice: 98,
|
||||
@@ -140,7 +140,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
|
||||
id: 'booklamp',
|
||||
name: '书本型灯',
|
||||
desc: '温暖光影,点亮心意',
|
||||
icon: '💡',
|
||||
icon: '/icon/书灯.png',
|
||||
iconImg: '/icon/书灯.png',
|
||||
price: 45,
|
||||
originalPrice: 68,
|
||||
@@ -207,6 +207,6 @@ export const PRODUCT_ICON_MAP: Record<string, string> = {
|
||||
*/
|
||||
export function getProductIconImg(product: any): string {
|
||||
if (product?.iconImg) return assetUrl(product.iconImg)
|
||||
if (product?.id) return assetUrl(PRODUCT_ICON_MAP[product.id] || '/icon/四角星.png')
|
||||
return assetUrl('/icon/四角星.png')
|
||||
if (product?.id) return assetUrl(PRODUCT_ICON_MAP[product.id] || '/icon/四角星.svg')
|
||||
return assetUrl('/icon/四角星.svg')
|
||||
}
|
||||
|
||||
+60
-26
@@ -51,6 +51,8 @@ export interface RequestOptions {
|
||||
headers?: Record<string, string>
|
||||
/** 是否自动携带 token,默认 true */
|
||||
auth?: boolean
|
||||
/** 内部参数:会话刷新内部的请求不再触发 401 自动刷新 */
|
||||
skipAuthRefresh?: boolean
|
||||
}
|
||||
|
||||
// ---------- request ----------
|
||||
@@ -59,37 +61,75 @@ export interface RequestOptions {
|
||||
* 发请求。成功(code===0)返回 data;业务失败抛 Error(message);HTTP 401 清 token 并抛错。
|
||||
* 调用方可自行 catch 或用 onUnauthorized 钩子统一处理跳登录。
|
||||
*/
|
||||
export async function request<T = unknown>(
|
||||
export let onUnauthorized: (() => void) | null = null
|
||||
export function setOnUnauthorized(handler: () => void): void {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
let sessionRefreshHandler: (() => Promise<boolean>) | null = null
|
||||
export function setSessionRefreshHandler(handler: (() => Promise<boolean>) | null): void {
|
||||
sessionRefreshHandler = handler
|
||||
}
|
||||
|
||||
async function sendRequest(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const { method = 'GET', data, headers = {}, auth = true } = options
|
||||
|
||||
const token = getToken()
|
||||
const finalHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(auth && token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers,
|
||||
}
|
||||
|
||||
let res: Taro.request.SuccessCallbackResult
|
||||
try {
|
||||
res = await Taro.request({
|
||||
method: string,
|
||||
data: unknown,
|
||||
finalHeaders: Record<string, string>,
|
||||
): Promise<Taro.request.SuccessCallbackResult> {
|
||||
return Taro.request({
|
||||
url: `${BASE_URL}${path}`,
|
||||
method,
|
||||
data,
|
||||
header: finalHeaders,
|
||||
})
|
||||
} catch (e) {
|
||||
// 网络层异常
|
||||
throw new Error('网络异常,请稍后重试')
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const { method = 'GET', data, headers = {}, auth = true, skipAuthRefresh = false } = options
|
||||
|
||||
const buildHeaders = (): Record<string, string> => {
|
||||
const token = getToken()
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(auth && token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers,
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 未授权:清 token,交回调处理
|
||||
let res: Taro.request.SuccessCallbackResult
|
||||
try {
|
||||
res = await sendRequest(path, method, data, buildHeaders())
|
||||
} catch (e) {
|
||||
const err = new Error('网络异常,请稍后重试') as Error & { statusCode?: number }
|
||||
err.statusCode = -1
|
||||
throw err
|
||||
}
|
||||
|
||||
// HTTP 未授权:先尝试自动续登一次,成功则用新 token 重试原请求
|
||||
if (res.statusCode === 401 && !skipAuthRefresh && sessionRefreshHandler) {
|
||||
const refreshed = await sessionRefreshHandler()
|
||||
if (refreshed) {
|
||||
try {
|
||||
res = await sendRequest(path, method, data, buildHeaders())
|
||||
} catch (e) {
|
||||
const err = new Error('网络异常,请稍后重试') as Error & { statusCode?: number }
|
||||
err.statusCode = -1
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 仍然未授权:清 token,交回调处理
|
||||
if (res.statusCode === 401) {
|
||||
clearToken()
|
||||
onUnauthorized?.()
|
||||
throw new Error('登录已过期,请重新登录')
|
||||
const err = new Error('登录已过期,请重新登录') as Error & { statusCode?: number }
|
||||
err.statusCode = 401
|
||||
throw err
|
||||
}
|
||||
|
||||
const body = res.data as ApiResponse<T>
|
||||
@@ -102,12 +142,6 @@ export async function request<T = unknown>(
|
||||
return (body && typeof body.code === 'number' ? body.data : body) as T
|
||||
}
|
||||
|
||||
/** 401 钩子,由业务方注册(如跳转登录页) */
|
||||
export let onUnauthorized: (() => void) | null = null
|
||||
export function setOnUnauthorized(handler: () => void): void {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
// ---------- 便捷方法 ----------
|
||||
|
||||
export const http = {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import http, { getToken, setToken, clearToken } from './request'
|
||||
import { getUserInfoRaw, clearUserInfo, isMockOpenid } from './store'
|
||||
|
||||
let refreshInflight: Promise<boolean> | null = null
|
||||
|
||||
/**
|
||||
* 重新获取登录态:wx.login 换新 code → 后端续签 accessToken →
|
||||
* getMe 校验 openid 与本地账号一致才放行,确保不会串号。
|
||||
*/
|
||||
export function refreshSession(): Promise<boolean> {
|
||||
if (refreshInflight) return refreshInflight
|
||||
refreshInflight = doRefreshSession().finally(() => {
|
||||
refreshInflight = null
|
||||
})
|
||||
return refreshInflight
|
||||
}
|
||||
|
||||
async function doRefreshSession(): Promise<boolean> {
|
||||
const localUser = getUserInfoRaw()
|
||||
if (!localUser?.openid || isMockOpenid(localUser.openid)) return false
|
||||
|
||||
try {
|
||||
const wxLogin = await Taro.login()
|
||||
if (!wxLogin.code) return false
|
||||
|
||||
const result = await http.post<{ accessToken?: string }>(
|
||||
'/api/auth/login',
|
||||
{ code: wxLogin.code },
|
||||
{ auth: false, skipAuthRefresh: true },
|
||||
)
|
||||
if (!result?.accessToken) return false
|
||||
setToken(result.accessToken)
|
||||
|
||||
const me = await http.get<{ openid?: string | null }>('/api/users/me', {
|
||||
skipAuthRefresh: true,
|
||||
})
|
||||
if (me?.openid === localUser.openid) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 新 token 对应了不同账号,不能静默切换,清掉旧会话
|
||||
clearToken()
|
||||
clearUserInfo()
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序启动时主动校验一次:token 还在就先 getMe 验签,
|
||||
* 只有 401 才触发自动续登;网络异常时保留本地会话,后续 401 再续。
|
||||
*/
|
||||
export async function ensureSessionFresh(): Promise<boolean> {
|
||||
const localUser = getUserInfoRaw()
|
||||
if (!localUser?.openid || isMockOpenid(localUser.openid)) return false
|
||||
if (!getToken()) return refreshSession()
|
||||
|
||||
try {
|
||||
await http.get('/api/users/me', { skipAuthRefresh: true })
|
||||
return true
|
||||
} catch (e) {
|
||||
const statusCode = (e as Error & { statusCode?: number })?.statusCode
|
||||
if (statusCode === 401) return refreshSession()
|
||||
return !!getToken()
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -176,7 +176,7 @@ export function setDesignList(list: DesignItem[]) {
|
||||
|
||||
export function addDesign(product: any, count: number): DesignItem {
|
||||
const list = getDesignList()
|
||||
const iconImg = assetUrl(product?.iconImg || PRODUCT_ICON_MAP[product?.id] || '/icon/四角星.png')
|
||||
const iconImg = assetUrl(product?.iconImg || PRODUCT_ICON_MAP[product?.id] || '/icon/四角星.svg')
|
||||
const item: DesignItem = {
|
||||
id: 'DSG' + Date.now(),
|
||||
productId: product.id,
|
||||
@@ -229,7 +229,7 @@ export function designToOrder(designId: string): OrderItem | null {
|
||||
|
||||
const rawIcon = design.productIcon?.startsWith('/icon/') || /^https?:/i.test(design.productIcon || '')
|
||||
? design.productIcon
|
||||
: PRODUCT_ICON_MAP[design.productId] || '/icon/四角星.png'
|
||||
: PRODUCT_ICON_MAP[design.productId] || '/icon/四角星.svg'
|
||||
|
||||
const iconImg = assetUrl(rawIcon)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user