更新若干功能

This commit is contained in:
2026-08-20 19:15:41 +08:00
parent 9e7f0fa1b5
commit 00b00e0c95
40 changed files with 1862 additions and 387 deletions

View File

@@ -61,6 +61,8 @@ export default {
).then((result) => {
setCache("token", result.token);
setCache("user_info", result.user_info);
// 详情页可能抢在登录前请求过,通知它们带 p_user_id 再绑一次
uni.$emit("wx-login-ready");
});
} else {
console.error("登录失败!" + loginRes.errMsg);
@@ -77,4 +79,8 @@ export default {
<style lang="scss">
@import "uview-plus/index.scss";
/* PC 限宽后两侧露出来的底,跟默认主题背景对齐 */
page {
background-color: #FAF7F2;
}
</style>

31
api/page/address.js Normal file
View File

@@ -0,0 +1,31 @@
import { get, post } from '../request'
const prefix = 'address/'
export const getAddressListApi = async () => {
return await get(`${prefix}list`)
}
export const getDefaultAddressApi = async () => {
return await get(`${prefix}default-one`)
}
export const getAddressDetailApi = async (id) => {
return await get(`${prefix}detail`, { id })
}
export const createAddressApi = async (params) => {
return await post(`${prefix}create`, params)
}
export const updateAddressApi = async (params) => {
return await post(`${prefix}update`, params)
}
export const deleteAddressApi = async (id) => {
return await post(`${prefix}delete`, { ids: [id] })
}
export const setDefaultAddressApi = async (id) => {
return await post(`${prefix}set-default`, { id })
}

11
api/page/afterSale.js Normal file
View File

@@ -0,0 +1,11 @@
import { get, post } from '../request'
const prefix = 'after-sale/'
export const getAfterSaleListApi = async () => {
return await get(`${prefix}list`)
}
export const createAfterSaleApi = async (params) => {
return await post(`${prefix}create`, params)
}

View File

@@ -48,3 +48,8 @@ export const updateItemApi = async (id, params = {}) => {
...params
})
}
/** 导出清单报价文本/CSV */
export const exportQuoteApi = async (id) => {
return await get(`${prefix}export-quote`, { id })
}

19
api/page/colorcard.js Normal file
View File

@@ -0,0 +1,19 @@
import { get } from '../request'
const prefix = 'colorcard/'
export const getColorcardListApi = async (params = {}) => {
return await get(`${prefix}list`, params)
}
export const getColorcardDetailApi = async (id) => {
return await get(`${prefix}detail`, { id })
}
export const getColorcardClassListApi = async () => {
return await get(`${prefix}class-list`)
}
export const getColorcardCompanyListApi = async () => {
return await get(`${prefix}company-list`)
}

15
api/page/factory.js Normal file
View File

@@ -0,0 +1,15 @@
import { get } from '../request'
const prefix = 'factory/'
export const getFactoryListApi = async (params = {}) => {
return await get(`${prefix}list`, params)
}
export const getFactoryDetailApi = async (id) => {
return await get(`${prefix}detail`, { id })
}
export const getFactoryClassListApi = async () => {
return await get(`${prefix}class-list`)
}

11
api/page/favorite.js Normal file
View File

@@ -0,0 +1,11 @@
import { get, post } from '../request'
const prefix = 'favorite/'
export const getFavoriteListApi = async () => {
return await get(`${prefix}list`)
}
export const toggleFavoriteApi = async (catalogueId) => {
return await post(`${prefix}toggle`, { catalogue_id: catalogueId })
}

11
api/page/feedback.js Normal file
View File

@@ -0,0 +1,11 @@
import { get, post } from '../request'
const prefix = 'feedback/'
export const getFeedbackListApi = async () => {
return await get(`${prefix}list`)
}
export const createFeedbackApi = async (params) => {
return await post(`${prefix}create`, params)
}

View File

@@ -15,3 +15,7 @@ export const updateNickNameApi = async (nick_name) => {
nick_name
})
}
export const getEnterpriseApi = async () => {
return await get(`${prefix}enterprise`)
}

View File

