feat: 积分商城-供应商-对账-发票3

This commit is contained in:
2026-09-08 16:41:48 +08:00
parent 3abe4ce9c9
commit f42ea5b270
47 changed files with 1211 additions and 285 deletions
+57 -2
View File
@@ -1,10 +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)。
+10 -1
View File
@@ -1,14 +1,23 @@
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 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' },
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 || '请求失败')
}
@@ -1,5 +1,5 @@
<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"><el-form-item label="供应商 ID"><el-input v-model="supplierId" placeholder="请输入供应商 ID" 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><el-table 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></el-table><el-empty v-if="!loading && queried && !records.length" description="暂无数据" /><el-dialog v-model="detailVisible" title="售后详情" width="620px"><pre class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre></el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px"><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" @click="shipExchange">确认发货</el-button></template></el-dialog></section>
<section class="page-card"><div class="page-title"><div><h2>{{ title }}</h2><p>{{ description }}</p></div></div><el-form :inline="true" class="query-form"><el-form-item label="供应商 ID"><el-input v-model="supplierId" placeholder="请输入供应商 ID" 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><el-table 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="620px"><pre class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre></el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px"><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" @click="shipExchange">确认发货</el-button></template></el-dialog></section>
</template>
<script setup lang="ts">
+51 -3
View File
@@ -2,22 +2,70 @@
<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="#001529" text-color="#bfcbd9" active-text-color="#ffffff" router>
<el-menu :collapse="appStore.sidebarCollapsed" :default-active="$route.path" background-color="#152238" text-color="#afbed3" active-text-color="#ffffff" router>
<el-menu-item index="/reconciliation"><el-icon><Tickets /></el-icon><template #title>订单对账</template></el-menu-item>
<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/process">售后处理</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 @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">管理员</div></el-header><el-main class="main"><RouterView /></el-main></el-container>
<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 { overflow: hidden; transition: width .2s; background: #001529; }.brand { display: flex; align-items: center; gap: 10px; height: 56px; padding: 0 20px; color: #fff; font-size: 16px; font-weight: 600; white-space: nowrap; }.sidebar :deep(.el-menu) { border-right: 0; }.header { display: flex; align-items: center; gap: 16px; height: 56px; border-bottom: 1px solid #ebeef5; background: #fff; }.header-user { margin-left: auto; color: #606266; }.main { min-height: calc(100vh - 56px); padding: 20px; background: #f5f7fa; }
.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>
+21 -2
View File
@@ -1,7 +1,10 @@
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: '/', component: AdminLayout, redirect: '/reconciliation', children: [
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: 'orders', redirect: '/orders/list' },
{ path: 'orders/list', name: 'Orders', component: () => import('@/views/orders/index.vue'), meta: { title: '订单列表' } },
@@ -11,4 +14,20 @@ const routes: RouteRecordRaw[] = [{ path: '/', component: AdminLayout, redirect:
{ path: 'products', name: 'Products', component: () => import('@/views/products/index.vue'), meta: { title: '商品管理' } }
] }]
export default createRouter({ history: createWebHistory(), routes })
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
+97 -1
View File
@@ -1 +1,97 @@
html, body, #app { min-width: 1180px; min-height: 100%; margin: 0; } body { color: #303133; font-family: "Microsoft YaHei", Arial, sans-serif; }.page-card { min-height: calc(100vh - 96px); padding: 24px; border-radius: 8px; background: #fff; }.page-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 26px; }.page-title h2 { margin: 0 0 8px; font-size: 20px; }.page-title p { margin: 0; color: #909399; font-size: 14px; }.query-form { padding: 18px 18px 2px; margin-bottom: 20px; border-radius: 6px; background: #f8fafc; }
: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: column; align-items: stretch; margin: 0; }
.query-form .el-form-item__label { justify-content: flex-start; height: auto; padding: 0 0 8px; color: #526078; font-size: 13px; line-height: 18px; }
.query-form .el-form-item__content { 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 { 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; }
}
+14
View File
@@ -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
}
+82
View File
@@ -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>
+62 -9
View File
@@ -1,21 +1,32 @@
<template>
<section class="page-card">
<div class="page-title"><div><h2>订单列表</h2><p>查询订单并对待发货订单确认发货。</p></div></div>
<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-select v-model="query.supplierId" placeholder="请选择供应商" filterable clearable><el-option v-for="supplier in suppliers" :key="supplier.supplierId" :label="`${supplier.supplierName}(${supplier.supplierId})`" :value="supplier.supplierId" /></el-select></el-form-item><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号或子订单号" clearable /></el-form-item><el-form-item label="工号"><el-input v-model="query.userId" 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="160" /><el-table-column prop="orderId" label="子订单号" min-width="180" /><el-table-column prop="userId" label="工号" min-width="130" /><el-table-column label="订单状态" width="110"><template #default="{ row }">{{ stateText(row.orderState) }}</template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="商品" min-width="180"><template #default="{ row }">{{ productNames(row) }}</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" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span><el-button v-if="row.orderState === 1" link type="warning" :loading="repushingOrderId === row.orderId" :disabled="Boolean(repushingOrderId)" @click="repushSplit(row)">重新推送拆单</el-button></template></el-table-column></el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" style="margin-top: 16px; justify-content: flex-end" @current-change="loadOrders" />
<el-empty v-if="!loading && queried && !orders.length" description="暂无订单" />
<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="product.image" fit="cover" :preview-src-list="product.image ? [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 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 prop="createTime" label="下单时间" min-width="170" /><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 { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin } from '@/api/admin'
type OrderProduct = { name?: string }
type OrderItem = { orderId: string; supplierId: string; orderNumberId?: string; userId?: string; orderState: number; createTime?: string; products?: OrderProduct[] }
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[] }
type SupplierItem = { supplierId: string; supplierName: string }
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<{ supplierId: string; orderId: string; userId: string; orderState?: number; pageNumber: number; pageSize: number }>({ supplierId: '', orderId: '', userId: '', pageNumber: 1, pageSize: 10 })
@@ -26,9 +37,39 @@ const loadSuppliers = async () => { try { const data = await postAdmin<{ supplie
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.supplierId = ''; query.orderId = ''; query.userId = ''; query.orderState = undefined; searchOrders() }
const productNames = (order: OrderItem) => order.products?.map(item => item.name).filter(Boolean).join('、') || '-'
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
@@ -45,3 +86,15 @@ const repushSplit = async (order: OrderItem) => {
}
onMounted(() => { loadSuppliers(); 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>
+2 -2
View File
@@ -2,8 +2,8 @@
<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="load">查询</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></el-table>
<el-empty v-if="loaded && !products.length" description="暂无商品" />
<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-dialog v-model="visible" :title="form.id ? '编辑商品' : '新增商品'" width="680px" destroy-on-close><el-form :model="form" label-width="90px"><el-row :gutter="16"><el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" /></el-form-item></el-col><el-col :span="12"><el-form-item label="商品名称" required><el-input v-model="form.name" /></el-form-item></el-col><el-col :span="12"><el-form-item label="售价" required><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item></el-col><el-col :span="12"><el-form-item label="原价"><el-input-number v-model="form.originPrice" :min="0" :precision="2" /></el-form-item></el-col><el-col :span="12"><el-form-item label="库存"><el-input-number v-model="form.stock" :min="0" :precision="0" /></el-form-item></el-col><el-col :span="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" :precision="0" /></el-form-item></el-col><el-col :span="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 :span="12"><el-form-item label="状态"><el-switch v-model="form.enabled" active-text="上架" inactive-text="下架" /></el-form-item></el-col></el-row><el-form-item label="图片地址"><el-input v-model="form.imageUrl" /></el-form-item><el-form-item label="商品描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item></el-form><template #footer><el-button @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template></el-dialog>
</section>
</template>
@@ -18,8 +18,8 @@
<el-table-column prop="reconciliationBatchNo" label="对账批次" min-width="150" show-overflow-tooltip />
<el-table-column prop="reconciliationTime" label="对账时间" min-width="170" />
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">处理</el-button></template></el-table-column>
</el-table>
<el-empty v-if="loaded && !items.length" description="暂无对账结果" />
<template #empty><el-empty :image-size="80" description="暂无对账结果" /></template></el-table>
</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>
+146 -83
View File
@@ -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,
+4 -4
View File
@@ -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"
}
}
+3 -3
View File
@@ -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>
+3 -12
View File
@@ -1,20 +1,11 @@
import Vue from 'vue'
import { createApp } from 'vue'
import Vant from 'vant'
import { Skeleton } 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(Skeleton)
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')
+4 -6
View File
@@ -1,5 +1,4 @@
import Vue from 'vue'
import Router from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
import SupplierHome from '@/view/index.vue'
import JoyfulSelectionLayout from '@/view/joyful-selection/layout.vue'
import JoyfulSelectionHome from '@/view/joyful-selection/index.vue'
@@ -29,16 +28,15 @@ 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({
export default createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: SupplierHome },
{
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 },
+7
View File
@@ -20,6 +20,7 @@ const saveUser = data => {
export const useUserStore = defineStore('joyfulSelectionUser', {
state: () => ({
userId: '',
nickname: '',
supplierIdentification: '',
supplierId: '',
supplierName: '',
@@ -34,6 +35,7 @@ export const useUserStore = defineStore('joyfulSelectionUser', {
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)
@@ -46,6 +48,7 @@ export const useUserStore = defineStore('joyfulSelectionUser', {
persistUser() {
saveUser({
userId: this.userId,
nickname: this.nickname,
supplierIdentification: this.supplierIdentification,
supplierId: this.supplierId,
supplierName: this.supplierName,
@@ -60,6 +63,7 @@ export const useUserStore = defineStore('joyfulSelectionUser', {
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, '+')
@@ -70,12 +74,15 @@ export const useUserStore = defineStore('joyfulSelectionUser', {
})
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
}
@@ -87,7 +87,7 @@
<button v-else type="button" class="primary" @click="contactService">联系客服</button>
</footer>
<van-popup v-model="showLogisticsPopup" round position="bottom">
<van-popup v-model:show="showLogisticsPopup" round position="bottom">
<section class="logistics-popup">
<h3>填写物流单号</h3>
<van-field v-model="logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
@@ -96,7 +96,7 @@
</section>
<van-button v-show="!loading" class="submit-button" block :loading="submitting" @click="submitReturn">提交申请</van-button>
<van-popup v-model="showReasonPopup" round position="bottom">
<van-popup v-model:show="showReasonPopup" round position="bottom">
<section class="reason-popup">
<h3>请选择换货原因</h3>
<van-radio-group v-model="selectedReason">
@@ -116,7 +116,7 @@
</van-radio-group>
</section>
</van-popup>
<van-popup v-model="showExchangeGoodsPopup" round position="bottom" :style="{ height: '80vh' }">
<van-popup v-model:show="showExchangeGoodsPopup" round position="bottom" :style="{ height: '80vh' }">
<section class="exchange-popup">
<h3>选择换货商品</h3>
<p>仅可选择价格为 ¥{{ originalProductPrice }} 的商品</p>
@@ -3,7 +3,7 @@
<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="activeTab" color="#ff5b20">
<van-tabs v-model:active="activeTab" color="#ff5b20">
<van-tab title="处理中" name="processing" />
<van-tab title="历史" name="history" />
</van-tabs>
@@ -76,7 +76,7 @@
</section>
<van-button v-show="!loading" class="submit-button" block :loading="submitting" @click="submitReturn">提交申请</van-button>
<van-popup v-model="showReasonPopup" round position="bottom">
<van-popup v-model:show="showReasonPopup" round position="bottom">
<section class="reason-popup">
<h3>请选择退货原因</h3>
<van-radio-group v-model="selectedReason">
@@ -18,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">
@@ -52,10 +52,10 @@
@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>
@@ -7,10 +7,10 @@
<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">
@@ -26,10 +26,10 @@
</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>
@@ -91,7 +91,7 @@
</button>
</footer>
<van-popup v-model="showExchangeShipPopup" round position="bottom">
<van-popup v-model:show="showExchangeShipPopup" round position="bottom">
<section class="action-popup">
<h3>填写换货发货信息</h3>
<van-field v-model="exchangeShipForm.logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
@@ -3,7 +3,7 @@
<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="activeTab" sticky offset-top="46" color="#ff5b20" @change="loadCurrentTab">
<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>
@@ -161,7 +161,7 @@
</van-tabs>
</van-pull-refresh>
<van-popup v-model="showShipPopup" round position="bottom">
<van-popup v-model:show="showShipPopup" round position="bottom">
<section class="action-popup">
<h3>填写发货信息</h3>
<van-field v-model="shipForm.crrgBsnNm" label="快递公司" placeholder="请输入快递公司" />
@@ -170,7 +170,7 @@
</section>
</van-popup>
<van-popup v-model="showExchangeShipPopup" round position="bottom">
<van-popup v-model:show="showExchangeShipPopup" round position="bottom">
<section class="action-popup">
<h3>填写换货发货信息</h3>
<van-field v-model="exchangeShipForm.logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
@@ -43,7 +43,7 @@
立即支付 ¥{{ amountText }}
</van-button>
<van-popup v-model="showConfirm" class="pay-popup" round position="bottom">
<van-popup v-model:show="showConfirm" class="pay-popup" round position="bottom">
<article class="pay-panel">
<header>
<van-icon name="cross" @click="showConfirm = false" />
@@ -59,16 +59,16 @@
</div>
</section>
<van-goods-action>
<van-goods-action-icon icon="chat-o" text="客服" dot />
<van-goods-action-icon icon="cart-o" text="购物车" :badge="cartBadge" @click="openCartPopup" />
<van-goods-action-icon icon="star" text="已收藏" color="#ff5000" />
<van-goods-action-button type="warning" text="加入购物车" @click="openSku('cart')" />
<van-goods-action-button type="danger" text="立即购买" @click="openSku('buy')" />
</van-goods-action>
<van-action-bar>
<van-action-bar-icon icon="chat-o" text="客服" dot />
<van-action-bar-icon icon="cart-o" text="购物车" :badge="cartBadge" @click="openCartPopup" />
<van-action-bar-icon icon="star" text="已收藏" color="#ff5000" />
<van-action-bar-button type="warning" text="加入购物车" @click="openSku('cart')" />
<van-action-bar-button type="danger" text="立即购买" @click="openSku('buy')" />
</van-action-bar>
<joyful-sku-popup ref="skuPopup" @add-cart="loadCart" />
<van-popup v-model="showCartPopup" class="cart-popup" position="bottom" round>
<van-popup v-model:show="showCartPopup" class="cart-popup" position="bottom" round>
<header class="cart-popup-head">
<b>购物车</b>
<van-icon name="cross" @click="showCartPopup = false" />
@@ -251,7 +251,7 @@ export default {
}
.product-info {
margin-top: -4px;
margin-top: 12px;
padding: 18px 16px 16px;
}
@@ -1,26 +1,70 @@
<template>
<van-sku
:key="skuKey"
v-model="show"
<van-popup
v-model:show="show"
:class="`sku-popup--${mode}`"
:sku="sku"
:goods="skuGoods"
:goods-id="currentGoods.id"
:initial-sku="initialSku"
:hide-stock="sku.hide_stock"
:show-stepper="false"
:show-add-cart-btn="mode !== 'buy'"
:show-buy-btn="true"
:preview-on-click-image="true"
add-cart-text="加入购物车"
buy-text="立即购买"
@add-cart="onAddCartClicked"
@buy-clicked="onBuyClicked"
/>
class="van-sku-container"
position="bottom"
round
closeable
safe-area-inset-bottom
>
<div class="sku-content">
<div class="van-sku-header">
<div class="van-sku-header__goods-info">
<div class="van-sku__goods-price">
<span class="van-sku__price-symbol">¥</span>
<span class="van-sku__price-num">{{ sku.price }}</span>
</div>
<div class="van-sku__goods-name">{{ skuGoods.title }}</div>
<div class="van-sku__stock">库存 {{ sku.stock_num }} 件</div>
</div>
</div>
<van-cell-group class="van-sku-body" :border="false">
<div v-for="group in sku.tree" :key="group.k_s" class="van-sku-row">
<div class="van-sku-row__title">{{ group.k }}</div>
<div class="sku-options" :class="{ 'sku-options--colors': group.k_s === 's1' }">
<van-button
v-for="option in group.v"
:key="option.id"
size="small"
class="van-sku-row__item"
:class="{ 'van-sku-row__item--active': selectedSku[group.k_s] === option.id, 'sku-color-card': group.k_s === 's1' }"
@click="selectedSku[group.k_s] = option.id"
>
<span v-if="group.k_s === 's1'" class="sku-color-card__picture">
<van-image class="sku-color-card__image" :src="option.imgUrl" width="100%" height="100%" fit="cover" />
<van-icon
v-if="option.previewImgUrl"
name="expand-o"
class="sku-color-card__preview"
role="button"
tabindex="0"
aria-label="预览商品图片"
@click.stop="previewImage(option.previewImgUrl)"
@keydown.enter.stop.prevent="previewImage(option.previewImgUrl)"
@keydown.space.stop.prevent="previewImage(option.previewImgUrl)"
/>
</span>
<span :class="{ 'sku-color-card__name': group.k_s === 's1' }">{{ option.name }}</span>
</van-button>
</div>
</div>
<van-cell title="购买数量" class="sku-quantity" center>
<template #right-icon>
<van-stepper v-model="selectedNum" integer :min="1" :max="sku.stock_num" />
</template>
</van-cell>
</van-cell-group>
<div class="sku-actions">
<van-button v-if="mode !== 'buy'" round type="warning" @click="onAddCartClicked(selectionData())">加入购物车</van-button>
<van-button round type="danger" @click="onBuyClicked(selectionData())">立即购买</van-button>
</div>
</div>
</van-popup>
</template>
<script>
import { Toast } from 'vant'
import { ImagePreview, Toast } from 'vant'
import { useCartStore } from '@/store/cart'
const colorOptions = [
@@ -47,7 +91,8 @@ export default {
data: () => ({
show: false,
mode: 'cart',
skuKey: 0,
selectedSku: {},
selectedNum: 1,
currentGoods: emptyGoods
}),
computed: {
@@ -105,9 +150,19 @@ export default {
open(goods, mode = 'cart') {
this.currentGoods = goods
this.mode = mode
this.skuKey += 1
this.selectedSku = { ...this.initialSku }
this.selectedNum = 1
this.show = true
},
previewImage(image) {
if (image) ImagePreview([image])
},
selectionData() {
return {
selectedSkuComb: this.sku.list.find(item => item.s1 === this.selectedSku.s1 && item.s2 === this.selectedSku.s2),
selectedNum: Math.min(this.sku.stock_num, Math.max(1, Math.floor(Number(this.selectedNum) || 1)))
}
},
getCartPayload(data) {
const selectedSku = data.selectedSkuComb || {}
const selectedColor = colorOptions.find(item => item.id === selectedSku.s1) || colorOptions[0]
@@ -153,14 +208,110 @@ export default {
</script>
<style scoped lang="less">
::v-deep .van-sku-container {
.sku-content {
padding: 15px 15px 0;
}
.sku-options .van-sku-row__item.sku-color-card {
width: 100%;
min-width: 0;
overflow: hidden;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
background: #f6f7f9;
}
.sku-color-card :deep(.van-button__content),
.sku-color-card :deep(.van-button__text) {
display: block;
width: 100%;
}
.sku-color-card__picture {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
line-height: 0;
background: #f1f1f3;
}
.sku-color-card__image {
position: absolute;
inset: 0;
display: block;
}
.sku-color-card__preview {
position: absolute;
top: 3px;
right: 3px;
padding: 2px;
border-radius: 2px;
background: rgba(0, 0, 0, .4);
color: #fff;
font-size: 14px;
}
.sku-color-card__name {
display: block;
padding: 10px 4px;
color: #333;
font-size: 12px;
line-height: 18px;
text-align: center;
}
.sku-color-card.van-sku-row__item--active .sku-color-card__name {
color: #ff5b20;
}
.sku-options,
.sku-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.sku-options.sku-options--colors {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.sku-options .van-sku-row__item {
height: auto;
min-height: 36px;
padding: 6px 12px;
}
.sku-quantity {
padding: 12px 0;
}
.sku-actions {
padding: 15px 0;
}
.sku-actions .van-button {
flex: 1;
}
.van-sku-container {
max-height: 85vh;
overflow-y: auto;
border-radius: 14px 14px 0 0;
}
.van-sku-container.van-popup--safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
::v-deep .van-sku-header {
min-height: 96px;
padding: 18px 42px 12px 16px;
padding: 0 30px 15px 0;
}
::v-deep .van-sku-header__img-wrap {
@@ -210,7 +361,7 @@ export default {
}
::v-deep .van-sku-body {
padding: 0 16px;
padding: 0;
}
::v-deep .van-sku-group-container {
@@ -236,7 +387,7 @@ export default {
::v-deep .van-sku-row:not(:first-child) .van-sku-row__item {
height: 31px;
margin: 0 10px 8px 0;
margin: 0;
padding: 0 14px;
border: 0;
border-radius: 4px;
@@ -4,7 +4,7 @@
<main v-else class="orders-page">
<van-nav-bar title="我的订单" left-arrow @click-left="$router.replace('/joyful-selection/profile')" />
<van-tabs
v-model="active"
v-model:active="active"
class="order-tabs"
line-width="18"
line-height="2"
@@ -50,7 +50,7 @@ export default {
this.loadLogs()
this.startListening()
},
beforeDestroy() {
beforeUnmount() {
this.stopListening()
},
methods: {
+107 -42
View File
@@ -1,61 +1,126 @@
# supplied-service
Netlify Functions backend for the supplied mall H5 project.
供应商业务后端,为 supplied-h5、supplied-admin 和 zhgh 平台对接提供接口。当前 HTTP 入口为 `src/server.ts`,默认端口为 `8888`。
## Stack
## 技术栈
- Netlify Functions
- TypeScript
- Prisma ORM
- Neon Postgres
- `@prisma/adapter-neon` + `@neondatabase/serverless`
- Koa、TypeScript:HTTP 服务与业务逻辑。
- Prisma、MySQL:数据库访问与模型定义。
- Redis:平台桥接 Token 缓存。
- MinIO:文件对象存储。
- Node.js crypto:管理端密码哈希与随机登录 Token。
## Directory
## 目录结构
```text
netlify/
functions/ # API entrypoints
utils/ # shared function utilities
types/ # shared API types
src/
server.ts # Koa 入口、路由分发、管理端登录校验
controllers/ # 请求解析与响应封装
services/ # 业务逻辑
dao/ # 数据访问
functions/ # 导出各接口处理器
utils/ # 数据库、日志、平台桥接等工具
types/ # 公共类型
prisma/
schema.prisma # Prisma models
schema.prisma # 数据模型与表映射
sql/ # 专项表结构脚本
script/supplier_db.sql # 数据库结构及开发数据快照
init-supplied-user.mjs # 管理账号初始化与旧表处理
tools/ # 发票等辅助工具
```
## Environment
`src/functions` 保留接口处理器导出,当前本地服务由 Koa 的 `/api` 路由分发。
Copy `.env.example` to `.env` and set:
## 环境配置
- `DATABASE_URL`: Neon Postgres connection string for Netlify Functions runtime and Prisma CLI
- `JWT_SECRET`: auth signing secret
- `UPLOAD_TOKEN`: upload guard token
- `POINTS_MALL_OPEN_API_BASE`: main platform bridge base URL, for example `http://localhost/open`
配置文件为本目录 `.env`,可参考 `.env.example`。数据库及存储服务的账号信息不在本文重复列出。
Supplier H5 uses `VUE_APP_SUPPLIER_IDENTIFICATION` to request supplier config from this service. The supplier `clientSecret` stays in the database and is only used by the Netlify Function when requesting the platform bridge token.
| 配置项 | 用途 |
| --- | --- |
| `DATABASE_URL` | MySQL 连接地址 |
| `PORT` | Koa 服务端口,默认 `8888` |
| `PRISMA_CLIENT_ENGINE_TYPE` | Prisma 引擎设置,示例为 `binary` |
| `POINTS_MALL_OPEN_API_BASE` | zhgh 平台接口根地址,例如 `http://localhost/open` |
| `SUPPLIER_OPEN_SM4_KEY_HEX`、`SUPPLIER_OPEN_SM4_IV_HEX` | 供应商开放接口 SM4 配置 |
| `REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD`、`REDIS_DB` | 平台 Token 缓存连接配置 |
| `MINIO_ENDPOINT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY` | MinIO 地址与访问凭据 |
| `MINIO_BUCKET`、`MINIO_REGION` | 存储桶与区域;本项目配置示例使用 `njupt` |
## Scripts
`.env.example` 中的 `JWT_SECRET`、`UPLOAD_TOKEN` 不参与当前管理端登录校验;管理端采用数据库保存 Token 哈希的会话机制。
```bash
npm run dev
npm run build
npm run typecheck
npm run prisma:generate
npm run prisma:migrate
npm run prisma:studio
```
H5 通过供应商标识获取配置。供应商 `clientSecret` 保存在后端数据库中,用于请求平台桥接 Token,与管理端登录密码、登录 Token 分开处理。
## Function Routes
## 已有脚本说明
Netlify exposes functions under `/.netlify/functions/<name>`.
以下列出 `package.json` 中已有脚本的用途。
Current placeholders:
| 脚本名 | 用途 |
| --- | --- |
| `dev`、`start` | 通过 tsx 运行 Koa 服务 |
| `build`、`prisma:generate` | 生成 Prisma Client;`build` 不生成后端应用打包产物 |
| `typecheck` | TypeScript 静态类型检查 |
| `prisma:migrate` | Prisma 开发迁移 |
| `prisma:studio` | Prisma 数据查看界面 |
| `seed:products` | 商品初始化数据脚本 |
- `login`
- `product-list`
- `product-detail`
- `cart-add`
- `order-create`
- `order-pay`
- `user-info`
- `upload`
- `supplier-info`
- `supplier-token`
## 管理账号与表结构
| 表名 | 用途 |
| --- | --- |
| `supplied_user` | 管理端账号、密码哈希、会话及失败登录状态 |
| `supplied_cart_item` | 购物车条目,Prisma 模型为 `CartItem` |
管理端密码使用随机盐和 scrypt 哈希,格式为 `scrypt$盐$哈希`。登录时校验密码后签发随机 Token,数据库仅保存 Token 的 SHA-256 哈希及有效期。
- 会话有效期:8 小时,同一账号仅保留最新一次登录。
- 连续输错密码:启用账号累计 5 次后锁定 15 分钟。
- 退出登录:清除当前 Token 哈希与有效期。
- 登录请求密码不会写入供应商调试日志。
- 购物车 `userId` 仍为业务用户 ID,不关联 `supplied_user` 中的管理员。
### 账号初始化脚本
`prisma/init-supplied-user.mjs` 读取本目录 `.env`,创建账号表并插入管理账号:
- `SUPPLIED_ADMIN_USERNAME`:初始化账号,默认 `admin`。
- `SUPPLIED_ADMIN_PASSWORD`:初始化密码;未指定时随机生成,长度要求为 12–128 位。
- 账号创建成功后输出账号和密码,已存在账号不会覆盖密码。
- 可将旧 `cartitem` 重命名为 `supplied_cart_item`;新旧表同时存在时停止,避免覆盖数据。
- 旧 `user` 表有数据时停止;为空时移除。表结构处理发生在账号是否已存在的判断之前。
- 此脚本不会创建缺失的购物车表,也不处理曾使用过的 `supplied_cartitem` 名称。
当前开发账号见 [项目 README](../README.md#管理端登录)。
## 接口入口
| 路径 | 用途 |
| --- | --- |
| `/api/:name` | H5 及通用业务接口 |
| `/api/admin/:name` | 管理端入口,除 `login` 外校验 Bearer Token |
| `/supplierOpenApi/pointOrder/*` | zhgh 调用的供应商开放接口 |
| `/api/supplier-logs` | 查询或清除当前进程保存的调试日志 |
### 管理端登录接口
| 方法 | 路径 | 请求内容 |
| --- | --- | --- |
| POST | `/api/admin/login` | JSON:`username`、`password` |
| POST | `/api/admin/admin-session` | Bearer Token,返回用户信息及过期时间 |
| POST | `/api/admin/admin-logout` | Bearer Token,使当前登录失效 |
登录成功返回 `data.token`、`data.expiresAt` 和 `data.user`。管理端后续请求通过 `Authorization: Bearer <token>` 携带凭据。
### 业务接口分组
- 商品:`product-list`、`product-detail`、`merchant-product-*`。
- 订单:`order-create`、`order-pay`、`order-list`、`order-detail`、取消及事件推送接口。
- 商家处理:`merchant-order-*`、`merchant-after-sale-*`。
- 对账:`merchant-reconciliation-*`、`supplier-reconciliation-*`。
- 发票:`supplier-invoice-apply`、`supplier-invoice-info`、`supplier-file-base64-info`。
- 平台授权:`supplier-info`、`supplier-session`、`supplier-token`。
完整路由以 `src/server.ts` 为准。`cart-add`、`user-info` 仍为占位实现。
## 调试日志
`GET /api/supplier-logs` 返回当前后端接收请求、向 zhgh 发送请求、接收 zhgh 请求的记录;`DELETE /api/supplier-logs` 清除记录。日志保存在进程内存中,最多保留 200 条,服务重启后清空。
@@ -0,0 +1,41 @@
import { config } from 'dotenv'
import { PrismaClient } from '@prisma/client'
import { randomBytes, scryptSync } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
config({ path: fileURLToPath(new URL('../.env', import.meta.url)), quiet: true })
const prisma = new PrismaClient()
try {
const username = (process.env.SUPPLIED_ADMIN_USERNAME || 'admin').trim()
const password = process.env.SUPPLIED_ADMIN_PASSWORD || `Sp!${randomBytes(12).toString('base64url')}9a`
if (!username || username.length > 64 || password.length < 12 || password.length > 128) {
throw new Error('账号长度需为 1-64,密码长度需为 12-128')
}
const tables = await prisma.$queryRaw`SELECT TABLE_NAME AS name FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) IN ('user', 'cartitem', 'supplied_cart_item')`
const legacyUser = tables.find(row => row.name.toLowerCase() === 'user')
const legacyCart = tables.find(row => row.name.toLowerCase() === 'cartitem')
if (legacyUser) {
const rows = await prisma.$queryRawUnsafe('SELECT COUNT(*) AS count FROM `' + legacyUser.name.replace(/`/g, '``') + '`')
if (Number(rows[0].count) > 0) throw new Error('旧 user 表存在数据,未删除,请先处理原有数据')
}
if (legacyCart) {
if (tables.some(row => row.name.toLowerCase() === 'supplied_cart_item')) throw new Error('新旧购物车表同时存在,未覆盖数据')
await prisma.$executeRawUnsafe('RENAME TABLE `' + legacyCart.name.replace(/`/g, '``') + '` TO `supplied_cart_item`')
}
if (legacyUser) await prisma.$executeRawUnsafe('DROP TABLE `' + legacyUser.name.replace(/`/g, '``') + '`')
await prisma.$executeRawUnsafe(readFileSync(new URL('./sql/supplied-user.sql', import.meta.url), 'utf8').trim().replace(/;$/, ''))
const existing = await prisma.$queryRaw`SELECT id FROM supplied_user WHERE username = ${username} LIMIT 1`
if (existing.length) throw new Error('账号已存在,未覆盖原有密码')
const salt = randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }).toString('hex')
const passwordHash = `scrypt$${salt}$${hash}`
await prisma.$executeRaw`INSERT INTO supplied_user (username, passwordHash, nickname) VALUES (${username}, ${passwordHash}, ${'管理员'})`
console.log(JSON.stringify({ username, password, table: 'supplied_user' }))
} catch (error) {
console.error(error instanceof Error ? error.message : '初始化账号失败')
process.exitCode = 1
} finally {
await prisma.$disconnect()
}
+16 -11
View File
@@ -7,16 +7,21 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
mobile String @unique
nickname String?
avatarUrl String?
points Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model SuppliedUser {
id Int @id @default(autoincrement())
username String? @unique @db.VarChar(64)
virtualId String? @unique @db.VarChar(191)
passwordHash String? @db.VarChar(255)
nickname String @default("管理员") @db.VarChar(64)
enabled Boolean @default(true)
tokenHash String? @unique @db.Char(64)
tokenExpiresAt DateTime?
failedLoginCount Int @default(0)
lockedUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carts CartItem[]
@@map("supplied_user")
}
model SupplierProduct {
@@ -75,10 +80,10 @@ model CartItem {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product SupplierProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId, skuText])
@@unique([userId, productId, skuText], map: "CartItem_userId_productId_skuText_key")
@@map("supplied_cart_item")
}
model SupplierOrderMain {
+22 -23
View File
@@ -18,10 +18,10 @@ SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for cartitem
-- Table structure for supplied_cart_item
-- ----------------------------
DROP TABLE IF EXISTS `cartitem`;
CREATE TABLE `cartitem` (
DROP TABLE IF EXISTS `supplied_cart_item`;
CREATE TABLE `supplied_cart_item` (
`id` int NOT NULL AUTO_INCREMENT,
`userId` int NOT NULL,
`productId` int NOT NULL,
@@ -35,7 +35,7 @@ CREATE TABLE `cartitem` (
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of cartitem
-- Records of supplied_cart_item
-- ----------------------------
-- ----------------------------
@@ -528,24 +528,23 @@ CREATE TABLE `supplier_user_address` (
INSERT INTO `supplier_user_address` VALUES (1, 'addr-1785395646503', '99999999', 'b2397ac9a67f476c9833471d27709db1', '谌盼兮', '13545478393', '吉林省', '白山市', '临江市', '未来科技城', '220681', '430213', 1, '2026-07-30 07:14:06.541', '2026-07-30 07:29:34.829', 0);
INSERT INTO `supplier_user_address` VALUES (2, 'addr-1788252731181', '99999999', 'c281e124321c8db3a14f0a7c86046c3c', '谌盼兮', '19090230905', '湖北省', '武汉市', '洪山区', '中国地质大学', '420111', '430074', 1, '2026-09-01 08:52:11.226', '2026-09-01 08:52:11.226', 0);
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`mobile` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`nickname` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`avatarUrl` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`points` int NOT NULL DEFAULT 0,
`createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` datetime(3) NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `User_mobile_key`(`mobile` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user
-- ----------------------------
CREATE TABLE IF NOT EXISTS `supplied_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NULL,
`virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL,
`passwordHash` VARCHAR(255) NULL,
`nickname` VARCHAR(64) NOT NULL DEFAULT '管理员',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`tokenHash` CHAR(64) NULL,
`tokenExpiresAt` DATETIME(3) NULL,
`failedLoginCount` INT NOT NULL DEFAULT 0,
`lockedUntil` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `supplied_user_username_key` (`username`),
UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
UNIQUE KEY `supplied_user_tokenHash_key` (`tokenHash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;
@@ -0,0 +1,5 @@
ALTER TABLE `supplied_user`
ADD COLUMN `virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER `username`,
ADD UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
MODIFY COLUMN `username` VARCHAR(64) NULL,
MODIFY COLUMN `passwordHash` VARCHAR(255) NULL;
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS `supplied_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NULL,
`virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL,
`passwordHash` VARCHAR(255) NULL,
`nickname` VARCHAR(64) NOT NULL DEFAULT '管理员',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`tokenHash` CHAR(64) NULL,
`tokenExpiresAt` DATETIME(3) NULL,
`failedLoginCount` INT NOT NULL DEFAULT 0,
`lockedUntil` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `supplied_user_username_key` (`username`),
UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
UNIQUE KEY `supplied_user_tokenHash_key` (`tokenHash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -3,14 +3,27 @@ import { guardMethod } from '../utils/method.js'
import { withErrorBoundary } from '../utils/handler.js'
import { basicService } from '../services/basic.service.js'
import { minioUploadService, type PreviewInput, type UploadInput } from '../services/minio-upload.service.js'
import { readJson } from '../utils/request.js'
import { getBearerToken, readJson } from '../utils/request.js'
import { adminAuthService, type AdminLoginInput } from '../services/admin-auth.service.js'
import { toHttpResponse } from './controller-response.js'
export const loginController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(basicService.login())
return toHttpResponse(await adminAuthService.login(await readJson<AdminLoginInput>(request)))
})
export const adminSessionController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await adminAuthService.session(getBearerToken(request)))
})
export const adminLogoutController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await adminAuthService.logout(getBearerToken(request)))
})
export const cartAddController = withErrorBoundary(async (request: Request, _context: Context) => {
@@ -0,0 +1,71 @@
import { Prisma } from '@prisma/client'
import { prisma } from '../utils/prisma.js'
export type SuppliedUser = {
id: number
username: string
passwordHash: string
nickname: string
enabled: boolean
tokenExpiresAt: Date | null
lockedUntil: Date | null
}
export type SuppliedCustomer = { id: number; virtualId: string; nickname: string; enabled: boolean }
export const suppliedUserDao = {
async findByVirtualId(virtualId: string) {
const rows = await prisma.$queryRaw<SuppliedCustomer[]>`
SELECT id, virtualId, nickname, enabled FROM supplied_user WHERE virtualId = ${virtualId} LIMIT 1`
return rows[0]
},
async findOrCreateByVirtualId(virtualId: string, nickname: string) {
const existing = await this.findByVirtualId(virtualId)
if (existing) return existing
// 并发进入时由虚拟 ID 唯一索引去重,不覆盖已有昵称。
await prisma.$executeRaw`
INSERT INTO supplied_user (virtualId, nickname, username, passwordHash)
VALUES (${virtualId}, ${nickname}, NULL, NULL)
ON DUPLICATE KEY UPDATE virtualId = VALUES(virtualId)`
const user = await this.findByVirtualId(virtualId)
if (!user) throw new Error('获取供应商用户信息失败')
return user
},
async findByVirtualIds(virtualIds: string[]) {
if (!virtualIds.length) return []
return prisma.$queryRaw<SuppliedCustomer[]>`
SELECT id, virtualId, nickname, enabled FROM supplied_user
WHERE virtualId IN (${Prisma.join(virtualIds)})`
},
async findByUsername(username: string) {
const rows = await prisma.$queryRaw<SuppliedUser[]>`
SELECT id, username, passwordHash, nickname, enabled, tokenExpiresAt, lockedUntil
FROM supplied_user WHERE username = ${username} AND virtualId IS NULL LIMIT 1`
return rows[0]
},
async findSession(tokenHash: string) {
const now = new Date()
const rows = await prisma.$queryRaw<SuppliedUser[]>`
SELECT id, username, nickname, tokenExpiresAt FROM supplied_user
WHERE tokenHash = ${tokenHash} AND virtualId IS NULL AND enabled = TRUE AND tokenExpiresAt > ${now} LIMIT 1`
return rows[0]
},
async recordFailure(id: number) {
await prisma.$executeRaw`
UPDATE supplied_user SET
failedLoginCount = IF(lockedUntil IS NOT NULL AND lockedUntil <= NOW(3), 1, failedLoginCount + 1),
lockedUntil = IF(failedLoginCount >= 5, DATE_ADD(NOW(3), INTERVAL 15 MINUTE), NULL),
updatedAt = NOW(3) WHERE id = ${id}`
},
async saveSession(id: number, tokenHash: string, expiresAt: Date) {
return prisma.$executeRaw`
UPDATE supplied_user SET tokenHash = ${tokenHash}, tokenExpiresAt = ${expiresAt},
failedLoginCount = 0, lockedUntil = NULL, updatedAt = NOW(3)
WHERE id = ${id} AND virtualId IS NULL AND enabled = TRUE AND (lockedUntil IS NULL OR lockedUntil <= NOW(3))`
},
async clearSession(tokenHash: string) {
await prisma.$executeRaw`
UPDATE supplied_user SET tokenHash = NULL, tokenExpiresAt = NULL, updatedAt = NOW(3)
WHERE tokenHash = ${tokenHash}`
}
}
+24 -2
View File
@@ -4,6 +4,7 @@ import Router from '@koa/router'
import bodyParser from 'koa-bodyparser'
import type { FunctionHandler } from './utils/handler.js'
import { logger } from './utils/logger.js'
import { adminAuthService } from './services/admin-auth.service.js'
import {
addressDefaultController,
addressDeleteController,
@@ -11,6 +12,8 @@ import {
addressSaveController
} from './controllers/address.controller.js'
import {
adminSessionController,
adminLogoutController,
cartAddController,
loginController,
uploadController,
@@ -87,6 +90,8 @@ const handlers: Record<string, FunctionHandler> = {
'after-sale-logistics': afterSaleLogisticsController,
'cart-add': cartAddController,
login: loginController,
'admin-session': adminSessionController,
'admin-logout': adminLogoutController,
'merchant-after-sale-audit': merchantAfterSaleAuditController,
'merchant-after-sale-detail': merchantAfterSaleDetailController,
'merchant-after-sale-list': merchantAfterSaleListController,
@@ -185,8 +190,9 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
path: ctx.path,
url: ctx.originalUrl,
query: ctx.query,
body: ctx.request.body || {}
body: name === 'login' ? { password: '[REDACTED]' } : ctx.request.body || {}
})
if (name === 'login' || name === 'admin-session' || name === 'admin-logout') ctx.set('Cache-Control', 'no-store')
const handler = handlers[name]
if (!handler) {
ctx.status = 404
@@ -225,7 +231,23 @@ router.delete('/api/supplier-logs', ctx => {
}
})
router.all('/api/:name', async ctx => dispatch(ctx.params.name)(ctx))
router.all('/api/admin/:name', async ctx => dispatch(ctx.params.name)(ctx))
router.all('/api/admin/:name', async ctx => {
if (ctx.params.name !== 'login') {
try {
const token = ctx.get('Authorization').match(/^Bearer\s+(.+)$/i)?.[1] || ''
if (!await adminAuthService.findSession(token)) {
ctx.status = 401
ctx.body = { success: false, error: { code: 'UNAUTHORIZED', message: '登录已失效,请重新登录' } }
return
}
} catch {
ctx.status = 500
ctx.body = { success: false, error: { code: 'AUTH_SERVICE_ERROR', message: '登录状态校验失败,请稍后重试' } }
return
}
}
await dispatch(ctx.params.name)(ctx)
})
router.post('/supplierOpenApi/pointOrder/queryOrderInfo', dispatch('supplier-order-info'))
router.post('/supplierOpenApi/pointOrder/queryReconciliationInfo', dispatch('supplier-reconciliation-info'))
router.post('/supplierOpenApi/pointOrder/applyForInvoices', dispatch('supplier-invoice-apply'))
@@ -0,0 +1,55 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { suppliedUserDao, type SuppliedUser } from '../dao/supplied-user.dao.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type AdminLoginInput = { username?: unknown; password?: unknown }
const digest = (token: string) => createHash('sha256').update(token).digest('hex')
const publicUser = (user: SuppliedUser) => ({ id: user.id, username: user.username, nickname: user.nickname })
const derivePassword = (password: string, salt: string) => new Promise<Buffer>((resolve, reject) => {
scrypt(password, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }, (error, key) => {
if (error) reject(error)
else resolve(key)
})
})
export const adminAuthService = {
async login(input: AdminLoginInput) {
const username = typeof input?.username === 'string' ? input.username.trim() : ''
const password = typeof input?.password === 'string' ? input.password : ''
if (!username || username.length > 64 || !password || password.length > 128) {
return serviceFail('请输入有效的账号和密码', 400, 'INVALID_LOGIN_INPUT')
}
const user = await suppliedUserDao.findByUsername(username)
const [algorithm, salt, hash] = (user?.passwordHash || '').split('$')
const validHash = algorithm === 'scrypt' && /^[a-f0-9]{32}$/.test(salt || '') && /^[a-f0-9]{128}$/.test(hash || '')
const actual = await derivePassword(password, validHash ? salt : '00000000000000000000000000000000')
const expected = validHash ? Buffer.from(hash, 'hex') : Buffer.alloc(64)
const passwordMatches = timingSafeEqual(actual, expected) && validHash
if (user?.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
return serviceFail('登录尝试过多,请稍后重试', 429, 'LOGIN_LOCKED')
}
if (!user || !user.enabled || !passwordMatches) {
if (user?.enabled) await suppliedUserDao.recordFailure(user.id)
return serviceFail('账号或密码错误', 401, 'INVALID_CREDENTIALS')
}
const token = randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000)
if (!await suppliedUserDao.saveSession(user.id, digest(token), expiresAt)) {
return serviceFail('账号暂时无法登录,请稍后重试', 401, 'LOGIN_UNAVAILABLE')
}
return serviceOk({ token, expiresAt: expiresAt.toISOString(), user: publicUser(user) })
},
async findSession(token: string) {
if (!/^[a-f0-9]{64}$/.test(token)) return undefined
return suppliedUserDao.findSession(digest(token))
},
async session(token: string) {
const user = await this.findSession(token)
return user ? serviceOk({ user: publicUser(user), expiresAt: user.tokenExpiresAt?.toISOString() })
: serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED')
},
async logout(token: string) {
if (/^[a-f0-9]{64}$/.test(token)) await suppliedUserDao.clearSession(digest(token))
return serviceOk({})
}
}
@@ -138,7 +138,7 @@ const createServiceOrderId = (subOrderId: string, serviceType: number) => {
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法推送退货单')
}
@@ -149,6 +149,7 @@ const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof ord
orderId: record.serviceOrderId,
mainOrderId: record.subOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -159,7 +160,7 @@ const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof ord
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法推送换货单')
}
@@ -170,6 +171,7 @@ const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof o
orderId: record.serviceOrderId,
mainOrderId: record.subOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -180,7 +182,7 @@ const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof o
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法取消子单')
}
@@ -191,6 +193,7 @@ const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof
orderId: record.subOrderId,
mainOrderId: record.mainOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -498,14 +501,14 @@ export const afterSaleService = {
? (isSigned ? AFTER_SALE_LOGISTICS_STATE.WAIT_USER_SHIP : AFTER_SALE_LOGISTICS_STATE.NONE)
: AFTER_SALE_LOGISTICS_STATE.NONE
const now = new Date()
if (finishUnsignedReturn) {
const cancelSubOrder = await pushCancelSubOrder(record)
const cancelSubOrder = await pushCancelSubOrder(record, now)
if (!cancelSubOrder.success) {
return cancelSubOrder
}
}
const now = new Date()
await orderDao.transaction(async tx => {
await tx.supplierAfterSale.update({
where: { serviceOrderId },
@@ -550,20 +553,20 @@ export const afterSaleService = {
return serviceFail('退款/售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND')
}
const now = new Date()
if (record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_SHIPPED) {
const exchangeOrder = await pushExchangeOrder(record)
const exchangeOrder = await pushExchangeOrder(record, now)
if (!exchangeOrder.success) {
return exchangeOrder
}
}
if (record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED) {
const returnOrder = await pushReturnOrder(record)
const returnOrder = await pushReturnOrder(record, now)
if (!returnOrder.success) {
return returnOrder
}
}
const now = new Date()
const finish = logisticsState === AFTER_SALE_LOGISTICS_STATE.USER_RECEIVED ||
(record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED)
const finishReturn = record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER &&
@@ -2,11 +2,6 @@ import { logger } from '../utils/logger.js'
import { serviceOk } from '../types/service-result.js'
export const basicService = {
login() {
logger.info('login placeholder called')
return serviceOk({ message: '登录接口待实现' })
},
addCart() {
logger.info('cart add placeholder called')
return serviceOk({ message: '加入购物车接口待实现' })
@@ -7,6 +7,7 @@ import {
} from '../constants/order.js'
import { orderDao } from '../dao/order.dao.js'
import { supplierDao } from '../dao/supplier.dao.js'
import { suppliedUserDao } from '../dao/supplied-user.dao.js'
import { afterSaleService, type AfterSaleAuditInput, type AfterSaleLogisticsInput } from './after-sale.service.js'
import { orderActionService } from './order-action.service.js'
import { logger } from '../utils/logger.js'
@@ -123,10 +124,14 @@ export const merchantService = {
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
const afterSaleSubOrderIds = afterSales.map(item => item.subOrderId).filter((id): id is string => Boolean(id))
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination)
const virtualIds = [...new Set(orders.map(order => order.userId).filter((id): id is string => Boolean(id)))]
const users = await suppliedUserDao.findByVirtualIds(virtualIds)
const nicknames = new Map(users.map(user => [user.virtualId, user.nickname]))
const orderItems = orders.map(order => ({
orderId: order.orderId,
mainOrderId: order.mainOrderId,
userId: order.userId || '',
userNickname: nicknames.get(order.userId || '') || '',
supplierId: order.supplierId,
orderState: order.orderState,
orderNumberId: order.orderNumberId || '',
@@ -64,14 +64,15 @@ export const orderActionService = {
return serviceFail('已支付主单不能通过未支付取消接口取消', 400, 'MAIN_ORDER_ALREADY_PAID')
}
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId: mainOrderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
await tx.supplierOrderSub.updateMany({
where: { mainOrderId, supplierId, userId, deleted: 0 },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -80,6 +81,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -112,14 +114,15 @@ export const orderActionService = {
return serviceFail('该主单已拆单,不能按已支付未拆单整单取消', 400, 'MAIN_ORDER_ALREADY_SPLIT')
}
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId: mainOrderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
await tx.supplierOrderSub.updateMany({
where: { mainOrderId, supplierId, userId, deleted: 0 },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -128,6 +131,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -153,10 +157,11 @@ export const orderActionService = {
const mainOrderId = input.mainOrderId || subOrder.mainOrderId
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderSub.update({
where: { orderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -165,6 +170,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -259,6 +265,7 @@ export const orderActionService = {
orderId: subOrderIds,
mainOrderId: orderId,
userId: order.userId,
operationTime: toDateTimeText(payTime),
time: Date.now()
})
if (!split.response.ok || split.payload.resultCode !== 0) {
@@ -376,6 +383,7 @@ export const orderActionService = {
deliveryTime: toDateTimeText(now),
logisticsOrderId,
crrgBsnNm,
operationTime: toDateTimeText(now),
time: Date.now()
})
if (!ship.response.ok || ship.payload.resultCode !== 0) {
@@ -17,6 +17,7 @@ export type OrderPushEventInput = {
mainOrderId?: string
subOrderId?: string
time?: number
operationTime?: string
amount?: number
reason?: string
settlementId?: string
@@ -61,12 +62,18 @@ const operateType = (orderType: number) => {
return orderType
}
const toDateTimeText = (date: Date) => {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const buildBridgeBody = (body: OrderPushEventInput) => ({
supplierId: String(body.supplierId || '').trim(),
orderType: Number(body.orderType),
orderId: String(body.orderId || '').trim(),
mainOrderId: String(body.mainOrderId || '').trim(),
userId: String(body.userId || '').trim(),
operationTime: body.operationTime,
time: Number(body.time || Date.now())
})
@@ -87,8 +94,17 @@ export const orderPushEventService = {
return serviceFail('暂不支持该订单事件类型', 400, 'ORDER_EVENT_NOT_SUPPORTED')
}
// 操作时间在业务处理时确定,写入事件及推送记录;不得由接口 time 推导。
const operationTime = body.operationTime ?? toDateTimeText(new Date())
if (typeof operationTime !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(operationTime)) {
return serviceFail('业务操作时间格式必须为 yyyy-MM-dd HH:mm:ss')
}
const now = new Date(operationTime.replace(' ', 'T'))
if (Number.isNaN(now.getTime()) || toDateTimeText(now) !== operationTime) {
return serviceFail('业务操作时间无效')
}
body = { ...body, operationTime }
const bridgeBody = buildBridgeBody(body)
const now = new Date()
let pushLogId = 0
let affectedSubOrderIds: string[] = []
@@ -0,0 +1,18 @@
import { randomBytes, randomInt } from 'node:crypto'
import { suppliedUserDao } from '../dao/supplied-user.dao.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export const suppliedUserService = {
async getSessionUser(userId: string) {
const virtualId = userId.trim()
if (!virtualId || virtualId.length > 191) return serviceFail('用户虚拟 ID 无效')
const letters = 'abcdefghijklmnopqrstuvwxyz'
const nickname = `用户${randomInt(10)}${letters[randomInt(letters.length)]}${randomBytes(4).toString('hex')}`
const user = await suppliedUserDao.findOrCreateByVirtualId(virtualId, nickname)
if (!user.enabled) return serviceFail('当前用户已停用', 403, 'USER_DISABLED')
return serviceOk({
userId: user.virtualId,
userInfo: { id: user.id, virtualId: user.virtualId, nickname: user.nickname }
})
}
}
@@ -1,4 +1,5 @@
import { supplierDao } from '../dao/supplier.dao.js'
import { suppliedUserService } from './supplied-user.service.js'
import { bridgeFetchWithAuth, findSupplierByIdentification, getBridgeApiBase, getBridgeToken, warmBridgeToken } from '../utils/bridge-auth.js'
import { sha256 } from '../utils/crypto.js'
import { logger } from '../utils/logger.js'
@@ -46,7 +47,7 @@ export const supplierService = {
return serviceFail('用户参数无效')
}
return serviceOk({ userId })
return await suppliedUserService.getSessionUser(userId)
} catch (error) {
logger.warn('supplier session exchange failed', {
message: error instanceof Error ? error.message : String(error)
@@ -79,7 +80,7 @@ export const supplierService = {
return serviceFail('用户参数无效')
}
return serviceOk({ userId })
return await suppliedUserService.getSessionUser(userId)
} catch (error) {
logger.warn('supplier session decrypt failed', {
message: error instanceof Error ? error.message : String(error)