chore: establish V1.1 recovery baseline

This commit is contained in:
2026-09-09 22:05:00 +08:00
commit b46b48eb33
947 changed files with 155471 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
// 丹阳公益婚恋服务平台 - 小程序入口
const stores = require('./stores');
App({
globalData: {
// 平台配置(静态信息)
platformName: '丹阳公益婚恋服务平台',
slogan: '真实相识 · 公益牵线',
},
onLaunch() {
// 触发 7 个 Store 的冷启动初始化(规范 §131)
// - 同步从 storage 恢复登录态/用户状态/未读数(避免白屏)
// - 异步拉取服务端 App 配置(不阻塞)
// - 若已登录异步刷新 /me/status(不阻塞)
stores.bootstrap();
},
onError(err) {
console.error('[App onError]', err);
},
onPageNotFound(res) {
wx.redirectTo({
url: '/pages/index/index'
});
}
});
+107
View File
@@ -0,0 +1,107 @@
{
"usingComponents": {},
"sitemapLocation": "sitemap.json",
"style": "v2",
"pages": [
"pages/index/index",
"pages/discover/discover",
"pages/messages/messages",
"pages/activity/activity",
"pages/mine/mine",
"pages/login/login",
"pages/bind-phone/bind-phone",
"pages/verification/verification",
"pages/profile-basic/profile-basic",
"pages/profile-lifestyle/profile-lifestyle",
"pages/profile-photos/profile-photos",
"pages/profile-interests/profile-interests",
"pages/profile-intent/profile-intent",
"pages/profile-intro/profile-intro",
"pages/profile-preference/profile-preference",
"pages/profile-confirm/profile-confirm",
"pages/review-pending/review-pending",
"pages/review-rejected/review-rejected",
"pages/profile-detail/profile-detail",
"pages/fate-center/fate-center",
"pages/chat/chat",
"pages/activity-detail/activity-detail",
"pages/activity-register/activity-register",
"pages/my-activities/my-activities",
"pages/checkin/checkin",
"pages/activity-feedback/activity-feedback",
"pages/activity-matches/activity-matches",
"pages/matchmaker/matchmaker",
"pages/matchmaker-apply/matchmaker-apply",
"pages/matchmaker-progress/matchmaker-progress",
"pages/settings/settings",
"pages/privacy-settings/privacy-settings",
"pages/relationship-status/relationship-status",
"pages/blacklist/blacklist",
"pages/notifications/notifications",
"pages/report/report",
"pages/my-reports/my-reports",
"pages/help-center/help-center",
"pages/about/about",
"pages/account-status-page/account-status-page",
"pages/account-logout/account-logout",
"pages/account-status/account-status",
"pages/appeal/appeal",
"pages/matchmaker-login/matchmaker-login",
"pages/matchmaker-workbench/matchmaker-workbench",
"pages/local-eligibility/local-eligibility",
"pages/local-eligibility-supplement/local-eligibility-supplement",
"pages/counseling-intro/counseling-intro",
"pages/counseling-request/counseling-request",
"pages/my-counseling/my-counseling",
"pages/premarital-intro/premarital-intro",
"pages/premarital-request/premarital-request",
"pages/activity-mutual-result/activity-mutual-result",
"pages/activity-mutual-waiting/activity-mutual-waiting"
],
"window": {
"navigationBarTitleText": "丹阳公益婚恋",
"navigationBarBackgroundColor": "#E96B6B",
"navigationBarTextStyle": "white",
"backgroundColor": "#F7F8FA",
"backgroundTextStyle": "dark",
"enablePullDownRefresh": false
},
"tabBar": {
"color": "#999999",
"selectedColor": "#E96B6B",
"backgroundColor": "#FFFFFF",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "assets/tabbar/home.png",
"selectedIconPath": "assets/tabbar/home-active.png"
},
{
"pagePath": "pages/discover/discover",
"text": "找对象",
"iconPath": "assets/tabbar/discover.png",
"selectedIconPath": "assets/tabbar/discover-active.png"
},
{
"pagePath": "pages/messages/messages",
"text": "消息",
"iconPath": "assets/tabbar/message.png",
"selectedIconPath": "assets/tabbar/message-active.png"
},
{
"pagePath": "pages/activity/activity",
"text": "活动",
"iconPath": "assets/tabbar/activity.png",
"selectedIconPath": "assets/tabbar/activity-active.png"
},
{
"pagePath": "pages/mine/mine",
"text": "我的",
"iconPath": "assets/tabbar/mine.png",
"selectedIconPath": "assets/tabbar/mine-active.png"
}
]
}
}
+199
View File
@@ -0,0 +1,199 @@
/**
* 丹阳公益婚恋服务平台 - 全局 Design Token
*
* 依据《前端组件库与 Design Token 规范 V1.0》
* 单位说明:rpx(750rpx = 屏幕宽度),规范 px 值按 2x 映射为 rpx
* 禁止页面层写死颜色/间距/圆角,必须引用 var(--token)
*/
page {
/* ============ 品牌主色(规范 §3) ============ */
--color-primary: #E96B6B;
--color-primary-active: #D95F5F;
--color-primary-light: #FFF3F2;
--color-primary-lighter: #FFF8F7;
/* ============ 功能色(规范 §4) ============ */
--color-success: #2E9B68;
--color-success-light: #ECF8F2;
--color-warning: #E59A32;
--color-warning-light: #FFF7E9;
--color-danger: #D94A4A;
--color-danger-light: #FFF0F0;
--color-info: #4A7CD4;
--color-info-light: #EEF4FF;
/* ============ 中性色(规范 §5-6) ============ */
--color-text-primary: #222222;
--color-text-secondary: #444444;
--color-text-tertiary: #777777;
--color-text-placeholder: #999999;
--color-text-disabled: #B5B5B5;
--color-text-inverse: #FFFFFF; /* 用于 primary/success 等深色背景上的文字 */
--color-border: #EEEEEE;
--color-divider: #F0F0F0;
--color-border-focus: #E96B6B;
--color-border-error: #D94A4A;
--color-bg-page: #F7F8FA;
--color-bg-card: #FFFFFF;
--color-bg-disabled: #F3F3F3;
--color-bg-overlay: rgba(0, 0, 0, 0.45);
/* ============ 字体(规范 §7-11) ============ */
--font-family-cn: -apple-system, "PingFang SC", "Noto Sans SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
--font-size-xs: 24rpx; /* 12px */
--font-size-sm: 26rpx; /* 13px */
--font-size-md: 28rpx; /* 14px */
--font-size-base: 32rpx; /* 16px */
--font-size-lg: 36rpx; /* 18px */
--font-size-xl: 40rpx; /* 20px */
--font-size-2xl: 48rpx; /* 24px */
--font-size-3xl: 56rpx; /* 28px */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.7;
/* ============ 间距(规范 §12,4px 栅格) ============ */
--spacing-1: 8rpx; /* 4px */
--spacing-2: 16rpx; /* 8px */
--spacing-3: 24rpx; /* 12px */
--spacing-4: 32rpx; /* 16px */
--spacing-5: 40rpx; /* 20px */
--spacing-6: 48rpx; /* 24px */
--spacing-8: 64rpx; /* 32px */
--spacing-10: 80rpx; /* 40px */
--spacing-12: 96rpx; /* 48px */
--spacing-page-x: 32rpx; /* 页面左右 16px */
--spacing-section: 40rpx; /* 区块间 20px */
--spacing-card-inner: 32rpx; /* 卡片内部 16px */
--spacing-tight: 16rpx; /* 紧密 8px */
/* ============ 圆角(规范 §14) ============ */
--radius-sm: 16rpx; /* 8px */
--radius-md: 24rpx; /* 12px */
--radius-lg: 32rpx; /* 16px */
--radius-xl: 40rpx; /* 20px */
--radius-full: 9999rpx;
/* ============ 阴影(规范 §15,极轻) ============ */
--shadow-card: 0 8rpx 32rpx rgba(0, 0, 0, 0.04);
--shadow-popup: 0 16rpx 64rpx rgba(0, 0, 0, 0.10);
/* ============ 边框(规范 §16) ============ */
--border-width: 1rpx;
/* ============ 动效(规范 §17) ============ */
--motion-fast: 120ms;
--motion-normal: 200ms;
--motion-slow: 300ms;
--ease-standard: ease-out;
/* ============ 层级 z-index(规范 §90) ============ */
--z-base: 1;
--z-sticky: 100;
--z-tabbar: 200;
--z-dropdown: 500;
--z-modal: 1000;
--z-toast: 2000;
/* ============ 页面基础样式 ============ */
background-color: var(--color-bg-page);
font-family: var(--font-family-cn);
font-size: var(--font-size-base);
color: var(--color-text-primary);
line-height: var(--line-height-normal);
}
/* ============ 通用工具类 ============ */
.container {
padding: var(--spacing-page-x);
}
.flex { display: flex; }
.flex-column { display: flex; flex-direction: column; }
.flex-center { display: flex; align-items: center; justify-content: center; }
.flex-between { display: flex; align-items: center; justify-content: space-between; }
.flex-start { display: flex; align-items: center; justify-content: flex-start; }
.flex-1 { flex: 1; min-width: 0; }
.flex-shrink-0 { flex-shrink: 0; }
.wrap { flex-wrap: wrap; }
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-tertiary { color: var(--color-text-tertiary); }
.text-placeholder { color: var(--color-text-placeholder); }
.text-white { color: #FFFFFF; }
.text-brand { color: var(--color-primary); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-danger { color: var(--color-danger); }
.text-info { color: var(--color-info); }
.font-xs { font-size: var(--font-size-xs); }
.font-sm { font-size: var(--font-size-sm); }
.font-md { font-size: var(--font-size-md); }
.font-base { font-size: var(--font-size-base); }
.font-lg { font-size: var(--font-size-lg); }
.font-xl { font-size: var(--font-size-xl); }
.font-2xl { font-size: var(--font-size-2xl); }
.font-3xl { font-size: var(--font-size-3xl); }
.font-regular { font-weight: var(--font-weight-regular); }
.font-medium { font-weight: var(--font-weight-medium); }
.font-semibold { font-weight: var(--font-weight-semibold); }
.font-bold { font-weight: var(--font-weight-bold); }
.mt-1 { margin-top: var(--spacing-1); }
.mt-2 { margin-top: var(--spacing-2); }
.mt-3 { margin-top: var(--spacing-3); }
.mt-4 { margin-top: var(--spacing-4); }
.mt-5 { margin-top: var(--spacing-5); }
.mt-6 { margin-top: var(--spacing-6); }
.mb-1 { margin-bottom: var(--spacing-1); }
.mb-2 { margin-bottom: var(--spacing-2); }
.mb-3 { margin-bottom: var(--spacing-3); }
.mb-4 { margin-bottom: var(--spacing-4); }
.mb-5 { margin-bottom: var(--spacing-5); }
.mb-6 { margin-bottom: var(--spacing-6); }
/* 文本省略 */
.ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ellipsis-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
/* 安全区底部留白(避开 Home Indicator) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* 页面卡片基础样式(中低密度留白) */
.card-base {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
padding: var(--spacing-card-inner);
box-shadow: var(--shadow-card);
}
/* 分割线 */
.divider {
height: var(--border-width);
background: var(--color-divider);
}
+113
View File
@@ -0,0 +1,113 @@
# 丹阳公益婚恋平台 - 底部导航栏图标设计规范
## 概述
为小程序 5 个底部导航 Tab 设计的圆润线性风格图标,包含未选中(灰色)和选中(玫瑰红)两种状态,适配三种屏幕密度(1x/2x/3x)。
## 设计规范
| 属性 | 规范值 |
|------|--------|
| 图标尺寸 | 81 × 81 px (基础) |
| 安全区域 | 56 × 56 px (边距 12px) |
| 线宽 | 2px |
| 圆角 | 端点圆角 + 转折圆角 |
| 填充 | 无(纯线性) |
## 颜色规范
| 状态 | 颜色值 | 用途 |
|------|--------|------|
| 未选中 | `#999999` | 默认状态图标 |
| 选中 | `#E96B6B` | 激活状态图标 |
| 背景 | `#FFFFFF` | 透明背景(PNG 支持 alpha) |
## 图标清单
### 1. 首页 (home)
- **意象**: 温馨小屋
- **元素**: 屋顶三角 + 房屋主体 + 门 + 烟囱
- **文件**: `home.png` / `home-active.png`
### 2. 找对象 (discover)
- **意象**: 双心相连,象征缘分匹配
- **元素**: 两个相交的爱心
- **文件**: `discover.png` / `discover-active.png`
### 3. 消息 (message)
- **意象**: 对话沟通
- **元素**: 圆角气泡 + 小三角 + 文字线条
- **文件**: `message.png` / `message-active.png`
### 4. 活动 (activity)
- **意象**: 日程安排
- **元素**: 日历主体 + 挂环 + 日期标记点
- **文件**: `activity.png` / `activity-active.png`
### 5. 我的 (mine)
- **意象**: 个人用户
- **元素**: 圆形头部 + 半圆身体轮廓
- **文件**: `mine.png` / `mine-active.png`
## 导出尺寸
| 尺寸 | 像素 | 适用场景 |
|------|------|---------|
| 1x | 81 × 81 | 标准屏设备 |
| 2x | 162 × 162 | Retina 屏设备 |
| 3x | 243 × 243 | 超高清晰屏设备 |
文件名约定:`{icon-name}.png` / `{icon-name}-active.png` / `{icon-name}_2x.png` / `{icon-name}_3x.png`
## SVG 源文件
所有图标均提供可编辑的 SVG 版本,位于 `svg/` 目录:
- 可直接在 Figma 中导入编辑
- 可调整颜色、粗细、圆角等参数
- 颜色替换:将 `stroke="#999999"` 改为 `stroke="#E96B6B"` 即可切换激活状态
## 文件位置
```
miniapp/assets/tabbar/
├── svg/ # SVG 源文件(可编辑)
│ ├── home.svg / home-active.svg
│ ├── discover.svg / discover-active.svg
│ ├── message.svg / message-active.svg
│ ├── activity.svg / activity-active.svg
│ └── mine.svg / mine-active.svg
├── home.png / home-active.png
├── home_2x.png / home-active_2x.png
├── home_3x.png / home-active_3x.png
├── discover.png / discover-active.png
├── discover_2x.png / discover-active_2x.png
├── discover_3x.png / discover-active_3x.png
├── message.png / message-active.png
├── message_2x.png / message-active_2x.png
├── message_3x.png / message-active_3x.png
├── activity.png / activity-active.png
├── activity_2x.png / activity-active_2x.png
├── activity_3x.png / activity-active_3x.png
├── mine.png / mine-active.png
├── mine_2x.png / mine-active_2x.png
└── mine_3x.png / mine-active_3x.png
```
## 更新说明
小程序 TabBar 配置无需修改,文件名与原有配置保持一致。
原配置 (`app.json`):
```json
{
"tabBar": {
"list": [
{ "pagePath": "pages/index/index", "iconPath": "assets/tabbar/home.png", "selectedIconPath": "assets/tabbar/home-active.png" },
{ "pagePath": "pages/discover/discover", "iconPath": "assets/tabbar/discover.png", "selectedIconPath": "assets/tabbar/discover-active.png" },
{ "pagePath": "pages/messages/messages", "iconPath": "assets/tabbar/message.png", "selectedIconPath": "assets/tabbar/message-active.png" },
{ "pagePath": "pages/activity/activity", "iconPath": "assets/tabbar/activity.png", "selectedIconPath": "assets/tabbar/activity-active.png" },
{ "pagePath": "pages/mine/mine", "iconPath": "assets/tabbar/mine.png", "selectedIconPath": "assets/tabbar/mine-active.png" }
]
}
}
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,12 @@
<!-- 活动 - 日历图标 (选中状态) -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="25" cy="18" r="5" stroke="#E96B6B" stroke-width="2"/>
<circle cx="56" cy="18" r="5" stroke="#E96B6B" stroke-width="2"/>
<rect x="12" y="26" width="57" height="40" rx="8" stroke="#E96B6B" stroke-width="2"/>
<line x1="16" y1="36" x2="65" y2="36" stroke="#E96B6B" stroke-width="2"/>
<circle cx="25" cy="46" r="3" fill="#E96B6B"/>
<circle cx="40.5" cy="46" r="3" fill="#E96B6B"/>
<circle cx="56" cy="46" r="3" fill="#E96B6B"/>
<circle cx="33" cy="56" r="3" fill="#E96B6B"/>
<circle cx="48" cy="56" r="3" fill="#E96B6B"/>
</svg>

After

Width:  |  Height:  |  Size: 696 B

+16
View File
@@ -0,0 +1,16 @@
<!-- 活动 - 日历图标 -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 挂环 -->
<circle cx="25" cy="18" r="5" stroke="#999999" stroke-width="2"/>
<circle cx="56" cy="18" r="5" stroke="#999999" stroke-width="2"/>
<!-- 日历主体 -->
<rect x="12" y="26" width="57" height="40" rx="8" stroke="#999999" stroke-width="2"/>
<!-- 分隔线 -->
<line x1="16" y1="36" x2="65" y2="36" stroke="#999999" stroke-width="2"/>
<!-- 日期点 -->
<circle cx="25" cy="46" r="3" fill="#999999"/>
<circle cx="40.5" cy="46" r="3" fill="#999999"/>
<circle cx="56" cy="46" r="3" fill="#999999"/>
<circle cx="33" cy="56" r="3" fill="#999999"/>
<circle cx="48" cy="56" r="3" fill="#999999"/>
</svg>

After

Width:  |  Height:  |  Size: 765 B

@@ -0,0 +1,5 @@
<!-- 找对象 - 双心图标 (选中状态) -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28 32C28 26 32 22 36 22C38 22 40 23 41 25C42 23 44 22 46 22C50 22 54 26 54 32C54 38 48 44 41 50C34 44 28 38 28 32Z" stroke="#E96B6B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M42 36C42 30 46 26 50 26C52 26 54 27 55 29C56 27 58 26 60 26C64 26 68 30 68 36C68 42 62 48 55 54C48 48 42 42 42 36Z" stroke="#E96B6B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 574 B

+7
View File
@@ -0,0 +1,7 @@
<!-- 找对象 - 双心图标 -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 左心 -->
<path d="M28 32C28 26 32 22 36 22C38 22 40 23 41 25C42 23 44 22 46 22C50 22 54 26 54 32C54 38 48 44 41 50C34 44 28 38 28 32Z" stroke="#999999" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 右心 -->
<path d="M42 36C42 30 46 26 50 26C52 26 54 27 55 29C56 27 58 26 60 26C64 26 68 30 68 36C68 42 62 48 55 54C48 48 42 42 42 36Z" stroke="#999999" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 595 B

@@ -0,0 +1,7 @@
<!-- 首页 - 房子图标 (选中状态) -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="58" y="18" width="8" height="14" rx="2" stroke="#E96B6B" stroke-width="2"/>
<path d="M40.5 16L22 34H59L40.5 16Z" stroke="#E96B6B" stroke-width="2" stroke-linejoin="round"/>
<rect x="24" y="34" width="33" height="22" rx="4" stroke="#E96B6B" stroke-width="2"/>
<rect x="35" y="46" width="11" height="10" rx="2" stroke="#E96B6B" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

+11
View File
@@ -0,0 +1,11 @@
<!-- 首页 - 房子图标 -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 烟囱 -->
<rect x="58" y="18" width="8" height="14" rx="2" stroke="#999999" stroke-width="2"/>
<!-- 屋顶 -->
<path d="M40.5 16L22 34H59L40.5 16Z" stroke="#999999" stroke-width="2" stroke-linejoin="round"/>
<!-- 房屋主体 -->
<rect x="24" y="34" width="33" height="22" rx="4" stroke="#999999" stroke-width="2"/>
<!-- 门 -->
<rect x="35" y="46" width="11" height="10" rx="2" stroke="#999999" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

@@ -0,0 +1,8 @@
<!-- 消息 - 对话气泡图标 (选中状态) -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="18" width="57" height="36" rx="10" stroke="#E96B6B" stroke-width="2"/>
<path d="M22 54L16 64L28 54" stroke="#E96B6B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="20" y1="28" x2="61" y2="28" stroke="#E96B6B" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="36" x2="53" y2="36" stroke="#E96B6B" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="44" x2="45" y2="44" stroke="#E96B6B" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 655 B

+11
View File
@@ -0,0 +1,11 @@
<!-- 消息 - 对话气泡图标 -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 气泡主体 -->
<rect x="12" y="18" width="57" height="36" rx="10" stroke="#999999" stroke-width="2"/>
<!-- 小三角 -->
<path d="M22 54L16 64L28 54" stroke="#999999" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 文字线条 -->
<line x1="20" y1="28" x2="61" y2="28" stroke="#999999" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="36" x2="53" y2="36" stroke="#999999" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="44" x2="45" y2="44" stroke="#999999" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 709 B

@@ -0,0 +1,7 @@
<!-- 我的 - 用户图标 (选中状态) -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="40.5" cy="28" r="14" stroke="#E96B6B" stroke-width="2"/>
<path d="M22 62C22 50 30 44 40.5 44C51 44 59 50 59 62" stroke="#E96B6B" stroke-width="2" stroke-linecap="round"/>
<line x1="22" y1="52" x2="22" y2="62" stroke="#E96B6B" stroke-width="2" stroke-linecap="round"/>
<line x1="59" y1="52" x2="59" y2="62" stroke="#E96B6B" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

+10
View File
@@ -0,0 +1,10 @@
<!-- 我的 - 用户图标 -->
<svg width="81" height="81" viewBox="0 0 81 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 头部 -->
<circle cx="40.5" cy="28" r="14" stroke="#999999" stroke-width="2"/>
<!-- 身体 -->
<path d="M22 62C22 50 30 44 40.5 44C51 44 59 50 59 62" stroke="#999999" stroke-width="2" stroke-linecap="round"/>
<!-- 身体两侧 -->
<line x1="22" y1="52" x2="22" y2="62" stroke="#999999" stroke-width="2" stroke-linecap="round"/>
<line x1="59" y1="52" x2="59" y2="62" stroke="#999999" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 579 B

+20
View File
@@ -0,0 +1,20 @@
/**
* ActivityCard 活动卡片(规范 §43-46)
*
* 不变量(AGENTS.md 第 1 条):feeType=paid 仅作展示字段,前端不实现任何支付入口。
* "公益免费"作为主要视觉标识突出展示。
*/
Component({
options: { addGlobalClass: true },
properties: {
activity: { type: Object, value: null },
showStatusBadge: { type: Boolean, value: true },
compact: { type: Boolean, value: false }, // 紧凑模式(首页推荐位)
},
methods: {
onTap() {
if (!this.data.activity) return;
this.triggerEvent('tap', { activityId: this.data.activity.activityId });
},
},
});
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"ui-status-badge": "../status-badge/index"
}
}
@@ -0,0 +1,62 @@
<view class="ui-act {{compact ? 'ui-act--compact' : ''}}" bindtap="onTap">
<view class="ui-act__cover">
<image
wx:if="{{activity.coverUrl}}"
class="ui-act__cover-img"
src="{{activity.coverUrl}}"
mode="aspectFill"
/>
<view wx:else class="ui-act__cover-placeholder">
<view class="ui-act__cover-icon"></view>
</view>
<view class="ui-act__fee-badge {{activity.feeType === 'paid' ? 'ui-act__fee-badge--paid' : 'ui-act__fee-badge--free'}}">
{{activity.feeType === 'paid' ? '付费' : '公益免费'}}
</view>
</view>
<view class="ui-act__body">
<view class="ui-act__title">{{activity.title}}</view>
<view class="ui-act__meta">
<text class="ui-act__time">{{activity.startAtText || activity.startAt}}</text>
<text wx:if="{{activity.location}}"> · {{activity.location}}</text>
</view>
<view wx:if="{{activity.organizer}}" class="ui-act__meta ui-act__organizer">
主办:{{activity.organizer}}
</view>
<view wx:if="{{activity.suitableFor}}" class="ui-act__meta">
适合:{{activity.suitableFor}}
</view>
<view wx:if="{{!compact}}" class="ui-act__intro">{{activity.intro}}</view>
<view class="ui-act__footer">
<view class="ui-act__capacity">
<text wx:if="{{activity.capacity}}">容量 {{activity.capacity}}</text>
</view>
<view wx:if="{{showStatusBadge}}">
<ui-status-badge
wx:if="{{activity.status === 'registration_open'}}"
kind="success"
label="报名中"
/>
<ui-status-badge
wx:elif="{{activity.status === 'registration_closed'}}"
kind="warning"
label="报名截止"
/>
<ui-status-badge
wx:elif="{{activity.status === 'ongoing'}}"
kind="info"
label="进行中"
/>
<ui-status-badge
wx:elif="{{activity.status === 'ended'}}"
kind="disabled"
label="已结束"
/>
<ui-status-badge
wx:elif="{{activity.status === 'cancelled'}}"
kind="danger"
label="已取消"
/>
</view>
</view>
</view>
</view>
+108
View File
@@ -0,0 +1,108 @@
.ui-act {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
margin-bottom: var(--spacing-3);
}
.ui-act__cover {
position: relative;
width: 100%;
height: 240rpx;
background: var(--color-bg-disabled);
}
.ui-act__cover-img {
width: 100%;
height: 100%;
display: block;
}
.ui-act__cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary-light);
}
.ui-act__cover-icon {
width: 80rpx;
height: 80rpx;
background: var(--color-primary);
opacity: 0.4;
border-radius: var(--radius-md);
}
.ui-act__fee-badge {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-2);
padding: 6rpx 14rpx;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: #FFFFFF;
}
.ui-act__fee-badge--free {
background: var(--color-success);
}
.ui-act__fee-badge--paid {
background: var(--color-text-tertiary);
}
.ui-act__body {
padding: var(--spacing-3) var(--spacing-4);
}
.ui-act__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
line-height: var(--line-height-tight);
margin-bottom: var(--spacing-1);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ui-act__meta {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
line-height: var(--line-height-normal);
margin-top: 4rpx;
}
.ui-act__organizer {
color: var(--color-text-secondary);
}
.ui-act__intro {
margin-top: var(--spacing-2);
font-size: var(--font-size-md);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.ui-act__footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-3);
}
.ui-act__capacity {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.ui-act--compact {
display: flex;
height: 200rpx;
}
.ui-act--compact .ui-act__cover {
width: 200rpx;
height: 200rpx;
flex-shrink: 0;
}
.ui-act--compact .ui-act__body {
flex: 1;
min-width: 0;
padding: var(--spacing-2) var(--spacing-3);
}
.ui-act--compact .ui-act__intro {
display: none;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Avatar 头像(规范 §22 用户卡片)
*
* 显示用户头像 + 已实名 / 已审核徽标。
* 不变量(AGENTS 第 8 条):头像 URL 来自 OSS 公开链接,不携带任何敏感身份字段。
* 用法:
* <ui-avatar src="{{user.avatarUrl}}" size="lg"
* verified="{{user.verified}}" reviewed="{{user.reviewed}}" />
*/
Component({
options: { addGlobalClass: true },
properties: {
src: { type: String, value: '' },
size: { type: String, value: 'md' }, // sm|md|lg|xl
shape: { type: String, value: 'circle' }, // circle|square
verified: { type: Boolean, value: false },
reviewed: { type: Boolean, value: false },
showBadge: { type: Boolean, value: true },
// 兜底默认头像(不依赖外部资源)
defaultText: { type: String, value: '' }, // 没图时显示首字(如"王")
},
data: {
loadError: false,
},
methods: {
onError() {
this.setData({ loadError: true });
this.triggerEvent('error', {});
},
onTap() {
this.triggerEvent('tap', {});
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+20
View File
@@ -0,0 +1,20 @@
<view
class="ui-avatar ui-avatar--{{size}} ui-avatar--{{shape}}"
bindtap="onTap"
>
<image
wx:if="{{src && !loadError}}"
class="ui-avatar__img"
src="{{src}}"
mode="aspectFill"
binderror="onError"
/>
<view wx:else class="ui-avatar__fallback">
<text wx:if="{{defaultText}}" class="ui-avatar__fallback-text">{{defaultText}}</text>
<view wx:else class="ui-avatar__fallback-icon"></view>
</view>
<view wx:if="{{showBadge && (verified || reviewed)}}" class="ui-avatar__badges">
<view wx:if="{{verified}}" class="ui-avatar__badge ui-avatar__badge--verified">实名</view>
<view wx:if="{{reviewed}}" class="ui-avatar__badge ui-avatar__badge--reviewed">审核</view>
</view>
</view>
+62
View File
@@ -0,0 +1,62 @@
.ui-avatar {
position: relative;
flex-shrink: 0;
background: var(--color-bg-disabled);
overflow: hidden;
}
.ui-avatar--circle { border-radius: 50%; }
.ui-avatar--square { border-radius: var(--radius-md); }
/* 尺寸:sm 60 / md 88 / lg 120 / xl 160 */
.ui-avatar--sm { width: 60rpx; height: 60rpx; }
.ui-avatar--md { width: 88rpx; height: 88rpx; }
.ui-avatar--lg { width: 120rpx; height: 120rpx; }
.ui-avatar--xl { width: 160rpx; height: 160rpx; }
.ui-avatar__img {
width: 100%;
height: 100%;
display: block;
}
.ui-avatar__fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary-light);
}
.ui-avatar__fallback-text {
font-size: var(--font-size-lg);
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
}
.ui-avatar__fallback-icon {
width: 50%;
height: 50%;
border-radius: 50%;
background: var(--color-primary);
opacity: 0.6;
}
.ui-avatar__badges {
position: absolute;
bottom: -4rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6rpx;
}
.ui-avatar__badge {
font-size: 16rpx;
line-height: 1;
padding: 4rpx 8rpx;
border-radius: var(--radius-full);
color: #FFFFFF;
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.ui-avatar__badge--verified { background: var(--color-success); }
.ui-avatar__badge--reviewed { background: var(--color-info); }
/* 小尺寸时隐藏徽标避免溢出 */
.ui-avatar--sm .ui-avatar__badges { display: none; }
+35
View File
@@ -0,0 +1,35 @@
/**
* Button 按钮(规范 §22 / Token §2 主色 + 功能色)
*
* 变体 variant:primary(主红)/ secondary(白底红边)/ ghost(无背景)/ danger / text
* 尺寸 size:sm / md(默认)/ lg
* 状态:disabled / loading
* 用法:
* <ui-button variant="primary" size="md" bindtap="onSubmit">提交</ui-button>
* <ui-button variant="danger" block loading>删除</ui-button>
*/
Component({
options: { addGlobalClass: true, multipleSlots: true },
externalClasses: ['custom-class'],
properties: {
variant: { type: String, value: 'primary' }, // primary|secondary|ghost|danger|text
size: { type: String, value: 'md' }, // sm|md|lg
block: { type: Boolean, value: false }, // 是否撑满父容器
disabled: { type: Boolean, value: false },
loading: { type: Boolean, value: false },
openType: { type: String, value: '' }, // 透传原生 open-type(getPhoneNumber 等)
hoverClass: { type: String, value: 'ui-btn--hover' },
},
methods: {
onTap(e) {
if (this.data.disabled || this.data.loading) return;
this.triggerEvent('tap', e.detail || {});
},
onGetPhoneNumber(e) {
this.triggerEvent('getphonenumber', e.detail || {});
},
onError(e) {
this.triggerEvent('error', e.detail || {});
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+11
View File
@@ -0,0 +1,11 @@
<view
class="ui-btn ui-btn--{{variant}} ui-btn--{{size}} {{block ? 'ui-btn--block' : ''}} {{disabled ? 'ui-btn--disabled' : ''}} {{loading ? 'ui-btn--loading' : ''}} custom-class"
hover-class="{{disabled || loading ? '' : hoverClass}}"
hover-stay-time="100"
bindtap="onTap"
>
<view wx:if="{{loading}}" class="ui-btn__spinner">
<view class="ui-btn__spinner-dot"></view>
</view>
<slot></slot>
</view>
+111
View File
@@ -0,0 +1,111 @@
/**
* Button 组件样式
* 引用全局 Design Token(addGlobalClass: true)
*/
.ui-btn {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: var(--radius-full);
font-family: var(--font-family-cn);
font-weight: var(--font-weight-medium);
text-align: center;
transition: opacity var(--motion-fast) var(--ease-standard),
transform var(--motion-fast) var(--ease-standard);
user-select: none;
line-height: 1;
white-space: nowrap;
}
/* 尺寸 */
.ui-btn--sm {
padding: 14rpx 24rpx;
font-size: var(--font-size-sm);
min-height: 56rpx;
}
.ui-btn--md {
padding: 20rpx 36rpx;
font-size: var(--font-size-base);
min-height: 80rpx;
}
.ui-btn--lg {
padding: 26rpx 48rpx;
font-size: var(--font-size-lg);
min-height: 96rpx;
}
/* 撑满 */
.ui-btn--block {
display: flex;
width: 100%;
}
/* 变体 */
.ui-btn--primary {
background: var(--color-primary);
color: #FFFFFF;
}
.ui-btn--secondary {
background: var(--color-bg-card);
color: var(--color-primary);
border: 1rpx solid var(--color-primary);
}
.ui-btn--ghost {
background: transparent;
color: var(--color-text-primary);
border: 1rpx solid var(--color-border);
}
.ui-btn--danger {
background: var(--color-danger);
color: #FFFFFF;
}
.ui-btn--text {
background: transparent;
color: var(--color-primary);
padding-left: 0;
padding-right: 0;
min-height: auto;
}
/* 状态 */
.ui-btn--disabled,
.ui-btn--loading {
opacity: 0.55;
}
.ui-btn--disabled.ui-btn--primary,
.ui-btn--disabled.ui-btn--danger {
background: var(--color-text-disabled);
}
.ui-btn--disabled.ui-btn--secondary,
.ui-btn--disabled.ui-btn--ghost {
color: var(--color-text-disabled);
border-color: var(--color-text-disabled);
}
.ui-btn--hover {
opacity: 0.85;
transform: scale(0.98);
}
/* Loading spinner */
.ui-btn__spinner {
width: 28rpx;
height: 28rpx;
margin-right: 12rpx;
display: flex;
align-items: center;
justify-content: center;
}
.ui-btn__spinner-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
border: 3rpx solid currentColor;
border-top-color: transparent;
animation: ui-btn-spin 0.7s linear infinite;
}
@keyframes ui-btn-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -0,0 +1,24 @@
/**
* ConversationItem 会话列表项(规范 §35)
*
* 不变量(AGENTS.md):
* - 第 3 条:仅 Mutual Match 后才能聊天——canSendMessage=false 时显示"已停用"角标,
* 点击进入后聊天页禁止输入。
* - 第 4 条:Block 后会话立即停用,relationStatus=blocked 时灰显。
*/
Component({
options: { addGlobalClass: true },
properties: {
conversation: { type: Object, value: null },
showAvatar: { type: Boolean, value: true },
},
methods: {
onTap() {
if (!this.data.conversation) return;
this.triggerEvent('tap', {
conversationId: this.data.conversation.conversationId,
targetUserId: this.data.conversation.targetUserId,
});
},
},
});
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"ui-avatar": "../avatar/index"
}
}
@@ -0,0 +1,26 @@
<view
class="ui-conv {{!conversation.canSendMessage ? 'ui-conv--disabled' : ''}}"
bindtap="onTap"
>
<ui-avatar
wx:if="{{showAvatar}}"
src="{{conversation.targetAvatarUrl}}"
size="lg"
default-text="{{conversation.targetDisplayName}}"
/>
<view class="ui-conv__body">
<view class="ui-conv__top">
<text class="ui-conv__name">{{conversation.targetDisplayName}}</text>
<text class="ui-conv__time">{{conversation.lastMessageAtText || ''}}</text>
</view>
<view class="ui-conv__bottom">
<text class="ui-conv__last">{{conversation.lastMessage || '开始你们的对话吧'}}</text>
<view wx:if="{{conversation.unreadCount > 0}}" class="ui-conv__badge">
{{conversation.unreadCount > 99 ? '99+' : conversation.unreadCount}}
</view>
</view>
<view wx:if="{{!conversation.canSendMessage}}" class="ui-conv__status-hint">
{{conversation.relationStatus === 'blocked' ? '会话已停用(已拉黑)' : '会话已停用'}}
</view>
</view>
</view>
@@ -0,0 +1,66 @@
.ui-conv {
display: flex;
align-items: flex-start;
background: var(--color-bg-card);
padding: var(--spacing-3) var(--spacing-page-x);
border-bottom: 1rpx solid var(--color-divider);
}
.ui-conv--disabled {
opacity: 0.6;
}
.ui-conv__body {
flex: 1;
min-width: 0;
margin-left: var(--spacing-3);
}
.ui-conv__top {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.ui-conv__name {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.ui-conv__time {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
flex-shrink: 0;
margin-left: var(--spacing-2);
}
.ui-conv__bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-1);
}
.ui-conv__last {
flex: 1;
min-width: 0;
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: var(--spacing-2);
}
.ui-conv__badge {
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: var(--radius-full);
background: var(--color-danger);
color: #FFFFFF;
font-size: var(--font-size-xs);
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
flex-shrink: 0;
}
.ui-conv__status-hint {
margin-top: var(--spacing-1);
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
@@ -0,0 +1,108 @@
/**
* DangerActionDialog 危险动作确认弹窗(规范 §107 DANGER_ACTION)
*
* 用于:拉黑 / 解除匹配 / 暂停寻找 / 退出服务 / 关闭账号
* 不变量(AGENTS.md):
* - 第 4 条:Block 前必须二次确认,本弹窗为唯一确认入口;
* - 第 6 条:解除匹配/退出服务这类不可逆动作同样需要确认。
*
* 用法:
* <ui-danger-action-dialog
* visible="{{showBlockDialog}}"
* action="block"
* target-name="王女士"
* bind:confirm="onConfirmBlock"
* bind:cancel="onCancelBlock" />
*/
const { DANGER_ACTION } = require('../../constants');
const ACTION_CONFIG = {
[DANGER_ACTION.BLOCK]: {
title: '拉黑该用户',
confirmText: '确认拉黑',
danger: true,
buildContent: (name) => `拉黑后 ${name || '该用户'} 将无法与你互动,你也不会再收到对方的推荐与消息。此操作可由你随时解除。`,
},
[DANGER_ACTION.UNBLOCK]: {
title: '解除拉黑',
confirmText: '确认解除',
danger: false,
buildContent: (name) => `解除拉黑后,${name || '对方'} 将可以重新看到你并向你发起互动。请确认你已准备好重新与对方接触。`,
},
[DANGER_ACTION.UNMATCH]: {
title: '解除匹配',
confirmText: '确认解除',
danger: true,
buildContent: (name) => `解除与 ${name || '对方'} 的匹配后,你们的会话将立即停用,历史记录保留但不可继续发送消息。此操作不可撤销。`,
},
[DANGER_ACTION.PAUSE]: {
title: '暂停寻找',
confirmText: '暂停寻找',
danger: false,
buildContent: () => '暂停后你将不再被推荐给他人,也无法收到新的喜欢提示。可随时恢复。',
},
[DANGER_ACTION.SUCCESS_MATCH]: {
title: '成功牵手',
confirmText: '发起牵手请求',
danger: false,
buildContent: (name) => `发起牵手请求后,${name || '对方'} 需在 7 天内确认。双方均确认后关系将置为"成功牵手",平台将不再为你推荐其他人。`,
},
[DANGER_ACTION.LOGOUT]: {
title: '退出登录',
confirmText: '确认退出',
danger: false,
buildContent: () => '退出后需重新通过微信登录。本地未同步的草稿可能丢失。',
},
[DANGER_ACTION.CLOSE_ACCOUNT]: {
title: '关闭账号',
confirmText: '申请关闭',
danger: true,
buildContent: () => '关闭账号后你的资料将进入 30 天冷静期,期间可联系客服恢复;超过冷静期将永久删除并不可恢复。',
},
};
Component({
options: { addGlobalClass: true },
properties: {
visible: { type: Boolean, value: false },
action: { type: String, value: 'block' },
targetName: { type: String, value: '' },
},
data: {
config: null,
},
observers: {
action(action) {
this.setData({ config: ACTION_CONFIG[action] || ACTION_CONFIG[DANGER_ACTION.BLOCK] });
},
'targetName'(name) {
// 重新计算 content
const cfg = this.data.config;
if (cfg && cfg.buildContent) {
this.setData({
config: { ...cfg, content: cfg.buildContent(name) },
});
}
},
},
lifetimes: {
attached() {
const action = this.data.action;
const cfg = ACTION_CONFIG[action] || ACTION_CONFIG[DANGER_ACTION.BLOCK];
this.setData({
config: { ...cfg, content: cfg.buildContent(this.data.targetName) },
});
},
},
methods: {
onConfirm() {
this.triggerEvent('confirm', { action: this.data.action });
},
onCancel() {
this.triggerEvent('cancel', { action: this.data.action });
},
onStop() {
// 阻止冒泡到 mask
},
},
});
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"ui-button": "../button/index"
}
}
@@ -0,0 +1,20 @@
<view wx:if="{{visible}}" class="ui-dialog-mask" catchtap="onCancel">
<view class="ui-dialog" catchtap="onStop">
<view class="ui-dialog__title">{{config.title}}</view>
<view class="ui-dialog__content">{{config.content}}</view>
<view class="ui-dialog__actions">
<ui-button
variant="ghost"
size="md"
class="ui-dialog__btn"
bindtap="onCancel"
>取消</ui-button>
<ui-button
variant="{{config.danger ? 'danger' : 'primary'}}"
size="md"
class="ui-dialog__btn"
bindtap="onConfirm"
>{{config.confirmText}}</ui-button>
</view>
</view>
</view>
@@ -0,0 +1,47 @@
.ui-dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-bg-overlay);
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--spacing-6);
}
.ui-dialog {
width: 100%;
max-width: 600rpx;
background: var(--color-bg-card);
border-radius: var(--radius-lg);
padding: var(--spacing-5) var(--spacing-4) var(--spacing-4);
box-shadow: var(--shadow-popup);
animation: ui-dialog-in var(--motion-normal) var(--ease-standard);
}
@keyframes ui-dialog-in {
from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.ui-dialog__title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
text-align: center;
margin-bottom: var(--spacing-3);
}
.ui-dialog__content {
font-size: var(--font-size-md);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
text-align: center;
margin-bottom: var(--spacing-5);
}
.ui-dialog__actions {
display: flex;
gap: var(--spacing-2);
}
.ui-dialog__btn {
flex: 1;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Empty 空状态(规范 §22 推荐为空时引导去 Discover / 申请红娘)
*
* 用法:
* <ui-empty title="暂时没有符合条件的人"
* description="可以去「找对象」看看更多符合条件的人"
* actions="{{[{text:'去「找对象', event:'discover'}, {text:'申请公益红娘', event:'matchmaker'}]}}"
* bind:action="onEmptyAction" />
*/
Component({
options: { addGlobalClass: true, multipleSlots: true },
properties: {
title: { type: String, value: '暂无内容' },
description: { type: String, value: '' },
// [{text, event, variant?: 'primary'|'secondary'}]
actions: { type: Array, value: [] },
// 图标变体(不依赖外部图片资源,纯 CSS 绘制)
illustration: { type: String, value: 'default' }, // default|search|heart|activity|message
},
methods: {
onAction(e) {
const { event } = e.currentTarget.dataset;
this.triggerEvent('action', { event });
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+17
View File
@@ -0,0 +1,17 @@
<view class="ui-empty">
<view class="ui-empty__illustration ui-empty__illustration--{{illustration}}">
<view class="ui-empty__icon"></view>
</view>
<text class="ui-empty__title">{{title}}</text>
<text wx:if="{{description}}" class="ui-empty__desc">{{description}}</text>
<view class="ui-empty__actions">
<view
wx:for="{{actions}}"
wx:key="event"
class="ui-empty__action ui-empty__action--{{item.variant || 'secondary'}}"
data-event="{{item.event}}"
bindtap="onAction"
>{{item.text}}</view>
</view>
<slot></slot>
</view>
+80
View File
@@ -0,0 +1,80 @@
.ui-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-10) var(--spacing-page-x);
text-align: center;
}
.ui-empty__illustration {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: var(--color-bg-disabled);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--spacing-4);
}
.ui-empty__icon {
width: 60rpx;
height: 60rpx;
background: var(--color-text-disabled);
opacity: 0.5;
border-radius: 50%;
}
.ui-empty__illustration--search .ui-empty__icon {
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
}
.ui-empty__illustration--heart .ui-empty__icon {
clip-path: polygon(50% 100%, 0 38%, 18% 18%, 50% 38%, 82% 18%, 100% 38%);
border-radius: 0;
}
.ui-empty__illustration--activity .ui-empty__icon {
border-radius: var(--radius-sm);
width: 80rpx;
height: 80rpx;
}
.ui-empty__illustration--message .ui-empty__icon {
border-radius: var(--radius-md);
width: 80rpx;
height: 60rpx;
}
.ui-empty__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin-bottom: var(--spacing-2);
}
.ui-empty__desc {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
line-height: var(--line-height-relaxed);
max-width: 480rpx;
}
.ui-empty__actions {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
margin-top: var(--spacing-6);
width: 100%;
max-width: 480rpx;
}
.ui-empty__action {
display: flex;
align-items: center;
justify-content: center;
height: 80rpx;
border-radius: var(--radius-full);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
.ui-empty__action--primary {
background: var(--color-primary);
color: #FFFFFF;
}
.ui-empty__action--secondary {
background: var(--color-bg-card);
color: var(--color-primary);
border: 1rpx solid var(--color-primary);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Input 输入框(规范 §18-19 表单)
*
* 支持 label、错误提示、字数计数(maxLength 触发)、清除按钮。
* 用法:
* <ui-input label="昵称" placeholder="请输入昵称" value="{{name}}"
* maxlength="20" error="{{errors.name}}" bind:input="onInputName" />
*/
Component({
options: { addGlobalClass: true },
properties: {
label: { type: String, value: '' },
placeholder: { type: String, value: '' },
value: { type: String, value: '' },
type: { type: String, value: 'text' }, // text|number|idcard|digit|nickname
maxlength: { type: Number, value: 140 }, // -1 表示不限
showCount: { type: Boolean, value: false }, // 是否显示字数
clearable: { type: Boolean, value: true }, // 是否显示清除按钮
error: { type: String, value: '' }, // 错误文案,非空时显示错误态
disabled: { type: Boolean, value: false },
password: { type: Boolean, value: false },
confirmType: { type: String, value: 'done' }, // send|search|next|go|done
inputAlign: { type: String, value: 'left' }, // left|right
},
methods: {
onInput(e) {
this.triggerEvent('input', { value: e.detail.value });
},
onFocus(e) {
this.triggerEvent('focus', e.detail);
},
onBlur(e) {
this.triggerEvent('blur', { value: e.detail.value });
},
onConfirm(e) {
this.triggerEvent('confirm', { value: e.detail.value });
},
onClear() {
this.triggerEvent('input', { value: '' });
this.triggerEvent('clear', {});
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+32
View File
@@ -0,0 +1,32 @@
<view class="ui-input {{error ? 'ui-input--error' : ''}} {{disabled ? 'ui-input--disabled' : ''}}">
<view wx:if="{{label}}" class="ui-input__label">{{label}}</view>
<view class="ui-input__field-wrap">
<input
class="ui-input__field"
style="text-align: {{inputAlign}};"
type="{{type}}"
password="{{password}}"
value="{{value}}"
placeholder="{{placeholder}}"
placeholder-class="ui-input__placeholder"
maxlength="{{maxlength}}"
disabled="{{disabled}}"
confirm-type="{{confirmType}}"
bindinput="onInput"
bindfocus="onFocus"
bindblur="onBlur"
bindconfirm="onConfirm"
/>
<view
wx:if="{{clearable && value && !disabled}}"
class="ui-input__clear"
catchtap="onClear"
>×</view>
</view>
<view wx:if="{{showCount || error}}" class="ui-input__footer">
<view wx:if="{{error}}" class="ui-input__error">{{error}}</view>
<view wx:if="{{showCount}}" class="ui-input__count">
{{value.length}}{{maxlength > 0 ? '/' + maxlength : ''}}
</view>
</view>
</view>
+69
View File
@@ -0,0 +1,69 @@
.ui-input {
padding: var(--spacing-3) 0;
border-bottom: 1rpx solid var(--color-border);
position: relative;
}
.ui-input__label {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
margin-bottom: var(--spacing-2);
}
.ui-input__field-wrap {
display: flex;
align-items: center;
}
.ui-input__field {
flex: 1;
min-width: 0;
font-size: var(--font-size-base);
color: var(--color-text-primary);
padding: var(--spacing-2) 0;
line-height: var(--line-height-normal);
}
.ui-input__placeholder {
color: var(--color-text-placeholder);
}
.ui-input__clear {
flex-shrink: 0;
margin-left: var(--spacing-2);
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: var(--color-text-disabled);
color: #FFFFFF;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.ui-input__footer {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: var(--spacing-1);
min-height: 28rpx;
}
.ui-input__error {
color: var(--color-danger);
font-size: var(--font-size-xs);
flex: 1;
}
.ui-input__count {
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
margin-left: var(--spacing-2);
}
.ui-input--error .ui-input__field {
color: var(--color-danger);
}
.ui-input--error {
border-bottom-color: var(--color-border-error);
}
.ui-input--disabled .ui-input__field {
color: var(--color-text-disabled);
background: var(--color-bg-disabled);
border-radius: var(--radius-sm);
padding-left: var(--spacing-2);
padding-right: var(--spacing-2);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* LikersBadge 喜欢我的人计数徽标(规范 §22 / §107)
*
* 不变量(AGENTS.md 第 6 条):单向意向不泄露——
* - 仅展示计数("3 人喜欢你"),不展示具体身份列表入口。
* - 详情由用户主动进入"喜欢我的人"页查看,且后端对该列表做匿名化处理
* (未 Mutual 前对方不会被告知你看过了谁)。
*
* 用法:
* <ui-likers-badge count="{{likersCount}}" bind:tap="onViewLikers" />
*/
Component({
options: { addGlobalClass: true },
properties: {
count: { type: Number, value: 0 },
size: { type: String, value: 'md' }, // sm | md | lg
},
data: {
displayText: '',
},
observers: {
count(n) {
let text;
if (n <= 0) text = '还没有人喜欢你';
else if (n === 1) text = '1 人喜欢你';
else text = `${n} 人喜欢你`;
this.setData({ displayText: text });
},
},
methods: {
onTap() {
if (this.data.count <= 0) return;
this.triggerEvent('tap', { count: this.data.count });
},
},
});
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,9 @@
<view
class="ui-likers ui-likers--{{size}} {{count > 0 ? 'ui-likers--active' : ''}}"
bindtap="onTap"
>
<view class="ui-likers__icon">
<view class="ui-likers__heart"></view>
</view>
<text class="ui-likers__text">{{displayText}}</text>
</view>
@@ -0,0 +1,42 @@
.ui-likers {
display: inline-flex;
align-items: center;
background: var(--color-bg-card);
border-radius: var(--radius-full);
border: 1rpx solid var(--color-border);
color: var(--color-text-tertiary);
}
.ui-likers--active {
background: var(--color-primary-light);
border-color: var(--color-primary-light);
color: var(--color-primary);
}
.ui-likers--sm {
padding: 6rpx 14rpx;
font-size: var(--font-size-xs);
}
.ui-likers--md {
padding: 8rpx 18rpx;
font-size: var(--font-size-sm);
}
.ui-likers--lg {
padding: 12rpx 24rpx;
font-size: var(--font-size-md);
}
.ui-likers__icon {
width: 24rpx;
height: 24rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8rpx;
}
.ui-likers__heart {
width: 20rpx;
height: 18rpx;
background: currentColor;
clip-path: polygon(50% 100%, 0 38%, 18% 8%, 50% 28%, 82% 8%, 100% 38%);
}
.ui-likers__text {
font-size: inherit;
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Loading 加载(规范 §21 推荐列表加载状态)
*
* 两种形态:
* - mode="spinner":圆形旋转(页面级首次加载)
* - mode="skeleton":骨架屏(列表渐进加载,避免抖动)
* 用法:
* <ui-loading mode="spinner" text="正在为你挑选…" />
* <ui-loading mode="skeleton" rows="3" />
*/
Component({
options: { addGlobalClass: true },
properties: {
mode: { type: String, value: 'spinner' }, // spinner|skeleton
text: { type: String, value: '' },
rows: { type: Number, value: 3 }, // skeleton 模式行数
inline: { type: Boolean, value: false }, // 是否行内显示
},
data: {
rowList: [],
},
observers: {
rows(n) {
const arr = [];
for (let i = 0; i < n; i++) arr.push(i);
this.setData({ rowList: arr });
},
},
lifetimes: {
attached() {
const arr = [];
for (let i = 0; i < this.data.rows; i++) arr.push(i);
this.setData({ rowList: arr });
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+19
View File
@@ -0,0 +1,19 @@
<view class="ui-loading {{inline ? 'ui-loading--inline' : 'ui-loading--block'}}">
<block wx:if="{{mode === 'spinner'}}">
<view class="ui-loading__spinner">
<view class="ui-loading__spinner-ring"></view>
</view>
<text wx:if="{{text}}" class="ui-loading__text">{{text}}</text>
</block>
<block wx:elif="{{mode === 'skeleton'}}">
<view class="ui-loading__skeleton">
<view wx:for="{{rowList}}" wx:key="*this" class="ui-loading__skeleton-row">
<view class="ui-loading__skeleton-avatar"></view>
<view class="ui-loading__skeleton-content">
<view class="ui-loading__skeleton-line ui-loading__skeleton-line--title"></view>
<view class="ui-loading__skeleton-line ui-loading__skeleton-line--meta"></view>
</view>
</view>
</view>
</block>
</view>
+94
View File
@@ -0,0 +1,94 @@
.ui-loading--block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-8) var(--spacing-page-x);
}
.ui-loading--inline {
display: inline-flex;
align-items: center;
}
.ui-loading__spinner {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.ui-loading__spinner-ring {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
border: 4rpx solid var(--color-divider);
border-top-color: var(--color-primary);
animation: ui-loading-spin 0.8s linear infinite;
}
@keyframes ui-loading-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ui-loading__text {
margin-top: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
}
/* Skeleton */
.ui-loading__skeleton {
width: 100%;
}
.ui-loading__skeleton-row {
display: flex;
align-items: center;
padding: var(--spacing-3) var(--spacing-page-x);
background: var(--color-bg-card);
border-bottom: 1rpx solid var(--color-divider);
}
.ui-loading__skeleton-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: var(--color-bg-disabled);
margin-right: var(--spacing-3);
flex-shrink: 0;
}
.ui-loading__skeleton-content {
flex: 1;
min-width: 0;
}
.ui-loading__skeleton-line {
height: 24rpx;
background: var(--color-bg-disabled);
border-radius: var(--radius-sm);
position: relative;
overflow: hidden;
}
.ui-loading__skeleton-line--title {
width: 40%;
margin-bottom: var(--spacing-2);
}
.ui-loading__skeleton-line--meta {
width: 70%;
}
/* shimmer */
.ui-loading__skeleton-line::after,
.ui-loading__skeleton-avatar::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.55) 50%,
transparent 100%
);
animation: ui-loading-shimmer 1.4s ease-in-out infinite;
}
@keyframes ui-loading-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@@ -0,0 +1,43 @@
/**
* MatchmakerCard 红娘服务卡片(规范 §48-50)
*
* 展示我申请的红娘服务进度,含状态、所属机构、最近跟进时间。
* 不变量(AGENTS.md 第 5 条):红娘不可替用户操作——本卡片不暴露任何
* "代用户 Like/Pass"入口,红娘推荐列表走 matchStore.like/pass(userId)。
* 不变量(AGENTS.md 第 1 条):用户永久免费——本卡片不展示任何付费红娘/会员红娘字段。
*/
Component({
options: { addGlobalClass: true },
properties: {
service: { type: Object, value: null },
showRecommendations: { type: Boolean, value: false },
},
data: {
statusLabelMap: {
submitted: '已提交申请',
assigned: '红娘已分配',
in_service: '服务中',
waiting_user: '等待你的回应',
completed: '服务完成',
cancelled: '已取消',
},
statusKindMap: {
submitted: 'info',
assigned: 'info',
in_service: 'success',
waiting_user: 'warning',
completed: 'disabled',
cancelled: 'disabled',
},
},
methods: {
onViewDetail() {
if (!this.data.service) return;
this.triggerEvent('view', { serviceId: this.data.service.serviceId });
},
onViewRecommendations() {
if (!this.data.service) return;
this.triggerEvent('recommendations', { serviceId: this.data.service.serviceId });
},
},
});
@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"ui-status-badge": "../status-badge/index",
"ui-button": "../button/index"
}
}
@@ -0,0 +1,31 @@
<view class="ui-mm" wx:if="{{service}}">
<view class="ui-mm__header" bindtap="onViewDetail">
<view class="ui-mm__title-row">
<text class="ui-mm__title">公益红娘服务</text>
<ui-status-badge
kind="{{statusKindMap[service.status]}}"
label="{{statusLabelMap[service.status]}}"
/>
</view>
<view wx:if="{{service.matchmakerName}}" class="ui-mm__mm-info">
<text>红娘:{{service.matchmakerName}}</text>
<text wx:if="{{service.matchmakerOrg}}"> · {{service.matchmakerOrg}}</text>
</view>
<view class="ui-mm__req">
<text class="ui-mm__req-label">你的需求:</text>
<text class="ui-mm__req-content">{{service.requirement}}</text>
</view>
<view wx:if="{{service.lastFollowUpAt}}" class="ui-mm__follow-up">
最近跟进:{{service.lastFollowUpAtText || '近期'}}
</view>
</view>
<view wx:if="{{showRecommendations && service.status === 'in_service' || service.status === 'waiting_user'}}" class="ui-mm__footer">
<ui-button
variant="secondary"
size="md"
block
bindtap="onViewRecommendations"
>查看红娘推荐</ui-button>
</view>
</view>
@@ -0,0 +1,52 @@
.ui-mm {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
margin-bottom: var(--spacing-3);
box-shadow: var(--shadow-card);
}
.ui-mm__header {
display: block;
}
.ui-mm__title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-2);
}
.ui-mm__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.ui-mm__mm-info {
font-size: var(--font-size-md);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-2);
}
.ui-mm__req {
padding: var(--spacing-2) var(--spacing-3);
background: var(--color-bg-disabled);
border-radius: var(--radius-sm);
margin-bottom: var(--spacing-2);
}
.ui-mm__req-label {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.ui-mm__req-content {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: var(--line-height-normal);
display: block;
margin-top: 4rpx;
}
.ui-mm__follow-up {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.ui-mm__footer {
margin-top: var(--spacing-3);
padding-top: var(--spacing-3);
border-top: 1rpx solid var(--color-divider);
}
@@ -0,0 +1,29 @@
/**
* MessageBubble 聊天气泡(规范 §61-63)
*
* bubbleType: self / other / system
* self:右对齐,主红气泡
* other:左对齐,白色气泡
* system:居中,灰色文字(如"成功牵手"系统提示)
*
* 失败状态:sendStatus='failed' 时显示重试图标。
*/
Component({
options: { addGlobalClass: true },
properties: {
bubbleType: { type: String, value: 'other' },
content: { type: String, value: '' },
sentAt: { type: Number, value: 0 },
sentAtText: { type: String, value: '' },
avatarUrl: { type: String, value: '' },
showAvatar: { type: Boolean, value: true },
showTime: { type: Boolean, value: false },
sendStatus: { type: String, value: 'sent' }, // sending|sent|failed
},
methods: {
onRetry() {
if (this.data.sendStatus !== 'failed') return;
this.triggerEvent('retry', {});
},
},
});
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"ui-avatar": "../avatar/index"
}
}
@@ -0,0 +1,33 @@
<view class="ui-bubble ui-bubble--{{bubbleType}}">
<view wx:if="{{showTime && sentAtText}}" class="ui-bubble__time">
<text>{{sentAtText}}</text>
</view>
<block wx:if="{{bubbleType === 'system'}}">
<view class="ui-bubble__system">{{content}}</view>
</block>
<block wx:else>
<view class="ui-bubble__row">
<ui-avatar
wx:if="{{bubbleType === 'other' && showAvatar}}"
src="{{avatarUrl}}"
size="sm"
/>
<view class="ui-bubble__content-wrap">
<view class="ui-bubble__content">{{content}}</view>
<view
wx:if="{{bubbleType === 'self' && sendStatus === 'failed'}}"
class="ui-bubble__retry"
catchtap="onRetry"
>重发</view>
<view wx:if="{{bubbleType === 'self' && sendStatus === 'sending'}}" class="ui-bubble__sending">
<view class="ui-bubble__sending-dot"></view>
</view>
</view>
<ui-avatar
wx:if="{{bubbleType === 'self' && showAvatar}}"
src="{{avatarUrl}}"
size="sm"
/>
</view>
</block>
</view>
@@ -0,0 +1,78 @@
.ui-bubble {
padding: var(--spacing-1) var(--spacing-page-x);
}
.ui-bubble__time {
text-align: center;
margin: var(--spacing-3) 0;
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.ui-bubble__system {
text-align: center;
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
background: var(--color-bg-disabled);
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-sm);
display: inline-block;
margin: 0 auto;
}
.ui-bubble__row {
display: flex;
align-items: flex-end;
}
.ui-bubble--other .ui-bubble__row {
justify-content: flex-start;
}
.ui-bubble--self .ui-bubble__row {
justify-content: flex-end;
}
.ui-bubble__content-wrap {
display: flex;
flex-direction: column;
max-width: 70%;
margin: 0 var(--spacing-2);
}
.ui-bubble--self .ui-bubble__content-wrap {
align-items: flex-end;
}
.ui-bubble__content {
padding: var(--spacing-2) var(--spacing-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
word-break: break-all;
}
.ui-bubble--other .ui-bubble__content {
background: var(--color-bg-card);
color: var(--color-text-primary);
border-top-left-radius: var(--radius-sm);
}
.ui-bubble--self .ui-bubble__content {
background: var(--color-primary);
color: #FFFFFF;
border-top-right-radius: var(--radius-sm);
}
.ui-bubble__retry {
margin-top: var(--spacing-1);
font-size: var(--font-size-xs);
color: var(--color-danger);
align-self: flex-end;
}
.ui-bubble__sending {
margin-top: var(--spacing-1);
display: flex;
align-items: center;
justify-content: flex-end;
}
.ui-bubble__sending-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: var(--color-text-disabled);
animation: ui-bubble-pulse 1s ease-in-out infinite;
}
@keyframes ui-bubble-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
+33
View File
@@ -0,0 +1,33 @@
/**
* NavBar 自定义导航栏(规范 §17)
*
* 用于非 tabBar 页面:返回按钮 + 标题 + 右侧操作 slot。
* statusBarHeight / navBarHeight 由调用方 app.js 通过 wx.getSystemInfoSync 注入。
* 用法:
* <ui-nav-bar title="编辑资料" show-back bind:back="onBack" />
*/
Component({
options: { addGlobalClass: true, multipleSlots: true },
properties: {
title: { type: String, value: '' },
showBack: { type: Boolean, value: true },
statusBarHeight: { type: Number, value: 20 },
navBarHeight: { type: Number, value: 44 },
bg: { type: String, value: 'card' }, // card | transparent
},
data: {
containerStyle: '',
},
observers: {
'statusBarHeight, navBarHeight'(s, n) {
this.setData({
containerStyle: `padding-top: ${s}px; height: ${s + n}px;`,
});
},
},
methods: {
onBack() {
this.triggerEvent('back', {});
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+12
View File
@@ -0,0 +1,12 @@
<view class="ui-nav ui-nav--{{bg}}" style="{{containerStyle}}">
<view class="ui-nav__bar">
<view wx:if="{{showBack}}" class="ui-nav__back" bindtap="onBack">
<view class="ui-nav__back-arrow"></view>
</view>
<view wx:else class="ui-nav__placeholder"></view>
<view class="ui-nav__title">{{title}}</view>
<view class="ui-nav__right">
<slot name="right"></slot>
</view>
</view>
</view>
+54
View File
@@ -0,0 +1,54 @@
.ui-nav {
position: sticky;
top: 0;
left: 0;
width: 100%;
z-index: var(--z-sticky);
box-sizing: border-box;
}
.ui-nav--card {
background: var(--color-bg-card);
border-bottom: 1rpx solid var(--color-divider);
}
.ui-nav--transparent {
background: transparent;
}
.ui-nav__bar {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 var(--spacing-2);
}
.ui-nav__back,
.ui-nav__placeholder {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ui-nav__back-arrow {
width: 20rpx;
height: 20rpx;
border-left: 4rpx solid var(--color-text-primary);
border-bottom: 4rpx solid var(--color-text-primary);
transform: rotate(45deg);
}
.ui-nav__title {
flex: 1;
text-align: center;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ui-nav__right {
flex-shrink: 0;
min-width: 64rpx;
display: flex;
align-items: center;
justify-content: flex-end;
}
@@ -0,0 +1,58 @@
/**
* PhotoUploader 照片上传器(规范 §22, §115 PHOTO_STATUS)
*
* 多图上传 + 审核状态展示 + 排序 + 删除。
* 不变量(AGENTS.md 第 8 条):本组件不接收身份证号/手机号等敏感字段,
* 仅处理生活照 URL(已上传至 OSS 后由 Profile.photos 引用)。
* 上传凭证由调用方通过 profileService.requestUploadToken 获取后注入。
*
* 用法:
* <ui-photo-uploader photos="{{photos}}" max="{{6}}"
* bind:add="onAddPhoto" bind:remove="onRemovePhoto" bind:tap="onPreviewPhoto" />
*/
Component({
options: { addGlobalClass: true },
properties: {
photos: { type: Array, value: [] },
max: { type: Number, value: 6 },
readonly: { type: Boolean, value: false },
// 是否显示审核状态徽标
showReviewStatus: { type: Boolean, value: true },
},
data: {
photoStatusKindMap: {
local: 'warning',
uploading: 'info',
uploaded: 'success',
failed: 'danger',
review_pending: 'warning',
},
photoStatusLabelMap: {
local: '本地',
uploading: '上传中',
uploaded: '已上传',
failed: '失败',
review_pending: '审核中',
},
},
methods: {
onAdd() {
if (this.data.readonly) return;
if (this.data.photos.length >= this.data.max) return;
this.triggerEvent('add', {});
},
onRemove(e) {
if (this.data.readonly) return;
const { index } = e.currentTarget.dataset;
this.triggerEvent('remove', { index });
},
onTap(e) {
const { index } = e.currentTarget.dataset;
this.triggerEvent('tap', { index });
},
onRetry(e) {
const { index } = e.currentTarget.dataset;
this.triggerEvent('retry', { index });
},
},
});
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"ui-status-badge": "../status-badge/index"
}
}
@@ -0,0 +1,45 @@
<view class="ui-photo">
<view class="ui-photo__list">
<view
wx:for="{{photos}}"
wx:key="index"
class="ui-photo__item"
data-index="{{index}}"
bindtap="onTap"
>
<image class="ui-photo__img" src="{{item.url}}" mode="aspectFill" />
<view wx:if="{{item.status && item.status !== 'uploaded'}}" class="ui-photo__status">
<ui-status-badge
kind="{{photoStatusKindMap[item.status]}}"
label="{{photoStatusLabelMap[item.status]}}"
/>
</view>
<view
wx:if="{{!readonly && item.status !== 'uploading'}}"
class="ui-photo__remove"
catchtap="onRemove"
data-index="{{index}}"
>×</view>
<view
wx:if="{{item.status === 'failed'}}"
class="ui-photo__retry"
catchtap="onRetry"
data-index="{{index}}"
>重试</view>
<view wx:if="{{item.status === 'uploading'}}" class="ui-photo__mask">
<view class="ui-photo__spinner"></view>
</view>
</view>
<view
wx:if="{{!readonly && photos.length < max}}"
class="ui-photo__add"
bindtap="onAdd"
>
<view class="ui-photo__add-icon">+</view>
<text class="ui-photo__add-text">{{photos.length}}/{{max}}</text>
</view>
</view>
<view class="ui-photo__hint">
<text>最多 {{max}} 张,建议真人生活照,避免过度美颜</text>
</view>
</view>
@@ -0,0 +1,99 @@
.ui-photo__list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.ui-photo__item,
.ui-photo__add {
width: 200rpx;
height: 200rpx;
border-radius: var(--radius-md);
position: relative;
overflow: hidden;
}
.ui-photo__item {
background: var(--color-bg-disabled);
}
.ui-photo__img {
width: 100%;
height: 100%;
display: block;
}
.ui-photo__status {
position: absolute;
top: 8rpx;
left: 8rpx;
}
.ui-photo__remove {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #FFFFFF;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.ui-photo__retry {
position: absolute;
bottom: 8rpx;
left: 50%;
transform: translateX(-50%);
padding: 4rpx 16rpx;
border-radius: var(--radius-full);
background: var(--color-danger);
color: #FFFFFF;
font-size: var(--font-size-xs);
}
.ui-photo__mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.ui-photo__spinner {
width: 40rpx;
height: 40rpx;
border: 3rpx solid rgba(255, 255, 255, 0.5);
border-top-color: #FFFFFF;
border-radius: 50%;
animation: ui-photo-spin 0.8s linear infinite;
}
@keyframes ui-photo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ui-photo__add {
background: var(--color-bg-disabled);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2rpx dashed var(--color-text-disabled);
}
.ui-photo__add-icon {
font-size: var(--font-size-2xl);
color: var(--color-text-tertiary);
line-height: 1;
}
.ui-photo__add-text {
margin-top: var(--spacing-1);
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.ui-photo__hint {
margin-top: var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
line-height: var(--line-height-normal);
}
@@ -0,0 +1,63 @@
/**
* PreferenceEditor 择偶条件编辑器(规范 §30)
*
* 每条条件有两个 importance 等级:
* - required:必须符合(AGENTS 第 2 条硬约束)
* - preferred:最好符合(软约束,后端可适度放宽匹配)
*
* 不变量(AGENTS.md 第 2 条):用户已勾选"必须符合"的字段不可被前端自动放宽为
* "最好符合",UI 必须显式提示用户此操作将影响匹配精度。
*/
Component({
options: { addGlobalClass: true },
properties: {
preference: { type: Object, value: null },
readonly: { type: Boolean, value: false },
},
data: {
importanceLabelMap: {
required: '必须符合',
preferred: '最好符合',
},
},
methods: {
// 切换某字段的 importance:required ↔ preferred
onToggleImportance(e) {
if (this.data.readonly) return;
const { field } = e.currentTarget.dataset;
const pref = this.data.preference || {};
const current = pref[field];
if (!current) return;
const next = current === 'required' ? 'preferred' : 'required';
this.triggerEvent('change', {
field,
importance: next,
// 必须符合 → 最好符合,需用户二次确认(AGENTS 第 2 条)
needConfirmRelax: current === 'required',
});
},
onAgeChange(e) {
if (this.data.readonly) return;
const { bound } = e.currentTarget.dataset;
this.triggerEvent('change', {
field: bound === 'min' ? 'ageMin' : 'ageMax',
value: Number(e.detail.value),
});
},
onHeightChange(e) {
if (this.data.readonly) return;
const { bound } = e.currentTarget.dataset;
this.triggerEvent('change', {
field: bound === 'min' ? 'heightMin' : 'heightMax',
value: Number(e.detail.value) || null,
});
},
onAcceptChildrenChange(e) {
if (this.data.readonly) return;
this.triggerEvent('change', {
field: 'acceptChildren',
value: e.detail.value,
});
},
},
});
@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"ui-input": "../input/index",
"ui-tag": "../tag/index"
}
}
@@ -0,0 +1,124 @@
<view class="ui-pref" wx:if="{{preference}}">
<view class="ui-pref__hint">
标记为"必须符合"的条件将作为硬约束,不会自动放宽;
"最好符合"为软偏好,可能匹配到略不满足的对象。
</view>
<!-- 年龄 -->
<view class="ui-pref__row">
<view class="ui-pref__label">年龄</view>
<view class="ui-pref__range">
<input
class="ui-pref__num"
type="number"
value="{{preference.ageMin}}"
placeholder="不限"
data-bound="min"
bindinput="onAgeChange"
/>
<text class="ui-pref__dash">~</text>
<input
class="ui-pref__num"
type="number"
value="{{preference.ageMax}}"
placeholder="不限"
data-bound="max"
bindinput="onAgeChange"
/>
</view>
<view
class="ui-pref__importance ui-pref__importance--{{preference.ageImportance}}"
data-field="ageImportance"
bindtap="onToggleImportance"
>{{importanceLabelMap[preference.ageImportance]}}</view>
</view>
<!-- 身高 -->
<view class="ui-pref__row">
<view class="ui-pref__label">身高</view>
<view class="ui-pref__range">
<input
class="ui-pref__num"
type="number"
value="{{preference.heightMin}}"
placeholder="不限"
data-bound="min"
bindinput="onHeightChange"
/>
<text class="ui-pref__dash">~</text>
<input
class="ui-pref__num"
type="number"
value="{{preference.heightMax}}"
placeholder="不限"
data-bound="max"
bindinput="onHeightChange"
/>
</view>
<view
class="ui-pref__importance ui-pref__importance--{{preference.heightImportance}}"
data-field="heightImportance"
bindtap="onToggleImportance"
>{{importanceLabelMap[preference.heightImportance]}}</view>
</view>
<!-- 地区 -->
<view class="ui-pref__row">
<view class="ui-pref__label">地区</view>
<view class="ui-pref__tags">
<ui-tag
wx:for="{{preference.region}}"
wx:key="*this"
label="{{item}}"
variant="primary"
/>
</view>
<view
class="ui-pref__importance ui-pref__importance--{{preference.regionImportance}}"
data-field="regionImportance"
bindtap="onToggleImportance"
>{{importanceLabelMap[preference.regionImportance]}}</view>
</view>
<!-- 婚姻状况 -->
<view class="ui-pref__row">
<view class="ui-pref__label">婚姻状况</view>
<view class="ui-pref__tags">
<ui-tag
wx:for="{{preference.maritalStatusAccepted}}"
wx:key="*this"
label="{{item}}"
variant="primary"
/>
</view>
</view>
<!-- 学历 -->
<view class="ui-pref__row">
<view class="ui-pref__label">学历</view>
<view class="ui-pref__tags">
<ui-tag
wx:for="{{preference.educationAccepted}}"
wx:key="*this"
label="{{item}}"
variant="primary"
/>
</view>
</view>
<!-- 是否接受有孩 -->
<view class="ui-pref__row">
<view class="ui-pref__label">子女</view>
<picker
class="ui-pref__picker"
value="{{preference.acceptChildren}}"
range="{{['any', 'accept', 'reject']}}"
range-key=""
bindchange="onAcceptChildrenChange"
>
<view class="ui-pref__picker-value">
{{preference.acceptChildren === 'any' ? '不限' : preference.acceptChildren === 'accept' ? '可接受' : '不接受'}}
</view>
</picker>
</view>
</view>
@@ -0,0 +1,72 @@
.ui-pref__hint {
padding: var(--spacing-2) var(--spacing-3);
background: var(--color-warning-light);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--color-warning);
line-height: var(--line-height-normal);
margin-bottom: var(--spacing-3);
}
.ui-pref__row {
display: flex;
align-items: center;
padding: var(--spacing-3) 0;
border-bottom: 1rpx solid var(--color-divider);
}
.ui-pref__label {
width: 140rpx;
font-size: var(--font-size-md);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.ui-pref__range {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.ui-pref__num {
flex: 1;
min-width: 0;
height: 64rpx;
padding: 0 var(--spacing-2);
border: 1rpx solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--font-size-md);
text-align: center;
}
.ui-pref__dash {
margin: 0 var(--spacing-2);
color: var(--color-text-tertiary);
}
.ui-pref__tags {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.ui-pref__picker {
flex: 1;
}
.ui-pref__picker-value {
padding: var(--spacing-2) 0;
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.ui-pref__importance {
margin-left: var(--spacing-2);
padding: 6rpx 16rpx;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
flex-shrink: 0;
}
.ui-pref__importance--required {
background: var(--color-danger-light);
color: var(--color-danger);
}
.ui-pref__importance--preferred {
background: var(--color-bg-disabled);
color: var(--color-text-tertiary);
}

Some files were not shown because too many files have changed in this diff Show More