@@ -97,12 +97,15 @@ export default {
},
},
methods: {
/**
* 画布坐标按 340×420 设计稿写成百分比,列变窄时图层一起缩小,不会撑破格子。
*/
layerStyle(layer) {
return {
left: (layer.x || 0) + 'rpx',
top: (layer.y || 0) + 'rpx',
width: (layer.w || 40) + 'rpx',
height: (layer.h || 40) + 'rpx',
left: (((layer.x || 0) / 340) * 100) + '%',
top: (((layer.y || 0) / 420) * 100) + '%',
width: (((layer.w || 40) / 340) * 100) + '%',
height: (((layer.h || 40) / 420) * 100) + '%',
transform: 'rotate(' + (layer.rotate || 0) + 'deg)',
zIndex: layer.z || 1,
}
@@ -169,17 +172,22 @@ export default {
</script>
<style lang="scss" scoped>
.tcc-fallback {
width: 100%;
}
.tcc {
position: relative;
width: 100%;
height: 420rpx;
height: 0;
padding-bottom: 123.53%;
overflow: hidden;
}
.tcc-stage {
position: relative;
width: 340rpx;
height: 420rpx;
margin: 0 auto;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.tcc-layer {
position: absolute;

View File

@@ -1,8 +1,9 @@
<template>
<view class="tn">
<!-- PC 大屏不支持自定义顶栏留给微信拖拽区标题写到系统导航栏 -->
<view v-if="!pcMode" class="tn">
<view class="tn-fixed" :class="'tn--' + mode" :style="barStyle">
<view class="tn-status" :style="{ height: statusBarHeight + 'px' }" />
<view class="tn-inner" :style="{ height: innerHeight + 'px' }">
<view class="tn-status" :style="statusStyle" />
<view class="tn-inner" :style="innerStyle">
<view class="tn-left" @tap="onBack">
<u-icon v-if="showBack" name="arrow-left" :color="text" size="20" />
</view>
@@ -12,12 +13,13 @@
</view>
</view>
</view>
<view class="tn-ph" :style="{ height: navHeight + 'px' }" />
<view class="tn-ph" :style="phStyle" />
</view>
</template>
<script>
import { getActiveLayout, getActiveVars } from '@/utils/theme'
import { isPcWeixin } from '@/utils/device'
/**
* 自定义顶栏:色跟主题 token形态跟 chrome.navbar
@@ -32,6 +34,8 @@ export default {
return {
statusBarHeight: 20,
innerHeight: 44,
// 量不到胶囊时按常见胶囊宽度让开,避免右侧按钮被挡住
capsulePadRight: 96,
mode: 'solid',
surface: '#ffffff',
text: '#1C1917',
@@ -39,6 +43,9 @@ export default {
}
},
computed: {
pcMode() {
return isPcWeixin()
},
navHeight() {
return this.statusBarHeight + this.innerHeight
},
@@ -48,10 +55,41 @@ export default {
borderColor: this.border,
}
},
statusStyle() {
return {
height: this.statusBarHeight + 'px',
}
},
/** 占位跟固定栏同高,用像素避免 env(safe-area) 在安卓微信为 0 时切内容 */
phStyle() {
return {
height: this.navHeight + 'px',
width: '100%',
}
},
innerStyle() {
return {
height: this.innerHeight + 'px',
paddingRight: this.capsulePadRight + 'px',
}
},
},
watch: {
title: {
immediate: true,
handler(val) {
this.syncPcTitle(val)
},
},
},
created() {
this.measure()
this.pullTheme()
this.syncPcTitle(this.title)
},
mounted() {
// 部分机型 created 时 statusBarHeight 还是 0挂载后再量一次
this.measure()
},
beforeUpdate() {
this.pullTheme()
@@ -64,6 +102,8 @@ export default {
const menu = uni.getMenuButtonBoundingClientRect && uni.getMenuButtonBoundingClientRect()
if (menu && menu.height) {
this.innerHeight = (menu.top - this.statusBarHeight) * 2 + menu.height
const winW = Number(sys.windowWidth) || 375
this.capsulePadRight = Math.max(8, Math.round(winW - menu.left + 8))
}
} catch (err) {
this.statusBarHeight = 20
@@ -77,6 +117,21 @@ export default {
this.surface = vars['--color-surface'] || '#ffffff'
this.text = vars['--color-text'] || '#1C1917'
this.border = vars['--color-border'] || '#E7DFD3'
this.syncPcBarColor()
},
/** 系统导航栏底色跟主题表面色对齐,避免 PC 顶栏发灰 */
syncPcBarColor() {
if (!isPcWeixin()) return
const bg = this.surface || '#ffffff'
uni.setNavigationBarColor({
frontColor: '#000000',
backgroundColor: bg,
})
},
/** PC 走系统导航栏,动态标题(如编辑地址)要写过去 */
syncPcTitle(val) {
if (!isPcWeixin() || !val) return
uni.setNavigationBarTitle({ title: String(val) })
},
onBack() {
if (!this.showBack) return
@@ -92,6 +147,15 @@ export default {
</script>
<style scoped>
.tn {
display: block;
width: 100%;
}
.tn-ph {
display: block;
width: 100%;
overflow: hidden;
}
.tn-fixed {
position: fixed;
top: 0;
@@ -105,20 +169,35 @@ export default {
.tn-inner {
display: flex;
align-items: center;
padding: 0 16rpx;
position: relative;
padding-left: 16rpx;
box-sizing: border-box;
}
.tn-left,
.tn-right {
.tn-left {
width: 80rpx;
flex-shrink: 0;
z-index: 1;
}
.tn-right {
width: auto;
min-width: 80rpx;
flex-shrink: 0;
margin-left: auto;
z-index: 1;
display: flex;
align-items: center;
justify-content: flex-end;
}
.tn-title {
flex: 1;
position: absolute;
left: 160rpx;
right: 160rpx;
text-align: center;
font-size: 32rpx;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
}
</style>

View File

@@ -59,7 +59,7 @@
"urlCheck" : false,
"minified" : true
},
// "resizable" : true,
"resizable" : true,
"usingComponents" : true
},
"mp-alipay" : {

View File

@@ -1,7 +1,7 @@
/**
* 商品瀑布流通用 mixin
*
* product.vue / search.vue 的瀑布流高度同构data 字段、左右两列 computed、
* product.vue / search.vue 的瀑布流高度同构data 字段、按列数拆列 computed、
* 触底加载更多、下拉刷新这些样板代码两边一模一样。这里抽公共,避免改一处忘改另一处。
*
* 约定(引入本 mixin 的页面必须遵守):
@@ -20,7 +20,7 @@ export default {
page: 1,
// 每页条数,与后端约定 10
pageSize: 10,
// 商品列表扁平数组computed 会拆成左右两
// 商品列表扁平数组computed 按 pcGoodsCols 拆成 N
products: [],
// 首屏 loading控制骨架屏显隐
loading: false,
@@ -33,13 +33,17 @@ export default {
}
},
computed: {
// 偶数下标 → 左列
leftProducts() {
return this.products.filter((_, index) => index % 2 === 0)
},
// 奇数下标 → 右列
rightProducts() {
return this.products.filter((_, index) => index % 2 === 1)
/**
* 按当前窗口列数把商品轮流分进各列,宽屏一行更多而不是两列撑满。
* 依赖 theme mixin 的 pcGoodsCols两个列表页都混了 theme。
*/
productColumns() {
const n = Math.max(2, Number(this.pcGoodsCols) || 2)
const cols = Array.from({ length: n }, () => [])
this.products.forEach((item, index) => {
cols[index % n].push(item)
})
return cols
},
},
onPullDownRefresh() {

View File

@@ -7,6 +7,14 @@ import {
loadAndApplyTheme,
} from '@/utils/theme'
import { resolvePageTabBar, syncTabBarByRoute } from '@/utils/tabbar'
import {
PC_PAGE_MAX_WIDTH,
catColsByWidth,
getWindowWidth,
goodsColsByWidth,
isPcWeixin,
watchWindowWidth,
} from '@/utils/device'
/**
* 页面主题 mixin暴露色板 + layout + 进入动效 class
@@ -20,18 +28,39 @@ export default {
themeMeta: getThemeMeta(),
themeSchemes: getActiveSchemes(),
themeFeatures: getThemeFeatures(),
pcWindowWidth: getWindowWidth(),
}
},
computed: {
isPcWeixin() {
return isPcWeixin()
},
/** 首页 / 列表商品列数,随窗口宽度变(不限 PC */
pcGoodsCols() {
return goodsColsByWidth(this.pcWindowWidth)
},
/** 首页分类宫格列数,格子更小所以更早加列 */
pcCatCols() {
return catColsByWidth(this.pcWindowWidth)
},
themeEffect() {
return (this.themeLayout && this.themeLayout.effect) || {}
},
themePageStyle() {
const v = this.themeVars || {}
return {
const style = {
backgroundColor: v['--color-bg'] || '#F7F8FA',
color: v['--color-text'] || '#1a1a1a',
}
// PC 大窗不要把手机版拉成一条超宽带,内容限宽居中
if (isPcWeixin()) {
style.maxWidth = PC_PAGE_MAX_WIDTH + 'px'
style.marginLeft = 'auto'
style.marginRight = 'auto'
style.width = '100%'
style.boxSizing = 'border-box'
}
return style
},
themePrimary() {
return (this.themeVars && this.themeVars['--color-primary']) || '#B08D57'
@@ -108,7 +137,17 @@ export default {
)
},
packageList() {
return this.sectionVariant('package', 'list', 'card')
return this.sectionVariant(
'package',
'list',
(this.themeLayout.package && this.themeLayout.package.list) || 'card',
)
},
/** 套餐页搜索:优先本页 search 区块,没有则复用首页搜索样式 */
packageSearch() {
const own = this.sectionVariant('package', 'search', '')
if (own) return own
return this.homeSearch
},
packageEnabled() {
return !!(this.themeFeatures && this.themeFeatures.package_enabled)
@@ -193,16 +232,21 @@ export default {
homeSearch() {
return this.sectionVariant('home', 'search', 'bar')
},
/** 当前页皮肤:先读本页 pages.xxx.skin没有再回落到首页 */
pageSkin() {
return this.pageMeta('home').skin || 'shop'
const page = this.pageMeta(this.currentThemePage || 'home')
const home = this.pageMeta('home')
return page.skin || home.skin || 'shop'
},
pageDensity() {
const page = this.currentThemePage || 'home'
return this.pageMeta(page).density || 'regular'
const page = this.pageMeta(this.currentThemePage || 'home')
const home = this.pageMeta('home')
return page.density || home.density || 'regular'
},
pageFrame() {
const page = this.currentThemePage || 'home'
return this.pageMeta(page).frame || 'none'
const page = this.pageMeta(this.currentThemePage || 'home')
const home = this.pageMeta('home')
return page.frame || home.frame || 'none'
},
productInfo() {
return this.sectionVariant('product', 'info', 'plain')
@@ -251,6 +295,12 @@ export default {
* 拉主题并按当前路由对齐底栏。
* 页面自己写了 onShow 时可能盖掉 mixin 钩子,所以抽成方法让页面显式调用。
*/
unbindWindowWidth() {
if (typeof this._unwatchWindowWidth === 'function') {
this._unwatchWindowWidth()
this._unwatchWindowWidth = null
}
},
async applyThemeOnShow() {
// 先对齐当前页底栏,不要等主题接口,否则邻项互切时 pending 还在栏已经画死
syncTabBarByRoute(this)
@@ -276,6 +326,17 @@ export default {
if (bar && typeof bar.pullTheme === 'function') bar.pullTheme()
},
},
created() {
this._unwatchWindowWidth = watchWindowWidth((width) => {
this.pcWindowWidth = width
})
},
beforeDestroy() {
this.unbindWindowWidth()
},
beforeUnmount() {
this.unbindWindowWidth()
},
async onShow() {
await this.applyThemeOnShow()
},

View File

@@ -104,6 +104,25 @@
}
}
],
"subPackages": [
{
"root": "pkg-more",
"name": "more",
"pages": [
{ "path": "enterprise/detail", "style": { "navigationBarTitleText": "企业详情" } },
{ "path": "feedback/index", "style": { "navigationBarTitleText": "用户反馈" } },
{ "path": "address/list", "style": { "navigationBarTitleText": "收货地址" } },
{ "path": "address/form", "style": { "navigationBarTitleText": "编辑地址" } },
{ "path": "favorite/index", "style": { "navigationBarTitleText": "我的收藏" } },
{ "path": "after-sale/index", "style": { "navigationBarTitleText": "我的售后" } },
{ "path": "after-sale/apply", "style": { "navigationBarTitleText": "申请售后" } },
{ "path": "factory/index", "style": { "navigationBarTitleText": "工厂目录" } },
{ "path": "factory/detail", "style": { "navigationBarTitleText": "工厂详情" } },
{ "path": "colorcard/index", "style": { "navigationBarTitleText": "色卡目录" } },
{ "path": "colorcard/detail", "style": { "navigationBarTitleText": "色卡详情" } }
]
}
],
"globalStyle": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black",

View File

@@ -152,6 +152,8 @@
</page-container>
<view class="order-bar" :style="{ background: themeSurface, borderColor: themeBorder }">
<button class="quote-btn" open-type="share" :style="{ color: themeText, borderColor: themeBorder }">分享</button>
<button class="quote-btn" :style="{ color: themeText, borderColor: themeBorder }" @tap="shareQuote">复制报价</button>
<button class="order-btn" :style="{ background: themePrimary, color: themeSurface }" @tap="openOrderForm">
{{ Number(info.status) === 1 ? '再次下单' : '生成订单' }}
</button>
@@ -172,7 +174,10 @@
<view class="order-fields">
<input class="order-input" v-model="orderForm.receiver_name" placeholder="收件人" />
<input class="order-input" v-model="orderForm.receiver_phone" placeholder="联系电话" type="number" />
<input class="order-input" v-model="orderForm.receiver_address" placeholder="收货地址" />
<view class="addr-pick" @tap="pickAddress">
<input class="order-input" v-model="orderForm.receiver_address" placeholder="收货地址" />
<text class="addr-link">地址簿</text>
</view>
<view class="delivery-row">
<text
v-for="item in deliveryOptions"
@@ -196,10 +201,12 @@
</template>
<script>
import {deleteCartItemApi, getCartItemApi, updateItemApi} from "@/api/page/cart";
import {deleteCartItemApi, exportQuoteApi, getCartItemApi, updateItemApi} from "@/api/page/cart";
import {getDefaultAddressApi} from "@/api/page/address";
import {createOrderApi} from "@/api/page/order";
import {getCache} from "@/utils/cache";
import themeMixin from "@/mixins/theme";
import { requestOrderSubscribe } from "@/utils/subscribe";
import ThemeNavbar from "@/components/theme/ThemeNavbar.vue";
export default {
@@ -249,6 +256,12 @@ export default {
this.listId = options.id
this.getInfo();
},
onShareAppMessage() {
return {
title: this.listName || '清单报价',
path: `/pages/cart/detail?id=${this.listId}`,
}
},
methods: {
handleDelete(index, id) {
uni.showModal({
@@ -326,10 +339,32 @@ export default {
this.orderForm.delivery_type = 1
this.orderForm.remark = ''
this.orderForm.visible = true
getDefaultAddressApi().then((addr) => {
if (!addr || !addr.id) return
this.orderForm.receiver_name = addr.name || this.orderForm.receiver_name
this.orderForm.receiver_phone = addr.phone || this.orderForm.receiver_phone
this.orderForm.receiver_address = addr.address || this.orderForm.receiver_address
}).catch(() => {})
},
submitOrder() {
pickAddress() {
uni.navigateTo({ url: '/pkg-more/address/list?pick=1' })
},
onPickAddress(item) {
if (!item) return
this.orderForm.receiver_name = item.name || this.orderForm.receiver_name
this.orderForm.receiver_phone = item.phone || this.orderForm.receiver_phone
this.orderForm.receiver_address = item.address || this.orderForm.receiver_address
},
shareQuote() {
exportQuoteApi(this.listId).then((res) => {
const text = (res && res.text) || this.listName
uni.setClipboardData({ data: text })
})
},
async submitOrder() {
if (this.orderForm.saving) return
this.orderForm.saving = true
await requestOrderSubscribe(['pay', 'ship'])
createOrderApi(this.listId, {
receiver_name: this.orderForm.receiver_name,
receiver_phone: this.orderForm.receiver_phone,
@@ -651,15 +686,40 @@ view, scroll-view, text, image {
padding: 16rpx 30rpx calc(env(safe-area-inset-bottom) + 16rpx);
border-top: 1rpx solid;
z-index: 20;
display: flex;
gap: 12rpx;
}
.quote-btn {
flex: 0 0 140rpx;
height: 88rpx;
line-height: 88rpx;
border-radius: 48rpx;
font-size: 26rpx;
border: 1rpx solid;
background: transparent;
}
.order-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 48rpx;
font-size: 30rpx;
}
.addr-pick {
position: relative;
}
.addr-link {
position: absolute;
right: 12rpx;
top: 18rpx;
font-size: 24rpx;
color: #999;
}
.order-wrap {
padding-bottom: 12rpx;
}

View File

@@ -184,6 +184,7 @@
'cat-grid--featured': homeCategory === 'featured',
'cat-grid--tile': homeCategory === 'tile',
}"
:style="homeCatGridStyle"
>
<ThemeCardFrame
v-for="(item, index) in categories"
@@ -237,7 +238,7 @@
</view>
</view>
</scroll-view>
<view v-else class="pkg-grid" :class="'pkg-grid--' + homePackage">
<view v-else class="pkg-grid" :class="'pkg-grid--' + homePackage" :style="homePkgGridStyle">
<view
v-for="(item, index) in homePackages"
:key="item.id"
@@ -271,7 +272,11 @@
<text class="title-en" :style="{ color: themePrimary }">SELECTION</text>
<text class="title-cn" :style="{ color: themeText }">精选型号</text>
</view>
<view class="home-goods-grid" :class="{ 'is-list': homeProduct === 'list' }">
<view
class="home-goods-grid"
:class="{ 'is-list': homeProduct === 'list' }"
:style="homeGoodsGridStyle"
>
<view
v-for="item in homeGoods"
:key="item.id"
@@ -346,6 +351,27 @@ export default {
paddingBottom: 'calc(60px + env(safe-area-inset-bottom))',
}
},
/** 宽屏把精选型号从双列扩开,卡片等比例变窄 */
homeGoodsGridStyle() {
if (this.homeProduct === 'list') return {}
return {
gridTemplateColumns: 'repeat(' + this.pcGoodsCols + ', 1fr)',
}
},
/** 分类宫格mosaic / featured 有自己的非对称列,不要覆盖 */
homeCatGridStyle() {
if (this.homeCategory === 'mosaic' || this.homeCategory === 'featured') return {}
return {
gridTemplateColumns: 'repeat(' + this.pcCatCols + ', 1fr)',
}
},
/** 套餐宫格:杂志单列保持原样,其余跟商品列数走 */
homePkgGridStyle() {
if (this.homePackage === 'magazine') return {}
return {
gridTemplateColumns: 'repeat(' + this.pcGoodsCols + ', 1fr)',
}
},
heroWrapStyle() {
const order = { order: this.sectionOrder('home', 'hero') }
if (this.homeHero === 'fullscreen') {

View File

@@ -1,5 +1,5 @@
<template>
<view class="login-container">
<view class="login-container" :style="themePageStyle">
<ThemeNavbar title="登录" />
<view class="logo-area">
<image
@@ -7,7 +7,7 @@
mode="aspectFill"
class="logo-image"
/>
<text class="logo-text">佛山家具工厂</text>
<text class="logo-text">{{ brandName }}</text>
</view>
<view class="login-form">
@@ -28,6 +28,7 @@
<script>
import {loginApi} from "@/api/page/auth";
import { BRAND_NAME } from "@/api/env";
import {getCache, setCache} from "@/utils/cache";
import ThemeNavbar from "@/components/theme/ThemeNavbar.vue";
import themeMixin from "@/mixins/theme";
@@ -38,6 +39,7 @@ export default {
data() {
return {
phoneCode: '',
brandName: BRAND_NAME,
}
},
methods: {

View File

@@ -14,7 +14,7 @@
<ThemeCardFrame class="user-card-wrap">
<view class="user-card animate-slide-down" :style="{ background: themeSurface }">
<view class="user-info-row" @click="handleUserClick">
<view class="avatar-wrapper" :style="{ boxShadow: '0 0 0 4rpx ' + themePrimary }">
<view class="avatar-wrapper" :style="{ borderColor: themePrimary }">
<image
:src="userInfo.avatar || 'https://img2.baidu.com/it/u=2953585264,744730101&fm=253&fmt=auto&app=138&f=JPEG?w=360&h=360'"
mode="aspectFill"
@@ -27,19 +27,16 @@
<view class="subtitle" v-else :style="{ color: themeTextSoft }">点击登录开启家具选购之旅</view>
</view>
<view class="arrow-icon">
<u-icon name="arrow-right" :color="themeTextSoft" size="16"></u-icon>
<u-icon name="arrow-right" :color="themeTextSoft" size="14"></u-icon>
</view>
</view>
<!-- 统计数据 -->
<view class="stats-row">
<view class="stat-item">
<view class="num" :style="{ color: themePrimary }">{{ count }}</view>
<view class="label" :style="{ color: themeTextSoft }">当前清单数量</view>
<view class="stats-row" :style="{ borderTopColor: themeBorder }">
<view class="stat-copy">
<text class="num" :style="{ color: themePrimary }">{{ count }}</text>
<text class="label" :style="{ color: themeTextSoft }">当前清单</text>
</view>
<view class="vertical-line" :style="{ background: themeBorder }"></view>
<view class="stat-item" @click="goCart">
<view class="action-text" :style="{ background: themeText, color: themeSurface }">去查看</view>
<view class="action-text" :style="{ background: themePrimary, color: themeSurface }" @click.stop="goCart">
去查看
</view>
</view>
</view>
@@ -55,40 +52,40 @@
<u-icon name="arrow-right" color="rgba(255,255,255,0.6)" size="14"></u-icon>
</view>
<!-- 菜单列表 -->
<!-- 宫格三列收紧列表按交易 / 账户 / 更多分组避免九个入口摊成一块空白板 -->
<view
v-for="group in menuGroups"
:key="group.key"
class="menu-group animate-fade-in"
:class="'menu-group--' + mineMenu"
:style="{ background: themeSurface }"
>
<view class="menu-item" @click="goCart">
<text
v-if="!mineIsGrid && group.title"
class="menu-group-title"
:style="{ color: themeTextSoft }"
>{{ group.title }}</text>
<view
v-for="item in group.items"
:key="item.key"
class="menu-item"
@click="onMenu(item)"
>
<view class="item-left">
<ThemeIcon src="/static/icon/await.png" :size="20" />
<text :style="{ color: themeText, marginLeft: '20rpx' }">我的清单</text>
<view class="icon-well" :style="iconWellStyle">
<ThemeIcon :name="item.icon" :size="mineIsGrid ? 20 : 18" />
</view>
<text class="menu-label" :style="{ color: themeText }">{{ item.label }}</text>
</view>
<u-icon name="arrow-right" :color="themeTextSoft" size="14"></u-icon>
</view>
<view class="menu-item" @click="goOrder">
<view class="item-left">
<ThemeIcon name="file-text" :size="20" />
<text :style="{ color: themeText, marginLeft: '20rpx' }">我的订单</text>
<view v-if="!mineIsGrid" class="menu-arrow">
<u-icon name="arrow-right" :color="themeTextSoft" size="12"></u-icon>
</view>
<u-icon name="arrow-right" :color="themeTextSoft" size="14"></u-icon>
</view>
<view class="menu-item" @click="goMy">
<view class="item-left">
<ThemeIcon name="setting-fill" :size="20" />
<text :style="{ color: themeText, marginLeft: '20rpx' }">个人设置</text>
</view>
<u-icon name="arrow-right" :color="themeTextSoft" size="14"></u-icon>
</view>
</view>
<Footer />
</view>
<ThemeTabBar id="themeTabBar" ref="themeTabBar" />
<ThemeTabBar id="themeTabBar" ref="themeTabBar" :placeholder="false" />
</view>
</template>
@@ -110,37 +107,45 @@ export default {
currentThemePage: 'mine',
userInfo: {},
count: 0,
indexList: [
// {
// name: '用户反馈',
// icon: {
// color: '#ff8800',
// size: '26',
// type: 'info-circle'
// },
// page: '/pages/feedback/feedback'
// },
// {
// name: '我的邮件',
// icon: {
// color: '#ff8800',
// size: '26',
// type: 'email'
// },
// page: '/pages/email/email'
// },
// {
// name: '分享有礼',
// icon: {
// color: '#ff8800',
// size: '26',
// type: 'gift'
// },
// page: '/pages/share/share'
// },
],
};
},
computed: {
/** 宫格 / 瓷砖 / 卡片:三列图标井,不画列表箭头 */
mineIsGrid() {
return ['grid', 'tile', 'card'].includes(this.mineMenu)
},
iconWellStyle() {
return {
background: this.themeVars['--color-primary-soft'] || 'rgba(176, 141, 87, 0.14)',
}
},
/** 九个入口:宫格合成一块 3×3列表拆成三组避免一长条 */
menuGroups() {
const trade = [
{ key: 'cart', label: '我的清单', icon: 'list', action: 'cart' },
{ key: 'order', label: '我的订单', icon: 'file-text', action: 'order' },
{ key: 'after', label: '我的售后', icon: 'rmb-circle', url: '/pkg-more/after-sale/index' },
]
const account = [
{ key: 'setting', label: '个人设置', icon: 'setting-fill', action: 'setting' },
{ key: 'fav', label: '我的收藏', icon: 'star', url: '/pkg-more/favorite/index' },
{ key: 'addr', label: '收货地址', icon: 'map', url: '/pkg-more/address/list' },
]
const more = [
{ key: 'fb', label: '用户反馈', icon: 'chat', url: '/pkg-more/feedback/index' },
{ key: 'factory', label: '工厂目录', icon: 'home', url: '/pkg-more/factory/index', guest: true },
{ key: 'color', label: '色卡目录', icon: 'grid', url: '/pkg-more/colorcard/index', guest: true },
]
if (this.mineIsGrid) {
return [{ key: 'all', title: '', items: trade.concat(account, more) }]
}
return [
{ key: 'trade', title: '交易', items: trade },
{ key: 'account', title: '账户', items: account },
{ key: 'more', title: '更多', items: more },
]
},
},
async onShow() {
await this.applyThemeOnShow();
this.checkLoginAndLoad();
@@ -184,8 +189,33 @@ export default {
uni.navigateTo({ url: '/pages/order/list' });
},
goEnterpriseDetail() {
// 企业详情跳转逻辑
}
uni.navigateTo({ url: '/pkg-more/enterprise/detail' })
},
goPage(url) {
if (!this.userInfo.id && url.indexOf('factory') < 0 && url.indexOf('colorcard') < 0) {
this.loginFun()
return
}
uni.navigateTo({ url })
},
/** 宫格和列表共用入口,避免九份重复点击 */
onMenu(item) {
if (item.action === 'cart') {
this.goCart()
return
}
if (item.action === 'order') {
this.goOrder()
return
}
if (item.action === 'setting') {
this.goMy()
return
}
if (item.url) {
this.goPage(item.url)
}
},
},
}
</script>
@@ -195,6 +225,8 @@ export default {
min-height: 100vh;
background-color: #F5F7FA;
position: relative;
display: flex;
flex-direction: column;
}
.header-bg {
@@ -211,50 +243,63 @@ export default {
.content {
position: relative;
z-index: 1;
padding: 140rpx 30rpx 40rpx;
flex: 1;
/* 底栏是 fixed内容区自己留出 pill + 安全区,避免最后一行被挡住 */
padding: 140rpx 30rpx calc(48rpx + 60px + env(safe-area-inset-bottom));
}
/* 间距写在 ThemeCardFrame 根上,写在内层会被外壳吃掉,两张大卡会贴死 */
.user-card-wrap {
display: block;
margin-bottom: 20rpx;
}
/* User Card */
.user-card {
background: #fff;
border-radius: 24rpx;
padding: 40rpx 30rpx;
box-shadow: 0 8rpx 24rpx rgba(180, 133, 77, 0.08);
margin-bottom: 24rpx;
padding: 28rpx 28rpx 24rpx;
}
.user-info-row {
display: flex;
align-items: center;
margin-bottom: 40rpx;
}
.avatar-wrapper {
margin-right: 24rpx;
width: 104rpx;
height: 104rpx;
margin-right: 20rpx;
border-radius: 50%;
border: 4rpx solid;
overflow: hidden;
box-sizing: border-box;
flex-shrink: 0;
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 4rpx solid #fff;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
width: 100%;
height: 100%;
background: #f0f0f0;
}
.info-text {
flex: 1;
min-width: 0;
}
.nickname {
font-size: 36rpx;
font-weight: 700;
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
margin-bottom: 6rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 24rpx;
font-size: 22rpx;
color: #999;
}
@@ -262,38 +307,33 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 30rpx;
border-top: 1rpx solid #f5f5f5;
margin-top: 22rpx;
padding-top: 20rpx;
border-top: 1rpx solid;
}
.stat-item {
flex: 1;
text-align: center;
.stat-copy {
display: flex;
align-items: baseline;
gap: 10rpx;
}
.num {
font-size: 44rpx;
font-weight: 700;
line-height: 1.2;
font-size: 40rpx;
font-weight: 600;
line-height: 1;
}
.label {
font-size: 24rpx;
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.vertical-line {
width: 2rpx;
height: 40rpx;
background: #eee;
}
.action-text {
display: inline-block;
padding: 12rpx 32rpx;
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 28rpx;
font-size: 22rpx;
letter-spacing: 2rpx;
border-radius: 999rpx;
}
/* Enterprise Card */
@@ -335,61 +375,115 @@ export default {
.menu-group {
background: #fff;
border-radius: 24rpx;
padding: 10rpx 0;
padding: 8rpx 0;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.02);
margin-bottom: 16rpx;
}
.menu-group--grid {
display: flex;
flex-wrap: wrap;
padding: 20rpx;
}
.menu-group--grid .menu-item {
width: 50%;
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
padding: 28rpx 24rpx;
box-sizing: border-box;
}
.menu-group--grid .menu-item .item-left {
flex-direction: column;
align-items: flex-start;
gap: 12rpx;
.menu-group-title {
display: block;
padding: 16rpx 28rpx 4rpx;
font-size: 22rpx;
letter-spacing: 4rpx;
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx 40rpx;
transition: background 0.2s;
padding: 22rpx 28rpx;
box-sizing: border-box;
}
.menu-item:active {
background: #f9f9f9;
background: rgba(0, 0, 0, 0.03);
}
.item-left {
display: flex;
align-items: center;
font-size: 30rpx;
color: #333;
min-width: 0;
}
.menu-icon {
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
.icon-well {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.u-icon-fix {
width: 40rpx;
.menu-label {
margin-left: 18rpx;
font-size: 28rpx;
}
/* 三列用 grid 留出行列间距33% flex 会把 gutter 吃掉 */
.menu-group--grid,
.menu-group--tile,
.menu-group--card {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8rpx 12rpx;
padding: 24rpx 16rpx 20rpx;
}
.menu-group--grid .menu-item,
.menu-group--tile .menu-item,
.menu-group--card .menu-item {
width: auto;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 16rpx 4rpx 12rpx;
}
.menu-group--grid .item-left,
.menu-group--tile .item-left,
.menu-group--card .item-left {
flex-direction: column;
align-items: center;
}
.menu-group--grid .icon-well,
.menu-group--tile .icon-well,
.menu-group--card .icon-well {
width: 80rpx;
height: 80rpx;
}
.menu-group--grid .menu-label,
.menu-group--tile .menu-label,
.menu-group--card .menu-label {
margin-left: 0;
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.3;
text-align: center;
}
.menu-group--tile .menu-item {
padding: 12rpx 6rpx 16rpx;
}
.menu-group--card .menu-item {
padding: 8rpx;
}
.menu-group--card .item-left {
width: 100%;
padding: 16rpx 4rpx 18rpx;
border-radius: 16rpx;
background: rgba(0, 0, 0, 0.03);
box-sizing: border-box;
}
.menu-group--compact .menu-item {
padding: 16rpx 24rpx;
}
/* Animations */
@keyframes slideDown {
from { opacity: 0; transform: translateY(-20rpx); }

View File

@@ -1,6 +1,6 @@
<template>
<view class="detail-page" :style="themePageStyle">
<ThemeNavbar title="订单详情" />
<ThemeNavbar v-if="!voucherVisible" title="订单详情" />
<!-- 加载中 -->
<view v-if="loading && !order.id" class="empty-tip">加载中...</view>
<view v-else-if="!order.id" class="empty-tip">订单不存在</view>
@@ -127,6 +127,13 @@
>
确认收货
</view>
<view
class="action-btn outline-btn"
v-if="canAfterSale"
@tap="onAfterSale"
>
申请售后
</view>
</view>
<!-- 上传凭证 page-container防意外退出按规则用 v-if -->
@@ -136,10 +143,13 @@
:overlay="true"
:close-on-overlay="false"
position="right"
title="上传转账凭证"
@clickoverlay="closeVoucher"
>
<view class="voucher-panel">
<view class="voucher-panel" :style="{ paddingTop: voucherPadTop + 'px' }">
<view class="voucher-head">
<text class="voucher-head-title">上传转账凭证</text>
<text class="voucher-head-close" @tap="closeVoucher">关闭</text>
</view>
<view class="voucher-tip">
请上传银行/微信转账截图后台审核通过后订单状态自动推进
</view>
@@ -196,6 +206,7 @@ import {
} from '@/api/page/order.js'
import themeMixin from '@/mixins/theme'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import { requestOrderSubscribe } from '@/utils/subscribe'
const STATUS_MAP = {
0: '待付款',
@@ -223,7 +234,8 @@ export default {
voucherVisible: false,
voucherImages: [],
voucherAmount: '',
submitting: false
submitting: false,
statusBarHeight: 20
}
},
computed: {
@@ -240,8 +252,11 @@ export default {
canConfirm() {
return this.order.status === 2
},
canAfterSale() {
return this.order.status === 1 || this.order.status === 2 || this.order.status === 3
},
showActions() {
return this.order.id && (this.canCancel || this.canPay || this.canUploadVoucher || this.canConfirm)
return this.order.id && (this.canCancel || this.canPay || this.canUploadVoucher || this.canConfirm || this.canAfterSale)
},
timeline() {
if (!this.order.id) return []
@@ -266,10 +281,20 @@ export default {
nodes.push({title: '已取消', time: '', type: 'cancel'})
}
return nodes
},
/** page-container 没有系统导航,内容要从状态栏下面开始 */
voucherPadTop() {
return this.statusBarHeight
}
},
onLoad(options) {
this.orderId = Number(options.id || 0)
try {
const sys = uni.getSystemInfoSync() || {}
this.statusBarHeight = sys.statusBarHeight || 20
} catch (e) {
this.statusBarHeight = 20
}
this.fetchDetail()
},
onPullDownRefresh() {
@@ -341,7 +366,12 @@ export default {
}
})
},
onAfterSale() {
uni.navigateTo({ url: `/pkg-more/after-sale/apply?order_id=${this.orderId}` })
},
async onPay() {
// 支付手势里同时要发货模板,否则后台发货时订阅发不出去
await requestOrderSubscribe(['pay', 'ship'])
uni.showLoading({title: '正在调起支付...'})
try {
const params = await wechatPayApi(this.orderId)
@@ -444,10 +474,12 @@ export default {
.detail-page {
min-height: 100vh;
padding-bottom: 140rpx;
box-sizing: border-box;
}
.status-banner {
padding: 48rpx 32rpx;
box-sizing: border-box;
}
.status-text {
@@ -685,17 +717,36 @@ export default {
/* 凭证上传 page-container 内容 */
.voucher-panel {
padding: 32rpx;
padding: 0 32rpx 180rpx;
min-height: 100vh;
background: #fff;
box-sizing: border-box;
}
.voucher-head {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin-bottom: 8rpx;
}
.voucher-head-title {
font-size: 32rpx;
font-weight: 600;
color: #1a1a1a;
}
.voucher-head-close {
font-size: 26rpx;
color: #999;
}
.voucher-tip {
font-size: 24rpx;
color: #999;
line-height: 1.6;
margin-bottom: 32rpx;
margin: 16rpx 0 32rpx;
}
.voucher-images {

View File

@@ -1,74 +1,96 @@
<template>
<view class="order-page" :style="themePageStyle">
<ThemeNavbar title="我的订单" :show-back="false" />
<!-- 顶部状态 Tab -->
<!-- 顶部状态 Tab点击与左右滑共用 currentIndex -->
<view class="status-tabs" :style="{ background: themeSurface, borderColor: themeBorder }">
<view
v-for="tab in tabs"
:key="tab.value"
v-for="(tab, index) in tabs"
:key="tab.key"
class="status-tab"
:class="{active: currentStatus === tab.value}"
:style="{ color: currentStatus === tab.value ? themePrimary : themeTextSoft }"
@tap="switchTab(tab.value)"
:class="{active: currentIndex === index}"
:style="{ color: currentIndex === index ? themePrimary : themeTextSoft }"
@tap="onTabTap(index)"
>
<text>{{ tab.label }}</text>
</view>
</view>
<!-- 订单列表 -->
<scroll-view
class="order-list"
scroll-y
@scrolltolower="loadMore"
<swiper
class="order-swiper"
:style="{ height: swiperHeight + 'px' }"
:current="currentIndex"
@change="onSwiperChange"
>
<view v-if="loading && list.length === 0" class="empty-tip" :style="{ color: themeTextSoft }">加载中...</view>
<view v-else-if="list.length === 0" class="empty-tip" :style="{ color: themeTextSoft }">
<text class="empty-icon">📭</text>
<text>暂无订单</text>
</view>
<swiper-item v-for="(tab, index) in tabs" :key="tab.key">
<scroll-view
class="order-list"
scroll-y
@scrolltolower="loadMore(index)"
>
<view
v-if="tabCaches[index] && tabCaches[index].loading && tabCaches[index].list.length === 0"
class="empty-tip"
:style="{ color: themeTextSoft }"
>加载中...</view>
<view
v-else-if="tabCaches[index] && tabCaches[index].list.length === 0"
class="empty-tip"
:style="{ color: themeTextSoft }"
>
<text class="empty-icon">📭</text>
<text>暂无订单</text>
</view>
<view
v-for="order in list"
:key="order.id"
class="order-card"
:style="{ background: themeSurface, borderColor: themeBorder }"
@tap="goDetail(order.id)"
>
<view class="order-card-header">
<text class="order-no" :style="{ color: themeTextSoft }">订单号{{ order.order_no }}</text>
<text class="order-status" :style="{ color: statusColor(order.status) }">
{{ statusText(order.status) }}
</text>
</view>
<view
v-for="order in (tabCaches[index] && tabCaches[index].list) || []"
:key="order.id"
class="order-card"
:style="{ background: themeSurface, borderColor: themeBorder }"
@tap="goDetail(order.id)"
>
<view class="order-card-header">
<text class="order-no" :style="{ color: themeTextSoft }">订单号{{ order.order_no }}</text>
<text class="order-status" :style="{ color: statusColor(order.status) }">
{{ statusText(order.status) }}
</text>
</view>
<view class="order-card-body">
<view class="order-item-thumb" v-if="order.items && order.items.length">
<image
v-for="(item, idx) in order.items.slice(0, 4)"
:key="idx"
:src="item.cover"
mode="aspectFill"
class="thumb"
/>
<view v-if="order.items.length > 4" class="thumb-more">
+{{ order.items.length - 4 }}
<view class="order-card-body">
<view class="order-item-thumb" v-if="order.items && order.items.length">
<image
v-for="(item, idx) in order.items.slice(0, 4)"
:key="idx"
:src="item.cover"
mode="aspectFill"
class="thumb"
/>
<view v-if="order.items.length > 4" class="thumb-more">
+{{ order.items.length - 4 }}
</view>
</view>
<view v-else class="order-item-empty"> {{ order.item_count || 0 }} 件商品</view>
</view>
<view class="order-card-footer" :style="{ borderColor: themeBorder }">
<view class="order-amount">
<text class="amount-label" :style="{ color: themeTextSoft }">合计</text>
<text class="amount-value" :style="{ color: themePrice }">¥{{ order.total_amount_text || '0' }}</text>
</view>
<view class="order-time" :style="{ color: themeTextSoft }">{{ order.created_at }}</view>
</view>
</view>
<view v-else class="order-item-empty"> {{ order.item_count || 0 }} 件商品</view>
</view>
<view class="order-card-footer" :style="{ borderColor: themeBorder }">
<view class="order-amount">
<text class="amount-label" :style="{ color: themeTextSoft }">合计</text>
<text class="amount-value" :style="{ color: themePrice }">¥{{ order.total_amount_text || '0' }}</text>
</view>
<view class="order-time" :style="{ color: themeTextSoft }">{{ order.created_at }}</view>
</view>
</view>
<view v-if="loading && list.length > 0" class="load-more-tip">加载中...</view>
<view v-if="noMore && list.length > 0" class="load-more-tip">没有更多了</view>
</scroll-view>
<view
v-if="tabCaches[index] && tabCaches[index].loading && tabCaches[index].list.length > 0"
class="load-more-tip"
>加载中...</view>
<view
v-if="tabCaches[index] && tabCaches[index].noMore && tabCaches[index].list.length > 0"
class="load-more-tip"
>没有更多了</view>
</scroll-view>
</swiper-item>
</swiper>
<ThemeTabBar id="themeTabBar" ref="themeTabBar" />
</view>
</template>
@@ -91,40 +113,64 @@ const STATUS_MAP = {
4: '已取消'
}
function emptyCache() {
return {list: [], page: 1, noMore: false, loaded: false, loading: false}
}
export default {
components: { ThemeNavbar, ThemeTabBar },
mixins: [themeMixin],
data() {
return {
// 顶部 Tabundefined 表示全部
tabs: [
{label: '全部', value: undefined},
{label: '待付款', value: 0},
{label: '待发货', value: 1},
{label: '待收货', value: 2},
{label: '已完成', value: 3}
{label: '全部', value: undefined, key: 'all'},
{label: '待付款', value: 0, key: '0'},
{label: '待发货', value: 1, key: '1'},
{label: '待收货', value: 2, key: '2'},
{label: '已完成', value: 3, key: '3'}
],
currentStatus: undefined,
list: [],
page: 1,
currentIndex: 0,
tabCaches: [],
pageSize: 20,
loading: false,
noMore: false
swiperHeight: 400
}
},
onLoad(options) {
// 支持从外部带 status 跳进来(如「待付款」入口)
this.tabCaches = this.tabs.map(() => emptyCache())
if (options.status !== undefined && options.status !== '') {
this.currentStatus = Number(options.status)
const idx = this.tabs.findIndex((tab) => Number(tab.value) === Number(options.status))
if (idx >= 0) this.currentIndex = idx
}
this.fetchList(true)
this.measureSwiper()
this.ensureTab(this.currentIndex, true)
},
onReady() {
this.measureSwiper()
},
onPullDownRefresh() {
this.fetchList(true).finally(() => {
this.ensureTab(this.currentIndex, true).finally(() => {
uni.stopPullDownRefresh()
})
},
methods: {
/**
* swiper 必须有明确像素高度,否则微信里内容高度为 0
*/
measureSwiper() {
try {
const sys = uni.getSystemInfoSync() || {}
const status = sys.statusBarHeight || 20
const menu = uni.getMenuButtonBoundingClientRect && uni.getMenuButtonBoundingClientRect()
const inner = menu && menu.height ? (menu.top - status) * 2 + menu.height : 44
const navH = this.isPcWeixin ? 0 : (status + inner)
const tabH = 44
const safeBottom = (sys.safeAreaInsets && sys.safeAreaInsets.bottom) || 0
const tabbarH = 60 + safeBottom
this.swiperHeight = Math.max(240, (sys.windowHeight || 667) - navH - tabH - tabbarH)
} catch (e) {
this.swiperHeight = 400
}
},
statusText(status) {
return STATUS_MAP[status] || '-'
},
@@ -133,44 +179,59 @@ export default {
if (status === 4) return this.themeTextSoft
return this.themePrimary
},
switchTab(value) {
if (this.currentStatus === value) return
this.currentStatus = value
this.fetchList(true)
onTabTap(index) {
if (this.currentIndex === index) return
this.currentIndex = index
this.ensureTab(index)
},
async fetchList(reset = false) {
if (this.loading) return
this.loading = true
onSwiperChange(e) {
const index = (e.detail && e.detail.current) || 0
this.currentIndex = index
this.ensureTab(index)
},
/**
* 每个 Tab 自己缓存列表,滑到没加载过的才请求,避免来回清空
*/
ensureTab(index, force = false) {
const cache = this.tabCaches[index]
if (!cache) return Promise.resolve()
if (!force && (cache.loaded || cache.loading)) return Promise.resolve()
return this.fetchList(index, true)
},
async fetchList(index, reset = false) {
const cache = this.tabCaches[index]
if (!cache || cache.loading) return
cache.loading = true
this.$forceUpdate()
if (reset) {
this.page = 1
this.noMore = false
cache.page = 1
cache.noMore = false
}
try {
const tab = this.tabs[index]
const res = await getOrderListApi({
status: this.currentStatus,
page: this.page,
status: tab.value,
page: cache.page,
pageSize: this.pageSize
})
// 后端返回 { items, total } 或数组(兼容两种结构)
const items = Array.isArray(res) ? res : (res.items || res.list || [])
if (reset) {
this.list = items
} else {
this.list = this.list.concat(items)
}
cache.list = reset ? items : cache.list.concat(items)
cache.loaded = true
if (items.length < this.pageSize) {
this.noMore = true
cache.noMore = true
}
} catch (e) {
// 拦截器已统一 toast,这里只兜底
// 拦截器已统一 toast
} finally {
this.loading = false
cache.loading = false
this.$forceUpdate()
}
},
loadMore() {
if (this.noMore || this.loading) return
this.page += 1
this.fetchList(false)
loadMore(index) {
const cache = this.tabCaches[index]
if (!cache || cache.noMore || cache.loading) return
cache.page += 1
this.fetchList(index, false)
},
goDetail(id) {
uni.navigateTo({url: `/pages/order/detail?id=${id}`})
@@ -189,8 +250,7 @@ export default {
.status-tabs {
display: flex;
border-bottom: 1rpx solid;
position: sticky;
top: 0;
flex-shrink: 0;
z-index: 10;
}
@@ -206,8 +266,12 @@ export default {
position: relative;
}
.order-swiper {
width: 100%;
}
.order-list {
flex: 1;
height: 100%;
padding: 24rpx;
box-sizing: border-box;
}
@@ -237,26 +301,6 @@ export default {
font-weight: 500;
}
.status-0 {
color: #e6a23c;
}
.status-1 {
color: #409eff;
}
.status-2 {
color: #409eff;
}
.status-3 {
color: #67c23a;
}
.status-4 {
color: #999;
}
.order-card-body {
margin-bottom: 24rpx;
}

View File

@@ -165,12 +165,24 @@ export default {
lists: [],
pickerVisible: false,
saving: false,
// 分享落地时详情请求可能早于静默登录,登录后再拉一次才能写 pid
needRebind: false,
}
},
onLoad(options) {
this.id = Number(options.id || 0)
this.p_user_id = Number(options.p_user_id || 0)
this.needRebind = !!(this.p_user_id && !getCache('token'))
this.loadDetail()
if (this.needRebind) {
uni.$on('wx-login-ready', this.onWxLoginReady)
if (getCache('token')) {
this.onWxLoginReady()
}
}
},
onUnload() {
uni.$off('wx-login-ready', this.onWxLoginReady)
},
async onShow() {
await this.applyThemeOnShow()
@@ -192,6 +204,15 @@ export default {
this.info = res || { items: [] }
})
},
/**
* 静默登录完成后补绑:首次详情请求没带上 token 时 inheritAgentPrice 不会写库
*/
onWxLoginReady() {
if (!this.needRebind) return
this.needRebind = false
uni.$off('wx-login-ready', this.onWxLoginReady)
this.loadDetail()
},
isBoundOther(list) {
const bound = Number(list.package_id || 0)
return bound > 0 && bound !== Number(this.id)

View File

@@ -9,7 +9,7 @@
<ThemeSearch
v-model="keyword"
placeholder="搜索套餐名称"
:variant="homeSearch"
:variant="packageSearch"
@search="handleSearch"
/>
</view>
@@ -34,26 +34,29 @@
<ThemeEnter v-else>
<view class="pkg-list" :class="'pkg-list--' + packageList">
<ThemeCardFrame
<!-- pkg-card 放在本页节点上避免 scoped 打不到 ThemeCardFrame 根节点 -->
<view
v-for="(item, index) in items"
:key="item.id"
class="pkg-card"
>
<view class="pkg-inner" @tap="goDetail(item.id)">
<image :src="item.cover" mode="aspectFill" class="pkg-cover" />
<view class="pkg-veil"></view>
<view class="pkg-meta">
<text class="pkg-vol" :style="{ color: themePrimary }">SET {{ padNo(index + 1) }}</text>
<text class="pkg-name">{{ item.name }}</text>
<text v-if="item.subtitle" class="pkg-sub">{{ item.subtitle }}</text>
<text class="pkg-count">{{ item.item_count || 0 }} 件搭配</text>
<view v-if="item.is_show_price" class="pkg-price">
<text class="pkg-origin">¥{{ item.original_amount_text }}</text>
<text class="pkg-quote" :style="{ color: themePrice }">¥{{ item.package_amount_text }}</text>
<ThemeCardFrame>
<view class="pkg-inner" @tap="goDetail(item.id)">
<image :src="item.cover" mode="aspectFill" class="pkg-cover" />
<view class="pkg-veil"></view>
<view class="pkg-meta">
<text class="pkg-vol" :style="{ color: themePrimary }">SET {{ padNo(index + 1) }}</text>
<text class="pkg-name">{{ item.name }}</text>
<text v-if="item.subtitle" class="pkg-sub">{{ item.subtitle }}</text>
<text class="pkg-count">{{ item.item_count || 0 }} 件搭配</text>
<view v-if="item.is_show_price" class="pkg-price">
<text class="pkg-origin">¥{{ item.original_amount_text }}</text>
<text class="pkg-quote" :style="{ color: themePrice }">¥{{ item.package_amount_text }}</text>
</view>
</view>
</view>
</view>
</ThemeCardFrame>
</ThemeCardFrame>
</view>
</view>
<view v-if="loadingMore" class="loading-more">
<ThemeSpinner text="加载更多" />
@@ -189,6 +192,19 @@ export default {
.content-body {
flex: 1;
}
.skin--magazine .page-en {
letter-spacing: 12rpx;
}
.skin--magazine .page-cn {
font-weight: 400;
letter-spacing: 8rpx;
}
.density--compact .pkg-list {
gap: 16rpx;
}
.density--airy .pkg-list {
gap: 40rpx;
}
.page-head {
padding: 12rpx 32rpx 8rpx;
}
@@ -302,9 +318,14 @@ export default {
font-weight: 500;
letter-spacing: 1rpx;
}
/* 首图:第一张拉高,其余略矮,和装修「首图」对得上 */
.pkg-list--featured .pkg-card:first-child .pkg-cover {
height: 520rpx;
height: 560rpx;
}
.pkg-list--featured .pkg-card:not(:first-child) .pkg-cover {
height: 320rpx;
}
/* 刊名条:左图右文,取消压字,换主题立刻能看出差别 */
.pkg-list--magazine .pkg-inner {
display: flex;
flex-direction: row;
@@ -328,6 +349,7 @@ export default {
.pkg-list--magazine .pkg-origin {
color: inherit;
}
/* 单列:更紧凑的左图右文 */
.pkg-list--list .pkg-inner {
display: flex;
flex-direction: row;

View File

@@ -1,6 +1,25 @@
<template>
<view class="container" :style="themePageStyle">
<ThemeNavbar title="商品详情" />
<ThemeNavbar title="商品详情">
<template #right>
<view class="nav-actions">
<button class="nav-icon-btn" open-type="share" hover-class="none" :style="{ background: themeSurface, color: themeText }">
<u-icon name="share-fill" :color="themeText" size="18"></u-icon>
</button>
<view class="nav-icon-btn" :style="{ background: themeSurface }" @tap="toggleFav">
<u-icon :name="product.favorited ? 'star-fill' : 'star'" :color="product.favorited ? themePrimary : themeText" size="18"></u-icon>
</view>
</view>
</template>
</ThemeNavbar>
<view v-if="isPcWeixin" class="pc-actions">
<button class="nav-icon-btn" open-type="share" hover-class="none" :style="{ background: themeSurface, color: themeText }">
<u-icon name="share-fill" :color="themeText" size="18"></u-icon>
</button>
<view class="nav-icon-btn" :style="{ background: themeSurface }" @tap="toggleFav">
<u-icon :name="product.favorited ? 'star-fill' : 'star'" :color="product.favorited ? themePrimary : themeText" size="18"></u-icon>
</view>
</view>
<view
class="swiper-container"
:class="'gallery--' + productGallery"
@@ -62,9 +81,13 @@
</view>
</view>
<button class="share-fixed-btn animate-scale" open-type="share" hover-class="none" :style="{ background: themeSurface, color: themeText }">
<u-icon name="share-fill" :color="themeText" size="20"></u-icon>
</button>
<video
v-if="product.video"
:src="product.video"
class="product-video"
controls
:style="{ background: themeSurface }"
/>
<ThemeEnter>
<view class="info-card animate-slide-up" :class="'info--' + productInfo" :style="{ background: themeSurface, borderRadius: themeRadiusLg }">
@@ -240,6 +263,7 @@
<script>
import { getProductDetailApi } from "@/api/page/product";
import { getCartListApi, toCartApi } from "@/api/page/cart";
import { toggleFavoriteApi } from "@/api/page/favorite";
import {getCache} from "@/utils/cache";
import { urlSafeBase64Encode } from "@/utils/utils";
import themeMixin from "@/mixins/theme";
@@ -259,7 +283,9 @@ export default {
listOption: [],
galleryIndex: 0,
specPicker: { visible: false, listId: 0, sheets: [], priceSheetId: 0, quantity: 1 },
product: { title: '', cover: '', price_sheet: [], alias: '', carousel: [] }
product: { title: '', cover: '', price_sheet: [], alias: '', carousel: [], video: '', favorited: false },
// 分享落地时详情请求可能早于静默登录,登录后再拉一次才能写 pid
needRebind: false,
};
},
computed: {
@@ -289,7 +315,18 @@ export default {
this.id = options.id;
this.p_user_id = options.p_user_id;
this.initAlias();
this.needRebind = !!(this.p_user_id && !getCache('token'));
this.getProductDetail();
if (this.needRebind) {
uni.$on('wx-login-ready', this.onWxLoginReady);
// 登录可能已经在 onLoad 和 $on 之间完成,补一次检查
if (getCache('token')) {
this.onWxLoginReady();
}
}
},
onUnload() {
uni.$off('wx-login-ready', this.onWxLoginReady);
},
onShareAppMessage() {
const userInfo = getCache('user_info')
@@ -313,6 +350,10 @@ export default {
uni.previewImage({ current: 0, urls: [`${url}?watermark/2/text/${w}/fontsize/800`] });
},
showModal() {
if (!getCache('token')) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
this.getListOption();
this.specPicker.visible = false;
this.listModal = true;
@@ -345,11 +386,31 @@ export default {
this.initAlias();
});
},
/**
* 静默登录完成后补绑:首次详情请求没带上 token 时 inheritAgentPrice 不会写库
*/
onWxLoginReady() {
if (!this.needRebind) return;
this.needRebind = false;
uni.$off('wx-login-ready', this.onWxLoginReady);
this.getProductDetail();
},
initAlias() {
if (this.product.alias) this.product.alias = this.product.alias.replace(/\n/g, '<br/>');
},
getListOption() {
getCartListApi().then((res) => { this.listOption = res; });
},
toggleFav() {
if (!this.id) return
if (!getCache('token')) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
toggleFavoriteApi(this.id).then((res) => {
this.product.favorited = !!(res && res.favorited)
uni.showToast({ title: this.product.favorited ? '已收藏' : '已取消', icon: 'none' })
})
}
}
};
@@ -380,7 +441,33 @@ export default {
.hero-overlay { position: absolute; left: 0; right: 0; bottom: 0; padding: 40rpx 32rpx 28rpx; background: linear-gradient(transparent, rgba(0, 0, 0, 0.45)); display: flex; flex-direction: column; gap: 8rpx; }
.hero-overlay-code { font-size: 40rpx; font-weight: 600; letter-spacing: 4rpx; }
.hero-overlay-alias { font-size: 24rpx; opacity: 0.85; }
.share-fixed-btn { position: fixed; top: 40rpx; right: 30rpx; width: 72rpx; height: 72rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08); z-index: 999; }
.nav-actions,
.pc-actions {
display: flex;
align-items: center;
gap: 8rpx;
}
.pc-actions {
justify-content: flex-end;
padding: 12rpx 24rpx 0;
}
.nav-icon-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
line-height: 1;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.nav-icon-btn::after {
border: none;
}
.product-video { width: 100%; height: 420rpx; }
.info-card { margin-top: 0; position: relative; z-index: 2; padding: 40rpx 30rpx; min-height: 500rpx; }
.info--editorial .product-code { letter-spacing: 6rpx; font-weight: 700; }
.info--split .title-section { display: flex; gap: 24rpx; align-items: flex-start; }

View File

@@ -67,12 +67,16 @@
:class="['list--' + catalogList, { 'is-grid': catalogList === 'grid' || catalogList === 'compact' || catalogList === 'airy' }]"
v-else
>
<view class="waterfall-column">
<view
v-for="(col, colIndex) in productColumns"
:key="'col-' + colIndex"
class="waterfall-column"
>
<view
v-for="(product, index) in leftProducts"
v-for="(product, index) in col"
:key="product.id"
class="product-item animate-card-enter"
:style="{ animationDelay: index * 0.1 + 's' }"
:style="{ animationDelay: (index * 0.1 + colIndex * 0.05) + 's' }"
@tap="navigateToDetail(product.id)"
>
<ThemeCanvasCard
@@ -88,38 +92,6 @@
:src="product.cover"
mode="widthFix"
class="product-image"
@load="imageLoaded(index * 2)"
/>
</view>
<view class="info-box">
<text class="product-code">{{ product.title }}</text>
</view>
</ThemeCanvasCard>
</view>
</view>
<view class="waterfall-column">
<view
v-for="(product, index) in rightProducts"
:key="product.id"
class="product-item animate-card-enter"
:style="{ animationDelay: (index * 0.1 + 0.05) + 's' }"
@tap="navigateToDetail(product.id)"
>
<ThemeCanvasCard
:cover="product.cover"
:name="product.title"
:show-price="false"
:scheme="pageCardScheme"
:caption="pageCardCaption"
page="catalog"
>
<view class="img-box">
<image
:src="product.cover"
mode="widthFix"
class="product-image"
@load="imageLoaded(index * 2 + 1)"
/>
</view>
<view class="info-box">
@@ -251,8 +223,6 @@ export default {
this.loading = false
})
},
imageLoaded(index) {
},
},
}
</script>

View File

@@ -41,12 +41,16 @@
:class="['list--' + searchList, { 'is-grid': searchList === 'grid' || searchList === 'compact' || searchList === 'airy' }]"
v-else
>
<view class="waterfall-column">
<view
v-for="(col, colIndex) in productColumns"
:key="'col-' + colIndex"
class="waterfall-column"
>
<view
v-for="(product, index) in leftProducts"
v-for="(product, index) in col"
:key="product.id"
class="product-item animate-card-enter"
:style="{ animationDelay: index * 0.05 + 's' }"
:style="{ animationDelay: (index * 0.05 + colIndex * 0.03) + 's' }"
@tap="navigateToDetail(product.id)"
>
<ThemeCanvasCard
@@ -61,36 +65,6 @@
:src="product.cover"
mode="widthFix"
class="product-image"
@load="imageLoaded(index * 2)"
/>
<view class="product-info">
<text class="product-code">{{ product.title }}</text>
</view>
</ThemeCanvasCard>
</view>
</view>
<view class="waterfall-column">
<view
v-for="(product, index) in rightProducts"
:key="product.id"
class="product-item animate-card-enter"
:style="{ animationDelay: (index * 0.05 + 0.05) + 's' }"
@tap="navigateToDetail(product.id)"
>
<ThemeCanvasCard
:cover="product.cover"
:name="product.title"
:show-price="false"
:scheme="pageCardScheme"
:caption="pageCardCaption"
page="search"
>
<image
:src="product.cover"
mode="widthFix"
class="product-image"
@load="imageLoaded(index * 2 + 1)"
/>
<view class="product-info">
<text class="product-code">{{ product.title }}</text>
@@ -185,8 +159,6 @@ export default {
uni.hideLoading()
})
},
imageLoaded(index) {
},
},
}
</script>

63
pkg-more/address/form.vue Normal file
View File

@@ -0,0 +1,63 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar :title="id ? '编辑地址' : '新增地址'" />
<view class="form" :style="{ background: themeSurface }">
<input v-model="form.name" class="input" placeholder="收件人" />
<input v-model="form.phone" class="input" type="number" placeholder="手机号" />
<input v-model="form.address" class="input" placeholder="详细地址" />
<view class="switch-row" @tap="form.is_default = form.is_default === 1 ? 0 : 1">
<text :style="{ color: themeText }">设为默认</text>
<text :style="{ color: themePrimary }">{{ form.is_default === 1 ? '是' : '否' }}</text>
</view>
<button class="submit" :style="{ background: themePrimary, color: themeSurface }" @tap="save">保存</button>
</view>
</view>
</template>
<script>
import { createAddressApi, getAddressDetailApi, updateAddressApi } from '@/api/page/address'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { id: 0, form: { name: '', phone: '', address: '', is_default: 0 } }
},
async onLoad(options) {
await this.applyThemeOnShow()
this.id = Number(options.id || 0)
if (this.id) {
getAddressDetailApi(this.id).then((res) => {
this.form = {
name: res.name || '',
phone: res.phone || '',
address: res.address || '',
is_default: Number(res.is_default) === 1 ? 1 : 0,
}
})
}
},
methods: {
save() {
const payload = { ...this.form }
const req = this.id
? updateAddressApi({ id: this.id, ...payload })
: createAddressApi(payload)
req.then(() => {
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
})
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; }
.form { margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.input { height: 88rpx; border-bottom: 1rpx solid #eee; font-size: 28rpx; }
.switch-row { display: flex; justify-content: space-between; padding: 24rpx 0; }
.submit { margin-top: 24rpx; }
</style>

90
pkg-more/address/list.vue Normal file
View File

@@ -0,0 +1,90 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="收货地址" />
<view
v-for="item in list"
:key="item.id"
class="card"
:style="{ background: themeSurface }"
@tap="pick(item)"
>
<view class="name" :style="{ color: themeText }">
{{ item.name }} {{ item.phone }}
<text v-if="item.is_default === 1" class="tag" :style="{ color: themePrimary }">默认</text>
</view>
<view class="addr" :style="{ color: themeTextSoft }">{{ item.address }}</view>
<view class="ops">
<text @tap.stop="edit(item)">编辑</text>
<text @tap.stop="setDefault(item)">设为默认</text>
<text @tap.stop="remove(item)">删除</text>
</view>
</view>
<view class="empty" v-if="!list.length" :style="{ color: themeTextSoft }">还没有地址</view>
<button class="add" :style="{ background: themePrimary, color: themeSurface }" @tap="edit({})">新增地址</button>
</view>
</template>
<script>
import { deleteAddressApi, getAddressListApi, setDefaultAddressApi } from '@/api/page/address'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { list: [], pickMode: false }
},
onLoad(options) {
this.pickMode = options.pick === '1'
},
async onShow() {
await this.applyThemeOnShow()
this.load()
},
methods: {
load() {
getAddressListApi().then((res) => {
this.list = res || []
})
},
pick(item) {
if (!this.pickMode) return
const pages = getCurrentPages()
const prev = pages[pages.length - 2]
if (prev && prev.$vm && prev.$vm.onPickAddress) {
prev.$vm.onPickAddress(item)
}
uni.navigateBack()
},
edit(item) {
const id = item.id ? `?id=${item.id}` : ''
uni.navigateTo({ url: `/pkg-more/address/form${id}` })
},
setDefault(item) {
setDefaultAddressApi(item.id).then(() => this.load())
},
remove(item) {
uni.showModal({
title: '删除地址',
content: '确定删除该地址吗?',
success: (res) => {
if (!res.confirm) return
deleteAddressApi(item.id).then(() => this.load())
},
})
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; padding-bottom: 160rpx; }
.card { margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.name { font-size: 30rpx; }
.tag { margin-left: 12rpx; font-size: 22rpx; }
.addr { font-size: 26rpx; margin-top: 8rpx; }
.ops { display: flex; gap: 24rpx; margin-top: 16rpx; font-size: 24rpx; color: #999; }
.empty { text-align: center; padding: 80rpx 0; }
.add { position: fixed; left: 32rpx; right: 32rpx; bottom: 40rpx; }
</style>

View File

@@ -0,0 +1,79 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="申请售后" />
<view class="form" :style="{ background: themeSurface }">
<textarea v-model="reason" class="input" maxlength="255" placeholder="请说明售后原因" />
<input v-model="amountYuan" class="amount" type="digit" placeholder="退款金额(元,不填按已付)" />
<view class="images">
<image v-for="(src, i) in images" :key="i" :src="src" class="thumb" @tap="removeImage(i)" />
<view v-if="images.length < 3" class="add" :style="{ borderColor: themeBorder }" @tap="chooseImage">+</view>
</view>
<button class="submit" :style="{ background: themePrimary, color: themeSurface }" @tap="submit">提交申请</button>
</view>
</view>
</template>
<script>
import { createAfterSaleApi } from '@/api/page/afterSale'
import { uploadVoucherImageApi } from '@/api/page/order'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { orderId: 0, reason: '', amountYuan: '', images: [] }
},
async onLoad(options) {
await this.applyThemeOnShow()
this.orderId = Number(options.order_id || 0)
},
methods: {
chooseImage() {
uni.chooseImage({
count: 3 - this.images.length,
success: async (res) => {
for (const path of res.tempFilePaths || []) {
const url = await uploadVoucherImageApi(path)
this.images.push(url)
}
},
})
},
removeImage(index) {
this.images.splice(index, 1)
},
submit() {
if (!this.reason.trim()) {
uni.showToast({ title: '请填写原因', icon: 'none' })
return
}
const yuan = Number(this.amountYuan)
const payload = {
order_id: this.orderId,
reason: this.reason,
images: this.images,
}
if (!Number.isNaN(yuan) && yuan > 0) {
payload.amount = Math.round(yuan * 100)
}
createAfterSaleApi(payload).then(() => {
uni.showToast({ title: '已提交', icon: 'success' })
setTimeout(() => uni.redirectTo({ url: '/pkg-more/after-sale/index' }), 400)
})
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; }
.form { margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.input { width: 100%; min-height: 180rpx; font-size: 28rpx; }
.amount { height: 80rpx; font-size: 28rpx; margin-top: 16rpx; }
.images { display: flex; gap: 16rpx; margin: 16rpx 0; }
.thumb, .add { width: 140rpx; height: 140rpx; border-radius: 8rpx; }
.add { border: 2rpx dashed; display: flex; align-items: center; justify-content: center; font-size: 48rpx; }
.submit { margin-top: 12rpx; }
</style>

View File

@@ -0,0 +1,63 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="我的售后" />
<view v-if="!list.length" class="empty" :style="{ color: themeTextSoft }">还没有售后申请</view>
<view
v-for="item in list"
:key="item.id"
class="card"
:style="{ background: themeSurface }"
>
<view class="row">
<text class="no" :style="{ color: themeText }">{{ item.order_no || '订单' }}</text>
<text class="status" :style="{ color: statusColor(item.status) }">{{ item.status_text }}</text>
</view>
<text class="reason" :style="{ color: themeText }">{{ item.reason }}</text>
<text class="amount" :style="{ color: themePrimary }">申请退款 ¥{{ item.amount_text || '0' }}</text>
<text v-if="item.admin_remark" class="remark" :style="{ color: themeTextSoft }">
商家{{ item.admin_remark }}
</text>
</view>
</view>
</template>
<script>
import { getAfterSaleListApi } from '@/api/page/afterSale'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { list: [] }
},
async onShow() {
await this.applyThemeOnShow()
this.load()
},
methods: {
load() {
getAfterSaleListApi().then((res) => {
this.list = res || []
})
},
statusColor(status) {
if (status === 1) return this.themePrimary
if (status === 2) return '#C2410C'
return this.themeTextSoft
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; padding-bottom: 40rpx; }
.empty { text-align: center; padding: 120rpx 0; }
.card { margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.row { display: flex; justify-content: space-between; align-items: center; }
.no { font-size: 26rpx; }
.status { font-size: 24rpx; }
.reason { display: block; margin-top: 12rpx; font-size: 28rpx; }
.amount, .remark { display: block; margin-top: 8rpx; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,45 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="色卡详情" />
<image v-if="info.cover" :src="info.cover" class="hero" mode="aspectFill" @tap="preview" />
<view class="card" :style="{ background: themeSurface }">
<text class="name" :style="{ color: themeText }">{{ info.description || '色卡' }}</text>
<text class="meta" :style="{ color: themeTextSoft }">分类 {{ info.card_class_name || '-' }}</text>
<text class="meta" :style="{ color: themeTextSoft }">公司 {{ info.company_name || '-' }}</text>
</view>
</view>
</template>
<script>
import { getColorcardDetailApi } from '@/api/page/colorcard'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { info: {} }
},
async onLoad(options) {
await this.applyThemeOnShow()
getColorcardDetailApi(Number(options.id || 0)).then((res) => {
this.info = res || {}
})
},
methods: {
preview() {
if (!this.info.cover) return
uni.previewImage({ current: this.info.cover, urls: [this.info.cover] })
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; }
.hero { width: 100%; height: 720rpx; }
.card { margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.name { font-size: 34rpx; font-weight: 600; display: block; }
.meta { display: block; margin-top: 8rpx; font-size: 26rpx; }
</style>

View File

@@ -0,0 +1,74 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="色卡目录" />
<scroll-view scroll-x class="tabs">
<text
v-for="item in classes"
:key="item.id"
class="tab"
:style="{ color: classId === item.id ? themePrimary : themeTextSoft }"
@tap="switchClass(item.id)"
>{{ item.name }}</text>
</scroll-view>
<view v-if="!list.length" class="empty" :style="{ color: themeTextSoft }">暂无色卡</view>
<view
v-for="item in list"
:key="item.id"
class="card"
:style="{ background: themeSurface }"
@tap="goDetail(item.id)"
>
<image :src="item.cover" class="cover" mode="aspectFill" />
<view class="info">
<text class="title" :style="{ color: themeText }">{{ item.description || item.name || '色卡' }}</text>
<text class="meta" :style="{ color: themeTextSoft }">{{ item.company_name }} {{ item.card_class_name }}</text>
</view>
</view>
</view>
</template>
<script>
import { getColorcardClassListApi, getColorcardListApi } from '@/api/page/colorcard'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { classes: [{ id: 0, name: '全部' }], classId: 0, list: [] }
},
async onShow() {
await this.applyThemeOnShow()
getColorcardClassListApi().then((res) => {
this.classes = [{ id: 0, name: '全部' }, ...(res || [])]
})
this.load()
},
methods: {
load() {
getColorcardListApi({ card_class: this.classId || undefined }).then((res) => {
this.list = res || []
})
},
switchClass(id) {
this.classId = id
this.load()
},
goDetail(id) {
uni.navigateTo({ url: `/pkg-more/colorcard/detail?id=${id}` })
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; }
.tabs { white-space: nowrap; padding: 16rpx 24rpx; }
.tab { display: inline-block; margin-right: 28rpx; font-size: 28rpx; }
.empty { text-align: center; padding: 120rpx 0; }
.card { display: flex; margin: 24rpx; padding: 20rpx; border-radius: 16rpx; }
.cover { width: 160rpx; height: 160rpx; border-radius: 12rpx; margin-right: 20rpx; }
.title { font-size: 30rpx; }
.meta { display: block; margin-top: 8rpx; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,45 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="企业详情" />
<view v-if="info.id" class="card" :style="{ background: themeSurface }">
<image v-if="info.logo" :src="info.logo" class="logo" mode="aspectFill" />
<text class="name" :style="{ color: themeText }">{{ info.name }}</text>
<view class="row" :style="{ color: themeTextSoft }">联系人 {{ info.contact_name || '-' }}</view>
<view class="row" :style="{ color: themeTextSoft }">电话 {{ info.phone || '-' }}</view>
<view class="row" :style="{ color: themeTextSoft }">地址 {{ info.address || '-' }}</view>
<view class="row" :style="{ color: themeTextSoft }">税号 {{ info.tax_no || '-' }}</view>
</view>
<view v-else class="empty" :style="{ color: themeTextSoft }">未绑定企业</view>
</view>
</template>
<script>
import { getEnterpriseApi } from '@/api/page/user'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { info: {} }
},
async onShow() {
await this.applyThemeOnShow()
getEnterpriseApi().then((res) => {
this.info = res || {}
}).catch(() => {
this.info = {}
})
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; }
.card { margin: 24rpx; padding: 32rpx; border-radius: 16rpx; }
.logo { width: 120rpx; height: 120rpx; border-radius: 12rpx; margin-bottom: 16rpx; }
.name { font-size: 36rpx; font-weight: 600; display: block; margin-bottom: 16rpx; }
.row { font-size: 26rpx; line-height: 1.8; }
.empty { padding: 120rpx 0; text-align: center; }
</style>

View File

@@ -0,0 +1,58 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="工厂详情" />
<image v-if="info.cover" :src="info.cover" class="hero" mode="aspectFill" />
<view class="card" :style="{ background: themeSurface }">
<text class="name" :style="{ color: themeText }">{{ info.name }}</text>
<text class="meta" :style="{ color: themeTextSoft }">{{ info.classification_name }}</text>
<text class="meta" :style="{ color: themeTextSoft }">{{ info.phone }}</text>
<text class="meta" :style="{ color: themeTextSoft }">{{ info.address }}</text>
</view>
<view class="grid">
<image
v-for="(img, i) in info.images || []"
:key="i"
:src="img.url || img.cover"
class="grid-img"
mode="aspectFill"
@tap="preview(img.url || img.cover)"
/>
</view>
</view>
</template>
<script>
import { getFactoryDetailApi } from '@/api/page/factory'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { info: {} }
},
async onLoad(options) {
await this.applyThemeOnShow()
getFactoryDetailApi(Number(options.id || 0)).then((res) => {
this.info = res || {}
})
},
methods: {
preview(url) {
const urls = (this.info.images || []).map((item) => item.url || item.cover).filter(Boolean)
uni.previewImage({ current: url, urls })
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; }
.hero { width: 100%; height: 420rpx; }
.card { margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.name { font-size: 34rpx; font-weight: 600; display: block; }
.meta { display: block; margin-top: 8rpx; font-size: 26rpx; }
.grid { display: flex; flex-wrap: wrap; padding: 0 24rpx 40rpx; gap: 12rpx; }
.grid-img { width: 226rpx; height: 226rpx; border-radius: 8rpx; }
</style>

View File

@@ -0,0 +1,74 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="工厂目录" />
<scroll-view scroll-x class="tabs">
<text
v-for="item in classes"
:key="item.id"
class="tab"
:style="{ color: classId === item.id ? themePrimary : themeTextSoft }"
@tap="switchClass(item.id)"
>{{ item.name }}</text>
</scroll-view>
<view v-if="!list.length" class="empty" :style="{ color: themeTextSoft }">暂无工厂</view>
<view
v-for="item in list"
:key="item.id"
class="card"
:style="{ background: themeSurface }"
@tap="goDetail(item.id)"
>
<image :src="item.cover" class="cover" mode="aspectFill" />
<view class="info">
<text class="title" :style="{ color: themeText }">{{ item.name }}</text>
<text class="meta" :style="{ color: themeTextSoft }">{{ item.classification_name }} {{ item.phone }}</text>
</view>
</view>
</view>
</template>
<script>
import { getFactoryClassListApi, getFactoryListApi } from '@/api/page/factory'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { classes: [{ id: 0, name: '全部' }], classId: 0, list: [] }
},
async onShow() {
await this.applyThemeOnShow()
getFactoryClassListApi().then((res) => {
this.classes = [{ id: 0, name: '全部' }, ...(res || [])]
})
this.load()
},
methods: {
load() {
getFactoryListApi({ classification: this.classId || undefined }).then((res) => {
this.list = res || []
})
},
switchClass(id) {
this.classId = id
this.load()
},
goDetail(id) {
uni.navigateTo({ url: `/pkg-more/factory/detail?id=${id}` })
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; }
.tabs { white-space: nowrap; padding: 16rpx 24rpx; }
.tab { display: inline-block; margin-right: 28rpx; font-size: 28rpx; }
.empty { text-align: center; padding: 120rpx 0; }
.card { display: flex; margin: 24rpx; padding: 20rpx; border-radius: 16rpx; }
.cover { width: 160rpx; height: 160rpx; border-radius: 12rpx; margin-right: 20rpx; }
.title { font-size: 30rpx; }
.meta { display: block; margin-top: 8rpx; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,55 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="我的收藏" />
<view v-if="!list.length" class="empty" :style="{ color: themeTextSoft }">还没有收藏</view>
<view
v-for="item in list"
:key="item.id"
class="card"
:style="{ background: themeSurface }"
@tap="goDetail(item.catalogue_id)"
>
<image :src="item.cover" class="cover" mode="aspectFill" />
<view class="info">
<text class="title" :style="{ color: themeText }">{{ item.title }}</text>
<text class="price" :style="{ color: themePrimary }">{{ item.price || '' }}</text>
</view>
</view>
</view>
</template>
<script>
import { getFavoriteListApi } from '@/api/page/favorite'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { list: [] }
},
async onShow() {
await this.applyThemeOnShow()
getFavoriteListApi().then((res) => {
this.list = res || []
})
},
methods: {
goDetail(id) {
if (!id) return
uni.navigateTo({ url: `/pages/product/detail?id=${id}` })
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; }
.empty { text-align: center; padding: 120rpx 0; }
.card { display: flex; margin: 24rpx; padding: 20rpx; border-radius: 16rpx; }
.cover { width: 160rpx; height: 160rpx; border-radius: 12rpx; margin-right: 20rpx; }
.info { flex: 1; }
.title { font-size: 28rpx; }
.price { display: block; margin-top: 12rpx; font-size: 26rpx; }
</style>

View File

@@ -0,0 +1,82 @@
<template>
<view class="page" :style="themePageStyle">
<ThemeNavbar title="用户反馈" />
<view class="form" :style="{ background: themeSurface }">
<textarea v-model="content" class="input" maxlength="500" placeholder="请填写反馈内容" />
<view class="images">
<image v-for="(src, i) in images" :key="i" :src="src" class="thumb" @tap="removeImage(i)" />
<view v-if="images.length < 3" class="add" :style="{ borderColor: themeBorder }" @tap="chooseImage">+</view>
</view>
<button class="submit" :style="{ background: themePrimary, color: themeSurface }" @tap="submit">提交</button>
</view>
<view v-for="item in list" :key="item.id" class="card" :style="{ background: themeSurface }">
<text class="content" :style="{ color: themeText }">{{ item.content }}</text>
<text class="meta" :style="{ color: themeTextSoft }">{{ item.status === 1 ? '已回复' : '待处理' }}</text>
<text v-if="item.reply" class="reply" :style="{ color: themePrimary }">回复{{ item.reply }}</text>
</view>
</view>
</template>
<script>
import { createFeedbackApi, getFeedbackListApi } from '@/api/page/feedback'
import { uploadVoucherImageApi } from '@/api/page/order'
import ThemeNavbar from '@/components/theme/ThemeNavbar.vue'
import themeMixin from '@/mixins/theme'
export default {
components: { ThemeNavbar },
mixins: [themeMixin],
data() {
return { content: '', images: [], list: [] }
},
async onShow() {
await this.applyThemeOnShow()
this.load()
},
methods: {
load() {
getFeedbackListApi().then((res) => {
this.list = res || []
})
},
chooseImage() {
uni.chooseImage({
count: 3 - this.images.length,
success: async (res) => {
for (const path of res.tempFilePaths || []) {
const url = await uploadVoucherImageApi(path)
this.images.push(url)
}
},
})
},
removeImage(index) {
this.images.splice(index, 1)
},
submit() {
if (!this.content.trim()) {
uni.showToast({ title: '请填写内容', icon: 'none' })
return
}
createFeedbackApi({ content: this.content, images: this.images }).then(() => {
this.content = ''
this.images = []
uni.showToast({ title: '已提交', icon: 'success' })
this.load()
})
},
},
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; padding-bottom: 40rpx; }
.form, .card { margin: 24rpx; padding: 24rpx; border-radius: 16rpx; }
.input { width: 100%; min-height: 160rpx; font-size: 28rpx; }
.images { display: flex; gap: 16rpx; margin: 16rpx 0; }
.thumb, .add { width: 140rpx; height: 140rpx; border-radius: 8rpx; }
.add { border: 2rpx dashed; display: flex; align-items: center; justify-content: center; font-size: 48rpx; }
.submit { margin-top: 12rpx; }
.content { display: block; font-size: 28rpx; }
.meta, .reply { display: block; font-size: 24rpx; margin-top: 8rpx; }
</style>

83
utils/device.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* 运行环境:区分手机微信与 PC 微信Windows / Mac
* 大屏适配只认官方 platform开发者工具手机预览不要走 PC 布局。
*/
export const PC_PAGE_MAX_WIDTH = 1200
const PC_PLATFORMS = { windows: true, mac: true }
export function isPcWeixin() {
try {
const sys = uni.getSystemInfoSync() || {}
const platform = String(sys.platform || '').toLowerCase()
return !!PC_PLATFORMS[platform]
} catch (e) {
return false
}
}
export function getWindowWidth() {
try {
const sys = uni.getSystemInfoSync() || {}
return Number(sys.windowWidth) || 375
} catch (e) {
return 375
}
}
/**
* 宽屏商品列数:卡片跟着窗口变窄、一行变多。
* 手机仍双列iPad / PC 中窗起加列,避免 rpx 把两列撑得过大。
*/
export function goodsColsByWidth(width) {
const w = Number(width) || 375
if (w >= 1200) return 5
if (w >= 900) return 4
if (w >= 600) return 3
return 2
}
/**
* 首页分类宫格列数:格子比商品卡更小,更早加列。
* mosaic / featured 这种非对称布局不要用这个函数去覆盖。
*/
export function catColsByWidth(width) {
const w = Number(width) || 375
if (w >= 1100) return 6
if (w >= 800) return 5
if (w >= 480) return 4
return 3
}
const widthListeners = []
let resizeBound = false
function emitWindowWidth(res) {
const size = (res && res.size) || {}
const width = Number(size.windowWidth) || getWindowWidth()
widthListeners.forEach((fn) => {
try {
fn(width)
} catch (e) {
// 单个页面抛错不影响其它订阅
}
})
}
/**
* 订阅窗口宽度。PC 拉伸窗口时通知页面改列数。
* 返回取消订阅函数,页面销毁时必须调用。
*/
export function watchWindowWidth(fn) {
if (typeof fn !== 'function') return function noop() {}
widthListeners.push(fn)
if (!resizeBound && typeof uni.onWindowResize === 'function') {
uni.onWindowResize(emitWindowWidth)
resizeBound = true
}
return function unwatch() {
const idx = widthListeners.indexOf(fn)
if (idx >= 0) widthListeners.splice(idx, 1)
}
}

25
utils/subscribe.js Normal file
View File

@@ -0,0 +1,25 @@
import { getThemeFeatures } from '@/utils/theme'
/**
* 在用户点击手势里申请订阅授权。
* 微信要求授权必须跟在 tap 后面,付款/发货各一张,一次最多两张。
*/
export function requestOrderSubscribe(scenes = ['pay', 'ship']) {
const features = getThemeFeatures()
const ids = []
scenes.forEach((scene) => {
const id = scene === 'ship' ? features.subscribe_ship_tpl : features.subscribe_pay_tpl
if (id && ids.indexOf(id) < 0) {
ids.push(id)
}
})
if (!ids.length) {
return Promise.resolve()
}
return new Promise((resolve) => {
uni.requestSubscribeMessage({
tmplIds: ids,
complete: () => resolve(),
})
})
}

View File

@@ -38,6 +38,7 @@ export const FALLBACK_LAYOUT = {
product: { gallery: 'swiper', price: 'inline', action: 'fixed' },
list: { style: 'card' },
mine: { header: 'gradient', menu: 'list' },
package: { list: 'card' },
effect: {
transition: 'fade',
skeleton: 'shimmer',
@@ -97,6 +98,8 @@ export function applyTheme(theme) {
setCache(STORAGE_SCHEMES_KEY, theme.card_schemes || {})
setCache(STORAGE_FEATURES_KEY, {
package_enabled: !!(theme.features && theme.features.package_enabled),
subscribe_pay_tpl: (theme.features && theme.features.subscribe_pay_tpl) || '',
subscribe_ship_tpl: (theme.features && theme.features.subscribe_ship_tpl) || '',
})
applyVarsToDom(vars)
}
@@ -109,6 +112,7 @@ function mergeLayout(layout) {
product: { ...FALLBACK_LAYOUT.product, ...(src.product || {}) },
list: { ...FALLBACK_LAYOUT.list, ...(src.list || {}) },
mine: { ...FALLBACK_LAYOUT.mine, ...(src.mine || {}) },
package: { ...FALLBACK_LAYOUT.package, ...(src.package || {}) },
effect: { ...FALLBACK_LAYOUT.effect, ...(src.effect || {}) },
card_scheme: src.card_scheme || FALLBACK_LAYOUT.card_scheme,
chrome: {
@@ -163,6 +167,8 @@ export function getThemeFeatures() {
const cached = getCache(STORAGE_FEATURES_KEY) || {}
return {
package_enabled: !!cached.package_enabled,
subscribe_pay_tpl: cached.subscribe_pay_tpl || '',
subscribe_ship_tpl: cached.subscribe_ship_tpl || '',
}
}