feat: harden phase one workflows

This commit is contained in:
王鹏
2026-07-14 16:04:51 +08:00
parent 47f4a24760
commit 767534df48
103 changed files with 4849 additions and 388 deletions

View File

@@ -4,11 +4,21 @@ function listCategories() {
return request({ url: '/api/mini/product-categories' })
}
function listProducts(categoryId) {
function listProducts(categoryId, keyword) {
return request({
url: '/api/mini/products',
data: categoryId ? { categoryId } : {}
})
url: '/api/mini/products/page',
data: buildProductQuery(categoryId, keyword)
}).then((page) => page && page.records ? page.records : [])
}
function buildProductQuery(categoryId, keyword) {
const data = {
pageNo: 1,
pageSize: 20
}
if (categoryId) data.categoryId = categoryId
if (keyword) data.keyword = keyword
return data
}
function getProductDetail(id) {
@@ -26,6 +36,7 @@ function createGoodsOrder(data) {
module.exports = {
listCategories,
listProducts,
buildProductQuery,
getProductDetail,
createGoodsOrder
}

View File

@@ -0,0 +1,12 @@
const { buildProductQuery } = require('./productApi')
describe('buildProductQuery', () => {
test('keeps pagination and optional filters stable', () => {
expect(buildProductQuery(2, '苹果')).toEqual({
pageNo: 1,
pageSize: 20,
categoryId: 2,
keyword: '苹果'
})
})
})

View File

@@ -1,10 +1,20 @@
const { request } = require('../utils/request')
function listSecondGoods(category) {
function listSecondGoods(category, keyword) {
return request({
url: '/api/mini/second-goods',
data: category ? { category } : {}
})
url: '/api/mini/second-goods/page',
data: buildSecondGoodsQuery(category, keyword)
}).then((page) => page && page.records ? page.records : [])
}
function buildSecondGoodsQuery(category, keyword) {
const data = {
pageNo: 1,
pageSize: 20
}
if (category) data.category = category
if (keyword) data.keyword = keyword
return data
}
function getSecondGoodsDetail(id) {
@@ -21,6 +31,7 @@ function createSecondGoods(data) {
module.exports = {
listSecondGoods,
buildSecondGoodsQuery,
getSecondGoodsDetail,
createSecondGoods
}

View File

@@ -0,0 +1,12 @@
const { buildSecondGoodsQuery } = require('./secondGoodsApi')
describe('buildSecondGoodsQuery', () => {
test('keeps pagination and optional filters stable', () => {
expect(buildSecondGoodsQuery('母婴', '儿童')).toEqual({
pageNo: 1,
pageSize: 20,
category: '母婴',
keyword: '儿童'
})
})
})

View File

@@ -1,6 +1,7 @@
App({
globalData: {
apiBaseUrl: 'http://localhost:8080',
useDevLogin: true,
token: '',
user: null
}

View File

@@ -1,31 +1,87 @@
page {
background: #f4f6f8;
color: #17212b;
background: #f3f6f8;
color: #16212f;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
font-size: 28rpx;
line-height: 1.5;
}
view,
text,
input,
textarea,
button {
box-sizing: border-box;
}
.page {
min-height: 100vh;
padding: 24rpx;
padding: 28rpx 24rpx 48rpx;
}
.section {
.page-heading {
margin-bottom: 24rpx;
}
.heading-eyebrow {
display: inline-flex;
align-items: center;
height: 40rpx;
padding: 0 14rpx;
color: #0f766e;
background: #e6f4f1;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
}
.heading-title {
margin-top: 10rpx;
color: #111827;
font-size: 40rpx;
font-weight: 800;
line-height: 1.2;
}
.heading-subtitle {
margin-top: 8rpx;
color: #64748b;
font-size: 24rpx;
}
.section {
margin-bottom: 28rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title {
margin: 0 0 16rpx;
font-size: 32rpx;
font-weight: 700;
margin: 0;
color: #111827;
font-size: 34rpx;
font-weight: 800;
line-height: 1.25;
}
.card {
background: #ffffff;
border: 1rpx solid #e5e9ef;
border-radius: 8rpx;
padding: 20rpx;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.card-title {
color: #111827;
font-size: 30rpx;
font-weight: 700;
line-height: 1.35;
}
.row {
@@ -38,6 +94,12 @@ page {
justify-content: space-between;
}
.stack {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.muted {
color: #64748b;
font-size: 24rpx;
@@ -49,12 +111,29 @@ page {
}
.tag {
display: inline-block;
padding: 4rpx 12rpx;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36rpx;
padding: 4rpx 14rpx;
background: #e6f4f1;
color: #0f766e;
border-radius: 6rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
white-space: nowrap;
}
.pill {
display: inline-flex;
align-items: center;
height: 44rpx;
padding: 0 16rpx;
color: #33536f;
background: #edf4f8;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
}
.grid {
@@ -72,7 +151,7 @@ page {
justify-content: center;
background: #ffffff;
border: 1rpx solid #e5e9ef;
border-radius: 8rpx;
border-radius: 16rpx;
font-weight: 600;
}
@@ -80,17 +159,41 @@ page {
.textarea,
.picker {
width: 100%;
min-height: 76rpx;
padding: 0 18rpx;
min-height: 84rpx;
padding: 0 22rpx;
background: #ffffff;
border: 1rpx solid #d9e0e8;
border-radius: 6rpx;
margin-bottom: 16rpx;
border: 1rpx solid #d9e2ea;
border-radius: 14rpx;
margin-bottom: 20rpx;
color: #17212b;
font-size: 28rpx;
}
.picker {
display: flex;
align-items: center;
color: #334155;
}
.textarea {
min-height: 140rpx;
padding-top: 16rpx;
padding-top: 18rpx;
}
.field-label {
display: block;
margin: 6rpx 0 12rpx;
color: #334155;
font-size: 24rpx;
font-weight: 700;
}
.form-card {
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.button-row {
@@ -99,12 +202,19 @@ page {
}
button {
border-radius: 6rpx;
border-radius: 14rpx;
font-size: 28rpx;
font-weight: 700;
}
button::after {
border: none;
}
.primary {
background: #0f766e;
color: #ffffff;
border: 1rpx solid #0f766e;
}
.ghost {
@@ -112,3 +222,38 @@ button {
color: #0f766e;
border: 1rpx solid #0f766e;
}
.action-button {
min-height: 88rpx;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
display: flex;
min-height: 180rpx;
align-items: center;
justify-content: center;
color: #64748b;
background: #ffffff;
border: 1rpx dashed #cbd5e1;
border-radius: 16rpx;
font-size: 26rpx;
}
.media-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #0f766e;
background: #e6f4f1;
font-weight: 800;
}
.divider {
height: 1rpx;
margin: 20rpx 0;
background: #edf1f5;
}

View File

@@ -1,19 +1,41 @@
<view class="page">
<view class="section-title">地址管理</view>
<view class="card">
<input class="input" placeholder="联系人" data-field="contactName" value="{{form.contactName}}" bindinput="onFieldInput" />
<input class="input" placeholder="手机号" data-field="phone" value="{{form.phone}}" bindinput="onFieldInput" />
<input class="input" placeholder="楼栋" data-field="building" value="{{form.building}}" bindinput="onFieldInput" />
<input class="input" placeholder="房号" data-field="room" value="{{form.room}}" bindinput="onFieldInput" />
<input class="input" placeholder="详细地址" data-field="detail" value="{{form.detail}}" bindinput="onFieldInput" />
<button class="primary" loading="{{submitting}}" bindtap="submit">保存地址</button>
<view class="page-heading">
<view class="heading-eyebrow">配送资料</view>
<view class="heading-title">地址管理</view>
<view class="heading-subtitle">保存常用住址,提交订单更快</view>
</view>
<view wx:for="{{addresses}}" wx:key="id" class="card">
<view class="row between">
<view>{{item.contactName}} {{item.phone}}</view>
<view class="address-form form-card">
<view class="card-title form-title">新增地址</view>
<view class="field-label">联系人</view>
<input class="input" placeholder="联系人" data-field="contactName" value="{{form.contactName}}" bindinput="onFieldInput" />
<view class="field-label">手机号</view>
<input class="input" placeholder="手机号" data-field="phone" value="{{form.phone}}" bindinput="onFieldInput" />
<view class="field-label">楼栋</view>
<input class="input" placeholder="楼栋" data-field="building" value="{{form.building}}" bindinput="onFieldInput" />
<view class="field-label">房号</view>
<input class="input" placeholder="房号" data-field="room" value="{{form.room}}" bindinput="onFieldInput" />
<view class="field-label">详细地址</view>
<input class="input" placeholder="详细地址" data-field="detail" value="{{form.detail}}" bindinput="onFieldInput" />
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">保存地址</button>
</view>
<view class="section-header address-header">
<view class="section-title">已保存地址</view>
</view>
<view wx:for="{{addresses}}" wx:key="id" class="address-card">
<view class="row between address-top">
<view class="card-title">{{item.contactName}} {{item.phone}}</view>
<view wx:if="{{item.default}}" class="tag">默认</view>
</view>
<view class="muted">{{item.building}} {{item.room}} {{item.detail}}</view>
<button wx:if="{{!item.default}}" size="mini" class="ghost" data-id="{{item.id}}" bindtap="setDefault">设为默认</button>
<view class="address-detail">{{item.building}} {{item.room}} {{item.detail}}</view>
<button wx:if="{{!item.default}}" size="mini" class="ghost default-button" data-id="{{item.id}}" bindtap="setDefault">设为默认</button>
</view>
<view wx:if="{{!addresses.length}}" class="empty-state">暂无地址</view>
</view>

View File

@@ -1,3 +1,41 @@
.ghost {
margin-top: 16rpx;
.page-heading {
margin-bottom: 22rpx;
}
.address-form {
margin-bottom: 28rpx;
}
.form-title {
margin-bottom: 20rpx;
}
.address-header {
margin-bottom: 14rpx;
}
.address-card {
padding: 24rpx;
margin-bottom: 16rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.address-top {
align-items: flex-start;
gap: 18rpx;
}
.address-detail {
margin-top: 12rpx;
color: #475569;
font-size: 26rpx;
line-height: 42rpx;
}
.default-button {
margin: 18rpx 0 0;
min-width: 154rpx;
}

View File

@@ -1,23 +1,46 @@
<view class="page">
<view class="section-title">代取快递</view>
<view class="card">
<view class="page-heading">
<view class="heading-eyebrow">快递服务</view>
<view class="heading-title">代取快递</view>
<view class="heading-subtitle">填写取件信息,配送员帮你送到家</view>
</view>
<view class="express-tips">
<view class="tips-title">收费规则</view>
<view class="muted">首件 3 元,第二件起每件加 1 元,异常订单后台可调整。</view>
</view>
<view class="form-card express-form">
<view class="field-label">快递公司</view>
<input class="input" placeholder="快递公司" data-field="expressCompany" bindinput="onFieldInput" />
<view class="field-label">取件码</view>
<input class="input" placeholder="取件码" data-field="pickupCode" bindinput="onFieldInput" />
<view class="field-label">快递站地址</view>
<input class="input" placeholder="快递站地址" data-field="pickupAddress" bindinput="onFieldInput" />
<view class="field-label">手机号</view>
<input class="input" placeholder="手机号" data-field="receiverPhone" bindinput="onFieldInput" />
<view class="muted">送达地址</view>
<view class="field-label">送达地址</view>
<picker mode="selector" range="{{addresses}}" range-key="detail" value="{{addressIndex}}" bindchange="onAddressChange">
<view class="picker">{{addresses[addressIndex] ? addresses[addressIndex].building + ' ' + addresses[addressIndex].room + ' ' + addresses[addressIndex].detail : '请选择地址'}}</view>
</picker>
<view class="field-label">件数</view>
<input class="input" type="number" value="{{form.packageCount}}" bindinput="onPackageCountChange" />
<view class="card">
<view class="fee-panel">
<view class="row between">
<text>预估服务费</text>
<text class="price">¥{{feeCent / 100}}</text>
</view>
<view class="muted">首件 3 元,续件每件 1 元</view>
</view>
<view class="field-label">备注</view>
<textarea class="textarea" placeholder="备注" data-field="remark" bindinput="onFieldInput" />
<button class="primary" loading="{{submitting}}" bindtap="submit">提交代取</button>
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">提交代取</button>
</view>
</view>

View File

@@ -1,3 +1,46 @@
.card .card {
margin-top: 0;
.page-heading {
margin-bottom: 20rpx;
}
.express-tips {
padding: 22rpx;
margin-bottom: 18rpx;
background: #fff7ed;
border: 1rpx solid #fed7aa;
border-radius: 16rpx;
}
.tips-title {
margin-bottom: 8rpx;
color: #9a3412;
font-size: 28rpx;
font-weight: 800;
}
.form-card {
margin-bottom: 20rpx;
}
.express-form {
padding-bottom: 28rpx;
}
.field-label {
margin-top: 4rpx;
}
.fee-panel {
padding: 22rpx;
margin-bottom: 20rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
border-radius: 16rpx;
}
.fee-panel .price {
font-size: 36rpx;
}
.action-button {
margin-top: 6rpx;
}

View File

@@ -1,25 +1,42 @@
<view class="page" wx:if="{{groupBuy}}">
<view class="card">
<view class="section-title">{{groupBuy.title}}</view>
<view class="price">¥{{groupBuy.priceCent / 100}}</view>
<view class="muted">{{groupBuy.description || '社区团购'}}</view>
<view class="muted">自提点:{{groupBuy.pickupAddress}}</view>
<view class="page detail-page" wx:if="{{groupBuy}}">
<view class="detail-hero">
<image wx:if="{{groupBuy.coverUrl}}" class="detail-cover" src="{{groupBuy.coverUrl}}" mode="aspectFill" />
<view wx:else class="detail-cover media-placeholder">团</view>
<view class="detail-info">
<view class="tag">{{groupBuy.status}}</view>
<view class="detail-title">{{groupBuy.title}}</view>
<view class="detail-price">¥{{groupBuy.priceCent / 100}}</view>
<view wx:if="{{groupBuy.originPriceCent}}" class="origin-price">原价 ¥{{groupBuy.originPriceCent / 100}}</view>
<view class="muted">{{groupBuy.description || '社区团购'}}</view>
</view>
</view>
<view class="card">
<view class="muted">数量</view>
<view class="delivery-panel">
<view class="panel-label">自提点</view>
<view class="panel-value">{{groupBuy.pickupAddress}}</view>
<view class="muted">截单 {{groupBuy.endTime}}</view>
</view>
<view class="purchase-card form-card">
<view class="card-title form-title">团购下单</view>
<view class="field-label">数量</view>
<input class="input" type="number" value="{{form.quantity}}" bindinput="onQuantityChange" />
<view class="muted">提货方式</view>
<radio-group bindchange="onMethodChange">
<view class="field-label">提货方式</view>
<radio-group class="radio-grid" bindchange="onMethodChange">
<label class="radio"><radio value="SELF_PICKUP" checked="{{form.deliveryMethod === 'SELF_PICKUP'}}" />自提</label>
<label class="radio"><radio value="IMMEDIATE" checked="{{form.deliveryMethod === 'IMMEDIATE'}}" />配送</label>
</radio-group>
<view wx:if="{{form.deliveryMethod !== 'SELF_PICKUP'}}">
<view class="muted">收货地址</view>
<view class="field-label">收货地址</view>
<picker mode="selector" range="{{addresses}}" range-key="detail" value="{{addressIndex}}" bindchange="onAddressChange">
<view class="picker">{{addresses[addressIndex] ? addresses[addressIndex].building + ' ' + addresses[addressIndex].room + ' ' + addresses[addressIndex].detail : '请选择地址'}}</view>
</picker>
</view>
<view class="field-label">备注</view>
<textarea class="textarea" placeholder="备注" bindinput="onRemarkInput" />
<button class="primary" loading="{{submitting}}" bindtap="submit">提交团购订单</button>
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">提交团购订单</button>
</view>
</view>

View File

@@ -1,5 +1,91 @@
.radio {
display: block;
height: 60rpx;
line-height: 60rpx;
.detail-page {
padding-bottom: 60rpx;
}
.detail-hero {
overflow: hidden;
margin-bottom: 18rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.detail-cover {
width: 100%;
height: 360rpx;
}
.detail-info {
padding: 26rpx;
}
.detail-title {
margin-top: 14rpx;
color: #111827;
font-size: 40rpx;
font-weight: 900;
line-height: 1.2;
}
.detail-price {
margin-top: 16rpx;
color: #c2410c;
font-size: 44rpx;
font-weight: 900;
}
.origin-price {
margin-bottom: 12rpx;
color: #94a3b8;
font-size: 24rpx;
text-decoration: line-through;
}
.delivery-panel {
padding: 22rpx;
margin-bottom: 18rpx;
background: #eff6ff;
border: 1rpx solid #bfdbfe;
border-radius: 16rpx;
}
.panel-label {
color: #1d4ed8;
font-size: 22rpx;
font-weight: 800;
}
.panel-value {
margin: 8rpx 0;
color: #111827;
font-size: 30rpx;
font-weight: 800;
}
.purchase-card {
margin-bottom: 20rpx;
}
.form-title {
margin-bottom: 20rpx;
}
.radio-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12rpx;
margin-bottom: 20rpx;
}
.radio {
min-height: 72rpx;
display: flex;
align-items: center;
padding: 0 14rpx;
color: #334155;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
border-radius: 14rpx;
font-size: 24rpx;
}

View File

@@ -1,14 +1,29 @@
<view class="page">
<view class="section-title">今日团购</view>
<view wx:for="{{groupBuys}}" wx:key="id" class="card" bindtap="goDetail" data-id="{{item.id}}">
<view class="row between">
<view>
<view>{{item.title}}</view>
<view class="muted">截单 {{item.endTime}}</view>
</view>
<view class="price">¥{{item.priceCent / 100}}</view>
</view>
<view class="muted">库存 {{item.stock}} / 已售 {{item.soldCount}}</view>
<view class="page-heading">
<view class="heading-eyebrow">社区拼单</view>
<view class="heading-title">今日团购</view>
<view class="heading-subtitle">集中采购,邻里共享实惠</view>
</view>
<view wx:if="{{!groupBuys.length && !loading}}" class="card muted">暂无团购</view>
<view class="group-list">
<view wx:for="{{groupBuys}}" wx:key="id" class="group-card" bindtap="goDetail" data-id="{{item.id}}">
<image wx:if="{{item.coverUrl}}" class="group-cover" src="{{item.coverUrl}}" mode="aspectFill" />
<view wx:else class="group-cover media-placeholder">团</view>
<view class="group-body">
<view class="row between">
<view class="card-title">{{item.title}}</view>
<view class="price">¥{{item.priceCent / 100}}</view>
</view>
<view class="muted">截单 {{item.endTime}}</view>
<view class="stock-line">
<view class="stock-track">
<view class="stock-fill"></view>
</view>
<view class="muted">库存 {{item.stock}} / 已售 {{item.soldCount}}</view>
</view>
</view>
</view>
</view>
<view wx:if="{{!groupBuys.length && !loading}}" class="empty-state">暂无团购</view>
</view>

View File

@@ -1,4 +1,51 @@
.page-heading {
margin-bottom: 24rpx;
}
.group-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.group-card {
overflow: hidden;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.group-cover {
width: 100%;
height: 280rpx;
}
.group-body {
padding: 22rpx;
}
.price {
min-width: 120rpx;
text-align: right;
font-size: 34rpx;
}
.stock-line {
margin-top: 18rpx;
}
.stock-track {
height: 12rpx;
overflow: hidden;
margin-bottom: 12rpx;
background: #edf2f7;
border-radius: 999rpx;
}
.stock-fill {
width: 46%;
height: 100%;
background: #0f766e;
border-radius: 999rpx;
}

View File

@@ -1,57 +1,126 @@
<view class="page">
<view class="section">
<view class="section-title">邻小帮</view>
<view class="muted">社区服务 30 分钟到家</view>
<view class="home-hero">
<view>
<view class="hero-eyebrow">社区生活服务</view>
<view class="hero-title">邻小帮</view>
<view class="hero-subtitle">商品预定、快递代取、团购闲置都在这里</view>
</view>
<view class="hero-badge">30 分钟到家</view>
</view>
<view class="grid section">
<view class="shortcut" bindtap="go" data-url="/pages/products/index">商品预定</view>
<view class="shortcut" bindtap="go" data-url="/pages/express/create">代取快递</view>
<view class="shortcut" bindtap="goTab" data-url="/pages/group-buy/index">今日团购</view>
<view class="shortcut" bindtap="goTab" data-url="/pages/second-hand/index">二手闲置</view>
<view class="shortcut" bindtap="go" data-url="/pages/notices/index">社区公告</view>
<view class="shortcut" bindtap="go" data-url="/pages/orders/index">我的订单</view>
<view class="home-search" bindtap="go" data-url="/pages/products/index">
<text>搜索社区商品、团购、闲置</text>
<text class="search-action">去看看</text>
</view>
<swiper wx:if="{{banners.length}}" class="banner-swiper" indicator-dots autoplay circular>
<swiper-item wx:for="{{banners}}" wx:key="id">
<view class="banner-card">
<image wx:if="{{item.imageUrl}}" src="{{item.imageUrl}}" mode="aspectFill" />
<view wx:else class="banner-fallback">邻小帮</view>
<view class="banner-mask">
<view class="banner-title">{{item.title}}</view>
</view>
</view>
</swiper-item>
</swiper>
<view class="service-grid section">
<view class="shortcut service-card service-product" bindtap="go" data-url="/pages/products/index">
<view class="service-icon">货</view>
<view class="service-name">商品预定</view>
</view>
<view class="shortcut service-card service-express" bindtap="go" data-url="/pages/express/create">
<view class="service-icon">快</view>
<view class="service-name">代取快递</view>
</view>
<view class="shortcut service-card service-group" bindtap="goTab" data-url="/pages/group-buy/index">
<view class="service-icon">团</view>
<view class="service-name">今日团购</view>
</view>
<view class="shortcut service-card service-second" bindtap="goTab" data-url="/pages/second-hand/index">
<view class="service-icon">闲</view>
<view class="service-name">二手闲置</view>
</view>
<view class="shortcut service-card service-notice" bindtap="go" data-url="/pages/notices/index">
<view class="service-icon">告</view>
<view class="service-name">社区公告</view>
</view>
<view class="shortcut service-card service-order" bindtap="go" data-url="/pages/orders/index">
<view class="service-icon">单</view>
<view class="service-name">我的订单</view>
</view>
</view>
<view class="section">
<view class="section-title">公告</view>
<view wx:for="{{notices}}" wx:key="id" class="card" bindtap="go" data-url="/pages/notices/index">
<view>{{item.title}}</view>
<view class="section-header">
<view class="section-title">公告</view>
<view class="pill" bindtap="go" data-url="/pages/notices/index">全部</view>
</view>
<view wx:for="{{notices}}" wx:key="id" class="content-card notice-brief" bindtap="go" data-url="/pages/notices/index">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.category}}</view>
</view>
<view wx:if="{{!notices.length}}" class="card muted">暂无公告</view>
<view wx:if="{{!notices.length}}" class="empty-state">暂无公告</view>
</view>
<view class="section">
<view class="section-title">热门商品</view>
<view wx:for="{{products}}" wx:key="id" class="card" bindtap="go" data-url="/pages/products/detail?id={{item.id}}">
<view class="row between">
<view>{{item.name}}</view>
<view class="section-header">
<view class="section-title">热门商品</view>
<view class="pill" bindtap="go" data-url="/pages/products/index">去选购</view>
</view>
<view wx:for="{{products}}" wx:key="id" class="content-card product-brief" bindtap="go" data-url="/pages/products/detail?id={{item.id}}">
<view class="brief-main">
<image wx:if="{{item.coverUrl}}" class="brief-cover" src="{{item.coverUrl}}" mode="aspectFill" />
<view wx:else class="brief-cover media-placeholder">货</view>
<view class="brief-body">
<view class="card-title">{{item.name}}</view>
<view class="muted">{{item.description || '社区精选商品'}}</view>
</view>
</view>
<view class="brief-side">
<view class="tag">{{item.status}}</view>
<view wx:if="{{item.skus.length}}" class="price">¥{{item.skus[0].priceCent / 100}} 起</view>
</view>
<view class="muted">{{item.description || '社区商品'}}</view>
</view>
<view wx:if="{{!products.length}}" class="empty-state">暂无热门商品</view>
</view>
<view class="section">
<view class="section-title">今日团购</view>
<view wx:for="{{groupBuys}}" wx:key="id" class="card" bindtap="go" data-url="/pages/group-buy/detail?id={{item.id}}">
<view class="row between">
<view>{{item.title}}</view>
<view class="price">¥{{item.priceCent / 100}}</view>
</view>
<view class="muted">已售 {{item.soldCount}} / 库存 {{item.stock}}</view>
<view class="section-header">
<view class="section-title">今日团购</view>
<view class="pill" bindtap="goTab" data-url="/pages/group-buy/index">更多</view>
</view>
<view wx:for="{{groupBuys}}" wx:key="id" class="content-card group-brief" bindtap="go" data-url="/pages/group-buy/detail?id={{item.id}}">
<view class="brief-main">
<image wx:if="{{item.coverUrl}}" class="brief-cover" src="{{item.coverUrl}}" mode="aspectFill" />
<view wx:else class="brief-cover media-placeholder">团</view>
<view class="brief-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">已售 {{item.soldCount}} / 库存 {{item.stock}}</view>
</view>
</view>
<view class="price">¥{{item.priceCent / 100}}</view>
</view>
<view wx:if="{{!groupBuys.length}}" class="empty-state">暂无团购</view>
</view>
<view class="section">
<view class="section-title">最新闲置</view>
<view wx:for="{{secondGoods}}" wx:key="id" class="card">
<view class="row between">
<view>{{item.title}}</view>
<view class="price">¥{{item.priceCent / 100}}</view>
</view>
<view class="muted">{{item.category}}</view>
<view class="section-header">
<view class="section-title">最新闲置</view>
<view class="pill" bindtap="goTab" data-url="/pages/second-hand/index">逛闲置</view>
</view>
<view wx:for="{{secondGoods}}" wx:key="id" class="content-card second-brief">
<view class="brief-main">
<image wx:if="{{item.imageUrls.length}}" class="brief-cover" src="{{item.imageUrls[0]}}" mode="aspectFill" />
<view wx:else class="brief-cover media-placeholder">闲</view>
<view class="brief-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.category}}</view>
</view>
</view>
<view class="price">¥{{item.priceCent / 100}}</view>
</view>
<view wx:if="{{!secondGoods.length}}" class="empty-state">暂无闲置</view>
</view>
</view>

View File

@@ -1,3 +1,218 @@
.page {
padding-bottom: 40rpx;
padding-bottom: 56rpx;
}
.home-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
min-height: 220rpx;
padding: 30rpx;
margin-bottom: 20rpx;
color: #ffffff;
background: #0f766e;
border-radius: 16rpx;
box-shadow: 0 14rpx 34rpx rgba(15, 118, 110, 0.22);
}
.hero-eyebrow {
display: inline-flex;
height: 42rpx;
align-items: center;
padding: 0 14rpx;
background: rgba(255, 255, 255, 0.16);
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.hero-title {
margin-top: 22rpx;
font-size: 48rpx;
font-weight: 900;
line-height: 1.1;
}
.hero-subtitle {
margin-top: 14rpx;
color: rgba(255, 255, 255, 0.86);
font-size: 25rpx;
}
.hero-badge {
flex-shrink: 0;
padding: 10rpx 16rpx;
color: #854d0e;
background: #fef3c7;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 800;
}
.home-search {
display: flex;
align-items: center;
justify-content: space-between;
height: 82rpx;
padding: 0 24rpx;
margin-bottom: 20rpx;
color: #64748b;
background: #ffffff;
border: 1rpx solid #dfe7ee;
border-radius: 16rpx;
}
.search-action {
color: #0f766e;
font-weight: 800;
}
.banner-swiper {
height: 220rpx;
margin-bottom: 24rpx;
}
.banner-card {
position: relative;
height: 220rpx;
overflow: hidden;
border-radius: 16rpx;
background: #33536f;
}
.banner-card image {
width: 100%;
height: 100%;
}
.banner-fallback {
height: 100%;
display: flex;
align-items: center;
padding-left: 32rpx;
color: #ffffff;
background: #33536f;
font-size: 36rpx;
font-weight: 900;
}
.banner-mask {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 34rpx 28rpx 24rpx;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(17, 24, 39, 0.68));
}
.banner-title {
color: #ffffff;
font-size: 30rpx;
font-weight: 800;
}
.service-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.service-card {
width: 100%;
height: 150rpx;
align-items: flex-start;
padding: 20rpx;
border: 1rpx solid #e2e8f0;
box-shadow: 0 8rpx 20rpx rgba(20, 36, 58, 0.05);
}
.service-icon {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14rpx;
color: #ffffff;
border-radius: 14rpx;
font-size: 24rpx;
font-weight: 900;
}
.service-name {
color: #111827;
font-size: 26rpx;
font-weight: 800;
}
.service-product .service-icon {
background: #0f766e;
}
.service-express .service-icon {
background: #2563eb;
}
.service-group .service-icon {
background: #c2410c;
}
.service-second .service-icon {
background: #7c3aed;
}
.service-notice .service-icon {
background: #33536f;
}
.service-order .service-icon {
background: #be123c;
}
.content-card {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 22rpx;
margin-bottom: 16rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.notice-brief {
display: block;
}
.brief-main {
flex: 1;
min-width: 0;
display: flex;
gap: 18rpx;
}
.brief-cover {
width: 112rpx;
height: 112rpx;
flex-shrink: 0;
overflow: hidden;
border-radius: 14rpx;
}
.brief-body {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8rpx;
}
.brief-side {
min-width: 112rpx;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
gap: 14rpx;
}

View File

@@ -1,12 +1,23 @@
<view class="page">
<view class="section-title">社区公告</view>
<view wx:for="{{notices}}" wx:key="id" class="card">
<view class="row between">
<view>{{item.title}}</view>
<view wx:if="{{item.pinned}}" class="tag">置顶</view>
</view>
<view class="muted">{{item.category}} {{item.publishedAt}}</view>
<view>{{item.content}}</view>
<view class="page-heading">
<view class="heading-eyebrow">社区信息</view>
<view class="heading-title">社区公告</view>
<view class="heading-subtitle">物业通知、活动消息和服务说明</view>
</view>
<view wx:if="{{!notices.length && !loading}}" class="card muted">暂无公告</view>
<view class="notice-list">
<view wx:for="{{notices}}" wx:key="id" class="notice-card">
<view class="row between notice-head">
<view class="card-title">{{item.title}}</view>
<view wx:if="{{item.pinned}}" class="tag">置顶</view>
</view>
<view class="notice-meta">
<view class="pill">{{item.category}}</view>
<view class="muted">{{item.publishedAt}}</view>
</view>
<view class="notice-content">{{item.content}}</view>
</view>
</view>
<view wx:if="{{!notices.length && !loading}}" class="empty-state">暂无公告</view>
</view>

View File

@@ -1,4 +1,37 @@
.card > view:last-child {
margin-top: 12rpx;
line-height: 44rpx;
.page-heading {
margin-bottom: 22rpx;
}
.notice-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.notice-card {
padding: 24rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.notice-head {
align-items: flex-start;
gap: 16rpx;
}
.notice-meta {
display: flex;
align-items: center;
gap: 12rpx;
margin: 16rpx 0;
}
.notice-content {
color: #334155;
font-size: 27rpx;
line-height: 46rpx;
padding-top: 18rpx;
border-top: 1rpx solid #edf1f5;
}

View File

@@ -1,29 +1,34 @@
<view class="page" wx:if="{{order}}">
<view class="card">
<view class="section-title">{{order.orderNo}}</view>
<view class="summary-card">
<view>
<view class="muted">订单编号</view>
<view class="summary-title">{{order.orderNo}}</view>
<view class="muted">类型:{{type}}</view>
</view>
<view class="tag">{{order.statusText}}</view>
<view class="muted">类型:{{type}}</view>
</view>
<view class="card">
<view class="detail-card">
<view wx:if="{{type === 'goods'}}">
<view>商品金额:{{order.totalAmountText}}</view>
<view>配送费:{{order.deliveryFeeText}}</view>
<view class="price">应收:{{order.payableAmountText}}</view>
<view class="detail-line"><text>商品金额</text><text>{{order.totalAmountText}}</text></view>
<view class="detail-line"><text>配送费</text><text>{{order.deliveryFeeText}}</text></view>
<view class="detail-line total"><text>应收</text><text class="price">{{order.payableAmountText}}</text></view>
<view class="muted">{{order.deliveryMethod}}</view>
</view>
<view wx:if="{{type === 'express'}}">
<view>{{order.expressCompany}} / {{order.pickupCode}}</view>
<view class="muted">{{order.pickupAddress}}</view>
<view class="price">服务费:{{order.feeText}}</view>
<view class="detail-line"><text>快递信息</text><text>{{order.expressCompany}} / {{order.pickupCode}}</text></view>
<view class="detail-line"><text>取件地址</text><text>{{order.pickupAddress}}</text></view>
<view class="detail-line total"><text>服务费</text><text class="price">{{order.feeText}}</text></view>
</view>
<view wx:if="{{type === 'group'}}">
<view>{{order.groupBuyTitle}}</view>
<view>数量:{{order.quantity}}</view>
<view class="price">金额:{{order.amountText}}</view>
<view class="detail-line"><text>团购商品</text><text>{{order.groupBuyTitle}}</text></view>
<view class="detail-line"><text>数量</text><text>{{order.quantity}}</text></view>
<view class="detail-line total"><text>金额</text><text class="price">{{order.amountText}}</text></view>
</view>
</view>
<view class="button-row">
<button class="ghost" bindtap="cancel">取消订单</button>
<button wx:if="{{type === 'express'}}" class="primary" bindtap="completeExpress">确认完成</button>
<button class="ghost action-button" bindtap="cancel">取消订单</button>
<button wx:if="{{type === 'express'}}" class="primary action-button" bindtap="completeExpress">确认完成</button>
</view>
</view>

View File

@@ -1,3 +1,58 @@
.summary-card {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 28rpx;
margin-bottom: 18rpx;
color: #ffffff;
background: #33536f;
border-radius: 16rpx;
}
.summary-card .muted {
color: rgba(255, 255, 255, 0.76);
}
.summary-title {
margin: 10rpx 0;
font-size: 36rpx;
font-weight: 900;
line-height: 1.2;
}
.detail-card {
padding: 24rpx;
margin-bottom: 20rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.detail-line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
padding: 18rpx 0;
color: #334155;
border-bottom: 1rpx solid #edf1f5;
}
.detail-line text:first-child {
flex-shrink: 0;
color: #64748b;
}
.detail-line text:last-child {
text-align: right;
}
.detail-line.total {
border-bottom: none;
font-weight: 800;
}
.button-row button {
flex: 1;
}

View File

@@ -1,17 +1,25 @@
<view class="page">
<view class="section-title">我的订单</view>
<view wx:for="{{orders}}" wx:key="id" class="card" bindtap="goDetail" data-type="{{item.type}}" data-id="{{item.id}}">
<view class="row between">
<view>
<view>{{item.title}}</view>
<view class="muted">{{item.typeText}} {{item.orderNo}}</view>
<view class="page-heading">
<view class="heading-eyebrow">履约进度</view>
<view class="heading-title">我的订单</view>
<view class="heading-subtitle">查看商品、快递和团购订单</view>
</view>
<view class="order-list">
<view wx:for="{{orders}}" wx:key="id" class="order-card" bindtap="goDetail" data-type="{{item.type}}" data-id="{{item.id}}">
<view class="row between order-head">
<view class="order-title-wrap">
<view class="card-title">{{item.title}}</view>
<view class="order-meta">{{item.typeText}} {{item.orderNo}}</view>
</view>
<view class="tag">{{item.statusText}}</view>
</view>
<view class="order-footer row between">
<view class="muted">状态:{{item.statusText}}</view>
<view class="price">{{item.amountText}}</view>
</view>
<view class="tag">{{item.statusText}}</view>
</view>
<view class="row between">
<view class="muted">状态:{{item.statusText}}</view>
<view class="price">{{item.amountText}}</view>
</view>
</view>
<view wx:if="{{!orders.length && !loading}}" class="card muted">暂无订单</view>
<view wx:if="{{!orders.length && !loading}}" class="empty-state">暂无订单</view>
</view>

View File

@@ -1,3 +1,42 @@
.page-heading {
margin-bottom: 22rpx;
}
.order-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.order-card {
padding: 24rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.order-head {
align-items: flex-start;
gap: 18rpx;
}
.order-title-wrap {
min-width: 0;
}
.order-meta {
margin-top: 8rpx;
color: #64748b;
font-size: 23rpx;
}
.order-footer {
margin-top: 20rpx;
padding-top: 18rpx;
border-top: 1rpx solid #edf1f5;
}
.tag {
min-width: 120rpx;
text-align: center;

View File

@@ -0,0 +1,68 @@
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
function readPage(page, ext, name = 'index') {
return fs.readFileSync(path.join(root, 'pages', page, `${name}.${ext}`), 'utf8')
}
function readPageFile(page, name, ext) {
return fs.readFileSync(path.join(root, 'pages', page, `${name}.${ext}`), 'utf8')
}
function selectorExists(wxss, selector) {
return new RegExp(`${selector.replace('.', '\\.')}\\s*[{,]`).test(wxss)
}
describe('miniapp page styling', () => {
const pageStyleExpectations = [
['home', 'index', ['.home-hero', '.service-grid', '.banner-card', '.content-card']],
['products', 'index', ['.page-heading', '.product-card', '.product-thumb', '.category-item']],
['express', 'create', ['.page-heading', '.form-card', '.fee-panel', '.field-label']],
['group-buy', 'index', ['.page-heading', '.group-card', '.group-cover', '.stock-line']],
['second-hand', 'index', ['.page-heading', '.second-card', '.second-cover', '.publish-button']],
['notices', 'index', ['.page-heading', '.notice-card', '.notice-content']],
['orders', 'index', ['.page-heading', '.order-card', '.order-meta']],
['profile', 'index', ['.profile-hero', '.menu-card', '.menu-icon']],
['address', 'index', ['.page-heading', '.address-card', '.address-form']]
]
test.each(pageStyleExpectations)('%s page has dedicated visual styles', (page, name, selectors) => {
const wxss = readPage(page, 'wxss', name)
expect(wxss.length).toBeGreaterThan(300)
selectors.forEach((selector) => {
expect(selectorExists(wxss, selector)).toBe(true)
})
})
test('detail pages have order and purchase layouts', () => {
const productDetail = readPageFile('products', 'detail', 'wxss')
const groupDetail = readPageFile('group-buy', 'detail', 'wxss')
const orderDetail = readPageFile('orders', 'detail', 'wxss')
expect(selectorExists(productDetail, '.detail-hero')).toBe(true)
expect(selectorExists(productDetail, '.purchase-card')).toBe(true)
expect(selectorExists(groupDetail, '.detail-hero')).toBe(true)
expect(selectorExists(groupDetail, '.purchase-card')).toBe(true)
expect(selectorExists(orderDetail, '.summary-card')).toBe(true)
expect(selectorExists(orderDetail, '.detail-line')).toBe(true)
})
test('global stylesheet provides shared design primitives', () => {
const appWxss = fs.readFileSync(path.join(root, 'app.wxss'), 'utf8')
;[
'.page-heading',
'.card-title',
'.empty-state',
'.form-card',
'.action-button',
'.pill',
'.media-placeholder'
].forEach((selector) => {
expect(selectorExists(appWxss, selector)).toBe(true)
})
})
})

View File

@@ -1,34 +1,48 @@
<view class="page" wx:if="{{product}}">
<view class="card">
<view class="section-title">{{product.name}}</view>
<view class="muted">{{product.description || '社区商品'}}</view>
<view class="page detail-page" wx:if="{{product}}">
<view class="detail-hero">
<image wx:if="{{product.coverUrl}}" class="detail-cover" src="{{product.coverUrl}}" mode="aspectFill" />
<view wx:else class="detail-cover media-placeholder">货</view>
<view class="detail-info">
<view class="tag">{{product.status}}</view>
<view class="detail-title">{{product.name}}</view>
<view class="muted">{{product.description || '社区商品'}}</view>
<view wx:if="{{product.skus.length}}" class="detail-price">¥{{product.skus[skuIndex].priceCent / 100}}</view>
</view>
</view>
<view class="card">
<view class="muted">规格</view>
<view class="purchase-card form-card" wx:if="{{product.skus.length}}">
<view class="card-title form-title">下单信息</view>
<view class="field-label">规格</view>
<picker mode="selector" range="{{product.skus}}" range-key="skuName" value="{{skuIndex}}" bindchange="onSkuChange">
<view class="picker">{{product.skus[skuIndex].skuName}} / ¥{{product.skus[skuIndex].priceCent / 100}} / 库存 {{product.skus[skuIndex].stock}}</view>
</picker>
<view class="muted">数量</view>
<view class="field-label">数量</view>
<input class="input" type="number" value="{{form.quantity}}" bindinput="onQuantityChange" />
<view class="muted">配送方式</view>
<radio-group bindchange="onMethodChange">
<view class="field-label">配送方式</view>
<radio-group class="radio-grid" bindchange="onMethodChange">
<label class="radio"><radio value="IMMEDIATE" checked="{{form.deliveryMethod === 'IMMEDIATE'}}" />立即配送</label>
<label class="radio"><radio value="SCHEDULED" checked="{{form.deliveryMethod === 'SCHEDULED'}}" />预约配送</label>
<label class="radio"><radio value="SELF_PICKUP" checked="{{form.deliveryMethod === 'SELF_PICKUP'}}" />自提</label>
</radio-group>
<view wx:if="{{form.deliveryMethod !== 'SELF_PICKUP'}}">
<view class="muted">收货地址</view>
<view class="field-label">收货地址</view>
<picker mode="selector" range="{{addresses}}" range-key="detail" value="{{addressIndex}}" bindchange="onAddressChange">
<view class="picker">{{addresses[addressIndex] ? addresses[addressIndex].building + ' ' + addresses[addressIndex].room + ' ' + addresses[addressIndex].detail : '请选择地址'}}</view>
</picker>
</view>
<view wx:if="{{form.deliveryMethod === 'SCHEDULED'}}">
<view class="muted">预约时间</view>
<view class="field-label">预约时间</view>
<input class="input" placeholder="2026-07-07T18:30:00" bindinput="onScheduledTimeChange" />
</view>
<view class="muted">备注</view>
<textarea class="textarea" bindinput="onRemarkInput" />
<button class="primary" loading="{{submitting}}" bindtap="submit">提交订单</button>
<view class="field-label">备注</view>
<textarea class="textarea" placeholder="口味、送达说明等" bindinput="onRemarkInput" />
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">提交订单</button>
</view>
<view wx:else class="empty-state">暂无可选规格</view>
</view>

View File

@@ -1,5 +1,63 @@
.radio {
display: block;
height: 60rpx;
line-height: 60rpx;
.detail-page {
padding-bottom: 60rpx;
}
.detail-hero {
overflow: hidden;
margin-bottom: 22rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.detail-cover {
width: 100%;
height: 360rpx;
}
.detail-info {
padding: 26rpx;
}
.detail-title {
margin-top: 14rpx;
color: #111827;
font-size: 40rpx;
font-weight: 900;
line-height: 1.2;
}
.detail-price {
margin-top: 18rpx;
color: #c2410c;
font-size: 42rpx;
font-weight: 900;
}
.purchase-card {
margin-bottom: 20rpx;
}
.form-title {
margin-bottom: 20rpx;
}
.radio-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin-bottom: 20rpx;
}
.radio {
min-height: 72rpx;
display: flex;
align-items: center;
padding: 0 12rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
border-radius: 14rpx;
color: #334155;
font-size: 24rpx;
}

View File

@@ -5,6 +5,7 @@ Page({
loading: false,
categories: [],
activeCategoryId: null,
keyword: '',
products: []
},
@@ -14,7 +15,7 @@ Page({
load() {
this.setData({ loading: true })
Promise.all([listCategories(), listProducts()])
Promise.all([listCategories(), listProducts(null, this.data.keyword)])
.then(([categories, products]) => {
this.setData({
categories: categories || [],
@@ -28,14 +29,24 @@ Page({
chooseCategory(event) {
const categoryId = event.currentTarget.dataset.id
this.setData({ activeCategoryId: categoryId })
listProducts(categoryId)
listProducts(categoryId, this.data.keyword)
.then((products) => this.setData({ products: products || [] }))
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
},
clearCategory() {
this.setData({ activeCategoryId: null })
listProducts()
listProducts(null, this.data.keyword)
.then((products) => this.setData({ products: products || [] }))
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
},
onKeywordInput(event) {
this.setData({ keyword: event.detail.value })
},
search() {
listProducts(this.data.activeCategoryId, this.data.keyword)
.then((products) => this.setData({ products: products || [] }))
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
},

View File

@@ -1,5 +1,15 @@
<view class="page">
<view class="section-title">商品预定</view>
<view class="page-heading">
<view class="heading-eyebrow">社区便利店</view>
<view class="heading-title">商品预定</view>
<view class="heading-subtitle">挑好商品,社区内配送或自提</view>
</view>
<view class="search-row">
<input class="input search-input" placeholder="搜索商品" value="{{keyword}}" bindinput="onKeywordInput" confirm-type="search" bindconfirm="search" />
<button size="mini" class="primary search-button" bindtap="search">搜索</button>
</view>
<scroll-view scroll-x class="category-scroll">
<view class="category-item {{!activeCategoryId ? 'active' : ''}}" bindtap="clearCategory">全部</view>
<view
@@ -13,16 +23,24 @@
</view>
</scroll-view>
<view wx:for="{{products}}" wx:key="id" class="card" bindtap="goDetail" data-id="{{item.id}}">
<view class="row between">
<view>
<view>{{item.name}}</view>
<view class="muted">{{item.description || '社区精选商品'}}</view>
<view class="product-list">
<view wx:for="{{products}}" wx:key="id" class="product-card" bindtap="goDetail" data-id="{{item.id}}">
<image wx:if="{{item.coverUrl}}" class="product-thumb" src="{{item.coverUrl}}" mode="aspectFill" />
<view wx:else class="product-thumb media-placeholder">货</view>
<view class="product-info">
<view class="row between">
<view class="card-title">{{item.name}}</view>
<view class="tag">{{item.status}}</view>
</view>
<view class="muted product-desc">{{item.description || '社区精选商品'}}</view>
<view class="row between product-footer">
<view wx:if="{{item.unitName}}" class="pill">{{item.unitName}}</view>
<view wx:else class="pill">社区好物</view>
<view wx:if="{{item.skus.length}}" class="price">¥{{item.skus[0].priceCent / 100}} 起</view>
</view>
</view>
<view class="tag">{{item.status}}</view>
</view>
<view wx:if="{{item.skus.length}}" class="price">¥{{item.skus[0].priceCent / 100}} 起</view>
</view>
<view wx:if="{{!products.length && !loading}}" class="card muted">暂无商品</view>
<view wx:if="{{!products.length && !loading}}" class="empty-state">暂无商品</view>
</view>

View File

@@ -1,6 +1,28 @@
.page-heading {
padding-bottom: 4rpx;
}
.category-scroll {
white-space: nowrap;
margin-bottom: 20rpx;
margin-bottom: 24rpx;
}
.search-row {
display: flex;
gap: 14rpx;
align-items: center;
margin-bottom: 18rpx;
}
.search-input {
flex: 1;
margin: 0;
}
.search-button {
width: 132rpx;
margin: 0;
min-height: 80rpx;
}
.category-item {
@@ -8,12 +30,15 @@
align-items: center;
justify-content: center;
min-width: 120rpx;
height: 64rpx;
padding: 0 20rpx;
height: 68rpx;
padding: 0 24rpx;
margin-right: 12rpx;
background: #ffffff;
border: 1rpx solid #d9e0e8;
border-radius: 6rpx;
border-radius: 999rpx;
color: #334155;
font-size: 25rpx;
font-weight: 700;
}
.category-item.active {
@@ -21,3 +46,43 @@
background: #0f766e;
border-color: #0f766e;
}
.product-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.product-card {
display: flex;
gap: 20rpx;
padding: 20rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.product-thumb {
width: 160rpx;
height: 160rpx;
flex-shrink: 0;
overflow: hidden;
border-radius: 14rpx;
}
.product-info {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.product-desc {
min-height: 64rpx;
}
.product-footer {
margin-top: auto;
}

View File

@@ -1,11 +1,37 @@
<view class="page">
<view class="card">
<view class="section-title">我的</view>
<view class="muted">邻小帮居民端</view>
<view class="profile-hero">
<view class="avatar">邻</view>
<view>
<view class="profile-title">我的</view>
<view class="profile-subtitle">邻小帮居民端</view>
</view>
</view>
<view class="menu-list">
<view class="menu-card" bindtap="go" data-url="/pages/orders/index">
<view class="menu-icon icon-order">单</view>
<view class="menu-text">我的订单</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-card" bindtap="go" data-url="/pages/address/index">
<view class="menu-icon icon-address">址</view>
<view class="menu-text">地址管理</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-card" bindtap="go" data-url="/pages/express/create">
<view class="menu-icon icon-express">快</view>
<view class="menu-text">代取快递</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-card" bindtap="go" data-url="/pages/products/index">
<view class="menu-icon icon-product">货</view>
<view class="menu-text">商品预定</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-card" bindtap="go" data-url="/pages/notices/index">
<view class="menu-icon icon-notice">告</view>
<view class="menu-text">社区公告</view>
<view class="menu-arrow"></view>
</view>
</view>
<view class="card menu" bindtap="go" data-url="/pages/orders/index">我的订单</view>
<view class="card menu" bindtap="go" data-url="/pages/address/index">地址管理</view>
<view class="card menu" bindtap="go" data-url="/pages/express/create">代取快递</view>
<view class="card menu" bindtap="go" data-url="/pages/products/index">商品预定</view>
<view class="card menu" bindtap="go" data-url="/pages/notices/index">社区公告</view>
</view>

View File

@@ -1,3 +1,99 @@
.menu {
font-weight: 600;
.profile-hero {
display: flex;
align-items: center;
gap: 22rpx;
padding: 30rpx;
margin-bottom: 22rpx;
color: #ffffff;
background: #0f766e;
border-radius: 16rpx;
box-shadow: 0 14rpx 34rpx rgba(15, 118, 110, 0.2);
}
.avatar {
width: 96rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
color: #0f766e;
background: #ffffff;
border-radius: 50%;
font-size: 34rpx;
font-weight: 900;
}
.profile-title {
font-size: 40rpx;
font-weight: 900;
line-height: 1.2;
}
.profile-subtitle {
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 24rpx;
}
.menu-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.menu-card {
display: flex;
align-items: center;
gap: 18rpx;
min-height: 104rpx;
padding: 20rpx 22rpx;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.menu-icon {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
border-radius: 16rpx;
font-size: 24rpx;
font-weight: 900;
}
.menu-text {
flex: 1;
color: #111827;
font-size: 29rpx;
font-weight: 800;
}
.menu-arrow {
color: #94a3b8;
font-size: 42rpx;
line-height: 1;
}
.icon-order {
background: #be123c;
}
.icon-address {
background: #33536f;
}
.icon-express {
background: #2563eb;
}
.icon-product {
background: #0f766e;
}
.icon-notice {
background: #c2410c;
}

View File

@@ -1,10 +1,10 @@
const { createSecondGoods } = require('../../api/secondGoodsApi')
const { setField } = require('../../utils/request')
const { setField, uploadFile } = require('../../utils/request')
Page({
data: {
submitting: false,
imageText: '',
uploading: false,
form: {
title: '',
priceCent: 0,
@@ -28,14 +28,43 @@ Page({
this.setData({ 'form.tradeMethod': event.detail.value })
},
onImageInput(event) {
const imageText = event.detail.value
this.setData({
imageText,
'form.imageUrls': imageText ? imageText.split(',').map((item) => item.trim()).filter(Boolean) : []
chooseImages() {
if (this.data.uploading) {
return
}
wx.chooseMedia({
count: Math.max(1, 6 - this.data.form.imageUrls.length),
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (result) => {
const files = result.tempFiles || []
this.uploadImages(files.map((item) => item.tempFilePath))
}
})
},
uploadImages(filePaths) {
if (!filePaths.length) {
return
}
this.setData({ uploading: true })
Promise.all(filePaths.map((filePath) => uploadFile(filePath)))
.then((results) => {
const urls = results.map((item) => item.url).filter(Boolean)
this.setData({
'form.imageUrls': this.data.form.imageUrls.concat(urls).slice(0, 6)
})
})
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
.finally(() => this.setData({ uploading: false }))
},
removeImage(event) {
const index = Number(event.currentTarget.dataset.index)
const next = this.data.form.imageUrls.filter((item, itemIndex) => itemIndex !== index)
this.setData({ 'form.imageUrls': next })
},
submit() {
if (this.data.submitting) {
return

View File

@@ -1,16 +1,40 @@
<view class="page">
<view class="section-title">发布闲置</view>
<view class="card">
<view class="page-heading">
<view class="heading-eyebrow">发布审核</view>
<view class="heading-title">发布闲置</view>
<view class="heading-subtitle">填写真实信息,审核通过后展示给邻居</view>
</view>
<view class="form-card publish-form">
<view class="field-label">标题</view>
<input class="input" placeholder="标题" data-field="title" bindinput="onFieldInput" />
<view class="field-label">价格</view>
<input class="input" placeholder="价格(元)" type="digit" bindinput="onPriceInput" />
<view class="field-label">分类</view>
<input class="input" placeholder="分类" data-field="category" bindinput="onFieldInput" />
<view class="field-label">联系电话</view>
<input class="input" placeholder="联系电话" data-field="contactPhone" bindinput="onFieldInput" />
<radio-group bindchange="onTradeMethodChange">
<view class="field-label">交易方式</view>
<radio-group class="radio-grid" bindchange="onTradeMethodChange">
<label class="radio"><radio value="FACE_TO_FACE" checked="{{form.tradeMethod === 'FACE_TO_FACE'}}" />面交</label>
<label class="radio"><radio value="DELIVERY" checked="{{form.tradeMethod === 'DELIVERY'}}" />可配送</label>
</radio-group>
<input class="input" placeholder="图片 URL多个用英文逗号分隔" value="{{imageText}}" bindinput="onImageInput" />
<view class="field-label">图片</view>
<view class="image-grid">
<view wx:for="{{form.imageUrls}}" wx:key="*this" class="image-item">
<image src="{{item}}" mode="aspectFill" />
<button size="mini" class="remove" data-index="{{index}}" bindtap="removeImage">删除</button>
</view>
<button wx:if="{{form.imageUrls.length < 6}}" class="image-picker" loading="{{uploading}}" bindtap="chooseImages">上传图片</button>
</view>
<view class="field-label">描述</view>
<textarea class="textarea" placeholder="描述" data-field="description" bindinput="onFieldInput" />
<button class="primary" loading="{{submitting}}" bindtap="submit">提交审核</button>
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">提交审核</button>
</view>
</view>

View File

@@ -1,5 +1,78 @@
.radio {
display: block;
height: 60rpx;
line-height: 60rpx;
.page-heading {
margin-bottom: 22rpx;
}
.publish-form {
padding-bottom: 28rpx;
}
.radio-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12rpx;
margin-bottom: 20rpx;
}
.radio {
min-height: 72rpx;
display: flex;
align-items: center;
padding: 0 14rpx;
color: #334155;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
border-radius: 14rpx;
font-size: 24rpx;
}
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin: 20rpx 0;
}
.image-item,
.image-picker {
width: 100%;
aspect-ratio: 1;
margin: 0;
}
.image-item {
position: relative;
overflow: hidden;
border-radius: 14rpx;
background: #eef3f7;
}
.image-item image {
width: 100%;
height: 100%;
}
.remove {
position: absolute;
right: 8rpx;
bottom: 8rpx;
margin: 0;
font-size: 22rpx;
color: #ffffff;
background: rgba(15, 23, 42, 0.62);
}
.image-picker {
display: flex;
align-items: center;
justify-content: center;
border: 1rpx dashed #8aa0b5;
color: #33536f;
background: #f8fafc;
border-radius: 14rpx;
font-size: 26rpx;
font-weight: 700;
}
.action-button {
margin-top: 8rpx;
}

View File

@@ -3,6 +3,7 @@ const { listSecondGoods } = require('../../api/secondGoodsApi')
Page({
data: {
loading: false,
keyword: '',
goods: []
},
@@ -16,12 +17,20 @@ Page({
load() {
this.setData({ loading: true })
listSecondGoods()
listSecondGoods(null, this.data.keyword)
.then((goods) => this.setData({ goods: goods || [] }))
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
.finally(() => this.setData({ loading: false }))
},
onKeywordInput(event) {
this.setData({ keyword: event.detail.value })
},
search() {
this.load()
},
goCreate() {
wx.navigateTo({ url: '/pages/second-hand/create' })
},

View File

@@ -1,18 +1,36 @@
<view class="page">
<view class="row between section">
<view class="section-title">二手闲置</view>
<button size="mini" class="primary" bindtap="goCreate">发布</button>
</view>
<view wx:for="{{goods}}" wx:key="id" class="card">
<view class="row between">
<view>
<view>{{item.title}}</view>
<view class="muted">{{item.category}} / {{item.tradeMethod}}</view>
</view>
<view class="price">¥{{item.priceCent / 100}}</view>
<view class="page-heading row between">
<view>
<view class="heading-eyebrow">邻里流转</view>
<view class="heading-title">二手闲置</view>
<view class="heading-subtitle">发布闲置,线下联系交易</view>
</view>
<view class="muted">{{item.description}}</view>
<button size="mini" class="ghost" data-phone="{{item.contactPhone}}" bindtap="callSeller">联系卖家</button>
<button size="mini" class="primary publish-button" bindtap="goCreate">发布</button>
</view>
<view wx:if="{{!goods.length && !loading}}" class="card muted">暂无闲置</view>
<view class="search-row">
<input class="input search-input" placeholder="搜索闲置" value="{{keyword}}" bindinput="onKeywordInput" confirm-type="search" bindconfirm="search" />
<button size="mini" class="primary search-button" bindtap="search">搜索</button>
</view>
<view class="second-grid">
<view wx:for="{{goods}}" wx:key="id" class="second-card">
<image wx:if="{{item.imageUrls.length}}" class="second-cover" src="{{item.imageUrls[0]}}" mode="aspectFill" />
<view wx:else class="second-cover media-placeholder">闲</view>
<view class="second-body">
<view class="card-title">{{item.title}}</view>
<view class="row between">
<view class="pill">{{item.category}}</view>
<view class="price">¥{{item.priceCent / 100}}</view>
</view>
<view class="muted second-desc">{{item.description}}</view>
<view class="row between">
<view class="muted">{{item.tradeMethod}}</view>
<button size="mini" class="ghost contact-button" data-phone="{{item.contactPhone}}" bindtap="callSeller">联系</button>
</view>
</view>
</view>
</view>
<view wx:if="{{!goods.length && !loading}}" class="empty-state">暂无闲置</view>
</view>

View File

@@ -1,3 +1,68 @@
.ghost {
margin-top: 16rpx;
.page-heading {
align-items: flex-start;
gap: 18rpx;
}
.search-row {
display: flex;
gap: 14rpx;
align-items: center;
margin-bottom: 22rpx;
}
.search-input {
flex: 1;
margin: 0;
}
.search-button {
width: 132rpx;
margin: 0;
min-height: 80rpx;
}
.publish-button {
width: 124rpx;
margin: 8rpx 0 0;
}
.second-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
}
.second-card {
overflow: hidden;
background: #ffffff;
border: 1rpx solid #e4eaf0;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
}
.second-cover {
width: 100%;
height: 210rpx;
}
.second-body {
padding: 18rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.second-desc {
min-height: 72rpx;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.contact-button {
min-width: 92rpx;
margin: 0;
padding: 0 12rpx;
}

View File

@@ -0,0 +1,28 @@
{
"appid": "wxf634a69e89290cc7",
"compileType": "miniprogram",
"libVersion": "3.16.2",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"coverView": true,
"es6": true,
"postcss": true,
"minified": true,
"enhance": true,
"showShadowRootInWxmlPanel": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"condition": {},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
}
}

View File

@@ -3,6 +3,13 @@ function ensureToken() {
if (app.globalData.token) {
return Promise.resolve(app.globalData.token)
}
if (app.globalData.useDevLogin) {
return devLogin(app)
}
return wxLogin(app)
}
function devLogin(app) {
return new Promise((resolve, reject) => {
wx.request({
url: app.globalData.apiBaseUrl + '/api/auth/dev-miniapp-login',
@@ -25,6 +32,37 @@ function ensureToken() {
})
}
function wxLogin(app) {
return new Promise((resolve, reject) => {
wx.login({
success(loginResult) {
if (!loginResult.code) {
reject(new Error('微信登录失败'))
return
}
wx.request({
url: app.globalData.apiBaseUrl + '/api/auth/miniapp-login',
method: 'POST',
data: {
code: loginResult.code
},
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300 && res.data && res.data.code === 0) {
app.globalData.token = res.data.data.token
app.globalData.user = res.data.data
resolve(app.globalData.token)
return
}
reject(new Error((res.data && res.data.message) || '登录失败'))
},
fail: reject
})
},
fail: reject
})
})
}
function request(options) {
const app = getApp()
return new Promise((resolve, reject) => {
@@ -50,6 +88,40 @@ function request(options) {
})
}
function uploadFile(filePath) {
const app = getApp()
return new Promise((resolve, reject) => {
ensureToken().then((token) => {
wx.uploadFile({
url: app.globalData.apiBaseUrl + '/api/files/upload',
filePath,
name: 'file',
header: {
Authorization: token,
satoken: token
},
success(res) {
let body = res.data
if (typeof body === 'string') {
try {
body = JSON.parse(body)
} catch (error) {
reject(new Error('上传失败'))
return
}
}
if (res.statusCode >= 200 && res.statusCode < 300 && body && body.code === 0) {
resolve(body.data)
return
}
reject(new Error((body && body.message) || '上传失败'))
},
fail: reject
})
}).catch(reject)
})
}
function yuan(cent) {
return ((cent || 0) / 100).toFixed(2)
}
@@ -85,6 +157,7 @@ function setField(page, event) {
module.exports = {
request,
uploadFile,
yuan,
statusText,
setField