chore: establish V1.1 recovery baseline
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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', {});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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 || {});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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', {});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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', {});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"ui-avatar": "../avatar/index",
|
||||
"ui-status-badge": "../status-badge/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user