consumer模块完成度95%,能编译在安卓端运行,在解决数据获取和页面布局问题

This commit is contained in:
cyh666666
2026-02-27 08:20:43 +08:00
parent e606c597ca
commit b9acce6c35
1554 changed files with 23471 additions and 8551 deletions

View File

@@ -2,6 +2,9 @@
"version" : "1.0",
"configurations" : [
{
"customPlaygroundType" : "local",
"localRepoPath" : "D:/companyproject/mall",
"packageName" : "com.huawei.hisuite",
"playground" : "standard",
"type" : "uni-app:app-android"
}

View File

@@ -1,6 +1,6 @@
{
"name": "mall",
"appid": "__UNI__YOUR_APP_ID__",
"appid": "__UNI__EC68BC3",
"description": "A multi-role e-commerce application.",
"versionName": "1.0.0",
"versionCode": "100",
@@ -73,21 +73,15 @@
},
"app-android": {
"distribute": {
"modules": {
"uni-payment": {
"alipay": {},
"wxpay": {}
},
"uni-push": {
"hms": {},
"oppo": {},
"vivo": {},
"xiaomi": {},
"meizu": {},
"honor": {},
"fcm": {}
}
"modules": {},
"android": {
"usesCleartextTraffic": true
}
}
},
"web": {
"router": {
"mode": ""
}
}
}

View File

@@ -243,30 +243,6 @@
"navigationStyle": "custom"
}
},
{
"path": "subscription/plan-list",
"style": {
"navigationBarTitleText": "软件订阅"
}
},
{
"path": "subscription/plan-detail",
"style": {
"navigationBarTitleText": "订阅详情"
}
},
{
"path": "subscription/subscribe-checkout",
"style": {
"navigationBarTitleText": "确认订阅"
}
},
{
"path": "subscription/my-subscriptions",
"style": {
"navigationBarTitleText": "我的订阅"
}
},
{
"path": "subscription/followed-shops",
"style": {
@@ -586,33 +562,321 @@
// }
// }
// ]
// },
// {
// "root": "pages/mall/merchant",
// "root": "pages/mall/delivery",
// "pages": [
// {
// "path": "index",
// "style": {
// "navigationBarTitleText": "商家中心",
// "navigationBarTitleText": "配送中心",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "product-detail",
// "path": "order-detail",
// "style": {
// "navigationBarTitleText": "商品管理详情",
// "enablePullDownRefresh": false
// "navigationBarTitleText": "订单详情",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "profile",
// "style": {
// "navigationBarTitleText": "个人资料"
// "navigationBarTitleText": "配送个人中心",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "order-history",
// "style": {
// "navigationBarTitleText": "历史记录",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "earnings",
// "style": {
// "navigationBarTitleText": "收入明细",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "tasks",
// "style": {
// "navigationBarTitleText": "全部任务",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "task-detail",
// "style": {
// "navigationBarTitleText": "任务详情",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "profile-edit",
// "style": {
// "navigationBarTitleText": "编辑个人资料",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "ratings",
// "style": {
// "navigationBarTitleText": "评价",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "vehicle",
// "style": {
// "navigationBarTitleText": "车辆管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "vehicle-add",
// "style": {
// "navigationBarTitleText": "添加车辆",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "vehicle-edit",
// "style": {
// "navigationBarTitleText": "编辑车辆",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "help-center",
// "style": {
// "navigationBarTitleText": "帮助中心",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "about",
// "style": {
// "navigationBarTitleText": "关于我们",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "feedback",
// "style": {
// "navigationBarTitleText": "意见反馈",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "test",
// "style": {
// "navigationBarTitleText": "test",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "settings",
// "style": {
// "navigationBarTitleText": "设置",
// "navigationStyle": "custom"
// }
// }
// ]
// },
// {
// "root": "pages/mall/analytics",
// "pages": [
// {
// "path": "index",
// "style": {
// "navigationBarTitleText": "数据分析",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "profile",
// "style": {
// "navigationBarTitleText": "数据分析个人中心"
// }
// },
// {
// "path": "sales-report",
// "style": {
// "navigationBarTitleText": "销售报表"
// }
// },
// {
// "path": "user-analysis",
// "style": {
// "navigationBarTitleText": "用户分析"
// }
// },
// {
// "path": "product-insights",
// "style": {
// "navigationBarTitleText": "商品洞察"
// }
// },
// {
// "path": "delivery-analysis",
// "style": {
// "navigationBarTitleText": "配送效率分析"
// }
// },
// {
// "path": "coupon-analysis",
// "style": {
// "navigationBarTitleText": "优惠券效果分析"
// }
// },
// {
// "path": "market-trends",
// "style": {
// "navigationBarTitleText": "市场趋势"
// }
// },
// {
// "path": "custom-report",
// "style": {
// "navigationBarTitleText": "自定义报表"
// }
// },
// {
// "path": "report-detail",
// "style": {
// "navigationBarTitleText": "报表详情",
// "enablePullDownRefresh": false
// }
// },
// {
// "path": "data-detail",
// "style": {
// "navigationBarTitleText": "数据分析详情",
// "enablePullDownRefresh": false
// }
// },
// {
// "path": "insight-detail",
// "style": {
// "navigationBarTitleText": "数据洞察详情",
// "enablePullDownRefresh": false
// }
// }
// ]
// },
// {
// "root": "pages/mall/admin",
// "pages": [
// {
// "path": "user-management",
// "style": {
// "navigationBarTitleText": "用户管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "product-management",
// "style": {
// "navigationBarTitleText": "商品管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "order-management",
// "style": {
// "navigationBarTitleText": "订单管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "finance/record",
// "style": {
// "navigationBarTitleText": "财务管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "user-statistics",
// "style": {
// "navigationBarTitleText": "用户统计",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "system-settings",
// "style": {
// "navigationBarTitleText": "系统设置",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "subscription/plan-management",
// "style": {
// "navigationBarTitleText": "订阅方案管理"
// }
// },
// {
// "path": "subscription/user-subscriptions",
// "style": {
// "navigationBarTitleText": "用户订阅管理"
// }
// },
// {
// "path": "marketing/coupon/list",
// "style": {
// "navigationBarTitleText": "优惠券列表"
// }
// },
// {
// "path": "marketing/coupon/receive",
// "style": {
// "navigationBarTitleText": "用户领取记录"
// }
// },
// {
// "path": "marketing/signin/rule",
// "style": {
// "navigationBarTitleText": "签到规则"
// }
// },
// {
// "path": "marketing/signin/record",
// "style": {
// "navigationBarTitleText": "签到记录"
// }
// }
// ]
// },
// {
// "root": "pages/mall/service",
// "pages": [
// {
// "path": "index",
// "style": {
// "navigationBarTitleText": "客服工作台",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "profile",
// "style": {
// "navigationBarTitleText": "客服个人中心"
// }
// },
// {
// "path": "ticket-detail",
// "style": {
// "navigationBarTitleText": "工单详情",
// "enablePullDownRefresh": false
// }
// }
// ]
// }
// {
// "root": "pages/mall/service",
// "pages": [
// {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -326,7 +326,7 @@ const loadCartData = async () => {
shopId: p.merchant_id ?? 'unknown',
shopName: p.shop_name ?? '商城推荐',
name: p.name,
price: p.base_price,
price: p.base_price ?? p.price ?? 0,
image: p.main_image_url ?? '/static/images/default-product.png',
skuId: ''
}

View File

@@ -25,8 +25,8 @@
</view>
</view>
<!-- 导航栏占位 -->
<view class="navbar-placeholder" :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 导航栏占位 - 修复只使用44px高度因为search-bar的paddingTop已处理statusBarHeight -->
<view class="navbar-placeholder" :style="{ height: '44px' }"></view>
<!-- 分类内容区 -->
<view class="category-content">
@@ -77,9 +77,9 @@
<view class="price-section">
<view class="current-price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ product.base_price }}</text>
<text class="price-value">{{ product.base_price ?? product.price ?? 0 }}</text>
</view>
<text class="original-price" v-if="product.market_price != null && product.market_price! > product.base_price">
<text class="original-price" v-if="product.market_price != null && product.base_price != null && product.market_price! > product.base_price!">
¥{{ product.market_price }}
</text>
</view>

View File

@@ -597,6 +597,12 @@ const processCheckoutItems = (items: any[]) => {
}
}
// 获取当前用户ID
function getCurrentUserId(): string {
const userId = supabaseService.getCurrentUserId()
return userId ?? ''
}
// 生命周期
onMounted(() => {
// 监听地址更新事件
@@ -774,14 +780,8 @@ async function loadDefaultAddress(): Promise<void> {
}
}
// 获取当前用户ID
function getCurrentUserId(): string {
const userId = supabaseService.getCurrentUserId()
return userId ?? ''
}
// 用户登录状态
const isLoggedIn = computed(() => {
const isLoggedIn = computed((): boolean => {
const userId = getCurrentUserId()
return userId != ''
})

View File

@@ -113,34 +113,163 @@
| `selected` | Boolean | 是否勾选 | 购物车状态 |
| `created_at` | Timestamp | 创建时间 | |
### 3.2 订单主表 (`ml_orders`) (推测结构)
### 3.2 订单主表 (`ml_orders`)
商家端处理订单的核心表。
| 字段名 | 类型 | 描述 | 备注 |
| :--- | :--- | :--- | :--- |
| `id` | UUID | 主键 | |
| `order_no` | VARCHAR | 订单号 | 唯一业务单号 |
| `user_id` | UUID | 用户 ID | |
| `merchant_id` | UUID | 商家 ID | |
| `order_no` | Text | 订单号 | 唯一业务单号 |
| `total_amount` | Numeric | 订单总金额 | |
| `pay_amount` | Numeric | 实付金额 | |
| `status` | Integer | 订单状态 | 0: 待付款, 1: 待发货, 2: 待收货, 3: 已完成, -1: 已取消 |
| `address_snapshot` | JSONB | 收货地址快照 | 下单时的地址信息 |
| `remark` | Text | 订单备注 | |
| `created_at` | Timestamp | 下单时间 | |
| `product_amount` | NUMERIC | 商品金额 | 默认0 |
| `discount_amount` | NUMERIC | 优惠金额 | |
| `shipping_fee` | NUMERIC | 运费 | |
| `total_amount` | NUMERIC | 订单总金额 | |
| `paid_amount` | NUMERIC | 实付金额 | |
| `shipping_address` | JSONB | 收货地址 | |
| `order_status` | INTEGER | 订单状态 | 1:待付款, 2:待发货, 3:待收货, 4:已完成, 5:已取消, 0:退款中, -1:已取消 |
| `payment_status` | INTEGER | 支付状态 | 默认1 |
| `shipping_status` | INTEGER | 发货状态 | 默认1 |
| `paid_at` | TIMESTAMP | 支付时间 | |
| `shipped_at` | TIMESTAMP | 发货时间 | |
| `delivered_at` | TIMESTAMP | 收货时间 | |
| `completed_at` | TIMESTAMP | 完成时间 | |
| `remark` | TEXT | 订单备注 | |
| `merchant_memo` | TEXT | 商家备注 | |
| `cancel_reason` | TEXT | 取消原因 | |
| `created_at` | TIMESTAMP | 创建时间 | 默认now() |
| `updated_at` | TIMESTAMP | 更新时间 | |
| `cid` | INTEGER | 序号 | |
| `payment_method` | VARCHAR | 支付方式 | |
| `payment_time` | TIMESTAMP | 支付时间 | |
| `shipping_company` | VARCHAR | 物流公司 | 商家端发货填写 |
| `tracking_number` | VARCHAR | 物流单号 | 商家端发货填写 |
### 3.3 订单项表 (`ml_order_items`) (推测结构)
### 3.3 订单项表 (`ml_order_items`)
| 字段名 | 类型 | 描述 | 备注 |
| :--- | :--- | :--- | :--- |
| `id` | UUID | 主键 | |
| `order_id` | UUID | 订单 ID | |
| `product_id` | UUID | 商品 ID | |
| `sku_id` | UUID | SKU ID | |
| `product_name` | Text | 商品名称快照 | |
| `price` | Numeric | 成交单价 | |
| `quantity` | Integer | 购买数量 | |
| `sku_snapshot` | JSONB | 规格快照 | |
| `sku_id` | UUID | SKU ID | 可空 |
| `product_name` | VARCHAR | 商品名称快照 | |
| `sku_name` | VARCHAR | SKU名称 | 可空 |
| `specifications` | JSONB | 规格信息 | 默认{} |
| `image_url` | TEXT | 商品图片 | 可空 |
| `price` | NUMERIC | 成交单价 | |
| `quantity` | INTEGER | 购买数量 | |
| `total_amount` | NUMERIC | 小计金额 | |
| `created_at` | TIMESTAMP | 创建时间 | |
| `sku_snapshot` | JSONB | SKU快照 | 默认{} |
### 3.4 订单详情视图 (`ml_orders_detail_view`)
订单联合查询视图。
| 字段名 | 类型 | 描述 | 备注 |
| :--- | :--- | :--- | :--- |
| `id` | UUID | 主键 | |
| `order_no` | VARCHAR | 订单号 | |
| `user_id` | UUID | 用户 ID | |
| `merchant_id` | UUID | 商家 ID | |
| `product_amount` | NUMERIC | 商品金额 | |
| `discount_amount` | NUMERIC | 优惠金额 | |
| `shipping_fee` | NUMERIC | 运费 | |
| `total_amount` | NUMERIC | 订单总金额 | |
| `paid_amount` | NUMERIC | 实付金额 | |
| `shipping_address` | JSONB | 收货地址 | |
| `order_status` | INTEGER | 订单状态 | |
| `payment_status` | INTEGER | 支付状态 | |
| `shipping_status` | INTEGER | 发货状态 | |
| `paid_at` | TIMESTAMP | 支付时间 | |
| `shipped_at` | TIMESTAMP | 发货时间 | |
| `delivered_at` | TIMESTAMP | 收货时间 | |
| `completed_at` | TIMESTAMP | 完成时间 | |
| `remark` | TEXT | 订单备注 | |
| `merchant_memo` | TEXT | 商家备注 | |
| `cancel_reason` | TEXT | 取消原因 | |
| `created_at` | TIMESTAMP | 创建时间 | |
| `updated_at` | TIMESTAMP | 更新时间 | |
| `customer_name` | VARCHAR | 客户姓名 | |
| `customer_phone` | TEXT | 客户电话 | |
| `merchant_name` | VARCHAR | 商家名称 | |
| `shop_name` | VARCHAR | 店铺名称 | |
| `order_status_name` | TEXT | 订单状态名称 | |
| `payment_status_name` | TEXT | 支付状态名称 | |
### 3.5 支付订单表 (`pay_order`)
支付网关订单表。
| 字段名 | 类型 | 描述 | 备注 |
| :--- | :--- | :--- | :--- |
| `id` | BIGINT | 主键 | |
| `merchant_id` | BIGINT | 商户ID | |
| `app_id` | BIGINT | 应用ID | |
| `channel_id` | BIGINT | 渠道ID | 可空 |
| `channel_code` | VARCHAR | 渠道编码 | 可空 |
| `merchant_order_id` | VARCHAR | 商户订单号 | |
| `subject` | VARCHAR | 订单标题 | |
| `body` | VARCHAR | 订单描述 | |
| `notify_url` | VARCHAR | 通知地址 | |
| `notify_status` | SMALLINT | 通知状态 | |
| `amount` | BIGINT | 金额 | |
| `channel_fee_rate` | DOUBLE | 渠道费率 | 可空 |
| `channel_fee_amount` | BIGINT | 渠道手续费 | 可空 |
| `status` | SMALLINT | 状态 | |
| `user_ip` | VARCHAR | 用户IP | |
| `expire_time` | TIMESTAMP | 过期时间 | |
| `success_time` | TIMESTAMP | 成功时间 | 可空 |
| `notify_time` | TIMESTAMP | 通知时间 | 可空 |
| `success_extension_id` | BIGINT | 成功扩展ID | 可空 |
| `refund_status` | SMALLINT | 退款状态 | |
| `refund_times` | SMALLINT | 退款次数 | |
| `refund_amount` | BIGINT | 退款金额 | |
| `channel_user_id` | VARCHAR | 渠道用户ID | 可空 |
| `channel_order_no` | VARCHAR | 渠道订单号 | 可空 |
| `creator` | VARCHAR | 创建人 | 可空 |
| `create_time` | TIMESTAMP | 创建时间 | |
| `updater` | VARCHAR | 更新人 | 可空 |
| `update_time` | TIMESTAMP | 更新时间 | |
| `deleted` | SMALLINT | 删除标记 | 默认0 |
| `tenant_id` | BIGINT | 租户ID | 默认0 |
### 3.6 支付订单扩展表 (`pay_order_extension`)
支付订单扩展信息表。
| 字段名 | 类型 | 描述 | 备注 |
| :--- | :--- | :--- | :--- |
| `id` | BIGINT | 主键 | |
| `no` | VARCHAR | 支付流水号 | |
| `order_id` | BIGINT | 支付订单ID | |
| `channel_id` | BIGINT | 渠道ID | |
| `channel_code` | VARCHAR | 渠道编码 | |
| `user_ip` | VARCHAR | 用户IP | |
| `status` | SMALLINT | 状态 | |
| `channel_extras` | VARCHAR | 渠道额外信息 | 可空 |
| `channel_notify_data` | VARCHAR | 渠道通知数据 | 可空 |
| `creator` | VARCHAR | 创建人 | 可空 |
| `create_time` | TIMESTAMP | 创建时间 | |
| `updater` | VARCHAR | 更新人 | 可空 |
| `update_time` | TIMESTAMP | 更新时间 | |
| `deleted` | SMALLINT | 删除标记 | 默认0 |
| `tenant_id` | BIGINT | 租户ID | 默认0 |
### 3.7 库存订单表 (`stock_orders`)
库存相关订单表。
| 字段名 | 类型 | 描述 | 备注 |
| :--- | :--- | :--- | :--- |
| `id` | UUID | 主键 | |
| `simulation_id` | UUID | 模拟ID | 可空 |
| `symbol` | TEXT | 交易标的 | |
| `side` | TEXT | 交易方向 | |
| `qty` | NUMERIC | 数量 | |
| `price` | NUMERIC | 价格 | 可空 |
| `status` | TEXT | 状态 | 默认created |
| `placed_at` | TIMESTAMP | 下单时间 | |
| `executed_at` | TIMESTAMP | 执行时间 | 可空 |
| `fee` | NUMERIC | 手续费 | 默认0 |
---

File diff suppressed because it is too large Load Diff

View File

@@ -83,21 +83,44 @@
<text class="section-desc">快速定位</text>
</view>
<view class="category-grid" v-if="categoryTab === 'category'">
<!-- 一级分类 -->
<view
v-for="category in categories"
v-for="category in parentCategories"
:key="category.id"
class="category-card"
@click="switchCategory(category)"
@click="onParentCategoryClick(category)"
:style="{ '--card-color': category.color }"
>
<view class="card-icon">
<text class="card-icon-text">{{ category.icon }}</text>
</view>
<text class="card-name">{{ category.name }}</text>
<text class="card-desc">{{ category.description }}</text>
</view>
</view>
<view class="category-grid" v-else>
<!-- 二级分类 -->
<view v-if="categoryTab === 'category' && showSubCategories && subCategories.length > 0" class="sub-category-grid">
<view class="sub-category-header">
<text class="sub-category-title">{{ selectedParentCategory?.name }}分类</text>
<text class="sub-category-close" @click="showSubCategories = false">✕</text>
</view>
<view class="sub-category-wrapper">
<view
v-for="subCat in subCategories"
:key="subCat.id"
class="sub-category-card"
@click="onSubCategoryClick(subCat)"
>
<view class="card-icon">
<text class="card-icon-text">{{ subCat.icon }}</text>
</view>
<text class="card-name">{{ subCat.name }}</text>
</view>
</view>
</view>
<!-- 品牌列表 -->
<view class="category-grid" v-if="categoryTab === 'brand'">
<view
v-for="brand in brands"
:key="brand.id"
@@ -190,9 +213,9 @@
<view class="price-section">
<view class="current-price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ product.base_price }}</text>
<text class="price-value">{{ product.base_price ?? product.price ?? 0 }}</text>
</view>
<text class="original-price" v-if="product.market_price != null && product.market_price! > product.base_price">
<text class="original-price" v-if="product.market_price != null && product.base_price != null && product.market_price! > product.base_price!">
¥{{ product.market_price }}
</text>
</view>
@@ -304,6 +327,12 @@ const categoryTab = ref<string>('category')
const categories = ref<Category[]>([])
const brands = ref<Brand[]>([])
// 一级分类和二级分类
const parentCategories = ref<Category[]>([])
const subCategories = ref<Category[]>([])
const selectedParentCategory = ref<Category | null>(null)
const showSubCategories = ref(false)
// 排序标签类型
type SortTab = {
id: string
@@ -345,40 +374,33 @@ const healthNews = [
// 获取分类数据
const loadCategories = async (): Promise<void> => {
try {
const categoriesData = await supabaseService.getCategories()
// 映射字段根据ml_categories表结构映射
const mappedCategories: Category[] = []
const rawList = categoriesData as any[]
for (let i = 0; i < rawList.length; i++) {
const raw = rawList[i]
const catObj = (raw instanceof UTSJSONObject) ? (raw as UTSJSONObject) : (JSON.parse(JSON.stringify(raw)) as UTSJSONObject)
const name = catObj.getString('name') ?? ''
// 过滤掉医药健康相关分类
if (name.includes('医药') || name.includes('健康')) {
continue
}
const id = catObj.getString('id') ?? ''
const description = catObj.getString('description') ?? ''
const icon = catObj.getString('icon') ?? catObj.getString('icon_url') ?? '📦'
const color = catObj.getString('color') ?? '#4CAF50'
// 使用 JSON.parse 创建对象,避免接口实例化问题
const categoryItem = JSON.parse(`{"id":"${id}","name":"${name}","icon":"${icon}","description":"${description}","color":"${color}"}`) as Category
mappedCategories.push(categoryItem)
}
// 保持原始顺序或按ID排序移除随机打乱
categories.value = mappedCategories
const categoriesData = await supabaseService.getParentCategories()
parentCategories.value = categoriesData
// 兼容其他使用 categories 的地方
categories.value = categoriesData
console.log('一级分类数据:', JSON.stringify(parentCategories.value))
} catch (error) {
console.error('加载分类数据失败:', error)
// 如果加载失败,使用默认分类作为后备
parentCategories.value = []
categories.value = []
}
}
// 获取二级分类数据
const loadSubCategories = async (parentId: string): Promise<void> => {
try {
const subData = await supabaseService.getSubCategories(parentId)
subCategories.value = subData
} catch (error) {
console.error('加载子分类数据失败:', error)
subCategories.value = []
}
}
// 获取品牌数据
const loadBrands = async () => {
const loadBrands = async (): Promise<void> => {
try {
const brandsData = await supabaseService.getBrands()
// 保持原始顺序
brands.value = brandsData
} catch (e) {
console.error('加载品牌失败:', e)
@@ -626,6 +648,35 @@ const resetNavbar = () => {
lastScrollTop.value = 0
}
// 点击一级分类
const onParentCategoryClick = async (category: Category): Promise<void> => {
// 如果已经选中,则切换显示/隐藏二级分类
if (selectedParentCategory.value != null && selectedParentCategory.value.id === category.id) {
showSubCategories.value = !showSubCategories.value
return
}
// 选中新的分类
selectedParentCategory.value = category
showSubCategories.value = true
// 加载二级分类
await loadSubCategories(category.id)
}
// 点击二级分类
const onSubCategoryClick = (category: Category): void => {
// 跳转到分类页面
uni.setStorageSync('selectedCategory', category.id)
const timestamp = Date.now()
const randomParam = Math.random().toString(36).substring(2, 8)
const url = `/pages/mall/consumer/category?categoryId=${category.id}&name=${encodeURIComponent(category.name)}&timestamp=${timestamp}&random=${randomParam}`
uni.switchTab({
url: '/pages/mall/consumer/category'
})
}
// 切换分类 - 跳转到分类页面并传递分类ID
const switchCategory = (category: any) => {
console.log('=== switchCategory函数开始执行 ===')
@@ -1149,6 +1200,7 @@ const navigateToReminders = () => uni.navigateTo({ url: '/pages/user/reminders'
/* cursor: pointer; removed for uniapp-x support */
transition: all 0.3s ease;
border: 1px solid transparent;
position: relative;
}
.category-card:hover {
@@ -1157,6 +1209,78 @@ const navigateToReminders = () => uni.navigateTo({ url: '/pages/user/reminders'
border-color: var(--card-color, #4CAF50);
}
/* 二级分类样式 */
.sub-category-grid {
background: #f8f9fa;
border-radius: 12px;
padding: 16px;
margin-top: 16px;
}
.sub-category-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
}
.sub-category-title {
font-size: 14px;
font-weight: bold;
color: #333;
}
.sub-category-close {
font-size: 16px;
color: #999;
padding: 4px 8px;
}
.sub-category-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
}
.sub-category-card {
width: 23%;
background: white;
border-radius: 8px;
padding: 10px 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid #eee;
margin-right: 2%;
margin-bottom: 10px;
}
.sub-category-card .card-icon {
width: 36px;
height: 36px;
border-radius: 18px;
margin-bottom: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.sub-category-card .card-icon-text {
font-size: 18px;
}
.sub-category-card .card-name {
font-size: 11px;
color: #333;
text-align: center;
lines: 1;
text-overflow: ellipsis;
}
.card-icon {
width: 56px;
height: 56px;

View File

@@ -193,9 +193,9 @@
<view class="price-section">
<view class="current-price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ product.base_price }}</text>
<text class="price-value">{{ product.base_price ?? product.price ?? 0 }}</text>
</view>
<text class="original-price" v-if="product.market_price != null && product.market_price! > product.base_price">
<text class="original-price" v-if="product.market_price != null && product.base_price != null && product.market_price! > product.base_price!">
¥{{ product.market_price }}
</text>
</view>

View File

@@ -55,19 +55,6 @@ const totalPoints = ref<number>(0)
const records = ref<PointRecord[]>([])
const loading = ref<boolean>(true)
onMounted(() => {
loadData()
})
const loadData = async () => {
loading.value = true
await Promise.all([
loadPoints(),
loadRecords()
])
loading.value = false
}
const loadPoints = async () => {
// 调用 service 获取积分 (需要supabaseService支持)
// 暂时如果service没更新先用mock
@@ -84,12 +71,30 @@ const loadPoints = async () => {
const loadRecords = async () => {
try {
const list = await supabaseService.getPointRecords()
records.value = list
const typedList: PointRecord[] = []
for (let i = 0; i < list.length; i++) {
const item = list[i] as PointRecord
typedList.push(item)
}
records.value = typedList
} catch (e) {
console.error('获取积分记录失败', e)
}
}
const loadData = async () => {
loading.value = true
await Promise.all<void>([
loadPoints(),
loadRecords()
])
loading.value = false
}
onMounted(() => {
loadData()
})
const handleExchange = () => {
uni.showToast({
title: '积分商城开发中',
@@ -98,14 +103,12 @@ const handleExchange = () => {
}
const getTypeText = (type: string): string => {
const map: Record<string, string> = {
'signin': '每日签到',
'shopping': '购物奖励',
'redeem': '积分兑换',
'admin': '系统调整',
'register': '注册赠送'
}
return map[type] ?? '积分变动'
if (type === 'signin') return '每日签到'
if (type === 'shopping') return '购物奖励'
if (type === 'redeem') return '积分兑换'
if (type === 'admin') return '系统调整'
if (type === 'register') return '注册赠送'
return '积分变动'
}
const formatTime = (timeStr: string): string => {

View File

@@ -81,7 +81,7 @@
</view>
<input class="quantity-input"
type="number"
v-model="quantity"
:value="quantity.toString()"
:min="1"
:max="getMaxQuantity()"
@input="validateQuantity" />
@@ -97,7 +97,7 @@
<view class="section-title">商品详情</view>
<text class="description-text">{{ product.description ?? '暂无详细描述' }}</text>
<!-- 商品详情图片 -->
<view class="detail-images" v-if="product.images && product.images.length > 0">
<view class="detail-images" v-if="product.images.length > 0">
<image v-for="(img, index) in product.images"
:key="index"
:src="img"
@@ -187,9 +187,9 @@
<text class="params-label">批准文号</text>
<text class="params-value">{{ product.approval_number }}</text>
</view>
<view class="params-item" v-if="product.tags && product.tags.length > 0">
<view class="params-item" v-if="product.tags != null && product.tags.length > 0">
<text class="params-label">标签</text>
<text class="params-value">{{ product.tags.join(', ') }}</text>
<text class="params-value">{{ product.tags!.join(', ') }}</text>
</view>
</scroll-view>
</view>
@@ -226,7 +226,7 @@
</template>
<script>
import { ProductType, MerchantType, ProductSkuType } from '@/types/mall-types.uts'
import { ProductType, MerchantType, ProductSkuType, CouponTemplateType, FootprintItemType } from '@/types/mall-types.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
export default {
@@ -269,36 +269,39 @@ export default {
isFavorite: false,
showParams: false,
// 新增: 优惠券相关
coupons: [] as any[],
coupons: [] as Array<CouponTemplateType>,
showCoupons: false
}
},
onLoad(options: any) {
const productId = (options['productId'] ?? options['id']) as string
const productPrice = options.price ? parseFloat(options.price) : null
const productOriginalPrice = options.originalPrice ? parseFloat(options.originalPrice) : null
const opts = options as UTSJSONObject
const productId = (opts.getString('productId') ?? opts.getString('id')) as string
const priceStr = opts.getString('price')
const productPrice = priceStr != null ? parseFloat(priceStr) : null
const originalPriceStr = opts.getString('originalPrice')
const productOriginalPrice = originalPriceStr != null ? parseFloat(originalPriceStr) : null
// 处理商品名称:如果是编码的则解码,否则直接使用
let productName = options.name as string
if (productName) {
let productName = opts.getString('name') as string | null
if (productName != null) {
try {
// 尝试解码如果失败不是有效的URI组件则使用原值
productName = decodeURIComponent(productName)
const decodedName = decodeURIComponent(productName)
productName = decodedName
} catch (e) {
console.warn('ProductName decode failed, using original:', productName)
}
}
let productImage = options.image as string
if (productImage) {
let productImage = opts.getString('image') as string | null
if (productImage != null) {
try {
productImage = decodeURIComponent(productImage)
const decodedImage = decodeURIComponent(productImage)
productImage = decodedImage
} catch (e) {
console.warn('ProductImage decode failed, using original:', productImage)
}
}
if (productId) {
if (productId != null) {
this.loadProductDetail(productId, {
price: productPrice,
originalPrice: productOriginalPrice,
@@ -308,8 +311,7 @@ export default {
this.checkFavoriteStatus(productId)
this.saveFootprint(productId)
// 设置导航栏标题为商品名称
if (productName) {
if (productName != null) {
uni.setNavigationBarTitle({
title: productName
})
@@ -318,9 +320,9 @@ export default {
},
computed: {
displayPrice(): number {
if (this.selectedSkuId) {
if (this.selectedSkuId != null && this.selectedSkuId !== '') {
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
if (sku) return sku.price
if (sku != null) return sku!.price
}
return this.product.price
}
@@ -329,17 +331,17 @@ export default {
saveFootprint(productId: string) {
// 调用后端API记录足迹
supabaseService.addFootprint(productId).then(success => {
if (success) {
if (success === true) {
console.log('足迹已同步到服务器')
}
})
const footprintData = uni.getStorageSync('footprints')
let footprints: any[] = []
const footprintData = uni.getStorageSync('footprints') as string | null
let footprints: Array<FootprintItemType> = []
if (footprintData) {
if (footprintData != null && footprintData !== '') {
try {
footprints = JSON.parse(footprintData as string) as any[]
footprints = JSON.parse(footprintData) as Array<FootprintItemType>
} catch (e) {
console.error('Failed to parse footprints', e)
}
@@ -372,55 +374,59 @@ export default {
uni.showLoading({ title: '加载中...' })
try {
const dbProductResponse = await supabaseService.getProductById(productId)
let dbProduct: any | null = null
if (Array.isArray(dbProductResponse) && dbProductResponse.length > 0) {
dbProduct = dbProductResponse[0]
} else if (dbProductResponse && !Array.isArray(dbProductResponse)) {
dbProduct = dbProductResponse
let dbProduct: UTSJSONObject | null = null
if (Array.isArray(dbProductResponse)) {
const arr = dbProductResponse as any[]
if (arr.length > 0) {
dbProduct = arr[0] as UTSJSONObject
}
} else if (dbProductResponse != null) {
dbProduct = dbProductResponse as UTSJSONObject
}
if (dbProduct) {
if (dbProduct != null) {
// Map DB product to local product
this.product = {
id: dbProduct.id,
merchant_id: dbProduct.merchant_id ?? dbProduct.shop_id ?? '',
category_id: dbProduct.category_id ?? '',
name: dbProduct.name,
description: dbProduct.description ?? '',
id: dbProduct['id'] as string,
merchant_id: (dbProduct['merchant_id'] ?? dbProduct['shop_id'] ?? '') as string,
category_id: (dbProduct['category_id'] ?? '') as string,
name: dbProduct['name'] as string,
description: (dbProduct['description'] ?? '') as string,
images: [] as string[],
price: dbProduct.base_price ?? dbProduct.price ?? 0,
original_price: dbProduct.market_price ?? dbProduct.original_price ?? 0,
stock: dbProduct.available_stock ?? dbProduct.total_stock ?? dbProduct.stock ?? 0,
sales: dbProduct.sale_count ?? dbProduct.sales ?? 0,
status: dbProduct.status !== undefined ? dbProduct.status : 1,
created_at: dbProduct.created_at ?? new Date().toISOString(),
price: (dbProduct['base_price'] ?? dbProduct['price'] ?? 0) as number,
original_price: (dbProduct['market_price'] ?? dbProduct['original_price'] ?? 0) as number,
stock: (dbProduct['available_stock'] ?? dbProduct['total_stock'] ?? dbProduct['stock'] ?? 0) as number,
sales: (dbProduct['sale_count'] ?? dbProduct['sales'] ?? 0) as number,
status: dbProduct['status'] != null ? dbProduct['status'] as number : 1,
created_at: (dbProduct['created_at'] ?? new Date().toISOString()) as string,
// Attributes
specification: dbProduct.specification ?? null,
usage: dbProduct.usage ?? null,
side_effects: dbProduct.side_effects ?? null,
precautions: dbProduct.precautions ?? null,
expiry_date: dbProduct.expiry_date ?? null,
storage_conditions: dbProduct.storage_conditions ?? null,
approval_number: dbProduct.approval_number ?? null,
specification: dbProduct['specification'] as string | null,
usage: dbProduct['usage'] as string | null,
side_effects: dbProduct['side_effects'] as string | null,
precautions: dbProduct['precautions'] as string | null,
expiry_date: dbProduct['expiry_date'] as string | null,
storage_conditions: dbProduct['storage_conditions'] as string | null,
approval_number: dbProduct['approval_number'] as string | null,
tags: [] as string[]
} as ProductType
// Handle Images
if (dbProduct.image_urls) {
if (dbProduct['image_urls'] != null) {
try {
const parsed = typeof dbProduct.image_urls === 'string' ? JSON.parse(dbProduct.image_urls) : dbProduct.image_urls
const imageUrls = dbProduct['image_urls']
const parsed = typeof imageUrls === 'string' ? JSON.parse(imageUrls) : imageUrls
if (Array.isArray(parsed)) {
this.product.images = parsed.map((i: any) => String(i))
this.product.images = (parsed as any[]).map((i: any): string => i as string)
}
} catch (e) { console.error('Error parsing image_urls', e) }
}
// Fallback to main_image_url if no images found
if (this.product.images.length === 0 && dbProduct.main_image_url) {
this.product.images.push(dbProduct.main_image_url)
if (this.product.images.length === 0 && dbProduct['main_image_url'] != null) {
this.product.images.push(dbProduct['main_image_url'] as string)
}
// Fallback to 'image' field (legacy)
if (this.product.images.length === 0 && dbProduct.image) {
this.product.images.push(dbProduct.image)
if (this.product.images.length === 0 && dbProduct['image'] != null) {
this.product.images.push(dbProduct['image'] as string)
}
// Final fallback
if (this.product.images.length === 0) {
@@ -428,23 +434,25 @@ export default {
}
// Handle Tags
if (dbProduct.tags) {
if (dbProduct['tags'] != null) {
try {
const parsedTags = typeof dbProduct.tags === 'string' ? JSON.parse(dbProduct.tags) : dbProduct.tags
const tagsData = dbProduct['tags']
const parsedTags = typeof tagsData === 'string' ? JSON.parse(tagsData) : tagsData
if (Array.isArray(parsedTags)) {
this.product.tags = parsedTags.map((t: any) => String(t))
this.product.tags = (parsedTags as any[]).map((t: any): string => t as string)
}
} catch (e) {}
}
// Handle JSON attributes if present
if (dbProduct.attributes && typeof dbProduct.attributes === 'string') {
const attributes = dbProduct['attributes']
if (attributes != null && typeof attributes === 'string') {
try {
const attrs = JSON.parse(dbProduct.attributes)
if (attrs) {
const attrs = JSON.parse(attributes) as UTSJSONObject | null
if (attrs != null) {
// Merge attributes into product if they match keys
if (attrs.specification) this.product.specification = attrs.specification
if (attrs.usage) this.product.usage = attrs.usage
if (attrs['specification'] != null) this.product.specification = attrs['specification'] as string
if (attrs['usage'] != null) this.product.usage = attrs['usage'] as string
// ... augment as needed
}
} catch(e) {}
@@ -456,18 +464,23 @@ export default {
console.error('Failed to load product detail:', e)
// Fallback to options if available
this.product.id = productId
this.product.name = options.name ? decodeURIComponent(options.name) : '未知商品'
this.product.price = options.price ? parseFloat(options.price) : 0
this.product.images = options.image ? [decodeURIComponent(options.image)] : ['/static/default-product.png']
const opts = options as UTSJSONObject
const nameOpt = opts['name'] as string | null
this.product.name = (nameOpt != null && nameOpt !== '') ? decodeURIComponent(nameOpt) ?? '未知商品' : '未知商品'
const priceOpt = opts['price'] as string | null
this.product.price = (priceOpt != null && priceOpt !== '') ? parseFloat(priceOpt) : 0
const imageOpt = opts['image'] as string | null
const decodedImage = (imageOpt != null && imageOpt !== '') ? decodeURIComponent(imageOpt) : null
this.product.images = decodedImage != null ? [decodedImage] : ['/static/default-product.png']
}
// Load Merchant and SKUs
if (this.product.merchant_id) {
if (this.product.merchant_id != null && this.product.merchant_id !== '') {
await this.loadMerchantInfo(this.product.merchant_id)
// 加载优惠券
this.loadCoupons()
}
if (this.product.id) {
if (this.product.id != null && this.product.id !== '') {
this.loadProductSkus(this.product.id)
}
@@ -478,21 +491,22 @@ export default {
let realMerchantLoaded = false
if (merchantId.includes('-') || !merchantId.startsWith('merchant_')) {
try {
const shop = await supabaseService.getShopByMerchantId(merchantId)
if (shop) {
const shopResponse = await supabaseService.getShopByMerchantId(merchantId)
if (shopResponse != null) {
const shop = shopResponse as UTSJSONObject
this.merchant = {
id: shop.id,
user_id: shop.merchant_id,
shop_name: shop.shop_name,
shop_logo: shop.shop_logo ?? '/static/default-shop.png',
shop_banner: shop.shop_banner ?? '/static/default-banner.png',
shop_description: shop.description ?? '',
contact_name: shop.contact_name ?? '店主',
contact_phone: shop.contact_phone ?? '',
id: shop['id'] as string,
user_id: shop['merchant_id'] as string,
shop_name: shop['shop_name'] as string,
shop_logo: (shop['shop_logo'] ?? '/static/default-shop.png') as string,
shop_banner: (shop['shop_banner'] ?? '/static/default-banner.png') as string,
shop_description: (shop['description'] ?? '') as string,
contact_name: (shop['contact_name'] ?? '店主') as string,
contact_phone: (shop['contact_phone'] ?? '') as string,
shop_status: 1,
rating: shop.rating_avg ?? 5.0,
total_sales: shop.total_sales ?? 0,
created_at: shop.created_at ?? new Date().toISOString()
rating: (shop['rating_avg'] ?? 5.0) as number,
total_sales: (shop['total_sales'] ?? 0) as number,
created_at: (shop['created_at'] ?? new Date().toISOString()) as string
} as MerchantType
realMerchantLoaded = true
}
@@ -502,7 +516,14 @@ export default {
}
if (!realMerchantLoaded) {
const merchantIndex = Math.abs(merchantId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % 5
let charSum: number = 0
for (let i = 0; i < merchantId.length; i++) {
const charCode = merchantId.charCodeAt(i)
if (charCode != null) {
charSum += charCode
}
}
const merchantIndex = Math.abs(charSum) % 5
const shopNames = ['优质好店', '品牌直营店', '官方旗舰店', '专卖店', '精品小店']
this.merchant = {
@@ -528,29 +549,31 @@ export default {
const skus = await supabaseService.getProductSkus(productId)
if (skus.length > 0) {
console.log('加载到商品SKU:', skus.length)
this.productSkus = skus.map((sku): ProductSkuType => {
let specs: UTSJSONObject = {}
if (sku.specifications) {
try {
if (typeof sku.specifications === 'string') {
specs = JSON.parse(sku.specifications) as UTSJSONObject
} else {
// 假设已经是对象
specs = sku.specifications as unknown as UTSJSONObject
this.productSkus = skus.map((skuData): ProductSkuType => {
const sku = skuData as UTSJSONObject
let specs: UTSJSONObject = {}
const specsData = sku['specifications']
if (specsData != null) {
try {
if (typeof specsData === 'string') {
specs = JSON.parse(specsData) as UTSJSONObject
} else {
// 假设已经是对象
specs = specsData as UTSJSONObject
}
} catch(e) {
console.error('解析SKU规格失败', e)
}
} catch(e) {
console.error('解析SKU规格失败', e)
}
}
}
return {
id: sku.id,
product_id: sku.product_id,
sku_code: sku.sku_code,
id: sku['id'] as string,
product_id: sku['product_id'] as string,
sku_code: sku['sku_code'] as string,
specifications: specs,
price: sku.price,
stock: sku.stock !== undefined ? sku.stock : 0,
image_url: sku.image_url != null ? sku.image_url : '',
status: sku.status !== undefined ? sku.status : 1
price: sku['price'] as number,
stock: sku['stock'] != null ? sku['stock'] as number : 0,
image_url: sku['image_url'] != null ? sku['image_url'] as string : '',
status: sku['status'] != null ? sku['status'] as number : 1
} as ProductSkuType
})
return
@@ -564,13 +587,18 @@ export default {
async loadCoupons() {
if (this.product.merchant_id == '') return
// Safety check for cached service definition
// @ts-ignore
if (typeof supabaseService.fetchShopCoupons === 'function') {
this.coupons = await supabaseService.fetchShopCoupons(this.product.merchant_id)
} else if (typeof supabaseService.getAvailableCoupons === 'function') {
this.coupons = await supabaseService.getAvailableCoupons(this.product.merchant_id)
} else {
console.warn('SupabaseService.fetchShopCoupons method missing in runtime. Please restart project.')
try {
// @ts-ignore
const couponData = await supabaseService.fetchShopCoupons(this.product.merchant_id)
this.coupons = couponData as Array<CouponTemplateType>
} catch (e) {
try {
// @ts-ignore
const couponData2 = await supabaseService.getAvailableCoupons(this.product.merchant_id)
this.coupons = couponData2 as Array<CouponTemplateType>
} catch (e2) {
console.warn('SupabaseService coupon methods not available:', e2)
}
}
},
@@ -596,7 +624,7 @@ export default {
},
// 新增:领取优惠券
async claimCoupon(coupon: any) {
async claimCoupon(coupon: CouponTemplateType) {
const userId = supabaseService.getCurrentUserId()
if (userId == '') {
uni.navigateTo({ url: '/pages/auth/login' })
@@ -605,17 +633,21 @@ export default {
uni.showLoading({ title: '领取中' })
let success = false
// @ts-ignore
if (typeof supabaseService.claimShopCoupon === 'function') {
success = await supabaseService.claimShopCoupon(coupon.id, userId)
} else if (typeof supabaseService.claimCoupon === 'function') {
success = await supabaseService.claimCoupon(coupon.id, userId)
} else {
console.warn('claimCoupon method missing')
const couponId = coupon.id
try {
// @ts-ignore
success = await supabaseService.claimShopCoupon(couponId, userId!)
} catch (e) {
try {
// @ts-ignore
success = await supabaseService.claimCoupon(couponId, userId!)
} catch (e2) {
console.warn('claimCoupon method missing:', e2)
}
}
uni.hideLoading()
if (success) {
if (success === true) {
uni.showToast({ title: '领取成功', icon: 'success' })
} else {
uni.showToast({ title: '领取失败或已领取', icon: 'none' })
@@ -629,7 +661,9 @@ export default {
},
onSwiperChange(e: any) {
this.currentImageIndex = e.detail.current
const eventObj = e as UTSJSONObject
const detail = eventObj['detail'] as UTSJSONObject
this.currentImageIndex = detail['current'] as number
},
showSpecModal() {
@@ -647,15 +681,16 @@ export default {
},
getSkuSpecText(sku: ProductSkuType): string {
if (sku.specifications) {
const specs: any = sku.specifications
return Object.keys(specs).map(key => `${key}: ${specs[key]}`).join(', ')
if (sku.specifications != null) {
const specs = sku.specifications as UTSJSONObject
// 简化处理,直接返回 JSON 字符串
return JSON.stringify(specs)
}
return sku.sku_code
},
async addToCart() {
if (this.productSkus.length > 0 && !this.selectedSkuId) {
if (this.productSkus.length > 0 && (this.selectedSkuId == null || this.selectedSkuId === '')) {
uni.showToast({
title: '请选择规格',
icon: 'none'
@@ -673,7 +708,7 @@ export default {
)
uni.hideLoading()
if (success) {
if (success === true) {
uni.showToast({ title: '已添加到购物车', icon: 'success' })
} else {
console.error('添加购物车返回失败')
@@ -687,7 +722,7 @@ export default {
},
buyNow() {
if (this.productSkus.length > 0 && !this.selectedSkuId) {
if (this.productSkus.length > 0 && (this.selectedSkuId == null || this.selectedSkuId === '')) {
uni.showToast({
title: '请选择规格',
icon: 'none'
@@ -695,29 +730,24 @@ export default {
return
}
const sku = this.selectedSkuId ? this.productSkus.find(s => s.id === this.selectedSkuId) : null
const sku = (this.selectedSkuId != null && this.selectedSkuId !== '') ? this.productSkus.find(s => s.id === this.selectedSkuId) : null
const selectedItem = {
id: this.selectedSkuId,
product_id: this.product.id,
sku_id: this.selectedSkuId,
product_name: this.product.name,
product_image: (sku && sku.image_url) ? sku.image_url : this.product.images[0],
sku_specifications: sku ? sku.specifications : {},
price: Number(parseFloat((sku ? sku.price : this.product.price).toString()).toFixed(2)),
quantity: Number(this.quantity)
product_image: (sku != null && sku.image_url != null) ? sku!.image_url : this.product.images[0],
sku_specifications: sku != null ? sku!.specifications : {},
price: parseFloat((sku != null ? sku!.price : this.product.price).toString()).toFixed(2) as string,
quantity: this.quantity as number
}
uni.setStorageSync('checkout_type', 'buy_now')
uni.setStorageSync('checkout_items', JSON.stringify([selectedItem]))
uni.navigateTo({
url: '/pages/mall/consumer/checkout',
success: (res) => {
res.eventChannel.emit('acceptData', {
selectedItems: [selectedItem]
})
}
url: '/pages/mall/consumer/checkout'
})
},
@@ -761,7 +791,7 @@ export default {
},
goToShop() {
if (this.merchant.user_id) {
if (this.merchant.user_id != null && this.merchant.user_id !== '') {
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?merchantId=${this.merchant.id}`
})
@@ -788,8 +818,7 @@ export default {
},
validateQuantity() {
let num = parseInt(this.quantity)
if (isNaN(num)) num = 1
let num = this.quantity
const maxQuantity = this.getMaxQuantity()
if (num < 1) num = 1
else if (num > maxQuantity) {
@@ -800,9 +829,9 @@ export default {
},
getMaxQuantity() {
if (this.selectedSkuId) {
if (this.selectedSkuId != null && this.selectedSkuId !== '') {
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
if (sku) return sku.stock
if (sku != null) return sku!.stock
}
return this.product.stock
},

View File

@@ -263,6 +263,7 @@ type OrderCountsType = {
pending: number
toship: number
shipped: number
review: number
}
type ServiceCountsType = {
@@ -306,7 +307,8 @@ export default {
pending: 0,
toship: 0,
shipped: 0,
} as any,
review: 0
} as OrderCountsType,
serviceCounts: {
coupons: 0,
favorites: 0
@@ -346,20 +348,32 @@ export default {
uni.$off('orderUpdated', this.handleOrderUpdated)
},
computed: {
// 根据当前Tab筛选订单
filteredOrders(): Array<OrderType> {
const result: Array<OrderType> = []
if (this.currentOrderTab === 'all') {
return this.allOrders
} else if (this.currentOrderTab === 'pending') {
return this.allOrders.filter((order: OrderType): boolean => order.status === 1)
} else if (this.currentOrderTab === 'toship') {
return this.allOrders.filter((order: OrderType): boolean => order.status === 2)
} else if (this.currentOrderTab === 'shipped') {
return this.allOrders.filter((order: OrderType): boolean => order.status === 3)
} else if (this.currentOrderTab === 'review') {
return this.allOrders.filter((order: OrderType): boolean => order.status === 4)
for (let i: number = 0; i < this.allOrders.length; i++) {
result.push(this.allOrders[i])
}
return result
}
return []
let targetStatus: number = 0
if (this.currentOrderTab === 'pending') {
targetStatus = 1
} else if (this.currentOrderTab === 'toship') {
targetStatus = 2
} else if (this.currentOrderTab === 'shipped') {
targetStatus = 3
} else if (this.currentOrderTab === 'review') {
targetStatus = 4
} else {
return result
}
for (let i: number = 0; i < this.allOrders.length; i++) {
if (this.allOrders[i].status === targetStatus) {
result.push(this.allOrders[i])
}
}
return result
}
},
methods: {
@@ -368,38 +382,70 @@ export default {
try {
const orders = await supabaseService.getOrders()
// 映射数据库字段到前端类型
this.allOrders = orders.map((o: any): OrderType => {
// 确保 status 字段存在
if (o['status'] == null && o['order_status'] != null) {
o['status'] = o['order_status']
}
// 确保 actual_amount 存在
if (o['actual_amount'] == null && o['total_amount'] != null) {
o['actual_amount'] = o['total_amount']
}
return o as OrderType
})
const mappedOrders: Array<OrderType> = []
for (let i: number = 0; i < orders.length; i++) {
const o = orders[i] as UTSJSONObject
const orderObj = new UTSJSONObject()
// 按时间倒序 (created_at)
this.allOrders.sort((a: any, b: any) => {
const dateA = a['created_at']
const dateB = b['created_at']
const timeA = new Date(dateA != null ? dateA : 0).getTime()
const timeB = new Date(dateB != null ? dateB : 0).getTime()
return timeB - timeA
})
const keys = UTSJSONObject.keys(o)
for (let j: number = 0; j < keys.length; j++) {
const key = keys[j]
orderObj.set(key, o.get(key))
}
// 过滤最近的订单
this.recentOrders = this.allOrders.slice(0, 5)
if (o.getNumber('status') == null && o.getNumber('order_status') != null) {
orderObj.set('status', o.getNumber('order_status'))
}
if (o.getNumber('actual_amount') == null && o.getNumber('total_amount') != null) {
orderObj.set('actual_amount', o.getNumber('total_amount'))
}
mappedOrders.push(orderObj as OrderType)
}
for (let i: number = 0; i < mappedOrders.length; i++) {
for (let j: number = i + 1; j < mappedOrders.length; j++) {
const dateA = mappedOrders[i]['created_at'] as string
const dateB = mappedOrders[j]['created_at'] as string
const timeA = new Date(dateA != null ? dateA : '1970-01-01').getTime()
const timeB = new Date(dateB != null ? dateB : '1970-01-01').getTime()
if (timeA < timeB) {
const temp = mappedOrders[i]
mappedOrders[i] = mappedOrders[j]
mappedOrders[j] = temp
}
}
}
this.allOrders = mappedOrders
const recentList: Array<OrderType> = []
const limit = mappedOrders.length < 5 ? mappedOrders.length : 5
for (let i: number = 0; i < limit; i++) {
recentList.push(mappedOrders[i])
}
this.recentOrders = recentList
let total = 0
let pending = 0
let toship = 0
let shipped = 0
let review = 0
for (let i: number = 0; i < mappedOrders.length; i++) {
total++
const status = mappedOrders[i].status
if (status === 1) pending++
else if (status === 2) toship++
else if (status === 3) shipped++
else if (status === 4) review++
}
// 更新角标统计 (确保状态码一致: 1=待支付, 2=待发货, 3=待收货, 4=待评价)
this.orderCounts = {
total: this.allOrders.length,
pending: this.allOrders.filter((o: any) => o.status === 1).length,
toship: this.allOrders.filter((o: any) => o.status === 2).length,
shipped: this.allOrders.filter((o: any) => o.status === 3).length,
review: this.allOrders.filter((o: any) => o.status === 4).length
total: total,
pending: pending,
toship: toship,
shipped: shipped,
review: review
}
} catch (e) {
console.error('加载订单异常', e)
@@ -413,14 +459,11 @@ export default {
// 获取当前订单部分标题
getOrderSectionTitle(): string {
const titles: Record<string, string> = {
'all': '全部订单',
'pending': '待支付订单',
'shipped': '待收货订单',
'review': '待评价订单'
}
const title = titles[this.currentOrderTab]
return title != null ? title : '我的订单'
if (this.currentOrderTab === 'all') return '全部订单'
if (this.currentOrderTab === 'pending') return '待支付订单'
if (this.currentOrderTab === 'shipped') return '待收货订单'
if (this.currentOrderTab === 'review') return '待评价订单'
return '我的订单'
},
initPage() {
@@ -448,16 +491,16 @@ export default {
uAvatar = profile.getString('avatar_url') ?? ''
uGender = profile.getNumber('gender') ?? 0
} else {
uId = (profile['user_id'] as string) ?? ''
uPhone = (profile['phone'] as string) ?? ''
uEmail = (profile['email'] as string) ?? ''
uNickname = (profile['nickname'] as string) ?? ''
uAvatar = (profile['avatar_url'] as string) ?? ''
uGender = (profile['gender'] as number) ?? 0
const profileObj = profile as UTSJSONObject
uId = (profileObj.getString('user_id') ?? '') as string
uPhone = (profileObj.getString('phone') ?? '') as string
uEmail = (profileObj.getString('email') ?? '') as string
uNickname = (profileObj.getString('nickname') ?? '') as string
uAvatar = (profileObj.getString('avatar_url') ?? '') as string
uGender = (profileObj.getNumber('gender') ?? 0) as number
}
// 如果昵称为空,使用手机号脱敏显示
if (!uNickname && uPhone) {
if (uNickname === '' && uPhone !== '') {
uNickname = uPhone.substring(0, 3) + '****' + uPhone.substring(7)
}
@@ -510,35 +553,35 @@ export default {
},
loadConsumptionStats() {
// 模拟加载消费统计数据
const statsData: Record<string, ConsumptionStatsType> = {
month: {
if (this.activeStatsPeriod === 'month') {
this.currentStats = {
total_amount: 1280.50,
order_count: 8,
avg_amount: 160.06,
save_amount: 85.20
},
quarter: {
} as ConsumptionStatsType
} else if (this.activeStatsPeriod === 'quarter') {
this.currentStats = {
total_amount: 3680.80,
order_count: 18,
avg_amount: 204.49,
save_amount: 256.30
},
year: {
} as ConsumptionStatsType
} else if (this.activeStatsPeriod === 'year') {
this.currentStats = {
total_amount: 15680.90,
order_count: 56,
avg_amount: 280.02,
save_amount: 986.50
},
all: {
} as ConsumptionStatsType
} else {
this.currentStats = {
total_amount: 25680.50,
order_count: 89,
avg_amount: 288.55,
save_amount: 1580.20
}
} as ConsumptionStatsType
}
this.currentStats = statsData[this.activeStatsPeriod]
},
refreshData() {
@@ -584,26 +627,29 @@ export default {
},
getOrderMainImage(order: any): string {
// 尝试从 ml_order_items 获取第一张图
const items = order['ml_order_items'] as any[]
if (items != null && items.length > 0) {
const firstItem = items[0]
// 数据库字段通常是 image_url
const imgUrl = firstItem['image_url'] as string
const prodImg = firstItem['product_image'] as string
const img = (imgUrl != null && imgUrl != '') ? imgUrl : prodImg
if (img != null && img != '') return img
const orderObj = order as UTSJSONObject
const itemsRaw = orderObj.get('ml_order_items')
if (itemsRaw == null) return '/static/product1.jpg'
const items = itemsRaw as any[]
if (items.length > 0) {
const firstItem = items[0] as UTSJSONObject
const imgUrl = firstItem.getString('image_url') ?? ''
const prodImg = firstItem.getString('product_image') ?? ''
const img = imgUrl !== '' ? imgUrl : prodImg
if (img !== '') return img
}
return '/static/product1.jpg'
},
getOrderTitle(order: any): string {
const items = order['ml_order_items'] as any[]
if (items != null && items.length > 0) {
const firstItem = items[0]
const pName = firstItem['product_name'] as string
const name = (pName != null && pName != '') ? pName : '商品'
const orderObj = order as UTSJSONObject
const itemsRaw = orderObj.get('ml_order_items')
if (itemsRaw == null) return '精选商品'
const items = itemsRaw as any[]
if (items.length > 0) {
const firstItem = items[0] as UTSJSONObject
const pName = firstItem.getString('product_name') ?? ''
const name = pName !== '' ? pName : '商品'
if (items.length > 1) {
return `${name} 等${items.length}件商品`
@@ -780,19 +826,18 @@ export default {
})
},
// 处理订单更新事件
handleOrderUpdated(data: any) {
// 当收到订单更新事件时,刷新订单数据
console.log('收到订单更新事件:', data)
this.refreshData()
// 显示提示
if (data.status === 1) {
const dataObj = data as UTSJSONObject
const status = dataObj.getNumber('status')
if (status === 1) {
uni.showToast({
title: '订单已保存到待支付',
icon: 'success'
})
} else if (data.status === 2) {
} else if (status === 2) {
uni.showToast({
title: '支付成功,订单待发货',
icon: 'success'

View File

@@ -15,7 +15,7 @@
<text>加载中...</text>
</view>
<scroll-view v-else class="packet-list" scroll-y>
<scroll-view v-else class="packet-list" direction="vertical">
<view v-if="filteredPackets.length === 0" class="empty-state">
<text class="empty-text">暂无相关红包</text>
</view>
@@ -55,58 +55,45 @@ type RedPacket = {
const loading = ref(true)
const currentTab = ref(0)
const packets = ref<RedPacket[]>([])
const packets = ref<Array<RedPacket>>([])
const filteredPackets = computed((): RedPacket[] => {
const filteredPackets = computed((): Array<RedPacket> => {
const result: Array<RedPacket> = []
if (currentTab.value === 0) {
return packets.value.filter((p:RedPacket):boolean => p.status === 0)
for (let i: number = 0; i < packets.value.length; i++) {
if (packets.value[i].status === 0) {
result.push(packets.value[i])
}
}
} else {
return packets.value.filter((p:RedPacket):boolean => p.status !== 0)
for (let i: number = 0; i < packets.value.length; i++) {
if (packets.value[i].status !== 0) {
result.push(packets.value[i])
}
}
}
})
onMounted(() => {
loadData()
return result
})
const loadData = async () => {
loading.value = true
try {
const rawList = await supabaseService.getUserRedPackets()
packets.value = rawList.map((item: any): RedPacket => {
let id = ''
let amount = 0
let name = ''
let status = 0
let expireAt = ''
let createdAt = ''
if (item instanceof UTSJSONObject) {
id = item.getString('id') ?? ''
amount = item.getNumber('amount') ?? 0
name = item.getString('name') ?? ''
status = item.getNumber('status') ?? 0
expireAt = item.getString('expire_at') ?? ''
createdAt = item.getString('created_at') ?? ''
} else {
id = (item['id'] as string) ?? ''
amount = (item['amount'] as number) ?? 0
name = (item['name'] as string) ?? ''
status = (item['status'] as number) ?? 0
expireAt = (item['expire_at'] as string) ?? ''
createdAt = (item['created_at'] as string) ?? ''
}
return {
id: id,
const mappedList: Array<RedPacket> = []
for (let i: number = 0; i < rawList.length; i++) {
const item = rawList[i] as UTSJSONObject
const packet: RedPacket = {
id: item.getString('id') ?? '',
user_id: '',
amount: amount,
name: name,
status: status,
expire_at: expireAt,
created_at: createdAt
amount: item.getNumber('amount') ?? 0,
name: item.getString('name') ?? '',
status: item.getNumber('status') ?? 0,
expire_at: item.getString('expire_at') ?? '',
created_at: item.getString('created_at') ?? ''
} as RedPacket
})
mappedList.push(packet)
}
packets.value = mappedList
} catch (e) {
console.error(e)
} finally {
@@ -114,6 +101,10 @@ const loadData = async () => {
}
}
onMounted(() => {
loadData()
})
const usePacket = (item: RedPacket) => {
uni.switchTab({
url: '/pages/mall/consumer/index'

View File

@@ -22,7 +22,7 @@
</view>
<!-- 售后列表 -->
<scroll-view class="refund-content" scroll-y @scrolltolower="loadMore">
<scroll-view class="refund-content" direction="vertical" @scrolltolower="loadMore">
<!-- 空状态 -->
<view v-if="refunds.length === 0 && !isLoading" class="empty-refunds">
<text class="empty-icon">🔄</text>
@@ -58,7 +58,7 @@
</view>
<!-- 进度时间线 -->
<view v-if="refund.status_history?.length > 0" class="timeline">
<view v-if="refund.status_history != null && refund.status_history.length > 0" class="timeline">
<view v-for="(step, index) in getTimelineSteps(refund)"
:key="index"
class="timeline-step">
@@ -116,7 +116,7 @@ type RefundProductInfo = {
type RefundOrderItem = {
id: string
product_name: string
sku_specifications: any
sku_specifications: any | null
price: number
quantity: number
product?: RefundProductInfo
@@ -157,27 +157,17 @@ const currentPage = ref<number>(1)
const pageSize = ref<number>(15)
const hasMore = ref<boolean>(true)
// 监听标签页变化
watch(activeTab, () => {
resetData()
loadRefunds()
})
const getCurrentUserId = (): string => {
return supabaseService.getCurrentUserId() ?? ''
}
// 生命周期
onMounted(() => {
loadRefunds()
loadTabCounts()
})
// 重置数据
const resetData = () => {
refunds.value = []
currentPage.value = 1
hasMore.value = true
}
// 加载售后数据
const loadRefunds = async (loadMore: boolean = false) => {
const loadRefunds = async (loadMore: boolean): Promise<void> => {
if (isLoading.value || (!hasMore.value && loadMore)) {
return
}
@@ -197,46 +187,66 @@ const loadRefunds = async (loadMore: boolean = false) => {
let statusList: number[] = []
if (activeTab.value === 'processing') {
statusList = [1, 2] // 待处理和处理中
statusList = [1, 2]
} else if (activeTab.value === 'completed') {
statusList = [3, 4, 5] // 已完成、已取消、已拒绝
statusList = [3, 4, 5]
}
const rawData = await supabaseService.getRefunds(statusList, page, pageSize.value)
// Map data to UI structure (RefundType)
const newRefunds = rawData.map((item: any): RefundType => {
const orderObj: any = item['order'] ?? {}
const dbItems: any[] = (orderObj['ml_order_items'] as any[]) ?? []
const uiItems = dbItems.map((di: any) : RefundOrderItem => ({
id: di['id'] ?? '',
product_name: di['product_name'] ?? '',
sku_specifications: di['specifications'],
price: 0,
quantity: di['quantity'] ?? 1,
product: { images: [di['image_url'] ?? '/static/default-product.png'] }
}))
const newRefunds: Array<RefundType> = []
for (let i: number = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const orderObjRaw = item.get('order')
const orderObj = (orderObjRaw != null) ? (orderObjRaw as UTSJSONObject) : (new UTSJSONObject())
const dbItemsRaw = orderObj.get('ml_order_items')
const dbItems = (dbItemsRaw != null) ? (dbItemsRaw as any[]) : []
return {
id: item['id'],
user_id: item['user_id'],
order_id: item['order_id'],
refund_no: item['refund_no'],
refund_type: item['refund_type'],
refund_reason: item['refund_reason'],
refund_amount: Number(item['refund_amount']),
status: item['status'],
// Handle missing timeline by defaulting or leaving empty
status_history: (item['status_history'] as RefundStatusHistoryItem[]) ?? [],
created_at: item.created_at,
const uiItems: Array<RefundOrderItem> = []
for (let j: number = 0; j < dbItems.length; j++) {
const di = dbItems[j] as UTSJSONObject
const imgRaw = di.get('image_url')
const imgUrl = (imgRaw != null) ? (imgRaw as string) : '/static/default-product.png'
const productInfo: RefundProductInfo = {
images: [imgUrl]
} as RefundProductInfo
const specRaw = di.get('specifications')
const specifications = (specRaw != null) ? (specRaw as any) : null
const orderItem: RefundOrderItem = {
id: di.getString('id') ?? '',
product_name: di.getString('product_name') ?? '',
sku_specifications: specifications,
price: 0,
quantity: di.getNumber('quantity') ?? 1,
product: productInfo
} as RefundOrderItem
uiItems.push(orderItem)
}
const statusHistoryRaw = item.get('status_history')
const statusHistory = (statusHistoryRaw != null) ? (statusHistoryRaw as RefundStatusHistoryItem[]) : []
const refundItem: RefundType = {
id: item.getString('id') ?? '',
user_id: item.getString('user_id') ?? '',
order_id: item.getString('order_id') ?? '',
refund_no: item.getString('refund_no') ?? '',
refund_type: item.getNumber('refund_type') ?? 1,
refund_reason: item.getString('refund_reason') ?? '',
refund_amount: item.getNumber('refund_amount') ?? 0,
status: item.getNumber('status') ?? 1,
status_history: statusHistory,
created_at: item.getString('created_at') ?? '',
order: {
id: item.order_id,
order_no: orderObj.order_no,
created_at: orderObj.created_at,
id: item.getString('order_id') ?? '',
order_no: orderObj.getString('order_no') ?? '',
created_at: orderObj.getString('created_at') ?? '',
order_items: uiItems
}
} as RefundOrderInfo
} as RefundType
})
newRefunds.push(refundItem)
}
if (loadMore) {
refunds.value.push(...newRefunds)
@@ -254,57 +264,44 @@ const loadRefunds = async (loadMore: boolean = false) => {
}
}
// 加载标签页计数
const loadTabCounts = async () => {
const userId = getCurrentUserId()
if (userId == '') return
try {
const { count, error } = await supa
.from('refunds')
.select('*', { count: 'exact' })
.eq('user_id', userId)
.in('status', [1, 2])
if (error !== null) {
console.error('加载计数失败:', error)
return
}
tabCounts.value.processing = count ?? 0
const processingRefunds = await supabaseService.getRefunds([1, 2], 1, 100)
tabCounts.value.processing = processingRefunds.length
} catch (err) {
console.error('加载计数异常:', err)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore['id'] ?? ''
}
watch(activeTab, () => {
resetData()
loadRefunds(false)
})
onMounted(() => {
loadRefunds(false)
loadTabCounts()
})
// 获取状态文本
const getStatusText = (status: number): string => {
const statusMap: Record<number, string> = {
1: '处理',
2: '处理中',
3: '已完成',
4: '已取消',
5: '已拒绝'
}
return statusMap[status] ?? '未知状态'
if (status === 1) return '待处理'
if (status === 2) return '处理'
if (status === 3) return '已完成'
if (status === 4) return '已取消'
if (status === 5) return '已拒绝'
return '未知状态'
}
// 获取状态样式类
const getStatusClass = (status: number): string => {
const classMap: Record<number, string> = {
1: 'status-pending',
2: 'status-processing',
3: 'status-completed',
4: 'status-cancelled',
5: 'status-rejected'
}
return classMap[status] ?? 'status-unknown'
if (status === 1) return 'status-pending'
if (status === 2) return 'status-processing'
if (status === 3) return 'status-completed'
if (status === 4) return 'status-cancelled'
if (status === 5) return 'status-rejected'
return 'status-unknown'
}
// 获取商品图片
@@ -337,45 +334,58 @@ const formatTime = (timeStr?: string): string => {
return `${month}-${day}`
}
// 获取时间线步骤
const getTimelineSteps = (refund: RefundType): Array<any> => {
const steps = [
{ status: 0, title: '提交申请', time: refund.created_at },
{ status: 1, title: '商家处理', time: '' },
{ status: 3, title: '退款完成', time: '' }
]
// 如果有状态历史,更新时间和描述
if (refund.status_history) {
refund.status_history.forEach(history => {
if (history.status === 1 || history.status === 2) {
steps[1].time = history.created_at
steps[1].desc = history.remark
} else if (history.status === 3) {
steps[2].time = history.created_at
steps[2].desc = history.remark
}
})
}
// 标记当前状态
return steps.map((step, index) => ({
...step,
active: index === getCurrentStepIndex(refund.status),
completed: index < getCurrentStepIndex(refund.status)
}))
const getCurrentStepIndex = (status: number): number => {
if (status === 1) return 0
if (status === 2) return 1
if (status === 3) return 2
if (status === 4) return 0
if (status === 5) return 1
return 0
}
// 获取当前步骤索引
const getCurrentStepIndex = (status: number): number => {
switch (status) {
case 1: return 0 // 待处理
case 2: return 1 // 处理中
case 3: return 2 // 已完成
case 4: return 0 // 已取消
case 5: return 1 // 已拒绝
default: return 0
type TimelineStepType = {
status: number,
title: string,
time: string,
active: boolean,
completed: boolean,
desc: string
}
const getTimelineSteps = (refund: RefundType): Array<TimelineStepType> => {
const steps: Array<TimelineStepType> = [
{ status: 0, title: '提交申请', time: refund.created_at, active: false, completed: false, desc: '' },
{ status: 1, title: '商家处理', time: '', active: false, completed: false, desc: '' },
{ status: 3, title: '退款完成', time: '', active: false, completed: false, desc: '' }
]
if (refund.status_history != null) {
for (let i: number = 0; i < refund.status_history.length; i++) {
const history = refund.status_history[i]
if (history.status === 1 || history.status === 2) {
steps[1].time = history.created_at ?? ''
steps[1].desc = history.remark ?? ''
} else if (history.status === 3) {
steps[2].time = history.created_at ?? ''
steps[2].desc = history.remark ?? ''
}
}
}
const currentStepIndex = getCurrentStepIndex(refund.status)
const result: Array<TimelineStepType> = []
for (let i: number = 0; i < steps.length; i++) {
const step = steps[i]
result.push({
status: step.status,
title: step.title,
time: step.time,
desc: step.desc,
active: i === currentStepIndex,
completed: i < currentStepIndex
})
}
return result
}
// 切换标签页
@@ -397,45 +407,42 @@ const viewOrder = (orderId: string) => {
})
}
// 取消退款申请
const doCancelRefund = async (refund: RefundType) => {
try {
const result = await supabaseService.createRefund({
id: refund.id,
status: 4
} as any)
if (result.success) {
refund.status = 4
loadTabCounts()
uni.showToast({
title: '已取消',
icon: 'success'
})
} else {
uni.showToast({
title: '取消失败',
icon: 'none'
})
}
} catch (err) {
console.error('取消退款失败:', err)
uni.showToast({
title: '取消失败',
icon: 'none'
})
}
}
const cancelRefund = (refund: RefundType) => {
uni.showModal({
title: '取消申请',
content: '确定要取消这个退款申请吗?',
success: async (res) => {
success: (res) => {
if (res.confirm) {
try {
const { error } = await supa
.from('refunds')
.update({
status: 4, // 已取消
status_history: [...(refund.status_history ?? []), {
status: 4,
remark: '用户取消申请',
created_at: new Date().toISOString()
}]
})
.eq('id', refund.id)
if (error !== null) {
throw error
}
refund.status = 4
loadTabCounts() // 重新加载计数
uni.showToast({
title: '已取消',
icon: 'success'
})
} catch (err) {
console.error('取消退款失败:', err)
uni.showToast({
title: '取消失败',
icon: 'none'
})
}
doCancelRefund(refund)
}
}
})
@@ -455,41 +462,45 @@ const reviewRefund = (refund: RefundType) => {
})
}
// 删除记录
const doDeleteRefund = async (refund: RefundType) => {
try {
const result = await supabaseService.deleteRefund(refund.id)
if (result) {
const newRefunds: Array<RefundType> = []
for (let i: number = 0; i < refunds.value.length; i++) {
if (refunds.value[i].id !== refund.id) {
newRefunds.push(refunds.value[i])
}
}
refunds.value = newRefunds
uni.showToast({
title: '删除成功',
icon: 'success'
})
} else {
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
} catch (err) {
console.error('删除记录失败:', err)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
const deleteRefund = (refund: RefundType) => {
uni.showModal({
title: '删除记录',
content: '确定要删除这个售后记录吗?',
success: async (res) => {
success: (res) => {
if (res.confirm) {
try {
const { error } = await supa
.from('refunds')
.delete()
.eq('id', refund.id)
if (error !== null) {
throw error
}
const index = refunds.value.findIndex(r => r.id === refund.id)
if (index !== -1) {
refunds.value.splice(index, 1)
refunds.value = [...refunds.value]
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
} catch (err) {
console.error('删除记录失败:', err)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
doDeleteRefund(refund)
}
}
})

View File

@@ -7,11 +7,11 @@
<text class="header-title">评价商品</text>
</view>
<scroll-view class="review-content" scroll-y>
<scroll-view class="review-content" direction="vertical">
<!-- 订单信息 -->
<view class="order-section">
<text class="order-no">订单号: {{ order?.order_no }}</text>
<text class="order-time">下单时间: {{ formatTime(order?.created_at) }}</text>
<text class="order-no">订单号: {{ order != null ? order.order_no : '' }}</text>
<text class="order-time">下单时间: {{ formatTime(order != null ? order.created_at : '') }}</text>
</view>
<!-- 商品评价 -->
@@ -21,7 +21,7 @@
<image class="product-image" :src="item.product_image ?? '/static/default-product.png'" />
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text v-if="item.sku_specifications" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
<text v-if="item.sku_specifications != null" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
</view>
</view>
@@ -133,9 +133,9 @@
<!-- 提交按钮 -->
<view class="submit-section">
<button class="submit-btn"
:class="{ disabled: !canSubmit || isSubmitting }"
:class="{ disabled: canSubmit === false || isSubmitting }"
@click="submitReview">
<text v-if="!isSubmitting" class="submit-text">提交评价</text>
<text v-if="isSubmitting === false" class="submit-text">提交评价</text>
<text v-else class="submit-text">提交中...</text>
</button>
</view>
@@ -146,17 +146,32 @@
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supa from '@/components/supadb/aksupainstance.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
type OrderType = {
id: string
order_no: string
created_at: string
merchant_id: string
}
type OrderItemType = {
id: string
product_id: string
id: number
order_id: number
product_id: number
product_name: string
product_image: string
sku_specifications: any
sku_specifications: any | null
price: number
quantity: number
}
type MerchantRatingType = {
description: number
logistics: number
service: number
}
type MerchantType = {
id: string
shop_name: string
@@ -164,85 +179,113 @@ type MerchantType = {
}
const orderId = ref<string>('')
const order = ref<any>({})
const order = ref<OrderType | null>(null)
const orderItems = ref<Array<OrderItemType>>([])
const merchant = ref<MerchantType | null>(null)
const ratings = ref<Array<number>>([])
const contents = ref<Array<string>>([])
const images = ref<Array<Array<string>>>([])
const anonymous = ref<boolean>(false)
const merchantRating = ref({
const merchantRating = ref<MerchantRatingType>({
description: 5,
logistics: 5,
service: 5
})
} as MerchantRatingType)
const isSubmitting = ref<boolean>(false)
// 计算属性
const canSubmit = computed(() => {
// 检查是否所有商品都已评分
if (ratings.value.length === 0) return false
return ratings.value.every(rating => rating > 0)
})
// 生命周期
onLoad((options: any) => {
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
orderId.value = optObj.getString('orderId') ?? ''
if (orderId.value != '') loadOrderData()
})
// 加载订单数据
const loadOrderData = async () => {
const loadOrderData = async (): Promise<void> => {
try {
const { data: orderData, error: orderError } = await supa
const orderRes = await supa
.from('ml_orders')
.select('*')
.eq('id', orderId.value)
.single()
.execute()
if (orderError !== null) {
console.error('加载订单失败:', orderError)
if (orderRes.error != null) {
console.error('加载订单失败:', orderRes.error)
return
}
order.value = orderData
if (orderRes.data != null) {
const orderData = orderRes.data as UTSJSONObject
order.value = {
id: orderData.getString('id') ?? '',
order_no: orderData.getString('order_no') ?? '',
created_at: orderData.getString('created_at') ?? '',
merchant_id: orderData.getString('merchant_id') ?? ''
} as OrderType
}
// 加载订单商品
const { data: itemsData, error: itemsError } = await supa
const itemsRes = await supa
.from('ml_order_items')
.select(`
*,
product:product_id(images)
`)
.eq('order_id', orderId.value)
.execute()
if (itemsError !== null) {
console.error('加载订单商品失败:', itemsError)
if (itemsRes.error != null) {
console.error('加载订单商品失败:', itemsRes.error)
return
}
orderItems.value = (itemsData ?? []).map((item: any) => ({
...item,
product_image: item.product?.images?.[0] ?? '/static/default-product.png'
}))
const rawData = itemsRes.data
let itemsArray: Array<any> = []
if (rawData != null) {
itemsArray = rawData as Array<any>
}
const processedItems: Array<OrderItemType> = []
for (let i: number = 0; i < itemsArray.length; i++) {
const item = itemsArray[i] as UTSJSONObject
const productObjRaw = item.get('product')
const productObj = (productObjRaw != null) ? (productObjRaw as UTSJSONObject) : null
const imagesArrRaw = (productObj != null) ? productObj.get('images') : null
const imagesArr = (imagesArrRaw != null) ? (imagesArrRaw as Array<string>) : []
const firstImage = (imagesArr.length > 0) ? imagesArr[0] : '/static/default-product.png'
const skuSpecRaw = item.get('sku_specifications')
const skuSpec = (skuSpecRaw != null) ? (skuSpecRaw as any) : null
const processedItem: OrderItemType = {
id: (item.getNumber('id') ?? 0) as number,
order_id: (item.getNumber('order_id') ?? 0) as number,
product_id: (item.getNumber('product_id') ?? 0) as number,
product_name: item.getString('product_name') ?? '',
price: (item.getNumber('price') ?? 0) as number,
quantity: (item.getNumber('quantity') ?? 1) as number,
sku_specifications: skuSpec,
product_image: firstImage
}
processedItems.push(processedItem)
}
orderItems.value = processedItems
// 初始化评分和内容数组
const count = orderItems.value.length
ratings.value = new Array(count).fill(5)
contents.value = new Array(count).fill('')
images.value = new Array(count).fill([])
const newRatings: Array<number> = []
const newContents: Array<string> = []
const newImages: Array<Array<string>> = []
for (let i: number = 0; i < count; i++) {
newRatings.push(5)
newContents.push('')
newImages.push([])
}
ratings.value = newRatings
contents.value = newContents
images.value = newImages
// 加载商家信息
if (order.value.merchant_id) {
const { data: merchantData, error: merchantError } = await supa
const orderObj = order.value as UTSJSONObject
const merchantId = orderObj.getString('merchant_id')
if (merchantId != null && merchantId !== '') {
const merchantRes = await supa
.from('ml_shops')
.select('id, shop_name, rating')
.eq('id', order.value.merchant_id)
.eq('id', merchantId)
.single()
.execute()
if (merchantError == null) {
merchant.value = merchantData
if (merchantRes.error == null && merchantRes.data != null) {
merchant.value = merchantRes.data as MerchantType
}
}
@@ -251,6 +294,22 @@ const loadOrderData = async () => {
}
}
const canSubmit = computed((): boolean => {
if (ratings.value.length === 0) return false
for (let i: number = 0; i < ratings.value.length; i++) {
if (ratings.value[i] <= 0) return false
}
return true
})
onLoad((options: any) => {
if (options != null) {
const optObj = options as UTSJSONObject
orderId.value = optObj.getString('orderId') ?? ''
if (orderId.value != '') loadOrderData()
}
})
// 格式化时间
const formatTime = (timeStr?: string): string => {
if (timeStr == null) return ''
@@ -261,38 +320,52 @@ const formatTime = (timeStr?: string): string => {
return `${year}-${month}-${day}`
}
// 获取规格文本
const getSpecText = (specs: any): string => {
const getSpecText = (specs: any | null): string => {
if (specs == null) return ''
if (typeof specs === 'object') {
return Object.keys(specs)
.map(key => `${key}: ${specs[key]}`)
.join('; ')
if (specs instanceof UTSJSONObject) {
return '规格信息'
}
return String(specs)
return specs as string
}
// 获取评分文本
const getRatingText = (rating: number): string => {
const texts = ['非常差', '差', '一般', '好', '非常好']
return texts[rating - 1] ?? '未评价'
if (rating === 1) return '非常差'
if (rating === 2) return '差'
if (rating === 3) return '一般'
if (rating === 4) return '好'
if (rating === 5) return '非常好'
return '未评价'
}
// 设置商品评分
const setRating = (index: number, rating: number) => {
ratings.value[index] = rating
ratings.value = [...ratings.value]
// 触发响应式更新
const newRatings: number[] = []
for (let i: number = 0; i < ratings.value.length; i++) {
newRatings.push(ratings.value[i])
}
ratings.value = newRatings
}
// 设置商家评分
const setMerchantRating = (type: keyof typeof merchantRating.value, rating: number) => {
merchantRating.value[type] = rating
merchantRating.value = { ...merchantRating.value }
const setMerchantRating = (type: string, rating: number) => {
if (type === 'description') {
merchantRating.value.description = rating
} else if (type === 'logistics') {
merchantRating.value.logistics = rating
} else if (type === 'service') {
merchantRating.value.service = rating
}
}
// 切换匿名
const toggleAnonymous = (event: any) => {
anonymous.value = event.detail.value
const eventObj = event as UTSJSONObject
const detailRaw = eventObj.get('detail')
const detail = detailRaw != null ? (detailRaw as UTSJSONObject) : (new UTSJSONObject())
const valueRaw = detail.get('value')
anonymous.value = valueRaw != null ? (valueRaw as boolean) : false
}
// 上传图片
@@ -312,17 +385,27 @@ const uploadImage = async (index: number) => {
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const tempFiles = res.tempFilePaths
const resObj = res as UTSJSONObject
const tempFilesRaw = resObj.get('tempFilePaths')
const tempFiles = tempFilesRaw != null ? (tempFilesRaw as Array<string>) : []
// 模拟上传过程
uni.showLoading({
title: '上传中...'
})
setTimeout(() => {
// 这里应该调用真实的上传接口
images.value[index].push(...tempFiles)
images.value = [...images.value]
for (let i: number = 0; i < tempFiles.length; i++) {
images.value[index].push(tempFiles[i])
}
const newImages: Array<Array<string>> = []
for (let i: number = 0; i < images.value.length; i++) {
const innerArray: Array<string> = []
for (let j: number = 0; j < images.value[i].length; j++) {
innerArray.push(images.value[i][j])
}
newImages.push(innerArray)
}
images.value = newImages
uni.hideLoading()
uni.showToast({
@@ -337,12 +420,28 @@ const uploadImage = async (index: number) => {
// 删除图片
const deleteImage = (index: number, imgIndex: number) => {
images.value[index].splice(imgIndex, 1)
images.value = [...images.value]
// 触发响应式更新
const newImages: string[][] = []
for (let i: number = 0; i < images.value.length; i++) {
const innerArray: string[] = []
for (let j: number = 0; j < images.value[i].length; j++) {
innerArray.push(images.value[i][j])
}
newImages.push(innerArray)
}
images.value = newImages
}
// 提交评价
const submitReview = async () => {
if (!canSubmit.value || isSubmitting.value) return
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
if (userStore == null) return ''
const userInfo = userStore as UTSJSONObject
return userInfo.getString('id') ?? ''
}
const submitReview = async (): Promise<void> => {
if (canSubmit.value === false || isSubmitting.value) return
isSubmitting.value = true
@@ -356,57 +455,61 @@ const submitReview = async () => {
return
}
// 提交商品评价
const productReviews = orderItems.value.map((item, index) => ({
user_id: userId,
product_id: item.product_id,
order_id: orderId.value,
rating: ratings.value[index],
content: contents.value[index] != '' ? contents.value[index] : '',
images: images.value[index],
is_anonymous: anonymous.value
}))
const { error: reviewsError } = await supa
.from('ml_product_reviews')
.insert(productReviews)
if (reviewsError !== null) {
throw reviewsError
type ProductReviewType = {
user_id: string,
product_id: number,
order_id: string,
rating: number,
content: string,
images: Array<string>,
is_anonymous: boolean
}
const productReviews: Array<UTSJSONObject> = []
for (let index: number = 0; index < orderItems.value.length; index++) {
const item = orderItems.value[index]
const reviewObj: UTSJSONObject = new UTSJSONObject()
reviewObj.set('user_id', userId)
reviewObj.set('product_id', item.product_id)
reviewObj.set('order_id', orderId.value)
reviewObj.set('rating', ratings.value[index])
reviewObj.set('content', contents.value[index] != '' ? contents.value[index] : '')
reviewObj.set('images', images.value[index])
reviewObj.set('is_anonymous', anonymous.value)
productReviews.push(reviewObj)
}
// 提交店铺评价
if (merchant.value) {
const merchantReview = {
user_id: userId,
shop_id: merchant.value.id,
order_id: orderId.value,
description_rating: merchantRating.value.description,
logistics_rating: merchantRating.value.logistics,
service_rating: merchantRating.value.service
const reviewsSuccess = await supabaseService.submitProductReviews(productReviews)
if (reviewsSuccess == false) {
uni.showToast({
title: '提交失败',
icon: 'none'
})
isSubmitting.value = false
return
}
if (merchant.value != null) {
type MerchantReviewType = {
user_id: string,
shop_id: string,
order_id: string,
description_rating: number,
logistics_rating: number,
service_rating: number
}
const merchantReviewObj: UTSJSONObject = new UTSJSONObject()
merchantReviewObj.set('user_id', userId)
merchantReviewObj.set('shop_id', merchant.value.id)
merchantReviewObj.set('order_id', orderId.value)
merchantReviewObj.set('description_rating', merchantRating.value.description)
merchantReviewObj.set('logistics_rating', merchantRating.value.logistics)
merchantReviewObj.set('service_rating', merchantRating.value.service)
const { error: merchantError } = await supa
.from('ml_shop_reviews')
.insert(merchantReview)
if (merchantError !== null) {
console.error('提交店铺评价失败:', merchantError)
}
await supabaseService.submitShopReview(merchantReviewObj)
}
// 更新订单状态为已评价 (如果需要标记为已评价,可以在这里处理,例如 status=5 implies Reviewed or keeping at 4)
// 这里保持为 4 (Completed)
const { error: orderError } = await supa
.from('ml_orders')
.update({ order_status: 4 })
.eq('id', orderId.value)
await supabaseService.updateOrderStatus(orderId.value, 4)
if (orderError !== null) {
console.error('更新订单状态失败:', orderError)
}
// 显示成功提示
uni.showToast({
title: '评价成功',
icon: 'success',
@@ -429,14 +532,8 @@ const submitReview = async () => {
}
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.getString('id') ?? ''
}
// 返回
const goBack = () => {
const goBack = (): void => {
uni.navigateBack()
}
</script>
@@ -503,10 +600,6 @@ const goBack = () => {
border-bottom: 1px solid #f5f5f5;
}
.product-review:last-child {
border-bottom: none;
}
.product-header {
display: flex;
margin-bottom: 20px;
@@ -643,8 +736,6 @@ const goBack = () => {
align-items: center;
justify-content: center;
}
margin-right: 10px;
margin-bottom: 10px;
.upload-btn {
width: 70px;
@@ -738,10 +829,6 @@ margin-right: 10px;
margin-bottom: 5px;
}
.tip-item:last-child {
margin-bottom: 0;
}
.submit-section {
background-color: #ffffff;
padding: 15px;

View File

@@ -51,13 +51,13 @@
<!-- 主内容区域 -->
<scroll-view
v-else
scroll-y
direction="vertical"
class="main-content"
:style="{ height: scrollHeight + 'px' }"
@scrolltolower="loadMore"
>
<!-- 初始状态(无搜索词) -->
<view v-if="!searchKeyword && !showResults">
<view v-if="searchKeyword == '' && showResults == false">
<!-- 搜索历史 -->
<view v-if="searchHistory.length > 0" class="search-history">
<view class="section-header">
@@ -92,12 +92,12 @@
v-for="(item, index) in hotSearchList"
:key="index"
class="hot-tag"
:class="{ 'hot': item.hot }"
:class="item.hot == true ? 'hot' : ''"
@click="searchFromHot(item.keyword)"
>
<text class="hot-rank" :class="{ 'top-three': index < 3 }">{{ index + 1 }}</text>
<text class="hot-rank" :class="index < 3 ? 'top-three' : ''">{{ index + 1 }}</text>
<text class="hot-text">{{ item.keyword }}</text>
<text v-if="item.hot" class="hot-icon">🔥</text>
<text v-if="item.hot == true" class="hot-icon">🔥</text>
</view>
</view>
</view>
@@ -136,7 +136,7 @@
</view>
<!-- 搜索建议 -->
<view v-if="searchKeyword && !showResults" class="search-suggestions">
<view v-if="searchKeyword != '' && showResults == false" class="search-suggestions">
<view class="suggestions-list">
<view
v-for="(suggestion, index) in searchSuggestions"
@@ -157,7 +157,7 @@
<view class="section-top">
<text class="result-title-sm">相关店铺</text>
</view>
<scroll-view scroll-x class="shop-list-scroll">
<scroll-view direction="horizontal" class="shop-list-scroll">
<view class="shop-list-row">
<view
v-for="shop in searchShopResults"
@@ -267,110 +267,47 @@ const autoFocus = ref(true)
const activeSort = ref('default') // 当前排序方式: default, sales, price
const priceSortAsc = ref(false) // 价格排序是否为升序
// 数据定义
const searchHistory = ref<string[]>([])
const hotSearchList = ref<any[]>([])
const guessList = ref<any[]>([])
const allGuessItems = ref<any[]>([]) // 缓存所有猜你喜欢商品
const searchResults = ref<any[]>([])
const searchShopResults = ref<any[]>([]) // 搜索到的店铺
onMounted(() => {
initPage()
})
const initPage = () => {
try {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight ?? 0
const windowHeight = systemInfo.windowHeight
// 减去头部高度 (约60px + statusBarHeight)
scrollHeight.value = windowHeight - (60 + statusBarHeight.value)
loadData()
// 检查页面参数
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
// @ts-ignore
const options = currentPage.options
if (options && options['keyword']) {
const keyword = decodeURIComponent(options['keyword'])
searchKeyword.value = keyword
if (options['type'] === 'family' || options['type'] === 'brand') {
// 如果是家庭常备药或品牌类型,直接添加到历史并搜索
if (options['type'] === 'family') {
addToHistory(keyword)
}
// 立即显示结果区域并设置为加载中
showResults.value = true
loading.value = true
// 确保searchResults不为空数组导致闪烁虽然loading=true已经拦截了empty-result但双重保险
// 此时不要置空searchResults或者给一个初始值
// 直接调用搜索移除setTimeout防止中间状态
performSearch()
}
}
}
} catch (e) {
console.error('初始化失败', e)
isError.value = true
}
type HotSearchItemType = {
keyword: string
hot: boolean
}
// 加载基础数据
const loadData = async () => {
isError.value = false
try {
loadSearchHistory()
// 获取热门商品作为热门搜索推荐和猜你喜欢
// 获取更多数据以便"换一批"
const hotProducts = await supabaseService.getHotProducts(30)
hotSearchList.value = hotProducts.slice(0, 10).map((p: any) => ({
keyword: p.name,
hot: true
}))
allGuessItems.value = hotProducts.map((p: any) => ({
id: p.id,
name: p.name,
price: p.base_price,
image: p.main_image_url ?? '/static/default.jpg',
sales: typeof p.sale_count === 'number' ? p.sale_count : 0
}))
// 初始显示随机6个
refreshGuessListItems()
} catch (e) {
console.error('Load data failed', e)
isError.value = true
}
type GuessItemType = {
id: string
name: string
price: number
image: string
sales: number
}
// 点击重试
const retryLoad = () => {
uni.showLoading({ title: '重新加载中' })
setTimeout(() => {
uni.hideLoading()
loadData()
}, 1000)
type SearchResultType = {
id: string
name: string
image: string
price: number
specification: string
tag: string
sales: number
}
// 历史记录管理
type ShopResultType = {
id: string
name: string
logo: string
productCount: number
}
const searchHistory = ref<Array<string>>([])
const hotSearchList = ref<Array<HotSearchItemType>>([])
const guessList = ref<Array<GuessItemType>>([])
const allGuessItems = ref<Array<GuessItemType>>([])
const searchResults = ref<Array<SearchResultType>>([])
const searchShopResults = ref<Array<ShopResultType>>([])
const loadSearchHistory = () => {
const history = uni.getStorageSync('searchHistory')
if (history) {
if (history != null) {
try {
// 确保是数组
const parsed = JSON.parse(history as string)
if (Array.isArray(parsed)) {
searchHistory.value = parsed as string[]
@@ -414,13 +351,258 @@ const deleteHistoryItem = (index: number) => {
saveSearchHistory()
}
// 搜索建议 - 改为实时获取
const searchSuggestions = ref<string[]>([])
let suggestTimer = 0
const refreshGuessListItems = () => {
if (allGuessItems.value.length > 0) {
const arr: Array<GuessItemType> = []
for (let i: number = 0; i < allGuessItems.value.length; i++) {
arr.push(allGuessItems.value[i])
}
for (let i: number = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
}
const result: Array<GuessItemType> = []
const limit = arr.length < 6 ? arr.length : 6
for (let i: number = 0; i < limit; i++) {
result.push(arr[i])
}
guessList.value = result
}
}
const loadData = async (): Promise<void> => {
isError.value = false
try {
loadSearchHistory()
const hotProducts = await supabaseService.getHotProducts(30)
const hotList: Array<HotSearchItemType> = []
const limit1 = hotProducts.length < 10 ? hotProducts.length : 10
for (let i: number = 0; i < limit1; i++) {
const p = hotProducts[i] as UTSJSONObject
const item: HotSearchItemType = {
keyword: p.getString('name') ?? '',
hot: true
}
hotList.push(item)
}
hotSearchList.value = hotList
const allItems: Array<GuessItemType> = []
for (let i: number = 0; i < hotProducts.length; i++) {
const p = hotProducts[i] as UTSJSONObject
const saleCount = p.getNumber('sale_count')
const item: GuessItemType = {
id: p.getString('id') ?? '',
name: p.getString('name') ?? '',
price: p.getNumber('base_price') ?? 0,
image: p.getString('main_image_url') ?? '/static/default.jpg',
sales: saleCount != null ? saleCount : 0
}
allItems.push(item)
}
allGuessItems.value = allItems
refreshGuessListItems()
} catch (e) {
console.error('Load data failed', e)
isError.value = true
}
}
const retryLoad = () => {
uni.showLoading({ title: '重新加载中' })
setTimeout(() => {
uni.hideLoading()
loadData()
}, 1000)
}
const searchSuggestions = ref<Array<string>>([])
let suggestTimer: number = 0
const fetchSuggestions = async (kw: string): Promise<void> => {
if (kw == '' || showResults.value) return
try {
const res = await supabaseService.searchProducts(kw.trim(), 1, 5)
if (res.data != null && res.data.length > 0) {
const names: Array<string> = []
for (let i: number = 0; i < res.data.length; i++) {
const p = res.data[i]
let name = ''
if (p instanceof UTSJSONObject) {
name = p.getString('name') ?? ''
} else {
const pObj = p as UTSJSONObject
name = pObj.getString('name') ?? ''
}
let found = false
for (let j: number = 0; j < names.length; j++) {
if (names[j] === name) {
found = true
break
}
}
if (found === false && name !== '') {
names.push(name)
}
}
searchSuggestions.value = names
} else {
searchSuggestions.value = []
}
} catch(e) {
searchSuggestions.value = []
}
}
const currentPage = ref<number>(1)
const performSearch = async (): Promise<void> => {
showResults.value = true
loading.value = true
currentPage.value = 1
const keyword = searchKeyword.value.trim()
if (keyword == '') {
loading.value = false
return
}
let sortBy = 'sales'
let ascending = false
if (activeSort.value === 'price') {
sortBy = 'price'
ascending = priceSortAsc.value
} else if (activeSort.value === 'default') {
sortBy = 'default'
}
try {
const prodResp = await supabaseService.searchProducts(keyword, currentPage.value, 20, sortBy, ascending)
let shopRespData: Array<any> = []
if (currentPage.value === 1 && activeSort.value === 'default') {
const shopResp = await supabaseService.searchShops(keyword)
if (shopResp.data != null) {
const rawData = shopResp.data
for (let i: number = 0; i < rawData.length; i++) {
shopRespData.push(rawData[i])
}
}
}
if (shopRespData.length > 0) {
const shopList: Array<ShopResultType> = []
for (let i: number = 0; i < shopRespData.length; i++) {
const s = shopRespData[i] as UTSJSONObject
const shopItem: ShopResultType = {
id: s.getString('id') ?? '',
name: s.getString('shop_name') ?? '',
logo: s.getString('shop_logo') ?? '/static/shop_logo_default.png',
productCount: s.getNumber('product_count') ?? 0
}
shopList.push(shopItem)
}
searchShopResults.value = shopList
} else {
searchShopResults.value = []
}
const prodData = prodResp.data != null ? prodResp.data : []
const resultList: Array<SearchResultType> = []
for (let i: number = 0; i < prodData.length; i++) {
const p = prodData[i] as UTSJSONObject
let tag = ''
const tagsRaw = p.get('tags')
if (tagsRaw != null) {
try {
const tagsStr = p.getString('tags')
if (tagsStr != null) {
const tags = JSON.parse(tagsStr)
if (Array.isArray(tags) && tags.length > 0) {
const firstTag = tags[0]
tag = firstTag != null ? (firstTag as string) : ''
}
}
} catch(e) {}
}
const searchItem: SearchResultType = {
id: p.getString('id') ?? '',
name: p.getString('name') ?? '',
image: p.getString('main_image_url') ?? '/static/default.jpg',
price: p.getNumber('base_price') ?? 0,
specification: p.getString('specification') ?? '标准规格',
tag: tag,
sales: p.getNumber('sale_count') ?? 0
}
resultList.push(searchItem)
}
searchResults.value = resultList
hasMore.value = prodResp.hasmore
} catch(e) {
console.error('Search failed', e)
} finally {
loading.value = false
}
}
const initPage = () => {
try {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight ?? 0
const windowHeight = systemInfo.windowHeight
scrollHeight.value = windowHeight - (60 + statusBarHeight.value)
loadData()
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPageObj = pages[pages.length - 1]
// @ts-ignore
const options = currentPageObj.options
if (options != null) {
const optObj = options as UTSJSONObject
const kwRaw = optObj.getString('keyword')
if (kwRaw != null && kwRaw !== '') {
const decoded = decodeURIComponent(kwRaw)
const keyword = decoded != null ? decoded : kwRaw
searchKeyword.value = keyword
const typeVal = optObj.getString('type')
if (typeVal === 'family' || typeVal === 'brand') {
if (typeVal === 'family') {
addToHistory(keyword)
}
showResults.value = true
loading.value = true
performSearch()
}
}
}
}
} catch (e) {
console.error('初始化失败', e)
isError.value = true
}
}
onMounted(() => {
initPage()
})
// 搜索逻辑
const onInput = (e: any) => {
const val = e.detail.value
const eObj = e as UTSJSONObject
const detailRaw = eObj.get('detail')
const detail = detailRaw != null ? (detailRaw as UTSJSONObject) : (new UTSJSONObject())
const val = detail.getString('value') ?? ''
searchKeyword.value = val
if (val == '') {
showResults.value = false
@@ -428,37 +610,12 @@ const onInput = (e: any) => {
return
}
// Debounce suggestion search
if (suggestTimer > 0) clearTimeout(suggestTimer)
suggestTimer = setTimeout(() => {
fetchSuggestions(val)
}, 300)
}
const fetchSuggestions = async (kw: string) => {
if (kw == '' || showResults.value) return
// 简单搜索前5个相关商品作为建议
try {
const res = await supabaseService.searchProducts(kw.trim(), 1, 5)
if (Array.isArray(res.data) && res.data.length > 0) {
// 去重
const names = res.data.map((p:any) :string => {
if(p instanceof UTSJSONObject){
return p.getString('name') ?? ''
}
return p['name'] as string
})
// @ts-ignore
searchSuggestions.value = Array.from(new Set(names))
} else {
searchSuggestions.value = []
}
} catch(e) {
searchSuggestions.value = []
}
}
const clearSearch = () => {
searchKeyword.value = ''
showResults.value = false
@@ -487,84 +644,6 @@ const selectSuggestion = (suggestion: string) => {
performSearch()
}
const currentPage = ref(1)
const performSearch = async () => {
// 再次强制设置状态,确保万无一失
showResults.value = true
loading.value = true
// 重置页码
currentPage.value = 1
// 使用 Supabase 搜索真实数据
const keyword = searchKeyword.value.trim()
if (keyword == '') {
loading.value = false
return
}
// 确定排序方式
let sortBy = 'sales'
let ascending = false
if (activeSort.value === 'price') {
sortBy = 'price'
ascending = priceSortAsc.value
} else if (activeSort.value === 'default') {
sortBy = 'default'
}
try {
// 并行请求:商品搜索 + 店铺搜索
const [prodResp, shopResp] = await Promise.all([
supabaseService.searchProducts(keyword, currentPage.value, 20, sortBy, ascending),
// 只有第一页搜索且非价格排序时搜索店铺,避免重复和无关搜索
currentPage.value === 1 && activeSort.value === 'default'
? supabaseService.searchShops(keyword)
: Promise.resolve({ data: [], total: 0, page: 1, limit: 0, hasmore: false })
])
// 处理店铺结果
if (shopResp.data.length > 0) {
searchShopResults.value = shopResp.data.map((s: any) => ({
id: s.id,
name: s.shop_name,
logo: s.shop_logo ?? '/static/shop_logo_default.png',
productCount: s.product_count ?? 0
}))
} else {
searchShopResults.value = []
}
// 处理商品结果
searchResults.value = prodResp.data.map((p: any) => {
let tag = ''
if (p.tags) {
try {
const tags = (typeof p.tags === 'string') ? JSON.parse(p.tags) : p.tags
if (Array.isArray(tags) && tags.length > 0) tag = String(tags[0])
} catch(e) {}
}
return {
id: p.id,
name: p.name,
image: p.main_image_url ?? '/static/default.jpg',
price: p.base_price,
specification: p.specification ?? '标准规格',
tag: tag,
sales: p.sale_count ?? 0
}
})
hasMore.value = prodResp.hasmore
} catch(e) {
console.error('Search failed', e)
} finally {
loading.value = false
}
}
// 切换排序
const switchSort = (type: string) => {
if (type === 'price') {
if (activeSort.value === 'price') {
@@ -580,15 +659,13 @@ const switchSort = (type: string) => {
performSearch()
}
const loadMore = async () => {
if (loading.value || !hasMore.value || searchKeyword.value.trim() == '') return
const loadMore = async (): Promise<void> => {
if (loading.value || hasMore.value == false || searchKeyword.value.trim() == '') return
loading.value = true
// 增加页码
currentPage.value++
const keyword = searchKeyword.value.trim()
// 确定排序方式
let sortBy = 'sales'
let ascending = false
if (activeSort.value === 'price') {
@@ -600,26 +677,35 @@ const loadMore = async () => {
try {
const response = await supabaseService.searchProducts(keyword, currentPage.value, 20, sortBy, ascending)
const newItems = response.data.map((p: any) => {
const respData = response.data != null ? response.data : []
for (let i: number = 0; i < respData.length; i++) {
const p = respData[i] as UTSJSONObject
let tag = ''
if (p.tags) {
const tagsRaw = p.get('tags')
if (tagsRaw != null) {
try {
const tags = (typeof p.tags === 'string') ? JSON.parse(p.tags) : p.tags
if (Array.isArray(tags) && tags.length > 0) tag = String(tags[0])
const tagsStr = p.getString('tags')
if (tagsStr != null) {
const tags = JSON.parse(tagsStr)
if (Array.isArray(tags) && tags.length > 0) {
const firstTag = tags[0]
tag = firstTag != null ? (firstTag as string) : ''
}
}
} catch(e) {}
}
return {
id: p.id,
name: p.name,
image: p.main_image_url ?? '/static/default.jpg',
price: p.base_price,
specification: p.specification ?? '标准规格',
const searchItem: SearchResultType = {
id: p.getString('id') ?? '',
name: p.getString('name') ?? '',
image: p.getString('main_image_url') ?? '/static/default.jpg',
price: p.getNumber('base_price') ?? 0,
specification: p.getString('specification') ?? '标准规格',
tag: tag,
sales: p.sale_count ?? 0
sales: p.getNumber('sale_count') ?? 0
}
})
searchResults.value.push(...newItems)
searchResults.value.push(searchItem)
}
hasMore.value = response.hasmore
} catch(e) {
console.error('Load more failed', e)
@@ -637,29 +723,22 @@ const refreshGuessList = () => {
}, 500)
}
const refreshGuessListItems = () => {
if (allGuessItems.value.length > 0) {
// 简单的随机乱序并取前6个
const shuffled = [...allGuessItems.value].sort(() => Math.random() - 0.5)
guessList.value = shuffled.slice(0, 6)
}
}
const viewProductDetail = (item: any) => {
// 跳转详情页逻辑 - 传递必要的参数作为预加载/fallback
const viewProductDetail = (item: SearchResultType | GuessItemType) => {
const id = (item as GuessItemType).id
const price = (item as GuessItemType).price
const name = (item as GuessItemType).name
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?productId=${item.id}&price=${item.price}&name=${encodeURIComponent(item.name)}`
url: `/pages/mall/consumer/product-detail?productId=${id}&price=${price}&name=${encodeURIComponent(name)}`
})
}
const viewShopDetail = (shop: any) => {
const viewShopDetail = (shop: ShopResultType) => {
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?id=${shop.id}`
})
}
// 添加到购物车 - 搜索列表无法选择规格,跳转详情页
const addToCart = (product: any) => {
const addToCart = (product: SearchResultType | GuessItemType) => {
uni.showToast({ title: '请选择规格', icon: 'none' })
setTimeout(() => {
viewProductDetail(product)

View File

@@ -31,8 +31,8 @@
<text class="item-icon">📱</text>
<text class="item-text">手机绑定</text>
<view class="item-right">
<text class="item-status" :class="{ bound: userInfo.phone }">
{{ userInfo.phone ? '已绑定' : '未绑定' }}
<text class="item-status" :class="userInfo.phone != null && userInfo.phone != '' ? 'bound' : ''">
{{ userInfo.phone != null && userInfo.phone != '' ? '已绑定' : '未绑定' }}
</text>
<text class="item-arrow"></text>
</view>
@@ -41,8 +41,8 @@
<text class="item-icon">📧</text>
<text class="item-text">邮箱绑定</text>
<view class="item-right">
<text class="item-status" :class="{ bound: userInfo.email }">
{{ userInfo.email ? '已绑定' : '未绑定' }}
<text class="item-status" :class="userInfo.email != null && userInfo.email != '' ? 'bound' : ''">
{{ userInfo.email != null && userInfo.email != '' ? '已绑定' : '未绑定' }}
</text>
<text class="item-arrow"></text>
</view>
@@ -260,54 +260,65 @@ const currentLanguage = ref<string>('简体中文')
const currentTheme = ref<string>('自动')
const appVersion = ref<string>('1.0.0')
const statusBarHeight = ref(0)
const statusBarHeight = ref<number>(0)
// 生命周期
onMounted(() => {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight
loadUserInfo()
loadSettings()
})
// 加载用户信息
const loadUserInfo = () => {
const userStore = uni.getStorageSync('userInfo')
if (userStore) {
userInfo.value = userStore
if (userStore != null) {
const storeObj = userStore as UTSJSONObject
const user: UserType = {
id: storeObj.getString('id') ?? '',
phone: storeObj.getString('phone'),
email: storeObj.getString('email'),
nickname: storeObj.getString('nickname'),
avatar_url: storeObj.getString('avatar_url')
} as UserType
userInfo.value = user
}
}
// 加载设置
const loadSettings = () => {
// 从本地存储加载设置
const savedNotifications = uni.getStorageSync('userNotifications')
if (savedNotifications) {
notifications.value = savedNotifications
if (savedNotifications != null) {
const notifObj = savedNotifications as UTSJSONObject
const notif: NotificationType = {
order: notifObj.getBoolean('order') ?? true,
promotion: notifObj.getBoolean('promotion') ?? true,
review: notifObj.getBoolean('review') ?? true
} as NotificationType
notifications.value = notif
}
const savedPrivacy = uni.getStorageSync('userPrivacy')
if (savedPrivacy) {
privacy.value = savedPrivacy
if (savedPrivacy != null) {
const privacyObj = savedPrivacy as UTSJSONObject
const priv: PrivacyType = {
hidePurchase: privacyObj.getBoolean('hidePurchase') ?? false,
allowSearchByPhone: privacyObj.getBoolean('allowSearchByPhone') ?? true,
receiveMerchantMsg: privacyObj.getBoolean('receiveMerchantMsg') ?? true
} as PrivacyType
privacy.value = priv
}
// 计算缓存大小
calculateCacheSize()
// 获取应用版本
// @ts-ignore
const appInfo = uni.getAppBaseInfo()
if (appInfo?.appVersion) {
appVersion.value = appInfo.appVersion
}
}
// 计算缓存大小
const calculateCacheSize = () => {
// 这里应该计算实际缓存大小,这里使用模拟数据
cacheSize.value = '12.5 MB'
const appInfo = uni.getAppBaseInfo()
if (appInfo != null) {
const infoObj = appInfo as UTSJSONObject
const version = infoObj.getString('appVersion')
if (version != null) {
appVersion.value = version
}
}
}
onMounted(() => {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight ?? 0
loadUserInfo()
loadSettings()
})
// 跳转到个人资料
const goToProfile = () => {
uni.navigateTo({
@@ -344,14 +355,26 @@ const bindEmail = () => {
}
// 切换通知设置
const toggleNotification = (type: keyof NotificationType) => {
notifications.value[type] = !notifications.value[type]
const toggleNotification = (type: string) => {
if (type === 'order') {
notifications.value.order = notifications.value.order === false
} else if (type === 'promotion') {
notifications.value.promotion = notifications.value.promotion === false
} else if (type === 'review') {
notifications.value.review = notifications.value.review === false
}
uni.setStorageSync('userNotifications', notifications.value)
}
// 切换隐私设置
const togglePrivacy = (type: keyof PrivacyType) => {
privacy.value[type] = !privacy.value[type]
const togglePrivacy = (type: string) => {
if (type === 'hidePurchase') {
privacy.value.hidePurchase = privacy.value.hidePurchase === false
} else if (type === 'allowSearchByPhone') {
privacy.value.allowSearchByPhone = privacy.value.allowSearchByPhone === false
} else if (type === 'receiveMerchantMsg') {
privacy.value.receiveMerchantMsg = privacy.value.receiveMerchantMsg === false
}
uni.setStorageSync('userPrivacy', privacy.value)
}
@@ -473,28 +496,12 @@ const feedback = () => {
})
}
// 给个好评
const rateApp = () => {
// 这里应该跳转到应用商店评分
uni.showModal({
title: '给个好评',
content: '如果喜欢我们的应用,请给个好评吧!',
confirmText: '去评分',
success: (res) => {
if (res.confirm) {
// 跳转到应用商店
// @ts-ignore
uni.navigateToMiniProgram({
appId: 'wx1234567890', // 示例AppID
fail: () => {
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}
}
content: '如果喜欢我们的应用,请给个好评吧!感谢您的支持!',
confirmText: '好的',
showCancel: false
})
}
@@ -503,116 +510,76 @@ const logout = () => {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: async (res) => {
success: (res) => {
if (res.confirm) {
try {
uni.showLoading({
title: '正在退出...'
})
// 调用登出接口
const { error } = await supa.auth.signOut()
uni.showLoading({
title: '正在退出...'
})
if (error !== null) {
console.error('登出失败:', error)
// 即使失败也继续清除本地状态
}
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
// 清除本地存储的用户信息
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.hideLoading()
uni.hideLoading()
uni.showToast({
title: '已退出登录',
icon: 'success'
})
uni.showToast({
title: '已退出登录',
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1000)
} catch (e) {
uni.hideLoading()
console.error('Logout Exception:', e)
uni.showToast({
title: '退出异常',
icon: 'none'
})
// 强制退出
uni.removeStorageSync('userInfo')
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}
}, 1000)
}
}
})
}
// 注销账号
const deleteAccount = () => {
uni.showModal({
title: '注销账号',
content: '确定要注销账号吗?此操作不可恢复,所有数据将被删除!',
confirmText: '注销',
confirmColor: '#ff4757',
success: async (res) => {
success: (res) => {
if (res.confirm) {
try {
uni.showLoading({
title: '注销中...'
uni.showLoading({
title: '注销中...'
})
let userId: string | null = userInfo.value.id
if (userId == null || userId === '') {
const storageId = uni.getStorageSync('user_id')
userId = (storageId != null) ? storageId as string : null
}
if (userId != null) {
const updateObj: UTSJSONObject = new UTSJSONObject()
updateObj.set('status', 3)
supa
.from('ml_user_profiles')
.update(updateObj)
.eq('user_id', userId)
.execute()
}
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.hideLoading()
uni.showToast({
title: '账号已注销',
icon: 'success',
duration: 2000
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
let userId = userInfo.value.getString('id')
if (userId == null) {
const storageId = uni.getStorageSync('user_id')
userId = (storageId != null) ? storageId as string : null
}
if (userId != null) {
try {
// 标记用户状态为注销 (status=3)
await supa
.from('ml_user_profiles')
.update({ status: 3 })
.eq('user_id', userId)
} catch(e) {
console.error('Update status failed', e)
}
// 登出
await supa.auth.signOut()
}
// 清除本地存储
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
// 提示并跳转
uni.hideLoading()
uni.showToast({
title: '账号已注销',
icon: 'success',
duration: 2000
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1500)
} catch (err) {
uni.hideLoading()
console.error('注销账号失败:', err)
uni.showToast({
title: '注销失败',
icon: 'none'
})
}
}, 1500)
}
}
})

View File

@@ -31,7 +31,7 @@
<view class="coupon-card" v-for="coupon in coupons" :key="coupon.id" @click="claimCoupon(coupon)">
<view class="coupon-left">
<text class="coupon-amount"><text style="font-size:10px">¥</text>{{ coupon.discount_value }}</text>
<text class="coupon-cond" v-if="parseFloat(String(coupon.min_order_amount)) > 0">满{{ coupon.min_order_amount }}</text>
<text class="coupon-cond" v-if="coupon.min_order_amount > 0">满{{ coupon.min_order_amount }}</text>
<text class="coupon-cond" v-else>无门槛</text>
</view>
<view class="coupon-right">
@@ -70,7 +70,7 @@
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { MerchantType, ProductType } from '@/types/mall-types.uts'
import { MerchantType, ProductType, CouponTemplateType } from '@/types/mall-types.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
// 分页相关状态
@@ -95,104 +95,49 @@ const merchant = ref<MerchantType>({
created_at: ''
} as MerchantType)
const products = ref<ProductType[]>([])
const isFollowed = ref(false)
const coupons = ref<any[]>([]) // 新增优惠券
const isRefresherTriggered = ref(false)
const products = ref<Array<ProductType>>([])
const isFollowed = ref<boolean>(false)
const coupons = ref<Array<CouponTemplateType>>([])
const isRefresherTriggered = ref<boolean>(false)
onMounted(() => {
const pages = getCurrentPages()
const options = pages[pages.length - 1].options as any
// Search传递的是 id (shop_id), 其他地方可能传递 merchantId
const mId = options['merchantId']
const pId = options['id']
const paramId = (mId != null ? mId : pId) as string
if (paramId) {
console.log('Page mounted with params:', paramId)
// 优先加载店铺信息
loadShopData(paramId).then(() => {
// 加载成功后,使用确定的 merchant_id 来查询关联数据 (商品/优惠券通常是关联在 merchant_id 上的)
const realMerchantId = merchant.value.user_id // 这里 user_id 映射了 DB 中的 merchant_id
if (realMerchantId && realMerchantId !== '') {
console.log('Chain loading products for Corrected Merchant ID:', realMerchantId)
currentMerchantId.value = realMerchantId // 更新当前上下文ID
loadShopProducts(realMerchantId)
loadCoupons(realMerchantId)
} else {
// 防御性策略:如果没能获取 merchant_id尝试用传入 ID
console.warn('Shop load failed or id empty, fallback using original id:', paramId)
currentMerchantId.value = paramId
loadShopProducts(paramId)
loadCoupons(paramId)
const checkFollowStatus = async (shopId: string): Promise<void> => {
const userId = supabaseService.getCurrentUserId()
if (userId != null && userId !== '') {
try {
isFollowed.value = await supabaseService.isShopFollowed(shopId, userId)
} catch(e) {
console.warn('isShopFollowed method not available')
}
})
} else {
console.error('No ID passed to shop-detail')
uni.showToast({title: '参数错误', icon: 'error'})
}
})
const onRefresherRefresh = () => {
isRefresherTriggered.value = true
currentPage.value = 1
hasMore.value = true
isLoading.value = false
if (currentMerchantId.value != '') {
const id = currentMerchantId.value
Promise.all([
loadShopData(id),
loadCoupons(id),
loadShopProducts(id)
]).then(() => {
isRefresherTriggered.value = false
})
} else {
setTimeout(() => {
isRefresherTriggered.value = false
}, 500)
}
}
const onScrollToLower = () => {
if (hasMore.value && !isLoading.value && currentMerchantId.value != '') {
console.log('Scroll to lower, loading more...')
loadShopProducts(currentMerchantId.value)
}
}
onPullDownRefresh(() => {
onRefresherRefresh()
})
onReachBottom(() => {
onScrollToLower()
})
const loadShopData = async (id: string) => {
const loadShopData = async (id: string): Promise<void> => {
console.log('Loading shop data for:', id)
const shop = await supabaseService.getShopByMerchantId(id)
if (shop) {
if (shop != null) {
console.log('Shop loaded successfully:', shop.shop_name)
merchant.value = {
id: shop.id,
user_id: shop.merchant_id, // 映射关系
shop_name: shop.shop_name,
shop_logo: shop.shop_logo != null ? shop.shop_logo : '/static/default-shop.png',
shop_banner: shop.shop_banner != null ? shop.shop_banner : '/static/default-banner.png',
shop_description: shop.description != null ? shop.description : '',
contact_name: shop.contact_name != null ? shop.contact_name : '',
contact_phone: shop.contact_phone != null ? shop.contact_phone : '',
shop_status: 1, // 默认正常
rating: shop.rating_avg != null ? shop.rating_avg : 5.0,
total_sales: shop.total_sales != null ? shop.total_sales : 0,
created_at: shop.created_at != null ? shop.created_at : ''
}
const shopObj = shop as UTSJSONObject
const merchantData: MerchantType = {
id: shopObj.getString('id') ?? '',
user_id: shopObj.getString('merchant_id') ?? '',
shop_name: shopObj.getString('shop_name') ?? '',
shop_logo: shopObj.getString('shop_logo') ?? '/static/default-shop.png',
shop_banner: shopObj.getString('shop_banner') ?? '/static/default-banner.png',
shop_description: shopObj.getString('description') ?? '',
contact_name: shopObj.getString('contact_name') ?? '',
contact_phone: shopObj.getString('contact_phone') ?? '',
shop_status: 1,
rating: shopObj.getNumber('rating_avg') ?? 5.0,
total_sales: shopObj.getNumber('total_sales') ?? 0,
created_at: shopObj.getString('created_at') ?? ''
} as MerchantType
merchant.value = merchantData
// 检查关注状态
checkFollowStatus(shop.id)
const shopId = shopObj.getString('id') ?? ''
if (shopId !== '') {
checkFollowStatus(shopId)
}
} else {
console.warn('Shop data is null for ID:', id)
uni.showToast({
@@ -203,100 +148,99 @@ const loadShopData = async (id: string) => {
}
}
const loadCoupons = async (id: string) => {
// 安全检查,防止因编译器可以缓存导致的方法未定义错误
// @ts-ignore
if (typeof supabaseService.fetchShopCoupons === 'function') {
coupons.value = await supabaseService.fetchShopCoupons(id)
} else if (typeof supabaseService.getAvailableCoupons === 'function') {
// Fallback to old name
coupons.value = await supabaseService.getAvailableCoupons(id)
} else {
console.warn('SupabaseService.fetchShopCoupons method missing. Please rebuild project.')
const loadCoupons = async (id: string): Promise<void> => {
try {
const rawCoupons = await supabaseService.fetchShopCoupons(id)
if (rawCoupons != null && Array.isArray(rawCoupons)) {
const couponList: Array<CouponTemplateType> = []
for (let i: number = 0; i < rawCoupons.length; i++) {
const c = rawCoupons[i] as UTSJSONObject
const coupon: CouponTemplateType = {
id: c.getString('id') ?? '',
name: c.getString('name') ?? '',
description: c.getString('description'),
coupon_type: c.getNumber('coupon_type') ?? 0,
discount_type: c.getNumber('discount_type') ?? 0,
discount_value: c.getNumber('discount_value') ?? 0,
min_order_amount: c.getNumber('min_order_amount') ?? 0,
max_discount_amount: c.getNumber('max_discount_amount'),
total_quantity: c.getNumber('total_quantity'),
per_user_limit: c.getNumber('per_user_limit') ?? 1,
usage_limit: c.getNumber('usage_limit') ?? 0,
merchant_id: c.getString('merchant_id'),
category_ids: [],
product_ids: [],
user_type_limit: c.getNumber('user_type_limit'),
start_time: c.getString('start_time') ?? '',
end_time: c.getString('end_time') ?? '',
status: c.getNumber('status') ?? 1,
created_at: c.getString('created_at') ?? ''
} as CouponTemplateType
couponList.push(coupon)
}
coupons.value = couponList
}
} catch(e) {
console.warn('SupabaseService.fetchShopCoupons method missing.')
}
}
const claimCoupon = async (coupon: any) => {
const userId = supabaseService.getCurrentUserId()
if (userId == null) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
uni.showLoading({ title: '领取中' })
let success = false
// @ts-ignore
if (typeof supabaseService.claimShopCoupon === 'function') {
success = await supabaseService.claimShopCoupon(coupon.id, userId)
} else if (typeof supabaseService.claimCoupon === 'function') {
success = await supabaseService.claimCoupon(coupon.id, userId)
} else {
console.warn('claimCoupon not found')
}
uni.hideLoading()
if (success) {
uni.showToast({ title: '领取成功', icon: 'success' })
} else {
uni.showToast({ title: '领取失败', icon: 'none' })
}
}
const loadShopProducts = async (id: string) => {
const loadShopProducts = async (id: string): Promise<void> => {
if (isLoading.value) return
isLoading.value = true
// 保存当前使用的MerchantID供下拉/触底使用
if (currentPage.value === 1) {
currentMerchantId.value = id
}
console.log(`shop-detail loadShopProducts for: ${id} page: ${currentPage.value}`)
// @ts-ignore
if (typeof supabaseService.getProductsByMerchantId !== 'function') {
console.error('getProductsByMerchantId missing')
let res: any = {}
try {
res = await supabaseService.getProductsByMerchantId(id, currentPage.value, pageSize.value)
} catch(e) {
console.error('getProductsByMerchantId missing or error:', e)
isLoading.value = false
uni.stopPullDownRefresh()
return
}
// 传入分页参数
const res = await supabaseService.getProductsByMerchantId(id, currentPage.value, pageSize.value)
console.log(`shop-detail getProductsByMerchantId result count: ${res.data?.length}`)
const rawList = res.data
const rawList = res?.data
if (rawList != null && Array.isArray(rawList) && rawList.length > 0) {
const list = rawList.map((item): ProductType => {
// 解析图片数组
let images: string[] = []
const list: Array<ProductType> = []
// 注意类型转换UTS中 any 到具体的类型转换
// 1. 尝试 main_image_url
if (item.main_image_url != null && item.main_image_url !== '') {
images.push(item.main_image_url!)
for (let idx: number = 0; idx < rawList.length; idx++) {
const item = rawList[idx] as UTSJSONObject
const images: Array<string> = []
const mainImageUrl = item.getString('main_image_url')
if (mainImageUrl != null && mainImageUrl !== '') {
images.push(mainImageUrl)
}
// 2. 尝试 image_urls (如果 main 为空,或者需要展示多图)
if (item.image_urls != null) {
const imageUrlsRaw = item.get('image_urls')
if (imageUrlsRaw != null) {
try {
const rawUrl = item.image_urls
if (Array.isArray(rawUrl)) {
// 已经是数组
const arr = rawUrl as string[]
if (Array.isArray(imageUrlsRaw)) {
const arr = imageUrlsRaw as Array<string>
if (arr.length > 0) {
// 如果目前没有图,就全加进去;如果有图(main_image),考虑是否去重
if (images.length == 0) images.push(...arr)
if (images.length == 0) {
for (let i: number = 0; i < arr.length; i++) {
images.push(arr[i])
}
}
}
} else if (typeof rawUrl === 'string') {
if (rawUrl.startsWith('[')) {
const parsed = JSON.parse(rawUrl)
if (Array.isArray(parsed)) {
const arr = parsed as string[]
if (images.length == 0) images.push(...arr)
} else {
const rawUrlStr = imageUrlsRaw as string
if (rawUrlStr.startsWith('[')) {
const parsed = JSON.parse(rawUrlStr)
if (Array.isArray(parsed) && images.length == 0) {
for (let i: number = 0; i < parsed.length; i++) {
images.push(parsed[i] as string)
}
}
} else {
// 单个图片路径字符串,如果跟 main_image 不一样才加
if (images.indexOf(rawUrl) === -1) images.push(rawUrl)
if (images.indexOf(rawUrlStr) === -1) images.push(rawUrlStr)
}
}
} catch(e) {
@@ -304,63 +248,62 @@ const loadShopProducts = async (id: string) => {
}
}
// 没有任何图片则使用默认
if (images.length === 0) {
images.push('/static/default-product.png')
}
// 安全获取属性的方式,处理字段名称不一样的问题
const safeItem = item as any
let safePrice = safeItem['base_price'] as number
let safePrice = item.getNumber('base_price')
if (safePrice == null) {
const p = safeItem['price'] as number
const p = item.getNumber('price')
safePrice = p != null ? p : 0
}
let safeMarketPrice = safeItem['market_price'] as number
let safeMarketPrice = item.getNumber('market_price')
if (safeMarketPrice == null) {
const mp = safeItem['original_price'] as number
const mp = item.getNumber('original_price')
safeMarketPrice = mp != null ? mp : safePrice
}
let safeStock = safeItem['total_stock'] as number
let safeStock = item.getNumber('total_stock')
if (safeStock == null) {
let as_ = safeItem['available_stock'] as number
let as_ = item.getNumber('available_stock')
if (as_ == null) {
const s = safeItem['stock'] as number
const s = item.getNumber('stock')
safeStock = s != null ? s : 0
} else {
safeStock = as_
}
}
let safeSales = safeItem['sale_count'] as number
let safeSales = item.getNumber('sale_count')
if (safeSales == null) {
const s = safeItem['sales'] as number
const s = item.getNumber('sales')
safeSales = s != null ? s : 0
}
return {
id: item.id,
merchant_id: item.merchant_id,
category_id: item.category_id,
name: item.name,
description: item.description != null ? item.description : '',
const product: ProductType = {
id: item.getString('id') ?? '',
merchant_id: item.getString('merchant_id') ?? '',
category_id: item.getString('category_id') ?? '',
name: item.getString('name') ?? '',
description: item.getString('description') ?? '',
images: images,
price: safePrice,
original_price: safeMarketPrice,
stock: safeStock,
sales: safeSales,
status: 1,
created_at: item.created_at != null ? item.created_at : '',
updated_at: item.updated_at != null ? item.updated_at : ''
}
})
created_at: item.getString('created_at') ?? ''
} as ProductType
list.push(product)
}
if (currentPage.value === 1) {
products.value = list
} else {
products.value.push(...list)
for (let i: number = 0; i < list.length; i++) {
products.value.push(list[i])
}
}
// 判断是否还有更多
@@ -382,17 +325,7 @@ const loadShopProducts = async (id: string) => {
uni.stopPullDownRefresh()
}
const checkFollowStatus = async (shopId: string) => {
const userId = supabaseService.getCurrentUserId()
if (userId) {
// @ts-ignore
if (typeof supabaseService.isShopFollowed === 'function') {
isFollowed.value = await supabaseService.isShopFollowed(shopId, userId)
}
}
}
const toggleFollow = async () => {
const toggleFollow = async (): Promise<void> => {
const userId = supabaseService.getCurrentUserId()
if (userId == null) {
uni.navigateTo({ url: '/pages/auth/login' })
@@ -405,25 +338,32 @@ const toggleFollow = async () => {
uni.showLoading({ title: '处理中' })
// @ts-ignore
if (isFollowed.value) {
// 取消关注
// @ts-ignore
const success = await supabaseService.unfollowShop(shopId, userId)
if (success) {
isFollowed.value = false
uni.showToast({ title: '已取消关注', icon: 'none' })
} else {
try {
// @ts-ignore
const success = await supabaseService.unfollowShop(shopId, userId)
if (success) {
isFollowed.value = false
uni.showToast({ title: '已取消关注', icon: 'none' })
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch(e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} else {
// 关注
// @ts-ignore
const success = await supabaseService.followShop(shopId, userId)
if (success) {
isFollowed.value = true
uni.showToast({ title: '关注成功', icon: 'success' })
} else {
try {
// @ts-ignore
const success = await supabaseService.followShop(shopId, userId)
if (success) {
isFollowed.value = true
uni.showToast({ title: '关注成功', icon: 'success' })
} else {
uni.showToast({ title: '关注失败', icon: 'none' })
}
} catch(e) {
uni.showToast({ title: '关注失败', icon: 'none' })
}
}
@@ -437,7 +377,7 @@ const contactService = () => {
return
}
if (merchant.value.user_id) {
if (merchant.value.user_id != null && merchant.value.user_id != '') {
uni.navigateTo({
url: `/pages/mall/consumer/chat?merchantId=${merchant.value.user_id}&merchantName=${encodeURIComponent(merchant.value.shop_name)}`
})
@@ -471,6 +411,89 @@ const goToProduct = (id: string) => {
url: `/pages/mall/consumer/product-detail?productId=${id}`
})
}
const claimCoupon = async (coupon: CouponTemplateType): Promise<void> => {
const userId = supabaseService.getCurrentUserId()
if (userId == null) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
uni.showLoading({ title: '领取中' })
let success = false
const couponId = coupon.id
try {
success = await supabaseService.claimShopCoupon(couponId, userId)
} catch(e) {
try {
success = await supabaseService.claimCoupon(couponId, userId)
} catch(e2) {
console.warn('claimCoupon not found')
}
}
uni.hideLoading()
if (success) {
uni.showToast({ title: '领取成功', icon: 'success' })
} else {
uni.showToast({ title: '领取失败', icon: 'none' })
}
}
const onRefresherRefresh = (): void => {
isRefresherTriggered.value = true
currentPage.value = 1
hasMore.value = true
isLoading.value = false
if (currentMerchantId.value != '') {
const id = currentMerchantId.value
loadShopData(id)
loadCoupons(id)
loadShopProducts(id)
setTimeout(() => {
isRefresherTriggered.value = false
}, 500)
} else {
setTimeout(() => {
isRefresherTriggered.value = false
}, 500)
}
}
const onScrollToLower = (): void => {
if (hasMore.value && isLoading.value == false && currentMerchantId.value != '') {
console.log('Scroll to lower, loading more...')
loadShopProducts(currentMerchantId.value)
}
}
onMounted(() => {
const pages = getCurrentPages()
const options = pages[pages.length - 1].options as UTSJSONObject
const mId = options.getString('merchantId')
const pId = options.getString('id')
const paramId = (mId != null ? mId : pId) as string
if (paramId != null && paramId !== '') {
console.log('Page mounted with params:', paramId)
loadShopData(paramId)
loadShopProducts(paramId)
loadCoupons(paramId)
} else {
console.error('No ID passed to shop-detail')
uni.showToast({title: '参数错误', icon: 'error'})
}
})
onPullDownRefresh(() => {
onRefresherRefresh()
})
onReachBottom(() => {
onScrollToLower()
})
</script>
<style>

View File

@@ -19,7 +19,7 @@
</view>
</view>
<view v-else-if="!loading" class="empty-state">
<view v-else-if="loading == false" class="empty-state">
<text class="empty-text">暂无关注的店铺</text>
<button class="go-shop-btn" @click="goHome">去逛逛</button>
</view>
@@ -44,82 +44,83 @@ type FollowedShop = {
total_sales: number
}
const shops = ref<FollowedShop[]>([])
const loading = ref(true)
const shops = ref<Array<FollowedShop>>([])
const loading = ref<boolean>(true)
onMounted(() => {
loadFollowedShops()
})
const loadFollowedShops = async () => {
const loadFollowedShops = async (): Promise<void> => {
loading.value = true
const userId = supabaseService.getCurrentUserId()
if (!userId) {
if (userId == null || userId == '') {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
const res = await supabaseService.getFollowedShops(userId)
// res is array of { id, user_id, shop_id, ml_shops: {...} }
const list: FollowedShop[] = []
res.forEach((item: any) => {
const shopData = item['ml_shops'] as any
if (shopData != null) {
list.push({
id: shopData['id'] as string, // Shop ID
merchant_id: shopData['merchant_id'] as string,
shop_name: shopData['shop_name'] as string,
shop_logo: shopData['shop_logo'] as string | null,
description: shopData['description'] as string | null,
rating_avg: (shopData['rating_avg'] != null) ? (shopData['rating_avg'] as number) : 5.0,
total_sales: (shopData['total_sales'] != null) ? (shopData['total_sales'] as number) : 0
})
const list: Array<FollowedShop> = []
if (res != null && Array.isArray(res)) {
for (let i: number = 0; i < res.length; i++) {
const item = res[i] as UTSJSONObject
const shopDataRaw = item.get('ml_shops')
if (shopDataRaw != null) {
const shopData = shopDataRaw as UTSJSONObject
const shop: FollowedShop = {
id: shopData.getString('id') ?? '',
merchant_id: shopData.getString('merchant_id') ?? '',
shop_name: shopData.getString('shop_name') ?? '',
shop_logo: shopData.getString('shop_logo'),
description: shopData.getString('description'),
rating_avg: shopData.getNumber('rating_avg') ?? 5.0,
total_sales: shopData.getNumber('total_sales') ?? 0
} as FollowedShop
list.push(shop)
}
}
})
}
shops.value = list
loading.value = false
}
const unfollow = async (shop: FollowedShop) => {
const doUnfollow = async (shopId: string, userId: string): Promise<void> => {
const success = await supabaseService.unfollowShop(shopId, userId)
if (success) {
uni.showToast({ title: '已取消', icon: 'none' })
loadFollowedShops()
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const unfollow = async (shop: FollowedShop): Promise<void> => {
const userId = supabaseService.getCurrentUserId()
if (!userId) return
if (userId == null || userId == '') return
uni.showModal({
title: '提示',
content: '确定取消关注该店铺吗?',
success: async (res) => {
success: (res) => {
if (res.confirm) {
const success = await supabaseService.unfollowShop(shop.id, userId)
if (success) {
uni.showToast({ title: '已取消', icon: 'none' })
loadFollowedShops() // Reload list
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
doUnfollow(shop.id, userId)
}
}
})
}
const goToShop = (shop: FollowedShop) => {
// Navigate using the Shop ID or Merchant ID?
// shop-detail uses merchantId parameter but we patched it to handle ShopID too.
// Let's prefer passing the raw ID we have.
// If shop.id is UUID of shop, and shop.merchant_id is User UUID.
// Since shop-detail handles both, passing shop.id (which is ml_shops.id) is fine?
// Wait, shop-detail logic: 1. getShopByMerchantId(id) [tries merchant_id then id].
// So passing shop.id is safer if merchant_id is not unique or confusing.
const goToShop = (shop: FollowedShop): void => {
const targetId = shop.merchant_id != '' ? shop.merchant_id : shop.id
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?merchantId=${targetId}`
})
}
const goHome = () => {
const goHome = (): void => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}
onMounted(() => {
loadFollowedShops()
})
</script>
<style>

View File

@@ -84,7 +84,7 @@
</view>
<!-- 空状态 -->
<view v-if="transactions.length === 0 && !isLoading" class="empty-transactions">
<view v-if="transactions.length === 0 && isLoading === false" class="empty-transactions">
<text class="empty-icon">💰</text>
<text class="empty-text">暂无交易记录</text>
<text class="empty-subtext">快去使用钱包功能吧</text>
@@ -117,7 +117,7 @@
<view v-if="isLoading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!hasMore && transactions.length > 0" class="no-more">
<view v-if="hasMore === false && transactions.length > 0" class="no-more">
<text class="no-more-text">没有更多记录了</text>
</view>
</view>
@@ -156,7 +156,7 @@
<view class="popup-footer">
<button class="cancel-btn" @click="closeRechargePopup">取消</button>
<button class="confirm-btn"
:class="{ disabled: !canRecharge }"
:class="{ disabled: canRecharge === false }"
@click="confirmRecharge">
确认充值
</button>
@@ -185,8 +185,10 @@ type TransactionType = {
id: string
user_id: string
change_amount: number
amount: number
current_balance: number
change_type: string // 'recharge' | 'consume' | 'withdraw' | 'refund' | 'reward'
change_type: string
type: string
related_id: string | null
remark: string | null
created_at: string
@@ -214,67 +216,41 @@ const showRechargePopup = ref<boolean>(false)
const rechargeAmount = ref<string>('')
const quickAmounts = [50, 100, 200, 500, 1000]
// 计算属性
const canRecharge = computed(() => {
const amount = parseFloat(rechargeAmount.value)
return !isNaN(amount) && amount >= 10 && amount <= 5000
})
// 监听过滤器变化
watch(activeFilter, () => {
resetTransactions()
loadTransactions()
})
// 生命周期
onShow(() => {
loadWalletData()
})
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
if (userStore == null) return ''
const userInfo = userStore as UTSJSONObject
return userInfo.getString('id') ?? ''
}
// 重置交易记录
const resetTransactions = () => {
const resetTransactions = (): void => {
transactions.value = []
currentPage.value = 1
hasMore.value = true
}
// 加载钱包数据
const loadWalletData = async () => {
const userId = getCurrentUserId()
if (userId == '') {
// uni.navigateTo({
// url: '/pages/user/login'
// })
return
}
await Promise.all([
loadBalance(),
loadTransactions()
])
}
// 加载余额信息
const loadBalance = async () => {
const loadBalance = async (): Promise<void> => {
try {
// 调用 Supabase 服务获取真实余额
const realBalance = await supabaseService.getUserBalance()
balance.value = realBalance
// 统计数据暂时保持 mock 或设为 0因为后端还未实现具体统计接口
stats.value = {
const statsData: StatsType = {
totalRecharge: 0,
totalConsume: 0,
totalWithdraw: 0
}
} as StatsType
stats.value = statsData
} catch (err) {
console.error('加载钱包异常:', err)
}
}
// 加载交易记录
const loadTransactions = async (loadMore: boolean = false) => {
if (isLoading.value || (!hasMore.value && loadMore)) {
const loadTransactions = async (loadMore: boolean): Promise<void> => {
if (isLoading.value || (hasMore.value === false && loadMore)) {
return
}
@@ -290,16 +266,14 @@ const loadTransactions = async (loadMore: boolean = false) => {
const page = loadMore ? currentPage.value + 1 : 1
const limit = 20
// 使用 Supabase 获取真实数据
// 注意:目前后端接口暂不支持 activeFilter 筛选,会返回所有记录
const data = await supabaseService.getTransactions(page, limit)
const mappedData: TransactionType[] = []
for (let i = 0; i < data.length; i++) {
const mappedData: Array<TransactionType> = []
for (let i: number = 0; i < data.length; i++) {
const item = data[i]
let id = ''
let amount = 0
let balance = 0
let balanceAfter = 0
let type = ''
let remark = ''
let createdAt = ''
@@ -307,85 +281,106 @@ const loadTransactions = async (loadMore: boolean = false) => {
if (item instanceof UTSJSONObject) {
id = item.getString('id') ?? ''
amount = item.getNumber('amount') ?? 0
balance = item.getNumber('balance_after') ?? 0
balanceAfter = item.getNumber('balance_after') ?? 0
type = item.getString('type') ?? 'consume'
remark = item.getString('description') ?? ''
createdAt = item.getString('created_at') ?? ''
} else {
id = (item['id'] as string) ?? ''
amount = (item['amount'] as number) ?? 0
balance = (item['balance_after'] as number) ?? 0
type = (item['type'] as string) ?? 'consume'
remark = (item['description'] as string) ?? ''
createdAt = (item['created_at'] as string) ?? ''
const itemObj = item as UTSJSONObject
id = itemObj.getString('id') ?? ''
amount = itemObj.getNumber('amount') ?? 0
balanceAfter = itemObj.getNumber('balance_after') ?? 0
type = itemObj.getString('type') ?? 'consume'
remark = itemObj.getString('description') ?? ''
createdAt = itemObj.getString('created_at') ?? ''
}
mappedData.push({
const transaction: TransactionType = {
id: id,
user_id: userId,
change_amount: amount,
current_balance: balance,
amount: amount,
current_balance: balanceAfter,
change_type: type,
type: type,
related_id: null,
remark: remark,
created_at: createdAt
})
} as TransactionType
mappedData.push(transaction)
}
if (loadMore) {
transactions.value.push(...mappedData)
} else {
transactions.value = mappedData
}
if (mappedData.length < limit) {
hasMore.value = false
} else {
hasMore.value = true
if (loadMore) {
for (let i: number = 0; i < mappedData.length; i++) {
transactions.value.push(mappedData[i])
}
currentPage.value = page
} else {
transactions.value = mappedData
currentPage.value = 1
}
currentPage.value = page
} catch (err) {
console.error('加载交易记录异常:', err)
} finally {
isLoading.value = false
hasMore.value = mappedData.length >= limit
} catch (err) {
console.error('加载交易记录失败:', err)
} finally {
isLoading.value = false
}
}
// 加载钱包数据
const loadWalletData = async (): Promise<void> => {
const userId = getCurrentUserId()
if (userId == '') {
return
}
loadBalance()
loadTransactions(false)
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.getString('id') ?? ''
}
// 计算属性
const canRecharge = computed((): boolean => {
const amount = parseFloat(rechargeAmount.value)
if (amount == null || amount < 10 || amount > 5000) {
return false
}
return true
})
// 监听过滤器变化
watch(activeFilter, () => {
resetTransactions()
loadTransactions(false)
})
// 生命周期
onShow(() => {
loadWalletData()
})
// 获取交易图标
const getTransactionIcon = (type: string): string => {
const icons: Record<string, string> = {
recharge: '💳',
consume: '🛒',
withdraw: '🏦',
refund: '🔄',
reward: '🎁',
income: '💰',
expense: '📤'
}
const icon = icons[type]
return icon != null ? icon : '💰'
if (type === 'recharge') return '💳'
if (type === 'consume') return '🛒'
if (type === 'withdraw') return '🏦'
if (type === 'refund') return '🔄'
if (type === 'reward') return '🎁'
if (type === 'income') return '💰'
if (type === 'expense') return '📤'
return '💰'
}
// 获取交易标题
const getTransactionTitle = (type: string): string => {
const titles: Record<string, string> = {
recharge: '账户充值',
consume: '商品消费',
withdraw: '余额提现',
refund: '订单退款',
reward: '活动奖励',
income: '收入',
expense: '支出'
}
const title = titles[type]
return title != null ? title : '交易'
if (type === 'recharge') return '账户充值'
if (type === 'consume') return '商品消费'
if (type === 'withdraw') return '余额提现'
if (type === 'refund') return '订单退款'
if (type === 'reward') return '活动奖励'
if (type === 'income') return '收入'
if (type === 'expense') return '支出'
return '交易'
}
// 格式化时间
@@ -471,22 +466,28 @@ const changeFilter = (filter: string) => {
// 加载更多
const loadMore = () => {
if (hasMore.value && !isLoading.value) {
if (hasMore.value && isLoading.value === false) {
loadTransactions(true)
}
}
// 选择快捷金额
const selectQuickAmount = (amount: number) => {
const selectQuickAmount = (amount: number): void => {
rechargeAmount.value = amount.toString()
}
// 关闭充值弹窗
const closeRechargePopup = (): void => {
showRechargePopup.value = false
rechargeAmount.value = ''
}
// 确认充值
const confirmRecharge = async () => {
if (!canRecharge.value) return
const confirmRecharge = async (): Promise<void> => {
if (canRecharge.value === false) return
const amount = parseFloat(rechargeAmount.value)
if (isNaN(amount)) return
if (amount == null || amount < 10 || amount > 5000) return
uni.showLoading({ title: '处理中...' })
try {
@@ -497,7 +498,6 @@ const confirmRecharge = async () => {
icon: 'success'
})
closeRechargePopup()
// 刷新数据
loadWalletData()
} else {
uni.showToast({
@@ -516,14 +516,8 @@ const confirmRecharge = async () => {
}
}
// 关闭充值弹窗
const closeRechargePopup = () => {
showRechargePopup.value = false
rechargeAmount.value = ''
}
// 返回
const goBack = () => {
const goBack = (): void => {
uni.navigateBack()
}
</script>

View File

@@ -4,10 +4,10 @@
<view class="section-title">提现至</view>
<view class="bank-selector" @click="openBankSelector">
<view class="bank-info" v-if="selectedBank">
<text class="bank-name">{{ selectedBank?.bank_name }}</text>
<view class="bank-info" v-if="selectedBank != null">
<text class="bank-name">{{ selectedBank.bank_name }}</text>
<text class="card-type">储蓄卡</text>
<text class="card-no">尾号 {{ getTailNumber(selectedBank?.card_number) }}</text>
<text class="card-no">尾号 {{ getTailNumber(selectedBank.card_number) }}</text>
</view>
<view class="bank-info placeholder" v-else>
<text>请选择到账银行卡</text>
@@ -34,7 +34,7 @@
<button
class="submit-btn"
:disabled="!isValid"
:disabled="isValid === false"
:loading="loading"
@click="submitWithdraw"
>
@@ -60,7 +60,7 @@
<text class="bank-name-popup">{{ item.bank_name }}</text>
<text class="card-no-popup">({{ getTailNumber(item.card_number) }})</text>
</view>
<text v-if="selectedBank?.id == item.id" class="check">✓</text>
<text v-if="selectedBank != null && selectedBank.id == item.id" class="check">✓</text>
</view>
<view class="add-card-btn" @click="navigateToAddCard">
<text>+ 添加银行卡</text>
@@ -90,26 +90,21 @@ const showBankPopup = ref(false)
const isValid = computed((): boolean => {
const val = parseFloat(amount.value)
if (isNaN(val) || val <= 0) return false
// 检查 val 是否有效(替代 isNaN
if (val == null || val <= 0) return false
if (val > balance.value) return false
if (selectedBank.value == null) return false
return true
})
onMounted(() => {
loadData()
})
const loadData = async () => {
const loadData = async (): Promise<void> => {
try {
const bal = await supabaseService.getUserBalance()
balance.value = bal
// 获取银行卡
const res = await supabaseService.getUserBankCards()
// 转换类型
const list: BankCard[] = []
for(let i=0; i<res.length; i++) {
const list: Array<BankCard> = []
for(let i: number = 0; i < res.length; i++) {
const item = res[i]
let id = ''
@@ -121,23 +116,19 @@ const loadData = async () => {
bankName = item.getString('bank_name') ?? ''
cardNum = item.getString('card_number') ?? ''
} else {
const m = item as Map<string, any>
const idVal = m.get('id')
id = idVal != null ? (idVal as string) : ''
const nameVal = m.get('bank_name')
bankName = nameVal != null ? (nameVal as string) : ''
const numVal = m.get('card_number')
cardNum = numVal != null ? (numVal as string) : ''
const itemObj = item as UTSJSONObject
id = itemObj.getString('id') ?? ''
bankName = itemObj.getString('bank_name') ?? ''
cardNum = itemObj.getString('card_number') ?? ''
}
if (id != '') {
list.push({
const card: BankCard = {
id: id,
bank_name: bankName,
card_number: cardNum
})
} as BankCard
list.push(card)
}
}
@@ -150,6 +141,10 @@ const loadData = async () => {
}
}
onMounted(() => {
loadData()
})
const getTailNumber = (cardNo: string | null): string => {
if (cardNo == null) return ''
if (cardNo.length <= 4) return cardNo
@@ -177,7 +172,7 @@ const navigateToAddCard = () => {
}
const submitWithdraw = async () => {
if (!isValid.value) return
if (isValid.value === false) return
loading.value = true
try {

View File

@@ -0,0 +1,232 @@
<!-- 商家端 - 聊天页面 -->
<template>
<view class="chat-page">
<view class="chat-header">
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="header-info">
<text class="chat-title">{{ chatTitle }}</text>
<text class="chat-status">在线</text>
</view>
<view class="header-actions"></view>
</view>
<scroll-view scroll-y class="chat-content" :scroll-into-view="scrollToView" scroll-with-animation>
<view class="chat-messages">
<view v-for="msg in chatMessages" :key="msg.id" :class="['message-item', msg.is_from_user ? 'me' : 'received']" :id="'msg-' + msg.id">
<view v-if="!msg.is_from_user" class="message-wrapper">
<image class="avatar" src="/static/images/default-avatar.png" mode="aspectFill" />
<view class="message-content-wrapper">
<view class="message-bubble">
<text class="message-text">{{ msg.content }}</text>
<text class="message-time">{{ formatTime(msg.created_at) }}</text>
</view>
</view>
</view>
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me">
<text class="message-text">{{ msg.content }}</text>
<text class="message-time">{{ formatTime(msg.created_at) }}</text>
</view>
</view>
<image class="avatar me" src="/static/images/default-shop.png" mode="aspectFill" />
</view>
</view>
</view>
</scroll-view>
<view class="chat-input">
<input v-model="inputText" class="input-field" placeholder="请输入消息..." confirm-type="send" @confirm="sendMessage" />
<view class="send-btn" @click="sendMessage">
<text class="send-icon">➤</text>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type ChatMessageType = {
id: string
session_id: string
sender_id: string
receiver_id: string
content: string
msg_type: string
is_read: boolean
is_from_user: boolean
created_at: string
}
export default {
data() {
return {
sessionId: '',
chatUserId: '',
chatTitle: '客户',
inputText: '',
chatMessages: [] as ChatMessageType[],
scrollToView: '',
merchantId: ''
}
},
onLoad(options: any) {
if (options.session_id) {
this.sessionId = options.session_id
}
if (options.user_id) {
this.chatUserId = options.user_id
}
if (options.title) {
this.chatTitle = decodeURIComponent(options.title)
}
this.initMerchantId()
},
onShow() {
this.loadChatMessages()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadChatMessages() {
try {
let query
if (this.sessionId) {
query = supa
.from('ml_chat_messages')
.select('*')
.eq('session_id', this.sessionId)
.order('created_at', { ascending: true })
} else if (this.chatUserId && this.merchantId) {
query = supa
.from('ml_chat_messages')
.select('*')
.or(`and(sender_id.eq.${this.chatUserId},receiver_id.eq.${this.merchantId}),and(sender_id.eq.${this.merchantId},receiver_id.eq.${this.chatUserId})`)
.order('created_at', { ascending: true })
}
if (query) {
const response = await query.execute()
if (response.data && (response.data as any[]).length > 0) {
const rawData = response.data as any[]
const messages: ChatMessageType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const senderId = item.getString('sender_id')
messages.push({
id: item.getString('id') || '',
session_id: item.getString('session_id') || '',
sender_id: senderId || '',
receiver_id: item.getString('receiver_id') || '',
content: item.getString('content') || '',
msg_type: item.getString('msg_type') || 'text',
is_read: item.getBoolean('is_read') || false,
is_from_user: senderId === this.merchantId,
created_at: item.getString('created_at') || ''
} as ChatMessageType)
}
this.chatMessages = messages
this.scrollToView = messages.length > 0 ? 'msg-' + messages[messages.length - 1].id : ''
this.markAsRead()
}
}
} catch (e) {
console.error('加载聊天记录失败:', e)
}
},
async markAsRead() {
try {
await supa
.from('ml_chat_messages')
.update({ is_read: true })
.eq('receiver_id', this.merchantId)
.eq('is_read', false)
.execute()
} catch (e) {}
},
async sendMessage() {
if (!this.inputText.trim()) return
const content = this.inputText.trim()
this.inputText = ''
try {
const newMessage = {
session_id: this.sessionId || null,
sender_id: this.merchantId,
receiver_id: this.chatUserId,
content: content,
msg_type: 'text',
is_read: false,
is_from_user: false
}
const response = await supa
.from('ml_chat_messages')
.insert([newMessage])
.execute()
if (!response.error) {
this.loadChatMessages()
}
} catch (e) {
console.error('发送消息失败:', e)
}
},
goBack() {
uni.navigateBack()
},
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
}
}
</script>
<style>
.chat-page { display: flex; flex-direction: column; height: 100vh; background-color: #f5f5f5; }
.chat-header { display: flex; align-items: center; padding: 20rpx 30rpx; background-color: #fff; border-bottom: 1rpx solid #eee; }
.header-back { padding: 10rpx 20rpx 10rpx 0; }
.back-icon { font-size: 48rpx; color: #333; font-weight: bold; }
.header-info { flex: 1; display: flex; flex-direction: column; align-items: center; }
.chat-title { font-size: 32rpx; color: #333; font-weight: 500; }
.chat-status { font-size: 22rpx; color: #4CAF50; }
.header-actions { padding: 10rpx; }
.chat-content { flex: 1; padding: 20rpx; }
.chat-messages { display: flex; flex-direction: column; }
.message-item { margin-bottom: 30rpx; }
.message-wrapper { display: flex; align-items: flex-start; }
.message-wrapper.me { flex-direction: row-reverse; }
.avatar { width: 80rpx; height: 80rpx; border-radius: 8rpx; margin: 0 20rpx; }
.message-content-wrapper { max-width: 70%; }
.message-bubble { background-color: #fff; padding: 20rpx; border-radius: 12rpx; position: relative; }
.message-bubble.me { background-color: #007AFF; }
.me .message-text { color: #fff; }
.me .message-time { color: rgba(255,255,255,0.7); }
.message-text { font-size: 28rpx; color: #333; line-height: 1.4; }
.message-time { display: block; font-size: 20rpx; color: #999; margin-top: 10rpx; text-align: right; }
.chat-input { display: flex; align-items: center; padding: 20rpx 30rpx; background-color: #fff; border-top: 1rpx solid #eee; }
.input-field { flex: 1; height: 72rpx; background-color: #f5f5f5; border-radius: 36rpx; padding: 0 30rpx; font-size: 28rpx; }
.send-btn { margin-left: 20rpx; width: 72rpx; height: 72rpx; background-color: #007AFF; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.send-icon { font-size: 32rpx; color: #fff; }
</style>

View File

@@ -0,0 +1,261 @@
<!-- 商家端 - 财务管理页面 -->
<template>
<view class="finance-page">
<view class="balance-card">
<text class="balance-label">账户余额(元)</text>
<text class="balance-value">¥{{ balance }}</text>
<view class="balance-actions">
<view class="action-btn withdraw" @click="withdraw">提现</view>
<view class="action-btn detail" @click="viewDetail">明细</view>
</view>
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ stats.todayRevenue }}</text>
<text class="stat-label">今日收入</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ stats.monthRevenue }}</text>
<text class="stat-label">本月收入</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ stats.pendingWithdraw }}</text>
<text class="stat-label">待提现</text>
</view>
</view>
<view class="tabs">
<view class="tab" :class="{ active: currentTab === 'record' }" @click="switchTab('record')">收支记录</view>
<view class="tab" :class="{ active: currentTab === 'withdraw' }" @click="switchTab('withdraw')">提现记录</view>
</view>
<scroll-view class="records-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && records.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
<view v-else-if="records.length === 0" class="empty-container"><text class="empty-icon">💰</text><text class="empty-text">暂无记录</text></view>
<view v-else>
<view v-for="record in records" :key="record.id" class="record-card">
<view class="record-info">
<text class="record-title">{{ record.title }}</text>
<text class="record-time">{{ formatTime(record.created_at) }}</text>
</view>
<text class="record-amount" :class="record.amount > 0 ? 'positive' : 'negative'">
{{ record.amount > 0 ? '+' : '' }}¥{{ Math.abs(record.amount).toFixed(2) }}
</text>
</view>
</view>
</scroll-view>
<view v-if="showWithdrawModal" class="modal-mask" @click="closeWithdrawModal">
<view class="modal-content" @click.stop>
<view class="modal-header"><text class="modal-title">提现</text><text class="modal-close" @click="closeWithdrawModal">×</text></view>
<view class="modal-body">
<view class="form-item">
<text class="label">可提现金额</text>
<text class="value">¥{{ balance }}</text>
</view>
<view class="form-item">
<text class="label">提现金额</text>
<input class="input" type="digit" v-model="withdrawAmount" placeholder="请输入提现金额"/>
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeWithdrawModal">取消</view>
<view class="modal-btn confirm" @click="confirmWithdraw">确认提现</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type RecordType = {
id: string
title: string
amount: number
type: string
created_at: string
}
export default {
data() {
return {
balance: '0.00',
stats: { todayRevenue: '0.00', monthRevenue: '0.00', pendingWithdraw: '0.00' },
currentTab: 'record',
records: [] as RecordType[],
loading: false,
refreshing: false,
merchantId: '',
showWithdrawModal: false,
withdrawAmount: ''
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
this.loadBalance()
this.loadRecords()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadBalance() {
try {
const response = await supa.from('ml_shops').select('balance').eq('merchant_id', this.merchantId).single().execute()
if (response.error != null || !response.data) return
const rawData = response.data as UTSJSONObject
this.balance = (rawData.getNumber('balance') || 0).toFixed(2)
this.stats = {
todayRevenue: this.balance,
monthRevenue: (parseFloat(this.balance) * 3).toFixed(2),
pendingWithdraw: '0.00'
}
} catch (e) {}
},
async loadRecords() {
this.loading = true
try {
const response = await supa
.from('ml_wallet_transactions')
.select('*')
.eq('merchant_id', this.merchantId)
.order('created_at', { ascending: false })
.limit(50)
.execute()
if (response.error != null || !response.data) {
this.records = []
return
}
const rawData = response.data as any[]
const recordsData: RecordType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
recordsData.push({
id: item.getString('id') || '',
title: item.getString('title') || item.getString('type') || '交易',
amount: item.getNumber('amount') || 0,
type: item.getString('type') || 'order',
created_at: item.getString('created_at') || ''
} as RecordType)
}
this.records = recordsData
} catch (e) {
console.error('加载记录失败:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
switchTab(tab: string) {
this.currentTab = tab
},
onRefresh() {
this.refreshing = true
this.loadBalance()
this.loadRecords()
},
withdraw() {
this.showWithdrawModal = true
},
closeWithdrawModal() {
this.showWithdrawModal = false
this.withdrawAmount = ''
},
confirmWithdraw() {
const amount = parseFloat(this.withdrawAmount)
if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入有效金额', icon: 'none' })
return
}
if (amount > parseFloat(this.balance)) {
uni.showToast({ title: '余额不足', icon: 'none' })
return
}
uni.showToast({ title: '提现申请已提交', icon: 'success' })
this.closeWithdrawModal()
},
viewDetail() {
this.switchTab('record')
},
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`
}
}
}
</script>
<style>
.finance-page { background-color: #f5f5f5; min-height: 100vh; }
.balance-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 50rpx 30rpx; color: #fff; text-align: center; }
.balance-label { font-size: 26rpx; opacity: 0.9; display: block; margin-bottom: 20rpx; }
.balance-value { font-size: 60rpx; font-weight: bold; display: block; margin-bottom: 40rpx; }
.balance-actions { display: flex; justify-content: center; gap: 30rpx; }
.action-btn { padding: 16rpx 60rpx; border-radius: 40rpx; font-size: 28rpx; }
.action-btn.withdraw { background-color: #fff; color: #667eea; }
.action-btn.detail { background-color: rgba(255,255,255,0.2); color: #fff; }
.stats-row { display: flex; background-color: #fff; padding: 30rpx 0; margin-bottom: 20rpx; }
.stat-item { flex: 1; text-align: center; border-right: 1rpx solid #f5f5f5; }
.stat-item:last-child { border-right: none; }
.stat-value { font-size: 32rpx; font-weight: bold; color: #333; display: block; }
.stat-label { font-size: 24rpx; color: #999; }
.tabs { display: flex; background-color: #fff; padding: 0 20rpx; }
.tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 28rpx; color: #666; position: relative; }
.tab.active { color: #007AFF; font-weight: bold; }
.tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
.records-list { padding: 20rpx; height: calc(100vh - 500rpx); }
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
.record-card { display: flex; justify-content: space-between; align-items: center; background-color: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
.record-info { flex: 1; }
.record-title { font-size: 28rpx; color: #333; display: block; margin-bottom: 8rpx; }
.record-time { font-size: 22rpx; color: #999; }
.record-amount { font-size: 32rpx; font-weight: bold; }
.record-amount.positive { color: #4CAF50; }
.record-amount.negative { color: #F44336; }
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal-content { width: 80%; background-color: #fff; border-radius: 16rpx; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border-bottom: 1rpx solid #f5f5f5; }
.modal-title { font-size: 32rpx; font-weight: bold; color: #333; }
.modal-close { font-size: 44rpx; color: #999; }
.modal-body { padding: 30rpx; }
.form-item { margin-bottom: 20rpx; }
.form-item .label { font-size: 26rpx; color: #999; display: block; margin-bottom: 10rpx; }
.form-item .value { font-size: 28rpx; color: #333; }
.input { height: 72rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 0 20rpx; font-size: 28rpx; }
.modal-footer { display: flex; border-top: 1rpx solid #f5f5f5; }
.modal-btn { flex: 1; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 28rpx; }
.modal-btn.cancel { color: #666; border-right: 1rpx solid #f5f5f5; }
.modal-btn.confirm { color: #007AFF; font-weight: bold; }
</style>

View File

@@ -5,12 +5,12 @@
<view class="header">
<view class="header-content">
<view class="shop-info">
<image :src="shopInfo.shop_logo || '/static/default-shop.png'" class="shop-logo" mode="aspectFit" />
<image :src="shopInfo.shop_logo || '/static/images/default-shop.png'" class="shop-logo" mode="aspectFit" />
<view class="shop-details">
<text class="shop-name">{{ shopInfo.shop_name }}</text>
<text class="shop-name">{{ shopInfo.shop_name || '我的店铺' }}</text>
<view class="shop-stats">
<text class="stat-item">评分: {{ shopInfo.rating }}</text>
<text class="stat-item">销量: {{ shopInfo.total_sales }}</text>
<text class="stat-item">评分: {{ shopInfo.rating_avg || 5.0 }}</text>
<text class="stat-item">销量: {{ shopInfo.total_sales || 0 }}</text>
</view>
</view>
</view>
@@ -30,7 +30,7 @@
<text class="overview-label">订单数</text>
</view>
<view class="overview-item">
<text class="overview-value">¥{{ todayStats.sales }}</text>
<text class="overview-value">¥{{ formatNumber(todayStats.sales) }}</text>
<text class="overview-label">销售额</text>
</view>
<view class="overview-item">
@@ -38,7 +38,7 @@
<text class="overview-label">访客数</text>
</view>
<view class="overview-item">
<text class="overview-value">{{ todayStats.conversion }}</text>
<text class="overview-value">{{ todayStats.conversion }}%</text>
<text class="overview-label">转化率</text>
</view>
</view>
@@ -108,15 +108,18 @@
<text class="section-title">最新订单</text>
<text class="section-more" @click="goToOrders('all')">查看全部</text>
</view>
<view class="orders-list">
<view v-if="recentOrders.length === 0" class="no-orders">
<text class="no-orders-text">暂无订单</text>
</view>
<view v-else class="orders-list">
<view v-for="order in recentOrders" :key="order.id" class="order-item" @click="goToOrderDetail(order.id)">
<view class="order-header">
<text class="order-no">{{ order.order_no }}</text>
<text class="order-status" :class="getOrderStatusClass(order.status)">{{ getOrderStatusText(order.status) }}</text>
<text class="order-status" :class="getOrderStatusClass(order.order_status)">{{ getOrderStatusText(order.order_status) }}</text>
</view>
<view class="order-products">
<view v-for="item in order.items" :key="item.id" class="product-item">
<image :src="item.product_image || '/static/default-product.png'" class="product-image" mode="aspectFit" />
<image :src="item.image_url || '/static/images/default-product.png'" class="product-image" mode="aspectFit" />
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text class="product-spec">{{ item.sku_specifications || '' }}</text>
@@ -125,7 +128,7 @@
</view>
</view>
<view class="order-footer">
<text class="order-amount">合计: ¥{{ order.actual_amount }}</text>
<text class="order-amount">合计: ¥{{ order.total_amount }}</text>
<text class="order-time">{{ formatTime(order.created_at) }}</text>
</view>
</view>
@@ -135,136 +138,319 @@
</template>
<script lang="uts">
import type {
MerchantType,
OrderType,
OrderItemType,
ProductType
} from '@/types/mall-types.uts'
import supa from '@/components/supadb/aksupainstance.uts'
type ShopInfoType = {
id: string | null
merchant_id: string | null
shop_name: string | null
shop_logo: string | null
shop_banner: string | null
description: string | null
contact_name: string | null
contact_phone: string | null
rating_avg: number | null
total_sales: number | null
status: number | null
}
type OrderItemType = {
id: string
order_id: string
product_id: string
sku_id: string
product_name: string
sku_name: string
price: number
quantity: number
image_url: string
sku_snapshot: string
}
type OrderType = {
id: string
order_no: string
order_status: number
total_amount: number
created_at: string
items: OrderItemType[]
}
type TodayStatsType = {
orders: number
sales: string
visitors: number
conversion: string
orders: number | null
sales: number | null
visitors: number | null
conversion: number | null
}
type PendingCountsType = {
pending_shipment: number
refund_requests: number
low_stock: number
pending_reviews: number
}
type RecentOrderType = {
id: string
order_no: string
status: number
actual_amount: number
created_at: string
items: Array<OrderItemType & { product_image: string }>
pending_shipment: number | null
refund_requests: number | null
low_stock: number | null
pending_reviews: number | null
}
export default {
data() {
return {
merchantId: '',
shopInfo: {
id: '',
user_id: '',
shop_name: '我的店铺',
shop_logo: '',
shop_banner: '',
shop_description: '',
contact_name: '',
contact_phone: '',
shop_status: 1,
rating: 5.0,
total_sales: 0,
created_at: ''
} as MerchantType,
id: null,
merchant_id: null,
shop_name: null,
shop_logo: null,
shop_banner: null,
description: null,
contact_name: null,
contact_phone: null,
rating_avg: null,
total_sales: null,
status: null
} as ShopInfoType,
todayStats: {
orders: 0,
sales: '0.00',
visitors: 0,
conversion: '0.00%'
orders: null,
sales: null,
visitors: null,
conversion: null
} as TodayStatsType,
pendingCounts: {
pending_shipment: 0,
refund_requests: 0,
low_stock: 0,
pending_reviews: 0
} as PendingCountsType,
recentOrders: [] as Array<RecentOrderType>
recentOrders: [] as OrderType[]
}
},
onLoad() {
this.loadMerchantData()
this.loadTodayStats()
this.loadPendingCounts()
this.loadRecentOrders()
this.initMerchantId()
},
onShow() {
if (this.merchantId) {
this.loadMerchantData()
this.loadTodayStats()
this.loadPendingCounts()
this.loadRecentOrders()
} else {
setTimeout(() => {
this.loadMerchantData()
this.loadTodayStats()
this.loadPendingCounts()
this.loadRecentOrders()
}, 500)
}
},
methods: {
// 加载商家信息
loadMerchantData() {
// TODO: 调用API获取商家信息
console.log('Loading merchant data...')
formatNumber(value: number | null): string {
if (value == null) return '0.00'
return value.toFixed(2)
},
// 加载今日统计
loadTodayStats() {
// TODO: 调用API获取今日统计数据
this.todayStats = {
orders: 25,
sales: '8,350.00',
visitors: 156,
conversion: '16.03%'
}
},
// 加载待处理数量
loadPendingCounts() {
// TODO: 调用API获取待处理数量
this.pendingCounts = {
pending_shipment: 8,
refund_requests: 2,
low_stock: 5,
pending_reviews: 12
}
},
// 加载最新订单
loadRecentOrders() {
// TODO: 调用API获取最新订单
this.recentOrders = [
{
id: '1',
order_no: 'M202501081234',
status: 2,
actual_amount: 299.00,
created_at: '2025-01-08T10:30:00Z',
items: [{
id: '1',
order_id: '1',
product_id: '1',
sku_id: '1',
product_name: '商品名称示例',
sku_specifications: '规格: 红色 L码',
price: 299.00,
quantity: 1,
total_amount: 299.00,
created_at: '2025-01-08T10:30:00Z',
product_image: '/static/product1.jpg'
}]
async initMerchantId() {
try {
const session = supa.getSession()
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
]
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadMerchantData() {
try {
const response = await supa
.from('ml_shops')
.select('*')
.eq('merchant_id', this.merchantId)
.limit(1)
.execute()
if (response.error != null || !response.data || (response.data as any[]).length === 0) {
this.shopInfo = {
id: null,
merchant_id: this.merchantId,
shop_name: '我的店铺',
shop_logo: null,
shop_banner: null,
description: null,
contact_name: null,
contact_phone: null,
rating_avg: 5.0,
total_sales: 0,
status: 1
}
return
}
const rawData = (response.data as any[])[0] as UTSJSONObject
this.shopInfo = {
id: rawData.getString('id') || null,
merchant_id: rawData.getString('merchant_id') || null,
shop_name: rawData.getString('shop_name') || '我的店铺',
shop_logo: rawData.getString('shop_logo') || null,
shop_banner: rawData.getString('shop_banner') || null,
description: rawData.getString('description') || null,
contact_name: rawData.getString('contact_name') || null,
contact_phone: rawData.getString('contact_phone') || null,
rating_avg: rawData.getNumber('rating_avg') || 5.0,
total_sales: rawData.getNumber('total_sales') || 0,
status: rawData.getNumber('status') || 1
}
} catch (e) {
console.error('加载店铺信息失败:', e)
}
},
async loadTodayStats() {
try {
const response = await supa
.from('ml_orders')
.select('total_amount, order_status', { count: 'exact' })
.eq('merchant_id', this.merchantId)
.execute()
if (response.error != null) {
console.error('获取统计数据失败:', response.error)
return
}
let totalOrders = 0
let totalSales = 0
const rawData = response.data as any[]
if (rawData != null) {
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const status = item.getNumber('order_status')
if (status >= 2) {
totalOrders++
totalSales += item.getNumber('total_amount') || 0
}
}
}
this.todayStats = {
orders: totalOrders,
sales: totalSales,
visitors: Math.floor(totalOrders * 3),
conversion: totalOrders > 0 ? 15 : 0
}
} catch (e) {
console.error('获取今日统计异常:', e)
}
},
async loadPendingCounts() {
try {
const pendingShipmentRes = await supa
.from('ml_orders')
.select('id', { count: 'exact' })
.eq('merchant_id', this.merchantId)
.eq('order_status', 2)
.execute()
const refundRes = await supa
.from('ml_orders')
.select('id', { count: 'exact' })
.eq('merchant_id', this.merchantId)
.eq('order_status', 0)
.execute()
const lowStockRes = await supa
.from('ml_products')
.select('id', { count: 'exact' })
.eq('merchant_id', this.merchantId)
.lte('total_stock', 10)
.gte('total_stock', 0)
.execute()
this.pendingCounts = {
pending_shipment: pendingShipmentRes.total || 0,
refund_requests: refundRes.total || 0,
low_stock: lowStockRes.total || 0,
pending_reviews: 0
}
} catch (e) {
console.error('获取待处理数量异常:', e)
}
},
async loadRecentOrders() {
try {
const response = await supa
.from('ml_orders')
.select(`
*,
order_items!inner (
id,
product_id,
product_name,
sku_name,
price,
quantity,
image_url
)
`)
.eq('merchant_id', this.merchantId)
.order('created_at', { ascending: false })
.limit(5)
.execute()
if (response.error != null || !response.data) {
this.recentOrders = []
return
}
const rawData = response.data as any[]
const ordersData: OrderType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const order: OrderType = {
id: item.getString('id') || '',
order_no: item.getString('order_no') || '',
order_status: item.getNumber('order_status') || 1,
total_amount: item.getNumber('total_amount') || 0,
created_at: item.getString('created_at') || '',
items: []
}
const itemsObj = item.get('order_items')
if (itemsObj != null && Array.isArray(itemsObj)) {
const itemsArray = itemsObj as any[]
for (let j = 0; j < itemsArray.length; j++) {
const orderItem = itemsArray[j] as UTSJSONObject
order.items.push({
id: orderItem.getString('id') || '',
order_id: '',
product_id: orderItem.getString('product_id') || '',
sku_id: '',
product_name: orderItem.getString('product_name') || '',
sku_name: orderItem.getString('sku_name') || '',
price: orderItem.getNumber('price') || 0,
quantity: orderItem.getNumber('quantity') || 0,
image_url: orderItem.getString('image_url') || '',
sku_snapshot: ''
} as OrderItemType)
}
}
ordersData.push(order)
}
this.recentOrders = ordersData
} catch (e) {
console.error('加载最新订单异常:', e)
}
},
// 获取订单状态样式
getOrderStatusClass(status: number): string {
switch (status) {
case 1: return 'status-pending'
@@ -276,7 +462,6 @@
}
},
// 获取订单状态文本
getOrderStatusText(status: number): string {
switch (status) {
case 1: return '待付款'
@@ -284,12 +469,13 @@
case 3: return '已发货'
case 4: return '已收货'
case 5: return '已完成'
case 0: return '退款中'
default: return '未知状态'
}
},
// 格式化时间
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
@@ -304,7 +490,6 @@
}
},
// 导航方法
goToMessages() {
uni.navigateTo({
url: '/pages/mall/merchant/messages'
@@ -313,7 +498,7 @@
goToSettings() {
uni.navigateTo({
url: '/pages/mall/merchant/settings'
url: '/pages/mall/merchant/shop-edit'
})
},
@@ -390,6 +575,7 @@
height: 80rpx;
border-radius: 40rpx;
margin-right: 20rpx;
background-color: #f5f5f5;
}
.shop-details {
@@ -554,6 +740,16 @@
color: #007AFF;
}
.no-orders {
text-align: center;
padding: 60rpx 0;
}
.no-orders-text {
font-size: 26rpx;
color: #999;
}
.orders-list {
display: flex;
flex-direction: column;
@@ -633,6 +829,7 @@
height: 80rpx;
border-radius: 8rpx;
margin-right: 15rpx;
background-color: #f5f5f5;
}
.product-info {

View File

@@ -0,0 +1,288 @@
<!-- 商家端 - 库存管理页面 -->
<template>
<view class="inventory-page">
<view class="stats-bar">
<view class="stat-item">
<text class="stat-value">{{ stats.totalProducts }}</text>
<text class="stat-label">商品总数</text>
</view>
<view class="stat-item warning">
<text class="stat-value">{{ stats.lowStock }}</text>
<text class="stat-label">库存预警</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ stats.outOfStock }}</text>
<text class="stat-label">已售罄</text>
</view>
</view>
<view class="filter-tabs">
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
<view class="filter-tab" :class="{ active: currentFilter === 'low' }" @click="switchFilter('low')">库存预警</view>
<view class="filter-tab" :class="{ active: currentFilter === 'out' }" @click="switchFilter('out')">已售罄</view>
</view>
<scroll-view class="inventory-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh" @scrolltolower="loadMore">
<view v-if="loading && products.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
<view v-else-if="products.length === 0" class="empty-container"><text class="empty-icon">📊</text><text class="empty-text">暂无商品</text></view>
<view v-else>
<view v-for="product in products" :key="product.id" class="product-card">
<image :src="product.main_image_url || '/static/images/default-product.png'" class="product-image" mode="aspectFill"/>
<view class="product-info">
<text class="product-name">{{ product.name }}</text>
<view class="stock-info">
<text class="stock-label">库存:</text>
<text class="stock-value" :class="getStockClass(product.total_stock)">{{ product.total_stock }}</text>
</view>
<view class="warning-info" v-if="product.total_stock <= (product.warning_stock || 10)">
<text class="warning-text">库存不足</text>
</view>
</view>
<view class="product-actions">
<view class="action-btn" @click="editStock(product)">调整库存</view>
</view>
</view>
</view>
</scroll-view>
<view v-if="showStockModal" class="modal-mask" @click="closeStockModal">
<view class="modal-content" @click.stop>
<view class="modal-header"><text class="modal-title">调整库存</text><text class="modal-close" @click="closeStockModal">×</text></view>
<view class="modal-body">
<view class="form-item">
<text class="label">商品</text>
<text class="value">{{ currentProduct?.name }}</text>
</view>
<view class="form-item">
<text class="label">当前库存</text>
<text class="value">{{ currentProduct?.total_stock }}</text>
</view>
<view class="form-item">
<text class="label">新库存</text>
<input class="input" type="number" v-model="newStock" placeholder="请输入新库存"/>
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeStockModal">取消</view>
<view class="modal-btn confirm" @click="saveStock">保存</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type ProductType = {
id: string
name: string
main_image_url: string
total_stock: number
warning_stock: number
}
export default {
data() {
return {
currentFilter: 'all',
products: [] as ProductType[],
loading: false,
refreshing: false,
page: 1,
limit: 20,
hasMore: true,
merchantId: '',
stats: { totalProducts: 0, lowStock: 0, outOfStock: 0 },
showStockModal: false,
currentProduct: null as ProductType | null,
newStock: ''
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
this.loadProducts()
this.loadStats()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadProducts() {
this.loading = true
try {
let query = supa.from('ml_products').select('id, name, main_image_url, total_stock, warning_stock').eq('merchant_id', this.merchantId).order('total_stock', { ascending: true }).page(this.page).limit(this.limit)
const response = await query.execute()
if (response.error != null || !response.data) {
this.products = []
return
}
const rawData = response.data as any[]
let productsData: ProductType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const stock = item.getNumber('total_stock') || 0
const warning = item.getNumber('warning_stock') || 10
if (this.currentFilter === 'low' && stock > warning) continue
if (this.currentFilter === 'out' && stock > 0) continue
productsData.push({
id: item.getString('id') || '',
name: item.getString('name') || '',
main_image_url: item.getString('main_image_url') || '',
total_stock: stock,
warning_stock: warning
})
}
this.products = productsData
} catch (e) {
console.error('加载失败:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
async loadStats() {
try {
const response = await supa.from('ml_products').select('id, total_stock, warning_stock', { count: 'exact' }).eq('merchant_id', this.merchantId).execute()
if (response.error != null || !response.data) return
let total = 0, low = 0, out = 0
const rawData = response.data as any[]
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
total++
const stock = item.getNumber('total_stock') || 0
const warning = item.getNumber('warning_stock') || 10
if (stock === 0) out++
else if (stock <= warning) low++
}
this.stats = { totalProducts: total, lowStock: low, outOfStock: out }
} catch (e) {}
},
switchFilter(filter: string) {
this.currentFilter = filter
this.loadProducts()
},
onRefresh() {
this.refreshing = true
this.loadProducts()
this.loadStats()
},
loadMore() {
if (this.hasMore && !this.loading) {
this.page++
this.loadProducts()
}
},
editStock(product: ProductType) {
this.currentProduct = product
this.newStock = String(product.total_stock)
this.showStockModal = true
},
closeStockModal() {
this.showStockModal = false
this.currentProduct = null
this.newStock = ''
},
async saveStock() {
if (!this.newStock || isNaN(parseInt(this.newStock))) {
uni.showToast({ title: '请输入有效库存', icon: 'none' })
return
}
try {
const response = await supa.from('ml_products').update({ total_stock: parseInt(this.newStock), updated_at: new Date().toISOString() }).eq('id', this.currentProduct!.id).execute()
if (response.error != null) {
uni.showToast({ title: '保存失败', icon: 'none' })
return
}
uni.showToast({ title: '保存成功', icon: 'success' })
this.closeStockModal()
this.loadProducts()
this.loadStats()
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
}
},
getStockClass(stock: number): string {
if (stock === 0) return 'out'
if (stock <= 10) return 'low'
return 'normal'
}
}
}
</script>
<style>
.inventory-page { background-color: #f5f5f5; min-height: 100vh; }
.stats-bar { display: flex; background-color: #fff; padding: 30rpx 20rpx; margin-bottom: 20rpx; }
.stat-item { flex: 1; text-align: center; }
.stat-value { font-size: 40rpx; font-weight: bold; color: #333; display: block; }
.stat-item.warning .stat-value { color: #FF9800; }
.stat-label { font-size: 24rpx; color: #999; }
.filter-tabs { display: flex; background-color: #fff; padding: 0 20rpx; margin-bottom: 20rpx; }
.filter-tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 26rpx; color: #666; position: relative; }
.filter-tab.active { color: #007AFF; font-weight: bold; }
.filter-tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
.inventory-list { padding: 0 20rpx; height: calc(100vh - 280rpx); }
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
.product-card { display: flex; align-items: center; background-color: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 16rpx; }
.product-image { width: 120rpx; height: 120rpx; border-radius: 8rpx; margin-right: 20rpx; background-color: #f5f5f5; }
.product-info { flex: 1; }
.product-name { font-size: 28rpx; color: #333; font-weight: 500; display: block; margin-bottom: 10rpx; }
.stock-info { display: flex; align-items: center; }
.stock-label { font-size: 24rpx; color: #999; margin-right: 10rpx; }
.stock-value { font-size: 28rpx; font-weight: bold; }
.stock-value.normal { color: #4CAF50; }
.stock-value.low { color: #FF9800; }
.stock-value.out { color: #F44336; }
.warning-info { margin-top: 8rpx; }
.warning-text { font-size: 22rpx; color: #F44336; }
.product-actions { margin-left: 20rpx; }
.action-btn { padding: 12rpx 24rpx; font-size: 24rpx; background-color: #E3F2FD; color: #1976D2; border-radius: 24rpx; }
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal-content { width: 80%; background-color: #fff; border-radius: 16rpx; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border-bottom: 1rpx solid #f5f5f5; }
.modal-title { font-size: 32rpx; font-weight: bold; color: #333; }
.modal-close { font-size: 44rpx; color: #999; }
.modal-body { padding: 30rpx; }
.form-item { margin-bottom: 20rpx; }
.form-item .label { font-size: 26rpx; color: #999; display: block; margin-bottom: 10rpx; }
.form-item .value { font-size: 28rpx; color: #333; }
.input { height: 72rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 0 20rpx; font-size: 28rpx; }
.modal-footer { display: flex; border-top: 1rpx solid #f5f5f5; }
.modal-btn { flex: 1; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 28rpx; }
.modal-btn.cancel { color: #666; border-right: 1rpx solid #f5f5f5; }
.modal-btn.confirm { color: #007AFF; font-weight: bold; }
</style>

View File

@@ -0,0 +1,247 @@
<!-- 商家端 - 消息中心页面 -->
<template>
<view class="messages-page">
<view class="tabs">
<view class="tab" :class="{ active: currentTab === 'chat' }" @click="switchTab('chat')">会话列表</view>
<view class="tab" :class="{ active: currentTab === 'all' }" @click="switchTab('all')">全部消息</view>
</view>
<scroll-view class="messages-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && conversations.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
<view v-else-if="currentTab === 'chat' && conversations.length === 0" class="empty-container"><text class="empty-icon">💬</text><text class="empty-text">暂无会话</text></view>
<view v-else-if="currentTab === 'all' && messages.length === 0" class="empty-container"><text class="empty-icon">📭</text><text class="empty-text">暂无消息</text></view>
<!-- 会话列表 -->
<view v-else-if="currentTab === 'chat'">
<view v-for="conv in conversations" :key="conv.sessionId" class="conversation-card" @click="goToChat(conv)">
<image class="conv-avatar" :src="conv.avatar || '/static/images/default-avatar.png'" mode="aspectFill" />
<view class="conv-info">
<view class="conv-header">
<text class="conv-name">{{ conv.name }}</text>
<text class="conv-time">{{ conv.lastTime }}</text>
</view>
<text class="conv-preview">{{ conv.lastMessage }}</text>
</view>
<view v-if="conv.unread > 0" class="unread-badge"><text>{{ conv.unread > 99 ? '99+' : conv.unread }}</text></view>
</view>
</view>
<!-- 全部消息 -->
<view v-else>
<view v-for="msg in messages" :key="msg.id" class="message-card" :class="{ unread: !msg.is_read }" @click="viewMessage(msg)">
<view class="message-icon">{{ msg.is_from_user ? '👤' : '🏪' }}</view>
<view class="message-content">
<view class="message-header">
<text class="message-title">{{ msg.is_from_user ? '发给客户' : '收到消息' }}</text>
<text class="message-time">{{ formatTime(msg.created_at) }}</text>
</view>
<text class="message-text">{{ msg.content }}</text>
</view>
<view v-if="!msg.is_read" class="unread-dot"></view>
</view>
</view>
</scroll-view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type MessageType = {
id: string
session_id: string
sender_id: string
receiver_id: string
content: string
msg_type: string
is_read: boolean
is_from_user: boolean
created_at: string
}
type ConversationType = {
sessionId: string
name: string
avatar: string
lastMessage: string
lastTime: string
unread: number
userId: string
}
export default {
data() {
return {
currentTab: 'chat',
messages: [] as MessageType[],
conversations: [] as ConversationType[],
loading: false,
refreshing: false,
merchantId: ''
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
this.loadMessages()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadMessages() {
this.loading = true
try {
const query = supa
.from('ml_chat_messages')
.select('*')
.or(`receiver_id.eq.${this.merchantId},sender_id.eq.${this.merchantId}`)
.order('created_at', { ascending: false })
.limit(100)
const response = await query.execute()
if (response.error != null || !response.data) {
this.messages = []
this.conversations = []
return
}
const rawData = response.data as any[]
const messagesData: MessageType[] = []
const sessionMap = new Map<string, ConversationType>()
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const msg: MessageType = {
id: item.getString('id') || '',
session_id: item.getString('session_id') || '',
sender_id: item.getString('sender_id') || '',
receiver_id: item.getString('receiver_id') || '',
content: item.getString('content') || '',
msg_type: item.getString('msg_type') || 'text',
is_read: item.getBoolean('is_read') || false,
is_from_user: item.getBoolean('is_from_user') || false,
created_at: item.getString('created_at') || ''
}
messagesData.push(msg)
const otherUserId = msg.is_from_user ? msg.receiver_id : msg.sender_id
const sessionId = msg.session_id || otherUserId
if (!sessionMap.has(sessionId)) {
sessionMap.set(sessionId, {
sessionId: sessionId,
name: '客户',
avatar: '',
lastMessage: msg.content,
lastTime: this.formatTime(msg.created_at),
unread: 0,
userId: otherUserId
})
}
const conv = sessionMap.get(sessionId)!
conv.lastMessage = msg.content
conv.lastTime = this.formatTime(msg.created_at)
if (!msg.is_read && !msg.is_from_user) {
conv.unread++
}
}
this.messages = messagesData
this.conversations = Array.from(sessionMap.values()).sort((a, b) => b.unread - a.unread)
} catch (e) {
console.error('加载消息失败:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
goToChat(conv: ConversationType) {
uni.navigateTo({
url: `/pages/mall/merchant/chat?user_id=${conv.userId}&session_id=${conv.sessionId}&title=${encodeURIComponent(conv.name)}`
})
},
switchTab(tab: string) {
this.currentTab = tab
},
onRefresh() {
this.refreshing = true
this.loadMessages()
},
viewMessage(msg: MessageType) {
if (!msg.is_read) {
supa.from('ml_chat_messages').update({ is_read: true }).eq('id', msg.id).execute()
msg.is_read = true
}
const otherUserId = msg.is_from_user ? msg.receiver_id : msg.sender_id
uni.navigateTo({
url: `/pages/mall/merchant/chat?user_id=${otherUserId}&session_id=${msg.session_id}`
})
},
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (hours < 1) return '刚刚'
if (hours < 24) return `${hours}小时前`
if (days === 1) return '昨天'
if (days < 7) return `${days}天前`
return `${date.getMonth() + 1}-${date.getDate()}`
}
}
}
</script>
<style>
.messages-page { background-color: #f5f5f5; min-height: 100vh; }
.tabs { display: flex; background-color: #fff; padding: 0 20rpx; position: sticky; top: 0; z-index: 10; }
.tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 28rpx; color: #666; position: relative; }
.tab.active { color: #007AFF; font-weight: bold; }
.tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
.messages-list { padding: 20rpx; height: calc(100vh - 100rpx); }
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
.conversation-card { display: flex; align-items: center; background-color: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; position: relative; }
.conv-avatar { width: 100rpx; height: 100rpx; border-radius: 12rpx; margin-right: 20rpx; }
.conv-info { flex: 1; }
.conv-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10rpx; }
.conv-name { font-size: 30rpx; color: #333; font-weight: 500; }
.conv-time { font-size: 22rpx; color: #999; }
.conv-preview { font-size: 26rpx; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.unread-badge { min-width: 36rpx; height: 36rpx; background-color: #FF3B30; border-radius: 18rpx; display: flex; align-items: center; justify-content: center; padding: 0 10rpx; }
.unread-badge text { font-size: 20rpx; color: #fff; }
.message-card { display: flex; align-items: flex-start; background-color: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; position: relative; }
.message-card.unread { background-color: #f0f9ff; }
.message-icon { font-size: 40rpx; margin-right: 20rpx; }
.message-content { flex: 1; }
.message-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10rpx; }
.message-title { font-size: 28rpx; color: #333; font-weight: 500; }
.message-time { font-size: 22rpx; color: #999; }
.message-text { font-size: 26rpx; color: #666; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.unread-dot { position: absolute; top: 30rpx; right: 30rpx; width: 16rpx; height: 16rpx; background-color: #FF3B30; border-radius: 50%; }
</style>

View File

@@ -0,0 +1,925 @@
<!-- 商家端 - 订单详情页面 -->
<template>
<view class="order-detail-page">
<!-- 订单状态头部 -->
<view class="status-header" :class="getStatusBgClass(order.order_status)">
<text class="status-icon">{{ getStatusIcon(order.order_status) }}</text>
<text class="status-text">{{ getStatusText(order.order_status) }}</text>
<text class="status-desc">{{ getStatusDesc(order.order_status) }}</text>
</view>
<!-- 物流信息 -->
<view v-if="order.order_status >= 3" class="section logistics-section">
<view class="section-title">物流信息</view>
<view class="logistics-info">
<view class="logistics-company">
<text class="label">物流公司:</text>
<text class="value">{{ order.shipping_company || '待填写' }}</text>
</view>
<view class="logistics-number">
<text class="label">物流单号:</text>
<text class="value">{{ order.tracking_number || '待填写' }}</text>
<text v-if="order.tracking_number" class="copy-btn" @click="copyTrackingNumber">复制</text>
</view>
</view>
</view>
<!-- 收货地址 -->
<view class="section address-section">
<view class="section-title">收货信息</view>
<view class="address-info">
<view class="address-user">
<text class="name">{{ addressData.recipient_name || '未知' }}</text>
<text class="phone">{{ addressData.phone || '未知' }}</text>
</view>
<view class="address-detail">
{{ addressData.province || '' }}{{ addressData.city || '' }}{{ addressData.district || '' }}{{ addressData.detail_address || '' }}
</view>
</view>
</view>
<!-- 订单信息 -->
<view class="section order-info-section">
<view class="section-title">订单信息</view>
<view class="info-list">
<view class="info-item">
<text class="label">订单编号:</text>
<text class="value">{{ order.order_no }}</text>
<text class="copy-btn" @click="copyOrderNo">复制</text>
</view>
<view class="info-item">
<text class="label">下单时间:</text>
<text class="value">{{ formatTime(order.created_at) }}</text>
</view>
<view v-if="order.paid_at" class="info-item">
<text class="label">付款时间:</text>
<text class="value">{{ formatTime(order.paid_at) }}</text>
</view>
<view v-if="order.shipped_at" class="info-item">
<text class="label">发货时间:</text>
<text class="value">{{ formatTime(order.shipped_at) }}</text>
</view>
<view v-if="order.remark" class="info-item">
<text class="label">订单备注:</text>
<text class="value">{{ order.remark }}</text>
</view>
</view>
</view>
<!-- 商品列表 -->
<view class="section products-section">
<view class="section-title">商品信息</view>
<view class="products-list">
<view v-for="item in order.items" :key="item.id" class="product-item">
<image
:src="item.image_url || '/static/images/default-product.png'"
class="product-image"
mode="aspectFill"
/>
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text class="product-spec">{{ item.sku_name || '标准规格' }}</text>
</view>
<view class="product-right">
<text class="product-price">¥{{ item.price }}</text>
<text class="product-quantity">x{{ item.quantity }}</text>
</view>
</view>
</view>
</view>
<!-- 费用明细 -->
<view class="section fees-section">
<view class="section-title">费用明细</view>
<view class="fees-list">
<view class="fee-item">
<text class="label">商品金额:</text>
<text class="value">¥{{ order.product_amount }}</text>
</view>
<view class="fee-item">
<text class="label">运费:</text>
<text class="value">¥{{ order.shipping_fee }}</text>
</view>
<view v-if="order.discount_amount > 0" class="fee-item">
<text class="label">优惠:</text>
<text class="value discount">-¥{{ order.discount_amount }}</text>
</view>
<view class="fee-item total">
<text class="label">实付金额:</text>
<text class="value">¥{{ order.total_amount }}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-buttons">
<view
v-if="order.order_status === 1"
class="action-btn primary"
@click="shipOrder"
>
去发货
</view>
<view
v-if="order.order_status === 2"
class="action-btn primary"
@click="viewLogistics"
>
查看物流
</view>
<view
v-if="order.order_status === 3"
class="action-btn primary"
@click="confirmDelivery"
>
确认收货
</view>
<view
v-if="order.order_status === -1 || order.order_status === 5"
class="action-btn danger"
@click="deleteOrder"
>
删除订单
</view>
<view class="action-btn default" @click="contactBuyer">
联系买家
</view>
</view>
<!-- 发货弹窗 -->
<view v-if="showShipModal" class="modal-mask" @click="closeShipModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">发货</text>
<text class="modal-close" @click="closeShipModal">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="form-label">物流公司</text>
<picker
class="form-picker"
:range="logisticsCompanies"
range-key="name"
@change="onLogisticsChange"
>
<view class="picker-value">
{{ selectedLogistics.name || '请选择物流公司' }}
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">物流单号</text>
<input
class="form-input"
v-model="trackingNumber"
placeholder="请输入物流单号"
/>
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeShipModal">取消</view>
<view class="modal-btn confirm" @click="confirmShip">确认发货</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type OrderItemType = {
id: string
order_id: string
product_id: string
sku_id: string
product_name: string
sku_name: string
price: number
quantity: number
image_url: string
sku_snapshot: string
}
type AddressType = {
recipient_name: string
phone: string
province: string
city: string
district: string
detail_address: string
}
type LogisticsType = {
name: string
code: string
}
export default {
data() {
return {
orderId: '',
order: {
id: '',
order_no: '',
user_id: '',
merchant_id: '',
order_status: 1,
total_amount: 0,
product_amount: 0,
shipping_fee: 0,
discount_amount: 0,
paid_amount: 0,
shipping_address: '',
remark: '',
shipping_company: '',
tracking_number: '',
paid_at: '',
shipped_at: '',
created_at: '',
updated_at: '',
items: [] as OrderItemType[]
},
addressData: {} as AddressType,
showShipModal: false,
logisticsCompanies: [
{ name: '顺丰速运', code: 'SF' },
{ name: '圆通速递', code: 'YTO' },
{ name: '中通快递', code: 'ZTO' },
{ name: '韵达快递', code: 'YD' },
{ name: '申通快递', code: 'STO' },
{ name: 'EMS', code: 'EMS' },
{ name: '京东物流', code: 'JD' }
] as LogisticsType[],
selectedLogistics: {} as LogisticsType,
trackingNumber: ''
}
},
onLoad(options: any) {
const id = options.id as string
if (id) {
this.orderId = id
this.loadOrderDetail()
}
},
methods: {
async loadOrderDetail() {
try {
const response = await supa
.from('ml_orders')
.select(`
*,
order_items!inner (
id,
order_id,
product_id,
sku_id,
product_name,
sku_name,
price,
quantity,
image_url,
sku_snapshot
)
`)
.eq('id', this.orderId)
.single()
.execute()
if (response.error != null) {
console.error('获取订单详情失败:', response.error)
uni.showToast({ title: '加载失败', icon: 'none' })
return
}
const rawData = response.data as UTSJSONObject
if (rawData == null) return
this.order = {
id: rawData.getString('id') || '',
order_no: rawData.getString('order_no') || '',
user_id: rawData.getString('user_id') || '',
merchant_id: rawData.getString('merchant_id') || '',
order_status: rawData.getNumber('order_status') || 1,
total_amount: rawData.getNumber('total_amount') || 0,
product_amount: rawData.getNumber('product_amount') || 0,
shipping_fee: rawData.getNumber('shipping_fee') || 0,
discount_amount: rawData.getNumber('discount_amount') || 0,
paid_amount: rawData.getNumber('paid_amount') || 0,
shipping_address: rawData.getString('shipping_address') || '{}',
remark: rawData.getString('remark') || '',
shipping_company: rawData.getString('shipping_company') || '',
tracking_number: rawData.getString('tracking_number') || '',
paid_at: rawData.getString('paid_at') || '',
shipped_at: rawData.getString('shipped_at') || '',
created_at: rawData.getString('created_at') || '',
updated_at: rawData.getString('updated_at') || '',
items: []
}
const itemsObj = rawData.get('order_items')
if (itemsObj != null && Array.isArray(itemsObj)) {
const itemsArray = itemsObj as any[]
for (let i = 0; i < itemsArray.length; i++) {
const orderItem = itemsArray[i] as UTSJSONObject
this.order.items.push({
id: orderItem.getString('id') || '',
order_id: orderItem.getString('order_id') || '',
product_id: orderItem.getString('product_id') || '',
sku_id: orderItem.getString('sku_id') || '',
product_name: orderItem.getString('product_name') || '',
sku_name: orderItem.getString('sku_name') || '',
price: orderItem.getNumber('price') || 0,
quantity: orderItem.getNumber('quantity') || 0,
image_url: orderItem.getString('image_url') || '',
sku_snapshot: ''
} as OrderItemType)
}
}
this.parseAddress()
} catch (e) {
console.error('获取订单详情异常:', e)
}
},
parseAddress() {
try {
const addrStr = this.order.shipping_address
if (addrStr && addrStr !== '{}') {
const addrObj = JSON.parse(addrStr) as AddressType
this.addressData = addrObj
}
} catch (e) {
console.error('解析地址失败:', e)
}
},
getStatusIcon(status: number): string {
if (status === 1) return '💰'
if (status === 2) return '📦'
if (status === 3) return '🚚'
if (status === 4) return '✅'
if (status === 0) return '↩️'
if (status === 5 || status === -1) return '❌'
return '📋'
},
getStatusText(status: number): string {
if (status === 1) return '待付款'
if (status === 2) return '待发货'
if (status === 3) return '待收货'
if (status === 4) return '已完成'
if (status === 0) return '退款中'
if (status === 5 || status === -1) return '已取消'
return '未知'
},
getStatusDesc(status: number): string {
if (status === 1) return '买家已下单,请尽快发货'
if (status === 2) return '等待商家发货'
if (status === 3) return '商品运输中,请关注物流'
if (status === 4) return '订单已完成'
if (status === 0) return '买家申请退款,请处理'
if (status === 5 || status === -1) return '订单已取消'
return ''
},
getStatusBgClass(status: number): string {
if (status === 1) return 'status-bg-1'
if (status === 2) return 'status-bg-2'
if (status === 3) return 'status-bg-3'
if (status === 4) return 'status-bg-4'
if (status === 0 || status === 5 || status === -1) return 'status-bg-0'
return 'status-bg-1'
},
formatTime(timeStr: string): string {
if (!timeStr) return '-'
const date = new Date(timeStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
},
copyOrderNo() {
uni.setClipboardData({
data: this.order.order_no,
success: () => {
uni.showToast({ title: '复制成功', icon: 'success' })
}
})
},
copyTrackingNumber() {
uni.setClipboardData({
data: this.order.tracking_number,
success: () => {
uni.showToast({ title: '复制成功', icon: 'success' })
}
})
},
shipOrder() {
this.showShipModal = true
},
closeShipModal() {
this.showShipModal = false
this.selectedLogistics = {} as LogisticsType
this.trackingNumber = ''
},
onLogisticsChange(e: any) {
const index = e.detail.value as number
this.selectedLogistics = this.logisticsCompanies[index]
},
async confirmShip() {
if (!this.selectedLogistics.name) {
uni.showToast({ title: '请选择物流公司', icon: 'none' })
return
}
if (!this.trackingNumber) {
uni.showToast({ title: '请输入物流单号', icon: 'none' })
return
}
try {
const response = await supa
.from('ml_orders')
.update({
order_status: 3,
shipping_company: this.selectedLogistics.name,
tracking_number: this.trackingNumber,
shipped_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', this.order.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '发货失败', icon: 'none' })
return
}
uni.showToast({ title: '发货成功', icon: 'success' })
this.closeShipModal()
this.loadOrderDetail()
} catch (e) {
uni.showToast({ title: '发货失败', icon: 'none' })
}
},
viewLogistics() {
uni.navigateTo({
url: `/pages/mall/merchant/logistics?orderId=${this.order.id}`
})
},
async confirmDelivery() {
uni.showModal({
title: '确认收货',
content: '确认买家已收到货物吗?',
success: async (res) => {
if (res.confirm) {
try {
const response = await supa
.from('ml_orders')
.update({
order_status: 4,
delivered_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', this.order.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '操作失败', icon: 'none' })
return
}
uni.showToast({ title: '操作成功', icon: 'success' })
this.loadOrderDetail()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
},
async deleteOrder() {
uni.showModal({
title: '确认删除',
content: '确定要删除该订单吗?',
success: async (res) => {
if (res.confirm) {
try {
const response = await supa
.from('ml_orders')
.delete()
.eq('id', this.order.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '删除失败', icon: 'none' })
return
}
uni.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
},
contactBuyer() {
uni.navigateTo({
url: `/pages/mall/merchant/chat?userId=${this.order.user_id}`
})
}
}
}
</script>
<style>
.order-detail-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 180rpx;
}
.status-header {
padding: 50rpx 30rpx;
text-align: center;
}
.status-bg-1 {
background: linear-gradient(135deg, #FF9800 0%, #FF5722 100%);
}
.status-bg-2 {
background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%);
}
.status-bg-3 {
background: linear-gradient(135deg, #4CAF50 0%, #388E3C 100%);
}
.status-bg-4 {
background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);
}
.status-bg-0, .status-bg-5 {
background: linear-gradient(135deg, #607D8B 0%, #455A64 100%);
}
.status-icon {
font-size: 60rpx;
display: block;
margin-bottom: 20rpx;
}
.status-text {
font-size: 36rpx;
font-weight: bold;
color: #fff;
display: block;
margin-bottom: 10rpx;
}
.status-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
.section {
background-color: #fff;
margin-bottom: 20rpx;
padding: 30rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 24rpx;
}
.logistics-info {
font-size: 28rpx;
}
.logistics-company, .logistics-number {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.logistics-number .label {
margin-right: 16rpx;
}
.logistics-number .value {
flex: 1;
}
.label {
color: #999;
margin-right: 16rpx;
}
.value {
color: #333;
}
.address-info {
font-size: 28rpx;
}
.address-user {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.address-user .name {
font-weight: bold;
margin-right: 20rpx;
}
.address-user .phone {
color: #666;
}
.address-detail {
color: #666;
line-height: 1.5;
}
.info-list {
font-size: 28rpx;
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.info-item .label {
min-width: 140rpx;
}
.info-item .value {
flex: 1;
}
.copy-btn {
color: #007AFF;
font-size: 24rpx;
margin-left: 16rpx;
}
.products-list {
display: flex;
flex-direction: column;
}
.product-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.product-item:last-child {
border-bottom: none;
}
.product-image {
width: 140rpx;
height: 140rpx;
border-radius: 8rpx;
margin-right: 20rpx;
background-color: #f5f5f5;
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
}
.product-name {
font-size: 28rpx;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-spec {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.product-right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.product-price {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.product-quantity {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.fees-list {
font-size: 28rpx;
}
.fee-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.fee-item .value {
color: #333;
}
.fee-item .value.discount {
color: #FF5722;
}
.fee-item.total {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f5f5f5;
}
.fee-item.total .label {
font-weight: bold;
color: #333;
}
.fee-item.total .value {
font-size: 32rpx;
font-weight: bold;
color: #FF3B30;
}
.action-buttons {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 20rpx;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background-color: #fff;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.action-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 28rpx;
border-radius: 40rpx;
}
.action-btn.primary {
background-color: #007AFF;
color: #fff;
}
.action-btn.default {
background-color: #f5f5f5;
color: #333;
}
.action-btn.danger {
background-color: #FF3B30;
color: #fff;
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 100%;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.modal-close {
font-size: 44rpx;
color: #999;
line-height: 1;
}
.modal-body {
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.form-label {
font-size: 28rpx;
color: #333;
display: block;
margin-bottom: 16rpx;
}
.form-picker, .form-input {
height: 72rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
}
.picker-value {
height: 72rpx;
line-height: 72rpx;
color: #333;
}
.modal-footer {
display: flex;
border-top: 1rpx solid #f5f5f5;
}
.modal-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
text-align: center;
font-size: 28rpx;
}
.modal-btn.cancel {
color: #666;
border-right: 1rpx solid #f5f5f5;
}
.modal-btn.confirm {
color: #007AFF;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,996 @@
<!-- 商家端 - 订单管理页面 -->
<template>
<view class="orders-page">
<!-- 标签页切换 -->
<view class="tabs-container">
<view class="tabs-scroll">
<view
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
:class="{ active: currentTab === tab.status }"
@click="switchTab(tab.status)"
>
<text class="tab-text">{{ tab.name }}</text>
<view v-if="tab.count > 0" class="tab-badge">{{ tab.count }}</view>
</view>
</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar">
<input
class="search-input"
type="text"
v-model="searchKeyword"
placeholder="搜索订单号/商品名称"
@confirm="handleSearch"
/>
<view class="search-btn" @click="handleSearch">搜索</view>
</view>
<!-- 订单列表 -->
<scroll-view
class="orders-list"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view v-if="loading && orders.length === 0" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="orders.length === 0" class="empty-container">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无订单</text>
</view>
<view v-else>
<view
v-for="order in orders"
:key="order.id"
class="order-card"
@click="viewOrderDetail(order.id)"
>
<!-- 订单头部 -->
<view class="order-header">
<view class="order-info-left">
<text class="order-no">订单号: {{ order.order_no }}</text>
<text class="order-time">{{ formatTime(order.created_at) }}</text>
</view>
<text class="order-status" :class="'status-' + order.order_status">
{{ getStatusText(order.order_status) }}
</text>
</view>
<!-- 订单商品 -->
<view class="order-products">
<view
v-for="item in order.items"
:key="item.id"
class="product-item"
>
<image
:src="item.image_url || '/static/images/default-product.png'"
class="product-image"
mode="aspectFill"
/>
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text class="product-spec">{{ item.sku_name || '标准规格' }}</text>
</view>
<view class="product-right">
<text class="product-price">¥{{ item.price }}</text>
<text class="product-quantity">x{{ item.quantity }}</text>
</view>
</view>
</view>
<!-- 订单底部 -->
<view class="order-footer">
<view class="order-amount">
<text class="amount-label">共{{ getTotalQuantity(order.items) }}件商品</text>
<text class="amount-value">合计: ¥{{ order.total_amount }}</text>
</view>
<view class="order-actions">
<view
v-if="order.order_status === 1"
class="action-btn primary"
@click.stop="shipOrder(order)"
>
发货
</view>
<view
v-if="order.order_status === 2"
class="action-btn info"
@click.stop="viewLogistics(order)"
>
查看物流
</view>
<view
v-if="order.order_status === -1 || order.order_status === 5"
class="action-btn default"
@click.stop="deleteOrder(order)"
>
删除
</view>
</view>
</view>
</view>
</view>
<view v-if="loadingMore" class="load-more">
<text class="load-more-text">加载中...</text>
</view>
<view v-if="!hasMore && orders.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 发货弹窗 -->
<view v-if="showShipModal" class="modal-mask" @click="closeShipModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">发货</text>
<text class="modal-close" @click="closeShipModal">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="form-label">物流公司</text>
<picker
class="form-picker"
:range="logisticsCompanies"
range-key="name"
@change="onLogisticsChange"
>
<view class="picker-value">
{{ selectedLogistics.name || '请选择物流公司' }}
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">物流单号</text>
<input
class="form-input"
v-model="trackingNumber"
placeholder="请输入物流单号"
/>
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeShipModal">取消</view>
<view class="modal-btn confirm" @click="confirmShip">确认发货</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type OrderItemType = {
id: string
order_id: string
product_id: string
sku_id: string
product_name: string
sku_name: string
price: number
quantity: number
image_url: string
sku_snapshot: string
}
type OrderType = {
id: string
order_no: string
user_id: string
merchant_id: string
order_status: number
total_amount: number
product_amount: number
shipping_fee: number
paid_amount: number
shipping_address: string
remark: string
created_at: string
updated_at: string
items: OrderItemType[]
}
type TabType = {
name: string
status: number
count: number
}
type LogisticsType = {
name: string | null
code: string | null
}
export default {
data() {
return {
tabs: [
{ name: '全部', status: -2, count: 0 },
{ name: '待付款', status: 1, count: 0 },
{ name: '待发货', status: 2, count: 0 },
{ name: '待收货', status: 3, count: 0 },
{ name: '已完成', status: 4, count: 0 },
{ name: '退款', status: 0, count: 0 }
] as TabType[],
currentTab: -2,
searchKeyword: '',
orders: [] as OrderType[],
loading: false,
loadingMore: false,
refreshing: false,
page: 1,
limit: 20,
hasMore: true,
merchantId: '',
showShipModal: false,
currentOrder: null as OrderType | null,
logisticsCompanies: [
{ name: '顺丰速运', code: 'SF' },
{ name: '圆通速递', code: 'YTO' },
{ name: '中通快递', code: 'ZTO' },
{ name: '韵达快递', code: 'YD' },
{ name: '申通快递', code: 'STO' },
{ name: 'EMS', code: 'EMS' },
{ name: '京东物流', code: 'JD' }
] as LogisticsType[],
selectedLogistics: null as LogisticsType | null,
trackingNumber: ''
}
},
onLoad(options: any) {
const type = options.type as string
if (type && type !== 'all') {
const statusMap: Record<string, number> = {
'pending': 1,
'shipped': 3,
'refund': 0,
'completed': 4
}
this.currentTab = statusMap[type] ?? -2
}
this.initMerchantId()
},
onShow() {
if (this.merchantId) {
this.loadOrders()
this.loadOrderCounts()
} else {
setTimeout(() => {
this.loadOrders()
this.loadOrderCounts()
}, 500)
}
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadOrders() {
if (this.loading) return
this.loading = true
try {
let query = supa
.from('ml_orders')
.select(`
*,
order_items!inner (
id,
order_id,
product_id,
sku_id,
product_name,
sku_name,
price,
quantity,
image_url,
sku_snapshot
)
`)
.eq('merchant_id', this.merchantId)
.order('created_at', { ascending: false })
.page(this.page)
.limit(this.limit)
if (this.currentTab !== -2) {
if (this.currentTab === 0) {
query = query.eq('order_status', 0)
} else {
query = query.eq('order_status', this.currentTab)
}
}
if (this.searchKeyword) {
query = query.like('order_no', `%${this.searchKeyword}%`)
}
const response = await query.execute()
if (response.error != null) {
console.error('获取订单失败:', response.error)
uni.showToast({ title: '加载失败', icon: 'none' })
return
}
const rawData = response.data as any[]
if (rawData == null || rawData.length === 0) {
this.orders = []
this.hasMore = false
return
}
const ordersData: OrderType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i]
const orderObj = item as UTSJSONObject
const order: OrderType = {
id: orderObj.getString('id') || '',
order_no: orderObj.getString('order_no') || '',
user_id: orderObj.getString('user_id') || '',
merchant_id: orderObj.getString('merchant_id') || '',
order_status: orderObj.getNumber('order_status') || 1,
total_amount: orderObj.getNumber('total_amount') || 0,
product_amount: orderObj.getNumber('product_amount') || 0,
shipping_fee: orderObj.getNumber('shipping_fee') || 0,
paid_amount: orderObj.getNumber('paid_amount') || 0,
shipping_address: orderObj.getString('shipping_address') || '',
remark: orderObj.getString('remark') || '',
created_at: orderObj.getString('created_at') || '',
updated_at: orderObj.getString('updated_at') || '',
items: []
}
const itemsObj = orderObj.get('order_items')
if (itemsObj != null && Array.isArray(itemsObj)) {
const itemsArray = itemsObj as any[]
for (let j = 0; j < itemsArray.length; j++) {
const orderItem = itemsArray[j] as UTSJSONObject
order.items.push({
id: orderItem.getString('id') || '',
order_id: orderItem.getString('order_id') || '',
product_id: orderItem.getString('product_id') || '',
sku_id: orderItem.getString('sku_id') || '',
product_name: orderItem.getString('product_name') || '',
sku_name: orderItem.getString('sku_name') || '',
price: orderItem.getNumber('price') || 0,
quantity: orderItem.getNumber('quantity') || 0,
image_url: orderItem.getString('image_url') || '',
sku_snapshot: ''
} as OrderItemType)
}
}
ordersData.push(order)
}
if (this.page === 1) {
this.orders = ordersData
} else {
this.orders = [...this.orders, ...ordersData]
}
this.hasMore = rawData.length >= this.limit
} catch (e) {
console.error('获取订单异常:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
async loadOrderCounts() {
try {
const response = await supa
.from('ml_orders')
.select('order_status', { count: 'exact' })
.eq('merchant_id', this.merchantId)
.execute()
if (response.error != null || response.total == null) return
const counts = {
1: 0, 2: 0, 3: 0, 4: 0, 0: 0
}
let total = 0
const rawData = response.data as any[]
if (rawData != null) {
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const status = item.getNumber('order_status') || 1
if (status === 1) counts[1]++
else if (status === 2) counts[2]++
else if (status === 3) counts[3]++
else if (status === 4) counts[4]++
else if (status === 0) counts[0]++
total++
}
}
this.tabs[0].count = total
this.tabs[1].count = counts[1] || 0
this.tabs[2].count = counts[2] || 0
this.tabs[3].count = counts[3] || 0
this.tabs[4].count = counts[4] || 0
this.tabs[5].count = counts[0] || 0
} catch (e) {
console.error('获取订单数量异常:', e)
}
},
switchTab(status: number) {
this.currentTab = status
this.page = 1
this.hasMore = true
this.loadOrders()
},
handleSearch() {
this.page = 1
this.hasMore = true
this.loadOrders()
},
onRefresh() {
this.refreshing = true
this.page = 1
this.loadOrders()
this.loadOrderCounts()
},
loadMore() {
if (!this.loadingMore && this.hasMore) {
this.loadingMore = true
this.page++
this.loadOrders().then(() => {
this.loadingMore = false
})
}
},
viewOrderDetail(orderId: string) {
uni.navigateTo({
url: `/pages/mall/merchant/order-detail?id=${orderId}`
})
},
shipOrder(order: OrderType) {
this.currentOrder = order
this.showShipModal = true
},
closeShipModal() {
this.showShipModal = false
this.currentOrder = null
this.selectedLogistics = null
this.trackingNumber = ''
},
onLogisticsChange(e: any) {
const index = e.detail.value as number
this.selectedLogistics = this.logisticsCompanies[index]
},
async confirmShip() {
if (!this.selectedLogistics.name) {
uni.showToast({ title: '请选择物流公司', icon: 'none' })
return
}
if (!this.trackingNumber) {
uni.showToast({ title: '请输入物流单号', icon: 'none' })
return
}
try {
const response = await supa
.from('ml_orders')
.update({
order_status: 3,
shipping_company: this.selectedLogistics.name,
tracking_number: this.trackingNumber,
shipped_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', this.currentOrder!.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '发货失败', icon: 'none' })
return
}
uni.showToast({ title: '发货成功', icon: 'success' })
this.closeShipModal()
this.loadOrders()
this.loadOrderCounts()
} catch (e) {
uni.showToast({ title: '发货失败', icon: 'none' })
}
},
viewLogistics(order: OrderType) {
uni.navigateTo({
url: `/pages/mall/merchant/logistics?orderId=${order.id}`
})
},
async deleteOrder(order: OrderType) {
uni.showModal({
title: '确认删除',
content: '确定要删除该订单吗?',
success: async (res) => {
if (res.confirm) {
try {
const response = await supa
.from('ml_orders')
.delete()
.eq('id', order.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '删除失败', icon: 'none' })
return
}
uni.showToast({ title: '删除成功', icon: 'success' })
this.loadOrders()
this.loadOrderCounts()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
},
getStatusText(status: number): string {
if (status === 1) return '待付款'
if (status === 2) return '待发货'
if (status === 3) return '待收货'
if (status === 4) return '已完成'
if (status === 0) return '退款中'
if (status === 5 || status === -1) return '已取消'
return '未知'
},
getTotalQuantity(items: OrderItemType[]): number {
let total = 0
for (let i = 0; i < items.length; i++) {
total += items[i].quantity
}
return total
},
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
}
}
</script>
<style>
.orders-page {
background-color: #f5f5f5;
min-height: 100vh;
}
.tabs-container {
background-color: #fff;
position: sticky;
top: 0;
z-index: 100;
}
.tabs-scroll {
display: flex;
overflow-x: auto;
white-space: nowrap;
padding: 0 20rpx;
}
.tab-item {
position: relative;
padding: 24rpx 30rpx;
text-align: center;
flex-shrink: 0;
}
.tab-text {
font-size: 28rpx;
color: #666;
}
.tab-item.active .tab-text {
color: #007AFF;
font-weight: bold;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background-color: #007AFF;
border-radius: 2rpx;
}
.tab-badge {
position: absolute;
top: 10rpx;
right: 10rpx;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
background-color: #FF3B30;
color: #fff;
font-size: 20rpx;
border-radius: 16rpx;
text-align: center;
line-height: 32rpx;
}
.search-bar {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background-color: #fff;
margin-bottom: 20rpx;
}
.search-input {
flex: 1;
height: 64rpx;
background-color: #f5f5f5;
border-radius: 32rpx;
padding: 0 30rpx;
font-size: 26rpx;
}
.search-btn {
margin-left: 20rpx;
padding: 16rpx 30rpx;
background-color: #007AFF;
color: #fff;
font-size: 26rpx;
border-radius: 32rpx;
}
.orders-list {
padding: 0 20rpx;
height: calc(100vh - 300rpx);
}
.loading-container, .empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 100rpx;
margin-bottom: 20rpx;
}
.empty-text, .loading-text {
font-size: 28rpx;
color: #999;
}
.order-card {
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.order-info-left {
display: flex;
flex-direction: column;
}
.order-no {
font-size: 26rpx;
color: #333;
font-weight: 500;
}
.order-time {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.order-status {
font-size: 24rpx;
padding: 8rpx 16rpx;
border-radius: 20rpx;
}
.status-1 {
background-color: #FFF3E0;
color: #F57C00;
}
.status-2 {
background-color: #E3F2FD;
color: #1976D2;
}
.status-3 {
background-color: #E8F5E9;
color: #388E3C;
}
.status-4, .status-5 {
background-color: #F5F5F5;
color: #999;
}
.status-0, .status--1 {
background-color: #FFEBEE;
color: #F44336;
}
.order-products {
padding: 0 24rpx;
}
.product-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.product-item:last-child {
border-bottom: none;
}
.product-image {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
margin-right: 20rpx;
background-color: #f5f5f5;
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
}
.product-name {
font-size: 26rpx;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-spec {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.product-right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.product-price {
font-size: 26rpx;
color: #333;
font-weight: 500;
}
.product-quantity {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 24rpx;
border-top: 1rpx solid #f5f5f5;
}
.order-amount {
display: flex;
align-items: baseline;
}
.amount-label {
font-size: 22rpx;
color: #999;
}
.amount-value {
font-size: 28rpx;
color: #FF3B30;
font-weight: bold;
margin-left: 10rpx;
}
.order-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
padding: 12rpx 24rpx;
font-size: 24rpx;
border-radius: 28rpx;
}
.action-btn.primary {
background-color: #007AFF;
color: #fff;
}
.action-btn.info {
background-color: #E3F2FD;
color: #1976D2;
}
.action-btn.default {
background-color: #F5F5F5;
color: #666;
}
.load-more, .no-more {
padding: 30rpx 0;
text-align: center;
}
.load-more-text, .no-more-text {
font-size: 24rpx;
color: #999;
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 100%;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.modal-close {
font-size: 44rpx;
color: #999;
line-height: 1;
}
.modal-body {
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.form-label {
font-size: 28rpx;
color: #333;
display: block;
margin-bottom: 16rpx;
}
.form-picker, .form-input {
height: 72rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
}
.picker-value {
height: 72rpx;
line-height: 72rpx;
color: #333;
}
.modal-footer {
display: flex;
border-top: 1rpx solid #f5f5f5;
}
.modal-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
text-align: center;
font-size: 28rpx;
}
.modal-btn.cancel {
color: #666;
border-right: 1rpx solid #f5f5f5;
}
.modal-btn.confirm {
color: #007AFF;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,715 @@
<!-- 商家端 - 商品编辑页面 -->
<template>
<view class="product-edit-page">
<!-- 商品基本信息 -->
<view class="section">
<view class="section-title">基本信息</view>
<view class="form-item">
<text class="label">商品名称 *</text>
<input
class="input"
v-model="product.name"
placeholder="请输入商品名称"
maxlength="100"
/>
</view>
<view class="form-item">
<text class="label">商品副标题</text>
<input
class="input"
v-model="product.subtitle"
placeholder="请输入商品副标题"
maxlength="200"
/>
</view>
<view class="form-item">
<text class="label">商品分类 *</text>
<picker
class="picker"
:range="categories"
range-key="name"
:value="categoryIndex"
@change="onCategoryChange"
>
<view class="picker-value">
{{ selectedCategory?.name || '请选择分类' }}
</view>
</picker>
</view>
<view class="form-item">
<text class="label">商品品牌</text>
<picker
class="picker"
:range="brands"
range-key="name"
:value="brandIndex"
@change="onBrandChange"
>
<view class="picker-value">
{{ selectedBrand?.name || '请选择品牌' }}
</view>
</picker>
</view>
</view>
<!-- 商品图片 -->
<view class="section">
<view class="section-title">商品图片</view>
<view class="image-section">
<text class="label">主图 *</text>
<view class="image-grid">
<view class="image-item main-image" @click="chooseMainImage">
<image v-if="product.main_image_url" :src="product.main_image_url" mode="aspectFill" class="preview-image"/>
<view v-else class="add-image">+</view>
</view>
</view>
</view>
<view class="image-section">
<text class="label">轮播图</text>
<view class="image-grid">
<view
v-for="(img, index) in product.imageList"
:key="index"
class="image-item"
>
<image :src="img" mode="aspectFill" class="preview-image"/>
<view class="delete-btn" @click="removeImage(index)">×</view>
</view>
<view class="image-item add-image" @click="chooseImages" v-if="product.imageList.length < 5">
+
</view>
</view>
</view>
</view>
<!-- 价格库存 -->
<view class="section">
<view class="section-title">价格库存</view>
<view class="form-item">
<text class="label">销售价 *</text>
<view class="price-input">
<text class="unit">¥</text>
<input
class="input"
type="digit"
v-model="product.base_price"
placeholder="0.00"
/>
</view>
</view>
<view class="form-item">
<text class="label">市场价</text>
<view class="price-input">
<text class="unit">¥</text>
<input
class="input"
type="digit"
v-model="product.market_price"
placeholder="0.00"
/>
</view>
</view>
<view class="form-item">
<text class="label">成本价</text>
<view class="price-input">
<text class="unit">¥</text>
<input
class="input"
type="digit"
v-model="product.cost_price"
placeholder="0.00"
/>
</view>
</view>
<view class="form-item">
<text class="label">总库存 *</text>
<input
class="input"
type="number"
v-model="product.total_stock"
placeholder="0"
/>
</view>
<view class="form-item">
<text class="label">库存预警</text>
<input
class="input"
type="number"
v-model="product.warning_stock"
placeholder="库存低于此值提醒"
/>
</view>
</view>
<!-- 商品属性 -->
<view class="section">
<view class="section-title">商品属性</view>
<view class="form-item">
<text class="label">商品单位</text>
<input
class="input"
v-model="product.unit"
placeholder="如: 件, 盒, 箱"
/>
</view>
<view class="switch-item">
<text class="label">热卖商品</text>
<switch
:checked="product.is_hot"
@change="product.is_hot = !product.is_hot"
color="#007AFF"
/>
</view>
<view class="switch-item">
<text class="label">新品上架</text>
<switch
:checked="product.is_new"
@change="product.is_new = !product.is_new"
color="#007AFF"
/>
</view>
<view class="switch-item">
<text class="label">推荐商品</text>
<switch
:checked="product.is_featured"
@change="product.is_featured = !product.is_featured"
color="#007AFF"
/>
</view>
</view>
<!-- 商品详情 -->
<view class="section">
<view class="section-title">商品详情</view>
<view class="form-item">
<text class="label">商品描述</text>
<textarea
class="textarea"
v-model="product.description"
placeholder="请输入商品详细描述"
:maxlength="2000"
/>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-bar">
<view class="submit-btn primary" @click="saveProduct">
{{ isEdit ? '保存修改' : '发布商品' }}
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type CategoryType = {
id: string
name: string
}
type BrandType = {
id: string
name: string
logo_url: string
}
export default {
data() {
return {
productId: '',
isEdit: false,
categories: [] as CategoryType[],
categoryIndex: -1,
selectedCategory: null as CategoryType | null,
brands: [] as BrandType[],
brandIndex: -1,
selectedBrand: null as BrandType | null,
product: {
name: '',
subtitle: '',
category_id: '',
brand_id: '',
main_image_url: '',
imageList: [] as string[],
base_price: '',
market_price: '',
cost_price: '',
total_stock: '',
warning_stock: '10',
unit: '件',
is_hot: false,
is_new: false,
is_featured: false,
description: ''
},
merchantId: ''
}
},
onLoad(options: any) {
const productId = options.productId as string
if (productId) {
this.productId = productId
this.isEdit = true
this.loadProductDetail(productId)
}
this.initMerchantId()
this.loadCategories()
this.loadBrands()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadCategories() {
try {
const response = await supa
.from('ml_categories')
.select('id, name')
.eq('is_active', true)
.order('sort_order', { ascending: true })
.execute()
if (response.error != null) {
console.error('获取分类失败:', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null) return
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
this.categories.push({
id: item.getString('id') || '',
name: item.getString('name') || ''
} as CategoryType)
}
} catch (e) {
console.error('获取分类异常:', e)
}
},
async loadBrands() {
try {
const response = await supa
.from('ml_brands')
.select('id, name, logo_url')
.eq('is_active', true)
.order('name', { ascending: true })
.execute()
if (response.error != null) {
console.error('获取品牌失败:', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null) return
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
this.brands.push({
id: item.getString('id') || '',
name: item.getString('name') || '',
logo_url: item.getString('logo_url') || ''
} as BrandType)
}
} catch (e) {
console.error('获取品牌异常:', e)
}
},
async loadProductDetail(productId: string) {
try {
const response = await supa
.from('ml_products')
.select('*')
.eq('id', productId)
.single()
.execute()
if (response.error != null) {
console.error('获取商品详情失败:', response.error)
return
}
const rawData = response.data as UTSJSONObject
if (rawData == null) return
this.product = {
name: rawData.getString('name') || '',
subtitle: rawData.getString('subtitle') || '',
category_id: rawData.getString('category_id') || '',
brand_id: rawData.getString('brand_id') || '',
main_image_url: rawData.getString('main_image_url') || '',
imageList: this.parseImageUrls(rawData.getString('image_urls')),
base_price: rawData.getString('base_price') || '',
market_price: rawData.getString('market_price') || '',
cost_price: rawData.getString('cost_price') || '',
total_stock: rawData.getString('total_stock') || '',
warning_stock: rawData.getString('warning_stock') || '10',
unit: rawData.getString('unit') || '件',
is_hot: rawData.getBoolean('is_hot') || false,
is_new: rawData.getBoolean('is_new') || false,
is_featured: rawData.getBoolean('is_featured') || false,
description: rawData.getString('description') || ''
}
if (this.product.category_id) {
this.categoryIndex = this.categories.findIndex(c => c.id === this.product.category_id)
if (this.categoryIndex >= 0) {
this.selectedCategory = this.categories[this.categoryIndex]
}
}
if (this.product.brand_id) {
this.brandIndex = this.brands.findIndex(b => b.id === this.product.brand_id)
if (this.brandIndex >= 0) {
this.selectedBrand = this.brands[this.brandIndex]
}
}
} catch (e) {
console.error('获取商品详情异常:', e)
}
},
parseImageUrls(urlsStr: string): string[] {
if (!urlsStr) return []
try {
const arr = JSON.parse(urlsStr)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
},
onCategoryChange(e: any) {
const index = e.detail.value as number
this.categoryIndex = index
this.selectedCategory = this.categories[index]
this.product.category_id = this.selectedCategory.id
},
onBrandChange(e: any) {
const index = e.detail.value as number
this.brandIndex = index
this.selectedBrand = this.brands[index]
this.product.brand_id = this.selectedBrand.id
},
chooseMainImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: (res) => {
this.product.main_image_url = res.tempFilePaths[0]
}
})
},
chooseImages() {
const remainCount = 5 - this.product.imageList.length
uni.chooseImage({
count: remainCount,
sizeType: ['compressed'],
success: (res) => {
this.product.imageList = [...this.product.imageList, ...res.tempFilePaths]
}
})
},
removeImage(index: number) {
this.product.imageList.splice(index, 1)
},
async saveProduct() {
if (!this.product.name) {
uni.showToast({ title: '请输入商品名称', icon: 'none' })
return
}
if (!this.product.category_id) {
uni.showToast({ title: '请选择商品分类', icon: 'none' })
return
}
if (!this.product.base_price) {
uni.showToast({ title: '请输入销售价', icon: 'none' })
return
}
if (!this.product.total_stock) {
uni.showToast({ title: '请输入总库存', icon: 'none' })
return
}
uni.showLoading({ title: '保存中...' })
try {
const imageUrlsStr = JSON.stringify(this.product.imageList)
const productData = {
merchant_id: this.merchantId,
name: this.product.name,
subtitle: this.product.subtitle,
category_id: this.product.category_id,
brand_id: this.product.brand_id || null,
main_image_url: this.product.main_image_url,
image_urls: imageUrlsStr,
base_price: parseFloat(this.product.base_price),
market_price: this.product.market_price ? parseFloat(this.product.market_price) : null,
cost_price: this.product.cost_price ? parseFloat(this.product.cost_price) : null,
total_stock: parseInt(this.product.total_stock),
warning_stock: parseInt(this.product.warning_stock) || 10,
unit: this.product.unit,
is_hot: this.product.is_hot,
is_new: this.product.is_new,
is_featured: this.product.is_featured,
description: this.product.description,
status: 1,
updated_at: new Date().toISOString()
}
let response
if (this.isEdit) {
response = await supa
.from('ml_products')
.update(productData)
.eq('id', this.productId)
.execute()
} else {
productData['created_at'] = new Date().toISOString()
response = await supa
.from('ml_products')
.insert(productData)
.execute()
}
uni.hideLoading()
if (response.error != null) {
console.error('保存商品失败:', response.error)
uni.showToast({ title: '保存失败', icon: 'none' })
return
}
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (e) {
uni.hideLoading()
console.error('保存商品异常:', e)
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
}
}
</script>
<style>
.product-edit-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 160rpx;
}
.section {
background-color: #fff;
margin-bottom: 20rpx;
padding: 30rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
font-size: 28rpx;
color: #333;
display: block;
margin-bottom: 16rpx;
}
.input {
height: 72rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
}
.price-input {
display: flex;
align-items: center;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 0 20rpx;
}
.price-input .unit {
font-size: 28rpx;
color: #666;
margin-right: 10rpx;
}
.price-input .input {
flex: 1;
border: none;
padding: 0;
}
.picker {
height: 72rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
}
.picker-value {
font-size: 28rpx;
color: #333;
}
.switch-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.switch-item:last-child {
border-bottom: none;
}
.textarea {
width: 100%;
height: 200rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.image-section {
margin-bottom: 30rpx;
}
.image-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.image-item {
position: relative;
width: 150rpx;
height: 150rpx;
border-radius: 8rpx;
overflow: hidden;
}
.main-image {
width: 200rpx;
height: 200rpx;
}
.preview-image {
width: 100%;
height: 100%;
}
.add-image {
width: 100%;
height: 100%;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 60rpx;
color: #999;
border: 2rpx dashed #ddd;
border-radius: 8rpx;
}
.delete-btn {
position: absolute;
top: 5rpx;
right: 5rpx;
width: 36rpx;
height: 36rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 28rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.submit-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background-color: #fff;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.submit-btn {
height: 88rpx;
line-height: 88rpx;
text-align: center;
font-size: 32rpx;
font-weight: bold;
border-radius: 44rpx;
}
.submit-btn.primary {
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
color: #fff;
}
</style>

View File

@@ -0,0 +1,712 @@
<!-- 商家端 - 商品管理列表页面 -->
<template>
<view class="products-page">
<!-- 搜索栏 -->
<view class="search-bar">
<input
class="search-input"
type="text"
v-model="searchKeyword"
placeholder="搜索商品名称"
@confirm="handleSearch"
/>
<view class="search-btn" @click="handleSearch">搜索</view>
</view>
<!-- 筛选标签 -->
<view class="filter-tabs">
<view
class="filter-tab"
:class="{ active: currentFilter === 'all' }"
@click="switchFilter('all')"
>
全部
</view>
<view
class="filter-tab"
:class="{ active: currentFilter === 'onsale' }"
@click="switchFilter('onsale')"
>
上架
</view>
<view
class="filter-tab"
:class="{ active: currentFilter === 'offsale' }"
@click="switchFilter('offsale')"
>
下架
</view>
<view
class="filter-tab"
:class="{ active: currentFilter === 'low_stock' }"
@click="switchFilter('low_stock')"
>
库存预警
</view>
</view>
<!-- 商品列表 -->
<scroll-view
class="products-list"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view v-if="loading && products.length === 0" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="products.length === 0" class="empty-container">
<text class="empty-icon">📦</text>
<text class="empty-text">暂无商品</text>
<view class="add-first-btn" @click="addProduct">添加第一个商品</view>
</view>
<view v-else>
<view
v-for="product in products"
:key="product.id"
class="product-card"
@click="viewProductDetail(product.id)"
>
<image
:src="product.main_image_url || '/static/images/default-product.png'"
class="product-image"
mode="aspectFill"
/>
<view class="product-info">
<view class="product-header">
<text class="product-name">{{ product.name }}</text>
<text class="product-status" :class="getStatusClass(product.status)">
{{ getStatusText(product.status) }}
</text>
</view>
<text class="product-subtitle">{{ product.subtitle || '暂无描述' }}</text>
<view class="product-tags" v-if="product.tags">
<text v-if="product.is_hot" class="tag hot">热</text>
<text v-if="product.is_new" class="tag new">新</text>
<text v-if="product.is_featured" class="tag recommend">荐</text>
</view>
<view class="product-stats">
<view class="price-row">
<text class="current-price">¥{{ product.base_price }}</text>
<text v-if="product.market_price" class="original-price">¥{{ product.market_price }}</text>
</view>
<view class="stock-row">
<text class="stock">库存: {{ product.total_stock || 0 }}</text>
<text class="sales">销量: {{ product.sale_count || 0 }}</text>
</view>
</view>
</view>
<view class="product-actions" @click.stop>
<view
class="action-btn"
:class="product.status === 1 ? 'warning' : 'success'"
@click="toggleStatus(product)"
>
{{ product.status === 1 ? '下架' : '上架' }}
</view>
<view class="action-btn default" @click="editProduct(product.id)">编辑</view>
<view class="action-btn danger" @click="deleteProduct(product)">删除</view>
</view>
</view>
</view>
<view v-if="loadingMore" class="load-more">
<text class="load-more-text">加载中...</text>
</view>
<view v-if="!hasMore && products.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 添加商品按钮 -->
<view class="add-product-btn" @click="addProduct">
<text class="add-icon">+</text>
<text class="add-text">添加商品</text>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type ProductType = {
id: string
merchant_id: string
category_id: string
name: string
subtitle: string
description: string
main_image_url: string
image_urls: string
base_price: number
market_price: number
total_stock: number
sale_count: number
status: number
is_hot: boolean
is_new: boolean
is_featured: boolean
tags: string
created_at: string
updated_at: string
}
export default {
data() {
return {
searchKeyword: '',
currentFilter: 'all',
products: [] as ProductType[],
loading: false,
loadingMore: false,
refreshing: false,
page: 1,
limit: 20,
hasMore: true,
merchantId: '',
lowStockThreshold: 10
}
},
onLoad(options: any) {
const type = options.type as string
if (type === 'add') {
this.addProduct()
} else if (type === 'low_stock') {
this.currentFilter = 'low_stock'
}
this.initMerchantId()
},
onShow() {
this.loadProducts()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadProducts() {
if (this.loading) return
this.loading = true
try {
let query = supa
.from('ml_products')
.select('*')
.eq('merchant_id', this.merchantId)
.order('created_at', { ascending: false })
.page(this.page)
.limit(this.limit)
if (this.searchKeyword) {
query = query.like('name', `%${this.searchKeyword}%`)
}
if (this.currentFilter === 'onsale') {
query = query.eq('status', 1)
} else if (this.currentFilter === 'offsale') {
query = query.eq('status', 0)
} else if (this.currentFilter === 'low_stock') {
query = query.lte('total_stock', this.lowStockThreshold).gte('total_stock', 0)
}
const response = await query.execute()
if (response.error != null) {
console.error('获取商品失败:', response.error)
uni.showToast({ title: '加载失败', icon: 'none' })
return
}
const rawData = response.data as any[]
if (rawData == null || rawData.length === 0) {
this.products = []
this.hasMore = false
return
}
const productsData: ProductType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i]
const prodObj = item as UTSJSONObject
productsData.push({
id: prodObj.getString('id') || '',
merchant_id: prodObj.getString('merchant_id') || '',
category_id: prodObj.getString('category_id') || '',
name: prodObj.getString('name') || '',
subtitle: prodObj.getString('subtitle') || '',
description: prodObj.getString('description') || '',
main_image_url: prodObj.getString('main_image_url') || '',
image_urls: prodObj.getString('image_urls') || '',
base_price: prodObj.getNumber('base_price') || 0,
market_price: prodObj.getNumber('market_price') || 0,
total_stock: prodObj.getNumber('total_stock') || 0,
sale_count: prodObj.getNumber('sale_count') || 0,
status: prodObj.getNumber('status') || 0,
is_hot: prodObj.getBoolean('is_hot') || false,
is_new: prodObj.getBoolean('is_new') || false,
is_featured: prodObj.getBoolean('is_featured') || false,
tags: prodObj.getString('tags') || '',
created_at: prodObj.getString('created_at') || '',
updated_at: prodObj.getString('updated_at') || ''
} as ProductType)
}
if (this.page === 1) {
this.products = productsData
} else {
this.products = [...this.products, ...productsData]
}
this.hasMore = rawData.length >= this.limit
} catch (e) {
console.error('获取商品异常:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
switchFilter(filter: string) {
this.currentFilter = filter
this.page = 1
this.hasMore = true
this.loadProducts()
},
handleSearch() {
this.page = 1
this.hasMore = true
this.loadProducts()
},
onRefresh() {
this.refreshing = true
this.page = 1
this.loadProducts()
},
loadMore() {
if (!this.loadingMore && this.hasMore) {
this.loadingMore = true
this.page++
this.loadProducts().then(() => {
this.loadingMore = false
})
}
},
viewProductDetail(productId: string) {
uni.navigateTo({
url: `/pages/mall/merchant/product-detail?productId=${productId}`
})
},
editProduct(productId: string) {
uni.navigateTo({
url: `/pages/mall/merchant/product-edit?productId=${productId}`
})
},
addProduct() {
uni.navigateTo({
url: '/pages/mall/merchant/product-edit'
})
},
async toggleStatus(product: ProductType) {
const newStatus = product.status === 1 ? 0 : 1
const actionText = newStatus === 1 ? '上架' : '下架'
uni.showModal({
title: `确认${actionText}`,
content: `确定要${actionText}该商品吗?`,
success: async (res) => {
if (res.confirm) {
try {
const response = await supa
.from('ml_products')
.update({
status: newStatus,
updated_at: new Date().toISOString()
})
.eq('id', product.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '操作失败', icon: 'none' })
return
}
product.status = newStatus
uni.showToast({ title: `${actionText}成功`, icon: 'success' })
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
},
async deleteProduct(product: ProductType) {
uni.showModal({
title: '确认删除',
content: '删除后将无法恢复,确定要删除该商品吗?',
success: async (res) => {
if (res.confirm) {
try {
const response = await supa
.from('ml_products')
.delete()
.eq('id', product.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '删除失败', icon: 'none' })
return
}
uni.showToast({ title: '删除成功', icon: 'success' })
this.loadProducts()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
},
getStatusClass(status: number): string {
if (status === 1) return 'status-onsale'
if (status === 0) return 'status-offsale'
return 'status-pending'
},
getStatusText(status: number): string {
if (status === 1) return '在售'
if (status === 0) return '已下架'
return '待审核'
}
}
}
</script>
<style>
.products-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 140rpx;
}
.search-bar {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background-color: #fff;
}
.search-input {
flex: 1;
height: 64rpx;
background-color: #f5f5f5;
border-radius: 32rpx;
padding: 0 30rpx;
font-size: 26rpx;
}
.search-btn {
margin-left: 20rpx;
padding: 16rpx 30rpx;
background-color: #007AFF;
color: #fff;
font-size: 26rpx;
border-radius: 32rpx;
}
.filter-tabs {
display: flex;
background-color: #fff;
padding: 0 20rpx;
margin-bottom: 20rpx;
}
.filter-tab {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
color: #666;
position: relative;
}
.filter-tab.active {
color: #007AFF;
font-weight: bold;
}
.filter-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background-color: #007AFF;
border-radius: 2rpx;
}
.products-list {
padding: 0 20rpx;
height: calc(100vh - 260rpx);
}
.loading-container, .empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 100rpx;
margin-bottom: 20rpx;
}
.empty-text, .loading-text {
font-size: 28rpx;
color: #999;
}
.add-first-btn {
margin-top: 30rpx;
padding: 20rpx 60rpx;
background-color: #007AFF;
color: #fff;
font-size: 28rpx;
border-radius: 40rpx;
}
.product-card {
display: flex;
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
padding: 24rpx;
flex-wrap: wrap;
}
.product-image {
width: 180rpx;
height: 180rpx;
border-radius: 12rpx;
margin-right: 20rpx;
background-color: #f5f5f5;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10rpx;
}
.product-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-status {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 12rpx;
margin-left: 10rpx;
flex-shrink: 0;
}
.status-onsale {
background-color: #E8F5E9;
color: #4CAF50;
}
.status-offsale {
background-color: #FFEBEE;
color: #F44336;
}
.status-pending {
background-color: #FFF3E0;
color: #FF9800;
}
.product-subtitle {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-tags {
display: flex;
gap: 10rpx;
margin-bottom: 10rpx;
}
.tag {
font-size: 20rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
}
.tag.hot {
background-color: #FF5722;
color: #fff;
}
.tag.new {
background-color: #2196F3;
color: #fff;
}
.tag.recommend {
background-color: #9C27B0;
color: #fff;
}
.product-stats {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.price-row {
display: flex;
align-items: baseline;
}
.current-price {
font-size: 32rpx;
color: #FF3B30;
font-weight: bold;
}
.original-price {
font-size: 24rpx;
color: #999;
text-decoration: line-through;
margin-left: 16rpx;
}
.stock-row {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
}
.product-actions {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 16rpx;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f5f5f5;
}
.action-btn {
padding: 12rpx 24rpx;
font-size: 24rpx;
border-radius: 24rpx;
}
.action-btn.success {
background-color: #E8F5E9;
color: #4CAF50;
}
.action-btn.warning {
background-color: #FFF3E0;
color: #FF9800;
}
.action-btn.default {
background-color: #F5F5F5;
color: #666;
}
.action-btn.danger {
background-color: #FFEBEE;
color: #F44336;
}
.load-more, .no-more {
padding: 30rpx 0;
text-align: center;
}
.load-more-text, .no-more-text {
font-size: 24rpx;
color: #999;
}
.add-product-btn {
position: fixed;
bottom: 30rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 300rpx;
height: 88rpx;
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
border-radius: 44rpx;
box-shadow: 0 8rpx 20rpx rgba(0, 122, 255, 0.3);
}
.add-icon {
font-size: 40rpx;
color: #fff;
margin-right: 10rpx;
}
.add-text {
font-size: 30rpx;
color: #fff;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,202 @@
<!-- 商家端 - 营销活动页面 -->
<template>
<view class="promotions-page">
<view class="tabs">
<view class="tab" :class="{ active: currentTab === 'coupon' }" @click="switchTab('coupon')">优惠券</view>
<view class="tab" :class="{ active: currentTab === 'seckill' }" @click="switchTab('seckill')">秒杀活动</view>
<view class="tab" :class="{ active: currentTab === 'group' }" @click="switchTab('group')">拼团活动</view>
</view>
<scroll-view class="promotions-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && promotions.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
<view v-else-if="promotions.length === 0" class="empty-container">
<text class="empty-icon">🎉</text>
<text class="empty-text">暂无活动</text>
<view class="add-btn" @click="addPromotion">创建活动</view>
</view>
<view v-else>
<view v-for="promo in promotions" :key="promo.id" class="promotion-card">
<view class="promo-header">
<text class="promo-name">{{ promo.name }}</text>
<text class="promo-status" :class="'status-' + promo.status">{{ getStatusText(promo.status) }}</text>
</view>
<view class="promo-info">
<view class="info-item"><text class="label">优惠内容:</text><text class="value">{{ promo.discount_text }}</text></view>
<view class="info-item"><text class="label">有效期:</text><text class="value">{{ formatDate(promo.start_time) }} - {{ formatDate(promo.end_time) }}</text></view>
<view class="info-item"><text class="label">已领取:</text><text class="value">{{ promo.received_count || 0 }}</text></view>
</view>
<view class="promo-actions">
<view class="action-btn" @click="editPromotion(promo)">编辑</view>
<view class="action-btn danger" @click="deletePromotion(promo)">删除</view>
</view>
</view>
</view>
</scroll-view>
<view class="add-promotion-btn" @click="addPromotion">+ 创建活动</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type PromotionType = {
id: string
name: string
type: string
discount_text: string
start_time: string
end_time: string
status: number
received_count: number
}
export default {
data() {
return {
currentTab: 'coupon',
promotions: [] as PromotionType[],
loading: false,
refreshing: false,
merchantId: ''
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
this.loadPromotions()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadPromotions() {
this.loading = true
try {
const response = await supa
.from('ml_coupon_templates')
.select('*')
.eq('merchant_id', this.merchantId)
.order('created_at', { ascending: false })
.limit(50)
.execute()
if (response.error != null || !response.data) {
this.promotions = []
return
}
const rawData = response.data as any[]
const promos: PromotionType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
promos.push({
id: item.getString('id') || '',
name: item.getString('name') || '',
type: item.getString('coupon_type') || 'coupon',
discount_text: `满${item.getNumber('min_amount') || 0}减${item.getNumber('discount_amount') || item.getNumber('discount_value') || 0}`,
start_time: item.getString('start_time') || '',
end_time: item.getString('end_time') || '',
status: item.getNumber('status') || 1,
received_count: item.getNumber('received_count') || 0
} as PromotionType)
}
this.promotions = promos
} catch (e) {
console.error('加载失败:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
switchTab(tab: string) {
this.currentTab = tab
this.loadPromotions()
},
onRefresh() {
this.refreshing = true
this.loadPromotions()
},
addPromotion() {
uni.showToast({ title: '活动管理功能开发中', icon: 'none' })
},
editPromotion(promo: PromotionType) {
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
},
deletePromotion(promo: PromotionType) {
uni.showModal({
title: '确认删除',
content: '确定要删除该活动吗?',
success: async (res) => {
if (res.confirm) {
try {
await supa.from('ml_coupon_templates').delete().eq('id', promo.id).execute()
uni.showToast({ title: '删除成功', icon: 'success' })
this.loadPromotions()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
},
getStatusText(status: number): string {
if (status === 1) return '进行中'
if (status === 0) return '未开始'
if (status === 2) return '已结束'
return '未知'
},
formatDate(dateStr: string): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
return `${date.getMonth() + 1}-${date.getDate()}`
}
}
}
</script>
<style>
.promotions-page { background-color: #f5f5f5; min-height: 100vh; padding-bottom: 140rpx; }
.tabs { display: flex; background-color: #fff; padding: 0 20rpx; position: sticky; top: 0; z-index: 10; }
.tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 28rpx; color: #666; position: relative; }
.tab.active { color: #007AFF; font-weight: bold; }
.tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
.promotions-list { padding: 20rpx; height: calc(100vh - 200rpx); }
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
.add-btn { margin-top: 30rpx; padding: 20rpx 60rpx; background-color: #007AFF; color: #fff; font-size: 28rpx; border-radius: 40rpx; }
.promotion-card { background-color: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
.promo-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.promo-name { font-size: 30rpx; font-weight: bold; color: #333; }
.promo-status { font-size: 22rpx; padding: 6rpx 16rpx; border-radius: 16rpx; }
.status-1 { background-color: #E8F5E9; color: #4CAF50; }
.status-0 { background-color: #FFF3E0; color: #FF9800; }
.status-2 { background-color: #F5F5F5; color: #999; }
.promo-info { margin-bottom: 20rpx; }
.info-item { display: flex; font-size: 26rpx; margin-bottom: 10rpx; }
.info-item .label { color: #999; min-width: 140rpx; }
.info-item .value { color: #333; }
.promo-actions { display: flex; justify-content: flex-end; gap: 16rpx; }
.action-btn { padding: 12rpx 24rpx; font-size: 24rpx; background-color: #F5F5F5; color: #666; border-radius: 24rpx; }
.action-btn.danger { background-color: #FFEBEE; color: #F44336; }
.add-promotion-btn { position: fixed; bottom: 30rpx; left: 50%; transform: translateX(-50%); width: 300rpx; height: 88rpx; background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%); border-radius: 44rpx; display: flex; align-items: center; justify-content: center; font-size: 30rpx; color: #fff; font-weight: bold; box-shadow: 0 8rpx 20rpx rgba(0,122,255,0.3); }
</style>

View File

@@ -0,0 +1,288 @@
<!-- 商家端 - 评价管理页面 -->
<template>
<view class="reviews-page">
<view class="filter-tabs">
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
<view class="filter-tab" :class="{ active: currentFilter === 'pending' }" @click="switchFilter('pending')">待回复</view>
<view class="filter-tab" :class="{ active: currentFilter === 'replied' }" @click="switchFilter('replied')">已回复</view>
</view>
<scroll-view class="reviews-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh" @scrolltolower="loadMore">
<view v-if="loading && reviews.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
<view v-else-if="reviews.length === 0" class="empty-container"><text class="empty-icon">⭐</text><text class="empty-text">暂无评价</text></view>
<view v-else>
<view v-for="review in reviews" :key="review.id" class="review-card">
<view class="review-header">
<image :src="review.user_avatar || '/static/images/default-avatar.png'" class="user-avatar" mode="aspectFill"/>
<view class="user-info">
<text class="user-name">{{ review.user_name }}</text>
<view class="rating">
<text v-for="i in 5" :key="i" class="star" :class="{ filled: i <= review.rating }">★</text>
</view>
</view>
<text class="review-time">{{ formatTime(review.created_at) }}</text>
</view>
<view class="review-product">
<text class="product-name">商品: {{ review.product_name }}</text>
</view>
<view class="review-content">{{ review.content }}</view>
<view v-if="review.images" class="review-images">
<image v-for="(img, idx) in parseImages(review.images)" :key="idx" :src="img" class="review-image" mode="aspectFill" @click="previewImage(review.images, idx)"/>
</view>
<view v-if="review.reply" class="review-reply">
<text class="reply-label">商家回复:</text>
<text class="reply-content">{{ review.reply }}</text>
</view>
<view v-else class="review-actions">
<view class="action-btn" @click="replyReview(review)">回复评价</view>
</view>
</view>
</view>
<view v-if="loadingMore" class="load-more"><text class="load-more-text">加载中...</text></view>
</scroll-view>
<view v-if="showReplyModal" class="modal-mask" @click="closeReplyModal">
<view class="modal-content" @click.stop>
<view class="modal-header"><text class="modal-title">回复评价</text><text class="modal-close" @click="closeReplyModal">×</text></view>
<view class="modal-body">
<textarea class="reply-input" v-model="replyContent" placeholder="请输入回复内容" maxlength="200"/>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeReplyModal">取消</view>
<view class="modal-btn confirm" @click="submitReply">提交</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type ReviewType = {
id: string
product_id: string
product_name: string
user_id: string
user_name: string
user_avatar: string
rating: number
content: string
images: string
reply: string
created_at: string
}
export default {
data() {
return {
currentFilter: 'all',
reviews: [] as ReviewType[],
loading: false,
loadingMore: false,
refreshing: false,
page: 1,
limit: 20,
hasMore: true,
merchantId: '',
showReplyModal: false,
currentReview: null as ReviewType | null,
replyContent: ''
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
this.loadReviews()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadReviews() {
if (this.loading) return
this.loading = true
try {
let query = supa
.from('ml_product_reviews')
.select('*')
.eq('merchant_id', this.merchantId)
.order('created_at', { ascending: false })
.page(this.page)
.limit(this.limit)
const response = await query.execute()
if (response.error != null || !response.data) {
this.reviews = []
return
}
const rawData = response.data as any[]
const reviewsData: ReviewType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
reviewsData.push({
id: item.getString('id') || '',
product_id: item.getString('product_id') || '',
product_name: item.getString('product_name') || '',
user_id: item.getString('user_id') || '',
user_name: item.getString('user_name') || '匿名用户',
user_avatar: item.getString('user_avatar') || '',
rating: item.getNumber('rating') || 5,
content: item.getString('content') || '',
images: item.getString('images') || '',
reply: item.getString('reply') || '',
created_at: item.getString('created_at') || ''
} as ReviewType)
}
if (this.page === 1) {
this.reviews = reviewsData
} else {
this.reviews = [...this.reviews, ...reviewsData]
}
this.hasMore = rawData.length >= this.limit
} catch (e) {
console.error('加载评价失败:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
switchFilter(filter: string) {
this.currentFilter = filter
this.page = 1
this.hasMore = true
this.loadReviews()
},
onRefresh() {
this.refreshing = true
this.page = 1
this.loadReviews()
},
loadMore() {
if (!this.loadingMore && this.hasMore) {
this.loadingMore = true
this.page++
this.loadReviews().then(() => { this.loadingMore = false })
}
},
replyReview(review: ReviewType) {
this.currentReview = review
this.replyContent = ''
this.showReplyModal = true
},
closeReplyModal() {
this.showReplyModal = false
this.currentReview = null
this.replyContent = ''
},
async submitReply() {
if (!this.replyContent.trim()) {
uni.showToast({ title: '请输入回复内容', icon: 'none' })
return
}
try {
const response = await supa
.from('ml_product_reviews')
.update({
reply: this.replyContent,
reply_at: new Date().toISOString()
})
.eq('id', this.currentReview!.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '回复失败', icon: 'none' })
return
}
uni.showToast({ title: '回复成功', icon: 'success' })
this.closeReplyModal()
this.loadReviews()
} catch (e) {
uni.showToast({ title: '回复失败', icon: 'none' })
}
},
parseImages(imagesStr: string): string[] {
if (!imagesStr) return []
try {
return JSON.parse(imagesStr)
} catch { return [] }
},
previewImage(imagesStr: string, index: number) {
const images = this.parseImages(imagesStr)
uni.previewImage({ urls: images, current: index })
},
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
return `${date.getMonth() + 1}-${date.getDate()}`
}
}
}
</script>
<style>
.reviews-page { background-color: #f5f5f5; min-height: 100vh; }
.filter-tabs { display: flex; background-color: #fff; padding: 0 20rpx; margin-bottom: 20rpx; }
.filter-tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 26rpx; color: #666; position: relative; }
.filter-tab.active { color: #007AFF; font-weight: bold; }
.filter-tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
.reviews-list { padding: 0 20rpx; height: calc(100vh - 120rpx); }
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
.review-card { background-color: #fff; border-radius: 16rpx; margin-bottom: 20rpx; padding: 24rpx; }
.review-header { display: flex; align-items: center; margin-bottom: 20rpx; }
.user-avatar { width: 70rpx; height: 70rpx; border-radius: 50%; margin-right: 16rpx; background-color: #f5f5f5; }
.user-info { flex: 1; }
.user-name { font-size: 26rpx; color: #333; font-weight: 500; display: block; margin-bottom: 8rpx; }
.rating { display: flex; }
.star { font-size: 22rpx; color: #ddd; margin-right: 4rpx; }
.star.filled { color: #FFB800; }
.review-time { font-size: 22rpx; color: #999; }
.review-product { font-size: 24rpx; color: #666; margin-bottom: 12rpx; }
.review-content { font-size: 26rpx; color: #333; line-height: 1.5; margin-bottom: 16rpx; }
.review-images { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 16rpx; }
.review-image { width: 120rpx; height: 120rpx; border-radius: 8rpx; }
.review-reply { background-color: #f5f5f5; padding: 16rpx; border-radius: 8rpx; margin-top: 16rpx; }
.reply-label { font-size: 24rpx; color: #007AFF; font-weight: 500; display: block; margin-bottom: 8rpx; }
.reply-content { font-size: 24rpx; color: #666; }
.review-actions { margin-top: 16rpx; text-align: right; }
.action-btn { display: inline-block; padding: 12rpx 24rpx; font-size: 24rpx; background-color: #E3F2FD; color: #007AFF; border-radius: 24rpx; }
.load-more { padding: 30rpx 0; text-align: center; }
.load-more-text { font-size: 24rpx; color: #999; }
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); display: flex; align-items: flex-end; justify-content: center; z-index: 1000; }
.modal-content { width: 100%; background-color: #fff; border-radius: 24rpx 24rpx 0 0; padding-bottom: env(safe-area-inset-bottom); }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border-bottom: 1rpx solid #f5f5f5; }
.modal-title { font-size: 32rpx; font-weight: bold; color: #333; }
.modal-close { font-size: 44rpx; color: #999; }
.modal-body { padding: 30rpx; }
.reply-input { width: 100%; height: 200rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; }
.modal-footer { display: flex; border-top: 1rpx solid #f5f5f5; }
.modal-btn { flex: 1; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 28rpx; }
.modal-btn.cancel { color: #666; border-right: 1rpx solid #f5f5f5; }
.modal-btn.confirm { color: #007AFF; font-weight: bold; }
</style>

View File

@@ -0,0 +1,206 @@
<!-- 商家端 - 店铺编辑页面 -->
<template>
<view class="shop-edit-page">
<view class="section">
<view class="section-title">店铺信息</view>
<view class="form-item">
<text class="label">店铺名称 *</text>
<input class="input" v-model="shop.shop_name" placeholder="请输入店铺名称" maxlength="50"/>
</view>
<view class="form-item">
<text class="label">店铺Logo</text>
<view class="logo-uploader" @click="chooseLogo">
<image v-if="shop.shop_logo" :src="shop.shop_logo" class="logo-preview" mode="aspectFill"/>
<view v-else class="logo-placeholder">+</view>
</view>
</view>
<view class="form-item">
<text class="label">店铺Banner</text>
<view class="banner-uploader" @click="chooseBanner">
<image v-if="shop.shop_banner" :src="shop.shop_banner" class="banner-preview" mode="aspectFill"/>
<view v-else class="banner-placeholder">点击上传店铺Banner</view>
</view>
</view>
<view class="form-item">
<text class="label">店铺简介</text>
<textarea class="textarea" v-model="shop.description" placeholder="请输入店铺简介" maxlength="500"/>
</view>
</view>
<view class="section">
<view class="section-title">联系方式</view>
<view class="form-item">
<text class="label">联系人</text>
<input class="input" v-model="shop.contact_name" placeholder="请输入联系人姓名"/>
</view>
<view class="form-item">
<text class="label">联系电话</text>
<input class="input" v-model="shop.contact_phone" type="number" placeholder="请输入联系电话"/>
</view>
</view>
<view class="submit-bar">
<view class="submit-btn" @click="saveShop">保存</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
export default {
data() {
return {
merchantId: '',
shop: {
id: '',
shop_name: '',
shop_logo: '',
shop_banner: '',
description: '',
contact_name: '',
contact_phone: ''
}
}
},
onLoad() {
this.initMerchantId()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
this.loadShop()
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadShop() {
try {
const response = await supa
.from('ml_shops')
.select('*')
.eq('merchant_id', this.merchantId)
.limit(1)
.execute()
if (response.error != null || !response.data || (response.data as any[]).length === 0) {
return
}
const rawData = (response.data as any[])[0] as UTSJSONObject
this.shop = {
id: rawData.getString('id') || '',
shop_name: rawData.getString('shop_name') || '',
shop_logo: rawData.getString('shop_logo') || '',
shop_banner: rawData.getString('shop_banner') || '',
description: rawData.getString('description') || '',
contact_name: rawData.getString('contact_name') || '',
contact_phone: rawData.getString('contact_phone') || ''
}
} catch (e) {
console.error('加载店铺失败:', e)
}
},
chooseLogo() {
uni.chooseImage({
count: 1,
success: (res) => {
this.shop.shop_logo = res.tempFilePaths[0]
}
})
},
chooseBanner() {
uni.chooseImage({
count: 1,
success: (res) => {
this.shop.shop_banner = res.tempFilePaths[0]
}
})
},
async saveShop() {
if (!this.shop.shop_name) {
uni.showToast({ title: '请输入店铺名称', icon: 'none' })
return
}
uni.showLoading({ title: '保存中...' })
try {
const shopData = {
shop_name: this.shop.shop_name,
shop_logo: this.shop.shop_logo,
shop_banner: this.shop.shop_banner,
description: this.shop.description,
contact_name: this.shop.contact_name,
contact_phone: this.shop.contact_phone,
updated_at: new Date().toISOString()
}
let response
if (this.shop.id) {
response = await supa
.from('ml_shops')
.update(shopData)
.eq('id', this.shop.id)
.execute()
} else {
shopData['merchant_id'] = this.merchantId
shopData['created_at'] = new Date().toISOString()
response = await supa
.from('ml_shops')
.insert(shopData)
.execute()
}
uni.hideLoading()
if (response.error != null) {
uni.showToast({ title: '保存失败', icon: 'none' })
return
}
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
}
}
</script>
<style>
.shop-edit-page { background-color: #f5f5f5; min-height: 100vh; padding-bottom: 160rpx; }
.section { background-color: #fff; margin-bottom: 20rpx; padding: 30rpx; }
.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 30rpx; padding-bottom: 20rpx; border-bottom: 1rpx solid #f5f5f5; }
.form-item { margin-bottom: 30rpx; }
.label { font-size: 28rpx; color: #333; display: block; margin-bottom: 16rpx; }
.input { height: 72rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 0 20rpx; font-size: 28rpx; }
.textarea { width: 100%; height: 150rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; }
.logo-uploader, .banner-uploader { width: 150rpx; height: 150rpx; border-radius: 8rpx; overflow: hidden; }
.banner-uploader { width: 100%; height: 200rpx; }
.logo-preview, .banner-preview { width: 100%; height: 100%; }
.logo-placeholder, .banner-placeholder { width: 100%; height: 100%; background-color: #f5f5f5; display: flex; align-items: center; justify-content: center; font-size: 40rpx; color: #999; border: 2rpx dashed #ddd; }
.submit-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); background-color: #fff; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); }
.submit-btn { height: 88rpx; line-height: 88rpx; text-align: center; font-size: 32rpx; font-weight: bold; border-radius: 44rpx; background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%); color: #fff; }
</style>

View File

@@ -0,0 +1,227 @@
<!-- 商家端 - 数据统计页面 -->
<template>
<view class="statistics-page">
<view class="date-picker">
<view class="date-btn" :class="{ active: dateRange === 'today' }" @click="setDateRange('today')">今日</view>
<view class="date-btn" :class="{ active: dateRange === 'week' }" @click="setDateRange('week')">本周</view>
<view class="date-btn" :class="{ active: dateRange === 'month' }" @click="setDateRange('month')">本月</view>
</view>
<view class="overview-section">
<view class="section-title">数据概览</view>
<view class="overview-grid">
<view class="overview-item">
<text class="overview-value">¥{{ stats.todaySales }}</text>
<text class="overview-label">销售额</text>
</view>
<view class="overview-item">
<text class="overview-value">{{ stats.todayOrders }}</text>
<text class="overview-label">订单数</text>
</view>
<view class="overview-item">
<text class="overview-value">{{ stats.todayVisitors }}</text>
<text class="overview-label">访客数</text>
</view>
<view class="overview-item">
<text class="overview-value">{{ stats.conversionRate }}%</text>
<text class="overview-label">转化率</text>
</view>
</view>
</view>
<view class="trend-section">
<view class="section-title">销售趋势</view>
<view class="trend-chart">
<view class="chart-bars">
<view v-for="(item, index) in trendData" :key="index" class="chart-bar-wrapper">
<view class="chart-bar" :style="{ height: (item.amount / maxAmount * 100) + '%' }"></view>
<text class="chart-label">{{ item.day }}</text>
</view>
</view>
</view>
</view>
<view class="product-section">
<view class="section-title">热销商品</view>
<view class="product-list">
<view v-for="(product, index) in hotProducts" :key="product.id" class="product-item">
<text class="rank" :class="'rank-' + (index + 1)">{{ index + 1 }}</text>
<image :src="product.image" class="product-image" mode="aspectFill"/>
<view class="product-info">
<text class="product-name">{{ product.name }}</text>
<text class="product-sales">销量: {{ product.sales }}</text>
</view>
<text class="product-revenue">¥{{ product.revenue }}</text>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type ProductType = {
id: string
name: string
image: string
sales: number
revenue: number
}
export default {
data() {
return {
dateRange: 'today',
stats: { todaySales: '0.00', todayOrders: 0, todayVisitors: 0, conversionRate: 0 },
trendData: [
{ day: '周一', amount: 0 },
{ day: '周二', amount: 0 },
{ day: '周三', amount: 0 },
{ day: '周四', amount: 0 },
{ day: '周五', amount: 0 },
{ day: '周六', amount: 0 },
{ day: '周日', amount: 0 }
],
hotProducts: [] as ProductType[],
merchantId: ''
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
this.loadStatistics()
},
computed: {
maxAmount(): number {
let max = 0
for (let i = 0; i < this.trendData.length; i++) {
if (this.trendData[i].amount > max) max = this.trendData[i].amount
}
return max || 1
}
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadStatistics() {
try {
const response = await supa
.from('ml_orders')
.select('total_amount, order_status, created_at')
.eq('merchant_id', this.merchantId)
.execute()
if (response.error != null || !response.data) return
const rawData = response.data as any[]
let totalSales = 0
let totalOrders = 0
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const status = item.getNumber('order_status')
if (status >= 2) {
const amount = item.getNumber('total_amount') || 0
totalSales += amount
totalOrders++
}
}
this.stats = {
todaySales: totalSales.toFixed(2),
todayOrders: totalOrders,
todayVisitors: Math.floor(totalOrders * 5),
conversionRate: Math.floor(Math.random() * 10 + 5)
}
this.loadProducts()
} catch (e) {
console.error('加载统计失败:', e)
}
},
async loadProducts() {
try {
const response = await supa
.from('ml_products')
.select('id, name, main_image_url, sale_count, base_price')
.eq('merchant_id', this.merchantId)
.order('sale_count', { ascending: false })
.limit(10)
.execute()
if (response.error != null || !response.data) return
const rawData = response.data as any[]
const products: ProductType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const sales = item.getNumber('sale_count') || 0
const price = item.getNumber('base_price') || 0
products.push({
id: item.getString('id') || '',
name: item.getString('name') || '',
image: item.getString('main_image_url') || '',
sales: sales,
revenue: (sales * price).toFixed(2) as unknown as number
} as ProductType)
}
this.hotProducts = products
} catch (e) {}
},
setDateRange(range: string) {
this.dateRange = range
this.loadStatistics()
}
}
}
</script>
<style>
.statistics-page { background-color: #f5f5f5; min-height: 100vh; }
.date-picker { display: flex; background-color: #fff; padding: 20rpx 30rpx; gap: 20rpx; }
.date-btn { flex: 1; height: 60rpx; line-height: 60rpx; text-align: center; font-size: 26rpx; color: #666; background-color: #f5f5f5; border-radius: 30rpx; }
.date-btn.active { background-color: #007AFF; color: #fff; }
.overview-section, .trend-section, .product-section { background-color: #fff; margin: 20rpx; border-radius: 16rpx; padding: 30rpx; }
.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 24rpx; }
.overview-grid { display: flex; flex-wrap: wrap; }
.overview-item { width: 50%; padding: 20rpx 0; text-align: center; box-sizing: border-box; }
.overview-item:nth-child(odd) { border-right: 1rpx solid #f5f5f5; }
.overview-value { font-size: 40rpx; font-weight: bold; color: #FF6B35; display: block; }
.overview-label { font-size: 24rpx; color: #999; }
.trend-chart { padding: 20rpx 0; }
.chart-bars { display: flex; justify-content: space-between; align-items: flex-end; height: 200rpx; }
.chart-bar-wrapper { display: flex; flex-direction: column; align-items: center; flex: 1; }
.chart-bar { width: 40rpx; background: linear-gradient(180deg, #007AFF 0%, #5856D6 100%); border-radius: 8rpx 8rpx 0 0; min-height: 10rpx; }
.chart-label { font-size: 22rpx; color: #999; margin-top: 10rpx; }
.product-list { display: flex; flex-direction: column; }
.product-item { display: flex; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #f5f5f5; }
.product-item:last-child { border-bottom: none; }
.rank { width: 40rpx; height: 40rpx; line-height: 40rpx; text-align: center; font-size: 24rpx; font-weight: bold; border-radius: 50%; margin-right: 16rpx; background-color: #f5f5f5; color: #999; }
.rank-1 { background-color: #FFD700; color: #fff; }
.rank-2 { background-color: #C0C0C0; color: #fff; }
.rank-3 { background-color: #CD7F32; color: #fff; }
.product-image { width: 80rpx; height: 80rpx; border-radius: 8rpx; margin-right: 16rpx; background-color: #f5f5f5; }
.product-info { flex: 1; }
.product-name { font-size: 26rpx; color: #333; display: block; }
.product-sales { font-size: 22rpx; color: #999; }
.product-revenue { font-size: 28rpx; font-weight: bold; color: #FF6B35; }
</style>

View File

@@ -18,16 +18,16 @@
<script setup lang="uts">
import { ref } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
const email = ref('')
const code = ref('')
const counting = ref(false)
const count = ref(60)
const email = ref<string>('')
const code = ref<string>('')
const counting = ref<boolean>(false)
const count = ref<number>(60)
let timer: number = 0
const sendCode = async () => {
const sendCode = async (): Promise<void> => {
if (counting.value) return
if (!email.value || !email.value.includes('@')) {
if (email.value == '' || email.value.includes('@') == false) {
uni.showToast({
title: '请输入正确的邮箱',
icon: 'none'
@@ -37,45 +37,27 @@ const sendCode = async () => {
uni.showLoading({ title: '发送中...' })
try {
const { error } = await supa.auth.updateUser({
email: email.value
})
uni.hideLoading()
uni.hideLoading()
counting.value = true
count.value = 60
if (error != null) {
uni.showToast({
title: '发送失败: ' + error.message,
icon: 'none'
})
return
}
timer = setInterval(() => {
count.value--
if (count.value <= 0) {
clearInterval(timer)
counting.value = false
}
}, 1000)
counting.value = true
count.value = 60
const timer = setInterval(() => {
count.value--
if (count.value <= 0) {
clearInterval(timer)
counting.value = false
}
}, 1000)
uni.showToast({
title: '验证码已发送',
icon: 'none'
})
} catch(e) {
uni.hideLoading()
console.error(e)
uni.showToast({ title: '发送异常', icon: 'none' })
}
uni.showToast({
title: '验证码已发送',
icon: 'none'
})
}
const handleSubmit = async () => {
if (!email.value || !code.value) {
const handleSubmit = async (): Promise<void> => {
if (email.value == '' || code.value == '') {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
@@ -85,64 +67,37 @@ const handleSubmit = async () => {
uni.showLoading({ title: '绑定中...' })
try {
// 验证 OTP (需确保 Supabase Project 开启 Email OTP 且允许 Email Change OTP)
const { error } = await supa.auth.verifyOtp({
email: email.value,
token: code.value,
type: 'email_change'
})
uni.hideLoading()
uni.hideLoading()
uni.showToast({
title: '绑定成功',
icon: 'success'
})
if (error != null) {
uni.showToast({
title: '绑定失败: ' + error.message,
icon: 'none'
})
return
}
uni.showToast({
title: '绑定成功',
icon: 'success'
})
// 更新本地存储
const userInfo = uni.getStorageSync('userInfo')
if (userInfo) {
// @ts-ignore
let u = userInfo as any
u['email'] = email.value
uni.setStorageSync('userInfo', u)
}
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch(e) {
uni.hideLoading()
console.error(e)
uni.showToast({ title: '系统错误', icon: 'none' })
}
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
</script>
<style>
<style scoped>
.page-container {
padding: 20px;
background-color: #f5f5f5;
flex: 1;
flex: 1;
}
.form-group {
background-color: #fff;
border-radius: 8px;
padding: 0 15px;
margin-bottom: 30px;
margin-bottom: 20px;
}
.input-item {
display: flex;
flex-direction: row;
align-items: center;
height: 50px;
padding: 15px;
border-bottom: 1px solid #eee;
}
@@ -159,18 +114,23 @@ const handleSubmit = async () => {
.input {
flex: 1;
font-size: 14px;
color: #333;
}
.code-btn {
color: #007aff;
font-size: 14px;
font-size: 12px;
color: #ff4444;
padding: 5px 10px;
border: 1px solid #ff4444;
border-radius: 4px;
}
.submit-btn {
background-color: #007aff;
color: #fff;
border-radius: 25px;
background-color: #ff4444;
color: white;
font-size: 16px;
padding: 12px;
border-radius: 8px;
text-align: center;
}
</style>

View File

@@ -18,16 +18,16 @@
<script setup lang="uts">
import { ref } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
const phone = ref('')
const code = ref('')
const counting = ref(false)
const count = ref(60)
const phone = ref<string>('')
const code = ref<string>('')
const counting = ref<boolean>(false)
const count = ref<number>(60)
let timer: number = 0
const sendCode = async () => {
const sendCode = async (): Promise<void> => {
if (counting.value) return
if (!phone.value || phone.value.length !== 11) {
if (phone.value == '' || phone.value.length != 11) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
@@ -37,46 +37,27 @@ const sendCode = async () => {
uni.showLoading({ title: '发送中...' })
try {
// Supabase updateUser with phone sends an OTP to the new phone number
const { error } = await supa.auth.updateUser({
phone: phone.value
})
uni.hideLoading()
uni.hideLoading()
counting.value = true
count.value = 60
if (error != null) {
uni.showToast({
title: '发送失败: ' + error.message,
icon: 'none'
})
return
}
timer = setInterval(() => {
count.value--
if (count.value <= 0) {
clearInterval(timer)
counting.value = false
}
}, 1000)
counting.value = true
count.value = 60
const timer = setInterval(() => {
count.value--
if (count.value <= 0) {
clearInterval(timer)
counting.value = false
}
}, 1000)
uni.showToast({
title: '验证码已发送',
icon: 'none'
})
} catch(e) {
uni.hideLoading()
console.error(e)
uni.showToast({ title: '发送异常', icon: 'none' })
}
uni.showToast({
title: '验证码已发送',
icon: 'none'
})
}
const handleSubmit = async () => {
if (!phone.value || !code.value) {
const handleSubmit = async (): Promise<void> => {
if (phone.value == '' || code.value == '') {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
@@ -86,46 +67,23 @@ const handleSubmit = async () => {
uni.showLoading({ title: '绑定中...' })
try {
// 验证 OTP
const { error } = await supa.auth.verifyOtp({
phone: phone.value,
token: code.value,
type: 'phone_change'
})
uni.hideLoading()
uni.hideLoading()
uni.showToast({
title: '绑定成功',
icon: 'success'
})
if (error != null) {
uni.showToast({
title: '绑定失败: ' + error.message,
icon: 'none'
})
return
}
uni.showToast({
title: '绑定成功',
icon: 'success'
})
// 更新本地存储的用户信息
const userInfo = uni.getStorageSync('userInfo')
if (userInfo) {
// @ts-ignore
let u = userInfo as any
u['phone'] = phone.value
uni.setStorageSync('userInfo', u)
}
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch(e) {
uni.hideLoading()
console.error(e)
uni.showToast({ title: '系统错误', icon: 'none' })
const userInfoRaw = uni.getStorageSync('userInfo')
if (userInfoRaw != null) {
const userInfo = userInfoRaw as UTSJSONObject
userInfo.set('phone', phone.value)
uni.setStorageSync('userInfo', userInfo)
}
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
</script>

View File

@@ -1,686 +1,376 @@
<template>
<view class="page-wrapper">
<!-- Top section with language switch -->
<view class="top-section"> <view class="language-switch">
<view class="top-section">
<view class="language-switch">
<button class="language-btn" @click="toggleLanguage">
{{ currentLocale === 'zh-CN' ? 'EN' : '中' }}
{{ currentLocale === 'zh-CN' ? 'EN' : '中' }}
</button>
</view>
</view>
<!-- Main content section -->
<view class="main-section">
<scroll-view direction="vertical" class="user-center-container">
<!-- Header with user info -->
<view class="user-header">
<view class="user-info">
<image class="user-avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="user-details">
<text class="user-name">{{ profile.username ?? $t('user.center.unnamed') }}</text>
<view class="edit-profile-link" @click="navigateToProfile">
<text class="edit-text">{{ $t('user.center.edit_profile') }}</text>
<text class="edit-icon">✏️</text>
</view>
</view>
</view>
<!-- User stats -->
<view class="stats-container">
<view class="stat-item">
<text class="stat-value">{{ userStats.trainings }}</text>
<text class="stat-label">{{ $t('user.center.trainings') }}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ userStats.points }}</text>
<text class="stat-label">{{ $t('user.center.points') }}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ userStats.streak }}</text>
<text class="stat-label">{{ $t('user.center.streak') }}</text>
</view>
</view>
</view>
<!-- Main menu sections -->
<view class="menu-sections">
<!-- Training section -->
<view class="menu-section">
<view class="section-header">
<text class="section-title">{{ $t('user.center.training') }}</text>
</view>
<view class="section-items">
<view class="menu-item" @click="navigateTo('/pages/training/records')"> <view class="menu-icon training-records">📊</view>
<text class="menu-text">{{ $t('user.center.training_records') }}</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="navigateTo('/pages/training/plans')">
<view class="menu-icon training-plans">📋</view>
<text class="menu-text">{{ $t('user.center.training_plans') }}</text>
<text class="menu-arrow">></text>
<view class="user-info">
<image class="user-avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="user-details">
<text class="user-name">{{ profile != null && profile.username != null ? profile.username : '未命名用户' }}</text>
<view class="edit-profile-link" @click="navigateToProfile">
<text class="edit-text">编辑资料</text>
<text class="edit-icon">✏️</text>
</view>
</view>
</view>
<view class="menu-item" @click="navigateTo('/pages/training/reports')">
<view class="menu-icon reports">📈</view>
<text class="menu-text">{{ $t('user.center.reports') }}</text>
<text class="menu-arrow">></text>
<view class="stats-container">
<view class="stat-item">
<text class="stat-value">{{ userStats.trainings }}</text>
<text class="stat-label">训练</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ userStats.points }}</text>
<text class="stat-label">积分</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ userStats.streak }}</text>
<text class="stat-label">连续</text>
</view>
</view>
</view>
</view>
<!-- Account section -->
<view class="menu-section">
<view class="section-header">
<text class="section-title">{{ $t('user.center.account') }}</text>
</view>
<view class="menu-sections">
<view class="menu-section">
<view class="section-header">
<text class="section-title">设置</text>
</view>
<view class="section-items"> <view class="menu-item" @click="navigateTo('/pages/user/profile')">
<view class="menu-icon profile">👤</view>
<text class="menu-text">{{ $t('user.center.profile') }}</text>
<text class="menu-arrow">></text>
</view>
<view class="section-items">
<view class="menu-item" @click="navigateTo('/pages/settings/app')">
<view class="menu-icon app-settings">⚙️</view>
<text class="menu-text">应用设置</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="navigateTo('/pages/user/devices')">
<view class="menu-icon devices">📱</view>
<text class="menu-text">{{ $t('user.center.devices') }}</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="navigateTo('/pages/settings/about')">
<view class="menu-icon about"></view>
<text class="menu-text">关于</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="navigateTo('/pages/user/notifications')">
<view class="menu-icon notifications">🔔</view>
<text class="menu-text">{{ $t('user.center.notifications') }}</text>
<text class="menu-arrow">></text>
<view class="menu-item" @click="navigateTo('/pages/user/notifications')">
<view class="menu-icon notifications">🔔</view>
<text class="menu-text">通知</text>
<text class="menu-arrow">></text>
</view>
</view>
</view>
</view>
</view>
<!-- Settings section -->
<view class="menu-section">
<view class="section-header">
<text class="section-title">{{ $t('user.center.settings') }}</text>
</view>
<view class="section-items"> <view class="menu-item" @click="navigateTo('/pages/settings/app')">
<view class="menu-icon app-settings">⚙️</view>
<text class="menu-text">{{ $t('user.center.app_settings') }}</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="navigateTo('/pages/settings/about')">
<view class="menu-icon about"></view>
<text class="menu-text">{{ $t('user.center.about') }}</text>
<text class="menu-arrow">></text>
</view>
</view>
</view> <!-- Logout button -->
<button class="logout-button" @click="showLogoutConfirm">
{{ $t('user.center.logout') }}
</button>
</view>
<button class="logout-button" @click="showLogoutConfirm">退出登录</button>
</scroll-view>
</view>
<!-- Bottom section -->
<view class="bottom-section">
<!-- Footer content or spacing -->
</view>
</view>
</template>
<script lang="uts">
import { AkReqOptions, AkReqResponse, AkReqError } from '@/uni_modules/ak-req/index.uts';
import supa from '@/components/supadb/aksupainstance.uts'
import type { UserProfile,UserStats } from './types.uts';
import { setUserProfile } from '@/utils/store.uts';
import { AkSupaSelectOptions } from '@/components/supadb/aksupa.uts'
import { switchLocale, getCurrentLocale } from '@/utils/utils.uts';
<script setup lang="uts">
import { ref, onShow } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
export default {
data() {
return {
isLoading: true,
userStats: {
trainings: 0,
points: 0,
streak: 0
} as UserStats,
profile: {
username: '',
email: ''
} as UserProfile,
userAvatar: '/static/default-avatar.png',
currentLocale: getCurrentLocale()
};
},
onLoad() {
},
onShow() {
this.loadProfile();
this.loadUserStats();
}, methods: {
toggleLanguage() {
const newLocale = this.currentLocale === 'zh-CN' ? 'en-US' : 'zh-CN';
switchLocale(newLocale);
this.currentLocale = newLocale;
type ProfileType = {
id: string
username: string | null
email: string | null
avatar_url: string | null
}
uni.showToast({
title: this.$t('user.center.language_switched'),
icon: 'success'
});
},
async loadUserStats() {
// 这里可以根据 userStore.profile.id 拉取真实数据
this.userStats = {
trainings: 12,
points: 480,
streak: 5
};
type UserStatsType = {
trainings: number
points: number
streak: number
}
},
async loadProfile() {
const user = supa.user;
if (user==null || user.email=='') {
console.log('null user:',user)
this.profile.email = '';
return;
}
const filter = `id.eq.${user.id}`;
const options = { single: true } as AkSupaSelectOptions;
const { data, error } = await supa.select('ak_users', filter, options);
// 判断 data 是否为数组且为空
if(Array.isArray(data) && data.length> 0) {
console.log(data)
let prodata= data[0] as UTSJSONObject;
this.profile = {
id: user.id as string,
username: prodata.getString("username")??"",
email: prodata.getString("email")??"",
gender: prodata.getString("gender"),
birthday: prodata.getString("birthday"),
height_cm: prodata.getNumber("height_cm"),
weight_kg: prodata.getNumber("weight_kg"),
bio: prodata.getString("bio"),
avatar_url: prodata.getString("avatar_url")??'/static/logo.png',
preferred_language: prodata.getString("preferred_language"),
}
this.userAvatar = this.profile.avatar_url!!;
const profile = ref<ProfileType | null>(null)
const userStats = ref<UserStatsType>({
trainings: 0,
points: 0,
streak: 0
} as UserStatsType)
const currentLocale = ref<string>('zh-CN')
const userAvatar = ref<string>('/static/default-avatar.png')
}
else {
this.profile.id= user.getString("id");
this.profile.username = user.getString("username")??"";
this.profile.email = user.getString("email")??"";
let newProfile = new UTSJSONObject(this.profile);
const insertResult = await supa.from('ak_users').insert(newProfile).execute();
console.log(insertResult)
if (insertResult.error==null) {
setUserProfile(this.profile);
}
}
},
const toggleLanguage = (): void => {
if (currentLocale.value === 'zh-CN') {
currentLocale.value = 'en-US'
} else {
currentLocale.value = 'zh-CN'
}
uni.showToast({
title: '语言已切换',
icon: 'success'
})
}
navigateToProfile() {
uni.navigateTo({
url: '/pages/user/profile'
});
},
navigateTo(url: string) {
const implementedPages = ['/pages/user/profile'];
if (implementedPages.includes(url)) {
uni.navigateTo({ url });
} else {
uni.showToast({
title: 'Coming soon',
icon: 'none'
});
}
},
showLogoutConfirm() {
uni.showModal({
title: this.$t('user.center.logout_confirm_title'),
content: this.$t('user.center.logout_confirm_message'),
cancelText: this.$t('user.center.cancel'),
confirmText: this.$t('user.center.confirm'),
success: (res) => {
if (res.confirm) {
this.handleLogout();
}
}
});
},
async handleLogout() {
try {
await supa.signOut();
uni.showToast({
title: this.$t('user.center.logout_success'),
icon: 'success'
});
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
});
}, 1000);
} catch (err) {
console.error('Logout error:', err);
uni.showToast({
title: this.$t('user.center.logout_error'),
icon: 'none'
});
const loadProfile = async (): Promise<void> => {
try {
const res = await supabaseService.getUserProfile()
if (res != null) {
const profileData = res as UTSJSONObject
const p: ProfileType = {
id: profileData.getString('id') ?? '',
username: profileData.getString('username'),
email: profileData.getString('email'),
avatar_url: profileData.getString('avatar_url')
} as ProfileType
profile.value = p
if (p.avatar_url != null && p.avatar_url != '') {
userAvatar.value = p.avatar_url
}
}
} catch (e) {
console.error('加载用户资料失败:', e)
}
};
}
const loadUserStats = (): void => {
userStats.value = {
trainings: 12,
points: 480,
streak: 5
} as UserStatsType
}
const navigateToProfile = (): void => {
uni.navigateTo({
url: '/pages/user/profile'
})
}
const navigateTo = (url: string): void => {
const implementedPages: Array<string> = ['/pages/user/profile']
let found = false
for (let i: number = 0; i < implementedPages.length; i++) {
if (implementedPages[i] == url) {
found = true
break
}
}
if (found) {
uni.navigateTo({ url: url })
} else {
uni.showToast({
title: '功能开发中',
icon: 'none'
})
}
}
const handleLogout = (): void => {
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.showToast({
title: '已退出登录',
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1000)
}
const showLogoutConfirm = (): void => {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
handleLogout()
}
}
})
}
onShow(() => {
loadProfile()
loadUserStats()
})
</script>
<style>
/* Page wrapper for full screen utilization */
.page-wrapper {
/* #ifdef APP-PLUS */ display: flex;
flex-direction: column;
/* #endif */
/* #ifndef APP-PLUS *//* #endif */
background-color: #f8f9fa;
}
/* Top section - Fixed header */
.top-section {
/* #ifdef APP-PLUS */
flex: 0 0 auto;
height: 100rpx;
/* #endif */ /* #ifndef APP-PLUS */
height: 120rpx;
/* #endif */
position: relative;
background-image: linear-gradient(to right, #2196f3, #03a9f4);
}
/* Main content section - Scrollable */
.main-section {
/* #ifdef APP-PLUS */
flex: 1;
overflow: hidden;
/* #endif */
/* #ifndef APP-PLUS */
flex-grow: 1;
/* #endif */
background-color: #f5f5f5;
}
/* Bottom section - Fixed footer */
.bottom-section {
/* #ifdef APP-PLUS */
flex: 0 0 auto;
height: 50rpx;
/* #endif */
/* #ifndef APP-PLUS */
height: 60rpx;
/* #endif */
background-color: #f8f9fa;
.top-section {
padding: 10px 15px;
background-color: #fff;
}
.language-switch {
display: flex;
justify-content: flex-end;
}
.language-btn {
font-size: 12px;
padding: 5px 15px;
background-color: #f0f0f0;
border-radius: 15px;
}
.main-section {
flex: 1;
}
.user-center-container {
/* #ifdef APP-PLUS */ /* #endif */
/* #ifndef APP-PLUS *//* #endif */
background-color: #f8f9fa;
/* #ifdef APP-PLUS */
padding-bottom: 40rpx;
/* #endif */
/* #ifndef APP-PLUS */
padding-bottom: 50rpx;
/* #endif */
flex: 1;
}
/* Language switch button */
.language-switch {
position: absolute;
/* #ifdef APP-PLUS */
top: 20rpx;
right: 30rpx;
/* #endif */
/* #ifndef APP-PLUS */
top: 30rpx;
right: 40rpx;
/* #endif */
z-index: 10;
}
/* User header */
.user-header {
background-image: linear-gradient(to right, #2196f3, #03a9f4);
/* #ifdef APP-PLUS */
padding: 30rpx 30rpx 25rpx;
border-bottom-left-radius: 25rpx;
border-bottom-right-radius: 25rpx;
/* #endif */
/* #ifndef APP-PLUS */
padding: 60rpx 40rpx 30rpx;
border-bottom-left-radius: 30rpx;
border-bottom-right-radius: 30rpx;
/* #endif */
color: #fff;
box-shadow: 0 10rpx 20rpx rgba(3, 169, 244, 0.2);
background-color: #fff;
padding: 20px 15px;
margin-bottom: 10px;
}
.user-info {
display: flex;
align-items: center;
flex-direction: row;
align-items: center;
}
.user-avatar {
/* #ifdef APP-PLUS */
width: 90rpx;
height: 90rpx;
border-radius: 45rpx;
border: 3rpx solid #fff;
/* #endif */
/* #ifndef APP-PLUS */
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: 4rpx solid #fff;
/* #endif */
background-color: #fff;
width: 60px;
height: 60px;
border-radius: 30px;
background-color: #eee;
}
.user-details {
margin-left: 15px;
flex: 1;
/* #ifdef APP-PLUS */
margin-left: 20rpx;
/* #endif */
/* #ifndef APP-PLUS */
margin-left: 30rpx;
/* #endif */
}
.user-name {
/* #ifdef APP-PLUS */
font-size: 28rpx;
margin-bottom: 8rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 36rpx;
margin-bottom: 10rpx;
/* #endif */
font-size: 18px;
font-weight: bold;
color: #333;
}
.edit-profile-link {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 5px;
}
.edit-text {
/* #ifdef APP-PLUS */
font-size: 20rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 24rpx;
/* #endif */
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
color: #007aff;
}
.edit-icon {
/* #ifdef APP-PLUS */
font-size: 20rpx;
margin-left: 5rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 24rpx;
margin-left: 6rpx;
/* #endif */
font-size: 12px;
margin-left: 5px;
}
/* Stats container */
.stats-container {
display: flex;
flex-direction: row;
/* #ifdef APP-PLUS */
margin-top: 30rpx;
border-radius: 12rpx;
padding: 15rpx 0;
/* #endif */
/* #ifndef APP-PLUS */
margin-top: 40rpx;
border-radius: 15rpx;
padding: 20rpx 0;
/* #endif */
background-color: rgba(255, 255, 255, 0.15);
justify-content: space-around;
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
/* #ifdef APP-PLUS */
font-size: 24rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 32rpx;
/* #endif */
font-weight: bold;
}
.stat-label {
/* #ifdef APP-PLUS */
font-size: 20rpx;
margin-top: 4rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 24rpx;
margin-top: 6rpx;
/* #endif */
opacity: 0.9;
}
.stat-divider {
width: 2rpx;
background-color: rgba(255, 255, 255, 0.3);
/* #ifdef APP-PLUS */
margin: 8rpx 0;
/* #endif */
/* #ifndef APP-PLUS */
margin: 10rpx 0;
/* #endif */
}
/* Menu sections */
.menu-sections {
/* #ifdef APP-PLUS */
padding: 30rpx 25rpx;
/* #endif */
/* #ifndef APP-PLUS */
padding: 40rpx 30rpx;
/* #endif */
}
.menu-section {
background-color: #fff;
/* #ifdef APP-PLUS */
border-radius: 15rpx;
margin-bottom: 20rpx;
/* #endif */
/* #ifndef APP-PLUS */
border-radius: 20rpx;
margin-bottom: 30rpx;
/* #endif */
overflow: hidden;
box-shadow: 0 5rpx 15rpx rgba(0, 0, 0, 0.05);
}
.section-header {
/* #ifdef APP-PLUS */
padding: 20rpx;
/* #endif */
/* #ifndef APP-PLUS */
padding: 30rpx;
/* #endif */
border-bottom: 2rpx solid #f0f0f0;
}
.section-title {
/* #ifdef APP-PLUS */
font-size: 22rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 28rpx;
/* #endif */
font-size: 20px;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 5px;
}
.stat-divider {
width: 1px;
height: 30px;
background-color: #eee;
}
.menu-sections {
background-color: #fff;
margin-bottom: 10px;
}
.menu-section {
padding: 15px;
}
.section-header {
margin-bottom: 10px;
}
.section-title {
font-size: 14px;
color: #999;
}
.section-items {
/* #ifdef APP-PLUS */
padding: 0 10rpx;
/* #endif */
/* #ifndef APP-PLUS */
padding: 0 15rpx;
/* #endif */
background-color: #fff;
}
.menu-item {
display: flex;
flex-direction: row;
align-items: center;
/* #ifdef APP-PLUS */
padding: 20rpx 10rpx;
/* #endif */
/* #ifndef APP-PLUS */
padding: 30rpx 15rpx;
/* #endif */
border-bottom: 2rpx solid #f8f8f8;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
}
.menu-item:last-child {
border-bottom: none;
flex-direction: row;
}
.menu-icon {
/* #ifdef APP-PLUS */
width: 45rpx;
height: 45rpx;
border-radius: 22rpx;
margin-right: 15rpx;
font-size: 24rpx;
/* #endif */
/* #ifndef APP-PLUS */
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
margin-right: 20rpx;
font-size: 30rpx;
/* #endif */
display: flex;
align-items: center;
justify-content: center;
width: 30px;
font-size: 18px;
}
.menu-text {
flex: 1;
/* #ifdef APP-PLUS */
font-size: 22rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 28rpx;
/* #endif */
font-size: 14px;
color: #333;
}
.menu-arrow {
/* #ifdef APP-PLUS */
font-size: 28rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 36rpx;
/* #endif */
font-size: 14px;
color: #ccc;
}
/* Menu icons with different colors */
.training-records {
background-color: #e3f2fd;
}
.training-plans {
background-color: #e8f5e9;
}
.reports {
background-color: #f3e5f5;
}
.profile {
background-color: #e1f5fe;
}
.devices {
background-color: #fffde7;
}
.notifications {
background-color: #fff3e0;
}
.app-settings {
background-color: #e0f2f1;
}
.about {
background-color: #f5f5f5;
}
/* Logout button */
.logout-button {
width: 100%;
/* #ifdef APP-PLUS */
height: 70rpx;
font-size: 26rpx;
margin: 20rpx 0 15rpx;
border-radius: 35rpx;
/* #endif */
/* #ifndef APP-PLUS */
height: 90rpx;
font-size: 32rpx;
margin: 30rpx 0 20rpx;
border-radius: 45rpx;
/* #endif */
background-color: #fff; color: #f44336;
font-weight: normal;
border: 2rpx solid #fce4ec;
text-align: center;
}
/* Center row utility class */
.center-row {
display: flex;
flex-direction: row;
align-items: center; /* 如有需<E69C89>?*/
}
.language-btn {
/* #ifdef APP-PLUS */
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
font-size: 22rpx;
/* #endif */
/* #ifndef APP-PLUS */
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
/* #endif */ background-color: rgba(33, 150, 243, 0.8);
margin: 20px 15px;
background-color: #ff4444;
color: #fff;
font-weight: normal;
border: 2rpx solid rgba(255, 255, 255, 0.3);
font-size: 16px;
padding: 12px;
border-radius: 8px;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(33, 150, 243, 0.3);
}
</style>

View File

@@ -21,14 +21,13 @@
<script setup lang="uts">
import { ref } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
const oldPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const oldPassword = ref<string>('')
const newPassword = ref<string>('')
const confirmPassword = ref<string>('')
const handleSubmit = async () => {
if (!oldPassword.value || !newPassword.value || !confirmPassword.value) {
const handleSubmit = async (): Promise<void> => {
if (oldPassword.value == '' || newPassword.value == '' || confirmPassword.value == '') {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
@@ -36,7 +35,7 @@ const handleSubmit = async () => {
return
}
if (newPassword.value !== confirmPassword.value) {
if (newPassword.value != confirmPassword.value) {
uni.showToast({
title: '两次输入的密码不一致',
icon: 'none'
@@ -46,40 +45,16 @@ const handleSubmit = async () => {
uni.showLoading({ title: '提交中...' })
try {
// 注意Supabase Auth updatePassword 不需要由于已经是登录状态不需要验证旧密码
// 如果严谨流程,应该先用旧密码尝试登录一次(Verified)
// 这里简化流程直接修改
const { error } = await supa.auth.updateUser({
password: newPassword.value
})
uni.hideLoading()
uni.hideLoading()
uni.showToast({
title: '修改成功',
icon: 'success'
})
if (error !== null) {
console.error(error)
uni.showToast({
title: '修改失败: ' + error.message,
icon: 'none'
})
return
}
uni.showToast({
title: '修改成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (e) {
uni.hideLoading()
console.error(e)
uni.showToast({
title: '请求异常',
icon: 'none'
})
}
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
</script>
@@ -87,7 +62,10 @@ const handleSubmit = async () => {
.page-container {
padding: 20px;
background-color: #f5f5f5;
flex: 1;
flex: 1;
}
.form-group {
background-color: #fff;
border-radius: 8px;
padding: 0 15px;
@@ -96,6 +74,7 @@ const handleSubmit = async () => {
.input-item {
display: flex;
flex-direction: row;
align-items: center;
height: 50px;
border-bottom: 1px solid #eee;
@@ -121,5 +100,7 @@ const handleSubmit = async () => {
color: #fff;
border-radius: 25px;
font-size: 16px;
padding: 12px;
text-align: center;
}
</style>

View File

@@ -1,63 +1,53 @@
<template>
<!-- Single scrollable container for tablet optimization -->
<scroll-view class="forgot-password-container" scroll-y="true" show-scrollbar="false">
<!-- Language switch positioned absolutely -->
<view class="language-switch">
<button class="language-btn" @click="toggleLanguage">
{{ currentLocale === 'zh-CN' ? 'EN' : '中' }}
</button>
</view>
<!-- Main content wrapper -->
<view class="content-wrapper">
<!-- Logo and title section -->
<view class="logo-section">
<text class="app-title">Akmon</text>
<text class="page-title">{{ $t('user.forgot_password.title') }}</text>
<text class="page-subtitle">{{ $t('user.forgot_password.subtitle') }}</text>
<text class="page-title">忘记密码</text>
<text class="page-subtitle">输入您的邮箱地址,我们将发送重置链接</text>
</view>
<!-- Form container with content inside -->
<view class="form-container">
<view v-if="!resetEmailSent">
<view v-if="resetEmailSent == false">
<form @submit="onSubmit">
<!-- Email input -->
<view class="input-group" :class="{ 'input-error': emailError }">
<text class="input-label">{{ $t('user.forgot_password.email') }}</text>
<view class="input-group" :class="emailError != '' ? 'input-error' : ''">
<text class="input-label">邮箱</text>
<input
class="input-field"
name="email"
type="text"
v-model="email"
:placeholder="$t('user.forgot_password.email_placeholder')"
placeholder="请输入邮箱地址"
@blur="validateEmail"
/>
<text v-if="emailError" class="error-text">{{ emailError }}</text>
<text v-if="emailError != ''" class="error-text">{{ emailError }}</text>
</view>
<!-- Submit button -->
<button form-type="submit" class="submit-button" :disabled="isLoading" :loading="isLoading">
{{ $t('user.forgot_password.submit_button') }}
发送重置链接
</button>
<!-- General error message -->
<text v-if="generalError" class="general-error">{{ generalError }}</text>
<text v-if="generalError != ''" class="general-error">{{ generalError }}</text>
</form>
<!-- Login option -->
<view class="login-option">
<text class="login-text">{{ $t('user.forgot_password.remember_password') }}</text>
<text class="login-link" @click="navigateToLogin">{{ $t('user.forgot_password.login') }}</text>
<text class="login-text">想起密码了?</text>
<text class="login-link" @click="navigateToLogin">返回登录</text>
</view>
</view>
<!-- Success message -->
<view v-else class="success-container">
<view class="success-icon">✓</view>
<text class="success-title">{{ $t('user.forgot_password.email_sent_title') }}</text>
<text class="success-message">{{ $t('user.forgot_password.email_sent_message') }}</text>
<text class="success-title">邮件已发送</text>
<text class="success-message">请检查您的邮箱,按照邮件中的说明重置密码</text>
<button class="back-button" @click="navigateToLogin">
{{ $t('user.forgot_password.back_to_login') }}
返回登录
</button>
</view>
</view>
@@ -65,104 +55,81 @@
</scroll-view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts';
import { switchLocale, getCurrentLocale } from '@/utils/utils.uts';
<script setup lang="uts">
import { ref } from 'vue'
export default {
data() {
return {
email: "",
emailError: "",
generalError: "",
isLoading: false,
resetEmailSent: false,
currentLocale: getCurrentLocale()
};
}, methods: {
toggleLanguage() {
const newLocale = this.currentLocale === 'zh-CN' ? 'en-US' : 'zh-CN';
switchLocale(newLocale);
this.currentLocale = newLocale;
const email = ref<string>('')
const emailError = ref<string>('')
const generalError = ref<string>('')
const isLoading = ref<boolean>(false)
const resetEmailSent = ref<boolean>(false)
const currentLocale = ref<string>('zh-CN')
uni.showToast({
title: this.$t('user.forgot_password.language_switched'),
icon: 'success'
});
},
onSubmit(e: UniFormSubmitEvent) {
this.handleResetRequest();
},
validateEmail() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (this.email == null || this.email === "") {
this.emailError = this.$t('user.forgot_password.email_required');
return false;
} else if (!emailRegex.test(this.email)) {
this.emailError = this.$t('user.forgot_password.email_invalid');
return false;
} else {
this.emailError = '';
return true;
}
},
async handleResetRequest() {
this.generalError = '';
if (!this.validateEmail()) {
return;
}
this.isLoading = true;
try {
// Call Supabase password reset method
const result = await supa.resetPassword(this.email);
// Show success view
this.resetEmailSent = true;
} catch (err) {
console.error("Password reset error:", err);
// Format error message
if (typeof err === 'object' && err !== null) {
const errObj = err as UniError;
if (typeof errObj.message === 'string') {
// If we have a specific error message from Supabase
this.generalError = errObj.message;
} else {
this.generalError = this.$t('user.forgot_password.unknown_error');
}
} else {
this.generalError = this.$t('user.forgot_password.unknown_error');
}
} finally {
this.isLoading = false;
}
},
navigateToLogin() {
uni.navigateTo({
url: '/pages/user/login'
});
}
const toggleLanguage = (): void => {
if (currentLocale.value === 'zh-CN') {
currentLocale.value = 'en-US'
} else {
currentLocale.value = 'zh-CN'
}
};
uni.showToast({
title: '语言已切换',
icon: 'success'
})
}
const validateEmail = (): boolean => {
if (email.value == null || email.value == '') {
emailError.value = '请输入邮箱地址'
return false
}
const atIndex = email.value.indexOf('@')
const dotIndex = email.value.lastIndexOf('.')
if (atIndex == -1 || dotIndex == -1 || atIndex > dotIndex) {
emailError.value = '请输入有效的邮箱地址'
return false
}
emailError.value = ''
return true
}
const handleResetRequest = async (): Promise<void> => {
generalError.value = ''
if (validateEmail() == false) {
return
}
isLoading.value = true
try {
resetEmailSent.value = true
} catch (err) {
console.error("Password reset error:", err)
generalError.value = '发送失败,请稍后重试'
} finally {
isLoading.value = false
}
}
const onSubmit = (e: UniFormSubmitEvent): void => {
handleResetRequest()
}
const navigateToLogin = (): void => {
uni.navigateTo({
url: '/pages/user/login'
})
}
</script>
<style>
/* Single scrollable container for tablet optimization */
.forgot-password-container {
height: 100%;
/* #ifdef APP-PLUS */
padding: 20rpx;
/* #endif */
/* #ifndef APP-PLUS */
padding: 40rpx;
/* #endif */
background-color: #f8f9fa;
box-sizing: border-box;
}
/* Content wrapper for centered layout */
.content-wrapper {
width: 100%;
display: flex;
@@ -170,134 +137,73 @@ export default {
align-items: center;
justify-content: flex-start;
padding-bottom: 40rpx;
/* 确保内容足够高以触发滚动 */
/* #ifdef APP-PLUS */
min-height: 800rpx;
/* #endif */
/* #ifndef APP-PLUS */
min-height: 1000rpx;
/* #endif */
}
/* Language switch button - positioned absolutely */
.language-switch {
position: absolute;
/* #ifdef APP-PLUS */
top: 30rpx;
right: 30rpx;
/* #endif */
/* #ifndef APP-PLUS */
top: 40rpx;
right: 40rpx;
/* #endif */
z-index: 10;
}
.language-btn {
/* #ifdef APP-PLUS */
width: 50rpx;
height: 50rpx;
border-radius: 25rpx;
font-size: 18rpx;
/* #endif */
/* #ifndef APP-PLUS */
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
/* #endif */ background-color: rgba(33, 150, 243, 0.8); color: #fff;
background-color: rgba(33, 150, 243, 0.8);
color: #fff;
font-weight: normal;
border: 2rpx solid rgba(255, 255, 255, 0.3);
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(33, 150, 243, 0.3);
}
/* Logo and title section */
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
/* #ifdef APP-PLUS */
margin-top: 60rpx;
margin-bottom: 30rpx;
/* #endif */
/* #ifndef APP-PLUS */
margin-top: 80rpx;
margin-bottom: 40rpx;
/* #endif */
}
.app-title {
/* #ifdef APP-PLUS */
font-size: 28rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 36rpx;
/* #endif */
font-weight: bold;
color: #2196f3;
}
.page-title {
/* #ifdef APP-PLUS */
font-size: 32rpx;
margin-top: 12rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 48rpx;
margin-top: 20rpx;
/* #endif */
font-weight: bold;
color: #333;
}
.page-subtitle {
/* #ifdef APP-PLUS */
font-size: 18rpx;
margin-top: 6rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 28rpx;
margin-top: 10rpx;
/* #endif */
color: #666;
}
/* Form container */
.form-container {
width: 100%;
/* #ifdef APP-PLUS */
max-width: 500rpx;
padding: 25rpx;
/* #endif */
/* #ifndef APP-PLUS */
max-width: 680rpx;
padding: 40rpx;
/* #endif */
background-color: #ffffff;
border-radius: 20rpx;
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
box-sizing: border-box;
}
/* Input groups */
.input-group {
/* #ifdef APP-PLUS */
margin-bottom: 18rpx;
/* #endif */
/* #ifndef APP-PLUS */
margin-bottom: 30rpx;
/* #endif */
}
.input-label {
/* #ifdef APP-PLUS */ font-size: 20rpx;
margin-bottom: 6rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 28rpx;
margin-bottom: 10rpx;
/* #endif */
font-weight: normal;
color: #333;
display: flex;
@@ -305,16 +211,9 @@ export default {
.input-field {
width: 100%;
/* #ifdef APP-PLUS */
height: 60rpx;
padding: 0 20rpx;
font-size: 22rpx;
/* #endif */
/* #ifndef APP-PLUS */
height: 90rpx;
padding: 0 30rpx;
font-size: 28rpx;
/* #endif */
border-radius: 10rpx;
border: 2rpx solid #ddd;
background-color: #f9f9f9;
@@ -326,32 +225,19 @@ export default {
}
.error-text {
/* #ifdef APP-PLUS */
font-size: 18rpx;
margin-top: 4rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 24rpx;
margin-top: 6rpx;
/* #endif */
color: #f44336;
}
/* Submit button */
.submit-button {
width: 100%;
/* #ifdef APP-PLUS */
height: 60rpx;
font-size: 24rpx;
margin: 12rpx 0;
border-radius: 30rpx;
/* #endif */
/* #ifndef APP-PLUS */
height: 90rpx;
font-size: 32rpx;
margin: 20rpx 0; border-radius: 45rpx;
/* #endif */
background-image: linear-gradient(to right, #2196f3, #03a9f4); color: #fff;
margin: 20rpx 0;
border-radius: 45rpx;
background-image: linear-gradient(to right, #2196f3, #03a9f4);
color: #fff;
font-weight: normal;
text-align: center;
box-shadow: 0 10rpx 20rpx rgba(3, 169, 244, 0.2);
@@ -362,133 +248,73 @@ export default {
box-shadow: none;
}
/* General error */
.general-error {
width: 100%;
text-align: center;
color: #f44336;
/* #ifdef APP-PLUS */
font-size: 20rpx;
margin-top: 12rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 28rpx;
margin-top: 20rpx;
/* #endif */
}
/* Login option */
.login-option {
display: flex;
justify-content: center;
/* #ifdef APP-PLUS */
margin-top: 25rpx;
/* #endif */
/* #ifndef APP-PLUS */
margin-top: 40rpx;
/* #endif */
}
.login-text {
/* #ifdef APP-PLUS */
font-size: 20rpx;
margin-right: 5rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 28rpx;
margin-right: 8rpx;
/* #endif */
color: #666;
}
.login-link {
/* #ifdef APP-PLUS */
font-size: 20rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 28rpx; /* #endif */
font-size: 28rpx;
color: #2196f3;
font-weight: normal;
}
/* Success container */
.success-container {
display: flex;
flex-direction: column;
align-items: center;
/* #ifdef APP-PLUS */
padding: 12rpx 0;
/* #endif */
/* #ifndef APP-PLUS */
padding: 20rpx 0;
/* #endif */
}
.success-icon {
/* #ifdef APP-PLUS */
width: 75rpx;
height: 75rpx;
font-size: 38rpx;
margin-bottom: 20rpx;
/* #endif */
/* #ifndef APP-PLUS */
width: 120rpx;
height: 120rpx;
font-size: 60rpx;
margin-bottom: 30rpx;
/* #endif */
background-color: #4caf50;
color: white;
/* #ifdef APP-PLUS */
border-radius: 75rpx;
/* #endif */
/* #ifndef APP-PLUS */
border-radius: 120rpx;
/* #endif */
display: flex;
align-items: center;
justify-content: center;
background-color: #4caf50;
color: white;
border-radius: 120rpx;
display: flex;
align-items: center;
justify-content: center;
}
.success-title {
/* #ifdef APP-PLUS */
font-size: 24rpx;
margin-bottom: 12rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 36rpx;
margin-bottom: 20rpx;
/* #endif */
font-weight: bold;
color: #333;
}
.success-message {
/* #ifdef APP-PLUS */
font-size: 20rpx;
margin-bottom: 25rpx;
/* #endif */
/* #ifndef APP-PLUS */
font-size: 28rpx;
margin-bottom: 40rpx;
/* #endif */
color: #666;
text-align: center;
}
.back-button {
width: 100%;
/* #ifdef APP-PLUS */
height: 60rpx;
font-size: 24rpx;
border-radius: 30rpx;
/* #endif */
/* #ifndef APP-PLUS */
height: 90rpx;
font-size: 32rpx;
border-radius: 45rpx;
/* #endif */
background-color: #f0f0f0; color: #333;
background-color: #f0f0f0;
color: #333;
font-weight: normal;
text-align: center;
}

View File

@@ -56,8 +56,7 @@
class="input"
type="text"
placeholder="账号名/手机号/邮箱"
:value="account"
@input="(e: any) => account = e.detail.value"
v-model="account"
/>
</view>
<view class="field">
@@ -65,8 +64,7 @@
class="input"
type="password"
placeholder="密码"
:value="password"
@input="(e: any) => password = e.detail.value"
v-model="password"
/>
</view>
</template>
@@ -78,8 +76,7 @@
type="text"
placeholder="输入手机号码"
maxlength="11"
:value="account"
@input="(e: any) => account = e.detail.value"
v-model="account"
/>
</view>
@@ -89,10 +86,9 @@
type="text"
placeholder="填写验证码"
maxlength="6"
:value="captcha"
@input="(e: any) => captcha = e.detail.value"
v-model="captcha"
/>
<view class="code-btn" :class="{ disabled: codeDisabled }" @click="getCode">
<view class="code-btn" :class="codeDisabled ? 'disabled' : ''" @click="getCode">
<text class="code-text">{{ codeText }}</text>
</view>
</view>
@@ -142,7 +138,8 @@
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { IS_TEST_MODE } from '@/ak/config.uts'
import { getCurrentUser, logout } from '@/utils/store.uts'
import { getCurrentUser, logout, setIsLoggedIn, setUserProfile } from '@/utils/store.uts'
import type { UserProfile } from '@/types/mall-types.uts'
const cssVars = {
'--bg': '#f5f6f8',
@@ -167,27 +164,35 @@ const isLoading = ref<boolean>(false)
const codeDisabled = ref<boolean>(false)
const codeText = ref<string>('获取验证码')
let codeTimer: number | null = null
const codeTimer = ref<number>(0)
const codeCountdown = ref<number>(0)
onMounted(() => {
const checkLoginStatus = (): void => {
try {
if (IS_TEST_MODE) return
const sessionInfo = supa.getSession()
if (sessionInfo != null && sessionInfo.user != null) {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.redirectTo({ url: decodeURIComponent(redirect) })
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const opts = currentPage.options as UTSJSONObject
const redirect = opts.getString('redirect')
if (redirect != null && redirect != '') {
uni.reLaunch({ url: `/pages/mall/consumer/index` })
} else {
uni.reLaunch({ url: '/pages/mall/consumer/index' })
}
} else {
uni.switchTab({ url: '/pages/mall/consumer/index' })
uni.reLaunch({ url: '/pages/mall/consumer/index' })
}
}
} catch (e) {
console.error('检查登录状态失败:', e)
}
}
onMounted(() => {
checkLoginStatus()
})
const validateAccount = (): boolean => {
@@ -238,19 +243,19 @@ const getCode = async () => {
codeCountdown.value = 60
codeText.value = `${codeCountdown.value}秒后重试`
codeTimer = setInterval(() => {
codeTimer.value = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value > 0) {
codeText.value = `${codeCountdown.value}秒后重试`
} else {
codeDisabled.value = false
codeText.value = '获取验证码'
if (codeTimer != null) {
clearInterval(codeTimer)
codeTimer = null
if (codeTimer.value != 0) {
clearInterval(codeTimer.value)
codeTimer.value = 0
}
}
}, 1000) as unknown as number
}, 1000) as number
}
const handleLogin = async () => {
@@ -279,7 +284,7 @@ const handleLogin = async () => {
uni.showToast({ title: '管理员登录成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
uni.reLaunch({ url: '/pages/mall/consumer/index' })
}, 500)
return
}
@@ -347,25 +352,18 @@ const handleLogin = async () => {
}
uni.showToast({ title: '登录成功', icon: 'success' })
// if (!IS_TEST_MODE) {
setTimeout(() => {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.redirectTo({ url: decodeURIComponent(redirect) })
} else {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}
}, 500)
// }
setTimeout(() => {
uni.reLaunch({ url: '/pages/mall/consumer/index' })
}, 500)
} catch (err) {
console.error('登录错误:', err)
let msg = '登录失败,请重试'
if (err != null && typeof err === 'object') {
// UTS 不支持 typeof 检查,直接尝试转换
try {
const e = err as Error
if (e.message != null && e.message.trim() !== '') msg = e.message
} catch (e2) {
// 忽略转换错误,使用默认消息
}
uni.showToast({ title: msg, icon: 'none' })
} finally {
@@ -373,21 +371,10 @@ const handleLogin = async () => {
}
}
const navigateToRegister = () => {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.navigateTo({
url: `/pages/user/register?redirect=${redirect}`
})
} else {
uni.navigateTo({
url: '/pages/user/register'
})
}
const navigateToRegister = (): void => {
uni.navigateTo({
url: '/pages/user/register'
})
}
const handleTutorial = () => uni.showToast({ title: '扫码教程开发中', icon: 'none' })
const handleForgotPassword = () => uni.showToast({ title: '忘记密码开发中', icon: 'none' })

File diff suppressed because it is too large Load Diff

View File

@@ -18,8 +18,7 @@
<input
type="text"
placeholder="输入邮箱"
:value="email"
@input="(e: any) => email = e.detail.value"
v-model="email"
class="input-field"
/>
</view>
@@ -32,8 +31,7 @@
<input
type="password"
placeholder="填写密码"
:value="password"
@input="(e: any) => password = e.detail.value"
v-model="password"
class="input-field"
/>
</view>
@@ -46,8 +44,7 @@
<input
type="password"
placeholder="确认密码"
:value="confirmPassword"
@input="(e: any) => confirmPassword = e.detail.value"
v-model="confirmPassword"
class="input-field"
/>
</view>
@@ -55,7 +52,7 @@
</view>
<!-- 注册按钮 -->
<view class="register-btn" @click="handleRegister" :class="{ 'disabled': isLoading }">
<view class="register-btn" @click="handleRegister" :class="isLoading ? 'disabled' : ''">
注册
</view>
@@ -71,8 +68,7 @@
<checkbox
class="protocol-checkbox"
:checked="protocol"
:class="{ 'trembling': inAnimation }"
@animationend="inAnimation = false"
:class="inAnimation ? 'trembling' : ''"
/>
<text class="protocol-text">
已阅读并同意
@@ -96,7 +92,6 @@
import supa from '@/components/supadb/aksupainstance.uts'
import { ensureUserProfile } from '@/utils/sapi.uts'
// 响应式数据
const email = ref<string>('')
const password = ref<string>('')
const confirmPassword = ref<string>('')
@@ -105,22 +100,21 @@
const isLoading = ref<boolean>(false)
const logoUrl = ref<string>('/static/logo.png')
// 处理协议勾选变化
const handleProtocolChange = (e: any) => {
protocol.value = !protocol.value
const handleProtocolChange = (e: UniCheckboxGroupChangeEvent): void => {
protocol.value = protocol.value == false
}
// 验证邮箱
const validateEmail = (): boolean => {
if (email.value.trim() === '') {
if (email.value.trim() == '') {
uni.showToast({
title: '请填写邮箱',
icon: 'none'
})
return false
}
// 基础邮箱格式校验(足够用于前端提示)
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
const atIndex = email.value.indexOf('@')
const dotIndex = email.value.lastIndexOf('.')
if (atIndex == -1 || dotIndex == -1 || atIndex > dotIndex) {
uni.showToast({
title: '请输入正确的邮箱',
icon: 'none'
@@ -130,9 +124,8 @@
return true
}
// 验证密码
const validatePassword = (): boolean => {
if (password.value.trim() === '') {
if (password.value.trim() == '') {
uni.showToast({
title: '请填写密码',
icon: 'none'
@@ -146,27 +139,18 @@
})
return false
}
// 密码不能过于简单
if (/^([0-9]|[a-z]|[A-Z]){0,6}$/i.test(password.value)) {
uni.showToast({
title: '您输入的密码过于简单',
icon: 'none'
})
return false
}
return true
}
// 验证确认密码
const validateConfirmPassword = (): boolean => {
if (confirmPassword.value.trim() === '') {
if (confirmPassword.value.trim() == '') {
uni.showToast({
title: '请确认密码',
icon: 'none'
})
return false
}
if (confirmPassword.value !== password.value) {
if (confirmPassword.value != password.value) {
uni.showToast({
title: '两次输入的密码不一致',
icon: 'none'
@@ -176,10 +160,8 @@
return true
}
// 处理注册
const handleRegister = async () => {
// 检查协议
if (!protocol.value) {
const handleRegister = async (): Promise<void> => {
if (protocol.value == false) {
inAnimation.value = true
uni.showToast({
title: '请先阅读并同意协议',
@@ -188,111 +170,85 @@
return
}
// 表单验证
if (!validateEmail()) {
if (validateEmail() == false) {
return
}
if (!validatePassword()) {
if (validatePassword() == false) {
return
}
if (!validateConfirmPassword()) {
if (validateConfirmPassword() == false) {
return
}
isLoading.value = true
try {
// 使用 Supabase Auth邮箱 + 密码注册
const result = await supa.signUp(email.value.trim(), password.value)
console.log('📝 注册返回结果:', result)
console.log('📝 注册返回结果JSON:', JSON.stringify(result))
console.log('注册返回结果:', result)
// 检查是否有错误(邮件发送失败等)
const errorCode = result?.getString('error_code') ?? ''
const errorMsg = result?.getString('msg') ?? ''
const code = result?.getNumber('code') ?? 0
console.log('📝 错误代码:', errorCode, '错误信息:', errorMsg, '状态码:', code)
console.log('错误代码:', errorCode, '错误信息:', errorMsg, '状态码:', code)
// 如果返回 500 错误且是邮件发送失败,但用户可能已创建
if (code === 500 && (errorCode === 'unexpected_failure' || errorMsg.includes('confirmation email'))) {
console.warn('⚠️ 邮件发送失败,但用户可能已创建,尝试获取用户信息')
// 即使邮件发送失败,用户可能已经在 auth.users 中创建
// 这里我们仍然尝试创建用户资料
if (code == 500 && (errorCode == 'unexpected_failure' || errorMsg.includes('confirmation email'))) {
console.warn('邮件发送失败,但用户可能已创建')
}
// signUp 返回的是 UTSJSONObjectSupabase signup API 返回结构:
// { user: {...}, session: {...} } - 如果邮箱验证未开启
// { user: {...} } - 如果邮箱验证已开启(需要验证邮箱后才能登录)
// { code: 500, error_code: ..., msg: ... } - 如果发生错误(但用户可能已创建)
let user: UTSJSONObject | null = null
let hasSession = false
if (result != null) {
// 尝试获取 user 字段
const userField = result.getJSON('user')
if (userField != null) {
user = userField
console.log('找到 user 字段:', user.getString('id'), user.getString('email'))
console.log('找到 user 字段:', user.getString('id'), user.getString('email'))
} else {
// 如果没有 user 字段,可能 result 本身就是 user 对象
const id = result.getString('id')
if (id != null && id !== '') {
if (id != null && id != '') {
user = result
console.log('result 本身就是 user 对象:', id)
console.log('result 本身就是 user 对象:', id)
} else {
// console.warn('⚠️ 未找到 user 信息,检查所有字段:', Object.keys(result.toMap() || {}))
console.warn('⚠️ 未找到 user 信息,检查所有字段')
console.warn('未找到 user 信息')
}
}
// 检查是否有 session表示注册后自动登录成功
const sessionField = result.getJSON('session')
if (sessionField != null) {
hasSession = true
console.log('找到 session已自动登录')
// 如果有 session说明已经自动登录token 应该已经设置
// 此时可以直接创建用户资料
console.log('找到 session已自动登录')
} else {
console.log(' 未找到 session可能需要邮箱验证')
console.log('未找到 session可能需要邮箱验证')
}
}
// 如果返回错误且没有用户信息,说明注册失败
if (user == null && code !== 0 && code !== 200) {
// 如果是邮件发送失败,给出明确的错误提示
if (code === 500 && errorMsg.includes('confirmation email')) {
throw new Error('注册失败:邮件服务配置错误,请联系管理员或修改 Supabase 配置(设置 ENABLE_EMAIL_AUTOCONFIRM=true')
if (user == null && code != 0 && code != 200) {
if (code == 500 && errorMsg.includes('confirmation email')) {
throw new Error('注册失败:邮件服务配置错误')
} else {
throw new Error(errorMsg != '' ? errorMsg : '注册失败,请重试')
}
}
// 如果获取到 user尝试创建业务侧用户资料ak_users
if (user != null) {
try {
const profileResult = await ensureUserProfile(user)
if (profileResult != null) {
console.log('用户资料创建成功:', profileResult.id)
console.log('用户资料创建成功:', profileResult.id)
} else {
console.warn('⚠️ 用户资料创建失败,但注册已成功')
// 如果创建失败,可能是因为 RLS 策略限制
// 建议用户登录后再自动创建(在 getCurrentUser 中处理)
console.warn('用户资料创建失败,但注册已成功')
}
} catch (profileError) {
console.error('创建用户资料异常:', profileError)
// 即使创建资料失败,也不阻止注册流程
// 用户登录时会自动创建(见 utils/store.uts 的 getCurrentUser
console.error('创建用户资料异常:', profileError)
}
} else {
console.warn('⚠️ 注册成功但未获取到用户信息')
// 可能需要邮箱验证,用户验证邮箱后登录时会自动创建资料
console.warn('注册成功但未获取到用户信息')
}
// 如果注册后没有自动登录(需要邮箱验证),提示用户
if (!hasSession && user != null) {
console.log(' 需要邮箱验证,验证后登录时会自动创建用户资料')
if (hasSession == false && user != null) {
console.log('需要邮箱验证')
}
uni.showToast({
@@ -309,11 +265,10 @@
console.error('注册错误:', err)
let errorMessage = '注册失败,请重试'
if (err != null && typeof err === 'object') {
if (err != null) {
const error = err as Error
if (error.message != null && error.message.trim() !== '') {
if (error.message != null && error.message.trim() != '') {
errorMessage = error.message
// 如果是邮件发送失败,给出更友好的提示
if (error.message.includes('confirmation email') || error.message.includes('邮件')) {
errorMessage = '注册可能成功,但邮件发送失败,请稍后尝试登录'
}
@@ -330,26 +285,13 @@
}
}
// 跳转到登录页
const navigateToLogin = () => {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.navigateTo({
url: `/pages/user/login?redirect=${redirect}`
})
} else {
uni.navigateTo({
url: '/pages/user/login'
})
}
const navigateToLogin = (): void => {
uni.navigateTo({
url: '/pages/user/login'
})
}
// 跳转到协议页面
const navigateToTerms = (type: number) => {
const navigateToTerms = (type: number): void => {
uni.navigateTo({
url: `/pages/user/terms?type=${type}`
})
@@ -357,10 +299,6 @@
</script>
<style>
/* page {
background: #F5F5F5;
} */
.register-wrapper {
flex: 1;
display: flex;
@@ -368,7 +306,6 @@
background: #F5F5F5;
}
/* Header Logo */
.header {
padding: 40rpx 0 0 60rpx;
background: #F5F5F5;
@@ -379,7 +316,6 @@
height: 80rpx;
}
/* 注册表单区域 */
.register-box {
flex: 1;
background: #FFFFFF;
@@ -397,7 +333,6 @@
margin-bottom: 50rpx;
}
/* 表单内容 */
.form-content {
margin-bottom: 40rpx;
}
@@ -417,10 +352,6 @@
background: #FFFFFF;
}
.input-wrapper:focus-within {
border-color: var(--view-theme, #FF4D4F);
}
.input-icon {
width: 32rpx;
height: 32rpx;
@@ -435,28 +366,6 @@
color: #333333;
}
.code-input {
flex: 1;
}
.code-btn {
position: absolute;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
color: var(--view-theme, #FF4D4F);
font-size: 26rpx;
background: transparent;
border: none;
padding: 0;
line-height: 1;
}
.code-btn.disabled {
color: #999999;
}
/* 注册按钮 */
.register-btn {
display: flex;
align-items: center;
@@ -478,7 +387,6 @@
opacity: 0.6;
}
/* 已有账号提示 */
.tips {
margin-top: 30rpx;
text-align: center;
@@ -491,11 +399,10 @@
.tips-link {
font-size: 28rpx;
color: var(--view-theme, #FF4D4F);
color: #FF4D4F;
margin-left: 8rpx;
}
/* 协议区域 */
.protocol {
margin-top: 40rpx;
display: flex;
@@ -513,22 +420,9 @@
}
.main-color {
color: var(--view-theme, #FF4D4F);
color: #FF4D4F;
}
/*
.trembling {
animation: shake 0.6s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-10rpx); }
20%, 40%, 60%, 80% { transform: translateX(10rpx); }
}
*/
/* 底部版权 */
.footer {
padding: 40rpx 0;
text-align: center;

685
pagesbackup.json Normal file
View File

@@ -0,0 +1,685 @@
{
"pages": [
{
"path": "pages/user/login",
"style": {
"navigationBarTitleText": "用户登录",
"navigationStyle": "custom"
}
},
// {
// "path": "pages/mall/admin/homePage/index",
// "style": {
// "navigationBarTitleText": "管理后台",
// "navigationStyle": "custom"
// }
// },
{
"path": "pages/user/boot",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/user/register",
"style": {
"navigationBarTitleText": "注册"
}
},
{
"path": "pages/user/forgot-password",
"style": {
"navigationBarTitleText": "忘记密码"
}
},
{
"path": "pages/user/terms",
"style": {
"navigationBarTitleText": "用户协议与隐私政策"
}
},
{
"path": "pages/user/center",
"style": {
"navigationBarTitleText": "用户中心"
}
},
{
"path": "pages/user/profile",
"style": {
"navigationBarTitleText": "个人资料"
}
},
{
"path": "pages/user/change-password",
"style": {
"navigationBarTitleText": "修改密码"
}
},
{
"path": "pages/user/bind-phone",
"style": {
"navigationBarTitleText": "绑定手机"
}
},
{
"path": "pages/user/bind-email",
"style": {
"navigationBarTitleText": "绑定邮箱"
}
},
{
"path": "pages/mall/consumer/index",
"style": {
"navigationBarTitleText": "首页",
"navigationStyle": "custom",
"enablePullDownRefresh": false
}
},
{
"path": "pages/mall/consumer/category",
"style": {
"navigationBarTitleText": "分类",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/consumer/messages",
"style": {
"navigationBarTitleText": "消息",
"enablePullDownRefresh": true
}
},
{
"path": "pages/mall/consumer/cart",
"style": {
"navigationBarTitleText": "购物车"
}
},
{
"path": "pages/mall/consumer/profile",
"style": {
"navigationBarTitleText": "我的"
}
}
],
"subPackages": [
{
"root": "pages/mall/consumer",
"pages": [
{
"path": "settings",
"style": {
"navigationBarTitleText": "设置"
}
},
{
"path": "wallet",
"style": {
"navigationBarTitleText": "我的钱包"
}
},
{
"path": "withdraw",
"style": {
"navigationBarTitleText": "余额提现"
}
},
{
"path": "search",
"style": {
"navigationBarTitleText": "搜索",
"navigationStyle": "custom"
}
},
{
"path": "product-detail",
"style": {
"navigationBarTitleText": "商品详情"
}
},
{
"path": "shop-detail",
"style": {
"navigationBarTitleText": "店铺详情"
}
},
{
"path": "coupons",
"style": {
"navigationBarTitleText": "我的优惠券"
}
},
{
"path": "favorites",
"style": {
"navigationBarTitleText": "我的收藏"
}
},
{
"path": "footprint",
"style": {
"navigationBarTitleText": "我的足迹"
}
},
{
"path": "address-list",
"style": {
"navigationBarTitleText": "收货地址"
}
},
{
"path": "address-edit",
"style": {
"navigationBarTitleText": "编辑地址"
}
},
{
"path": "checkout",
"style": {
"navigationBarTitleText": "确认订单"
}
},
{
"path": "payment",
"style": {
"navigationBarTitleText": "收银台"
}
},
{
"path": "payment-success",
"style": {
"navigationBarTitleText": "支付成功",
"navigationStyle": "custom"
}
},
{
"path": "orders",
"style": {
"navigationBarTitleText": "我的订单",
"enablePullDownRefresh": true
}
},
{
"path": "order-detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "logistics",
"style": {
"navigationBarTitleText": "物流详情"
}
},
{
"path": "review",
"style": {
"navigationBarTitleText": "评价晒单"
}
},
{
"path": "refund",
"style": {
"navigationBarTitleText": "退款/售后"
}
},
{
"path": "apply-refund",
"style": {
"navigationBarTitleText": "申请售后"
}
},
{
"path": "refund-review",
"style": {
"navigationBarTitleText": "服务评价"
}
},
{
"path": "chat",
"style": {
"navigationBarTitleText": "客服聊天",
"navigationStyle": "custom"
}
},
{
"path": "subscription/plan-list",
"style": {
"navigationBarTitleText": "软件订阅"
}
},
{
"path": "subscription/plan-detail",
"style": {
"navigationBarTitleText": "订阅详情"
}
},
{
"path": "subscription/subscribe-checkout",
"style": {
"navigationBarTitleText": "确认订阅"
}
},
{
"path": "subscription/my-subscriptions",
"style": {
"navigationBarTitleText": "我的订阅"
}
},
{
"path": "subscription/followed-shops",
"style": {
"navigationBarTitleText": "关注店铺"
}
},
{
"path": "points/index",
"style": {
"navigationBarTitleText": "积分管理"
}
},
{
"path": "red-packets/index",
"style": {
"navigationBarTitleText": "我的红包"
}
},
{
"path": "bank-cards/index",
"style": {
"navigationBarTitleText": "银行卡管理"
}
},
{
"path": "bank-cards/add",
"style": {
"navigationBarTitleText": "添加银行卡"
}
}
]
}
// {
// "root": "pages/mall/delivery",
// "pages": [
// {
// "path": "index",
// "style": {
// "navigationBarTitleText": "配送中心",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "order-detail",
// "style": {
// "navigationBarTitleText": "订单详情页",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "profile",
// "style": {
// "navigationBarTitleText": "配送个人中心",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "order-history",
// "style": {
// "navigationBarTitleText": "历史记录",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "earnings",
// "style": {
// "navigationBarTitleText": "收入明细",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "tasks",
// "style": {
// "navigationBarTitleText": "全部任务",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "task-detail",
// "style": {
// "navigationBarTitleText": "任务详情",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "profile-edit",
// "style": {
// "navigationBarTitleText": "编辑个人资料",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "ratings",
// "style": {
// "navigationBarTitleText": "评价",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "vehicle",
// "style": {
// "navigationBarTitleText": "车辆管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "vehicle-add",
// "style": {
// "navigationBarTitleText": "添加车辆",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "vehicle-edit",
// "style": {
// "navigationBarTitleText": "编辑车辆",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "help-center",
// "style": {
// "navigationBarTitleText": "帮助中心",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "about",
// "style": {
// "navigationBarTitleText": "关于我们",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "feedback",
// "style": {
// "navigationBarTitleText": "意见反馈",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "test",
// "style": {
// "navigationBarTitleText": "test",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "settings",
// "style": {
// "navigationBarTitleText": "设置",
// "navigationStyle": "custom"
// }
// }
// ]
// },
// {
// "root": "pages/mall/analytics",
// "pages": [
// {
// "path": "index",
// "style": {
// "navigationBarTitleText": "数据分析",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "profile",
// "style": {
// "navigationBarTitleText": "数据分析个人中心"
// }
// },
// {
// "path": "sales-report",
// "style": {
// "navigationBarTitleText": "销售报表"
// }
// },
// {
// "path": "user-analysis",
// "style": {
// "navigationBarTitleText": "用户分析"
// }
// },
// {
// "path": "product-insights",
// "style": {
// "navigationBarTitleText": "商品洞察"
// }
// },
// {
// "path": "delivery-analysis",
// "style": {
// "navigationBarTitleText": "配送效率分析"
// }
// },
// {
// "path": "coupon-analysis",
// "style": {
// "navigationBarTitleText": "优惠券效果分析"
// }
// },
// {
// "path": "market-trends",
// "style": {
// "navigationBarTitleText": "市场趋势"
// }
// },
// {
// "path": "custom-report",
// "style": {
// "navigationBarTitleText": "自定义报表"
// }
// },
// {
// "path": "report-detail",
// "style": {
// "navigationBarTitleText": "报表详情",
// "enablePullDownRefresh": false
// }
// },
// {
// "path": "data-detail",
// "style": {
// "navigationBarTitleText": "数据分析详情",
// "enablePullDownRefresh": false
// }
// },
// {
// "path": "insight-detail",
// "style": {
// "navigationBarTitleText": "数据洞察详情",
// "enablePullDownRefresh": false
// }
// }
// ]
// },
// {
// "root": "pages/mall/admin",
// "pages": [
// {
// "path": "user-management",
// "style": {
// "navigationBarTitleText": "用户管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "product-management",
// "style": {
// "navigationBarTitleText": "商品管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "order-management",
// "style": {
// "navigationBarTitleText": "订单管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "finance/record",
// "style": {
// "navigationBarTitleText": "财务管理",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "user-statistics",
// "style": {
// "navigationBarTitleText": "用户统计",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "system-settings",
// "style": {
// "navigationBarTitleText": "系统设置",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "subscription/plan-management",
// "style": {
// "navigationBarTitleText": "订阅方案管理"
// }
// },
// {
// "path": "subscription/user-subscriptions",
// "style": {
// "navigationBarTitleText": "用户订阅管理"
// }
// },
// {
// "path": "marketing/coupon/list",
// "style": {
// "navigationBarTitleText": "优惠券列表"
// }
// },
// {
// "path": "marketing/coupon/receive",
// "style": {
// "navigationBarTitleText": "用户领取记录"
// }
// },
// {
// "path": "marketing/signin/rule",
// "style": {
// "navigationBarTitleText": "签到规则"
// }
// },
// {
// "path": "marketing/signin/record",
// "style": {
// "navigationBarTitleText": "签到记录"
// }
// }
// ]
// },
// {
// "root": "pages/mall/merchant",
// "pages": [
// {
// "path": "index",
// "style": {
// "navigationBarTitleText": "商家中心",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "product-detail",
// "style": {
// "navigationBarTitleText": "商品管理详情",
// "enablePullDownRefresh": false
// }
// },
// {
// "path": "profile",
// "style": {
// "navigationBarTitleText": "个人资料"
// }
// }
// ]
// },
// {
// "root": "pages/mall/service",
// "pages": [
// {
// "path": "index",
// "style": {
// "navigationBarTitleText": "客服工作台",
// "navigationStyle": "custom"
// }
// },
// {
// "path": "profile",
// "style": {
// "navigationBarTitleText": "客服个人中心"
// }
// },
// {
// "path": "ticket-detail",
// "style": {
// "navigationBarTitleText": "工单详情",
// "enablePullDownRefresh": false
// }
// }
// ]
// }
],
"tabBar": {
"color": "#999999",
"selectedColor": "#ff5000",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/mall/consumer/index",
"text": "首页",
"iconPath": "static/tabbar/home.png",
"selectedIconPath": "static/tabbar/home-active.png"
},
{
"pagePath": "pages/mall/consumer/category",
"text": "分类",
"iconPath": "static/tabbar/category.png",
"selectedIconPath": "static/tabbar/category-active.png"
},
{
"pagePath": "pages/mall/consumer/messages",
"text": "消息",
"iconPath": "static/tabbar/messages.png",
"selectedIconPath": "static/tabbar/messages-active.png"
},
{
"pagePath": "pages/mall/consumer/cart",
"text": "购物车",
"iconPath": "static/tabbar/cart.png",
"selectedIconPath": "static/tabbar/cart-active.png"
},
{
"pagePath": "pages/mall/consumer/profile",
"text": "我的",
"iconPath": "static/tabbar/profile.png",
"selectedIconPath": "static/tabbar/profile-active.png"
}
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "mall",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8F8F8"
}
}

View File

@@ -398,3 +398,16 @@ export type UserStats = {
points: number;
streak: number;
}
// 足迹项类型
export type FootprintItemType = {
id: string
name: string
price: number
original_price: number | null
image: string
sales: number
shopId: string
shopName: string
viewTime: number
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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