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
+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);
}
@@ -0,0 +1,53 @@
/**
* RecommendCard 推荐卡片(规范 §22-25, §107-108)
*
* 推荐首页核心卡片,包含:头像 + 基本信息 + 推荐理由 + Like/Pass 操作。
* 不变量(AGENTS.md):
* - 第 2 条:Required 不可自动放宽——推荐理由仅作弱文字提示,
* 不暴露"放宽了 X 条件才得到这个推荐"的字样,前端只显示后端返回的 reasons。
* - 第 5 条:红娘不可替用户操作——本卡片仅由当前用户操作,不接受 matchmakerId 参数。
*/
const { STATUS_BADGE } = require('../../constants');
Component({
options: { addGlobalClass: true },
properties: {
recommendation: { type: Object, value: null },
// 是否显示操作按钮(详情页用,可能用底部固定按钮替代)
showActions: { type: Boolean, value: true },
// 是否禁用按钮(操作进行中)
actionLoading: { type: Boolean, value: false },
},
data: {
levelTextMap: {
high: '很合适',
medium: '比较合适',
normal: '值得了解',
},
levelKindMap: {
high: 'success',
medium: 'info',
normal: 'warning',
},
},
methods: {
onViewDetail() {
if (!this.data.recommendation) return;
this.triggerEvent('view', { userId: this.data.recommendation.userId });
},
onLike() {
if (this.data.actionLoading) return;
if (!this.data.recommendation) return;
this.triggerEvent('like', { userId: this.data.recommendation.userId });
},
onPass() {
if (this.data.actionLoading) return;
if (!this.data.recommendation) return;
this.triggerEvent('pass', { userId: this.data.recommendation.userId });
},
onBlock(e) {
// 阻止冒泡避免触发 view
this.triggerEvent('block', { userId: this.data.recommendation.userId });
},
},
});
@@ -0,0 +1,8 @@
{
"component": true,
"usingComponents": {
"ui-avatar": "../avatar/index",
"ui-status-badge": "../status-badge/index",
"ui-button": "../button/index"
}
}
@@ -0,0 +1,75 @@
<view class="ui-rec" wx:if="{{recommendation}}">
<view class="ui-rec__header" bindtap="onViewDetail">
<ui-avatar
src="{{recommendation.avatarUrl}}"
size="xl"
verified="{{recommendation.verified}}"
reviewed="{{recommendation.reviewed}}"
default-text="{{recommendation.displayName}}"
/>
<view class="ui-rec__info">
<view class="ui-rec__name-row">
<text class="ui-rec__name">{{recommendation.displayName}}</text>
<text class="ui-rec__age">{{recommendation.age}}岁</text>
</view>
<view class="ui-rec__meta">
<text wx:if="{{recommendation.heightCm}}">{{recommendation.heightCm}}cm · </text>
<text>{{recommendation.regionName}}</text>
<text wx:if="{{recommendation.educationCode}}"> · {{recommendation.educationCode}}</text>
</view>
<view wx:if="{{recommendation.occupationCode}}" class="ui-rec__meta">
{{recommendation.occupationCode}}
</view>
<view wx:if="{{recommendation.maritalStatus || recommendation.hasChildren}}" class="ui-rec__meta">
<text wx:if="{{recommendation.maritalStatus}}">{{recommendation.maritalStatus}}</text>
<text wx:if="{{recommendation.hasChildren}}"> · 有孩</text>
</view>
<view wx:if="{{recommendation.photoUrls}}" class="ui-rec__photo-count">
{{recommendation.photoUrls.length}} 张生活照
</view>
</view>
<view
wx:if="{{recommendation.recommendationLevel}}"
class="ui-rec__level ui-rec__level--{{recommendation.recommendationLevel}}"
>
{{levelTextMap[recommendation.recommendationLevel]}}
</view>
</view>
<view
wx:if="{{recommendation.recommendationReasons && recommendation.recommendationReasons.length}}"
class="ui-rec__reasons"
>
<view class="ui-rec__reasons-title">为什么推荐给你</view>
<view
wx:for="{{recommendation.recommendationReasons}}"
wx:key="*this"
class="ui-rec__reason"
>✓ {{item}}</view>
</view>
<view wx:if="{{recommendation.intro}}" class="ui-rec__intro">
{{recommendation.intro}}
</view>
<view wx:if="{{showActions}}" class="ui-rec__actions">
<ui-button
variant="ghost"
size="md"
disabled="{{actionLoading}}"
class="ui-rec__btn-pass"
bindtap="onPass"
>不合适</ui-button>
<ui-button
variant="primary"
size="md"
loading="{{actionLoading}}"
class="ui-rec__btn-like"
bindtap="onLike"
>喜欢</ui-button>
</view>
<view class="ui-rec__more">
<text class="ui-rec__more-text" catchtap="onBlock">拉黑该用户</text>
</view>
</view>
@@ -0,0 +1,113 @@
.ui-rec {
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-rec__header {
display: flex;
align-items: flex-start;
position: relative;
}
.ui-rec__info {
flex: 1;
min-width: 0;
margin-left: var(--spacing-3);
}
.ui-rec__name-row {
display: flex;
align-items: baseline;
margin-bottom: var(--spacing-1);
}
.ui-rec__name {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-right: var(--spacing-2);
}
.ui-rec__age {
font-size: var(--font-size-md);
color: var(--color-text-tertiary);
}
.ui-rec__meta {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: var(--line-height-normal);
margin-top: 4rpx;
}
.ui-rec__photo-count {
margin-top: var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-primary);
}
.ui-rec__level {
position: absolute;
top: 0;
right: 0;
padding: 6rpx 14rpx;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.ui-rec__level--high {
background: var(--color-success-light);
color: var(--color-success);
}
.ui-rec__level--medium {
background: var(--color-info-light);
color: var(--color-info);
}
.ui-rec__level--normal {
background: var(--color-warning-light);
color: var(--color-warning);
}
.ui-rec__reasons {
margin-top: var(--spacing-3);
padding: var(--spacing-3);
background: var(--color-primary-light);
border-radius: var(--radius-md);
}
.ui-rec__reasons-title {
font-size: var(--font-size-sm);
color: var(--color-primary);
font-weight: var(--font-weight-medium);
margin-bottom: var(--spacing-2);
}
.ui-rec__reason {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
}
.ui-rec__intro {
margin-top: var(--spacing-3);
padding-top: var(--spacing-3);
border-top: 1rpx solid var(--color-divider);
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: 3;
overflow: hidden;
}
.ui-rec__actions {
display: flex;
gap: var(--spacing-3);
margin-top: var(--spacing-4);
}
.ui-rec__btn-pass {
flex: 1;
}
.ui-rec__btn-like {
flex: 2;
}
.ui-rec__more {
margin-top: var(--spacing-3);
text-align: center;
}
.ui-rec__more .ui-rec__more-text {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
padding: var(--spacing-1) var(--spacing-2);
}
@@ -0,0 +1,21 @@
/**
* SectionHeader 区块标题(规范 §18)
*
* 标题 + 可选副标题 + 右侧操作入口。
* 用法:
* <ui-section-header title="今日推荐" subtitle="共 6 位"
* action="查看更多" bind:action="onMore" />
*/
Component({
options: { addGlobalClass: true },
properties: {
title: { type: String, value: '' },
subtitle: { type: String, value: '' },
action: { type: String, value: '' },
},
methods: {
onAction() {
this.triggerEvent('action', {});
},
},
});
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,10 @@
<view class="ui-section">
<view class="ui-section__left">
<text class="ui-section__title">{{title}}</text>
<text wx:if="{{subtitle}}" class="ui-section__subtitle">{{subtitle}}</text>
</view>
<view wx:if="{{action}}" class="ui-section__action" bindtap="onAction">
<text>{{action}}</text>
<text class="ui-section__arrow">›</text>
</view>
</view>
@@ -0,0 +1,31 @@
.ui-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-3) var(--spacing-page-x);
}
.ui-section__left {
display: flex;
align-items: baseline;
}
.ui-section__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.ui-section__subtitle {
margin-left: var(--spacing-2);
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
}
.ui-section__action {
display: flex;
align-items: center;
font-size: var(--font-size-sm);
color: var(--color-primary);
}
.ui-section__arrow {
margin-left: 4rpx;
font-size: var(--font-size-md);
line-height: 1;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* StatusBadge 状态徽标(规范 §22 / §107 STATUS_BADGE 映射)
*
* 用统一的 kind 控制色彩语义:success / warning / danger / info / disabled
* 用法:
* <ui-status-badge kind="success" label="已实名" />
* <ui-status-badge kind="warning" label="审核中" />
*/
Component({
options: { addGlobalClass: true },
properties: {
kind: { type: String, value: 'info' }, // success|warning|danger|info|disabled
label: { type: String, value: '' },
icon: { type: Boolean, value: false }, // 是否显示前缀圆点
},
});
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,4 @@
<view class="ui-badge ui-badge--{{kind}}">
<view wx:if="{{icon}}" class="ui-badge__dot"></view>
<text class="ui-badge__label">{{label}}</text>
</view>
@@ -0,0 +1,40 @@
.ui-badge {
display: inline-flex;
align-items: center;
padding: 4rpx 14rpx;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: 1.4;
white-space: nowrap;
}
.ui-badge__dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: currentColor;
margin-right: 8rpx;
}
.ui-badge__label {
font-size: inherit;
}
.ui-badge--success {
background: var(--color-success-light);
color: var(--color-success);
}
.ui-badge--warning {
background: var(--color-warning-light);
color: var(--color-warning);
}
.ui-badge--danger {
background: var(--color-danger-light);
color: var(--color-danger);
}
.ui-badge--info {
background: var(--color-info-light);
color: var(--color-info);
}
.ui-badge--disabled {
background: var(--color-bg-disabled);
color: var(--color-text-disabled);
}
@@ -0,0 +1,69 @@
/**
* SuccessConfirmDialog 成功牵手双确认弹窗(AGENTS.md 第 6 条)
*
* 双角色:
* - 发起方(请求方):mode="request" → 显示"发起牵手请求"
* - 被请求方(确认方):mode="confirm" → 显示"对方请求与你成功牵手"
*
* 不变量(AGENTS.md):
* - 第 6 条:成功牵手必须双确认——本组件是用户侧的唯一确认入口,
* 点击确认后由调用方调用 stores.confirmSuccess(matchId)(内部触发 matchService.confirmSuccess)。
* - 第 6 条(隐式):发起方 7 天内未确认则请求过期,本组件显示过期倒计时(如有 expiresAt)。
*/
Component({
options: { addGlobalClass: true },
properties: {
visible: { type: Boolean, value: false },
mode: { type: String, value: 'request' }, // request | confirm
targetName: { type: String, value: '' },
expiresAt: { type: Number, value: 0 }, // 过期时间戳(mode=confirm 时由调用方传入)
},
data: {
titleText: '',
contentText: '',
confirmText: '',
expiresInText: '',
},
observers: {
'mode, targetName, expiresAt'(mode, name, expiresAt) {
let title = '';
let content = '';
let confirm = '';
if (mode === 'request') {
title = '发起成功牵手';
content = `发起后 ${name || '对方'} 需在 7 天内确认。双方均确认后,关系将置为"成功牵手",平台不再为你推荐其他人。`;
confirm = '发起牵手请求';
} else {
title = '对方发起牵手请求';
content = `${name || '对方'} 希望与你确认成功牵手。确认后你们的关系将置为"成功牵手",平台不再为你们推荐其他人。`;
confirm = '我同意';
}
let expiresInText = '';
if (expiresAt > 0) {
const remain = expiresAt - Date.now();
if (remain > 0) {
const days = Math.floor(remain / (24 * 3600 * 1000));
const hours = Math.floor((remain % (24 * 3600 * 1000)) / (3600 * 1000));
expiresInText = days > 0 ? `剩余 ${days} 天 ${hours} 小时` : `剩余 ${hours} 小时`;
} else {
expiresInText = '已过期';
}
}
this.setData({
titleText: title,
contentText: content,
confirmText: confirm,
expiresInText,
});
},
},
methods: {
onConfirm() {
this.triggerEvent('confirm', { mode: this.data.mode });
},
onCancel() {
this.triggerEvent('cancel', {});
},
onStop() {},
},
});
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"ui-button": "../button/index"
}
}
@@ -0,0 +1,14 @@
<view wx:if="{{visible}}" class="ui-success-mask" catchtap="onCancel">
<view class="ui-success" catchtap="onStop">
<view class="ui-success__heart">
<view class="ui-success__heart-icon"></view>
</view>
<view class="ui-success__title">{{titleText}}</view>
<view class="ui-success__content">{{contentText}}</view>
<view wx:if="{{expiresInText}}" class="ui-success__expires">{{expiresInText}}</view>
<view class="ui-success__actions">
<ui-button variant="ghost" size="md" class="ui-success__btn" bindtap="onCancel">再想想</ui-button>
<ui-button variant="primary" size="md" class="ui-success__btn" bindtap="onConfirm">{{confirmText}}</ui-button>
</view>
</view>
</view>
@@ -0,0 +1,67 @@
.ui-success-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-success {
width: 100%;
max-width: 600rpx;
background: var(--color-bg-card);
border-radius: var(--radius-lg);
padding: var(--spacing-6) var(--spacing-4) var(--spacing-4);
text-align: center;
box-shadow: var(--shadow-popup);
animation: ui-success-in var(--motion-normal) var(--ease-standard);
}
@keyframes ui-success-in {
from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.ui-success__heart {
width: 120rpx;
height: 120rpx;
margin: 0 auto var(--spacing-3);
background: var(--color-primary-light);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.ui-success__heart-icon {
width: 56rpx;
height: 56rpx;
background: var(--color-primary);
clip-path: polygon(50% 100%, 0 38%, 18% 18%, 50% 38%, 82% 18%, 100% 38%);
}
.ui-success__title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-bottom: var(--spacing-2);
}
.ui-success__content {
font-size: var(--font-size-md);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed);
margin-bottom: var(--spacing-2);
}
.ui-success__expires {
font-size: var(--font-size-xs);
color: var(--color-warning);
margin-bottom: var(--spacing-4);
}
.ui-success__actions {
display: flex;
gap: var(--spacing-2);
}
.ui-success__btn {
flex: 1;
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Tag 标签(规范 §35 兴趣标签上限 8 个)
*
* 可选中 / 可移除变体,配合 PhotoUploader / PreferenceEditor 使用。
* 用法:
* <ui-tag label="阅读" selected="{{true}}" bind:tap="onToggleTag" />
* <ui-tag label="运动" removable bind:remove="onRemoveTag" />
*/
Component({
options: { addGlobalClass: true },
properties: {
label: { type: String, value: '' },
selected: { type: Boolean, value: false },
removable: { type: Boolean, value: false }, // 显示移除按钮
disabled: { type: Boolean, value: false },
variant: { type: String, value: 'default' }, // default|primary
},
methods: {
onTap() {
if (this.data.disabled) return;
this.triggerEvent('tap', { label: this.data.label });
},
onRemove(e) {
// 阻止冒泡,避免触发外层 tap
this.triggerEvent('remove', { label: this.data.label });
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+8
View File
@@ -0,0 +1,8 @@
<view
class="ui-tag ui-tag--{{variant}} {{selected ? 'ui-tag--selected' : ''}} {{disabled ? 'ui-tag--disabled' : ''}}"
hover-class="{{disabled ? '' : 'ui-tag--hover'}}"
bindtap="onTap"
>
<text class="ui-tag__label">{{label}}</text>
<view wx:if="{{removable && !disabled}}" class="ui-tag__close" catchtap="onRemove">×</view>
</view>
+46
View File
@@ -0,0 +1,46 @@
.ui-tag {
display: inline-flex;
align-items: center;
padding: 10rpx 20rpx;
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
line-height: 1.3;
background: var(--color-bg-disabled);
color: var(--color-text-secondary);
border: 1rpx solid transparent;
transition: all var(--motion-fast) var(--ease-standard);
user-select: none;
}
.ui-tag__label {
font-size: inherit;
}
.ui-tag__close {
margin-left: 8rpx;
font-size: var(--font-size-md);
line-height: 1;
color: var(--color-text-tertiary);
width: 24rpx;
height: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.ui-tag--default.ui-tag--selected {
background: var(--color-primary-light);
color: var(--color-primary);
border-color: var(--color-primary);
}
.ui-tag--primary {
background: var(--color-primary);
color: #FFFFFF;
}
.ui-tag--primary.ui-tag--selected {
background: var(--color-primary-active);
}
.ui-tag--disabled {
opacity: 0.55;
}
.ui-tag--hover {
opacity: 0.85;
transform: scale(0.97);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Textarea 多行输入(规范 §18, §35:自我介绍 20~500 字)
*
* 内置字数计数 + 字数下限/上限提示 + 错误态。
* 用法:
* <ui-textarea label="自我介绍" value="{{intro}}" minlength="20" maxlength="500"
* placeholder="简单介绍一下你自己…" error="{{errors.intro}}"
* bind:input="onIntroInput" />
*/
Component({
options: { addGlobalClass: true },
properties: {
label: { type: String, value: '' },
placeholder: { type: String, value: '' },
value: { type: String, value: '' },
minlength: { type: Number, value: 0 },
maxlength: { type: Number, value: 500 },
showCount: { type: Boolean, value: true },
error: { type: String, value: '' },
disabled: { type: Boolean, value: false },
autoHeight: { type: Boolean, value: true },
rows: { type: Number, value: 3 },
},
data: {
currentLen: 0,
},
observers: {
value(val) {
this.setData({ currentLen: (val || '').length });
},
},
methods: {
onInput(e) {
const val = e.detail.value;
this.setData({ currentLen: (val || '').length });
this.triggerEvent('input', { value: val });
},
onFocus(e) {
this.triggerEvent('focus', e.detail);
},
onBlur(e) {
this.triggerEvent('blur', { value: e.detail.value });
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+27
View File
@@ -0,0 +1,27 @@
<view class="ui-textarea {{error ? 'ui-textarea--error' : ''}} {{disabled ? 'ui-textarea--disabled' : ''}}">
<view wx:if="{{label}}" class="ui-textarea__label">{{label}}</view>
<view class="ui-textarea__field-wrap">
<textarea
class="ui-textarea__field"
value="{{value}}"
placeholder="{{placeholder}}"
placeholder-class="ui-textarea__placeholder"
maxlength="{{maxlength}}"
disabled="{{disabled}}"
auto-height="{{autoHeight}}"
style="min-height: {{rows * 48}}rpx;"
bindinput="onInput"
bindfocus="onFocus"
bindblur="onBlur"
/>
</view>
<view class="ui-textarea__footer">
<view wx:if="{{error}}" class="ui-textarea__error">{{error}}</view>
<view wx:elif="{{minlength > 0 && currentLen < minlength}}" class="ui-textarea__hint">
至少还需 {{minlength - currentLen}} 字
</view>
<view wx:if="{{showCount}}" class="ui-textarea__count">
{{currentLen}}/{{maxlength}}
</view>
</view>
</view>
+56
View File
@@ -0,0 +1,56 @@
.ui-textarea {
padding: var(--spacing-3) 0;
border-bottom: 1rpx solid var(--color-border);
}
.ui-textarea__label {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
margin-bottom: var(--spacing-2);
}
.ui-textarea__field-wrap {
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: var(--spacing-2) var(--spacing-3);
border: 1rpx solid var(--color-border);
}
.ui-textarea__field {
width: 100%;
font-size: var(--font-size-base);
color: var(--color-text-primary);
line-height: var(--line-height-normal);
min-height: 144rpx;
}
.ui-textarea__placeholder {
color: var(--color-text-placeholder);
}
.ui-textarea__footer {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: var(--spacing-1);
min-height: 28rpx;
}
.ui-textarea__error {
color: var(--color-danger);
font-size: var(--font-size-xs);
flex: 1;
}
.ui-textarea__hint {
color: var(--color-warning);
font-size: var(--font-size-xs);
flex: 1;
}
.ui-textarea__count {
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
margin-left: var(--spacing-2);
}
.ui-textarea--error .ui-textarea__field-wrap {
border-color: var(--color-border-error);
}
.ui-textarea--disabled .ui-textarea__field {
color: var(--color-text-disabled);
}
.ui-textarea--disabled .ui-textarea__field-wrap {
background: var(--color-bg-disabled);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* UserCard 用户卡片(列表项 / Discover / 活动互选)
*
* 不携带任何操作按钮,纯展示;操作按钮由调用方 slot 注入。
* 用法:
* <ui-user-card user="{{user}}" bind:tap="onTap">
* <view slot="actions">…自定义按钮…</view>
* </ui-user-card>
*/
Component({
options: { addGlobalClass: true, multipleSlots: true },
properties: {
user: { type: Object, value: null },
showReasons: { type: Boolean, value: false }, // 是否展示推荐理由
selectable: { type: Boolean, value: false }, // 是否显示选择框
selected: { type: Boolean, value: false },
},
methods: {
onTap() {
if (!this.data.user) return;
this.triggerEvent('tap', { userId: this.data.user.userId });
},
onToggleSelect() {
this.triggerEvent('select', {
userId: this.data.user.userId,
selected: !this.data.selected,
});
},
},
});
+7
View File
@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"ui-avatar": "../avatar/index",
"ui-status-badge": "../status-badge/index"
}
}
+34
View File
@@ -0,0 +1,34 @@
<view class="ui-user-card" bindtap="onTap">
<view wx:if="{{selectable}}" class="ui-user-card__select" catchtap="onToggleSelect">
<view class="ui-user-card__checkbox {{selected ? 'ui-user-card__checkbox--checked' : ''}}"></view>
</view>
<ui-avatar
src="{{user.avatarUrl}}"
size="lg"
verified="{{user.verified}}"
reviewed="{{user.reviewed}}"
default-text="{{user.displayName}}"
bind:tap="onTap"
/>
<view class="ui-user-card__body">
<view class="ui-user-card__name-row">
<text class="ui-user-card__name">{{user.displayName}}</text>
<text class="ui-user-card__age">{{user.age}}岁</text>
</view>
<view class="ui-user-card__meta">
<text wx:if="{{user.heightCm}}">{{user.heightCm}}cm · </text>
<text>{{user.regionName}}</text>
<text wx:if="{{user.educationCode}}"> · {{user.educationCode}}</text>
</view>
<view wx:if="{{user.occupationCode}}" class="ui-user-card__meta">
{{user.occupationCode}}
</view>
<view wx:if="{{user.maritalStatus || user.hasChildren !== undefined}}" class="ui-user-card__meta">
<text wx:if="{{user.maritalStatus}}">{{user.maritalStatus}}</text>
<text wx:if="{{user.hasChildren}}"> · 有孩</text>
</view>
</view>
<view class="ui-user-card__actions">
<slot name="actions"></slot>
</view>
</view>
+64
View File
@@ -0,0 +1,64 @@
.ui-user-card {
display: flex;
align-items: center;
background: var(--color-bg-card);
padding: var(--spacing-3) var(--spacing-page-x);
border-bottom: 1rpx solid var(--color-divider);
}
.ui-user-card__select {
margin-right: var(--spacing-3);
}
.ui-user-card__checkbox {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid var(--color-text-disabled);
position: relative;
background: var(--color-bg-card);
transition: all var(--motion-fast) var(--ease-standard);
}
.ui-user-card__checkbox--checked {
background: var(--color-primary);
border-color: var(--color-primary);
}
.ui-user-card__checkbox--checked::after {
content: '';
position: absolute;
left: 12rpx;
top: 6rpx;
width: 10rpx;
height: 18rpx;
border: solid #FFFFFF;
border-width: 0 3rpx 3rpx 0;
transform: rotate(45deg);
}
.ui-user-card__body {
flex: 1;
min-width: 0;
margin-left: var(--spacing-3);
}
.ui-user-card__name-row {
display: flex;
align-items: baseline;
margin-bottom: var(--spacing-1);
}
.ui-user-card__name {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-right: var(--spacing-2);
}
.ui-user-card__age {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
}
.ui-user-card__meta {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: var(--line-height-normal);
margin-top: 4rpx;
}
.ui-user-card__actions {
flex-shrink: 0;
margin-left: var(--spacing-2);
}