Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21646b8797 | ||
|
|
ff77ba16cc | ||
|
|
f42ea5b270 | ||
|
|
3abe4ce9c9 | ||
|
|
fc5df9c048 | ||
|
|
5c8fbddb2e | ||
|
|
750c36116d | ||
|
|
20d059443c | ||
|
|
898e239463 | ||
|
|
dcc32b3d8f | ||
|
|
fb178d304f | ||
|
|
63ff3edb11 | ||
|
|
1b45f9dac6 | ||
|
|
c443109869 | ||
|
|
53305757f8 | ||
|
|
dfe0d6bd95 | ||
|
|
88f5371ed8 |
@@ -158,3 +158,6 @@ dist
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
@@ -1,3 +1,65 @@
|
||||
# points-mall
|
||||
|
||||
积分商城-供应商模块-订单操作模块
|
||||
积分商城供应商项目,对接 zhgh 工会平台,包含商城 H5、供应商管理后台及业务服务。
|
||||
|
||||
## 项目组成
|
||||
|
||||
| 目录 | 用途 | 技术栈 |
|
||||
| --- | --- | --- |
|
||||
| `supplied-h5` | 商品浏览、购物车、下单支付、订单与售后、商家处理台 | Vue 3、Vant 3、Pinia、Vue Router 4、Vue CLI |
|
||||
| `supplied-admin` | 商品管理、订单管理、售后处理、订单对账 | Vue 3、Element Plus、Pinia、Vue Router 4、Vite |
|
||||
| `supplied-service` | 商城接口、管理端接口、工会平台对接、文件处理 | Koa、TypeScript、Prisma、MySQL、Redis、MinIO |
|
||||
|
||||
H5 和管理端的 Vue 版本声明均为 `^3.5.13`,H5 的 Vant 版本声明为 `^3.6.16`。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 商城 H5:商品与规格选择、购物车、地址管理、下单支付、订单查询及售后申请。
|
||||
- 管理后台:账号登录、商品维护、订单筛选与分页、确认发货、重新推送拆单、售后审核与处理、订单对账。
|
||||
- 平台对接:供应商授权、订单事件推送、对账及发票相关接口。
|
||||
- 调试日志:查看供应商后端与 zhgh 之间的请求记录。
|
||||
|
||||
## 本地访问入口
|
||||
|
||||
以下为默认开发地址,实际地址以端口和环境配置为准。
|
||||
|
||||
| 入口 | 地址 |
|
||||
| --- | --- |
|
||||
| 管理端登录 | `http://localhost:5173/login` |
|
||||
| 管理端订单列表 | `http://localhost:5173/orders/list` |
|
||||
| 商城 H5 | `http://localhost:3001/` |
|
||||
| 商城 API | `http://localhost:8888/api` |
|
||||
| 管理端 API | `http://localhost:8888/api/admin` |
|
||||
|
||||
### 商家处理与日志
|
||||
|
||||
- 商家处理台:`http://localhost:3001/#/joyful-selection/merchant`
|
||||
- 供应商日志页面:`http://localhost:3001/#/joyful-selection/supplier-logs`
|
||||
- 供应商日志接口:`GET http://localhost:8888/api/supplier-logs`
|
||||
- 日志内容包含:当前后端接收请求、向zhgh发送请求、接收zhgh请求。
|
||||
|
||||
|
||||
## 管理端登录
|
||||
|
||||
当前本地开发数据库已初始化以下账号;密码由初始化脚本生成并单独交付,不在文档中保存。
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 账号 | `admin` |
|
||||
| 登录后进入订单列表 | `http://localhost:5173/login?redirect=/orders/list` |
|
||||
|
||||
- 登录账号存储在 `supplied_user`,密码使用随机盐和 scrypt 哈希保存,不保存明文密码。
|
||||
- 管理端使用 Bearer Token,登录有效期为 8 小时;同一账号重新登录会替换旧 Token。
|
||||
- `/api/admin/*` 除登录接口外均校验登录状态;退出登录会使当前 Token 失效。
|
||||
- 同一启用账号连续输错密码 5 次后锁定 15 分钟。
|
||||
|
||||
## 数据库与配置
|
||||
|
||||
- 管理账号表:`supplied_user`。
|
||||
- 购物车表:`supplied_cart_item`;保留业务用户 `userId`,不关联后台管理员账号。
|
||||
- 原 `user` 空表已移除,购物车表名统一使用 `supplied_cart_item`。
|
||||
- 后端配置位于 `supplied-service/.env`,字段参考 `.env.example`。
|
||||
- H5 使用 `VUE_APP_SUPPLIED_API_BASE` 指定后端地址,使用 `VUE_APP_SUPPLIER_IDENTIFICATION` 指定供应商标识。
|
||||
- 管理端使用 `VITE_SUPPLIED_ADMIN_API_BASE` 指定管理接口地址。
|
||||
|
||||
后端目录、接口及初始化脚本说明见 [supplied-service/README.md](supplied-service/README.md)。
|
||||
@@ -0,0 +1,2 @@
|
||||
PORT=8050
|
||||
SUPPLIED_SERVICE_BASE_URL=http://127.0.0.1:8888
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "esf-relay",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import http from 'node:http'
|
||||
|
||||
const port = Number(process.env.PORT || 8050)
|
||||
const suppliedServiceBaseUrl = String(process.env.SUPPLIED_SERVICE_BASE_URL || 'http://127.0.0.1:8888').replace(/\/$/, '')
|
||||
|
||||
const addressRoutes = new Map([
|
||||
['/supplierOpenApi/pointOrder/queryOrderInfo', '/supplierOpenApi/pointOrder/queryOrderInfo'],
|
||||
['/supplierOpenApi/pointOrder/queryReconciliationInfo', '/supplierOpenApi/pointOrder/queryReconciliationInfo'],
|
||||
['/supplierOpenApi/pointOrder/applyForInvoices', '/supplierOpenApi/pointOrder/applyForInvoices'],
|
||||
['/supplierOpenApi/pointOrder/queryInvoicesInfo', '/supplierOpenApi/pointOrder/queryInvoicesInfo'],
|
||||
['/supplierOpenApi/pointOrder/queryFileBase64Info', '/supplierOpenApi/pointOrder/queryFileBase64Info'],
|
||||
['/supplierOpenApi/pointOrder/unReconciledMsg', '/supplierOpenApi/pointOrder/unReconciledMsg'],
|
||||
['/supplierOpenApi/pointOrder/reconciliationResult', '/supplierOpenApi/pointOrder/reconciliationResult']
|
||||
])
|
||||
|
||||
const readBody = request => new Promise((resolve, reject) => {
|
||||
const chunks = []
|
||||
request.on('data', chunk => chunks.push(chunk))
|
||||
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
request.on('error', reject)
|
||||
})
|
||||
|
||||
const getForwardPath = address4 => {
|
||||
if (typeof address4 !== 'string') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return addressRoutes.get(new URL(address4).pathname) || null
|
||||
} catch {
|
||||
return addressRoutes.get(address4) || null
|
||||
}
|
||||
}
|
||||
|
||||
const writeJson = (response, statusCode, body) => {
|
||||
response.writeHead(statusCode, { 'Content-Type': 'application/json;charset=UTF-8' })
|
||||
response.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
if (request.method !== 'POST' || request.url !== '/esf/S120020967Api') {
|
||||
writeJson(response, 404, { resultCode: 404, message: '接口不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const bodyText = await readBody(request)
|
||||
const body = JSON.parse(bodyText)
|
||||
const forwardPath = getForwardPath(body.Address4)
|
||||
if (!forwardPath) {
|
||||
writeJson(response, 400, { resultCode: 400, message: '不支持的Address4' })
|
||||
return
|
||||
}
|
||||
|
||||
const upstreamResponse = await fetch(`${suppliedServiceBaseUrl}${forwardPath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
body: bodyText
|
||||
})
|
||||
const responseBody = await upstreamResponse.text()
|
||||
response.writeHead(upstreamResponse.status, {
|
||||
'Content-Type': upstreamResponse.headers.get('content-type') || 'application/json;charset=UTF-8'
|
||||
})
|
||||
response.end(responseBody)
|
||||
} catch (error) {
|
||||
writeJson(response, 502, {
|
||||
resultCode: 502,
|
||||
message: error instanceof Error ? error.message : 'ESF中转请求失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`ESF relay running at http://127.0.0.1:${port}/esf/S120020967Api`)
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "points-mall",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>供应商管理后台</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "supplied-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"ele-admin-plus": "^1.6.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"sass-embedded": "^1.103.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<template><RouterView /></template>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clearAdminSession, readAdminSession } from '@/utils/auth'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_SUPPLIED_ADMIN_API_BASE || 'http://127.0.0.1:8888/api/admin'
|
||||
|
||||
const normalizeBase = (base: string) => base.replace(/\/$/, '')
|
||||
|
||||
export const resolveImageUrl = (value: string) => /^\/api\/(?:file|upload-preview)\?/.test(value)
|
||||
? `${normalizeBase(API_BASE).replace(/\/api\/admin$/, '')}${value.replace('/api/upload-preview?', '/api/file?')}`
|
||||
: value
|
||||
|
||||
export const postAdmin = async <T>(name: string, body: Record<string, unknown>): Promise<T> => {
|
||||
const token = readAdminSession()?.token
|
||||
const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (response.status === 401 && name !== 'login') {
|
||||
clearAdminSession()
|
||||
if (name !== 'admin-session' && window.location.pathname !== '/login') {
|
||||
window.location.replace(`/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`)
|
||||
}
|
||||
}
|
||||
if (!response.ok || payload.success === false) {
|
||||
throw new Error(payload.error?.message || payload.msg || '请求失败')
|
||||
}
|
||||
return payload.data as T
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<section class="page-card"><div class="page-title"><div><h2>{{ title }}</h2><p>{{ description }}</p></div></div><el-form :inline="true" class="query-form" @submit.prevent="loadRecords"><el-form-item label="售后单号"><el-input v-model="serviceOrderId" placeholder="请输入售后单号" clearable /></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="loadRecords">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form><div v-if="scene !== 'process'" v-loading="loading" class="audit-list">
|
||||
<article v-for="row in records" :key="row.serviceOrderId" class="audit-card">
|
||||
<header class="audit-card-header"><strong>{{ row.serviceOrderId }}</strong><el-tag type="warning">{{ row.serviceTypeText || '-' }}</el-tag></header>
|
||||
<div v-for="product in row.products || []" :key="product.id || product.sku" class="audit-product">
|
||||
<el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image>
|
||||
<div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span>
|
||||
</div>
|
||||
<div class="audit-summary"><dl><dt>原因</dt><dd>{{ row.reason || '-' }}</dd><dt>描述</dt><dd>{{ row.problemDescription || '-' }}</dd></dl><el-tag :type="row.isSigned === true ? 'success' : 'info'">{{ row.isSigned === true ? '已签收' : row.isSigned === false ? '未签收' : '签收状态未知' }}</el-tag></div>
|
||||
<div v-if="scene === 'audit'" class="after-sale-status"><el-tag :type="row.serviceState === 0 ? 'warning' : 'primary'">{{ currentStatusText(row) }}</el-tag></div>
|
||||
<div v-if="scene === 'history'" class="history-result"><span class="result-stamp" :class="{ approved: row.serviceState === 5, rejected: row.serviceState === 2 }">{{ historyResultText(row) }}</span></div>
|
||||
<footer class="audit-card-footer"><span>{{ row.applyTime || '-' }}</span><div><el-button @click="openDetail(row)">查看详情</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="danger" plain :disabled="auditing" @click="audit(row, 2)">拒绝</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="primary" :disabled="auditing" @click="audit(row, 1)">通过</el-button><el-button v-if="canReceive(row)" type="primary" :disabled="auditing" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" type="primary" :disabled="auditing" @click="openShipDialog(row)">换货发货</el-button></div></footer>
|
||||
</article>
|
||||
<el-empty v-if="!loading && !records.length" :description="scene === 'audit' ? '暂无待处理售后' : '暂无售后记录'" />
|
||||
</div><el-table v-else v-loading="loading" :data="records" border><el-table-column prop="serviceOrderId" label="售后单号" min-width="170" /><el-table-column prop="serviceTypeText" label="售后类型" width="110" /><el-table-column prop="serviceStateText" label="状态" width="130" /><el-table-column prop="reason" label="原因" min-width="160" /><el-table-column prop="applyTime" label="申请时间" min-width="170" /><el-table-column label="操作" min-width="160" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="scene === 'audit'" link type="success" @click="audit(row, 1)">通过</el-button><el-button v-if="scene === 'audit'" link type="danger" @click="audit(row, 2)">拒绝</el-button><el-button v-if="canReceive(row)" link type="success" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" link type="primary" @click="openShipDialog(row)">换货发货</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无售后记录" /></template></el-table><el-dialog v-model="detailVisible" title="售后详情" :width="scene !== 'process' ? '1080px' : '760px'">
|
||||
<div v-if="scene !== 'process' && detail.afterSale" class="audit-detail">
|
||||
<section class="audit-detail-section audit-progress"><span v-if="scene === 'history'" class="result-stamp detail-result-stamp" :class="{ approved: detail.afterSale.serviceState === 5, rejected: detail.afterSale.serviceState === 2 }" aria-label="处理结果">{{ historyResultText(detail.afterSale) }}</span><h3>售后进度</h3><el-steps align-center>
|
||||
<el-step title="提交申请" status="success"><template #description><p>用户提交售后申请</p><p>{{ detail.afterSale.applyTime || '-' }}</p></template></el-step>
|
||||
<el-step title="商家审核" :status="detail.afterSale.auditTime ? (detail.afterSale.auditStatus === 2 ? 'error' : 'success') : detail.afterSale.serviceState === 6 ? 'wait' : 'process'"><template #description><p>{{ detail.afterSale.auditRemark || (detail.afterSale.serviceState === 6 ? '申请已撤销' : '待商家审核') }}</p><p>{{ detail.afterSale.auditTime || '-' }}</p></template></el-step>
|
||||
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商品寄回" :status="detail.afterSale.userShipTime ? 'success' : 'process'"><template #description><p>{{ detail.afterSale.userShipTime ? '用户已寄回商品' : '等待用户填写寄回物流' }}</p><p>{{ detail.afterSale.userShipTime || '-' }}</p></template></el-step>
|
||||
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商家收货" :status="detail.afterSale.merchantReceiveTime ? 'success' : detail.afterSale.userShipTime ? 'process' : 'wait'"><template #description><p>{{ detail.afterSale.merchantReceiveTime ? '商家已确认收货' : '等待商家收到商品' }}</p><p>{{ detail.afterSale.merchantReceiveTime || '-' }}</p></template></el-step>
|
||||
<el-step :title="detail.afterSale.serviceState === 6 ? '已撤销' : detail.afterSale.serviceState === 2 ? '审核未通过' : '售后完成'" :status="detail.afterSale.serviceState === 5 ? 'success' : 'wait'"><template #description><p>{{ detail.afterSale.serviceState === 5 ? '售后已完成' : detail.afterSale.serviceState === 2 ? '审核未通过,售后已结束' : detail.afterSale.serviceState === 6 ? '用户已撤销售后申请' : '等待售后处理完成' }}</p><p>{{ detail.afterSale.finishTime || '-' }}</p></template></el-step>
|
||||
</el-steps></section>
|
||||
<section class="audit-detail-section"><h3>售后商品</h3><div v-for="product in detail.afterSale.products || []" :key="product.id || product.sku" class="audit-product"><el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image><div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span></div></section>
|
||||
<section class="audit-detail-section"><h3>售后信息</h3><el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="售后单号">{{ detail.afterSale.serviceOrderId }}</el-descriptions-item><el-descriptions-item label="子订单号">{{ detail.afterSale.orderNumberId || detail.afterSale.subOrderId || '-' }}</el-descriptions-item><el-descriptions-item label="售后类型"><el-tag type="warning">{{ detail.afterSale.serviceTypeText || '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="签收状态"><el-tag :type="detail.afterSale.isSigned === true ? 'success' : 'info'">{{ detail.afterSale.isSigned === true ? '已签收' : detail.afterSale.isSigned === false ? '未签收' : '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="当前状态"><el-tag>{{ currentStatusText(detail.afterSale) }}</el-tag></el-descriptions-item><el-descriptions-item v-if="detail.afterSale.userLogisticsNo" label="寄回物流">{{ detail.afterSale.userLogisticsCompany || '-' }} / {{ detail.afterSale.userLogisticsNo }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantReceiveTime" label="商家收货时间">{{ detail.afterSale.merchantReceiveTime }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantLogisticsNo" label="换货物流">{{ detail.afterSale.merchantLogisticsCompany || '-' }} / {{ detail.afterSale.merchantLogisticsNo }}</el-descriptions-item><el-descriptions-item label="售后原因">{{ detail.afterSale.reason || '-' }}</el-descriptions-item><el-descriptions-item label="问题描述">{{ detail.afterSale.problemDescription || '-' }}</el-descriptions-item><el-descriptions-item label="申请时间">{{ detail.afterSale.applyTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions><div v-if="detailImages.length" class="audit-evidence"><el-image v-for="url in detailImages" :key="url" :src="url" :preview-src-list="detailImages" preview-teleported fit="cover" /></div></section>
|
||||
</div><pre v-else class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="scene === 'audit' && detail.afterSale?.serviceState === 0"><el-button type="danger" plain :disabled="auditing" @click="audit(detail.afterSale, 2)">拒绝</el-button><el-button type="primary" :disabled="auditing" @click="audit(detail.afterSale, 1)">通过</el-button></template><el-button v-if="detail.afterSale && canReceive(detail.afterSale)" type="primary" :disabled="auditing" @click="receive(detail.afterSale)">确认收货</el-button><el-button v-if="detail.afterSale && canShip(detail.afterSale)" type="primary" :disabled="auditing" @click="openShipDialog(detail.afterSale)">换货发货</el-button></template>
|
||||
</el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px" append-to-body><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.logisticsCompany" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsNo" /></el-form-item></el-form><template #footer><el-button @click="shipVisible = false">取消</el-button><el-button type="primary" :loading="auditing" @click="shipExchange">确认发货</el-button></template></el-dialog></section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { postAdmin, resolveImageUrl } from '@/api/admin'
|
||||
const props = defineProps<{ scene: 'audit' | 'process' | 'history'; title: string; description: string }>()
|
||||
type AfterSaleProduct = { id?: number; sku: string; name: string; number: number; price: string; image?: string }
|
||||
type AfterSale = { serviceOrderId: string; supplierId: string; serviceState?: number; logisticsState?: number; serviceType?: number; isSigned?: boolean; serviceTypeText?: string; serviceStateText?: string; reason?: string; applyTime?: string; problemDescription?: string; products?: AfterSaleProduct[]; subOrderId?: string; orderNumberId?: string; auditStatus?: number; auditTime?: string; auditRemark?: string; finishTime?: string; userLogisticsNo?: string; userLogisticsCompany?: string; merchantLogisticsNo?: string; merchantLogisticsCompany?: string; userShipTime?: string; merchantReceiveTime?: string; merchantShipTime?: string; images?: Array<string | { url?: string; previewUrl?: string; fileUrl?: string }> }
|
||||
const serviceOrderId = ref('')
|
||||
const auditing = ref(false)
|
||||
const currentStatusText = (row: AfterSale) => {
|
||||
if (row.serviceState === 1 && row.logisticsState === 1) return '审核通过,待用户寄回'
|
||||
if (row.serviceState === 3) return '用户已寄回,待商家收货'
|
||||
if (row.serviceState === 4 && row.serviceType === 5) return '商家已收货,待换货发货'
|
||||
if (row.serviceState === 1 && row.serviceType === 5 && row.isSigned === false) return '审核通过,待换货发货'
|
||||
return row.serviceStateText || '-'
|
||||
}
|
||||
const historyResultText = (row: AfterSale) => row.serviceState === 5 ? '审核通过' : row.serviceStateText || '-'
|
||||
const money = (value: string) => Number(value || 0).toFixed(2)
|
||||
const specText = (sku: string) => {
|
||||
const labels: Record<string, string> = { cream: '奶油白', blue: '雾霾蓝', orange: '暖橙色', standard: '标准款', plus: '升级款' }
|
||||
const parts = String(sku || '').split('-').slice(-2)
|
||||
return parts.some(part => labels[part]) ? parts.map(part => labels[part] || part).join(' | ') : '-'
|
||||
}
|
||||
const detailImages = computed(() => (detail.value.afterSale?.images || []).map(item => resolveImageUrl(typeof item === 'string' ? item : item.fileUrl || item.url || item.previewUrl || '')).filter(Boolean))
|
||||
const records = ref<AfterSale[]>([]); const loading = ref(false); const queried = ref(false); const detailVisible = ref(false); const detail = ref<{ afterSale?: AfterSale }>({}); const shipVisible = ref(false); const current = ref<AfterSale>(); const shipForm = ref({ logisticsCompany: '', logisticsNo: '' })
|
||||
const loadRecords = async () => { loading.value = true; try { const data = await postAdmin<{ afterSaleItems?: AfterSale[] }>('merchant-after-sale-list', { scene: props.scene, serviceOrderId: serviceOrderId.value.trim() }); records.value = data.afterSaleItems || []; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后查询失败') } finally { loading.value = false } }
|
||||
const reset = () => { serviceOrderId.value = ''; return loadRecords() }
|
||||
const openDetail = async (row: AfterSale) => { try { detail.value = await postAdmin<{ afterSale: AfterSale }>('merchant-after-sale-detail', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); detailVisible.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后详情查询失败') } }
|
||||
const audit = async (row: AfterSale, auditStatus: number) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm(auditStatus === 1 ? '确认通过该售后申请?' : '确认拒绝该售后申请?', '售后审核'); await postAdmin('merchant-after-sale-audit', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId, auditStatus, auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝' }); ElMessage.success('处理成功'); detailVisible.value = false; await loadRecords() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '处理失败') } finally { auditing.value = false } }
|
||||
const canReceive = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceState) === 3
|
||||
const canShip = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceType) === 5 && (Number(row.serviceState) === 4 || (row.isSigned === false && Number(row.serviceState) === 1))
|
||||
const receive = async (row: AfterSale) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm('确认已收到寄回商品?', '售后处理'); await postAdmin('merchant-after-sale-receive', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); ElMessage.success('已确认收货'); await loadRecords(); if (detailVisible.value) await openDetail(row) } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '操作失败') } finally { auditing.value = false } }
|
||||
const openShipDialog = (row: AfterSale) => { current.value = row; shipForm.value = { logisticsCompany: '', logisticsNo: '' }; shipVisible.value = true }
|
||||
const shipExchange = async () => { if (auditing.value) return; if (!current.value || !shipForm.value.logisticsCompany || !shipForm.value.logisticsNo) return ElMessage.warning('请填写完整发货信息'); auditing.value = true; try { await postAdmin('merchant-after-sale-ship', { supplierId: current.value.supplierId, serviceOrderId: current.value.serviceOrderId, ...shipForm.value }); ElMessage.success('换货已发货'); shipVisible.value = false; await loadRecords(); if (detailVisible.value) await openDetail(current.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { auditing.value = false } }
|
||||
onMounted(loadRecords)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.after-sale-status { margin-top: 16px; }
|
||||
.audit-card-footer > div { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.audit-card-footer > div .el-button { margin-left: 0; }
|
||||
.history-result { display: flex; justify-content: flex-end; margin-top: 16px; padding: 8px 6px; }
|
||||
.result-stamp { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 112px; height: 56px; margin: 6px 0; border: 2px solid currentColor; border-radius: 50%; color: #909399; font-size: 16px; font-weight: 700; letter-spacing: 2px; line-height: 1; transform: rotate(-14deg); }
|
||||
.result-stamp::after { position: absolute; inset: 4px; border: 1px dashed currentColor; border-radius: 50%; content: ""; }
|
||||
.result-stamp.approved { color: #389e63; }
|
||||
.result-stamp.rejected { color: #d9534f; }
|
||||
.audit-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 390px), 1fr)); gap: 20px; }
|
||||
.audit-list > .el-empty { grid-column: 1 / -1; }
|
||||
.audit-card { padding: 20px; border: 1px solid #e9eef5; border-radius: 12px; background: #fff; }
|
||||
.audit-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
|
||||
.audit-card-header strong { min-width: 0; overflow-wrap: anywhere; line-height: 22px; }
|
||||
.audit-card-header .el-tag { flex-shrink: 0; }
|
||||
.audit-product { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
|
||||
.audit-product > .el-image { width: 72px; height: 72px; flex-shrink: 0; border-radius: 8px; }
|
||||
.audit-product-info { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 6px; }
|
||||
.audit-product-info strong { overflow-wrap: anywhere; }
|
||||
.audit-product-info span, .audit-card-footer > span { color: #8793a5; font-size: 12px; }
|
||||
.audit-product-info b { color: #ff6b35; }
|
||||
.audit-quantity { flex-shrink: 0; font-weight: 600; }
|
||||
.image-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #8793a5; font-size: 12px; }
|
||||
.audit-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.audit-summary dl { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px 12px; flex: 1; min-width: 0; margin: 0; font-size: 13px; }
|
||||
.audit-summary dt { color: #8793a5; }
|
||||
.audit-summary dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.audit-card-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; margin-top: 20px; padding-top: 16px; border-top: 1px solid #edf0f5; }
|
||||
.audit-detail { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); align-items: start; gap: 20px; }
|
||||
.audit-progress { position: relative; grid-column: 1 / -1; }
|
||||
.detail-result-stamp { position: absolute; z-index: 10; top: -18px; right: 8px; pointer-events: none; width: 128px; height: 128px; margin: 0; border-width: 3px; font-size: 20px; }
|
||||
.detail-result-stamp::after { inset: 7px; border-width: 2px; }
|
||||
.audit-detail-section { min-width: 0; padding: 20px; border: 1px solid #e9eef5; border-radius: 10px; }
|
||||
@media (max-width: 767px) { .audit-detail { grid-template-columns: minmax(0, 1fr); } }
|
||||
.audit-detail-section h3 { margin: 0 0 18px; font-size: 15px; }
|
||||
.audit-progress :deep(.el-step__description) { overflow-wrap: anywhere; }
|
||||
.audit-detail-section p { margin: 6px 0 0; color: #8793a5; font-size: 12px; }
|
||||
.audit-detail :deep(.el-descriptions__label) { width: 100px; white-space: nowrap; }
|
||||
.audit-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.audit-evidence { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.audit-evidence .el-image { width: 80px; height: 80px; border-radius: 6px; }
|
||||
.detail-json { max-height: 420px; overflow: auto; padding: 12px; border-radius: 4px; background: #f5f7fa; white-space: pre-wrap; word-break: break-all; }</style>
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<el-container class="admin-layout">
|
||||
<el-aside :width="appStore.sidebarCollapsed ? '64px' : '224px'" class="sidebar">
|
||||
<div class="brand"><el-icon><ShoppingBag /></el-icon><span v-show="!appStore.sidebarCollapsed">供应商管理</span></div>
|
||||
<el-menu :collapse="appStore.sidebarCollapsed" :default-active="$route.path" background-color="#152238" text-color="#afbed3" active-text-color="#ffffff" router>
|
||||
<el-sub-menu index="/billing"><template #title><el-icon><Tickets /></el-icon><span>对账开票</span></template><el-menu-item index="/reconciliation">订单对账</el-menu-item><el-menu-item index="/invoices">发票管理</el-menu-item></el-sub-menu>
|
||||
<el-sub-menu index="/orders"><template #title><el-icon><List /></el-icon><span>订单管理</span></template><el-menu-item index="/orders/list">订单列表</el-menu-item><el-menu-item index="/orders/after-sales/audit">订单售后</el-menu-item><el-menu-item index="/orders/after-sales/history">售后记录</el-menu-item></el-sub-menu>
|
||||
<el-menu-item index="/products"><el-icon><Goods /></el-icon><template #title>商品管理</template></el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container><el-header class="header"><el-button text aria-label="切换侧栏" @click="appStore.toggleSidebar"><el-icon :size="18"><Expand v-if="appStore.sidebarCollapsed" /><Fold v-else /></el-icon></el-button><el-breadcrumb separator="/"><el-breadcrumb-item>供应商管理</el-breadcrumb-item><el-breadcrumb-item>{{ String($route.meta.title || '') }}</el-breadcrumb-item></el-breadcrumb><div class="header-user"><span class="user-avatar" aria-hidden="true">{{ (currentUser?.nickname || currentUser?.username || '管').slice(0, 1) }}</span><span>{{ currentUser?.nickname || currentUser?.username }}</span><el-button text type="primary" :loading="loggingOut" @click="logout">退出登录</el-button></div></el-header><el-main class="main"><RouterView /></el-main></el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Expand, Fold, Goods, List, ShoppingBag, Tickets } from '@element-plus/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
import { clearAdminSession, readAdminSession } from '@/utils/auth'
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
const currentUser = readAdminSession()?.user
|
||||
const loggingOut = ref(false)
|
||||
const logout = async () => {
|
||||
if (loggingOut.value) return
|
||||
loggingOut.value = true
|
||||
try {
|
||||
await postAdmin('admin-logout', {})
|
||||
clearAdminSession()
|
||||
await router.replace('/login')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '退出失败,请稍后重试')
|
||||
} finally {
|
||||
loggingOut.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout { min-height: 100vh; }
|
||||
.sidebar { position: sticky; top: 0; height: 100vh; flex-shrink: 0; overflow-x: hidden; overflow-y: auto; background: #152238; transition: width .2s ease; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; height: 80px; padding: 0 20px; color: #f5f8ff; font-size: 17px; font-weight: 600; letter-spacing: .5px; white-space: nowrap; }
|
||||
.brand > .el-icon { width: 32px; height: 32px; flex-shrink: 0; border-radius: 9px; background: #2a66d8; color: #fff; font-size: 21px; }
|
||||
.sidebar :deep(.el-menu) { border-right: 0; background: transparent; }
|
||||
.sidebar > :deep(.el-menu) { padding: 8px; }
|
||||
.sidebar :deep(.el-menu-item), .sidebar :deep(.el-sub-menu__title) { height: 46px; margin-bottom: 5px; border-radius: 7px; line-height: 46px; font-size: 14px; }
|
||||
.sidebar :deep(.el-menu-item:hover), .sidebar :deep(.el-sub-menu__title:hover) { color: #fff; background: #21334e; }
|
||||
.sidebar :deep(.el-menu-item.is-active) { background: #2862cb; color: #fff; box-shadow: 0 4px 12px rgba(0, 0, 0, .12); }
|
||||
.sidebar :deep(.el-sub-menu .el-menu-item) { min-width: 0; font-size: 13px; }
|
||||
.sidebar :deep(.el-menu--collapse) { width: 64px; }
|
||||
.sidebar :deep(.el-menu--collapse .el-menu-item), .sidebar :deep(.el-menu--collapse .el-sub-menu__title) { padding: 0 12px !important; }
|
||||
.admin-layout > .el-container { min-width: 0; }
|
||||
.header { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; gap: 18px; height: 64px; padding: 0 28px; border-bottom: 1px solid #e7ecf3; background: #fff; }
|
||||
.header > .el-button { width: 34px; height: 34px; padding: 0; color: #69778e; background: #f4f6fa; }
|
||||
.header :deep(.el-breadcrumb) { font-size: 13px; }
|
||||
.header-user { display: flex; align-items: center; gap: 12px; margin-left: auto; color: #526078; font-size: 13px; white-space: nowrap; }
|
||||
.user-avatar { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid #dce7fb; border-radius: 50%; background: #edf3fd; color: #2463d4; font-weight: 600; }
|
||||
.header-user > .el-button { margin-left: 4px; font-size: 12px; }
|
||||
.main { min-width: 0; min-height: calc(100vh - 64px); padding: 24px; background: #f3f5f9; }
|
||||
.main :deep(.ele-page) { padding: 0; }
|
||||
@media (max-width: 1100px) { .header { padding: 0 20px; } .main { padding: 18px; } }
|
||||
@media (max-width: 767px) {
|
||||
.header { gap: 10px; padding: 0 12px; }
|
||||
.header :deep(.el-breadcrumb) { display: none; }
|
||||
.header-user { gap: 8px; }
|
||||
.main { padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElePage from 'ele-admin-plus/es/ele-page/index.js'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { pinia } from './stores'
|
||||
import './styles/index.scss'
|
||||
|
||||
createApp(App).component('ElePage', ElePage).use(pinia).use(router).use(ElementPlus).mount('#app')
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
import { clearAdminSession, readAdminSession } from '@/utils/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [{ path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: AdminLayout, redirect: '/reconciliation', children: [
|
||||
{ path: 'reconciliation', name: 'Reconciliation', component: () => import('@/views/reconciliation/index.vue'), meta: { title: '订单对账' } },
|
||||
{ path: 'invoices', name: 'Invoices', component: () => import('@/views/invoices/index.vue'), meta: { title: '发票管理' } },
|
||||
{ path: 'orders', redirect: '/orders/list' },
|
||||
{ path: 'orders/list', name: 'Orders', component: () => import('@/views/orders/index.vue'), meta: { title: '订单列表' } },
|
||||
{ path: 'orders/after-sales/audit', name: 'AfterSaleAudit', component: () => import('@/views/after-sales/audit.vue'), meta: { title: '订单售后' } },
|
||||
{ path: 'orders/after-sales/process', name: 'AfterSaleProcess', redirect: '/orders/after-sales/audit' },
|
||||
{ path: 'orders/after-sales/history', name: 'AfterSaleHistory', component: () => import('@/views/after-sales/history.vue'), meta: { title: '售后记录' } },
|
||||
{ path: 'products', name: 'Products', component: () => import('@/views/products/index.vue'), meta: { title: '商品管理' } }
|
||||
] }]
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
router.beforeEach(async to => {
|
||||
if (to.name === 'Login') return true
|
||||
if (readAdminSession()) {
|
||||
try {
|
||||
await postAdmin('admin-session', {})
|
||||
return true
|
||||
} catch (error) {
|
||||
clearAdminSession()
|
||||
ElMessage.error(error instanceof Error ? error.message : '登录状态校验失败,请重新登录')
|
||||
}
|
||||
}
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,2 @@
|
||||
import { defineStore } from 'pinia'
|
||||
export const useAppStore = defineStore('app', { state: () => ({ sidebarCollapsed: false }), actions: { toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed } } })
|
||||
@@ -0,0 +1,2 @@
|
||||
import { createPinia } from 'pinia'
|
||||
export const pinia = createPinia()
|
||||
@@ -0,0 +1,97 @@
|
||||
:root {
|
||||
--el-color-primary: #2463d4;
|
||||
--el-color-primary-light-3: #668fdf;
|
||||
--el-color-primary-light-5: #91b1ea;
|
||||
--el-color-primary-light-7: #bed0f2;
|
||||
--el-color-primary-light-8: #d3e0f7;
|
||||
--el-color-primary-light-9: #edf3fd;
|
||||
--el-color-primary-dark-2: #1d4faa;
|
||||
--el-border-radius-base: 7px;
|
||||
--el-border-color: #dce3ec;
|
||||
--el-border-color-light: #e9eef5;
|
||||
--el-text-color-primary: #202e45;
|
||||
--el-text-color-regular: #526078;
|
||||
--el-text-color-secondary: #78859a;
|
||||
--el-font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
||||
}
|
||||
|
||||
html, body, #app { min-width: 360px; min-height: 100%; margin: 0; }
|
||||
body { color: var(--el-text-color-primary); font-family: var(--el-font-family); background: #f3f5f9; -webkit-font-smoothing: antialiased; }
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
button, input, textarea { font: inherit; }
|
||||
:focus-visible { outline: 2px solid #2463d4; outline-offset: 3px; }
|
||||
|
||||
.page-card {
|
||||
min-width: 0;
|
||||
min-height: calc(100vh - 112px);
|
||||
padding: 28px;
|
||||
border: 1px solid #e5eaf2;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 3px 16px rgba(30, 50, 85, .035);
|
||||
}
|
||||
.page-title { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 24px; }
|
||||
.page-title h2 { margin: 0 0 8px; color: #202e45; font-size: 22px; font-weight: 600; letter-spacing: .5px; }
|
||||
.page-title p { margin: 0; color: #78859a; font-size: 13px; line-height: 1.7; }
|
||||
.query-form.el-form--inline { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px 20px; padding: 20px; margin-bottom: 24px; border: 1px solid #e9eef5; border-radius: 10px; background: #f8fafc; }
|
||||
.query-form.el-form--inline .el-form-item { display: flex; flex-direction: row; align-items: center; margin: 0; }
|
||||
.query-form .el-form-item__label { justify-content: flex-start; flex-shrink: 0; height: 36px; padding: 0 12px 0 0; color: #526078; font-size: 13px; line-height: 36px; white-space: nowrap; }
|
||||
.query-form .el-form-item__content { min-width: 0; min-height: 36px; }
|
||||
.query-form .el-input, .query-form .el-select { width: 208px; max-width: 100%; }
|
||||
.query-form .el-button { min-width: 76px; height: 36px; }
|
||||
.el-input__wrapper, .el-select__wrapper { min-height: 36px; }
|
||||
.el-textarea__inner { padding: 10px 12px; line-height: 1.6; }
|
||||
.page-title > .el-button { height: 36px; padding: 0 18px; }
|
||||
.el-button { font-weight: 500; }
|
||||
.el-button:not(.is-link):not(.is-text) { border-radius: 7px; }
|
||||
|
||||
.page-card .el-table {
|
||||
--el-table-border-color: #e9eef5;
|
||||
--el-table-header-bg-color: #f7f9fc;
|
||||
--el-table-header-text-color: #526078;
|
||||
--el-table-row-hover-bg-color: #f3f7ff;
|
||||
overflow: hidden;
|
||||
border-radius: 9px;
|
||||
color: #3f4d63;
|
||||
font-size: 13px;
|
||||
}
|
||||
.page-card .el-table th.el-table__cell { padding: 13px 0; font-size: 12px; font-weight: 600; }
|
||||
.page-card .el-table td.el-table__cell { padding: 15px 0; }
|
||||
.page-card .el-table .cell { padding-right: 16px; padding-left: 16px; line-height: 22px; }
|
||||
.page-card .el-table .el-button.is-link { font-size: 13px; }
|
||||
.page-card .el-table .el-table__empty-block { min-height: 240px; }
|
||||
.page-card .el-table .el-table__empty-text { width: 100%; line-height: normal; }
|
||||
.page-card .el-empty { padding: 36px 0; }
|
||||
.el-empty__description p { color: #8793a5; font-size: 13px; }
|
||||
.page-card .el-tag { height: 25px; padding: 0 9px; border-color: transparent; border-radius: 5px; font-size: 12px; font-weight: 500; }
|
||||
.page-card .el-pagination { justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding-top: 20px; margin-top: 20px; border-top: 1px solid #edf0f5; font-variant-numeric: tabular-nums; }
|
||||
.page-card .el-pagination .el-pager li { border-radius: 6px; }
|
||||
.page-card .el-pagination .el-pager li.is-active { color: #fff; background: var(--el-color-primary); }
|
||||
|
||||
.el-dialog { max-width: calc(100vw - 32px); padding: 0; overflow: hidden; border-radius: 14px; box-shadow: 0 24px 80px rgba(15, 30, 55, .18); }
|
||||
.el-dialog__header { padding: 22px 52px 20px 24px; margin: 0; border-bottom: 1px solid #edf0f5; }
|
||||
.el-dialog__title { color: #202e45; font-size: 17px; font-weight: 600; }
|
||||
.el-dialog__headerbtn { top: 9px; right: 8px; }
|
||||
.el-dialog__body { max-height: 65vh; padding: 24px; overflow-y: auto; }
|
||||
.el-dialog__footer { padding: 16px 24px; border-top: 1px solid #edf0f5; background: #fafbfd; }
|
||||
.el-dialog .el-form-item { margin-bottom: 22px; }
|
||||
.el-dialog .el-form-item:last-child { margin-bottom: 0; }
|
||||
.el-dialog .el-select, .el-dialog .el-input-number { width: 100%; }
|
||||
.el-dialog__footer .el-button { min-width: 80px; height: 36px; }
|
||||
.el-message-box { width: 360px; max-width: calc(100vw - 32px); padding: 20px; border-radius: 12px; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.page-card { padding: 22px; }
|
||||
.query-form .el-input, .query-form .el-select { width: 180px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.page-card { min-height: calc(100vh - 88px); padding: 16px; border-radius: 10px; }
|
||||
.page-title h2 { font-size: 19px; }
|
||||
.query-form.el-form--inline { gap: 14px; padding: 14px; }
|
||||
.query-form.el-form--inline .el-form-item { width: 100%; }
|
||||
.query-form .el-input, .query-form .el-select { width: 100% !important; }
|
||||
.el-dialog__body { padding: 18px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0s !important; animation-duration: 0s !important; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type AdminUser = { id: number; username: string; nickname: string }
|
||||
export type AdminSession = { token: string; expiresAt: string; user: AdminUser }
|
||||
const STORAGE_KEY = 'supplied-admin-session'
|
||||
|
||||
export const clearAdminSession = () => sessionStorage.removeItem(STORAGE_KEY)
|
||||
export const saveAdminSession = (session: AdminSession) => sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
export const readAdminSession = (): AdminSession | null => {
|
||||
try {
|
||||
const session = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null')
|
||||
if (session?.token && session?.user && Date.parse(session.expiresAt) > Date.now()) return session
|
||||
} catch { /* 忽略已损坏的本地登录信息。 */ }
|
||||
clearAdminSession()
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<template><MerchantAfterSaleTable scene="audit" title="订单售后" description="审核退换货申请,处理寄回收货和换货发货。" /></template>
|
||||
<script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
<template><MerchantAfterSaleTable scene="history" title="售后记录" description="查看已完成或已关闭的售后记录。" /></template>
|
||||
<script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
<template><MerchantAfterSaleTable scene="process" title="售后处理" description="处理已审核售后,包括确认收货和换货发货。" /></template>
|
||||
<script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="page-title"><div><h2>发票管理</h2><p>查看开票结果、关联订单及电子发票。</p></div></div>
|
||||
<el-form inline class="query-form" @submit.prevent="search">
|
||||
<el-form-item label="结算单号"><el-input v-model="query.settlementId" placeholder="请输入结算单号" clearable /></el-form-item>
|
||||
<el-form-item label="开票状态"><el-select v-model="query.invoiceStatus" placeholder="全部" clearable><el-option v-for="(name, value) in statuses" :key="value" :label="name" :value="Number(value)" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="items" v-loading="loading" border>
|
||||
<el-table-column prop="settlementId" label="结算单号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="invoiceIds" label="发票号码" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column label="开票状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.invoiceStatus)">{{ statuses[row.invoiceStatus] || '-' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="价税合计" width="120"><template #default="{ row }">¥{{ row.invoiceAmount }}</template></el-table-column>
|
||||
<el-table-column label="开票日期" width="130"><template #default="{ row }">{{ dateText(row.invoiceDate) }}</template></el-table-column>
|
||||
<el-table-column prop="failureMessage" label="说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.failureMessage || '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :loading="pushingId === row.id" :disabled="pushingId !== null || ![2, 3].includes(row.invoiceStatus)" @click="pushInvoice(row)">推送发票</el-button></template></el-table-column>
|
||||
<template #empty><el-empty :image-size="80" description="暂无发票记录" /></template>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
|
||||
<el-dialog v-model="detailVisible" title="发票详情" width="880px">
|
||||
<template v-if="current"><el-descriptions :column="2" border class="invoice-detail">
|
||||
<el-descriptions-item label="结算单号">{{ current.settlementId }}</el-descriptions-item><el-descriptions-item label="开票状态"><el-tag :type="statusType(current.invoiceStatus)">{{ statuses[current.invoiceStatus] || '-' }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="发票号码">{{ current.invoiceIds || '-' }}</el-descriptions-item><el-descriptions-item label="发票代码">{{ current.invoiceCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开票日期">{{ dateText(current.invoiceDate) }}</el-descriptions-item><el-descriptions-item label="发票类型">{{ current.invoiceType === 9 ? '全电专票' : current.invoiceType === 10 ? '全电普票' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="不含税金额">¥{{ current.invoiceNakeAmount }}</el-descriptions-item><el-descriptions-item label="税率">{{ current.invoiceTaxRate ? `${Number(current.invoiceTaxRate) * 100}%` : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税额">¥{{ current.invoiceTaxAmount }}</el-descriptions-item><el-descriptions-item label="价税合计">¥{{ current.invoiceAmount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="纳税人识别号">{{ current.taxIdNumber || '-' }}</el-descriptions-item><el-descriptions-item label="校验码">{{ current.checkCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="说明" :span="2">{{ current.failureMessage || '-' }}</el-descriptions-item>
|
||||
</el-descriptions><h3 class="orders-title">关联订单</h3><div class="invoice-orders"><el-tag v-for="orderId in current.orderIds.split(',').filter(Boolean)" :key="orderId" type="info">{{ orderId }}</el-tag><span v-if="!current.orderIds">暂无关联订单</span></div></template>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="current?.hasFile"><el-button :disabled="fileLoading" @click="openFile(current, true)">下载PDF</el-button><el-button type="primary" :disabled="fileLoading" @click="openFile(current)">预览PDF</el-button></template></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="previewVisible" title="发票预览" width="1000px" append-to-body @closed="previewUrl = ''"><iframe v-if="previewUrl" :src="previewUrl" title="电子发票" class="invoice-preview" /></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
type Invoice = { id: number; settlementId: string; invoiceIds: string; invoiceCode: string; invoiceStatus: number; invoiceDate: string | null; invoiceType: number | null; invoiceAmount: string; invoiceNakeAmount: string; invoiceTaxAmount: string; invoiceTaxRate: string; taxIdNumber: string; checkCode: string; orderIds: string; failureMessage: string; hasFile: boolean }
|
||||
const statuses: Record<number, string> = { 0: '未开票', 1: '开票中', 2: '成功', 3: '失败' }
|
||||
const statusType = (status: number) => status === 2 ? 'success' : status === 3 ? 'danger' : status === 1 ? 'warning' : 'info'
|
||||
const dateText = (value: string | null) => value ? new Date(value).toLocaleDateString('sv-SE') : '-'
|
||||
const query = reactive<{ settlementId: string; invoiceStatus?: number; pageNumber: number; pageSize: number }>({ settlementId: '', pageNumber: 1, pageSize: 10 })
|
||||
const items = ref<Invoice[]>([])
|
||||
const totalCount = ref(0)
|
||||
const loading = ref(false)
|
||||
const current = ref<Invoice>()
|
||||
const detailVisible = ref(false)
|
||||
const fileLoading = ref(false)
|
||||
const pushingId = ref<number | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await postAdmin<{ invoiceItems: Invoice[]; totalCount: number }>('merchant-invoice-list', query)
|
||||
items.value = data.invoiceItems || []
|
||||
totalCount.value = data.totalCount || 0
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '发票查询失败') } finally { loading.value = false }
|
||||
}
|
||||
const search = () => { query.pageNumber = 1; return load() }
|
||||
const reset = () => { query.settlementId = ''; query.invoiceStatus = undefined; return search() }
|
||||
const openDetail = (row: Invoice) => { current.value = row; detailVisible.value = true }
|
||||
const pushInvoice = async (row: Invoice) => {
|
||||
if (pushingId.value !== null) return
|
||||
pushingId.value = row.id
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认向工会推送结算单 ${row.settlementId} 的开票结果?`, '推送发票', { confirmButtonText: '确认推送', cancelButtonText: '取消' })
|
||||
await postAdmin('merchant-invoice-push', { id: row.id })
|
||||
ElMessage.success('发票推送成功')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '发票推送失败')
|
||||
} finally { pushingId.value = null }
|
||||
}
|
||||
const openFile = async (row: Invoice, download = false) => {
|
||||
if (fileLoading.value) return
|
||||
fileLoading.value = true
|
||||
try {
|
||||
const data = await postAdmin<{ url: string }>('merchant-invoice-file', { id: row.id, download })
|
||||
if (download) {
|
||||
const link = document.createElement('a')
|
||||
link.href = data.url
|
||||
link.download = `${row.invoiceIds || 'invoice'}.pdf`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
} else { previewUrl.value = data.url; previewVisible.value = true }
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '获取发票文件失败') } finally { fileLoading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invoice-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; }
|
||||
.invoice-detail :deep(.el-descriptions__label) { width: 110px; }
|
||||
.orders-title { margin: 24px 0 12px; font-size: 15px; }
|
||||
.invoice-orders { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.invoice-preview { display: block; width: 100%; height: 60vh; border: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-intro">
|
||||
<div class="login-brand"><el-icon><ShoppingBag /></el-icon>供应商管理</div>
|
||||
<h1>高效管理,让服务更进一步</h1>
|
||||
<p>商品、订单、售后与对账,集中处理每一项业务。</p>
|
||||
<div class="business-tags"><span>商品管理</span><span>订单处理</span><span>售后服务</span><span>订单对账</span></div>
|
||||
</section>
|
||||
<section class="login-card">
|
||||
<h2>账号登录</h2>
|
||||
<p class="login-hint">欢迎回来,请登录供应商管理平台</p>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
|
||||
<el-form-item label="账号" prop="username">
|
||||
<el-input v-model="form.username" :prefix-icon="User" placeholder="请输入账号" autocomplete="username" :maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" :prefix-icon="Lock" type="password" show-password placeholder="请输入密码" autocomplete="current-password" :maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" class="login-error" />
|
||||
<el-button class="login-submit" type="primary" native-type="submit" :loading="submitting">{{ submitting ? '正在登录' : '登录' }}</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Lock, ShoppingBag, User } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
import { saveAdminSession, type AdminSession } from '@/utils/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, whitespace: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
const submit = async () => {
|
||||
if (submitting.value || !await formRef.value?.validate().catch(() => false)) return
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const session = await postAdmin<AdminSession>('login', { username: form.username.trim(), password: form.password })
|
||||
saveAdminSession(session)
|
||||
form.password = ''
|
||||
const target = typeof route.query.redirect === 'string' ? route.query.redirect : ''
|
||||
const resolved = target.startsWith('/') && !target.startsWith('//') && !target.includes(String.fromCharCode(92)) ? router.resolve(target) : null
|
||||
await router.replace(resolved?.matched.length && resolved.name !== 'Login' ? target : '/reconciliation')
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page { display: flex; align-items: center; justify-content: center; gap: clamp(48px, 7vw, 120px); min-height: 100vh; padding: 60px 40px; background: linear-gradient(120deg, #eaf0fa 0%, #f6f8fc 55%, #eef2f8 100%); }
|
||||
.login-intro { max-width: 480px; color: #1c304e; }
|
||||
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 64px; font-size: 22px; font-weight: 600; letter-spacing: 1px; }
|
||||
.login-brand .el-icon { width: 48px; height: 48px; border-radius: 13px; background: #2463d4; color: #fff; font-size: 27px; box-shadow: 0 8px 20px rgba(36, 99, 212, .18); }
|
||||
.login-intro h1 { max-width: 410px; margin: 0 0 20px; font-size: 38px; font-weight: 600; line-height: 1.55; letter-spacing: 1px; }
|
||||
.login-intro p { color: #738199; font-size: 14px; line-height: 1.9; }
|
||||
.business-tags { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 30px; }
|
||||
.business-tags span { padding: 8px 13px; border: 1px solid #dce5f2; border-radius: 6px; color: #536985; background: rgba(255, 255, 255, .65); font-size: 12px; }
|
||||
.login-card { width: 420px; flex-shrink: 0; padding: 42px 38px; border: 1px solid #e4eaf3; border-radius: 18px; background: #fff; box-shadow: 0 18px 60px rgba(38, 60, 98, .08); }
|
||||
.login-card h2 { margin: 0 0 12px; color: #202e45; font-size: 25px; font-weight: 600; }
|
||||
.login-hint { margin: 0 0 32px; color: #8793a5; font-size: 13px; line-height: 1.7; }
|
||||
.login-card :deep(.el-form-item) { margin-bottom: 24px; }
|
||||
.login-card :deep(.el-form-item__label) { color: #526078; font-size: 13px; }
|
||||
.login-card :deep(.el-input__wrapper) { min-height: 44px; padding: 0 14px; }
|
||||
.login-submit { width: 100%; height: 44px; margin-top: 10px; letter-spacing: 2px; }
|
||||
.login-error { margin-bottom: 16px; }
|
||||
@media (max-width: 960px) { .login-page { gap: 36px; padding: 40px 24px; } .login-intro h1 { font-size: 30px; } .login-card { width: 380px; padding: 34px 28px; } }
|
||||
@media (max-width: 700px) { .login-page { flex-direction: column; gap: 26px; padding: 32px 20px; } .login-intro { width: 100%; max-width: 420px; } .login-brand { margin-bottom: 0; font-size: 20px; } .login-intro h1, .login-intro p, .business-tags { display: none; } .login-card { width: 100%; max-width: 420px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="page-title"><div><h2>订单列表</h2><p>查询订单并对待发货订单确认发货。</p></div><el-button type="primary" :loading="batchShipping" :disabled="loading || submitting || shipDialogVisible || Boolean(repushingOrderId) || !pendingShipOrders.length" @click="quickShipAll">一键发货</el-button></div>
|
||||
<el-form :inline="true" class="query-form"><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号" clearable /></el-form-item><el-form-item label="用户昵称"><el-input v-model="query.userNickname" placeholder="请输入用户昵称" clearable /></el-form-item><el-form-item label="订单状态"><el-select v-model="query.orderState" placeholder="全部" clearable style="width: 160px"><el-option v-for="status in orderStates" :key="status.value" :label="status.label" :value="status.value" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="searchOrders">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
|
||||
<el-table v-loading="loading" :data="orders" border><el-table-column prop="orderNumberId" label="订单编号" min-width="200" show-overflow-tooltip /><el-table-column label="用户昵称" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.userNickname || '-' }}</template></el-table-column><el-table-column label="商品信息" min-width="300"><template #default="{ row }">
|
||||
<div v-for="(product, index) in row.products || []" :key="product.id || index" class="order-product">
|
||||
<el-image class="order-product__image" :src="resolveImageUrl(product.image)" fit="cover" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported>
|
||||
<template #error><span class="order-product__placeholder">暂无图片</span></template>
|
||||
</el-image>
|
||||
<div class="order-product__info">
|
||||
<div class="order-product__name">{{ product.name || '-' }}</div>
|
||||
<div class="order-product__bottom"><span class="order-product__price">{{ formatPrice(product.price) }}</span><span class="order-product__quantity">×{{ product.number ?? '-' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!row.products?.length">-</span>
|
||||
</template></el-table-column><el-table-column label="订单金额" width="120" align="right"><template #default="{ row }"><span class="order-amount">{{ formatPrice(row.amount) }}</span></template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="订单状态" width="110"><template #default="{ row }"><el-tag :type="[4, 5].includes(row.orderState) ? 'success' : [0, 1, 6].includes(row.orderState) ? 'warning' : row.orderState === 2 ? 'primary' : 'info'">{{ stateText(row.orderState) }}</el-tag></template></el-table-column><el-table-column label="操作" width="240" fixed="right"><template #default="{ row }"><el-button v-if="row.orderState === 1" link type="primary" :disabled="batchShipping" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span><el-button v-if="row.orderState === 1" link type="warning" :loading="repushingOrderId === row.orderId" :disabled="batchShipping || Boolean(repushingOrderId)" @click="repushSplit(row)">重新推送拆单</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无订单" /></template></el-table>
|
||||
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="loadOrders" />
|
||||
|
||||
<el-dialog v-model="shipDialogVisible" title="确认发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.crrgBsnNm" placeholder="请输入快递公司" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsOrderId" placeholder="请输入快递单号" /></el-form-item></el-form><template #footer><el-button @click="shipDialogVisible = false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button></template></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { postAdmin, resolveImageUrl } from '@/api/admin'
|
||||
|
||||
type OrderProduct = { id?: number; name?: string; image?: string; price?: string | number; number?: number }
|
||||
type OrderItem = { orderId: string; supplierId: string; orderNumberId?: string; userId?: string; userNickname?: string; orderState: number; createTime?: string; crrgBsnNm?: string; logisticsOrderId?: string; amount?: string | number; products?: OrderProduct[] }
|
||||
const orderStates = [{ label: '待付款', value: 0 }, { label: '待发货', value: 1 }, { label: '已发货', value: 2 }, { label: '已取消', value: 3 }, { label: '已签收', value: 4 }, { label: '已完成', value: 5 }, { label: '换货', value: 6 }, { label: '已退款', value: 7 }]
|
||||
const query = reactive<{ orderId: string; userNickname: string; orderState?: number; pageNumber: number; pageSize: number }>({ orderId: '', userNickname: '', pageNumber: 1, pageSize: 10 })
|
||||
const orders = ref<OrderItem[]>([]); const loading = ref(false); const queried = ref(false); const submitting = ref(false); const shipDialogVisible = ref(false); const currentOrder = ref<OrderItem>(); const shipForm = ref({ crrgBsnNm: '', logisticsOrderId: '' })
|
||||
const totalCount = ref(0)
|
||||
const stateText = (state: number) => orderStates.find(item => item.value === state)?.label || '未知'
|
||||
const loadOrders = async () => { loading.value = true; try { const data = await postAdmin<{ orderItems?: OrderItem[]; totalCount?: number }>('merchant-order-list', query); orders.value = data.orderItems || []; totalCount.value = data.totalCount || 0; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单查询失败') } finally { loading.value = false } }
|
||||
const searchOrders = () => { query.pageNumber = 1; return loadOrders() }
|
||||
const reset = () => { query.orderId = ''; query.userNickname = ''; query.orderState = undefined; searchOrders() }
|
||||
const formatPrice = (value?: string | number) => value !== undefined && value !== null && value !== '' && Number.isFinite(Number(value)) ? `¥${Number(value).toFixed(2)}` : '-'
|
||||
const openShipDialog = (order: OrderItem) => { currentOrder.value = order; shipForm.value = { crrgBsnNm: '韵达快递', logisticsOrderId: `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}` }; shipDialogVisible.value = true }
|
||||
const submitShip = async () => { if (!currentOrder.value || !shipForm.value.crrgBsnNm || !shipForm.value.logisticsOrderId) return ElMessage.warning('请填写完整发货信息'); submitting.value = true; try { await postAdmin('merchant-order-ship', { supplierId: currentOrder.value.supplierId, orderId: currentOrder.value.orderId, userId: currentOrder.value.userId, ...shipForm.value }); ElMessage.success('发货成功'); shipDialogVisible.value = false; await loadOrders() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { submitting.value = false } }
|
||||
const batchShipping = ref(false)
|
||||
const pendingShipOrders = computed(() => orders.value.filter(order => order.orderState === 1))
|
||||
const quickShipAll = async () => {
|
||||
if (batchShipping.value || loading.value || submitting.value || shipDialogVisible.value || repushingOrderId.value) return
|
||||
const targets = pendingShipOrders.value.map(order => ({ ...order }))
|
||||
if (!targets.length) return ElMessage.warning('当前页没有待发货订单')
|
||||
batchShipping.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(`将为当前页的 ${targets.length} 个待发货订单提交发货,默认使用韵达快递,并为每笔订单生成独立快递单号。是否继续?`, '确认一键发货', { type: 'warning', confirmButtonText: '确认发货', cancelButtonText: '取消' })
|
||||
const results = await Promise.allSettled(targets.map((order, index) => postAdmin('merchant-order-ship', {
|
||||
supplierId: order.supplierId,
|
||||
userId: order.userId,
|
||||
orderId: order.orderId,
|
||||
crrgBsnNm: order.crrgBsnNm || '韵达快递',
|
||||
logisticsOrderId: order.logisticsOrderId || `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}${index}`
|
||||
})))
|
||||
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failures.length) {
|
||||
const reason = failures[0].reason instanceof Error ? failures[0].reason.message : '请求失败'
|
||||
ElMessage.warning({ message: `发货成功 ${results.length - failures.length} 笔,失败 ${failures.length} 笔:${reason}`, duration: 6000 })
|
||||
} else {
|
||||
ElMessage.success(`已成功发货 ${results.length} 笔订单`)
|
||||
}
|
||||
await loadOrders()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '批量发货失败')
|
||||
} finally {
|
||||
batchShipping.value = false
|
||||
}
|
||||
}
|
||||
const repushingOrderId = ref('')
|
||||
const repushSplit = async (order: OrderItem) => {
|
||||
if (repushingOrderId.value) return
|
||||
repushingOrderId.value = order.orderId
|
||||
try {
|
||||
await postAdmin('merchant-order-repush-split', { supplierId: order.supplierId, userId: order.userId, orderId: order.orderId })
|
||||
ElMessage.success('重新推送拆单成功')
|
||||
await loadOrders()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '重新推送拆单失败')
|
||||
} finally {
|
||||
repushingOrderId.value = ''
|
||||
}
|
||||
}
|
||||
onMounted(loadOrders)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-product { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.order-product + .order-product { margin-top: 12px; padding-top: 12px; border-top: 1px solid #edf0f5; }
|
||||
.order-product__image { width: 58px; height: 58px; flex-shrink: 0; overflow: hidden; border-radius: 7px; background: #f3f5f9; }
|
||||
.order-product__placeholder { display: grid; height: 100%; place-items: center; color: #8793a5; font-size: 11px; }
|
||||
.order-product__info { min-width: 0; flex: 1; }
|
||||
.order-product__name { color: #202e45; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.order-product__bottom { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 6px; }
|
||||
.order-product__price, .order-amount { color: #ed6a2c; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.order-product__quantity { color: #526078; font-size: 12px; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="page-title"><div><h2>商品管理</h2><p>管理供应商商品的展示、库存和上下架状态。</p></div><el-button type="primary" @click="openCreate">新增商品</el-button></div>
|
||||
<el-form inline class="query-form" @submit.prevent><el-form-item label="商品"><el-input v-model="query.keyword" placeholder="商品名称或 SKU" clearable /></el-form-item><el-form-item label="分类"><el-select v-model="query.categoryId" placeholder="全部" clearable><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
|
||||
<el-table :data="products" v-loading="loading" border><el-table-column prop="sku" label="SKU" min-width="140" /><el-table-column prop="name" label="商品名称" min-width="180" /><el-table-column prop="category" label="分类" width="120" /><el-table-column prop="price" label="售价" width="100" /><el-table-column prop="stock" label="库存" width="90" /><el-table-column prop="sortOrder" label="排序" width="80" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '上架' : '下架' }}</el-tag></template></el-table-column><el-table-column label="操作" width="130" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无商品" /></template></el-table>
|
||||
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
|
||||
<el-dialog v-model="visible" :title="form.id ? '编辑商品' : '新增商品'" width="760px" destroy-on-close><el-form class="product-form" :model="form" label-width="90px"><el-row :gutter="24"><el-col :xs="24" :sm="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品名称" required><el-input v-model="form.name" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="售价" required><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="原价"><el-input-number v-model="form.originPrice" :min="0" :precision="2" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="库存"><el-input-number v-model="form.stock" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="分类"><el-select v-model="form.categoryId" @change="setCategory"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态"><el-switch v-model="form.enabled" active-text="上架" inactive-text="下架" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品标签"><el-input v-model="form.tag" placeholder="如:赠品、积分兑换" clearable /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="已售数量"><el-input v-model="form.sales" disabled placeholder="0" clearable /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品数量"><el-input-number v-model="form.number" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="税率"><el-input-number v-model="form.taxRate" :min="0" :max="1" :step="0.01" :precision="4" /><span class="product-field-hint">按小数填写,例如 0.13 表示 13%</span></el-form-item></el-col></el-row><el-form-item label="商品图片"><el-upload class="product-image-upload" list-type="picture-card" accept="image/*" :auto-upload="false" :show-file-list="false" :disabled="uploading" :on-change="uploadProductImage" v-loading="uploading" :aria-label="form.imageUrl ? '更换商品图片' : '上传商品图片'"><template v-if="form.imageUrl"><img :src="resolveImageUrl(form.imageUrl)" alt="商品图片" class="product-image-preview" /><span class="product-image-replace">点击更换</span></template><div v-else class="product-image-placeholder"><span class="product-image-plus" aria-hidden="true">+</span><span>上传图片</span></div></el-upload></el-form-item><el-form-item label="商品描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item><el-form-item label="商品详情"><el-input v-model="form.detail" type="textarea" :rows="4" /></el-form-item></el-form><template #footer><el-button @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="uploading" @click="save">保存</el-button></template></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { postAdmin, resolveImageUrl } from '@/api/admin'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
|
||||
type Category = { id: string; name: string }
|
||||
type Product = { id: number; sku: string; name: string; categoryId: string; category: string; price: string; originPrice: string; stock: number; sortOrder: number; enabled: boolean; imageUrl: string; description: string; tag: string; sales: string; detail: string; number: number; taxRate: number }
|
||||
type ProductForm = { id?: number; sku: string; name: string; price: number; originPrice?: number; stock: number; sortOrder: number; categoryId: string; category: string; imageUrl: string; description: string; enabled: boolean; tag: string; sales: string; detail: string; number: number; taxRate: number }
|
||||
const query = reactive({ keyword: '', categoryId: '', pageNumber: 1, pageSize: 10 })
|
||||
const totalCount = ref(0)
|
||||
const uploading = ref(false)
|
||||
const uploadProductImage = async (file: UploadFile) => {
|
||||
if (!file.raw || uploading.value) return
|
||||
if (!file.raw.type.startsWith('image/')) return ElMessage.warning('请选择图片文件')
|
||||
uploading.value = true
|
||||
try {
|
||||
const contentBase64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = () => reject(new Error('图片读取失败'))
|
||||
reader.readAsDataURL(file.raw!)
|
||||
})
|
||||
const data = await postAdmin<{ fileUrl: string }>('upload', { fileName: file.name, contentType: file.raw.type, contentBase64, folder: 'supplied/product' })
|
||||
form.imageUrl = data.fileUrl
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '图片上传失败') } finally { uploading.value = false }
|
||||
}
|
||||
const categories = ref<Category[]>([]); const products = ref<Product[]>([]); const loading = ref(false); const loaded = ref(false); const visible = ref(false); const saving = ref(false)
|
||||
const emptyForm = (): ProductForm => ({ sku: '', name: '', price: 0, stock: 0, sortOrder: 0, categoryId: '', category: '', imageUrl: '', description: '', enabled: true, tag: '', sales: '0', detail: '', number: 0, taxRate: 0.13 })
|
||||
const form = reactive<ProductForm>(emptyForm())
|
||||
const setForm = (value: ProductForm) => Object.assign(form, value)
|
||||
const load = async () => { loading.value = true; try { const data = await postAdmin<{ categories: Category[]; products: Product[]; totalCount: number }>('merchant-product-list', query); categories.value = data.categories || []; products.value = data.products || []; totalCount.value = data.totalCount || 0; loaded.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '商品查询失败') } finally { loading.value = false } }
|
||||
const search = () => { query.pageNumber = 1; return load() }
|
||||
const reset = () => { query.keyword = ''; query.categoryId = ''; return search() }
|
||||
const openCreate = () => { setForm(emptyForm()); visible.value = true }
|
||||
const openEdit = (product: Product) => { setForm({ id: product.id, sku: product.sku, name: product.name, price: Number(product.price), originPrice: product.originPrice ? Number(product.originPrice) : undefined, stock: product.stock, sortOrder: product.sortOrder, categoryId: product.categoryId, category: product.category, imageUrl: product.imageUrl, description: product.description || '', enabled: product.enabled, tag: product.tag || '', sales: product.sales?.trim() || '0', detail: product.detail || '', number: product.number ?? 0, taxRate: product.taxRate ?? 0.13 }); visible.value = true }
|
||||
const setCategory = (categoryId: string) => { form.category = categories.value.find(item => item.id === categoryId)?.name || '' }
|
||||
const save = async () => { saving.value = true; try { await postAdmin('merchant-product-save', form); ElMessage.success('保存成功'); visible.value = false; await load() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false } }
|
||||
const remove = async (product: Product) => { try { await ElMessageBox.confirm(`确认删除商品“${product.name}”?`, '删除商品', { type: 'warning' }); await postAdmin('merchant-product-delete', { id: product.id }); ElMessage.success('删除成功'); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败') } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-field-hint { margin-top: 6px; color: #8793a5; font-size: 12px; line-height: 18px; }
|
||||
.product-image-upload :deep(.el-upload--picture-card) { position: relative; width: 128px; height: 128px; overflow: hidden; border-radius: 8px; }
|
||||
.product-image-preview { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.product-image-placeholder { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #8793a5; font-size: 13px; }
|
||||
.product-image-plus { font-size: 32px; line-height: 32px; }
|
||||
.product-image-replace { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 12px; background: rgba(0, 0, 0, .4); color: #fff; font-size: 13px; line-height: 20px; opacity: 0; }
|
||||
.product-image-upload :deep(.el-upload--picture-card:hover) .product-image-replace,
|
||||
.product-image-upload :deep(.el-upload--picture-card:focus-within) .product-image-replace { opacity: 1; }
|
||||
@media (hover: none) { .product-image-replace { top: auto; padding: 6px 12px; opacity: 1; } }
|
||||
.product-form :deep(.el-row .el-form-item) { margin-bottom: 24px; }
|
||||
.product-form :deep(.el-form-item__content) { min-width: 0; }
|
||||
.product-form :deep(.el-input),
|
||||
.product-form :deep(.el-select),
|
||||
.product-form :deep(.el-input-number) { width: 100%; }
|
||||
.product-form :deep(.el-form-item__label) { line-height: 36px; }
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<ele-page>
|
||||
<section class="page-card">
|
||||
<div class="page-title"><div><h2>订单对账</h2><p>查看 ZHGH 回传的子订单对账结果,并处理异常订单。</p></div></div>
|
||||
<el-form inline class="query-form" @submit.prevent>
|
||||
<el-form-item label="子订单"><el-input v-model="query.orderId" placeholder="子订单 ID" clearable /></el-form-item>
|
||||
<el-form-item label="对账状态"><el-select v-model="query.reconciliationStatus" clearable placeholder="全部"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="items" v-loading="loading" border>
|
||||
<el-table-column prop="orderId" label="子订单ID" min-width="180" />
|
||||
<el-table-column prop="reconciliationStatus" label="对账状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.reconciliationStatus)">{{ statusText(row.reconciliationStatus) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="diffReason" label="差异说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="平台金额" min-width="120"><template #default="{ row }">{{ row.platformTotalPrice || '-' }}</template></el-table-column>
|
||||
<el-table-column label="供应商金额" min-width="120"><template #default="{ row }">{{ row.supplierTotalPrice || '-' }}</template></el-table-column>
|
||||
<el-table-column prop="reconciliationBatchNo" label="对账批次" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="reconciliationTime" label="对账时间" min-width="170" />
|
||||
<template #empty><el-empty :image-size="80" description="暂无对账结果" /></template></el-table>
|
||||
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
|
||||
</section>
|
||||
<el-dialog v-model="dialogVisible" title="处理对账结果" width="460px">
|
||||
<el-form label-width="90px"><el-form-item label="子订单"><span>{{ editing?.orderId }}</span></el-form-item><el-form-item label="对账状态"><el-select v-model="form.reconciliationStatus"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item><el-form-item label="差异类型"><el-input-number v-model="form.diffType" :min="0" :precision="0" /></el-form-item><el-form-item label="差异说明"><el-input v-model="form.diffReason" type="textarea" :rows="3" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</ele-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
|
||||
type ReconciliationItem = { supplierId: string; orderId: string; mainOrderId: string; reconciliationStatus: number; diffType?: number; diffReason: string; platformTotalPrice: string; supplierTotalPrice: string; reconciliationBatchNo: string; reconciliationTime: string }
|
||||
const query = reactive<{ orderId: string; reconciliationStatus?: number; pageNumber: number; pageSize: number }>({ orderId: '', pageNumber: 1, pageSize: 10 })
|
||||
const totalCount = ref(0)
|
||||
const items = ref<ReconciliationItem[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editing = ref<ReconciliationItem>()
|
||||
const form = reactive<{ reconciliationStatus: number; diffType?: number; diffReason: string }>({ reconciliationStatus: 0, diffReason: '' })
|
||||
const statusText = (status: number) => ['未对账', '对账成功', '对账异常'][status] || '未知'
|
||||
const statusType = (status: number) => status === 1 ? 'success' : status === 2 ? 'danger' : 'info'
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await postAdmin<{ reconciliationItems: ReconciliationItem[]; totalCount: number }>('merchant-reconciliation-list', query)
|
||||
items.value = data.reconciliationItems || []
|
||||
totalCount.value = data.totalCount || 0
|
||||
loaded.value = true
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '查询失败') } finally { loading.value = false }
|
||||
}
|
||||
const search = () => { query.pageNumber = 1; return load() }
|
||||
const reset = () => { query.orderId = ''; query.reconciliationStatus = undefined; return search() }
|
||||
const openEdit = (row: ReconciliationItem) => { editing.value = row; form.reconciliationStatus = row.reconciliationStatus; form.diffType = row.diffType; form.diffReason = row.diffReason || ''; dialogVisible.value = true }
|
||||
const save = async () => {
|
||||
if (!editing.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await postAdmin('merchant-reconciliation-update', { supplierId: editing.value.supplierId, orderId: editing.value.orderId, reconciliationStatus: form.reconciliationStatus, diffType: form.diffType, diffReason: form.diffReason })
|
||||
ElMessage.success('保存成功'); dialogVisible.value = false; await load()
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, "lib": ["ES2020", "DOM"], "baseUrl": ".", "paths": { "@/*": ["src/*"] } },
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } })
|
||||
@@ -0,0 +1,2 @@
|
||||
VUE_APP_SUPPLIED_API_BASE="http://localhost:8888/api"
|
||||
VUE_APP_SUPPLIER_IDENTIFICATION="joyful-selection"
|
||||
@@ -11,16 +11,16 @@
|
||||
"@vant/area-data": "^1.5.2",
|
||||
"core-js": "^3.37.1",
|
||||
"pinia": "^2.3.1",
|
||||
"vant": "^2.13.9",
|
||||
"vue": "^2.7.16",
|
||||
"vue-router": "^3.6.5"
|
||||
"vant": "^3.6.16",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/compiler-sfc": "^3.5.13",
|
||||
"less": "^4.2.0",
|
||||
"less-loader": "^10.2.0",
|
||||
"vue-template-compiler": "^2.7.16"
|
||||
"less-loader": "^10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@achrinza/node-ipc": {
|
||||
@@ -337,10 +337,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -1446,6 +1448,7 @@
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1482,7 +1485,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
@@ -1550,7 +1555,6 @@
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -1859,15 +1863,27 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vant/icons": {
|
||||
"version": "3.0.2",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@vant/icons/-/icons-1.8.0.tgz",
|
||||
"integrity": "sha512-sKfEUo2/CkQFuERxvkuF6mGQZDKu3IQdj5rV9Fm0weJXtchDSSQ+zt8qPCNUEhh9Y8shy5PzxbvAfOOkCwlCXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vant/popperjs": {
|
||||
"version": "1.3.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vant/use": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vant/use/-/use-1.6.0.tgz",
|
||||
"integrity": "sha512-PHHxeAASgiOpSmMjceweIrv2AxDZIkWXyaczksMoWvKV2YAYEhoizRuk/xFnKF+emUIi46TsQ+rvlm/t2BBCfA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/babel-helper-vue-jsx-merge-props": {
|
||||
"version": "1.4.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/babel-helper-vue-transform-on": {
|
||||
@@ -2277,49 +2293,53 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.39",
|
||||
"dev": true,
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
|
||||
"integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@vue/shared": "3.5.39",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@vue/shared": "3.5.42",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.39",
|
||||
"dev": true,
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz",
|
||||
"integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.39",
|
||||
"@vue/shared": "3.5.39"
|
||||
"@vue/compiler-core": "3.5.42",
|
||||
"@vue/shared": "3.5.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.39",
|
||||
"dev": true,
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz",
|
||||
"integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@vue/compiler-core": "3.5.39",
|
||||
"@vue/compiler-dom": "3.5.39",
|
||||
"@vue/compiler-ssr": "3.5.39",
|
||||
"@vue/shared": "3.5.39",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@vue/compiler-core": "3.5.42",
|
||||
"@vue/compiler-dom": "3.5.42",
|
||||
"@vue/compiler-ssr": "3.5.42",
|
||||
"@vue/shared": "3.5.42",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss": "^8.5.19",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.39",
|
||||
"dev": true,
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz",
|
||||
"integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.39",
|
||||
"@vue/shared": "3.5.39"
|
||||
"@vue/compiler-dom": "3.5.42",
|
||||
"@vue/shared": "3.5.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/component-compiler-utils": {
|
||||
@@ -2386,9 +2406,52 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz",
|
||||
"integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz",
|
||||
"integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.42",
|
||||
"@vue/shared": "3.5.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz",
|
||||
"integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.42",
|
||||
"@vue/runtime-core": "3.5.42",
|
||||
"@vue/shared": "3.5.42",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz",
|
||||
"integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.42",
|
||||
"@vue/runtime-dom": "3.5.42",
|
||||
"@vue/shared": "3.5.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.39",
|
||||
"dev": true,
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz",
|
||||
"integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/vue-loader-v15": {
|
||||
@@ -3981,11 +4044,8 @@
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -4400,7 +4460,8 @@
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"dev": true,
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -4531,7 +4592,8 @@
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"dev": true,
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
@@ -6048,7 +6110,6 @@
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -6452,7 +6513,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7020,7 +7083,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.18",
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -7037,7 +7102,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -7553,6 +7618,7 @@
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "2.8.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
@@ -8397,6 +8463,7 @@
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -9025,19 +9092,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vant": {
|
||||
"version": "2.13.9",
|
||||
"resolved": "https://registry.npmmirror.com/vant/-/vant-2.13.9.tgz",
|
||||
"integrity": "sha512-AIDYTUKOlqEolrTs18vZanLX9Mq6IftzwOnbozaNFmx9+xMrCeJIqRKefbGUPZ7irQvO6t397+VwRXqCSHnedQ==",
|
||||
"version": "3.6.16",
|
||||
"resolved": "https://registry.npmjs.org/vant/-/vant-3.6.16.tgz",
|
||||
"integrity": "sha512-9pZao0NEeZQ0ZEb6N7SZxtqcdTp24o8IizhZS1G+FtStlXeKOFzCl+Nf1pIWRneQ9Kn+K+mNrfi2eiIZjVVppw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.x",
|
||||
"@vant/icons": "^3.0.2",
|
||||
"@vant/popperjs": "^1.1.0",
|
||||
"@vue/babel-helper-vue-jsx-merge-props": "^1.0.0",
|
||||
"vue-lazyload": "1.2.3"
|
||||
"@vant/icons": "^1.8.0",
|
||||
"@vant/popperjs": "^1.2.1",
|
||||
"@vant/use": "^1.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 2.6.0"
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
@@ -9049,11 +9114,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "2.7.16",
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
|
||||
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-sfc": "2.7.16",
|
||||
"csstype": "^3.1.0"
|
||||
"@vue/compiler-dom": "3.5.42",
|
||||
"@vue/compiler-sfc": "3.5.42",
|
||||
"@vue/runtime-dom": "3.5.42",
|
||||
"@vue/server-renderer": "3.5.42",
|
||||
"@vue/shared": "3.5.42"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-hot-reload-api": {
|
||||
@@ -9061,10 +9139,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-lazyload": {
|
||||
"version": "1.2.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-loader": {
|
||||
"version": "17.4.2",
|
||||
"dev": true,
|
||||
@@ -9102,10 +9176,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "3.6.5",
|
||||
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-3.6.5.tgz",
|
||||
"integrity": "sha512-VYXZQLtjuvKxxcshuRAwjHnciqZVoXAjTjcqBTz4rKc8qih9g9pI3hbDjmqXaHdgL3v8pV6P8Z335XvHzESxLQ==",
|
||||
"license": "MIT"
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-style-loader": {
|
||||
"version": "4.1.3",
|
||||
@@ -9121,31 +9204,11 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-template-compiler": {
|
||||
"version": "2.7.16",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"de-indent": "^1.0.2",
|
||||
"he": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-template-es2015-compiler": {
|
||||
"version": "1.9.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue/node_modules/@vue/compiler-sfc": {
|
||||
"version": "2.7.16",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.23.5",
|
||||
"postcss": "^8.4.14",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"prettier": "^1.18.2 || ^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.2",
|
||||
"dev": true,
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
"@vant/area-data": "^1.5.2",
|
||||
"core-js": "^3.37.1",
|
||||
"pinia": "^2.3.1",
|
||||
"vant": "^2.13.9",
|
||||
"vue": "^2.7.16",
|
||||
"vue-router": "^3.6.5"
|
||||
"vant": "^3.6.16",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"less": "^4.2.0",
|
||||
"less-loader": "^10.2.0",
|
||||
"vue-template-compiler": "^2.7.16"
|
||||
"@vue/compiler-sfc": "^3.5.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const STORAGE_KEY = 'supplied-h5-merchant-session'
|
||||
export const clearMerchantSession = () => sessionStorage.removeItem(STORAGE_KEY)
|
||||
export const saveMerchantSession = session => sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
export const readMerchantSession = () => {
|
||||
try {
|
||||
const session = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null')
|
||||
if (session?.token && session?.user && Date.parse(session.expiresAt) > Date.now()) return session
|
||||
} catch { /* 损坏的会话按未登录处理。 */ }
|
||||
clearMerchantSession()
|
||||
return null
|
||||
}
|
||||
export const merchantLoginLocation = () => {
|
||||
const redirect = window.location.hash.slice(1) || '/joyful-selection/merchant'
|
||||
return `#/joyful-selection/merchant/login?redirect=${encodeURIComponent(redirect)}`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readMerchantSession, clearMerchantSession, merchantLoginLocation } from './merchant-session'
|
||||
|
||||
const API_BASE = process.env.VUE_APP_SUPPLIED_API_BASE || 'http://localhost:8888/api'
|
||||
|
||||
const normalizeBase = base => base.replace(/\/$/, '')
|
||||
|
||||
const resolveFileUrls = value => {
|
||||
if (typeof value === 'string' && /^\/api\/(?:file|upload-preview)\?/.test(value)) {
|
||||
return `${normalizeBase(API_BASE).replace(/\/api$/, '')}${value.replace('/api/upload-preview?', '/api/file?')}`
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(resolveFileUrls)
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveFileUrls(item)]))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export const getFunction = async (name, params = {}) => {
|
||||
const url = new URL(`${normalizeBase(API_BASE)}/${name}`)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok || payload.success === false) {
|
||||
const message = payload.error?.message || payload.msg || '请求失败'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return resolveFileUrls(payload.data)
|
||||
}
|
||||
|
||||
export const postFunction = async (name, body = {}) => {
|
||||
const merchantRequest = name.startsWith('merchant-') || ['login', 'admin-session', 'admin-logout'].includes(name)
|
||||
const token = merchantRequest ? readMerchantSession()?.token : ''
|
||||
const response = await fetch(`${normalizeBase(API_BASE)}${merchantRequest ? '/admin' : ''}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (merchantRequest && name !== 'login' && (response.status === 401 || payload.error?.code === 'UNAUTHORIZED')) {
|
||||
clearMerchantSession()
|
||||
if (name !== 'admin-session' && !window.location.hash.startsWith('#/joyful-selection/merchant/login')) {
|
||||
window.location.replace(merchantLoginLocation())
|
||||
}
|
||||
}
|
||||
if (!response.ok || payload.success === false) {
|
||||
const message = payload.error?.message || payload.msg || '请求失败'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return resolveFileUrls(payload.data)
|
||||
}
|
||||
|
||||
export const uploadImageFunction = async (file, folder = 'after-sale') => {
|
||||
const contentBase64 = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
return postFunction('upload', {
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
contentBase64,
|
||||
folder
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 949 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -14,7 +14,7 @@
|
||||
<van-notice-bar class="notice" left-icon="volume-o" :text="shop.notice" />
|
||||
<van-search v-model="keyword" placeholder="搜索商品" shape="round" />
|
||||
|
||||
<van-tabs v-model="activeCategory" sticky :color="shop.theme" title-active-color="var(--theme)">
|
||||
<van-tabs v-model:active="activeCategory" sticky :color="shop.theme" title-active-color="var(--theme)">
|
||||
<van-tab v-for="category in categories" :key="category" :title="category" />
|
||||
</van-tabs>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<span class="selected">已选 {{ totalCount }} 件</span>
|
||||
</van-submit-bar>
|
||||
|
||||
<van-popup v-model="checkoutVisible" round position="bottom" :style="{ minHeight: '68%' }" closeable>
|
||||
<van-popup v-model:show="checkoutVisible" round position="bottom" :style="{ minHeight: '68%' }" closeable>
|
||||
<section class="checkout">
|
||||
<h2>确认订单</h2>
|
||||
<van-contact-card type="add" name="商城收货人" tel="138****8888" @click="Toast('当前使用默认收货地址')" />
|
||||
@@ -66,7 +66,7 @@
|
||||
</section>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model="successVisible" round closeable class="result-popup">
|
||||
<van-popup v-model:show="successVisible" round closeable class="result-popup">
|
||||
<van-icon name="checked" color="#23b65d" size="62" />
|
||||
<h2>订单提交成功</h2>
|
||||
<p>订单号:{{ submittedOrderId }}</p>
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import Vue from 'vue'
|
||||
import { createApp } from 'vue'
|
||||
import Vant from 'vant'
|
||||
import { createPinia, PiniaVuePlugin } from 'pinia'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'vant/lib/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
Vue.use(Vant)
|
||||
Vue.use(PiniaVuePlugin)
|
||||
Vue.config.productionTip = false
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
pinia,
|
||||
render: h => h(App)
|
||||
}).$mount('#app')
|
||||
createApp(App).use(pinia).use(router).use(Vant).mount('#app')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { readMerchantSession, clearMerchantSession } from '@/api/merchant-session'
|
||||
import SupplierHome from '@/view/index.vue'
|
||||
import JoyfulSelectionLayout from '@/view/joyful-selection/layout.vue'
|
||||
import JoyfulSelectionHome from '@/view/joyful-selection/index.vue'
|
||||
@@ -9,11 +11,19 @@ import JoyfulSelectionCart from '@/view/joyful-selection/cart/index.vue'
|
||||
import JoyfulSelectionCartEdit from '@/view/joyful-selection/cart/edit/index.vue'
|
||||
import JoyfulSelectionCheckout from '@/view/joyful-selection/order/checkout/index.vue'
|
||||
import JoyfulSelectionPayment from '@/view/joyful-selection/order/payment/index.vue'
|
||||
import JoyfulSelectionPaymentConfirm from '@/view/joyful-selection/order/payment-confirm/index.vue'
|
||||
import JoyfulSelectionPaymentSuccess from '@/view/joyful-selection/order/payment-success/index.vue'
|
||||
import JoyfulSelectionAddressList from '@/view/joyful-selection/address/index.vue'
|
||||
import JoyfulSelectionAddressEdit from '@/view/joyful-selection/address/edit/index.vue'
|
||||
import JoyfulSelectionOrders from '@/view/joyful-selection/profile/orders/index.vue'
|
||||
import JoyfulSelectionOrderDetail from '@/view/joyful-selection/profile/order-detail/index.vue'
|
||||
import JoyfulSelectionAfterSale from '@/view/joyful-selection/after-sale/index/index.vue'
|
||||
import JoyfulSelectionAfterSaleDetail from '@/view/joyful-selection/after-sale/detail/index.vue'
|
||||
import JoyfulSelectionAfterSaleSelect from '@/view/joyful-selection/after-sale/select/index.vue'
|
||||
import JoyfulSelectionAfterSaleReturn from '@/view/joyful-selection/after-sale/return/index.vue'
|
||||
import JoyfulSelectionAfterSaleExchange from '@/view/joyful-selection/after-sale/exchange/index.vue'
|
||||
import JoyfulSelectionMerchant from '@/view/joyful-selection/merchant/index.vue'
|
||||
import JoyfulSelectionMerchantAfterSaleDetail from '@/view/joyful-selection/merchant/after-sale-detail.vue'
|
||||
import JoyfulSelectionSupplierLogs from '@/view/joyful-selection/supplier-logs/index.vue'
|
||||
import JoyfulSelectionProfile from '@/view/joyful-selection/profile/index.vue'
|
||||
import JoyfulSelectionPoints from '@/view/joyful-selection/profile/points/index.vue'
|
||||
import HomeLiving from '@/view/home-living/index.vue'
|
||||
@@ -21,16 +31,16 @@ import HappyShopping from '@/view/happy-shopping/index.vue'
|
||||
import FreshFruit from '@/view/fresh-fruit/index.vue'
|
||||
import CuteSelection from '@/view/cute-selection/index.vue'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
export default new Router({
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: SupplierHome },
|
||||
{ path: '/joyful-selection/merchant/login', component: () => import('@/view/joyful-selection/merchant/login.vue') },
|
||||
{
|
||||
path: '/joyful-selection',
|
||||
component: JoyfulSelectionLayout,
|
||||
children: [
|
||||
{ path: '', redirect: 'home' },
|
||||
{ path: '', redirect: '/joyful-selection/home' },
|
||||
{ path: 'home', component: JoyfulSelectionHome },
|
||||
{ path: 'products', component: JoyfulSelectionProductList },
|
||||
{ path: 'product-detail', component: JoyfulSelectionProductDetail },
|
||||
@@ -38,11 +48,19 @@ export default new Router({
|
||||
{ path: 'cart/edit', component: JoyfulSelectionCartEdit },
|
||||
{ path: 'checkout', component: JoyfulSelectionCheckout },
|
||||
{ path: 'payment', component: JoyfulSelectionPayment },
|
||||
{ path: 'payment-confirm', component: JoyfulSelectionPaymentConfirm },
|
||||
{ path: 'payment-success', component: JoyfulSelectionPaymentSuccess },
|
||||
{ path: 'address', component: JoyfulSelectionAddressList },
|
||||
{ path: 'address/edit', component: JoyfulSelectionAddressEdit },
|
||||
{ path: 'orders', component: JoyfulSelectionOrders },
|
||||
{ path: 'order-detail', component: JoyfulSelectionOrderDetail },
|
||||
{ path: 'after-sale', component: JoyfulSelectionAfterSale },
|
||||
{ path: 'after-sale/detail', component: JoyfulSelectionAfterSaleDetail },
|
||||
{ path: 'after-sale/select', component: JoyfulSelectionAfterSaleSelect },
|
||||
{ path: 'after-sale/return', component: JoyfulSelectionAfterSaleReturn },
|
||||
{ path: 'after-sale/exchange', component: JoyfulSelectionAfterSaleExchange },
|
||||
{ path: 'merchant', component: JoyfulSelectionMerchant, meta: { merchantAuth: true } },
|
||||
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail, meta: { merchantAuth: true } },
|
||||
{ path: 'supplier-logs', component: JoyfulSelectionSupplierLogs },
|
||||
{ path: 'profile', component: JoyfulSelectionProfile },
|
||||
{ path: 'points', component: JoyfulSelectionPoints }
|
||||
]
|
||||
@@ -53,3 +71,19 @@ export default new Router({
|
||||
{ path: '/cute-selection', component: CuteSelection }
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach(async to => {
|
||||
if (!to.meta.merchantAuth) return true
|
||||
const login = { path: '/joyful-selection/merchant/login', query: { redirect: to.fullPath } }
|
||||
if (!readMerchantSession()) return login
|
||||
try {
|
||||
await postFunction('admin-session')
|
||||
return true
|
||||
} catch (error) {
|
||||
clearMerchantSession()
|
||||
Toast(error.message || '登录已失效,请重新登录')
|
||||
return login
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<main class="card-skeleton">
|
||||
<div v-if="showNav" class="nav-line"></div>
|
||||
<section v-for="item in cards" :key="item" class="card-line">
|
||||
<van-skeleton title :row="rows" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CardSkeleton',
|
||||
props: {
|
||||
cards: {
|
||||
type: Number,
|
||||
default: 4
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
showNav: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.card-skeleton {
|
||||
min-height: 100vh;
|
||||
padding: 12px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.nav-line {
|
||||
height: 46px;
|
||||
margin: -12px -12px 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.card-line {
|
||||
margin-bottom: 10px;
|
||||
padding: 16px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<main class="detail-skeleton">
|
||||
<div class="nav-line"></div>
|
||||
<div class="image-line"></div>
|
||||
<section class="card-line">
|
||||
<van-skeleton title :row="3" />
|
||||
</section>
|
||||
<section class="card-line">
|
||||
<van-skeleton title :row="2" />
|
||||
</section>
|
||||
<section class="grid-line">
|
||||
<span v-for="item in 4" :key="item"></span>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DetailSkeleton'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-skeleton {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.nav-line {
|
||||
height: 54px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.image-line {
|
||||
height: 365px;
|
||||
background: #eef0f4;
|
||||
}
|
||||
|
||||
.card-line,
|
||||
.grid-line {
|
||||
margin: 10px 12px 0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.card-line {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.grid-line span {
|
||||
height: 70px;
|
||||
border-radius: 8px;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<main class="list-skeleton">
|
||||
<div v-if="showNav" class="nav-line"></div>
|
||||
<div v-if="showSearch" class="search-line"></div>
|
||||
<section v-if="showBanner" class="banner-line"></section>
|
||||
<div v-if="showTabs" class="tabs-line">
|
||||
<span v-for="item in 5" :key="item"></span>
|
||||
</div>
|
||||
<article v-for="item in rows" :key="item" class="item-line">
|
||||
<div class="thumb-line"></div>
|
||||
<div class="content-line">
|
||||
<van-skeleton title :row="3" />
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ListSkeleton',
|
||||
props: {
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
showNav: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showBanner: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showTabs: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.list-skeleton {
|
||||
min-height: 100vh;
|
||||
padding: 12px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.nav-line,
|
||||
.search-line,
|
||||
.banner-line,
|
||||
.tabs-line,
|
||||
.item-line {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.nav-line {
|
||||
height: 46px;
|
||||
margin: -12px -12px 12px;
|
||||
}
|
||||
|
||||
.search-line {
|
||||
height: 42px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 21px;
|
||||
}
|
||||
|
||||
.banner-line {
|
||||
height: 108px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tabs-line {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
margin: -12px -12px 12px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.tabs-line span {
|
||||
width: 44px;
|
||||
height: 14px;
|
||||
border-radius: 8px;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.item-line {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.thumb-line {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
flex: none;
|
||||
border-radius: 6px;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.content-line {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<main class="profile-skeleton">
|
||||
<section class="hero-line">
|
||||
<div class="avatar-line"></div>
|
||||
<van-skeleton title :row="1" />
|
||||
</section>
|
||||
<section class="asset-line">
|
||||
<div class="coin-line"></div>
|
||||
<van-skeleton title :row="1" />
|
||||
</section>
|
||||
<section v-for="item in 2" :key="item" class="panel-line">
|
||||
<van-skeleton title :row="2" />
|
||||
<div class="icon-row">
|
||||
<span v-for="icon in 5" :key="icon"></span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ProfileSkeleton'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.profile-skeleton {
|
||||
min-height: 100vh;
|
||||
padding: 18px 10px 92px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.hero-line {
|
||||
min-height: 164px;
|
||||
padding: 68px 18px 28px;
|
||||
border-radius: 0 0 16px 16px;
|
||||
background: #f2eefc;
|
||||
}
|
||||
|
||||
.avatar-line {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
float: left;
|
||||
margin-right: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.asset-line,
|
||||
.panel-line {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.asset-line {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.coin-line {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: #fff0e8;
|
||||
}
|
||||
|
||||
.icon-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.icon-row span {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<list-skeleton :rows="5" show-nav show-search show-banner />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListSkeleton from './components/ListSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'SupplierHomeSkeleton',
|
||||
components: { ListSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<card-skeleton :cards="4" :rows="2" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardSkeleton from '../../../components/CardSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAddressEditSkeleton',
|
||||
components: { CardSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<list-skeleton :rows="4" show-nav />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListSkeleton from '../../components/ListSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAddressListSkeleton',
|
||||
components: { ListSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<list-skeleton :rows="5" show-nav />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListSkeleton from '../../../components/ListSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionCartEditSkeleton',
|
||||
components: { ListSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<list-skeleton :rows="4" show-nav />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListSkeleton from '../../components/ListSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionCartSkeleton',
|
||||
components: { ListSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<list-skeleton :rows="4" :show-nav="false" show-search show-banner />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListSkeleton from '../components/ListSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionHomeSkeleton',
|
||||
components: { ListSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<card-skeleton :cards="5" :rows="3" :show-nav="false" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardSkeleton from '../components/CardSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionLayoutSkeleton',
|
||||
components: { CardSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<card-skeleton :cards="5" :rows="2" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardSkeleton from '../../../components/CardSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionCheckoutSkeleton',
|
||||
components: { CardSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<card-skeleton :cards="3" :rows="2" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardSkeleton from '../../../components/CardSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionPaymentSuccessSkeleton',
|
||||
components: { CardSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<card-skeleton :cards="4" :rows="3" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardSkeleton from '../../../components/CardSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionPaymentSkeleton',
|
||||
components: { CardSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<detail-skeleton />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DetailSkeleton from '../../components/DetailSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionProductDetailSkeleton',
|
||||
components: { DetailSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<list-skeleton :rows="6" show-nav show-search />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListSkeleton from '../../components/ListSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionProductListSkeleton',
|
||||
components: { ListSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<profile-skeleton />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ProfileSkeleton from '../../components/ProfileSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionProfileSkeleton',
|
||||
components: { ProfileSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<card-skeleton :cards="4" :rows="3" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardSkeleton from '../../../components/CardSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionOrderDetailSkeleton',
|
||||
components: { CardSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<list-skeleton :rows="4" show-nav show-tabs />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListSkeleton from '../../../components/ListSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionOrdersSkeleton',
|
||||
components: { ListSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<card-skeleton :cards="4" :rows="2" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardSkeleton from '../../../components/CardSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionPointsSkeleton',
|
||||
components: { CardSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<card-skeleton :cards="5" :rows="3" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardSkeleton from '../components/CardSkeleton.vue'
|
||||
|
||||
export default {
|
||||
name: 'SupplierShopSkeleton',
|
||||
components: { CardSkeleton }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const UI_KEY = 'joyful-selection-ui'
|
||||
|
||||
const readUi = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(UI_KEY) || '{}')
|
||||
} catch (error) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const saveUi = data => {
|
||||
localStorage.setItem(UI_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore('joyfulSelectionUi', {
|
||||
state: () => ({
|
||||
ordersTab: 0,
|
||||
merchantTab: 'ship',
|
||||
afterSaleTab: 'processing',
|
||||
loaded: false
|
||||
}),
|
||||
actions: {
|
||||
loadUi() {
|
||||
const data = readUi()
|
||||
this.ordersTab = Number(data.ordersTab || 0)
|
||||
this.merchantTab = data.merchantTab || 'ship'
|
||||
this.afterSaleTab = data.afterSaleTab || 'processing'
|
||||
this.loaded = true
|
||||
},
|
||||
persistUi() {
|
||||
saveUi({
|
||||
ordersTab: this.ordersTab,
|
||||
merchantTab: this.merchantTab,
|
||||
afterSaleTab: this.afterSaleTab
|
||||
})
|
||||
this.loaded = true
|
||||
},
|
||||
setOrdersTab(value) {
|
||||
this.ordersTab = Number(value || 0)
|
||||
this.persistUi()
|
||||
},
|
||||
setMerchantTab(value) {
|
||||
this.merchantTab = value || 'ship'
|
||||
this.persistUi()
|
||||
},
|
||||
setAfterSaleTab(value) {
|
||||
this.afterSaleTab = value || 'processing'
|
||||
this.persistUi()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
|
||||
const USER_KEY = 'joyful-selection-user'
|
||||
const DEFAULT_SUPPLIER_IDENTIFICATION = (process.env.VUE_APP_SUPPLIER_IDENTIFICATION || '').toLowerCase()
|
||||
let bridgeTokenPromise = null
|
||||
|
||||
const readUser = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(USER_KEY) || '{}')
|
||||
} catch (error) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const saveUser = data => {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('joyfulSelectionUser', {
|
||||
state: () => ({
|
||||
userId: '',
|
||||
nickname: '',
|
||||
supplierIdentification: '',
|
||||
supplierId: '',
|
||||
supplierName: '',
|
||||
pointsBalance: 0,
|
||||
loaded: false,
|
||||
loadingToken: false
|
||||
}),
|
||||
getters: {
|
||||
hasToken: state => Boolean(state.supplierId && state.userId)
|
||||
},
|
||||
actions: {
|
||||
loadUser() {
|
||||
const data = readUser()
|
||||
this.userId = data.userId || ''
|
||||
this.nickname = data.nickname || ''
|
||||
const supplierIdentification = DEFAULT_SUPPLIER_IDENTIFICATION || data.supplierIdentification || ''
|
||||
const supplierChanged = Boolean(data.supplierIdentification && supplierIdentification !== data.supplierIdentification)
|
||||
|
||||
this.supplierIdentification = supplierIdentification
|
||||
this.supplierId = supplierChanged ? '' : data.supplierId || ''
|
||||
this.supplierName = supplierChanged ? '' : data.supplierName || ''
|
||||
this.pointsBalance = supplierChanged ? 0 : Number(data.pointsBalance || 0)
|
||||
this.loaded = true
|
||||
},
|
||||
persistUser() {
|
||||
saveUser({
|
||||
userId: this.userId,
|
||||
nickname: this.nickname,
|
||||
supplierIdentification: this.supplierIdentification,
|
||||
supplierId: this.supplierId,
|
||||
supplierName: this.supplierName,
|
||||
pointsBalance: this.pointsBalance
|
||||
})
|
||||
this.loaded = true
|
||||
},
|
||||
async hydrateFromRoute(query = {}) {
|
||||
if (!this.loaded) {
|
||||
this.loadUser()
|
||||
}
|
||||
|
||||
const keyword = query.keyword || new URLSearchParams(window.location.search).get('keyword') || ''
|
||||
let nextUserId = query.userId || ''
|
||||
let nextNickname
|
||||
|
||||
if (keyword) {
|
||||
const sessionKeyword = String(keyword).replace(/ /g, '+')
|
||||
console.log('[supplier-session] keyword', {
|
||||
routeKeyword: query.keyword,
|
||||
searchKeyword: new URLSearchParams(window.location.search).get('keyword') || '',
|
||||
sessionKeyword
|
||||
})
|
||||
const data = await postFunction('supplier-session', { keyword: sessionKeyword })
|
||||
nextUserId = data?.userId || ''
|
||||
nextNickname = data?.userInfo?.nickname || ''
|
||||
}
|
||||
|
||||
if (nextUserId && nextUserId !== this.userId) {
|
||||
this.userId = nextUserId
|
||||
this.nickname = ''
|
||||
this.pointsBalance = 0
|
||||
}
|
||||
if (nextNickname !== undefined) this.nickname = nextNickname
|
||||
if (!this.supplierIdentification) {
|
||||
this.supplierIdentification = DEFAULT_SUPPLIER_IDENTIFICATION
|
||||
}
|
||||
|
||||
this.persistUser()
|
||||
},
|
||||
async fetchSupplierInfo() {
|
||||
if (!this.loaded) {
|
||||
this.loadUser()
|
||||
}
|
||||
if (!this.supplierIdentification) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await postFunction('supplier-info', {
|
||||
supplierIdentification: this.supplierIdentification
|
||||
})
|
||||
|
||||
this.supplierId = data?.supplierId || ''
|
||||
this.supplierName = data?.supplierName || ''
|
||||
this.persistUser()
|
||||
},
|
||||
async fetchBridgeToken() {
|
||||
if (!this.loaded) {
|
||||
this.loadUser()
|
||||
}
|
||||
if (!this.userId || !this.supplierIdentification || this.hasToken) {
|
||||
return
|
||||
}
|
||||
if (this.loadingToken && bridgeTokenPromise) {
|
||||
return bridgeTokenPromise
|
||||
}
|
||||
|
||||
this.loadingToken = true
|
||||
bridgeTokenPromise = (async () => {
|
||||
if (!this.supplierId) {
|
||||
await this.fetchSupplierInfo()
|
||||
}
|
||||
|
||||
const data = await postFunction('supplier-token', {
|
||||
userId: this.userId,
|
||||
supplierIdentification: this.supplierIdentification
|
||||
})
|
||||
|
||||
this.supplierId = data?.supplierId || this.supplierId
|
||||
this.supplierName = data?.supplierName || this.supplierName
|
||||
this.persistUser()
|
||||
})()
|
||||
|
||||
try {
|
||||
await bridgeTokenPromise
|
||||
} finally {
|
||||
this.loadingToken = false
|
||||
bridgeTokenPromise = null
|
||||
}
|
||||
},
|
||||
async fetchUserPoints() {
|
||||
if (!this.loaded) {
|
||||
this.loadUser()
|
||||
}
|
||||
await this.fetchBridgeToken()
|
||||
|
||||
if (!this.userId || !this.supplierId) {
|
||||
return this.pointsBalance
|
||||
}
|
||||
|
||||
const data = await postFunction('supplier-points', {
|
||||
userId: this.userId,
|
||||
supplierId: this.supplierId
|
||||
})
|
||||
|
||||
this.pointsBalance = Number(data?.userPoints || 0)
|
||||
this.persistUser()
|
||||
return this.pointsBalance
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,14 +1,26 @@
|
||||
<template>
|
||||
<supplier-shop :shop="shop" />
|
||||
<div>
|
||||
<supplier-shop-skeleton v-if="loading" />
|
||||
<supplier-shop v-else :shop="shop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SupplierShop from '@/components/SupplierShop.vue'
|
||||
import SupplierShopSkeleton from '@/skeleton-view/shop/index.vue'
|
||||
import { shops } from '../shop-data'
|
||||
|
||||
export default {
|
||||
name: 'CuteSelection',
|
||||
components: { SupplierShop },
|
||||
data: () => ({ shop: shops.cuteSelection })
|
||||
components: { SupplierShop, SupplierShopSkeleton },
|
||||
data: () => ({
|
||||
loading: true,
|
||||
shop: shops.cuteSelection
|
||||
}),
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
<template>
|
||||
<supplier-shop :shop="shop" />
|
||||
<div>
|
||||
<supplier-shop-skeleton v-if="loading" />
|
||||
<supplier-shop v-else :shop="shop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SupplierShop from '@/components/SupplierShop.vue'
|
||||
import SupplierShopSkeleton from '@/skeleton-view/shop/index.vue'
|
||||
import { shops } from '../shop-data'
|
||||
|
||||
export default {
|
||||
name: 'FreshFruit',
|
||||
components: { SupplierShop },
|
||||
data: () => ({ shop: shops.freshFruit })
|
||||
components: { SupplierShop, SupplierShopSkeleton },
|
||||
data: () => ({
|
||||
loading: true,
|
||||
shop: shops.freshFruit
|
||||
}),
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
<template>
|
||||
<supplier-shop :shop="shop" />
|
||||
<div>
|
||||
<supplier-shop-skeleton v-if="loading" />
|
||||
<supplier-shop v-else :shop="shop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SupplierShop from '@/components/SupplierShop.vue'
|
||||
import SupplierShopSkeleton from '@/skeleton-view/shop/index.vue'
|
||||
import { shops } from '../shop-data'
|
||||
|
||||
export default {
|
||||
name: 'HappyShopping',
|
||||
components: { SupplierShop },
|
||||
data: () => ({ shop: shops.happyShopping })
|
||||
components: { SupplierShop, SupplierShopSkeleton },
|
||||
data: () => ({
|
||||
loading: true,
|
||||
shop: shops.happyShopping
|
||||
}),
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
<template>
|
||||
<supplier-shop :shop="shop" />
|
||||
<div>
|
||||
<supplier-shop-skeleton v-if="loading" />
|
||||
<supplier-shop v-else :shop="shop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SupplierShop from '@/components/SupplierShop.vue'
|
||||
import SupplierShopSkeleton from '@/skeleton-view/shop/index.vue'
|
||||
import { shops } from '../shop-data'
|
||||
|
||||
export default {
|
||||
name: 'HomeLiving',
|
||||
components: { SupplierShop },
|
||||
data: () => ({ shop: shops.homeLiving })
|
||||
components: { SupplierShop, SupplierShopSkeleton },
|
||||
data: () => ({
|
||||
loading: true,
|
||||
shop: shops.homeLiving
|
||||
}),
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<div>
|
||||
<supplier-home-skeleton v-if="loading" />
|
||||
<main v-else class="page">
|
||||
<van-nav-bar fixed placeholder title="首页" :border="false" />
|
||||
<van-search v-model="keyword" class="fixed-search" shape="round" placeholder="搜索供应商名称或商品" />
|
||||
|
||||
@@ -45,7 +47,8 @@
|
||||
<span><b>{{ service.title }}</b><small>{{ service.description }}</small></span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -57,10 +60,15 @@ import supplierHome from '@/assets/supplier-icons/home-living.png'
|
||||
import supplierHappy from '@/assets/supplier-icons/happy-shopping.png'
|
||||
import supplierFruit from '@/assets/supplier-icons/fresh-fruit.png'
|
||||
import supplierCute from '@/assets/supplier-icons/cute-selection.png'
|
||||
import SupplierHomeSkeleton from '@/skeleton-view/index.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SupplierHomeSkeleton
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
keyword: '',
|
||||
banners: [
|
||||
{ id: 'welfare', image: bannerWelfare, alt: '优质供应商精选' },
|
||||
@@ -81,6 +89,11 @@ export default {
|
||||
]
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
computed: {
|
||||
filteredSuppliers() {
|
||||
const keyword = this.keyword.trim()
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
const ADDRESS_KEY = 'joyful-selection-addresses'
|
||||
const CHOSEN_ADDRESS_KEY = 'joyful-selection-chosen-address-id'
|
||||
|
||||
const defaultAddresses = [
|
||||
{
|
||||
id: 'addr-1',
|
||||
name: '张三',
|
||||
tel: '13888888888',
|
||||
province: '浙江省',
|
||||
city: '杭州市',
|
||||
county: '余杭区',
|
||||
addressDetail: '文一西路 888 号未来科技城海创园5幢1203室',
|
||||
areaCode: '330110',
|
||||
postalCode: '310000',
|
||||
isDefault: true
|
||||
}
|
||||
]
|
||||
|
||||
export const readAddresses = () => {
|
||||
try {
|
||||
const addresses = JSON.parse(localStorage.getItem(ADDRESS_KEY) || '[]')
|
||||
return addresses.length ? addresses : defaultAddresses
|
||||
} catch (error) {
|
||||
return defaultAddresses
|
||||
}
|
||||
}
|
||||
|
||||
export const saveAddresses = addresses => {
|
||||
localStorage.setItem(ADDRESS_KEY, JSON.stringify(addresses))
|
||||
}
|
||||
|
||||
export const readChosenAddressId = () => {
|
||||
return localStorage.getItem(CHOSEN_ADDRESS_KEY) || readAddresses().find(item => item.isDefault)?.id || readAddresses()[0]?.id || ''
|
||||
return localStorage.getItem(CHOSEN_ADDRESS_KEY) || ''
|
||||
}
|
||||
|
||||
export const saveChosenAddressId = id => {
|
||||
@@ -42,7 +13,7 @@ export const formatAddress = address => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `${address.province}${address.city}${address.county}${address.addressDetail}`
|
||||
return `${address.province || ''}${address.city || ''}${address.county || ''}${address.addressDetail || ''}`
|
||||
}
|
||||
|
||||
export const createAddressId = () => `addr-${Date.now()}`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<main class="address-edit-page">
|
||||
<div>
|
||||
<joyful-selection-address-edit-skeleton v-if="loading" />
|
||||
<main v-else class="address-edit-page">
|
||||
<van-nav-bar :title="pageTitle" left-arrow @click-left="$router.back()" />
|
||||
<van-address-edit
|
||||
:area-list="areaList"
|
||||
@@ -8,28 +10,39 @@
|
||||
show-delete
|
||||
show-set-default
|
||||
show-search-result
|
||||
:tel-maxlength="11"
|
||||
:search-result="searchResult"
|
||||
:area-columns-placeholder="['请选择', '请选择', '请选择']"
|
||||
@save="onSave"
|
||||
@delete="onDelete"
|
||||
@change-detail="onChangeDetail"
|
||||
/>
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Toast } from 'vant'
|
||||
import { areaList } from '@vant/area-data'
|
||||
import { createAddressId, readAddresses, saveAddresses, saveChosenAddressId } from '../address-data'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { createAddressId, saveChosenAddressId } from '../address-data'
|
||||
import JoyfulSelectionAddressEditSkeleton from '@/skeleton-view/joyful-selection/address/edit/index.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAddressEdit',
|
||||
components: {
|
||||
JoyfulSelectionAddressEditSkeleton
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
areaList,
|
||||
searchResult: [],
|
||||
addressInfo: {}
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId', 'userId']),
|
||||
editId() {
|
||||
return this.$route.query.id
|
||||
},
|
||||
@@ -38,48 +51,65 @@ export default {
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const address = readAddresses().find(item => item.id === this.editId)
|
||||
|
||||
if (address) {
|
||||
this.addressInfo = { ...address }
|
||||
}
|
||||
this.loadAddress()
|
||||
},
|
||||
methods: {
|
||||
onSave(content) {
|
||||
const addresses = readAddresses()
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
async loadAddress() {
|
||||
this.loading = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
|
||||
if (!this.editId || !this.supplierId || !this.userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await postFunction('address-list', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId
|
||||
})
|
||||
const address = (data?.addresses || []).find(item => item.id === this.editId)
|
||||
|
||||
if (address) {
|
||||
this.addressInfo = { ...address }
|
||||
}
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询地址失败,请稍后再试')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async onSave(content) {
|
||||
const nextAddress = {
|
||||
...content,
|
||||
id: this.editId || content.id || createAddressId()
|
||||
}
|
||||
const nextAddresses = addresses.filter(item => item.id !== nextAddress.id)
|
||||
|
||||
if (nextAddress.isDefault) {
|
||||
nextAddresses.forEach(item => { item.isDefault = false })
|
||||
}
|
||||
|
||||
if (!nextAddresses.length && !nextAddress.isDefault) {
|
||||
nextAddress.isDefault = true
|
||||
}
|
||||
|
||||
saveAddresses([nextAddress, ...nextAddresses])
|
||||
await this.fetchBridgeToken()
|
||||
await postFunction('address-save', {
|
||||
...nextAddress,
|
||||
addressId: nextAddress.id,
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
isDefault: ['checkout', 'after-sale'].includes(this.$route.query.from)
|
||||
})
|
||||
saveChosenAddressId(nextAddress.id)
|
||||
Toast('保存成功')
|
||||
this.$router.back()
|
||||
},
|
||||
onDelete() {
|
||||
async onDelete() {
|
||||
if (!this.editId) {
|
||||
this.$router.back()
|
||||
return
|
||||
}
|
||||
|
||||
const addresses = readAddresses().filter(item => item.id !== this.editId)
|
||||
|
||||
if (addresses.length && !addresses.some(item => item.isDefault)) {
|
||||
addresses[0].isDefault = true
|
||||
}
|
||||
|
||||
saveAddresses(addresses)
|
||||
saveChosenAddressId(addresses[0]?.id || '')
|
||||
await this.fetchBridgeToken()
|
||||
await postFunction('address-delete', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
addressId: this.editId
|
||||
})
|
||||
saveChosenAddressId('')
|
||||
Toast('删除成功')
|
||||
this.$router.back()
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<main class="address-page">
|
||||
<div>
|
||||
<joyful-selection-address-list-skeleton v-if="loading" />
|
||||
<main v-else class="address-page">
|
||||
<van-nav-bar title="地址管理" left-arrow @click-left="$router.back()" />
|
||||
<van-address-list
|
||||
v-model="chosenAddressId"
|
||||
@@ -11,20 +13,31 @@
|
||||
@edit="onEdit"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatAddress, readAddresses, readChosenAddressId, saveChosenAddressId } from './address-data'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { formatAddress, readChosenAddressId, saveChosenAddressId } from './address-data'
|
||||
import JoyfulSelectionAddressListSkeleton from '@/skeleton-view/joyful-selection/address/index.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAddressList',
|
||||
components: {
|
||||
JoyfulSelectionAddressListSkeleton
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
chosenAddressId: '',
|
||||
addresses: [],
|
||||
disabledList: []
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId', 'userId']),
|
||||
addressList() {
|
||||
return this.addresses.map(item => ({
|
||||
id: item.id,
|
||||
@@ -36,23 +49,62 @@ export default {
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.addresses = readAddresses()
|
||||
this.chosenAddressId = readChosenAddressId()
|
||||
this.loadAddresses()
|
||||
},
|
||||
methods: {
|
||||
onSelect(item) {
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
async loadAddresses() {
|
||||
this.loading = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
|
||||
if (!this.supplierId || !this.userId) {
|
||||
Toast('用户信息不完整,请重新进入商城')
|
||||
this.addresses = []
|
||||
return
|
||||
}
|
||||
|
||||
const data = await postFunction('address-list', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId
|
||||
})
|
||||
this.addresses = data?.addresses || []
|
||||
this.chosenAddressId = readChosenAddressId() || this.addresses.find(item => item.isDefault)?.id || this.addresses[0]?.id || ''
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询地址失败,请稍后再试')
|
||||
this.addresses = []
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async onSelect(item) {
|
||||
this.chosenAddressId = item.id
|
||||
saveChosenAddressId(item.id)
|
||||
|
||||
if (this.$route.query.from === 'checkout') {
|
||||
if (['checkout', 'after-sale'].includes(this.$route.query.from)) {
|
||||
const address = this.addresses.find(addressItem => addressItem.id === item.id)
|
||||
if (address) {
|
||||
try {
|
||||
await postFunction('address-save', {
|
||||
...address,
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
addressId: address.id,
|
||||
isDefault: true
|
||||
})
|
||||
} catch (error) {
|
||||
Toast(error.message || '璁剧疆榛樿鍦板潃澶辫触锛岃绋嶅悗鍐嶈瘯')
|
||||
return
|
||||
}
|
||||
}
|
||||
this.$router.back()
|
||||
}
|
||||
},
|
||||
onAdd() {
|
||||
this.$router.push('/joyful-selection/address/edit')
|
||||
this.$router.push({ path: '/joyful-selection/address/edit', query: { from: this.$route.query.from } })
|
||||
},
|
||||
onEdit(item) {
|
||||
this.$router.push({ path: '/joyful-selection/address/edit', query: { id: item.id } })
|
||||
this.$router.push({ path: '/joyful-selection/address/edit', query: { id: item.id, from: this.$route.query.from } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
<template>
|
||||
<main class="after-sale-detail-page">
|
||||
<van-nav-bar title="售后详情" left-arrow @click-left="$router.replace('/joyful-selection/after-sale')" />
|
||||
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="5" />
|
||||
<van-skeleton title :row="3" />
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="status-card" :class="statusCardClass">
|
||||
<div class="status-icon">
|
||||
<van-icon :name="statusIcon" />
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ statusTitle }}</h2>
|
||||
<p>{{ statusDesc }}</p>
|
||||
<button v-if="afterSale.auditRemark" type="button" @click="showAuditRemark">查看审核意见</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="detail-card progress-card">
|
||||
<h3>售后进度</h3>
|
||||
<div class="timeline">
|
||||
<div v-for="step in progressSteps" :key="step.key" class="timeline-item" :class="step.state">
|
||||
<span class="timeline-dot">
|
||||
<van-icon v-if="step.state === 'done'" name="success" />
|
||||
</span>
|
||||
<div class="timeline-main">
|
||||
<b>{{ step.title }}</b>
|
||||
<em>{{ step.time || step.subTitle }}</em>
|
||||
</div>
|
||||
<p>{{ step.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="detail-card product-card">
|
||||
<h3>售后商品</h3>
|
||||
<article v-for="item in products" :key="item.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="item.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ item.name || '-' }}</h4>
|
||||
<p>{{ skuText(item) }}</p>
|
||||
<b>¥{{ item.price || afterSale.amount || '0.00' }}</b>
|
||||
</div>
|
||||
<span>x{{ item.number || 1 }}</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="!loading && showReturnInfo" class="detail-card address-card">
|
||||
<h3>{{ returnInfoTitle }}</h3>
|
||||
<p><span>联系人</span><b>{{ afterSale.pickupName || '-' }}</b></p>
|
||||
<p><span>联系电话</span><b>{{ maskedMobile(afterSale.pickupMobile) }}</b></p>
|
||||
<p><span>地址</span><b>{{ afterSale.pickupAddress || '-' }}</b></p>
|
||||
<p v-if="afterSale.userLogisticsNo" class="logistics-row">
|
||||
<span>快递单号</span><b>{{ afterSale.userLogisticsCompany || '快递' }} {{ afterSale.userLogisticsNo }}</b>
|
||||
</p>
|
||||
<p v-else-if="needUserShipNo" class="logistics-row muted" @click="fillLogisticsNo">
|
||||
<span>快递单号</span><b>请填写快递单号</b><van-icon name="arrow" />
|
||||
</p>
|
||||
<em v-if="needUserShipNo">请在7天内填写快递单号,逾期将自动关闭售后申请</em>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="detail-card info-card">
|
||||
<h3>售后信息</h3>
|
||||
<p class="service-no-row"><span>售后单号</span><b class="scrollable-no">{{ afterSale.serviceOrderId || '-' }}</b><button type="button" @click="copyServiceNo">复制</button></p>
|
||||
<p><span>子订单号</span><b>{{ afterSale.orderNumberId || afterSale.subOrderId || '-' }}</b></p>
|
||||
<p><span>售后类型</span><b>{{ afterSale.serviceTypeText || '-' }}</b></p>
|
||||
<p><span>售后原因</span><b>{{ afterSale.reason || '-' }}</b></p>
|
||||
<p><span>问题描述</span><b>{{ afterSale.problemDescription || '-' }}</b></p>
|
||||
<p><span>申请时间</span><b>{{ afterSale.applyTime || '-' }}</b></p>
|
||||
<p v-if="!isUnsignedAfterSale"><span>取件方式</span><b>{{ pickupMethodText }}</b></p>
|
||||
<p v-if="!isUnsignedAfterSale"><span>取件地址</span><b>{{ pickupAddressText }}</b></p>
|
||||
<div v-if="images.length" class="voucher-block">
|
||||
<h4>上传凭证</h4>
|
||||
<div>
|
||||
<van-image v-for="image in images" :key="image.url" :src="image.url" fit="cover" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer v-show="!loading" class="bottom-actions">
|
||||
<button v-if="canCancel" type="button">撤销申请</button>
|
||||
<button v-if="needUserShipNo" type="button" class="primary" @click="fillLogisticsNo">填写物流单号</button>
|
||||
<button v-else-if="canConfirmExchangeReceive" type="button" class="primary" @click="confirmExchangeReceive">确认收货</button>
|
||||
<button v-else type="button" class="primary" @click="contactService">联系客服</button>
|
||||
</footer>
|
||||
|
||||
<van-popup v-model:show="showLogisticsPopup" round position="bottom">
|
||||
<section class="logistics-popup">
|
||||
<h3>填写物流单号</h3>
|
||||
<van-field v-model="logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
|
||||
<van-field v-model="logisticsNo" label="快递单号" placeholder="请输入快递单号" />
|
||||
<van-button block class="logistics-submit" :loading="submittingLogistics" @click="submitLogisticsNo">确认提交</van-button>
|
||||
</section>
|
||||
</van-popup>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Dialog, Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const SERVICE_TYPE = {
|
||||
RETURN: 4,
|
||||
EXCHANGE: 5
|
||||
}
|
||||
|
||||
const SERVICE_STATE = {
|
||||
PENDING_AUDIT: 0,
|
||||
APPROVED: 1,
|
||||
REJECTED: 2,
|
||||
USER_SHIPPED: 3,
|
||||
RECEIVED: 4,
|
||||
FINISHED: 5,
|
||||
CANCELLED: 6
|
||||
}
|
||||
|
||||
const LOGISTICS_STATE = {
|
||||
NONE: 0,
|
||||
WAIT_USER_SHIP: 1,
|
||||
USER_SHIPPED: 2,
|
||||
MERCHANT_RECEIVED: 3,
|
||||
MERCHANT_SHIPPED: 4,
|
||||
USER_RECEIVED: 5
|
||||
}
|
||||
|
||||
const SKU_COLOR_MAP = {
|
||||
cream: '奶油白',
|
||||
blue: '雾霾蓝',
|
||||
orange: '暖橙色'
|
||||
}
|
||||
|
||||
const SKU_SIZE_MAP = {
|
||||
standard: '标准款',
|
||||
plus: '升级款'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAfterSaleDetail',
|
||||
data: () => ({
|
||||
afterSale: {},
|
||||
loading: false,
|
||||
showLogisticsPopup: false,
|
||||
logisticsCompany: '',
|
||||
logisticsNo: '',
|
||||
submittingLogistics: false
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId', 'userId']),
|
||||
products() {
|
||||
return this.afterSale.products || []
|
||||
},
|
||||
images() {
|
||||
return Array.isArray(this.afterSale.images)
|
||||
? this.afterSale.images.map(item => ({
|
||||
url: item.url || item.previewUrl || item
|
||||
})).filter(item => item.url)
|
||||
: []
|
||||
},
|
||||
isExchange() {
|
||||
return Number(this.afterSale.serviceType) === SERVICE_TYPE.EXCHANGE
|
||||
},
|
||||
isUnsignedAfterSale() {
|
||||
if (this.afterSale.isSigned === false || this.afterSale.isSigned === 0 || this.afterSale.isSigned === 'false') {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.afterSale.isSigned === undefined &&
|
||||
Number(this.afterSale.logisticsState) === LOGISTICS_STATE.NONE &&
|
||||
!this.afterSale.pickupMethod &&
|
||||
!this.afterSale.pickupName &&
|
||||
!this.afterSale.pickupMobile &&
|
||||
!this.afterSale.pickupAddress
|
||||
},
|
||||
statusCardClass() {
|
||||
if (Number(this.afterSale.serviceState) === SERVICE_STATE.REJECTED) return 'rejected'
|
||||
if (Number(this.afterSale.serviceState) === SERVICE_STATE.APPROVED) return 'approved'
|
||||
if (Number(this.afterSale.serviceState) === SERVICE_STATE.FINISHED) return 'finished'
|
||||
return 'pending'
|
||||
},
|
||||
statusIcon() {
|
||||
if (this.statusCardClass === 'approved' || this.statusCardClass === 'finished') return 'success'
|
||||
if (this.statusCardClass === 'rejected') return 'cross'
|
||||
return 'clock-o'
|
||||
},
|
||||
statusTitle() {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
const logisticsState = Number(this.afterSale.logisticsState)
|
||||
if (serviceState === SERVICE_STATE.REJECTED) return '审核未通过'
|
||||
if (serviceState === SERVICE_STATE.FINISHED) return '售后完成'
|
||||
if (logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED) return '商家已发货'
|
||||
if (logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED && this.isExchange) return '商家待发货'
|
||||
if (logisticsState === LOGISTICS_STATE.USER_SHIPPED) return '商品寄回中'
|
||||
if (serviceState === SERVICE_STATE.APPROVED) return '审核通过'
|
||||
return '审核中'
|
||||
},
|
||||
statusDesc() {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
const logisticsState = Number(this.afterSale.logisticsState)
|
||||
if (serviceState === SERVICE_STATE.REJECTED) return '商家未通过您的售后申请,请查看审核意见'
|
||||
if (serviceState === SERVICE_STATE.FINISHED) return '售后流程已完成'
|
||||
if (logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED) return '商家已发出换货商品,请确认收货'
|
||||
if (logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED && this.isExchange) return '商家已收到商品,等待发出换货商品'
|
||||
if (logisticsState === LOGISTICS_STATE.USER_SHIPPED) return '您已寄回商品,等待商家收货'
|
||||
if (this.isExchange && this.isUnsignedAfterSale && serviceState === SERVICE_STATE.APPROVED) return '商家已通过您的申请,等待商家发货'
|
||||
if (serviceState === SERVICE_STATE.APPROVED) return '商家已通过您的申请,请按要求寄回商品'
|
||||
return '商家正在审核您的售后申请,请耐心等待'
|
||||
},
|
||||
progressSteps() {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
const logisticsState = Number(this.afterSale.logisticsState)
|
||||
const steps = [
|
||||
{
|
||||
key: 'apply',
|
||||
title: '提交申请',
|
||||
time: this.afterSale.applyTime,
|
||||
desc: '您已成功提交售后申请',
|
||||
state: 'done'
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
title: '商家审核',
|
||||
time: this.afterSale.auditTime,
|
||||
desc: serviceState === SERVICE_STATE.REJECTED ? '商家审核未通过' : serviceState >= SERVICE_STATE.APPROVED ? '商家已审核通过' : '商家正在审核中',
|
||||
state: serviceState >= SERVICE_STATE.APPROVED || serviceState === SERVICE_STATE.REJECTED ? 'done' : 'current'
|
||||
},
|
||||
{
|
||||
key: 'userShip',
|
||||
title: '寄回商品',
|
||||
time: this.afterSale.userShipTime,
|
||||
desc: logisticsState >= LOGISTICS_STATE.USER_SHIPPED ? '您已寄回商品' : '请在7天内寄回商品',
|
||||
state: logisticsState >= LOGISTICS_STATE.USER_SHIPPED ? 'done' : logisticsState === LOGISTICS_STATE.WAIT_USER_SHIP ? 'current' : 'todo'
|
||||
},
|
||||
{
|
||||
key: 'merchantReceive',
|
||||
title: '商家收货',
|
||||
time: this.afterSale.merchantReceiveTime,
|
||||
desc: logisticsState >= LOGISTICS_STATE.MERCHANT_RECEIVED ? '商家已收到商品' : '商家尚未收到商品',
|
||||
state: logisticsState >= LOGISTICS_STATE.MERCHANT_RECEIVED ? 'done' : logisticsState === LOGISTICS_STATE.USER_SHIPPED ? 'current' : 'todo'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.isUnsignedAfterSale && !this.isExchange) {
|
||||
steps.splice(2)
|
||||
steps.push({
|
||||
key: 'finish',
|
||||
title: '售后完成',
|
||||
time: this.afterSale.finishTime,
|
||||
desc: serviceState === SERVICE_STATE.FINISHED ? '商家同意,售后完成' : '商家同意后售后完成',
|
||||
state: serviceState === SERVICE_STATE.FINISHED ? 'done' : serviceState >= SERVICE_STATE.APPROVED ? 'current' : 'todo'
|
||||
})
|
||||
|
||||
return steps.map((step, index) => ({
|
||||
...step,
|
||||
state: serviceState === SERVICE_STATE.REJECTED && index > 1 ? 'todo' : step.state,
|
||||
subTitle: step.state === 'done' ? '' : '-'
|
||||
}))
|
||||
}
|
||||
|
||||
if (this.isExchange) {
|
||||
if (this.isUnsignedAfterSale) {
|
||||
steps.splice(2)
|
||||
}
|
||||
|
||||
steps.push(
|
||||
{
|
||||
key: 'merchantShip',
|
||||
title: '商家发货',
|
||||
time: this.afterSale.merchantShipTime,
|
||||
desc: logisticsState >= LOGISTICS_STATE.MERCHANT_SHIPPED ? '商家已发出换货商品' : '等待商家发出换货商品',
|
||||
state: logisticsState >= LOGISTICS_STATE.MERCHANT_SHIPPED ? 'done' : (this.isUnsignedAfterSale ? serviceState >= SERVICE_STATE.APPROVED : logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED) ? 'current' : 'todo'
|
||||
},
|
||||
{
|
||||
key: 'userReceive',
|
||||
title: '用户收货',
|
||||
time: this.afterSale.finishTime,
|
||||
desc: logisticsState >= LOGISTICS_STATE.USER_RECEIVED ? '您已收到换货商品' : '等待用户收货',
|
||||
state: logisticsState >= LOGISTICS_STATE.USER_RECEIVED ? 'done' : logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED ? 'current' : 'todo'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
steps.push({
|
||||
key: 'finish',
|
||||
title: '售后完成',
|
||||
time: this.afterSale.finishTime,
|
||||
desc: serviceState === SERVICE_STATE.FINISHED ? '售后流程已完成' : '售后流程未完成',
|
||||
state: serviceState === SERVICE_STATE.FINISHED ? 'done' : !this.isExchange && logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED ? 'current' : 'todo'
|
||||
})
|
||||
|
||||
return steps.map((step, index) => ({
|
||||
...step,
|
||||
state: serviceState === SERVICE_STATE.REJECTED && index > 1 ? 'todo' : step.state,
|
||||
subTitle: step.state === 'done' ? '' : '-'
|
||||
}))
|
||||
},
|
||||
showReturnInfo() {
|
||||
return Number(this.afterSale.serviceState) >= SERVICE_STATE.APPROVED && !this.isUnsignedAfterSale
|
||||
},
|
||||
returnInfoTitle() {
|
||||
return Number(this.afterSale.logisticsState) >= LOGISTICS_STATE.WAIT_USER_SHIP ? '退货地址' : '取件地址'
|
||||
},
|
||||
needUserShipNo() {
|
||||
return Number(this.afterSale.logisticsState) === LOGISTICS_STATE.WAIT_USER_SHIP && !this.afterSale.userLogisticsNo
|
||||
},
|
||||
canCancel() {
|
||||
return Number(this.afterSale.serviceState) === SERVICE_STATE.PENDING_AUDIT
|
||||
},
|
||||
canConfirmExchangeReceive() {
|
||||
return this.isExchange && Number(this.afterSale.logisticsState) === LOGISTICS_STATE.MERCHANT_SHIPPED
|
||||
},
|
||||
pickupMethodText() {
|
||||
return this.afterSale.pickupMethod === 'mail' ? '自行寄回' : '上门取件'
|
||||
},
|
||||
pickupAddressText() {
|
||||
return this.afterSale.pickupAddress || '-'
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadDetail()
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
skuText(item = {}) {
|
||||
const sku = String(item.sku || item.skuText || '').trim()
|
||||
const parts = sku.split('-').slice(-2).filter(Boolean)
|
||||
const color = SKU_COLOR_MAP[parts[0]] || parts[0]
|
||||
const size = SKU_SIZE_MAP[parts[1]] || parts[1]
|
||||
|
||||
return color || size ? [color, size].filter(Boolean).join(' | ') : '-'
|
||||
},
|
||||
maskedMobile(mobile = '') {
|
||||
return String(mobile).replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2') || '-'
|
||||
},
|
||||
async loadDetail() {
|
||||
try {
|
||||
this.loading = true
|
||||
await this.fetchBridgeToken()
|
||||
const data = await postFunction('after-sale-detail', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
serviceOrderId: String(this.$route.query.serviceOrderId || '')
|
||||
})
|
||||
|
||||
this.afterSale = data?.afterSale || {}
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询售后详情失败,请稍后再试')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
copyServiceNo() {
|
||||
if (navigator.clipboard && this.afterSale.serviceOrderId) {
|
||||
navigator.clipboard.writeText(this.afterSale.serviceOrderId)
|
||||
}
|
||||
Toast('已复制')
|
||||
},
|
||||
showAuditRemark() {
|
||||
Dialog.alert({
|
||||
title: '审核意见',
|
||||
message: this.afterSale.auditRemark
|
||||
})
|
||||
},
|
||||
fillLogisticsNo() {
|
||||
this.logisticsCompany = this.afterSale.userLogisticsCompany || ''
|
||||
this.logisticsNo = this.afterSale.userLogisticsNo || ''
|
||||
this.showLogisticsPopup = true
|
||||
},
|
||||
async submitLogisticsNo() {
|
||||
if (this.submittingLogistics) {
|
||||
return
|
||||
}
|
||||
|
||||
const logisticsNo = String(this.logisticsNo || '').trim()
|
||||
const logisticsCompany = String(this.logisticsCompany || '').trim()
|
||||
if (!logisticsCompany) {
|
||||
Toast('请输入快递公司')
|
||||
return
|
||||
}
|
||||
if (!logisticsNo) {
|
||||
Toast('请输入快递单号')
|
||||
return
|
||||
}
|
||||
|
||||
this.submittingLogistics = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
await postFunction('after-sale-logistics', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
serviceOrderId: this.afterSale.serviceOrderId,
|
||||
logisticsState: LOGISTICS_STATE.USER_SHIPPED,
|
||||
userLogisticsCompany: logisticsCompany,
|
||||
userLogisticsNo: logisticsNo
|
||||
})
|
||||
Toast('物流单号已提交')
|
||||
this.showLogisticsPopup = false
|
||||
await this.loadDetail()
|
||||
} catch (error) {
|
||||
Toast(error.message || '提交物流单号失败,请稍后再试')
|
||||
} finally {
|
||||
this.submittingLogistics = false
|
||||
}
|
||||
},
|
||||
async confirmExchangeReceive() {
|
||||
await Dialog.confirm({
|
||||
title: '确认收到换货商品?',
|
||||
message: '确认后该换货售后将完成。'
|
||||
})
|
||||
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
await postFunction('after-sale-logistics', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
serviceOrderId: this.afterSale.serviceOrderId,
|
||||
logisticsState: LOGISTICS_STATE.USER_RECEIVED
|
||||
})
|
||||
Toast('已确认收货')
|
||||
await this.loadDetail()
|
||||
} catch (error) {
|
||||
Toast(error.message || '确认收货失败,请稍后再试')
|
||||
}
|
||||
},
|
||||
contactService() {
|
||||
Toast('客服功能开发中')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.after-sale-detail-page {
|
||||
min-height: 100vh;
|
||||
padding: 56px 10px 78px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.after-sale-detail-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.skeleton-card,
|
||||
.detail-card {
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.skeleton-card ::v-deep .van-skeleton + .van-skeleton {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: flex;
|
||||
min-height: 122px;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
padding: 18px 22px;
|
||||
border: 1px solid #ffe2d7;
|
||||
background: linear-gradient(135deg, #fff7f2 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.status-card.approved,
|
||||
.status-card.finished {
|
||||
border-color: #d7f3e9;
|
||||
background: linear-gradient(135deg, #f2fffa 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.status-card.rejected {
|
||||
border-color: #ffe2e2;
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
margin-right: 18px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
background: linear-gradient(180deg, #ff7b2f 0%, #ff4b18 100%);
|
||||
}
|
||||
|
||||
.approved .status-icon,
|
||||
.finished .status-icon {
|
||||
background: linear-gradient(180deg, #41d596 0%, #20b879 100%);
|
||||
}
|
||||
|
||||
.rejected .status-icon {
|
||||
background: linear-gradient(180deg, #ff7676 0%, #f04444 100%);
|
||||
}
|
||||
|
||||
.status-card h2 {
|
||||
margin: 0 0 8px;
|
||||
color: #ff4b18;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.approved h2,
|
||||
.finished h2 {
|
||||
color: #20b879;
|
||||
}
|
||||
|
||||
.rejected h2 {
|
||||
color: #f04444;
|
||||
}
|
||||
|
||||
.status-card p {
|
||||
margin: 0;
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.status-card button {
|
||||
height: 30px;
|
||||
margin-top: 10px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0 0 16px;
|
||||
color: #111;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding: 2px 0 0 6px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
bottom: -2px;
|
||||
left: 8px;
|
||||
width: 1px;
|
||||
background: #e2e2e2;
|
||||
}
|
||||
|
||||
.timeline-item.done::before,
|
||||
.timeline-item.current::before {
|
||||
background: #ff5b20;
|
||||
}
|
||||
|
||||
.timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #cfd3d8;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.timeline-item.done .timeline-dot {
|
||||
border-color: #ff5b20;
|
||||
background: #ff5b20;
|
||||
}
|
||||
|
||||
.timeline-item.current .timeline-dot {
|
||||
border: 4px solid #ff5b20;
|
||||
}
|
||||
|
||||
.timeline-main {
|
||||
width: 126px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.timeline-main b {
|
||||
display: block;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.timeline-item.current .timeline-main b {
|
||||
color: #ff4b18;
|
||||
}
|
||||
|
||||
.timeline-main em {
|
||||
display: block;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.timeline-item > p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.product-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 2px 8px 0 10px;
|
||||
}
|
||||
|
||||
.product-info h4 {
|
||||
margin: 0 0 7px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.product-info p {
|
||||
margin: 0 0 7px;
|
||||
color: #8f8f8f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-info b {
|
||||
color: #ff5b20;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.product-row > span {
|
||||
align-self: center;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.address-card p,
|
||||
.info-card p {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin: 0 0 14px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.address-card span,
|
||||
.info-card span {
|
||||
width: 76px;
|
||||
flex: none;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.info-card .service-no-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.address-card b,
|
||||
.info-card b {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: #333;
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.info-card .scrollable-no {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.info-card .scrollable-no::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.info-card button {
|
||||
flex: none;
|
||||
align-self: center;
|
||||
height: 24px;
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #ff5b20;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.address-card em {
|
||||
display: block;
|
||||
margin-top: -2px;
|
||||
color: #ff7b2f;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.logistics-row {
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.logistics-row.muted b {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.logistics-row .van-icon {
|
||||
flex: none;
|
||||
margin-left: 6px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.voucher-block h4 {
|
||||
margin: 2px 0 10px;
|
||||
color: #111;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.voucher-block > div {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.voucher-block .van-image {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bottom-actions button {
|
||||
height: 44px;
|
||||
flex: 1;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bottom-actions .primary {
|
||||
border-color: #ff4b18;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #ff6a2a 0%, #ff4b18 100%);
|
||||
}
|
||||
|
||||
.logistics-popup {
|
||||
padding: 18px 14px 16px;
|
||||
}
|
||||
|
||||
.logistics-popup h3 {
|
||||
margin: 0 0 14px;
|
||||
color: #222;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logistics-popup ::v-deep .van-cell {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.logistics-submit {
|
||||
height: 42px;
|
||||
margin-top: 18px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #ff6a2a 0%, #ff4b18 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,964 @@
|
||||
<template>
|
||||
<main class="after-sale-return-page">
|
||||
<van-nav-bar title="申请售后(换货)" left-arrow @click-left="$router.back()" />
|
||||
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="4" />
|
||||
<van-skeleton title :row="5" />
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="product-card">
|
||||
<h3>售后商品</h3>
|
||||
<article v-for="item in products" :key="item.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="item.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ item.name || '-' }}</h4>
|
||||
<p>{{ skuText(item) }}</p>
|
||||
<b>¥{{ item.price || currentSubOrder.amount || '0.00' }}</b>
|
||||
</div>
|
||||
<span>x{{ item.number || 1 }}</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="form-card">
|
||||
<div class="method-row">
|
||||
<span>是否签收</span>
|
||||
<button type="button" :class="{ active: isSigned === false }" @click="isSigned = false">未签收</button>
|
||||
<button type="button" :class="{ active: isSigned === true }" @click="isSigned = true">已签收</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading && isSigned" class="form-card">
|
||||
<div class="method-row">
|
||||
<span>取件方式</span>
|
||||
<button type="button" :class="{ active: pickupMethod === 'door' }" @click="pickupMethod = 'door'">上门取件</button>
|
||||
<button type="button" :class="{ active: pickupMethod === 'mail' }" @click="pickupMethod = 'mail'">自行寄回</button>
|
||||
</div>
|
||||
<div v-if="pickupMethod === 'door'" class="address-row" @click="goAddressList">
|
||||
<span>取件地址</span>
|
||||
<div>
|
||||
<p>{{ receiverNameText }} <b>{{ receiverMobileText }}</b></p>
|
||||
<p>{{ receiverAddressText }}</p>
|
||||
</div>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="exchange-card">
|
||||
<div class="exchange-head method-row">
|
||||
<h3>换货商品</h3>
|
||||
</div>
|
||||
<div class="exchange-body">
|
||||
<article v-if="selectedExchangeGoods.sku" class="product-row">
|
||||
<van-image class="product-thumb" :src="selectedExchangeGoods.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ selectedExchangeGoods.name || '-' }}</h4>
|
||||
<p>{{ skuText(selectedExchangeGoods) }}</p>
|
||||
<b>¥{{ selectedExchangeGoods.price || '0.00' }}</b>
|
||||
</div>
|
||||
<span>x1</span>
|
||||
</article>
|
||||
<p v-else>请选择与原商品价格一致的换货商品</p>
|
||||
<button type="button" @click="openExchangeGoodsPopup">{{ selectedExchangeGoods.sku ? '重新选择' : '选择商品' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="form-card">
|
||||
<div class="form-row reason-row" @click="showReasonPopup = true">
|
||||
<span>售后原因</span>
|
||||
<div class="reason-value">
|
||||
<b>{{ selectedReasonText }}</b>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="desc-block">
|
||||
<h3>问题描述</h3>
|
||||
<textarea v-model="description" maxlength="200" placeholder="请详细描述遇到的问题(选填)"></textarea>
|
||||
<em>{{ description.length }}/200</em>
|
||||
</div>
|
||||
|
||||
<div class="upload-block">
|
||||
<h3>上传图片(最多3张)</h3>
|
||||
<van-uploader
|
||||
v-model="fileList"
|
||||
class="upload-list"
|
||||
multiple
|
||||
:max-count="3"
|
||||
:after-read="afterRead"
|
||||
@delete="onDeleteImage"
|
||||
/>
|
||||
<p>选填,上传有助于更快处理您的申请</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<van-button v-show="!loading" class="submit-button" block :loading="submitting" @click="submitReturn">提交申请</van-button>
|
||||
<van-popup v-model:show="showReasonPopup" round position="bottom">
|
||||
<section class="reason-popup">
|
||||
<h3>请选择换货原因</h3>
|
||||
<van-radio-group v-model="selectedReason">
|
||||
<van-cell
|
||||
v-for="item in reasonOptions"
|
||||
:key="item.value"
|
||||
clickable
|
||||
@click="selectReason(item)"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ item.label }}</span>
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<van-radio :name="item.value" checked-color="#ff5b20" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-radio-group>
|
||||
</section>
|
||||
</van-popup>
|
||||
<van-popup v-model:show="showExchangeGoodsPopup" round position="bottom" :style="{ height: '80vh' }">
|
||||
<section class="exchange-popup">
|
||||
<h3>选择换货商品</h3>
|
||||
<p>仅可选择价格为 ¥{{ originalProductPrice }} 的商品</p>
|
||||
<div class="exchange-goods-list">
|
||||
<article v-for="item in samePriceGoods" :key="item.sku" class="exchange-goods-item" @click="selectExchangeGoods(item)">
|
||||
<van-image class="exchange-goods-thumb" :src="item.image" fit="cover" />
|
||||
<div>
|
||||
<h4>{{ item.name || '-' }}</h4>
|
||||
<p>{{ skuText(item) }}</p>
|
||||
<b>¥{{ item.price }}</b>
|
||||
</div>
|
||||
<van-icon v-if="selectedExchangeGoods.sku === item.sku" name="success" />
|
||||
</article>
|
||||
<van-empty v-if="samePriceGoods.length === 0" description="暂无同价商品" />
|
||||
</div>
|
||||
</section>
|
||||
</van-popup>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Toast } from 'vant'
|
||||
import { getFunction, postFunction, uploadImageFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const ORDER_EVENT_TYPE = {
|
||||
EXCHANGE_ORDER: 5
|
||||
}
|
||||
|
||||
const ORDER_STATE = {
|
||||
RECEIVED: 4,
|
||||
COMPLETED: 5
|
||||
}
|
||||
|
||||
const SKU_COLOR_MAP = {
|
||||
cream: '奶油白',
|
||||
blue: '雾霾蓝',
|
||||
orange: '暖橙色'
|
||||
}
|
||||
|
||||
const SKU_SIZE_MAP = {
|
||||
standard: '标准款',
|
||||
plus: '升级款'
|
||||
}
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
{ value: 'QUALITY_PROBLEM', label: '商品质量问题' },
|
||||
{ value: 'DAMAGED_GOODS', label: '商品破损' },
|
||||
{ value: 'WRONG_GOODS', label: '发错商品' },
|
||||
{ value: 'SPEC_NOT_FIT', label: '尺寸规格不合适' },
|
||||
{ value: 'DESCRIPTION_MISMATCH', label: '商品与描述不符' },
|
||||
{ value: 'OTHER', label: '其他' }
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAfterSaleExchange',
|
||||
data: () => ({
|
||||
loading: false,
|
||||
currentOrder: {},
|
||||
currentSubOrder: {},
|
||||
pickupAddress: {},
|
||||
description: '',
|
||||
pickupMethod: 'door',
|
||||
showReasonPopup: false,
|
||||
showExchangeGoodsPopup: false,
|
||||
selectedReason: '',
|
||||
exchangeGoods: [],
|
||||
selectedExchangeGoods: {},
|
||||
fileList: [],
|
||||
uploadedImages: [],
|
||||
submitting: false,
|
||||
isSigned: false
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId', 'userId']),
|
||||
products() {
|
||||
return this.currentSubOrder.products || []
|
||||
},
|
||||
originalProductPrice() {
|
||||
return Number(this.products[0]?.price || this.currentSubOrder.amount || 0).toFixed(2)
|
||||
},
|
||||
samePriceGoods() {
|
||||
return this.exchangeGoods
|
||||
.map(item => this.normalizeExchangeGoods(item))
|
||||
.filter(item => Number(item.price || 0).toFixed(2) === this.originalProductPrice)
|
||||
},
|
||||
receiverNameText() {
|
||||
return this.pickupAddress.name || this.currentSubOrder.receiverName || this.currentOrder.receiverName || '-'
|
||||
},
|
||||
receiverMobileText() {
|
||||
return this.pickupAddress.tel || this.currentSubOrder.receiverMobile || this.currentOrder.receiverMobile || '-'
|
||||
},
|
||||
receiverAddressText() {
|
||||
if (this.pickupAddress.id) {
|
||||
return `${this.pickupAddress.province || ''}${this.pickupAddress.city || ''}${this.pickupAddress.county || ''}${this.pickupAddress.addressDetail || ''}` || '-'
|
||||
}
|
||||
|
||||
return this.currentSubOrder.receiverAddress || this.currentOrder.receiverAddress || '-'
|
||||
},
|
||||
reasonOptions() {
|
||||
return REASON_OPTIONS
|
||||
},
|
||||
selectedReasonText() {
|
||||
return this.reasonOptions.find(item => item.value === this.selectedReason)?.label || '请选择售后原因'
|
||||
},
|
||||
hasProductShipped() {
|
||||
// Assuming currentSubOrder.logisticsState indicates if the product has been shipped to the user.
|
||||
// If the product is not in 'NONE' (0) or 'WAIT_USER_SHIP' (1) state, it's considered shipped.
|
||||
const logisticsState = Number(this.currentSubOrder.logisticsState);
|
||||
return logisticsState !== 0 && logisticsState !== 1;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadOrderDetail()
|
||||
this.loadPickupAddress()
|
||||
},
|
||||
activated() {
|
||||
this.loadOrderDetail()
|
||||
this.loadPickupAddress()
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
skuText(item = {}) {
|
||||
if (item.skuText) {
|
||||
return item.skuText
|
||||
}
|
||||
|
||||
const sku = String(item.sku || '').trim()
|
||||
const parts = sku.split('-').slice(-2).filter(Boolean)
|
||||
const color = SKU_COLOR_MAP[parts[0]] || parts[0]
|
||||
const size = SKU_SIZE_MAP[parts[1]] || parts[1]
|
||||
|
||||
return color || size ? [color, size].filter(Boolean).join(' | ') : '-'
|
||||
},
|
||||
normalizeExchangeGoods(item = {}) {
|
||||
const color = 'cream'
|
||||
const size = 'standard'
|
||||
const sku = item.sku || `${item.id}-${color}-${size}`
|
||||
const skuText = [SKU_COLOR_MAP[color] || color, SKU_SIZE_MAP[size] || size].filter(Boolean).join(' | ')
|
||||
|
||||
return {
|
||||
...item,
|
||||
sku,
|
||||
skuText
|
||||
}
|
||||
},
|
||||
selectReason(item) {
|
||||
this.selectedReason = item.value
|
||||
this.showReasonPopup = false
|
||||
},
|
||||
async openExchangeGoodsPopup() {
|
||||
this.showExchangeGoodsPopup = true
|
||||
if (this.exchangeGoods.length) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getFunction('product-list')
|
||||
this.exchangeGoods = data?.products || []
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询商品列表失败')
|
||||
}
|
||||
},
|
||||
selectExchangeGoods(item) {
|
||||
if (Number(item.price || 0).toFixed(2) !== this.originalProductPrice) {
|
||||
Toast('换货商品价格必须与原商品一致')
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedExchangeGoods = item
|
||||
this.showExchangeGoodsPopup = false
|
||||
},
|
||||
inferIsSigned(order = {}, subOrder = {}) {
|
||||
const state = Number(subOrder.orderState ?? order.orderState)
|
||||
|
||||
return [ORDER_STATE.RECEIVED, ORDER_STATE.COMPLETED].includes(state)
|
||||
},
|
||||
checkSignedMatch() {
|
||||
const realSigned = this.inferIsSigned(this.currentOrder, this.currentSubOrder)
|
||||
if (this.isSigned !== realSigned) {
|
||||
Toast(realSigned ? '该订单已签收,请选择已签收' : '该订单未签收,请选择未签收')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
async afterRead(fileItems) {
|
||||
const items = Array.isArray(fileItems) ? fileItems : [fileItems]
|
||||
await Promise.all(items.map(async item => {
|
||||
item.status = 'uploading'
|
||||
item.message = '上传中'
|
||||
try {
|
||||
const uploaded = await uploadImageFunction(item.file, 'after-sale')
|
||||
item.url = uploaded.previewUrl || uploaded.url
|
||||
item.objectName = uploaded.objectName
|
||||
item.status = 'done'
|
||||
item.message = ''
|
||||
this.uploadedImages.push({
|
||||
objectName: uploaded.objectName,
|
||||
url: uploaded.previewUrl || uploaded.url
|
||||
})
|
||||
} catch (error) {
|
||||
item.status = 'failed'
|
||||
item.message = '上传失败'
|
||||
Toast(error.message || '图片上传失败')
|
||||
}
|
||||
}))
|
||||
},
|
||||
onDeleteImage(item) {
|
||||
this.uploadedImages = this.uploadedImages.filter(image => image.objectName !== item.objectName)
|
||||
},
|
||||
async loadPickupAddress() {
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
if (!this.supplierId || !this.userId) {
|
||||
this.pickupAddress = {}
|
||||
return
|
||||
}
|
||||
|
||||
const data = await postFunction('address-default', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId
|
||||
})
|
||||
this.pickupAddress = data?.address || {}
|
||||
} catch (error) {
|
||||
this.pickupAddress = {}
|
||||
}
|
||||
},
|
||||
goAddressList() {
|
||||
this.$router.push({ path: '/joyful-selection/address', query: { from: 'after-sale' } })
|
||||
},
|
||||
async loadOrderDetail() {
|
||||
this.loading = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
const data = await postFunction('order-detail', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
mainOrderId: String(this.$route.query.mainOrderId || ''),
|
||||
orderId: String(this.$route.query.orderId || '')
|
||||
})
|
||||
|
||||
this.currentOrder = data?.order || {}
|
||||
this.currentSubOrder = data?.subOrder || {}
|
||||
this.isSigned = this.inferIsSigned(this.currentOrder, this.currentSubOrder)
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询订单详情失败,请稍后再试')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async submitReturn() {
|
||||
if (this.submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
|
||||
const mainOrderId = this.currentOrder.mainOrderId || this.currentOrder.orderId || this.$route.query.mainOrderId
|
||||
const orderId = this.currentSubOrder.orderId || this.$route.query.orderId
|
||||
if (!this.supplierId || !this.userId || !mainOrderId || !orderId) {
|
||||
Toast('订单信息不完整,请稍后再试')
|
||||
return
|
||||
}
|
||||
if (!this.checkSignedMatch()) {
|
||||
return
|
||||
}
|
||||
if (!this.selectedExchangeGoods.sku) {
|
||||
Toast('请选择换货商品')
|
||||
return
|
||||
}
|
||||
|
||||
// 售后原因必填
|
||||
if (!this.selectedReason || this.selectedReason === '') {
|
||||
Toast('请选择售后原因');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已签收,且取件方式是上门取件,则取件信息必填
|
||||
if (this.isSigned && this.pickupMethod === 'door') {
|
||||
if (!this.receiverNameText || this.receiverNameText === '-' ||
|
||||
!this.receiverMobileText || this.receiverMobileText === '-' ||
|
||||
!this.receiverAddressText || this.receiverAddressText === '-') {
|
||||
Toast('请填写完整的取件信息');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const applied = await postFunction('after-sale-apply', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
serviceType: ORDER_EVENT_TYPE.EXCHANGE_ORDER,
|
||||
orderId,
|
||||
subOrderId: orderId,
|
||||
mainOrderId,
|
||||
amount: Number(this.currentSubOrder.amount || 0),
|
||||
reason: this.selectedReasonText === '请选择售后原因' ? '换货' : this.selectedReasonText,
|
||||
reasonType: this.selectedReason || 'OTHER',
|
||||
problemDescription: this.description,
|
||||
pickupMethod: this.isSigned ? this.pickupMethod : undefined, // Conditionally set
|
||||
pickupName: this.isSigned && this.receiverNameText !== '-' ? this.receiverNameText : undefined, // Conditionally set
|
||||
pickupMobile: this.isSigned && this.receiverMobileText !== '-' ? this.receiverMobileText : undefined, // Conditionally set
|
||||
pickupAddress: this.isSigned && this.receiverAddressText !== '-' ? this.receiverAddressText : undefined, // Conditionally set
|
||||
exchangeProductSku: this.selectedExchangeGoods.sku,
|
||||
exchangeProductName: this.selectedExchangeGoods.name,
|
||||
exchangeProductPrice: this.selectedExchangeGoods.price,
|
||||
images: this.uploadedImages
|
||||
})
|
||||
|
||||
Toast('敬请期待')
|
||||
const serviceOrderId = applied?.serviceOrderId || applied?.afterSale?.serviceOrderId
|
||||
if (serviceOrderId) {
|
||||
this.$router.replace({
|
||||
path: '/joyful-selection/after-sale/detail',
|
||||
query: { serviceOrderId }
|
||||
})
|
||||
} else {
|
||||
this.$router.back()
|
||||
}
|
||||
} catch (error) {
|
||||
Toast(error.message || '换货申请失败,请稍后再试')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.after-sale-return-page {
|
||||
min-height: 100vh;
|
||||
padding: 46px 10px 72px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.after-sale-return-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.product-card,
|
||||
.skeleton-card,
|
||||
.exchange-card,
|
||||
.form-card {
|
||||
margin-top: 10px;
|
||||
padding: 0 12px 5px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.product-card h3,
|
||||
.exchange-card h3,
|
||||
.desc-block h3,
|
||||
.upload-block h3 {
|
||||
margin: 0 0 13px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.exchange-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.exchange-head h3 {
|
||||
margin-bottom: 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.exchange-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.exchange-body button {
|
||||
align-self: center;
|
||||
flex: none;
|
||||
min-width: 92px;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #ff5b20;
|
||||
border-radius: 6px;
|
||||
color: #ff5b20;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
background: #fff7f2;
|
||||
}
|
||||
|
||||
.exchange-body > p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.exchange-card .product-row {
|
||||
flex: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.exchange-card .product-info {
|
||||
padding-left: 8px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.product-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 2px 8px 0 10px;
|
||||
}
|
||||
|
||||
.product-info h4 {
|
||||
margin: 0 0 7px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.product-info p {
|
||||
margin: 0 0 7px;
|
||||
color: #8f8f8f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-info b {
|
||||
color: #ff5b20;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.product-row > span {
|
||||
align-self: center;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-row,
|
||||
.method-row,
|
||||
.address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 46px;
|
||||
color: #222;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-row span,
|
||||
.method-row span,
|
||||
.address-row span {
|
||||
flex: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-row b {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.reason-row {
|
||||
height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reason-value {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.reason-value b {
|
||||
overflow: hidden;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reason-value .van-icon {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: #aaa;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.form-row .van-icon {
|
||||
margin-left: 6px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.desc-block {
|
||||
padding: 14px 0 12px;
|
||||
border-bottom: 1px solid #f2f2f2;
|
||||
}
|
||||
|
||||
.desc-block textarea {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.desc-block textarea::placeholder {
|
||||
color: #c2c2c2;
|
||||
}
|
||||
|
||||
.desc-block em {
|
||||
display: block;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.method-row {
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.method-row span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.method-row button {
|
||||
display: inline-flex;
|
||||
min-width: 92px;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0 16px;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
|
||||
.method-row button.active {
|
||||
border: 1px solid #ff5b20;
|
||||
color: #ff5b20;
|
||||
background: #fff7f2;
|
||||
}
|
||||
|
||||
.address-row {
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.address-row .van-icon {
|
||||
align-self: center;
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: #aaa;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.address-row > div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin-left: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.address-row p {
|
||||
margin: 0 0 4px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.address-row b {
|
||||
margin-left: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.upload-block {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.upload-list ::v-deep .van-uploader__wrapper {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.upload-list ::v-deep .van-uploader__upload,
|
||||
.upload-list ::v-deep .van-uploader__preview-image {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-list ::v-deep .van-uploader__preview {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
display: flex;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.upload-item .van-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.upload-item span {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.upload-block > p {
|
||||
margin: 10px 0 0;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
right: auto;
|
||||
bottom: 10px;
|
||||
left: 12px;
|
||||
width: calc(100% - 24px);
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 44px;
|
||||
background: linear-gradient(90deg, #ff6a2a 0%, #ff4b18 100%);
|
||||
}
|
||||
|
||||
.submit-button ::v-deep .van-button__content {
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reason-popup {
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 16px 0 10px;
|
||||
}
|
||||
|
||||
.reason-popup h3 {
|
||||
margin: 0 0 8px;
|
||||
color: #222;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reason-popup ::v-deep .van-radio-group {
|
||||
max-height: calc(70vh - 56px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.reason-popup ::v-deep .van-cell {
|
||||
align-items: center;
|
||||
padding: 13px 18px;
|
||||
}
|
||||
|
||||
.reason-popup ::v-deep .van-cell__title {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.exchange-popup {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px 12px 12px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.exchange-popup h3 {
|
||||
margin: 0 0 6px;
|
||||
color: #222;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exchange-popup > p {
|
||||
flex: none;
|
||||
margin: 0 0 12px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exchange-goods-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.exchange-goods-item {
|
||||
display: flex;
|
||||
min-height: 116px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px 10px 10px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, .045);
|
||||
}
|
||||
|
||||
.exchange-goods-item + .exchange-goods-item {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.exchange-goods-thumb {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
min-width: 88px;
|
||||
max-width: 88px;
|
||||
min-height: 88px;
|
||||
max-height: 88px;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.exchange-goods-thumb ::v-deep .van-image__img,
|
||||
.exchange-goods-thumb ::v-deep img {
|
||||
display: block;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.exchange-goods-item > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.exchange-goods-item h4 {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: #111;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.exchange-goods-item p {
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 6px 0 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #8f8f8f;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
background: #f5f6f8;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exchange-goods-item b {
|
||||
color: #ff5b20;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.exchange-goods-item > .van-icon {
|
||||
flex: none;
|
||||
color: #ff5b20;
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<main class="after-sale-page">
|
||||
<van-nav-bar title="退款/售后" left-arrow @click-left="$router.replace('/joyful-selection/profile')" />
|
||||
|
||||
<van-pull-refresh v-model="refreshing" class="after-sale-list" @refresh="onRefresh">
|
||||
<van-tabs v-model:active="activeTab" color="#ff5b20">
|
||||
<van-tab title="处理中" name="processing" />
|
||||
<van-tab title="历史" name="history" />
|
||||
</van-tabs>
|
||||
|
||||
<section class="after-sale-tab-panel">
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="3" />
|
||||
</section>
|
||||
<van-empty v-else-if="currentAfterSaleItems.length === 0" :description="emptyDescription" />
|
||||
|
||||
<template v-else>
|
||||
<article v-for="item in currentAfterSaleItems" :key="item.serviceOrderId" class="after-sale-card" @click="goDetail(item)">
|
||||
<header class="after-sale-card__header">
|
||||
<span>{{ item.serviceTypeText }}</span>
|
||||
<b>{{ item.serviceStateText }}</b>
|
||||
</header>
|
||||
<span class="signed-stamp" :class="{ unsigned: item.isSigned === false }">{{ item.isSigned === false ? '未签收' : '已签收' }}</span>
|
||||
|
||||
<div class="after-sale-card__body">
|
||||
<div
|
||||
v-for="product in previewProducts(item.products)"
|
||||
:key="product.id"
|
||||
class="after-sale-product"
|
||||
>
|
||||
<van-image class="after-sale-thumb" :src="product.image" fit="cover" />
|
||||
<div class="after-sale-product__info">
|
||||
<h3>{{ product.name || '-' }}</h3>
|
||||
<p>{{ skuText(product) }}</p>
|
||||
<b>¥{{ product.price || item.amount }}</b>
|
||||
</div>
|
||||
<span>x{{ product.number || 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="after-sale-meta">
|
||||
<p><span>售后单号</span><b>{{ item.serviceOrderId }}</b></p>
|
||||
<p><span>子订单号</span><b>{{ item.orderNumberId || item.subOrderId || '-' }}</b></p>
|
||||
<p><span>申请时间</span><b>{{ item.applyTime || '-' }}</b></p>
|
||||
<p><span>物流状态</span><b>{{ item.logisticsStateText }}</b></p>
|
||||
<p v-if="item.reason"><span>申请原因</span><b>{{ item.reason }}</b></p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</van-pull-refresh>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useUiStore } from '@/store/ui'
|
||||
|
||||
const SKU_COLOR_MAP = {
|
||||
cream: '奶油白',
|
||||
blue: '雾霾蓝',
|
||||
orange: '暖橙色'
|
||||
}
|
||||
|
||||
const SKU_SIZE_MAP = {
|
||||
standard: '标准款',
|
||||
plus: '升级款'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAfterSale',
|
||||
data: () => ({
|
||||
afterSaleItems: [],
|
||||
loading: false,
|
||||
refreshing: false
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId', 'userId']),
|
||||
...mapState(useUiStore, ['afterSaleTab']),
|
||||
activeTab: {
|
||||
get() {
|
||||
return this.afterSaleTab
|
||||
},
|
||||
set(value) {
|
||||
this.setAfterSaleTab(value)
|
||||
}
|
||||
},
|
||||
currentAfterSaleItems() {
|
||||
return this.afterSaleItems.filter(item => {
|
||||
const serviceState = Number(item.serviceState)
|
||||
const isHistory = [2, 5, 6].includes(serviceState)
|
||||
|
||||
return this.activeTab === 'history' ? isHistory : !isHistory
|
||||
})
|
||||
},
|
||||
emptyDescription() {
|
||||
return this.activeTab === 'history' ? '暂无历史售后' : '暂无处理中售后'
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadUi()
|
||||
this.loadAfterSales()
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
...mapActions(useUiStore, ['loadUi', 'setAfterSaleTab']),
|
||||
previewProducts(products = []) {
|
||||
return products.slice(0, 3)
|
||||
},
|
||||
skuText(product = {}) {
|
||||
const sku = String(product.sku || product.skuText || '').trim()
|
||||
const parts = sku.split('-').slice(-2).filter(Boolean)
|
||||
const color = SKU_COLOR_MAP[parts[0]] || parts[0]
|
||||
const size = SKU_SIZE_MAP[parts[1]] || parts[1]
|
||||
|
||||
return color || size ? [color, size].filter(Boolean).join(' | ') : '-'
|
||||
},
|
||||
goDetail(item) {
|
||||
this.$router.push({
|
||||
path: '/joyful-selection/after-sale/detail',
|
||||
query: {
|
||||
serviceOrderId: item.serviceOrderId
|
||||
}
|
||||
})
|
||||
},
|
||||
async loadAfterSales(showLoading = true) {
|
||||
if (showLoading) {
|
||||
this.loading = true
|
||||
}
|
||||
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
|
||||
if (!this.supplierId || !this.userId) {
|
||||
Toast('用户信息不完整,请重新进入商城')
|
||||
this.afterSaleItems = []
|
||||
return
|
||||
}
|
||||
|
||||
const data = await postFunction('after-sale-list', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId
|
||||
})
|
||||
|
||||
this.afterSaleItems = data?.afterSaleItems || []
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询退款/售后失败,请稍后再试')
|
||||
this.afterSaleItems = []
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
async onRefresh() {
|
||||
try {
|
||||
await this.loadAfterSales(false)
|
||||
} finally {
|
||||
this.refreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.after-sale-page {
|
||||
min-height: 100vh;
|
||||
padding-top: 46px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.after-sale-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.after-sale-list {
|
||||
min-height: calc(100vh - 46px);
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.after-sale-tab-panel {
|
||||
padding: 12px 10px 0;
|
||||
}
|
||||
|
||||
.after-sale-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.signed-stamp {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 15px;
|
||||
bottom: 25px;
|
||||
display: flex;
|
||||
width: 95px;
|
||||
height: 60px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid rgba(255, 91, 32, .8);
|
||||
border-radius: 50%;
|
||||
color: #ff5b20;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
opacity: .9;
|
||||
background: rgba(255, 241, 232, .55);
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.signed-stamp::before {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border: 1px dashed currentColor;
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.signed-stamp.unsigned {
|
||||
border-color: rgba(153, 153, 153, .9);
|
||||
color: #666;
|
||||
background: rgba(246, 247, 249, .7);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 14px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skeleton-card ::v-deep .van-skeleton + .van-skeleton {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.after-sale-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 14px 8px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.after-sale-card__header b {
|
||||
color: #ff5b20;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.after-sale-card__header span {
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
color: #ff5b20;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
background: #fff0e9;
|
||||
}
|
||||
|
||||
.after-sale-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.after-sale-product {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.after-sale-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.after-sale-product__info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.after-sale-product__info h3 {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 0 0 6px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.after-sale-product__info p {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: #777;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.after-sale-product__info b {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: #ff5b20;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.after-sale-product > span {
|
||||
flex: none;
|
||||
align-self: flex-end;
|
||||
color: #222;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.after-sale-meta {
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.after-sale-meta p {
|
||||
display: flex;
|
||||
margin: 8px 0 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.after-sale-meta span {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.after-sale-meta b {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
color: #555;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,702 @@
|
||||
<template>
|
||||
<main class="after-sale-return-page">
|
||||
<van-nav-bar title="申请售后(退货)" left-arrow @click-left="$router.back()" />
|
||||
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="5" />
|
||||
<van-skeleton title :row="4" />
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="product-card">
|
||||
<h3>售后商品</h3>
|
||||
<article v-for="item in products" :key="item.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="item.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ item.name || '-' }}</h4>
|
||||
<p>{{ skuText(item) }}</p>
|
||||
<b>¥{{ item.price || currentSubOrder.amount || '0.00' }}</b>
|
||||
</div>
|
||||
<span>x{{ item.number || 1 }}</span>
|
||||
</article>
|
||||
</section>
|
||||
<section v-show="!loading" class="form-card">
|
||||
<div class="method-row">
|
||||
<span>是否签收</span>
|
||||
<button type="button" :class="{ active: isSigned === false }" @click="isSigned = false">未签收</button>
|
||||
<button type="button" :class="{ active: isSigned === true }" @click="isSigned = true">已签收</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading && isSigned" class="form-card">
|
||||
<div class="method-row">
|
||||
<span>取件方式</span>
|
||||
<button type="button" :class="{ active: pickupMethod === 'door' }" @click="pickupMethod = 'door'">上门取件</button>
|
||||
<button type="button" :class="{ active: pickupMethod === 'mail' }" @click="pickupMethod = 'mail'">自行寄回</button>
|
||||
</div>
|
||||
<div v-if="pickupMethod === 'door'" class="address-row" @click="goAddressList">
|
||||
<span>取件地址</span>
|
||||
<div>
|
||||
<p>{{ receiverNameText }} <b>{{ receiverMobileText }}</b></p>
|
||||
<p>{{ receiverAddressText }}</p>
|
||||
</div>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="form-card">
|
||||
<div class="form-row reason-row" @click="showReasonPopup = true">
|
||||
<span>售后原因</span>
|
||||
<div class="reason-value">
|
||||
<b>{{ selectedReasonText }}</b>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="desc-block">
|
||||
<h3>问题描述</h3>
|
||||
<textarea v-model="description" maxlength="200" placeholder="请详细描述遇到的问题(选填)"></textarea>
|
||||
<em>{{ description.length }}/200</em>
|
||||
</div>
|
||||
|
||||
<div class="upload-block">
|
||||
<h3>上传图片(最多3张)</h3>
|
||||
<van-uploader
|
||||
v-model="fileList"
|
||||
class="upload-list"
|
||||
multiple
|
||||
:max-count="3"
|
||||
:after-read="afterRead"
|
||||
@delete="onDeleteImage"
|
||||
/>
|
||||
<p>选填,上传有助于更快处理您的申请</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<van-button v-show="!loading" class="submit-button" block :loading="submitting" @click="submitReturn">提交申请</van-button>
|
||||
<van-popup v-model:show="showReasonPopup" round position="bottom">
|
||||
<section class="reason-popup">
|
||||
<h3>请选择退货原因</h3>
|
||||
<van-radio-group v-model="selectedReason">
|
||||
<van-cell
|
||||
v-for="item in reasonOptions"
|
||||
:key="item.value"
|
||||
clickable
|
||||
@click="selectReason(item)"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ item.label }}</span>
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<van-radio :name="item.value" checked-color="#ff5b20" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-radio-group>
|
||||
</section>
|
||||
</van-popup>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Toast } from 'vant'
|
||||
import { postFunction, uploadImageFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const ORDER_EVENT_TYPE = {
|
||||
RETURN_ORDER: 4
|
||||
}
|
||||
|
||||
const ORDER_STATE = {
|
||||
RECEIVED: 4,
|
||||
COMPLETED: 5
|
||||
}
|
||||
|
||||
const SKU_COLOR_MAP = {
|
||||
cream: '奶油白',
|
||||
blue: '雾霾蓝',
|
||||
orange: '暖橙色'
|
||||
}
|
||||
|
||||
const SKU_SIZE_MAP = {
|
||||
standard: '标准款',
|
||||
plus: '升级款'
|
||||
}
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
{ value: 'QUALITY_PROBLEM', label: '商品质量问题' },
|
||||
{ value: 'DAMAGED_GOODS', label: '商品破损' },
|
||||
{ value: 'DESCRIPTION_MISMATCH', label: '商品与描述不符' },
|
||||
{ value: 'WRONG_GOODS', label: '发错商品' },
|
||||
{ value: 'MISSING_GOODS', label: '少件/漏发' },
|
||||
{ value: 'SPEC_NOT_FIT', label: '尺寸规格不合适' },
|
||||
{ value: 'DISLIKE', label: '不喜欢/不想要了' },
|
||||
{ value: 'BUY_WRONG', label: '买错了' },
|
||||
{ value: 'OTHER', label: '其他' },
|
||||
{ value: 'SUPPLIER_ERROR', label: '供应商发货错误' },
|
||||
{ value: 'INVOICE_ERROR', label: '发票问题' },
|
||||
{ value: 'DELIVERY_TIMEOUT', label: '未按约定时间发货' },
|
||||
{ value: 'PRICE_ERROR', label: '价格异常' }
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAfterSaleReturn',
|
||||
data: () => ({
|
||||
loading: false,
|
||||
currentOrder: {},
|
||||
currentSubOrder: {},
|
||||
pickupAddress: {},
|
||||
description: '',
|
||||
pickupMethod: 'door',
|
||||
showReasonPopup: false,
|
||||
selectedReason: '',
|
||||
fileList: [],
|
||||
uploadedImages: [],
|
||||
submitting: false,
|
||||
isSigned: false
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId', 'userId']),
|
||||
products() {
|
||||
return this.currentSubOrder.products || []
|
||||
},
|
||||
receiverNameText() {
|
||||
return this.pickupAddress.name || this.currentSubOrder.receiverName || this.currentOrder.receiverName || '-'
|
||||
},
|
||||
receiverMobileText() {
|
||||
return this.pickupAddress.tel || this.currentSubOrder.receiverMobile || this.currentOrder.receiverMobile || '-'
|
||||
},
|
||||
receiverAddressText() {
|
||||
if (this.pickupAddress.id) {
|
||||
return `${this.pickupAddress.province || ''}${this.pickupAddress.city || ''}${this.pickupAddress.county || ''}${this.pickupAddress.addressDetail || ''}` || '-'
|
||||
}
|
||||
|
||||
return this.currentSubOrder.receiverAddress || this.currentOrder.receiverAddress || '-'
|
||||
},
|
||||
reasonOptions() {
|
||||
return REASON_OPTIONS
|
||||
},
|
||||
selectedReasonText() {
|
||||
return this.reasonOptions.find(item => item.value === this.selectedReason)?.label || '请选择售后原因'
|
||||
},
|
||||
hasProductShipped() {
|
||||
// Assuming currentSubOrder.logisticsState indicates if the product has been shipped to the user.
|
||||
// If the product is not in 'NONE' (0) or 'WAIT_USER_SHIP' (1) state, it's considered shipped.
|
||||
const logisticsState = Number(this.currentSubOrder.logisticsState);
|
||||
return logisticsState !== 0 && logisticsState !== 1;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadOrderDetail()
|
||||
this.loadPickupAddress()
|
||||
},
|
||||
activated() {
|
||||
this.loadOrderDetail()
|
||||
this.loadPickupAddress()
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
skuText(item = {}) {
|
||||
const sku = String(item.sku || item.skuText || '').trim()
|
||||
const parts = sku.split('-').slice(-2).filter(Boolean)
|
||||
const color = SKU_COLOR_MAP[parts[0]] || parts[0]
|
||||
const size = SKU_SIZE_MAP[parts[1]] || parts[1]
|
||||
|
||||
return color || size ? [color, size].filter(Boolean).join(' | ') : '-'
|
||||
},
|
||||
selectReason(item) {
|
||||
this.selectedReason = item.value
|
||||
this.showReasonPopup = false
|
||||
},
|
||||
inferIsSigned(order = {}, subOrder = {}) {
|
||||
const state = Number(subOrder.orderState ?? order.orderState)
|
||||
|
||||
return [ORDER_STATE.RECEIVED, ORDER_STATE.COMPLETED].includes(state)
|
||||
},
|
||||
checkSignedMatch() {
|
||||
const realSigned = this.inferIsSigned(this.currentOrder, this.currentSubOrder)
|
||||
if (this.isSigned !== realSigned) {
|
||||
Toast(realSigned ? '该订单已签收,请选择已签收' : '该订单未签收,请选择未签收')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
async afterRead(fileItems) {
|
||||
const items = Array.isArray(fileItems) ? fileItems : [fileItems]
|
||||
await Promise.all(items.map(async item => {
|
||||
item.status = 'uploading'
|
||||
item.message = '上传中'
|
||||
try {
|
||||
const uploaded = await uploadImageFunction(item.file, 'after-sale')
|
||||
item.url = uploaded.previewUrl || uploaded.url
|
||||
item.objectName = uploaded.objectName
|
||||
item.status = 'done'
|
||||
item.message = ''
|
||||
this.uploadedImages.push({
|
||||
objectName: uploaded.objectName,
|
||||
url: uploaded.previewUrl || uploaded.url
|
||||
})
|
||||
} catch (error) {
|
||||
item.status = 'failed'
|
||||
item.message = '上传失败'
|
||||
Toast(error.message || '图片上传失败')
|
||||
}
|
||||
}))
|
||||
},
|
||||
onDeleteImage(item) {
|
||||
this.uploadedImages = this.uploadedImages.filter(image => image.objectName !== item.objectName)
|
||||
},
|
||||
async loadPickupAddress() {
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
if (!this.supplierId || !this.userId) {
|
||||
this.pickupAddress = {}
|
||||
return
|
||||
}
|
||||
|
||||
const data = await postFunction('address-default', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId
|
||||
})
|
||||
this.pickupAddress = data?.address || {}
|
||||
} catch (error) {
|
||||
this.pickupAddress = {}
|
||||
}
|
||||
},
|
||||
goAddressList() {
|
||||
this.$router.push({ path: '/joyful-selection/address', query: { from: 'after-sale' } })
|
||||
},
|
||||
async loadOrderDetail() {
|
||||
this.loading = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
const data = await postFunction('order-detail', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
mainOrderId: String(this.$route.query.mainOrderId || ''),
|
||||
orderId: String(this.$route.query.orderId || '')
|
||||
})
|
||||
|
||||
this.currentOrder = data?.order || {}
|
||||
this.currentSubOrder = data?.subOrder || {}
|
||||
this.isSigned = this.inferIsSigned(this.currentOrder, this.currentSubOrder)
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询订单详情失败,请稍后再试')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async submitReturn() {
|
||||
if (this.submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
|
||||
const mainOrderId = this.currentOrder.mainOrderId || this.currentOrder.orderId || this.$route.query.mainOrderId
|
||||
const orderId = this.currentSubOrder.orderId || this.$route.query.orderId
|
||||
if (!this.supplierId || !this.userId || !mainOrderId || !orderId) {
|
||||
Toast('订单信息不完整,请稍后再试')
|
||||
return
|
||||
}
|
||||
if (!this.checkSignedMatch()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 售后原因必填
|
||||
if (!this.selectedReason || this.selectedReason === '') {
|
||||
Toast('请选择售后原因');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已签收,且取件方式是上门取件,则取件信息必填
|
||||
if (this.isSigned && this.pickupMethod === 'door') {
|
||||
if (!this.receiverNameText || this.receiverNameText === '-' ||
|
||||
!this.receiverMobileText || this.receiverMobileText === '-' ||
|
||||
!this.receiverAddressText || this.receiverAddressText === '-') {
|
||||
Toast('请填写完整的取件信息');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const applied = await postFunction('after-sale-apply', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
serviceType: ORDER_EVENT_TYPE.RETURN_ORDER,
|
||||
orderId,
|
||||
subOrderId: orderId,
|
||||
mainOrderId,
|
||||
amount: Number(this.currentSubOrder.amount || 0),
|
||||
reason: this.selectedReasonText === '请选择售后原因' ? '退货' : this.selectedReasonText,
|
||||
reasonType: this.selectedReason || 'OTHER',
|
||||
problemDescription: this.description,
|
||||
pickupMethod: this.isSigned ? this.pickupMethod : undefined, // Conditionally set
|
||||
pickupName: this.isSigned && this.receiverNameText !== '-' ? this.receiverNameText : undefined, // Conditionally set
|
||||
pickupMobile: this.isSigned && this.receiverMobileText !== '-' ? this.receiverMobileText : undefined, // Conditionally set
|
||||
pickupAddress: this.isSigned && this.receiverAddressText !== '-' ? this.receiverAddressText : undefined, // Conditionally set
|
||||
images: this.uploadedImages
|
||||
})
|
||||
|
||||
Toast('退货申请已提交,等待商家审核')
|
||||
const serviceOrderId = applied?.serviceOrderId || applied?.afterSale?.serviceOrderId
|
||||
if (serviceOrderId) {
|
||||
this.$router.replace({
|
||||
path: '/joyful-selection/after-sale/detail',
|
||||
query: { serviceOrderId }
|
||||
})
|
||||
} else {
|
||||
this.$router.back()
|
||||
}
|
||||
} catch (error) {
|
||||
Toast(error.message || '退货申请失败,请稍后再试')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.after-sale-return-page {
|
||||
min-height: 100vh;
|
||||
padding: 46px 10px 72px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.after-sale-return-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.product-card,
|
||||
.skeleton-card,
|
||||
.form-card {
|
||||
margin-top: 10px;
|
||||
padding: 0 12px 5px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.product-card h3,
|
||||
.desc-block h3,
|
||||
.upload-block h3 {
|
||||
margin: 0 0 13px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 2px 8px 0 10px;
|
||||
}
|
||||
|
||||
.product-info h4 {
|
||||
margin: 0 0 7px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.product-info p {
|
||||
margin: 0 0 7px;
|
||||
color: #8f8f8f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-info b {
|
||||
color: #ff5b20;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.product-row > span {
|
||||
align-self: center;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-row,
|
||||
.method-row,
|
||||
.address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 46px;
|
||||
color: #222;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-row span,
|
||||
.method-row span,
|
||||
.address-row span {
|
||||
flex: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-row b {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.reason-row {
|
||||
height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reason-value {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.reason-value b {
|
||||
overflow: hidden;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reason-value .van-icon {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: #aaa;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.form-row .van-icon {
|
||||
margin-left: 6px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.desc-block {
|
||||
padding: 14px 0 12px;
|
||||
border-bottom: 1px solid #f2f2f2;
|
||||
}
|
||||
|
||||
.desc-block textarea {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.desc-block textarea::placeholder {
|
||||
color: #c2c2c2;
|
||||
}
|
||||
|
||||
.desc-block em {
|
||||
display: block;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.method-row {
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.method-row span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.method-row button {
|
||||
display: inline-flex;
|
||||
min-width: 92px;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0 16px;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
|
||||
.method-row button.active {
|
||||
border: 1px solid #ff5b20;
|
||||
color: #ff5b20;
|
||||
background: #fff7f2;
|
||||
}
|
||||
|
||||
.address-row {
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.address-row .van-icon {
|
||||
align-self: center;
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: #aaa;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.address-row > div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin-left: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.address-row p {
|
||||
margin: 0 0 4px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.address-row b {
|
||||
margin-left: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.upload-block {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.upload-list ::v-deep .van-uploader__wrapper {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.upload-list ::v-deep .van-uploader__upload,
|
||||
.upload-list ::v-deep .van-uploader__preview-image {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-list ::v-deep .van-uploader__preview {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
display: flex;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.upload-item .van-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.upload-item span {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.upload-block > p {
|
||||
margin: 10px 0 0;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
right: auto;
|
||||
bottom: 10px;
|
||||
left: 12px;
|
||||
width: calc(100% - 24px);
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 44px;
|
||||
background: linear-gradient(90deg, #ff6a2a 0%, #ff4b18 100%);
|
||||
}
|
||||
|
||||
.submit-button ::v-deep .van-button__content {
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reason-popup {
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 16px 0 10px;
|
||||
}
|
||||
|
||||
.reason-popup h3 {
|
||||
margin: 0 0 8px;
|
||||
color: #222;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reason-popup ::v-deep .van-radio-group {
|
||||
max-height: calc(70vh - 56px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.reason-popup ::v-deep .van-cell {
|
||||
align-items: center;
|
||||
padding: 13px 18px;
|
||||
}
|
||||
|
||||
.reason-popup ::v-deep .van-cell__title {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<main class="after-sale-select-page">
|
||||
<van-nav-bar title="申请售后" left-arrow @click-left="$router.back()" />
|
||||
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="4" />
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="product-card">
|
||||
<h3>售后商品</h3>
|
||||
<article v-for="item in products" :key="item.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="item.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ item.name || '-' }}</h4>
|
||||
<p>{{ skuText(item) }}</p>
|
||||
<b>¥{{ item.price || currentSubOrder.amount || '0.00' }}</b>
|
||||
</div>
|
||||
<span>x{{ item.number || 1 }}</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="type-card">
|
||||
<h3>请选择售后类型</h3>
|
||||
<div class="type-item" @click="goReturn">
|
||||
<span class="type-icon return-icon"><van-icon name="gift-o" /></span>
|
||||
<div>
|
||||
<h4>退货</h4>
|
||||
<p>已收到货,需要退回已收到的商品</p>
|
||||
</div>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
<div class="type-item" @click="goExchange">
|
||||
<span class="type-icon exchange-icon"><van-icon name="exchange" /></span>
|
||||
<div>
|
||||
<h4>换货</h4>
|
||||
<p>已收到货,需要更换已收到的商品</p>
|
||||
</div>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-show="!loading" class="tips">售后申请提交后,商家会尽快为您处理</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const SKU_COLOR_MAP = {
|
||||
cream: '奶油白',
|
||||
blue: '雾霾蓝',
|
||||
orange: '暖橙色'
|
||||
}
|
||||
|
||||
const SKU_SIZE_MAP = {
|
||||
standard: '标准款',
|
||||
plus: '升级款'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionAfterSaleSelect',
|
||||
data: () => ({
|
||||
loading: false,
|
||||
currentOrder: {},
|
||||
currentSubOrder: {}
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId', 'userId']),
|
||||
products() {
|
||||
return this.currentSubOrder.products || []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadOrderDetail()
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
skuText(item = {}) {
|
||||
const sku = String(item.sku || item.skuText || '').trim()
|
||||
const parts = sku.split('-').slice(-2).filter(Boolean)
|
||||
const color = SKU_COLOR_MAP[parts[0]] || parts[0]
|
||||
const size = SKU_SIZE_MAP[parts[1]] || parts[1]
|
||||
|
||||
return color || size ? [color, size].filter(Boolean).join(' | ') : '-'
|
||||
},
|
||||
async loadOrderDetail() {
|
||||
this.loading = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
const data = await postFunction('order-detail', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
mainOrderId: String(this.$route.query.mainOrderId || ''),
|
||||
orderId: String(this.$route.query.orderId || '')
|
||||
})
|
||||
|
||||
this.currentOrder = data?.order || {}
|
||||
this.currentSubOrder = data?.subOrder || {}
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询订单详情失败,请稍后再试')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
goReturn() {
|
||||
this.$router.push({
|
||||
path: '/joyful-selection/after-sale/return',
|
||||
query: {
|
||||
mainOrderId: this.currentOrder.mainOrderId || this.$route.query.mainOrderId,
|
||||
orderId: this.currentSubOrder.orderId || this.$route.query.orderId
|
||||
}
|
||||
})
|
||||
},
|
||||
goExchange() {
|
||||
this.$router.push({
|
||||
path: '/joyful-selection/after-sale/exchange',
|
||||
query: {
|
||||
mainOrderId: this.currentOrder.mainOrderId || this.$route.query.mainOrderId,
|
||||
orderId: this.currentSubOrder.orderId || this.$route.query.orderId
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.after-sale-select-page {
|
||||
min-height: 100vh;
|
||||
padding: 46px 10px 16px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.after-sale-select-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.product-card,
|
||||
.skeleton-card,
|
||||
.type-card {
|
||||
margin-top: 10px;
|
||||
padding: 14px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.product-card h3,
|
||||
.type-card h3 {
|
||||
margin: 0 0 14px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.product-info h4 {
|
||||
margin: 0 0 7px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.product-info p {
|
||||
margin: 0 0 7px;
|
||||
color: #8f8f8f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-info b {
|
||||
color: #ff5b20;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.product-row > span {
|
||||
align-self: flex-end;
|
||||
color: #111;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.type-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 70px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
display: flex;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.return-icon {
|
||||
background: #ff6b2c;
|
||||
}
|
||||
|
||||
.exchange-icon {
|
||||
background: #5aa7ff;
|
||||
}
|
||||
|
||||
.type-item div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.type-item h4 {
|
||||
margin: 0 0 6px;
|
||||
color: #222;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.type-item p {
|
||||
margin: 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.type-item > .van-icon {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin: 10px 0 0;
|
||||
padding: 11px 12px;
|
||||
border-radius: 8px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,15 @@
|
||||
<section class="cart-panel" :class="{ 'cart-panel--page': pageMode }">
|
||||
<van-notice-bar v-if="showNotice" text="满 199 元包邮,快去凑单吧~" left-icon="info-o" />
|
||||
<section class="cart-list">
|
||||
<van-empty v-if="!items.length" description="购物车还是空的" />
|
||||
<div v-if="!items.length" class="cart-empty">
|
||||
<van-empty description="购物车还是空的" />
|
||||
<van-button
|
||||
class="cart-empty__button"
|
||||
round
|
||||
type="danger"
|
||||
@click="$router.push('/joyful-selection/products')"
|
||||
>去购物</van-button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<article
|
||||
v-for="item in items"
|
||||
@@ -10,10 +18,10 @@
|
||||
class="cart-item"
|
||||
>
|
||||
<van-checkbox
|
||||
:value="isItemChecked(item)"
|
||||
:model-value="isItemChecked(item)"
|
||||
class="item-check"
|
||||
checked-color="#ff5b20"
|
||||
@input="toggleItem(item, $event)"
|
||||
@update:model-value="toggleItem(item, $event)"
|
||||
/>
|
||||
<van-image class="item-thumb" :src="item.image" fit="cover" />
|
||||
<div class="item-info">
|
||||
@@ -37,16 +45,17 @@
|
||||
</template>
|
||||
</section>
|
||||
<van-submit-bar
|
||||
v-if="items.length"
|
||||
:fixed="fixedSubmit"
|
||||
:price="selectedTotalPrice"
|
||||
:button-text="submitText"
|
||||
@submit="$emit('checkout', selectedItems)"
|
||||
@submit="handleCheckout"
|
||||
>
|
||||
<van-checkbox
|
||||
:value="isAllChecked"
|
||||
:model-value="isAllChecked"
|
||||
:indeterminate="isPartChecked"
|
||||
checked-color="#ff5b20"
|
||||
@input="toggleAll"
|
||||
@update:model-value="toggleAll"
|
||||
>全选</van-checkbox>
|
||||
</van-submit-bar>
|
||||
</section>
|
||||
@@ -139,6 +148,14 @@ export default {
|
||||
}
|
||||
|
||||
this.persistCart()
|
||||
},
|
||||
handleCheckout() {
|
||||
if (!this.items.length || !this.selectedItems.length) {
|
||||
this.$toast('请选择要结算的商品')
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('checkout', this.selectedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +190,19 @@ export default {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cart-empty {
|
||||
padding-top: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cart-empty__button {
|
||||
width: 120px;
|
||||
height: 38px;
|
||||
background: #ff5b20;
|
||||
border-color: #ff5b20;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: flex;
|
||||
min-height: 128px;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<main class="edit-page">
|
||||
<div>
|
||||
<joyful-selection-cart-edit-skeleton v-if="loading" />
|
||||
<main v-else class="edit-page">
|
||||
<van-nav-bar title="购物车" right-text="完成" @click-right="$router.push('/joyful-selection/cart')" />
|
||||
<section class="edit-list">
|
||||
<van-empty v-if="!items.length" description="购物车还是空的" />
|
||||
<article v-for="item in items" :key="item.cartId" class="edit-item">
|
||||
<van-checkbox
|
||||
:value="isItemChecked(item)"
|
||||
:model-value="isItemChecked(item)"
|
||||
class="item-check"
|
||||
checked-color="#ff5b20"
|
||||
@input="toggleItem(item, $event)"
|
||||
@update:model-value="toggleItem(item, $event)"
|
||||
/>
|
||||
<van-image class="item-thumb" :src="item.image" fit="cover" />
|
||||
<div class="item-info">
|
||||
@@ -24,23 +26,29 @@
|
||||
</section>
|
||||
<van-submit-bar class="edit-submit" button-text="删除" @submit="remove">
|
||||
<van-checkbox
|
||||
:value="isAllChecked"
|
||||
:model-value="isAllChecked"
|
||||
:indeterminate="isPartChecked"
|
||||
checked-color="#ff5b20"
|
||||
@input="toggleAll"
|
||||
@update:model-value="toggleAll"
|
||||
>全选</van-checkbox>
|
||||
</van-submit-bar>
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Toast } from 'vant'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { useCartStore } from '@/store/cart'
|
||||
import JoyfulSelectionCartEditSkeleton from '@/skeleton-view/joyful-selection/cart/edit/index.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionCartEdit',
|
||||
components: {
|
||||
JoyfulSelectionCartEditSkeleton
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
selectedIds: []
|
||||
}),
|
||||
computed: {
|
||||
@@ -55,6 +63,9 @@ export default {
|
||||
created() {
|
||||
this.loadCart()
|
||||
this.selectedIds = this.items.map(item => item.cartId)
|
||||
this.$nextTick(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useCartStore, ['loadCart', 'removeItems']),
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
<template>
|
||||
<main class="cart-page">
|
||||
<div>
|
||||
<joyful-selection-cart-skeleton v-if="loading" />
|
||||
<main v-else class="cart-page">
|
||||
<van-nav-bar title="购物车" right-text="编辑" @click-right="$router.push('/joyful-selection/cart/edit')" />
|
||||
<joyful-cart-panel fixed-submit page-mode @checkout="goCheckout" />
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import JoyfulCartPanel from './components/JoyfulCartPanel.vue'
|
||||
import JoyfulSelectionCartSkeleton from '@/skeleton-view/joyful-selection/cart/index.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionCart',
|
||||
components: {
|
||||
JoyfulCartPanel
|
||||
JoyfulCartPanel,
|
||||
JoyfulSelectionCartSkeleton
|
||||
},
|
||||
data: () => ({
|
||||
loading: true
|
||||
}),
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
goCheckout(items) {
|
||||
if (!items || !items.length) {
|
||||
this.$toast('请选择要结算的商品')
|
||||
return
|
||||
}
|
||||
|
||||
sessionStorage.setItem('joyful-selection-checkout-items', JSON.stringify(items))
|
||||
this.$router.push({ path: '/joyful-selection/checkout', query: { source: 'cart' } })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<main class="joy-home">
|
||||
<div>
|
||||
<joyful-selection-home-skeleton v-if="loading" />
|
||||
<main v-else class="joy-home">
|
||||
<van-search class="fixed-search" v-model="keyword" shape="round" placeholder="搜索商品名称" />
|
||||
|
||||
<section class="hero">
|
||||
@@ -24,7 +26,7 @@
|
||||
<section class="product-section">
|
||||
<header class="section-head"><b>限时秒杀</b><a @click="$router.push('/joyful-selection/products')">更多 ›</a></header>
|
||||
<div class="product-row">
|
||||
<article v-for="item in flashProducts" :key="item.name" @click="goProductDetail(item)">
|
||||
<article v-for="item in flashProducts" :key="item.id" @click="goProductDetail(item)">
|
||||
<van-image :src="item.image" fit="cover" />
|
||||
<div class="product-meta">
|
||||
<p>{{ item.name }}</p><b>¥{{ item.price }}</b>
|
||||
@@ -36,7 +38,7 @@
|
||||
<section class="product-section hot-section">
|
||||
<header class="section-head"><b>爆款推荐</b><a @click="$router.push('/joyful-selection/products')">更多 ›</a></header>
|
||||
<div class="product-row">
|
||||
<article v-for="item in hotProducts" :key="item.name" @click="goProductDetail(item)">
|
||||
<article v-for="item in hotProducts" :key="item.id" @click="goProductDetail(item)">
|
||||
<van-image :src="item.image" fit="cover" />
|
||||
<div class="product-meta">
|
||||
<p>{{ item.name }}</p><b>¥{{ item.price }}</b>
|
||||
@@ -44,17 +46,22 @@
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { joyfulProducts } from './product-data'
|
||||
import { getFunction } from '@/api/supplied-service'
|
||||
import JoyfulSelectionHomeSkeleton from '@/skeleton-view/joyful-selection/index.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionHome',
|
||||
components: {
|
||||
JoyfulSelectionHomeSkeleton
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
keyword: '',
|
||||
categories: [
|
||||
{ name: '精选推荐', icon: 'like', color: '#ff6e8a', background: '#fff0f3' },
|
||||
@@ -68,11 +75,33 @@ export default {
|
||||
{ name: '超值专区', icon: 'label', color: '#ec8c3d', background: '#fff4e9' },
|
||||
{ name: '全部分类', icon: 'apps-o', color: '#989898', background: '#f5f5f5' }
|
||||
],
|
||||
flashProducts: joyfulProducts.slice(0, 4),
|
||||
hotProducts: [joyfulProducts[2], joyfulProducts[0], joyfulProducts[1], joyfulProducts[3]]
|
||||
products: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
flashProducts() {
|
||||
return this.products.slice(0, 4)
|
||||
},
|
||||
hotProducts() {
|
||||
return this.products.slice(0, 4).reverse()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadProducts()
|
||||
},
|
||||
methods: {
|
||||
async loadProducts() {
|
||||
this.loading = true
|
||||
try {
|
||||
const data = await getFunction('product-list')
|
||||
this.products = data?.products || []
|
||||
} catch (error) {
|
||||
console.warn('查询商品列表失败', error)
|
||||
this.$toast(error.message || '查询商品列表失败')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
goProductDetail(item) {
|
||||
this.$router.push({ path: '/joyful-selection/product-detail', query: { id: item.id } })
|
||||
}
|
||||
@@ -199,8 +228,8 @@ export default {
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
align-items: center;
|
||||
margin: 0 0 8px 1px;
|
||||
}
|
||||
|
||||
@@ -229,10 +258,10 @@ export default {
|
||||
|
||||
.product-row .van-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
@@ -269,5 +298,4 @@ export default {
|
||||
.hot-section {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<template>
|
||||
<main class="joyful-layout">
|
||||
<router-view />
|
||||
<joyful-selection-layout-skeleton v-if="sessionLoading" />
|
||||
<router-view v-else />
|
||||
<van-tabbar v-if="showTabbar" route active-color="#ff5b20" inactive-color="#777">
|
||||
<van-tabbar-item icon="wap-home-o" to="/joyful-selection/home">首页</van-tabbar-item>
|
||||
<van-tabbar-item icon="apps-o" to="/joyful-selection/products">分类</van-tabbar-item>
|
||||
@@ -13,9 +14,17 @@
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { useCartStore } from '@/store/cart'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import JoyfulSelectionLayoutSkeleton from '@/skeleton-view/joyful-selection/layout.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionLayout',
|
||||
components: {
|
||||
JoyfulSelectionLayoutSkeleton
|
||||
},
|
||||
data: () => ({
|
||||
sessionLoading: true
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useCartStore, ['totalCount']),
|
||||
cartBadge() {
|
||||
@@ -27,9 +36,35 @@ export default {
|
||||
},
|
||||
created() {
|
||||
this.loadCart()
|
||||
this.prepareUserSession()
|
||||
},
|
||||
watch: {
|
||||
'$route.query': {
|
||||
handler() {
|
||||
this.prepareUserSession()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useCartStore, ['loadCart'])
|
||||
...mapActions(useCartStore, ['loadCart']),
|
||||
...mapActions(useUserStore, ['hydrateFromRoute', 'fetchBridgeToken']),
|
||||
async prepareUserSession() {
|
||||
if (this.$route.meta.merchantAuth) {
|
||||
this.sessionLoading = false
|
||||
return
|
||||
}
|
||||
this.sessionLoading = true
|
||||
try {
|
||||
await this.hydrateFromRoute(this.$route.query)
|
||||
await this.fetchBridgeToken()
|
||||
} catch (error) {
|
||||
console.warn('获取平台授权令牌失败', error)
|
||||
this.$toast(error.message || '供应商验证失败,请稍后再试')
|
||||
} finally {
|
||||
this.sessionLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
<template>
|
||||
<main class="after-sale-detail-page">
|
||||
<van-nav-bar title="售后详情" left-arrow @click-left="$router.replace('/joyful-selection/merchant')" />
|
||||
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="5" />
|
||||
<van-skeleton title :row="3" />
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="status-card" :class="statusCardClass">
|
||||
<div class="status-icon">
|
||||
<van-icon :name="statusIcon" />
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ statusTitle }}</h2>
|
||||
<p>{{ statusDesc }}</p>
|
||||
<button v-if="afterSale.auditRemark" type="button" @click="showAuditRemark">查看审核意见</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="detail-card progress-card">
|
||||
<h3>售后进度</h3>
|
||||
<div class="timeline">
|
||||
<div v-for="step in progressSteps" :key="step.key" class="timeline-item" :class="step.state">
|
||||
<span class="timeline-dot">
|
||||
<van-icon v-if="step.state === 'done'" name="success" />
|
||||
</span>
|
||||
<div class="timeline-main">
|
||||
<b>{{ step.title }}</b>
|
||||
<em>{{ step.time || step.subTitle }}</em>
|
||||
</div>
|
||||
<p>{{ step.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="detail-card product-card">
|
||||
<h3>售后商品</h3>
|
||||
<article v-for="item in products" :key="item.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="item.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ item.name || '-' }}</h4>
|
||||
<p>{{ skuText(item) }}</p>
|
||||
<b>¥{{ item.price || afterSale.amount || '0.00' }}</b>
|
||||
</div>
|
||||
<span>x{{ item.number || 1 }}</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="!loading && showReturnInfo" class="detail-card address-card">
|
||||
<h3>{{ returnInfoTitle }}</h3>
|
||||
<p><span>联系人</span><b>{{ afterSale.pickupName || '-' }}</b></p>
|
||||
<p><span>联系电话</span><b>{{ maskedMobile(afterSale.pickupMobile) }}</b></p>
|
||||
<p><span>地址</span><b>{{ afterSale.pickupAddress || '-' }}</b></p>
|
||||
<p v-if="afterSale.userLogisticsNo" class="logistics-row">
|
||||
<span>寄回单号</span><b>{{ afterSale.userLogisticsCompany || '快递' }} {{ afterSale.userLogisticsNo }}</b>
|
||||
</p>
|
||||
<p v-if="afterSale.merchantLogisticsNo" class="logistics-row">
|
||||
<span>换货单号</span><b>{{ afterSale.merchantLogisticsCompany || '快递' }} {{ afterSale.merchantLogisticsNo }}</b>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-show="!loading" class="detail-card info-card">
|
||||
<h3>售后信息</h3>
|
||||
<p class="service-no-row"><span>售后单号</span><b class="scrollable-no">{{ afterSale.serviceOrderId || '-' }}</b><button type="button" @click="copyServiceNo">复制</button></p>
|
||||
<p><span>子订单号</span><b>{{ afterSale.orderNumberId || afterSale.subOrderId || '-' }}</b></p>
|
||||
<p><span>售后类型</span><b>{{ afterSale.serviceTypeText || '-' }}</b></p>
|
||||
<p><span>售后原因</span><b>{{ afterSale.reason || '-' }}</b></p>
|
||||
<p><span>问题描述</span><b>{{ afterSale.problemDescription || '-' }}</b></p>
|
||||
<p><span>申请时间</span><b>{{ afterSale.applyTime || '-' }}</b></p>
|
||||
<p v-if="!isUnsignedAfterSale"><span>取件方式</span><b>{{ pickupMethodText }}</b></p>
|
||||
<p v-if="!isUnsignedAfterSale"><span>取件地址</span><b>{{ pickupAddressText }}</b></p>
|
||||
<div v-if="images.length" class="voucher-block">
|
||||
<h4>上传凭证</h4>
|
||||
<div>
|
||||
<van-image v-for="image in images" :key="image.url" :src="image.url" fit="cover" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer v-show="!loading && merchantActions.length" class="bottom-actions">
|
||||
<button
|
||||
v-for="action in merchantActions"
|
||||
:key="action.key"
|
||||
type="button"
|
||||
:class="{ primary: action.primary }"
|
||||
@click="handleAction(action.key)"
|
||||
>
|
||||
{{ action.text }}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<van-popup v-model:show="showExchangeShipPopup" round position="bottom">
|
||||
<section class="action-popup">
|
||||
<h3>填写换货发货信息</h3>
|
||||
<van-field v-model="exchangeShipForm.logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
|
||||
<van-field v-model="exchangeShipForm.logisticsNo" label="快递单号" placeholder="请输入快递单号" />
|
||||
<button type="button" @click="submitExchangeShip">确认发货</button>
|
||||
</section>
|
||||
</van-popup>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Dialog, Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const SERVICE_TYPE = {
|
||||
RETURN: 4,
|
||||
EXCHANGE: 5
|
||||
}
|
||||
|
||||
const SERVICE_STATE = {
|
||||
PENDING_AUDIT: 0,
|
||||
APPROVED: 1,
|
||||
REJECTED: 2,
|
||||
USER_SHIPPED: 3,
|
||||
RECEIVED: 4,
|
||||
FINISHED: 5,
|
||||
CANCELLED: 6
|
||||
}
|
||||
|
||||
const LOGISTICS_STATE = {
|
||||
NONE: 0,
|
||||
WAIT_USER_SHIP: 1,
|
||||
USER_SHIPPED: 2,
|
||||
MERCHANT_RECEIVED: 3,
|
||||
MERCHANT_SHIPPED: 4,
|
||||
USER_RECEIVED: 5
|
||||
}
|
||||
|
||||
const SKU_COLOR_MAP = {
|
||||
cream: '奶油白',
|
||||
blue: '雾霾蓝',
|
||||
orange: '暖橙色'
|
||||
}
|
||||
|
||||
const SKU_SIZE_MAP = {
|
||||
standard: '标准款',
|
||||
plus: '升级款'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionMerchantAfterSaleDetail',
|
||||
data: () => ({
|
||||
afterSale: {},
|
||||
loading: false,
|
||||
showExchangeShipPopup: false,
|
||||
exchangeShipForm: {
|
||||
logisticsCompany: '',
|
||||
logisticsNo: ''
|
||||
}
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId']),
|
||||
products() {
|
||||
return this.afterSale.products || []
|
||||
},
|
||||
images() {
|
||||
return Array.isArray(this.afterSale.images)
|
||||
? this.afterSale.images.map(item => ({
|
||||
url: item.url || item.previewUrl || item
|
||||
})).filter(item => item.url)
|
||||
: []
|
||||
},
|
||||
isExchange() {
|
||||
return Number(this.afterSale.serviceType) === SERVICE_TYPE.EXCHANGE
|
||||
},
|
||||
isUnsignedAfterSale() {
|
||||
if (this.afterSale.isSigned === false || this.afterSale.isSigned === 0 || this.afterSale.isSigned === 'false') {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.afterSale.isSigned === undefined &&
|
||||
Number(this.afterSale.logisticsState) === LOGISTICS_STATE.NONE &&
|
||||
!this.afterSale.pickupMethod &&
|
||||
!this.afterSale.pickupName &&
|
||||
!this.afterSale.pickupMobile &&
|
||||
!this.afterSale.pickupAddress
|
||||
},
|
||||
statusCardClass() {
|
||||
if (Number(this.afterSale.serviceState) === SERVICE_STATE.REJECTED) return 'rejected'
|
||||
if (Number(this.afterSale.serviceState) === SERVICE_STATE.APPROVED) return 'approved'
|
||||
if (Number(this.afterSale.serviceState) === SERVICE_STATE.FINISHED) return 'finished'
|
||||
return 'pending'
|
||||
},
|
||||
statusIcon() {
|
||||
if (this.statusCardClass === 'approved' || this.statusCardClass === 'finished') return 'success'
|
||||
if (this.statusCardClass === 'rejected') return 'cross'
|
||||
return 'clock-o'
|
||||
},
|
||||
statusTitle() {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
const logisticsState = Number(this.afterSale.logisticsState)
|
||||
if (serviceState === SERVICE_STATE.REJECTED) return '审核未通过'
|
||||
if (serviceState === SERVICE_STATE.FINISHED) return '售后完成'
|
||||
if (logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED) return '商家已发货'
|
||||
if (logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED && this.isExchange) return '待商家发货'
|
||||
if (logisticsState === LOGISTICS_STATE.USER_SHIPPED) return '用户已寄回'
|
||||
if (serviceState === SERVICE_STATE.APPROVED) return '审核通过'
|
||||
return '待审核'
|
||||
},
|
||||
statusDesc() {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
const logisticsState = Number(this.afterSale.logisticsState)
|
||||
if (serviceState === SERVICE_STATE.REJECTED) return '商家已拒绝该售后申请'
|
||||
if (serviceState === SERVICE_STATE.FINISHED) return '售后流程已完成'
|
||||
if (logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED) return '换货商品已发出,等待用户收货'
|
||||
if (logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED && this.isExchange) return '已收到寄回商品,请发出换货商品'
|
||||
if (logisticsState === LOGISTICS_STATE.USER_SHIPPED) return '用户已寄回商品,请确认收货'
|
||||
if (this.isExchange && this.isUnsignedAfterSale && serviceState === SERVICE_STATE.APPROVED) return '未签收换货申请已通过,请发出换货商品'
|
||||
if (serviceState === SERVICE_STATE.APPROVED) return '已通过售后申请,等待用户寄回商品'
|
||||
return '请审核用户提交的售后申请'
|
||||
},
|
||||
progressSteps() {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
const logisticsState = Number(this.afterSale.logisticsState)
|
||||
const steps = [
|
||||
{
|
||||
key: 'apply',
|
||||
title: '提交申请',
|
||||
time: this.afterSale.applyTime,
|
||||
desc: '用户已提交售后申请',
|
||||
state: 'done'
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
title: '商家审核',
|
||||
time: this.afterSale.auditTime,
|
||||
desc: serviceState === SERVICE_STATE.REJECTED ? '商家审核未通过' : serviceState >= SERVICE_STATE.APPROVED ? '商家已审核通过' : '待商家审核',
|
||||
state: serviceState >= SERVICE_STATE.APPROVED || serviceState === SERVICE_STATE.REJECTED ? 'done' : 'current'
|
||||
},
|
||||
{
|
||||
key: 'userShip',
|
||||
title: '寄回商品',
|
||||
time: this.afterSale.userShipTime,
|
||||
desc: logisticsState >= LOGISTICS_STATE.USER_SHIPPED ? '用户已寄回商品' : '等待用户寄回商品',
|
||||
state: logisticsState >= LOGISTICS_STATE.USER_SHIPPED ? 'done' : logisticsState === LOGISTICS_STATE.WAIT_USER_SHIP ? 'current' : 'todo'
|
||||
},
|
||||
{
|
||||
key: 'merchantReceive',
|
||||
title: '商家收货',
|
||||
time: this.afterSale.merchantReceiveTime,
|
||||
desc: logisticsState >= LOGISTICS_STATE.MERCHANT_RECEIVED ? '商家已收到商品' : '商家尚未收到商品',
|
||||
state: logisticsState >= LOGISTICS_STATE.MERCHANT_RECEIVED ? 'done' : logisticsState === LOGISTICS_STATE.USER_SHIPPED ? 'current' : 'todo'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.isUnsignedAfterSale && !this.isExchange) {
|
||||
steps.splice(2)
|
||||
steps.push({
|
||||
key: 'finish',
|
||||
title: '售后完成',
|
||||
time: this.afterSale.finishTime,
|
||||
desc: serviceState === SERVICE_STATE.FINISHED ? '售后已完成' : '审核通过后售后完成',
|
||||
state: serviceState === SERVICE_STATE.FINISHED ? 'done' : serviceState >= SERVICE_STATE.APPROVED ? 'current' : 'todo'
|
||||
})
|
||||
|
||||
return this.normalizeSteps(steps)
|
||||
}
|
||||
|
||||
if (this.isExchange) {
|
||||
if (this.isUnsignedAfterSale) {
|
||||
steps.splice(2)
|
||||
}
|
||||
|
||||
steps.push(
|
||||
{
|
||||
key: 'merchantShip',
|
||||
title: '商家发货',
|
||||
time: this.afterSale.merchantShipTime,
|
||||
desc: logisticsState >= LOGISTICS_STATE.MERCHANT_SHIPPED ? '商家已发出换货商品' : '等待商家发出换货商品',
|
||||
state: logisticsState >= LOGISTICS_STATE.MERCHANT_SHIPPED ? 'done' : (this.isUnsignedAfterSale ? serviceState >= SERVICE_STATE.APPROVED : logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED) ? 'current' : 'todo'
|
||||
},
|
||||
{
|
||||
key: 'userReceive',
|
||||
title: '用户收货',
|
||||
time: this.afterSale.finishTime,
|
||||
desc: logisticsState >= LOGISTICS_STATE.USER_RECEIVED ? '用户已收到换货商品' : '等待用户收货',
|
||||
state: logisticsState >= LOGISTICS_STATE.USER_RECEIVED ? 'done' : logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED ? 'current' : 'todo'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
steps.push({
|
||||
key: 'finish',
|
||||
title: '售后完成',
|
||||
time: this.afterSale.finishTime,
|
||||
desc: serviceState === SERVICE_STATE.FINISHED ? '售后流程已完成' : '售后流程未完成',
|
||||
state: serviceState === SERVICE_STATE.FINISHED ? 'done' : !this.isExchange && logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED ? 'current' : 'todo'
|
||||
})
|
||||
|
||||
return this.normalizeSteps(steps)
|
||||
},
|
||||
showReturnInfo() {
|
||||
return Number(this.afterSale.serviceState) >= SERVICE_STATE.APPROVED && !this.isUnsignedAfterSale
|
||||
},
|
||||
returnInfoTitle() {
|
||||
return Number(this.afterSale.logisticsState) >= LOGISTICS_STATE.WAIT_USER_SHIP ? '退货地址' : '取件地址'
|
||||
},
|
||||
pickupMethodText() {
|
||||
return this.afterSale.pickupMethod === 'mail' ? '自行寄回' : '上门取件'
|
||||
},
|
||||
pickupAddressText() {
|
||||
return this.afterSale.pickupAddress || '-'
|
||||
},
|
||||
merchantActions() {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
if (serviceState === SERVICE_STATE.PENDING_AUDIT) {
|
||||
return [
|
||||
{ key: 'reject', text: '拒绝' },
|
||||
{ key: 'approve', text: '通过', primary: true }
|
||||
]
|
||||
}
|
||||
|
||||
if (Number(this.afterSale.serviceState) === SERVICE_STATE.USER_SHIPPED) {
|
||||
return [{ key: 'receive', text: '确认收货', primary: true }]
|
||||
}
|
||||
|
||||
if (this.canShipExchange) {
|
||||
return [{ key: 'shipExchange', text: '换货发货', primary: true }]
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
canShipExchange() {
|
||||
return this.isExchange &&
|
||||
(Number(this.afterSale.serviceState) === SERVICE_STATE.RECEIVED ||
|
||||
(this.afterSale.isSigned === false && Number(this.afterSale.serviceState) === SERVICE_STATE.APPROVED))
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadDetail()
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchSupplierInfo']),
|
||||
normalizeSteps(steps) {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
return steps.map((step, index) => ({
|
||||
...step,
|
||||
state: serviceState === SERVICE_STATE.REJECTED && index > 1 ? 'todo' : step.state,
|
||||
subTitle: step.state === 'done' ? '' : '-'
|
||||
}))
|
||||
},
|
||||
skuText(item = {}) {
|
||||
const sku = String(item.sku || item.skuText || '').trim()
|
||||
const parts = sku.split('-').slice(-2).filter(Boolean)
|
||||
const color = SKU_COLOR_MAP[parts[0]] || parts[0]
|
||||
const size = SKU_SIZE_MAP[parts[1]] || parts[1]
|
||||
|
||||
return color || size ? [color, size].filter(Boolean).join(' | ') : '-'
|
||||
},
|
||||
maskedMobile(mobile = '') {
|
||||
return String(mobile).replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2') || '-'
|
||||
},
|
||||
async loadDetail() {
|
||||
try {
|
||||
this.loading = true
|
||||
if (!this.supplierId && !this.$route.query.supplierId) await this.fetchSupplierInfo()
|
||||
const data = await postFunction('merchant-after-sale-detail', {
|
||||
supplierId: String(this.$route.query.supplierId || this.supplierId),
|
||||
serviceOrderId: String(this.$route.query.serviceOrderId || '')
|
||||
})
|
||||
|
||||
this.afterSale = data?.afterSale || {}
|
||||
} catch (error) {
|
||||
Toast(error.message || '查询售后详情失败,请稍后再试')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
copyServiceNo() {
|
||||
if (navigator.clipboard && this.afterSale.serviceOrderId) {
|
||||
navigator.clipboard.writeText(this.afterSale.serviceOrderId)
|
||||
}
|
||||
Toast('已复制')
|
||||
},
|
||||
showAuditRemark() {
|
||||
Dialog.alert({
|
||||
title: '审核意见',
|
||||
message: this.afterSale.auditRemark
|
||||
})
|
||||
},
|
||||
handleAction(key) {
|
||||
if (key === 'approve') {
|
||||
this.auditAfterSale(1)
|
||||
return
|
||||
}
|
||||
if (key === 'reject') {
|
||||
this.auditAfterSale(2)
|
||||
return
|
||||
}
|
||||
if (key === 'receive') {
|
||||
this.receiveAfterSale()
|
||||
return
|
||||
}
|
||||
if (key === 'shipExchange') {
|
||||
this.openExchangeShipPopup()
|
||||
}
|
||||
},
|
||||
async auditAfterSale(auditStatus) {
|
||||
await Dialog.confirm({
|
||||
title: auditStatus === 1 ? '确认通过售后申请?' : '确认拒绝售后申请?',
|
||||
message: auditStatus === 1 ? '通过后用户可寄回商品。' : '拒绝后该售后申请将结束。'
|
||||
})
|
||||
await postFunction('merchant-after-sale-audit', {
|
||||
supplierId: this.afterSale.supplierId || this.supplierId,
|
||||
serviceOrderId: this.afterSale.serviceOrderId,
|
||||
auditStatus,
|
||||
auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝'
|
||||
})
|
||||
Toast('处理成功')
|
||||
await this.loadDetail()
|
||||
},
|
||||
async receiveAfterSale() {
|
||||
await Dialog.confirm({
|
||||
title: '确认已收到寄回商品?',
|
||||
message: '确认后退货售后会进入完成,换货售后会进入待商家寄货。'
|
||||
})
|
||||
await postFunction('merchant-after-sale-receive', {
|
||||
supplierId: this.afterSale.supplierId || this.supplierId,
|
||||
serviceOrderId: this.afterSale.serviceOrderId
|
||||
})
|
||||
Toast('已确认收货')
|
||||
await this.loadDetail()
|
||||
},
|
||||
openExchangeShipPopup() {
|
||||
this.exchangeShipForm = {
|
||||
logisticsCompany: '',
|
||||
logisticsNo: ''
|
||||
}
|
||||
this.showExchangeShipPopup = true
|
||||
},
|
||||
async submitExchangeShip() {
|
||||
if (!this.exchangeShipForm.logisticsCompany || !this.exchangeShipForm.logisticsNo) {
|
||||
Toast('请填写完整发货信息')
|
||||
return
|
||||
}
|
||||
|
||||
await postFunction('merchant-after-sale-ship', {
|
||||
supplierId: this.afterSale.supplierId || this.supplierId,
|
||||
serviceOrderId: this.afterSale.serviceOrderId,
|
||||
logisticsCompany: this.exchangeShipForm.logisticsCompany,
|
||||
logisticsNo: this.exchangeShipForm.logisticsNo
|
||||
})
|
||||
Toast('换货发货成功')
|
||||
this.showExchangeShipPopup = false
|
||||
await this.loadDetail()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.after-sale-detail-page {
|
||||
min-height: 100vh;
|
||||
padding: 56px 10px 78px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.after-sale-detail-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.skeleton-card,
|
||||
.detail-card {
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
background: #ff9f1a;
|
||||
}
|
||||
|
||||
.status-card.approved .status-icon,
|
||||
.status-card.finished .status-icon {
|
||||
background: #20b879;
|
||||
}
|
||||
|
||||
.status-card.rejected .status-icon {
|
||||
background: #ee3f4d;
|
||||
}
|
||||
|
||||
.status-card h2 {
|
||||
margin: 0 0 6px;
|
||||
color: #222;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-card p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.status-card button {
|
||||
margin-top: 8px;
|
||||
border: 0;
|
||||
color: #ff5b20;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.info-card button {
|
||||
flex: none;
|
||||
align-self: center;
|
||||
height: 24px;
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #ff5b20;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0 0 12px;
|
||||
color: #222;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
bottom: -2px;
|
||||
left: 6px;
|
||||
width: 1px;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
background: #d8d8d8;
|
||||
}
|
||||
|
||||
.timeline-item.done .timeline-dot {
|
||||
background: #20b879;
|
||||
}
|
||||
|
||||
.timeline-item.current .timeline-dot {
|
||||
background: #ff5b20;
|
||||
}
|
||||
|
||||
.timeline-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeline-main b {
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timeline-main em,
|
||||
.timeline-item p {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.timeline-item p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.product-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
flex: none;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-info h4 {
|
||||
margin: 0 0 6px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.product-info p {
|
||||
margin: 0 0 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-info b {
|
||||
color: #ff5b20;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.product-row > span {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.address-card p,
|
||||
.info-card p {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 10px 0 0;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.address-card b,
|
||||
.info-card b {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-card .service-no-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-card .scrollable-no {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.info-card .scrollable-no::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.voucher-block h4 {
|
||||
margin: 14px 0 8px;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.voucher-block div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.voucher-block .van-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.bottom-actions button,
|
||||
.action-popup button {
|
||||
min-width: 96px;
|
||||
height: 36px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 18px;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bottom-actions button.primary,
|
||||
.action-popup button {
|
||||
border-color: #ff5b20;
|
||||
color: #fff;
|
||||
background: #ff5b20;
|
||||
}
|
||||
|
||||
.action-popup {
|
||||
padding: 18px 14px 22px;
|
||||
}
|
||||
|
||||
.action-popup h3 {
|
||||
margin: 0 0 12px;
|
||||
color: #222;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-popup button {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,705 @@
|
||||
<template>
|
||||
<main class="merchant-page">
|
||||
<van-nav-bar title="商家处理台" left-arrow @click-left="$router.back()" />
|
||||
|
||||
<van-pull-refresh v-model="refreshing" class="merchant-refresh" @refresh="onRefresh">
|
||||
<van-tabs v-model:active="activeTab" sticky offset-top="46" color="#ff5b20" @change="loadCurrentTab">
|
||||
<van-tab title="待发货" name="ship" :badge="tabBadge('ship')">
|
||||
<section class="list-panel">
|
||||
<button v-if="orderItems.length" type="button" class="batch-ship-button" @click="quickShipAll">一键发货</button>
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="3" />
|
||||
</section>
|
||||
<van-empty v-else-if="orderItems.length === 0" description="暂无待发货订单" />
|
||||
<template v-else>
|
||||
<article v-for="item in orderItems" :key="item.orderId" class="work-card">
|
||||
<header>
|
||||
<b>{{ item.orderNumberId || item.orderId }}</b>
|
||||
<span>待发货</span>
|
||||
</header>
|
||||
<div v-for="product in item.products" :key="product.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="product.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ product.name || '-' }}</h4>
|
||||
<p>{{ skuText(product) }}</p>
|
||||
<b>¥{{ product.price }}</b>
|
||||
</div>
|
||||
<em>x{{ product.number || 1 }}</em>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>用户</span><b>{{ item.userId || '-' }}</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>下单时间</span><b>{{ item.createTime || '-' }}</b>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="primary" @click="openShipPopup(item)">发货</button>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="售后审核" name="audit" :badge="tabBadge('audit')">
|
||||
<section class="list-panel">
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="3" />
|
||||
</section>
|
||||
<van-empty v-else-if="auditItems.length === 0" description="暂无待审核售后" />
|
||||
<template v-else>
|
||||
<article v-for="item in auditItems" :key="item.serviceOrderId" class="work-card audit-card" @click="goAfterSaleDetail(item)">
|
||||
<header>
|
||||
<b>{{ item.serviceOrderId }}</b>
|
||||
<span>{{ item.serviceTypeText }}</span>
|
||||
</header>
|
||||
<span class="signed-stamp" :class="{ unsigned: item.isSigned === false }">{{ item.isSigned === false ? '未签收' : '已签收' }}</span>
|
||||
<div v-for="product in item.products" :key="product.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="product.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ product.name || '-' }}</h4>
|
||||
<p>{{ skuText(product) }}</p>
|
||||
<b>¥{{ product.price }}</b>
|
||||
</div>
|
||||
<em>x{{ product.number || 1 }}</em>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>原因</span><b>{{ item.reason || '-' }}</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>描述</span><b>{{ item.problemDescription || '-' }}</b>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" @click.stop="goAfterSaleDetail(item)">查看详情</button>
|
||||
<button type="button" @click.stop="auditAfterSale(item, 2)">拒绝</button>
|
||||
<button type="button" class="primary" @click.stop="auditAfterSale(item, 1)">通过</button>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="售后处理" name="process" :badge="tabBadge('process')">
|
||||
<section class="list-panel">
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="3" />
|
||||
</section>
|
||||
<van-empty v-else-if="processItems.length === 0" description="暂无处理中售后" />
|
||||
<template v-else>
|
||||
<article v-for="item in processItems" :key="item.serviceOrderId" class="work-card" @click="goAfterSaleDetail(item)">
|
||||
<header>
|
||||
<b>{{ item.serviceOrderId }}</b>
|
||||
<span>{{ item.serviceStateText }}</span>
|
||||
</header>
|
||||
<span class="signed-stamp" :class="{ unsigned: item.isSigned === false }">{{ item.isSigned === false ? '未签收' : '已签收' }}</span>
|
||||
<div v-for="product in item.products" :key="product.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="product.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ product.name || '-' }}</h4>
|
||||
<p>{{ skuText(product) }}</p>
|
||||
<b>¥{{ product.price }}</b>
|
||||
</div>
|
||||
<em>x{{ product.number || 1 }}</em>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>寄回单号</span><b>{{ item.userLogisticsCompany || '' }} {{ item.userLogisticsNo || '-' }}</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>取件地址</span><b>{{ item.pickupAddress || '-' }}</b>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" @click.stop="goAfterSaleDetail(item)">查看详情</button>
|
||||
<button v-if="canReceiveAfterSale(item)" type="button" class="primary" @click.stop="receiveAfterSale(item)">确认收货</button>
|
||||
<button v-if="canShipExchange(item)" type="button" class="primary" @click.stop="openExchangeShipPopup(item)">换货发货</button>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="历史售后" name="history">
|
||||
<section class="list-panel">
|
||||
<section v-if="loading" class="skeleton-card">
|
||||
<van-skeleton title :row="3" />
|
||||
<van-skeleton title :row="3" />
|
||||
</section>
|
||||
<van-empty v-else-if="historyItems.length === 0" description="暂无历史售后" />
|
||||
<template v-else>
|
||||
<article v-for="item in historyItems" :key="item.serviceOrderId" class="work-card" @click="goAfterSaleDetail(item)">
|
||||
<header>
|
||||
<b>{{ item.serviceOrderId }}</b>
|
||||
<span>{{ item.serviceStateText }}</span>
|
||||
</header>
|
||||
<span class="signed-stamp" :class="{ unsigned: item.isSigned === false }">{{ item.isSigned === false ? '未签收' : '已签收' }}</span>
|
||||
<div v-for="product in item.products" :key="product.id" class="product-row">
|
||||
<van-image class="product-thumb" :src="product.image" fit="cover" />
|
||||
<div class="product-info">
|
||||
<h4>{{ product.name || '-' }}</h4>
|
||||
<p>{{ skuText(product) }}</p>
|
||||
<b>¥{{ product.price }}</b>
|
||||
</div>
|
||||
<em>x{{ product.number || 1 }}</em>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>类型</span><b>{{ item.serviceTypeText || '-' }}</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>原因</span><b>{{ item.reason || '-' }}</b>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>申请时间</span><b>{{ item.applyTime || '-' }}</b>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="primary" @click.stop="goAfterSaleDetail(item)">查看详情</button>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</van-pull-refresh>
|
||||
|
||||
<van-popup v-model:show="showShipPopup" round position="bottom">
|
||||
<section class="action-popup">
|
||||
<h3>填写发货信息</h3>
|
||||
<van-field v-model="shipForm.crrgBsnNm" label="快递公司" placeholder="请输入快递公司" />
|
||||
<van-field v-model="shipForm.logisticsOrderId" label="快递单号" placeholder="请输入快递单号" />
|
||||
<button type="button" @click="submitShip">确认发货</button>
|
||||
</section>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showExchangeShipPopup" round position="bottom">
|
||||
<section class="action-popup">
|
||||
<h3>填写换货发货信息</h3>
|
||||
<van-field v-model="exchangeShipForm.logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
|
||||
<van-field v-model="exchangeShipForm.logisticsNo" label="快递单号" placeholder="请输入快递单号" />
|
||||
<button type="button" @click="submitExchangeShip">确认发货</button>
|
||||
</section>
|
||||
</van-popup>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { Dialog, Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useUiStore } from '@/store/ui'
|
||||
|
||||
const ORDER_STATE = {
|
||||
FINISH_PAY: 1
|
||||
}
|
||||
|
||||
const SERVICE_TYPE = {
|
||||
EXCHANGE: 5
|
||||
}
|
||||
|
||||
const SERVICE_STATE = {
|
||||
APPROVED: 1,
|
||||
USER_SHIPPED: 3,
|
||||
RECEIVED: 4
|
||||
}
|
||||
|
||||
const SKU_COLOR_MAP = {
|
||||
cream: '奶油白',
|
||||
blue: '雾霾蓝',
|
||||
orange: '暖橙色'
|
||||
}
|
||||
|
||||
const SKU_SIZE_MAP = {
|
||||
standard: '标准款',
|
||||
plus: '升级款'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionMerchant',
|
||||
data: () => ({
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
orderItems: [],
|
||||
auditItems: [],
|
||||
processItems: [],
|
||||
historyItems: [],
|
||||
currentOrder: null,
|
||||
currentAfterSale: null,
|
||||
showShipPopup: false,
|
||||
showExchangeShipPopup: false,
|
||||
shipForm: {
|
||||
crrgBsnNm: '',
|
||||
logisticsOrderId: ''
|
||||
},
|
||||
exchangeShipForm: {
|
||||
logisticsCompany: '',
|
||||
logisticsNo: ''
|
||||
}
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['supplierId']),
|
||||
...mapState(useUiStore, ['merchantTab']),
|
||||
activeTab: {
|
||||
get() {
|
||||
return this.merchantTab
|
||||
},
|
||||
set(value) {
|
||||
this.setMerchantTab(value)
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadUi()
|
||||
this.prepareSupplier().then(() => this.loadAllTabs())
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchSupplierInfo']),
|
||||
...mapActions(useUiStore, ['loadUi', 'setMerchantTab']),
|
||||
skuText(item = {}) {
|
||||
const sku = String(item.sku || item.skuText || '').trim()
|
||||
const parts = sku.split('-').slice(-2).filter(Boolean)
|
||||
const color = SKU_COLOR_MAP[parts[0]] || parts[0]
|
||||
const size = SKU_SIZE_MAP[parts[1]] || parts[1]
|
||||
|
||||
return color || size ? [color, size].filter(Boolean).join(' | ') : '-'
|
||||
},
|
||||
tabBadge(name) {
|
||||
const countMap = {
|
||||
ship: this.orderItems.length,
|
||||
audit: this.auditItems.length,
|
||||
process: this.processItems.length
|
||||
}
|
||||
const count = countMap[name] || 0
|
||||
|
||||
return count > 0 ? count : ''
|
||||
},
|
||||
async prepareSupplier() {
|
||||
if (!this.supplierId) {
|
||||
await this.fetchSupplierInfo()
|
||||
}
|
||||
},
|
||||
async loadAllTabs() {
|
||||
this.loading = true
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadOrders(),
|
||||
this.loadAfterSales('audit'),
|
||||
this.loadAfterSales('process'),
|
||||
this.loadAfterSales('history')
|
||||
])
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async loadCurrentTab(showLoading = true) {
|
||||
const shouldShowLoading = typeof showLoading === 'boolean' ? showLoading : true
|
||||
if (shouldShowLoading) {
|
||||
this.loading = true
|
||||
}
|
||||
if (this.activeTab === 'ship') {
|
||||
await this.loadOrders().finally(() => {
|
||||
if (shouldShowLoading) {
|
||||
this.loading = false
|
||||
}
|
||||
})
|
||||
} else if (this.activeTab === 'audit') {
|
||||
await this.loadAfterSales('audit').finally(() => {
|
||||
if (shouldShowLoading) {
|
||||
this.loading = false
|
||||
}
|
||||
})
|
||||
} else if (this.activeTab === 'process') {
|
||||
await this.loadAfterSales('process').finally(() => {
|
||||
if (shouldShowLoading) {
|
||||
this.loading = false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await this.loadAfterSales('history').finally(() => {
|
||||
if (shouldShowLoading) {
|
||||
this.loading = false
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
async onRefresh() {
|
||||
try {
|
||||
await this.loadCurrentTab(false)
|
||||
} finally {
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
async loadOrders() {
|
||||
const data = await postFunction('merchant-order-list', {
|
||||
supplierId: this.supplierId,
|
||||
orderState: ORDER_STATE.FINISH_PAY
|
||||
})
|
||||
this.orderItems = data?.orderItems || []
|
||||
},
|
||||
async loadAfterSales(scene) {
|
||||
const data = await postFunction('merchant-after-sale-list', {
|
||||
supplierId: this.supplierId,
|
||||
scene
|
||||
})
|
||||
if (scene === 'audit') {
|
||||
this.auditItems = data?.afterSaleItems || []
|
||||
} else if (scene === 'history') {
|
||||
this.historyItems = data?.afterSaleItems || []
|
||||
} else {
|
||||
this.processItems = data?.afterSaleItems || []
|
||||
}
|
||||
},
|
||||
goAfterSaleDetail(item) {
|
||||
this.$router.push({
|
||||
path: '/joyful-selection/merchant/after-sale-detail',
|
||||
query: {
|
||||
serviceOrderId: item.serviceOrderId,
|
||||
supplierId: item.supplierId || this.supplierId
|
||||
}
|
||||
})
|
||||
},
|
||||
openShipPopup(item) {
|
||||
this.currentOrder = item
|
||||
this.shipForm = {
|
||||
crrgBsnNm: item.crrgBsnNm || '韵达快递',
|
||||
logisticsOrderId: item.logisticsOrderId || `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`
|
||||
}
|
||||
this.showShipPopup = true
|
||||
},
|
||||
async quickShipAll() {
|
||||
await Dialog.confirm({
|
||||
title: '确认一键发货?',
|
||||
message: `将为当前 ${this.orderItems.length} 个待发货订单提交发货。`
|
||||
})
|
||||
await Promise.all(this.orderItems.map(item => postFunction('merchant-order-ship', {
|
||||
supplierId: this.supplierId,
|
||||
userId: item.userId,
|
||||
orderId: item.orderId,
|
||||
crrgBsnNm: item.crrgBsnNm || '韵达快递',
|
||||
logisticsOrderId: item.logisticsOrderId || `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`
|
||||
})))
|
||||
Toast('批量发货成功')
|
||||
await this.loadOrders()
|
||||
},
|
||||
async submitShip() {
|
||||
if (!this.shipForm.crrgBsnNm || !this.shipForm.logisticsOrderId) {
|
||||
Toast('请填写完整发货信息')
|
||||
return
|
||||
}
|
||||
|
||||
await postFunction('merchant-order-ship', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.currentOrder.userId,
|
||||
orderId: this.currentOrder.orderId,
|
||||
crrgBsnNm: this.shipForm.crrgBsnNm,
|
||||
logisticsOrderId: this.shipForm.logisticsOrderId
|
||||
})
|
||||
Toast('发货成功')
|
||||
this.showShipPopup = false
|
||||
await this.loadOrders()
|
||||
},
|
||||
async auditAfterSale(item, auditStatus) {
|
||||
await Dialog.confirm({
|
||||
title: auditStatus === 1 ? '确认通过售后申请?' : '确认拒绝售后申请?',
|
||||
message: auditStatus === 1 ? '通过后用户可寄回商品。' : '拒绝后该售后申请将结束。'
|
||||
})
|
||||
await postFunction('merchant-after-sale-audit', {
|
||||
supplierId: this.supplierId,
|
||||
serviceOrderId: item.serviceOrderId,
|
||||
auditStatus,
|
||||
auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝'
|
||||
})
|
||||
Toast('处理成功')
|
||||
await this.loadAfterSales('audit')
|
||||
},
|
||||
canReceiveAfterSale(item) {
|
||||
return Number(item.serviceState) === SERVICE_STATE.USER_SHIPPED
|
||||
},
|
||||
canShipExchange(item) {
|
||||
return Number(item.serviceType) === SERVICE_TYPE.EXCHANGE &&
|
||||
(Number(item.serviceState) === SERVICE_STATE.RECEIVED ||
|
||||
(item.isSigned === false && Number(item.serviceState) === SERVICE_STATE.APPROVED))
|
||||
},
|
||||
async receiveAfterSale(item) {
|
||||
await Dialog.confirm({
|
||||
title: '确认已收到寄回商品?',
|
||||
message: '确认后退货售后会进入完成,换货售后会进入待商家寄货。'
|
||||
})
|
||||
await postFunction('merchant-after-sale-receive', {
|
||||
supplierId: this.supplierId,
|
||||
serviceOrderId: item.serviceOrderId
|
||||
})
|
||||
Toast('已确认收货')
|
||||
await this.loadAfterSales('process')
|
||||
},
|
||||
openExchangeShipPopup(item) {
|
||||
this.currentAfterSale = item
|
||||
this.exchangeShipForm = {
|
||||
logisticsCompany: '',
|
||||
logisticsNo: ''
|
||||
}
|
||||
this.showExchangeShipPopup = true
|
||||
},
|
||||
async submitExchangeShip() {
|
||||
if (!this.exchangeShipForm.logisticsCompany || !this.exchangeShipForm.logisticsNo) {
|
||||
Toast('请填写完整发货信息')
|
||||
return
|
||||
}
|
||||
|
||||
await postFunction('merchant-after-sale-ship', {
|
||||
supplierId: this.supplierId,
|
||||
serviceOrderId: this.currentAfterSale.serviceOrderId,
|
||||
logisticsCompany: this.exchangeShipForm.logisticsCompany,
|
||||
logisticsNo: this.exchangeShipForm.logisticsNo
|
||||
})
|
||||
Toast('换货发货成功')
|
||||
this.showExchangeShipPopup = false
|
||||
await this.loadAfterSales('process')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.merchant-page {
|
||||
min-height: 100vh;
|
||||
padding-top: 46px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.merchant-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.merchant-refresh {
|
||||
min-height: calc(100vh - 46px);
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
min-height: calc(100vh - 90px);
|
||||
padding: 12px 10px 18px;
|
||||
}
|
||||
|
||||
.batch-ship-button {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
margin-bottom: 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #ff6a2a 0%, #ff4b18 100%);
|
||||
}
|
||||
|
||||
.work-card {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.audit-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.signed-stamp {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 110px;
|
||||
right: 45px;
|
||||
display: flex;
|
||||
width: 95px;
|
||||
height: 60px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid rgba(255, 91, 32, .8);
|
||||
border-radius: 50%;
|
||||
color: #ff5b20;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
opacity: .9;
|
||||
background: rgba(255, 241, 232, .55);
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.signed-stamp::before {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border: 1px dashed currentColor;
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.signed-stamp.unsigned {
|
||||
border-color: rgba(153, 153, 153, .9);
|
||||
color: #666;
|
||||
background: rgba(246, 247, 249, .7);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 14px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skeleton-card ::v-deep .van-skeleton + .van-skeleton {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.work-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.work-card header b {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.work-card header b::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.work-card header span {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: #ff5b20;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.product-row {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.product-info h4 {
|
||||
overflow: hidden;
|
||||
margin: 0 0 5px;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-info p {
|
||||
overflow: hidden;
|
||||
margin: 0 0 5px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-info b {
|
||||
color: #ff5b20;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.product-row em {
|
||||
flex: none;
|
||||
color: #222;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.meta-row span {
|
||||
width: 64px;
|
||||
flex: none;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.meta-row b {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: #555;
|
||||
font-weight: 400;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.work-card footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.work-card footer button {
|
||||
min-width: 88px;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: #f6f7f9;
|
||||
}
|
||||
|
||||
.work-card footer .primary {
|
||||
color: #ff5b20;
|
||||
background: #fff1e8;
|
||||
}
|
||||
|
||||
.action-popup {
|
||||
padding: 18px 12px 14px;
|
||||
}
|
||||
|
||||
.action-popup h3 {
|
||||
margin: 0 0 12px;
|
||||
color: #222;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-popup button {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
margin-top: 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #ff6a2a 0%, #ff4b18 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<main class="merchant-login-page">
|
||||
<van-nav-bar title="商家登录" left-arrow @click-left="$router.replace('/joyful-selection/home')" />
|
||||
<section class="merchant-login-card">
|
||||
<div class="login-icon"><van-icon name="shop-o" size="36" /></div>
|
||||
<h1>商家处理台</h1><p>使用供应商管理账号登录</p>
|
||||
<van-form @submit="submit">
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="username" name="username" label="账号" placeholder="请输入账号" autocomplete="username" :rules="[{ required: true, message: '请输入账号' }]" />
|
||||
<van-field v-model="password" name="password" type="password" label="密码" placeholder="请输入密码" autocomplete="current-password" :rules="[{ required: true, message: '请输入密码' }]" />
|
||||
</van-cell-group>
|
||||
<van-button block round type="primary" native-type="submit" :loading="submitting" :disabled="submitting" color="#ff5b20" class="login-submit">登录</van-button>
|
||||
</van-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { saveMerchantSession, clearMerchantSession } from '@/api/merchant-session'
|
||||
|
||||
export default {
|
||||
name: 'MerchantLogin',
|
||||
data: () => ({ username: '', password: '', submitting: false }),
|
||||
methods: {
|
||||
async submit() {
|
||||
if (this.submitting) return
|
||||
this.submitting = true
|
||||
try {
|
||||
const session = await postFunction('login', { username: this.username.trim(), password: this.password })
|
||||
saveMerchantSession(session)
|
||||
await postFunction('admin-session')
|
||||
this.password = ''
|
||||
const target = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : ''
|
||||
const resolved = this.$router.resolve(target || '/joyful-selection/merchant')
|
||||
await this.$router.replace(resolved.meta.merchantAuth ? resolved.fullPath : '/joyful-selection/merchant')
|
||||
} catch (error) {
|
||||
clearMerchantSession()
|
||||
Toast(error.message || '登录失败')
|
||||
} finally { this.submitting = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.merchant-login-page { min-height: 100vh; background: #f7f8fa; }
|
||||
.merchant-login-card { max-width: 420px; margin: 48px auto; padding: 28px 20px; background: #fff; border-radius: 16px; }
|
||||
.login-icon { display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; margin: 0 auto 20px; border-radius: 20px; color: #ff5b20; background: #fff1e9; }
|
||||
.merchant-login-card h1 { margin: 0; text-align: center; font-size: 23px; }
|
||||
.merchant-login-card > p { margin: 12px 0 28px; text-align: center; color: #8793a5; font-size: 14px; }
|
||||
.merchant-login-card :deep(.van-cell-group--inset) { margin: 0; }
|
||||
.login-submit { margin-top: 28px; }
|
||||
@media (max-width: 480px) { .merchant-login-card { margin: 32px 16px; } }
|
||||
</style>
|
||||
@@ -1,15 +1,25 @@
|
||||
<template>
|
||||
<main class="checkout-page">
|
||||
<div>
|
||||
<joyful-selection-checkout-skeleton v-if="loading" />
|
||||
<main v-else class="checkout-page">
|
||||
<van-nav-bar title="确认订单" left-arrow @click-left="$router.back()" />
|
||||
|
||||
<section class="address-card" @click="$router.push({ path: '/joyful-selection/address', query: { from: 'checkout' } })">
|
||||
<section class="address-card" :class="{ 'address-card--empty': !chosenAddress.id }" @click="goAddress">
|
||||
<van-icon class="address-icon" name="location" />
|
||||
<div class="address-info">
|
||||
<div v-if="chosenAddress.id" class="address-info">
|
||||
<h3>收货人:{{ chosenAddress.name }} <span>{{ maskTel(chosenAddress.tel) }}</span></h3>
|
||||
<p>{{ chosenAddress.province }}{{ chosenAddress.city }}{{ chosenAddress.county }}</p>
|
||||
<p>{{ chosenAddress.addressDetail }}</p>
|
||||
</div>
|
||||
<van-icon class="arrow-icon" name="arrow" />
|
||||
<div v-else class="address-info address-empty">
|
||||
<h3>请先新增收货地址</h3>
|
||||
<p>用于商品配送,请确保信息准确</p>
|
||||
</div>
|
||||
<div v-if="!chosenAddress.id" class="address-action">
|
||||
<span>去新增</span>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
<van-icon v-else class="arrow-icon" name="arrow" />
|
||||
</section>
|
||||
|
||||
<section class="delivery-card">
|
||||
@@ -28,7 +38,8 @@
|
||||
<van-image class="order-thumb" :src="item.image" fit="cover" />
|
||||
<div class="order-info">
|
||||
<h3>{{ item.name }}</h3>
|
||||
<p>¥{{ item.price }}</p>
|
||||
<p v-if="productSpecText(item)" class="order-desc">{{ productSpecText(item) }}</p>
|
||||
<b>¥{{ item.price }}</b>
|
||||
</div>
|
||||
<span class="order-count">x{{ item.count || 1 }}</span>
|
||||
</article>
|
||||
@@ -46,31 +57,40 @@
|
||||
<section class="amount-card">
|
||||
<p><span>商品金额</span><b>¥{{ formatYuan(goodsAmount) }}</b></p>
|
||||
<p><span>运费</span><b>¥0.00</b></p>
|
||||
<p><span>优惠券</span><b class="muted">选择优惠券</b></p>
|
||||
<p class="coupon-row" @click="showCouponTip"><span>优惠券</span><b class="muted">选择优惠券<van-icon name="arrow" /></b></p>
|
||||
</section>
|
||||
|
||||
<van-submit-bar :price="goodsAmount" button-text="提交订单" @submit="onSubmit" />
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { useCartStore } from '@/store/cart'
|
||||
import { readAddresses, readChosenAddressId } from '../../address/address-data'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { createOrderNo, savePaymentInfo } from '../payment/payment-data'
|
||||
import JoyfulSelectionCheckoutSkeleton from '@/skeleton-view/joyful-selection/order/checkout/index.vue'
|
||||
|
||||
const CHECKOUT_ITEMS_KEY = 'joyful-selection-checkout-items'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionCheckout',
|
||||
components: {
|
||||
JoyfulSelectionCheckoutSkeleton
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
remark: '',
|
||||
goods: [],
|
||||
showAllGoods: false,
|
||||
chosenAddress: {}
|
||||
chosenAddress: {},
|
||||
submitting: false
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useCartStore, ['items']),
|
||||
...mapState(useUserStore, ['supplierId', 'userId']),
|
||||
visibleGoods() {
|
||||
return this.showAllGoods ? this.goods : this.goods.slice(0, 3)
|
||||
},
|
||||
@@ -84,13 +104,20 @@ export default {
|
||||
created() {
|
||||
this.loadCart()
|
||||
this.goods = this.getCheckoutGoods()
|
||||
this.loadAddress()
|
||||
this.loadAddress().catch(error => {
|
||||
this.$toast(error.message || '查询地址失败,请稍后再试')
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
activated() {
|
||||
this.loadAddress()
|
||||
this.loadAddress().catch(error => {
|
||||
this.$toast(error.message || '查询地址失败,请稍后再试')
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useCartStore, ['loadCart']),
|
||||
...mapActions(useCartStore, ['loadCart', 'removeItems']),
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
getCheckoutGoods() {
|
||||
const storedItems = this.readCheckoutItems()
|
||||
|
||||
@@ -114,23 +141,96 @@ export default {
|
||||
formatYuan(price) {
|
||||
return (Number(price || 0) / 100).toFixed(2)
|
||||
},
|
||||
loadAddress() {
|
||||
const addresses = readAddresses()
|
||||
const chosenId = readChosenAddressId()
|
||||
this.chosenAddress = addresses.find(item => item.id === chosenId) || addresses[0] || {}
|
||||
productSpecText(item = {}) {
|
||||
return String(item.skuText || item.desc || '').replace(/\s*\/\s*/g, ' | ')
|
||||
},
|
||||
async loadAddress() {
|
||||
await this.fetchBridgeToken()
|
||||
|
||||
if (!this.supplierId || !this.userId) {
|
||||
this.chosenAddress = {}
|
||||
return
|
||||
}
|
||||
|
||||
const data = await postFunction('address-default', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId
|
||||
})
|
||||
this.chosenAddress = data?.address || {}
|
||||
},
|
||||
goAddress() {
|
||||
if (this.chosenAddress.id) {
|
||||
this.$router.push({ path: '/joyful-selection/address', query: { from: 'checkout' } })
|
||||
return
|
||||
}
|
||||
|
||||
this.$router.push({ path: '/joyful-selection/address/edit', query: { from: 'checkout' } })
|
||||
},
|
||||
maskTel(tel = '') {
|
||||
return tel.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2')
|
||||
},
|
||||
onSubmit() {
|
||||
savePaymentInfo({
|
||||
orderNo: createOrderNo(),
|
||||
amount: this.goodsAmount,
|
||||
receiverName: this.chosenAddress.name || '',
|
||||
receiverTel: this.maskTel(this.chosenAddress.tel || ''),
|
||||
goods: this.goods
|
||||
})
|
||||
this.$router.push('/joyful-selection/payment')
|
||||
showCouponTip() {
|
||||
this.$toast('急速开发中,敬请期待')
|
||||
},
|
||||
async onSubmit() {
|
||||
if (this.submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.goods.length) {
|
||||
this.$toast('暂无可提交的商品')
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
try {
|
||||
await this.fetchBridgeToken()
|
||||
|
||||
if (!this.supplierId || !this.userId) {
|
||||
throw new Error('供应商授权信息不完整,请重新进入商城')
|
||||
}
|
||||
|
||||
const orderNo = createOrderNo()
|
||||
const data = await postFunction('order-create', {
|
||||
supplierId: this.supplierId,
|
||||
userId: this.userId,
|
||||
orderNo,
|
||||
amount: this.goodsAmount,
|
||||
addressId: this.chosenAddress.id || '',
|
||||
remark: this.remark,
|
||||
goods: this.goods
|
||||
})
|
||||
|
||||
savePaymentInfo({
|
||||
orderNo: data?.orderId || orderNo,
|
||||
orderNumberId: data?.orderNumberId || '',
|
||||
amount: this.goodsAmount,
|
||||
receiverName: this.chosenAddress.name || '',
|
||||
receiverTel: this.maskTel(this.chosenAddress.tel || ''),
|
||||
goods: this.goods
|
||||
})
|
||||
|
||||
this.clearSubmittedCartItems()
|
||||
this.$router.push('/joyful-selection/payment')
|
||||
} catch (error) {
|
||||
console.warn('提交待支付订单失败', error)
|
||||
this.$toast(error.message || '提交待支付订单失败')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
clearSubmittedCartItems() {
|
||||
if (this.$route.query.source !== 'cart') {
|
||||
return
|
||||
}
|
||||
|
||||
const cartIds = this.goods.map(item => item.cartId).filter(Boolean)
|
||||
|
||||
if (cartIds.length) {
|
||||
this.removeItems(cartIds)
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(CHECKOUT_ITEMS_KEY)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +269,10 @@ export default {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.address-card--empty {
|
||||
background: #fff6ea;
|
||||
}
|
||||
|
||||
.address-icon {
|
||||
flex: none;
|
||||
margin-right: 10px;
|
||||
@@ -203,6 +307,34 @@ export default {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.address-empty h3 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.address-empty p {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.address-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
height: 30px;
|
||||
margin-left: 8px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #ff6a2a;
|
||||
border-radius: 16px;
|
||||
color: #ff5b20;
|
||||
font-size: 12px;
|
||||
background: #fff6ea;
|
||||
}
|
||||
|
||||
.address-action .van-icon {
|
||||
margin-left: 4px;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
flex: none;
|
||||
color: #999;
|
||||
@@ -299,13 +431,26 @@ export default {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.order-info p {
|
||||
.order-info b {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
color: #ff5b20;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-info .order-desc {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 17px;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.order-count {
|
||||
flex: none;
|
||||
color: #222;
|
||||
@@ -361,10 +506,22 @@ export default {
|
||||
}
|
||||
|
||||
.amount-card .muted {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #777;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.coupon-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.coupon-row .van-icon {
|
||||
margin-left: 4px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.checkout-page ::v-deep .van-submit-bar {
|
||||
bottom: 0;
|
||||
height: 58px;
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
<template>
|
||||
<main class="confirm-page">
|
||||
<van-nav-bar title="确认支付" left-arrow @click-left="$router.back()" />
|
||||
|
||||
<section class="amount-card">
|
||||
<p>支付金额</p>
|
||||
<b>¥ {{ amountText }}</b>
|
||||
<span>{{ payTypeText }}</span>
|
||||
</section>
|
||||
|
||||
<van-popup v-model="show" class="pay-popup" round position="bottom" :close-on-click-overlay="false">
|
||||
<article class="pay-panel">
|
||||
<header>
|
||||
<van-icon name="cross" @click="$router.back()" />
|
||||
<h2>确认支付</h2>
|
||||
</header>
|
||||
<section class="pay-summary">
|
||||
<p>支付金额</p>
|
||||
<b>¥ {{ amountText }}</b>
|
||||
</section>
|
||||
<div class="pay-info">
|
||||
<p><span>支付方式</span><b>{{ payTypeText }}</b></p>
|
||||
<p><span>订单编号</span><b>{{ paymentInfo.orderNo || '-' }}</b></p>
|
||||
</div>
|
||||
<footer>
|
||||
<van-button round @click="$router.back()">取消</van-button>
|
||||
<van-button round type="danger" @click="confirmPay">确认支付</van-button>
|
||||
</footer>
|
||||
</article>
|
||||
</van-popup>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { readPaymentInfo, formatYuan } from '../payment/payment-data'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionPaymentConfirm',
|
||||
data: () => ({
|
||||
show: true,
|
||||
paymentInfo: {}
|
||||
}),
|
||||
computed: {
|
||||
amountText() {
|
||||
return formatYuan(this.paymentInfo.amount)
|
||||
},
|
||||
payTypeText() {
|
||||
const map = {
|
||||
points: '积分支付',
|
||||
wechat: '微信支付',
|
||||
alipay: '支付宝支付'
|
||||
}
|
||||
|
||||
return map[this.$route.query.payType] || '积分支付'
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.paymentInfo = readPaymentInfo()
|
||||
},
|
||||
methods: {
|
||||
confirmPay() {
|
||||
this.$router.push({ path: '/joyful-selection/payment-success', query: { payType: this.$route.query.payType } })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.confirm-page {
|
||||
min-height: 100vh;
|
||||
padding: 46px 12px 0;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.confirm-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
.amount-card {
|
||||
margin-top: 12px;
|
||||
padding: 28px 16px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.amount-card p {
|
||||
margin: 0 0 13px;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.amount-card b {
|
||||
display: block;
|
||||
color: #222;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.amount-card span {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pay-popup {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pay-panel {
|
||||
padding: 0 18px 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pay-panel header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 52px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pay-panel header h2 {
|
||||
margin: 0;
|
||||
color: #222;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pay-panel header .van-icon {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #999;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pay-summary {
|
||||
padding: 18px 0 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pay-summary p {
|
||||
margin: 0 0 12px;
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pay-summary b {
|
||||
color: #222;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pay-info {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #f3f3f3;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
}
|
||||
|
||||
.pay-info p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.pay-info b {
|
||||
max-width: 68%;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pay-panel footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.pay-panel footer .van-button {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.confirm-page ::v-deep .van-button--danger {
|
||||
background: #ff5b20;
|
||||
border-color: #ff5b20;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,42 @@
|
||||
<template>
|
||||
<main class="success-page">
|
||||
<van-icon name="checked" color="#20bd58" size="68" />
|
||||
<h2>支付成功</h2>
|
||||
<b>¥ {{ amountText }}</b>
|
||||
<section>
|
||||
<p>订单编号 <span>{{ paymentInfo.orderNo || '-' }}</span></p>
|
||||
<p>支付方式 <span>{{ payTypeText }}</span></p>
|
||||
<p>支付时间 <span>{{ paidAt }}</span></p>
|
||||
<div>
|
||||
<joyful-selection-payment-success-skeleton v-if="loading" />
|
||||
<main v-else class="success-page">
|
||||
<van-nav-bar title="支付成功" left-arrow @click-left="goProductList" />
|
||||
|
||||
<section class="success-card">
|
||||
<div class="success-icon">
|
||||
<van-icon name="success" />
|
||||
</div>
|
||||
<h2>支付成功</h2>
|
||||
<b>¥ {{ amountText }}</b>
|
||||
|
||||
<div class="order-info">
|
||||
<p><span>订单编号</span><em>{{ paymentInfo.orderNo || '-' }}</em></p>
|
||||
<p><span>支付方式</span><em>{{ payTypeText }}</em></p>
|
||||
<p><span>支付时间</span><em>{{ paidAt }}</em></p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<van-button round plain @click="$router.push('/joyful-selection/orders')">查看订单</van-button>
|
||||
<van-button round type="danger" @click="$router.push('/joyful-selection/home')">继续购物</van-button>
|
||||
</footer>
|
||||
</section>
|
||||
<div>
|
||||
<van-button round @click="$router.push('/joyful-selection/orders')">查看订单</van-button>
|
||||
<van-button round type="danger" @click="$router.push('/joyful-selection/home')">继续购物</van-button>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { readPaymentInfo, formatYuan } from '../payment/payment-data'
|
||||
import JoyfulSelectionPaymentSuccessSkeleton from '@/skeleton-view/joyful-selection/order/payment-success/index.vue'
|
||||
|
||||
export default {
|
||||
name: 'JoyfulSelectionPaymentSuccess',
|
||||
components: {
|
||||
JoyfulSelectionPaymentSuccessSkeleton
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
paymentInfo: {},
|
||||
paidAt: ''
|
||||
}),
|
||||
@@ -41,8 +57,14 @@ export default {
|
||||
created() {
|
||||
this.paymentInfo = readPaymentInfo()
|
||||
this.paidAt = this.formatTime(new Date())
|
||||
this.$nextTick(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
goProductList() {
|
||||
this.$router.replace('/joyful-selection/products')
|
||||
},
|
||||
formatTime(date) {
|
||||
const pad = value => String(value).padStart(2, '0')
|
||||
|
||||
@@ -52,6 +74,105 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.success-page{min-height:100vh;padding:80px 30px;text-align:center;background:#fff}.success-page h2{margin:15px 0 10px;font-size:17px}.success-page>b{font-size:22px}.success-page section{margin-top:42px;text-align:left;color:#888;font-size:12px}.success-page section p{display:flex;justify-content:space-between}.success-page section span{color:#333}.success-page>div{display:flex;gap:10px;margin-top:35px}.success-page .van-button{flex:1}.success-page .van-button--danger{background:#ff5b20;border-color:#ff5b20}
|
||||
<style scoped lang="less">
|
||||
.success-page {
|
||||
min-height: 100vh;
|
||||
padding: 46px 8px 24px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.success-page ::v-deep .van-nav-bar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
.success-card {
|
||||
min-height: calc(100vh - 70px);
|
||||
margin-top: 8px;
|
||||
padding: 34px 20px 24px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
display: flex;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.success-icon .van-icon {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.success-card h2 {
|
||||
margin: 14px 0 9px;
|
||||
color: #222;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.success-card > b {
|
||||
display: block;
|
||||
color: #222;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-info {
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
.order-info p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
color: #777;
|
||||
font-size: 12px;
|
||||
line-height: 31px;
|
||||
}
|
||||
|
||||
.order-info em {
|
||||
max-width: 68%;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
font-style: normal;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.success-card footer {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.success-card footer .van-button {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.success-card ::v-deep .van-button--plain {
|
||||
color: #333;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
.success-card ::v-deep .van-button--danger {
|
||||
background: #ff5b20;
|
||||
border-color: #ff5b20;
|
||||
}
|
||||
</style>
|
||||
|
||||