dev
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { post, get, put, request } from '@/utils/request';
|
||||
|
||||
export interface ApplyMerchantParams {
|
||||
shopName: string;
|
||||
phone: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
businessLicense: string;
|
||||
licenseNo?: string;
|
||||
legalPerson?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMerchantParams {
|
||||
shopName?: string;
|
||||
logo?: string;
|
||||
phone?: string;
|
||||
description?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface MerchantInfo {
|
||||
id: number;
|
||||
sellerId: number;
|
||||
shopName: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
address: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
businessLicense: string;
|
||||
licenseNo: string;
|
||||
legalPerson: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'frozen';
|
||||
rejectReason: string;
|
||||
deposit: number;
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
autoConfirm: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 申请创建店铺(需要商家token)
|
||||
export function applyMerchant(data: ApplyMerchantParams) {
|
||||
return request<{ message: string; merchant: MerchantInfo }>({
|
||||
url: '/merchant/apply',
|
||||
method: 'POST',
|
||||
data,
|
||||
useSellerToken: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取我的店铺信息(需要商家token)
|
||||
export function getMyMerchant() {
|
||||
return request<MerchantInfo>({
|
||||
url: '/merchant/mine',
|
||||
method: 'GET',
|
||||
useSellerToken: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新店铺信息(需要商家token)
|
||||
export function updateMerchant(data: UpdateMerchantParams) {
|
||||
return request<MerchantInfo>({
|
||||
url: '/merchant/update',
|
||||
method: 'PUT',
|
||||
data,
|
||||
useSellerToken: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取商家详情(公开)
|
||||
export function getMerchantById(id: number) {
|
||||
return get<MerchantInfo>(`/merchant/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { post, get, request } from '@/utils/request';
|
||||
|
||||
export interface SellerRegisterParams {
|
||||
phone: string;
|
||||
code: string;
|
||||
contactName: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface SellerLoginParams {
|
||||
phone: string;
|
||||
code?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface SellerLoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sellerInfo: {
|
||||
id: number;
|
||||
phone: string;
|
||||
contactName: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
merchantId?: number;
|
||||
merchantStatus?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 发送商家验证码
|
||||
export function sellerSendCode(phone: string) {
|
||||
return post('/seller-auth/send-code', { phone });
|
||||
}
|
||||
|
||||
// 商家注册(验证码)
|
||||
export function sellerRegister(data: SellerRegisterParams) {
|
||||
return post<SellerLoginResult>('/seller-auth/register', data);
|
||||
}
|
||||
|
||||
// 商家登录(验证码或密码)
|
||||
export function sellerLogin(data: SellerLoginParams) {
|
||||
return post<SellerLoginResult>('/seller-auth/login', data);
|
||||
}
|
||||
|
||||
// 刷新商家令牌
|
||||
export function sellerRefreshToken(refreshToken: string) {
|
||||
return post<SellerLoginResult>('/seller-auth/refresh', { refreshToken });
|
||||
}
|
||||
|
||||
// 获取商家信息(需要商家token)
|
||||
export function getSellerProfile() {
|
||||
return request({ url: '/seller-auth/profile', method: 'GET', useSellerToken: true });
|
||||
}
|
||||
@@ -48,6 +48,24 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/seller-register/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商家入驻"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/shop-create/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "创建店铺"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/shop-edit/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "修改店铺"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -1,28 +1,66 @@
|
||||
<template>
|
||||
<view class="page-merchant">
|
||||
<view v-if="!userStore.isLoggedIn()" class="login-tip">
|
||||
<text class="tip-text">请先登录后查看商家中心</text>
|
||||
<button class="login-btn" @tap="goLogin">去登录</button>
|
||||
<!-- 未登录商家账号 -->
|
||||
<view v-if="!sellerStore.isSellerLoggedIn()" class="login-tip">
|
||||
<text class="tip-text">请先注册/登录商家账号</text>
|
||||
<button class="login-btn" @tap="goSellerRegister">去注册/登录</button>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!merchant" class="apply-section">
|
||||
<text class="apply-title">成为商家</text>
|
||||
<text class="apply-desc">开通店铺,发布房源,开始接单</text>
|
||||
<button class="apply-btn" @tap="goApply">立即申请入驻</button>
|
||||
<!-- 已登录但未申请店铺 -->
|
||||
<view v-else-if="!sellerStore.hasMerchant()" class="apply-section">
|
||||
<view class="seller-info">
|
||||
<text class="seller-name">{{ sellerStore.sellerInfo?.contactName }}</text>
|
||||
<text class="seller-phone">{{ sellerStore.sellerInfo?.phone }}</text>
|
||||
</view>
|
||||
<text class="apply-title">创建店铺</text>
|
||||
<text class="apply-desc">完成店铺信息填写,提交审核后即可营业</text>
|
||||
<button class="apply-btn" @tap="goCreateShop">创建店铺</button>
|
||||
<button class="logout-btn" @tap="handleLogoutSeller">退出商家账号</button>
|
||||
</view>
|
||||
|
||||
<!-- 已有店铺 -->
|
||||
<view v-else class="merchant-content">
|
||||
<!-- 店铺信息 -->
|
||||
<view class="shop-header">
|
||||
<image class="shop-logo" :src="merchant.logo || '/static/default-avatar.png'" mode="aspectFill" />
|
||||
<view class="shop-info">
|
||||
<text class="shop-name">{{ merchant.shopName }}</text>
|
||||
<text :class="['shop-status', merchant.status]">{{ merchantStatus[merchant.status] }}</text>
|
||||
<view class="status-row">
|
||||
<text :class="['shop-status', merchant.status]">{{ statusText }}</text>
|
||||
<!-- 审核中状态显示提示 -->
|
||||
<text v-if="merchant.status === 'pending'" class="status-tip">等待平台审核</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<view class="stat-grid">
|
||||
<!-- 审核拒绝原因 -->
|
||||
<view v-if="merchant.status === 'rejected' && merchant.rejectReason" class="reject-section">
|
||||
<view class="reject-header">
|
||||
<text class="reject-icon">!</text>
|
||||
<text class="reject-title">审核拒绝原因</text>
|
||||
</view>
|
||||
<text class="reject-reason">{{ merchant.rejectReason }}</text>
|
||||
<text class="reject-tip">请修改店铺信息后重新提交审核</text>
|
||||
</view>
|
||||
|
||||
<!-- 店铺信息卡片(非审核中状态) -->
|
||||
<view v-if="merchant.status !== 'pending'" class="shop-detail-card">
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">联系电话</text>
|
||||
<text class="detail-value">{{ merchant.phone }}</text>
|
||||
</view>
|
||||
<view class="detail-item" v-if="merchant.city">
|
||||
<text class="detail-label">所在城市</text>
|
||||
<text class="detail-value">{{ merchant.province }} {{ merchant.city }} {{ merchant.district }}</text>
|
||||
</view>
|
||||
<view class="detail-item" v-if="merchant.address">
|
||||
<text class="detail-label">详细地址</text>
|
||||
<text class="detail-value">{{ merchant.address }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据概览(仅审核通过显示) -->
|
||||
<view v-if="merchant.status === 'approved'" class="stat-grid">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">0</text>
|
||||
<text class="stat-label">今日订单</text>
|
||||
@@ -41,8 +79,28 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能菜单 -->
|
||||
<view class="menu-section">
|
||||
<!-- 操作按钮 -->
|
||||
<view class="action-section">
|
||||
<!-- 审核通过/拒绝时可修改店铺 -->
|
||||
<button
|
||||
v-if="merchant.status === 'approved' || merchant.status === 'rejected'"
|
||||
class="edit-btn"
|
||||
@tap="goEditShop"
|
||||
>
|
||||
修改店铺信息
|
||||
</button>
|
||||
<!-- 审核中状态不可修改 -->
|
||||
<view v-if="merchant.status === 'pending'" class="pending-tip">
|
||||
<text class="pending-text">店铺信息审核中,暂不可修改</text>
|
||||
</view>
|
||||
<!-- 冻结状态 -->
|
||||
<view v-if="merchant.status === 'frozen'" class="frozen-tip">
|
||||
<text class="frozen-text">店铺已被冻结,请联系平台客服</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能菜单(仅审核通过显示) -->
|
||||
<view v-if="merchant.status === 'approved'" class="menu-section">
|
||||
<view class="menu-group">
|
||||
<view class="menu-item" @tap="navigateTo('/pages/order/index')">
|
||||
<text class="menu-label">订单管理</text>
|
||||
@@ -59,11 +117,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { getMerchantInfo } from '@/api/auth';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useSellerStore } from '@/store/seller';
|
||||
import { getMyMerchant } from '@/api/merchant';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const sellerStore = useSellerStore();
|
||||
const merchant = ref<any>(null);
|
||||
|
||||
const merchantStatus: Record<string, string> = {
|
||||
@@ -73,21 +131,47 @@ const merchantStatus: Record<string, string> = {
|
||||
frozen: '已冻结',
|
||||
};
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!merchant.value) return '';
|
||||
return merchantStatus[merchant.value.status] || '';
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.isLoggedIn()) {
|
||||
if (sellerStore.isSellerLoggedIn() && sellerStore.hasMerchant()) {
|
||||
try {
|
||||
const res: any = await getMerchantInfo();
|
||||
const res = await getMyMerchant();
|
||||
merchant.value = res.data;
|
||||
} catch { /* 未申请商家 */ }
|
||||
// 同步更新 sellerStore 中的状态
|
||||
if (sellerStore.sellerInfo && res.data.status !== sellerStore.sellerInfo.merchantStatus) {
|
||||
sellerStore.sellerInfo.merchantStatus = res.data.status;
|
||||
uni.setStorageSync('sellerInfo', sellerStore.sellerInfo);
|
||||
}
|
||||
} catch { /* 获取店铺信息失败 */ }
|
||||
}
|
||||
});
|
||||
|
||||
function goLogin() {
|
||||
uni.navigateTo({ url: '/pages/login/index' });
|
||||
function goSellerRegister() {
|
||||
uni.navigateTo({ url: '/pages/seller-register/index' });
|
||||
}
|
||||
|
||||
function goApply() {
|
||||
uni.navigateTo({ url: '/pages/login/index' });
|
||||
function goCreateShop() {
|
||||
uni.navigateTo({ url: '/pages/shop-create/index' });
|
||||
}
|
||||
|
||||
function goEditShop() {
|
||||
uni.navigateTo({ url: '/pages/shop-edit/index' });
|
||||
}
|
||||
|
||||
function handleLogoutSeller() {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定退出商家账号?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
sellerStore.logoutSeller();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function navigateTo(url: string) {
|
||||
@@ -130,6 +214,25 @@ function navigateTo(url: string) {
|
||||
padding: 120rpx 48rpx;
|
||||
}
|
||||
|
||||
.seller-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.seller-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.seller-phone {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.apply-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
@@ -152,6 +255,16 @@ function navigateTo(url: string) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
margin-top: 32rpx;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
border: 1rpx solid #ddd;
|
||||
border-radius: 40rpx;
|
||||
padding: 20rpx 60rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.shop-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -168,6 +281,8 @@ function navigateTo(url: string) {
|
||||
|
||||
.shop-info {
|
||||
margin-left: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shop-name {
|
||||
@@ -176,16 +291,103 @@ function navigateTo(url: string) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.shop-status {
|
||||
font-size: 24rpx;
|
||||
margin-top: 8rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 16rpx;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
|
||||
&.approved { background: rgba(82, 196, 26, 0.6); }
|
||||
&.pending { background: rgba(250, 173, 20, 0.6); }
|
||||
&.approved { background: rgba(82, 196, 26, 0.8); }
|
||||
&.pending { background: rgba(250, 173, 20, 0.8); }
|
||||
&.rejected { background: rgba(255, 77, 79, 0.8); }
|
||||
&.frozen { background: rgba(140, 140, 140, 0.8); }
|
||||
}
|
||||
|
||||
.status-tip {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.reject-section {
|
||||
background: #fff2f0;
|
||||
border: 1rpx solid #ffccc7;
|
||||
border-radius: 16rpx;
|
||||
margin: 24rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.reject-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.reject-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
background: #ff4d4f;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reject-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #ff4d4f;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.reject-reason {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.reject-tip {
|
||||
font-size: 24rpx;
|
||||
color: #ff7875;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.shop-detail-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
margin: 24rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
@@ -215,6 +417,39 @@ function navigateTo(url: string) {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
padding: 0 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #fff;
|
||||
color: #FF6B35;
|
||||
border: 2rpx solid #FF6B35;
|
||||
border-radius: 44rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pending-tip, .frozen-tip {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pending-text {
|
||||
font-size: 28rpx;
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.frozen-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
@@ -244,4 +479,4 @@ function navigateTo(url: string) {
|
||||
font-size: 28rpx;
|
||||
color: #ccc;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -28,8 +28,12 @@
|
||||
</view>
|
||||
|
||||
<view class="menu-group">
|
||||
<view class="menu-item flex-between" @tap="navigateTo('/pages/merchant/home')">
|
||||
<text class="menu-label">我要开店</text>
|
||||
<view class="menu-item flex-between" @tap="goMerchantCenter">
|
||||
<text class="menu-label">商家中心</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
<view class="menu-item flex-between" @tap="goMerchantEntry">
|
||||
<text class="menu-label">商家入驻</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -53,8 +57,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { useSellerStore } from '@/store/seller';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const sellerStore = useSellerStore();
|
||||
const userInfo = computed(() => userStore.userInfo);
|
||||
|
||||
function goLogin() {
|
||||
@@ -69,6 +75,24 @@ function navigateTo(url: string) {
|
||||
uni.navigateTo({ url });
|
||||
}
|
||||
|
||||
function goMerchantEntry() {
|
||||
if (!userStore.isLoggedIn()) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: '/pages/seller-register/index' });
|
||||
}
|
||||
|
||||
function goMerchantCenter() {
|
||||
if (!sellerStore.isSellerLoggedIn()) {
|
||||
// 未登录商家账号,跳转注册/登录页
|
||||
uni.navigateTo({ url: '/pages/seller-register/index' });
|
||||
return;
|
||||
}
|
||||
// 已登录商家账号,跳转商家中心
|
||||
uni.navigateTo({ url: '/pages/merchant/home' });
|
||||
}
|
||||
|
||||
function showComingSoon(feature: string) {
|
||||
uni.showToast({
|
||||
title: `${feature}功能开发中`,
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<view class="page-seller-register">
|
||||
<view class="header">
|
||||
<image class="logo" src="/static/logo.png" mode="aspectFit" />
|
||||
<text class="title">商家入驻</text>
|
||||
<text class="subtitle">开通店铺,发布房源,开始接单</text>
|
||||
</view>
|
||||
|
||||
<view class="form-section">
|
||||
<!-- 注册表单 -->
|
||||
<view v-if="mode === 'register'" class="form">
|
||||
<view class="form-item">
|
||||
<text class="label">手机号</text>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
v-model="registerForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">验证码</text>
|
||||
<view class="code-row">
|
||||
<input
|
||||
class="input code-input"
|
||||
type="number"
|
||||
v-model="registerForm.code"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="6"
|
||||
/>
|
||||
<button
|
||||
class="code-btn"
|
||||
:disabled="registerCountdown > 0"
|
||||
@tap="sendRegisterCode"
|
||||
>
|
||||
{{ registerCountdown > 0 ? `${registerCountdown}s` : '获取验证码' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">联系人姓名</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="registerForm.contactName"
|
||||
placeholder="请输入联系人姓名"
|
||||
maxlength="50"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">邮箱(选填)</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="registerForm.email"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">密码(选填)</text>
|
||||
<input
|
||||
class="input"
|
||||
type="password"
|
||||
v-model="registerForm.password"
|
||||
placeholder="设置密码便于密码登录,6-20位"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<button class="submit-btn" :disabled="loading" @tap="handleRegister">
|
||||
{{ loading ? '注册中...' : '立即注册' }}
|
||||
</button>
|
||||
|
||||
<view class="switch-mode">
|
||||
<text class="switch-text">已有账号?</text>
|
||||
<text class="switch-link" @tap="switchToLogin">立即登录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<view v-else class="form">
|
||||
<view class="form-item">
|
||||
<text class="label">手机号</text>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
v-model="loginForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">验证码</text>
|
||||
<view class="code-row">
|
||||
<input
|
||||
class="input code-input"
|
||||
type="number"
|
||||
v-model="loginForm.code"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="6"
|
||||
/>
|
||||
<button
|
||||
class="code-btn"
|
||||
:disabled="loginCountdown > 0"
|
||||
@tap="sendLoginCode"
|
||||
>
|
||||
{{ loginCountdown > 0 ? `${loginCountdown}s` : '获取验证码' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="submit-btn" :disabled="loading" @tap="handleLogin">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
|
||||
<view class="switch-mode">
|
||||
<text class="switch-text">没有账号?</text>
|
||||
<text class="switch-link" @tap="switchToRegister">立即注册</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 协议 -->
|
||||
<view class="agreement">
|
||||
<checkbox :checked="agreed" @tap="agreed = !agreed" />
|
||||
<text class="agreement-text">
|
||||
我已阅读并同意
|
||||
<text class="agreement-link" @tap="showAgreement('商家服务协议')">《商家服务协议》</text>
|
||||
和
|
||||
<text class="agreement-link" @tap="showAgreement('隐私政策')">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { sellerRegister, sellerLogin, sellerSendCode } from '@/api/seller-auth';
|
||||
import { useSellerStore } from '@/store/seller';
|
||||
|
||||
const sellerStore = useSellerStore();
|
||||
|
||||
const mode = ref<'register' | 'login'>('register');
|
||||
const loading = ref(false);
|
||||
const agreed = ref(false);
|
||||
|
||||
const registerCountdown = ref(0);
|
||||
const loginCountdown = ref(0);
|
||||
|
||||
const registerForm = ref({
|
||||
phone: '',
|
||||
code: '',
|
||||
contactName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const loginForm = ref({
|
||||
phone: '',
|
||||
code: '',
|
||||
});
|
||||
|
||||
function switchToLogin() {
|
||||
mode.value = 'login';
|
||||
}
|
||||
|
||||
function switchToRegister() {
|
||||
mode.value = 'register';
|
||||
}
|
||||
|
||||
function validatePhone(phone: string): boolean {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
function startCountdown(type: 'register' | 'login') {
|
||||
const countdown = type === 'register' ? registerCountdown : loginCountdown;
|
||||
countdown.value = 60;
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--;
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function sendRegisterCode() {
|
||||
const { phone } = registerForm.value;
|
||||
if (!validatePhone(phone)) {
|
||||
uni.showToast({ title: '手机号格式不正确', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sellerSendCode(phone);
|
||||
uni.showToast({ title: '验证码已发送', icon: 'success' });
|
||||
startCountdown('register');
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error.message || '发送失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
|
||||
async function sendLoginCode() {
|
||||
const { phone } = loginForm.value;
|
||||
if (!validatePhone(phone)) {
|
||||
uni.showToast({ title: '手机号格式不正确', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sellerSendCode(phone);
|
||||
uni.showToast({ title: '验证码已发送', icon: 'success' });
|
||||
startCountdown('login');
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error.message || '发送失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先同意协议', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { phone, code, contactName } = registerForm.value;
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
uni.showToast({ title: '手机号格式不正确', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || code.length !== 6) {
|
||||
uni.showToast({ title: '请输入6位验证码', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contactName || contactName.length < 2) {
|
||||
uni.showToast({ title: '请填写联系人姓名', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (registerForm.value.password && (registerForm.value.password.length < 6 || registerForm.value.password.length > 20)) {
|
||||
uni.showToast({ title: '密码长度为6-20位', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const res = await sellerRegister({
|
||||
phone,
|
||||
code,
|
||||
contactName,
|
||||
email: registerForm.value.email || undefined,
|
||||
password: registerForm.value.password || undefined,
|
||||
});
|
||||
|
||||
sellerStore.setSellerLogin({
|
||||
accessToken: res.data.accessToken,
|
||||
sellerInfo: res.data.sellerInfo,
|
||||
});
|
||||
|
||||
uni.showToast({ title: '注册成功', icon: 'success' });
|
||||
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pages/merchant/home' });
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error.message || '注册失败', icon: 'none' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先同意协议', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { phone, code } = loginForm.value;
|
||||
|
||||
if (!validatePhone(phone)) {
|
||||
uni.showToast({ title: '手机号格式不正确', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || code.length !== 6) {
|
||||
uni.showToast({ title: '请输入6位验证码', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const res = await sellerLogin({ phone, code });
|
||||
|
||||
sellerStore.setSellerLogin({
|
||||
accessToken: res.data.accessToken,
|
||||
sellerInfo: res.data.sellerInfo,
|
||||
});
|
||||
|
||||
uni.showToast({ title: '登录成功', icon: 'success' });
|
||||
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pages/merchant/home' });
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error.message || '登录失败', icon: 'none' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showAgreement(name: string) {
|
||||
uni.showToast({ title: `${name}功能开发中`, icon: 'none' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-seller-register {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
padding: 0 48rpx;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80rpx 0 60rpx;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
flex-shrink: 0;
|
||||
width: 200rpx;
|
||||
height: 88rpx;
|
||||
background: #FF6B35;
|
||||
color: #fff;
|
||||
border-radius: 12rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
&[disabled] {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #FF6B35, #ff8a5c);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 44rpx;
|
||||
border: none;
|
||||
margin-top: 40rpx;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-mode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.switch-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.switch-link {
|
||||
font-size: 28rpx;
|
||||
color: #FF6B35;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.agreement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 60rpx;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: #FF6B35;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<view class="page-shop-create">
|
||||
<view class="form-section">
|
||||
<!-- 店铺名称 -->
|
||||
<view class="form-item">
|
||||
<text class="label required">店铺名称</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="form.shopName"
|
||||
placeholder="请输入店铺名称"
|
||||
maxlength="100"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 联系电话 -->
|
||||
<view class="form-item">
|
||||
<text class="label required">联系电话</text>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
v-model="form.phone"
|
||||
placeholder="请输入联系电话"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 地区选择 -->
|
||||
<view class="form-item">
|
||||
<text class="label">所在地区</text>
|
||||
<picker mode="region" @change="onRegionChange" :value="region">
|
||||
<view class="picker-value">
|
||||
<text v-if="region.length">{{ region[0] }} {{ region[1] }} {{ region[2] }}</text>
|
||||
<text v-else class="placeholder">请选择省/市/区</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 详细地址 -->
|
||||
<view class="form-item">
|
||||
<text class="label">详细地址</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="form.address"
|
||||
placeholder="请输入详细地址"
|
||||
maxlength="255"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 营业执照图片 -->
|
||||
<view class="form-item">
|
||||
<text class="label required">营业执照</text>
|
||||
<view class="upload-section">
|
||||
<view v-if="form.businessLicense" class="image-preview">
|
||||
<image class="preview-img" :src="form.businessLicense" mode="aspectFit" />
|
||||
<view class="delete-btn" @tap="deleteLicense">×</view>
|
||||
</view>
|
||||
<view v-else class="upload-btn" @tap="uploadLicense">
|
||||
<text class="upload-icon">+</text>
|
||||
<text class="upload-text">上传营业执照</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 营业执照编号 -->
|
||||
<view class="form-item">
|
||||
<text class="label">营业执照编号</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="form.licenseNo"
|
||||
placeholder="请输入营业执照编号"
|
||||
maxlength="50"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 法人姓名 -->
|
||||
<view class="form-item">
|
||||
<text class="label">法人姓名</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="form.legalPerson"
|
||||
placeholder="请输入法人姓名"
|
||||
maxlength="50"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 店铺描述 -->
|
||||
<view class="form-item">
|
||||
<text class="label">店铺描述</text>
|
||||
<textarea
|
||||
class="textarea"
|
||||
v-model="form.description"
|
||||
placeholder="请输入店铺描述(选填)"
|
||||
maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="submit-btn" :disabled="loading" @tap="handleSubmit">
|
||||
{{ loading ? '提交中...' : '提交申请' }}
|
||||
</button>
|
||||
|
||||
<view class="tips">
|
||||
<text class="tips-title">温馨提示</text>
|
||||
<text class="tips-text">1. 提交后需等待平台审核,审核结果将通过消息通知</text>
|
||||
<text class="tips-text">2. 营业执照需清晰可见,确保信息真实有效</text>
|
||||
<text class="tips-text">3. 审核通过后即可发布房源开始营业</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { applyMerchant } from '@/api/merchant';
|
||||
import { useSellerStore } from '@/store/seller';
|
||||
|
||||
const sellerStore = useSellerStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const region = ref<string[]>([]);
|
||||
|
||||
const form = ref({
|
||||
shopName: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
address: '',
|
||||
businessLicense: '',
|
||||
licenseNo: '',
|
||||
legalPerson: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
function onRegionChange(e: any) {
|
||||
const value = e.detail.value as string[];
|
||||
region.value = value;
|
||||
form.value.province = value[0] || '';
|
||||
form.value.city = value[1] || '';
|
||||
form.value.district = value[2] || '';
|
||||
}
|
||||
|
||||
function deleteLicense() {
|
||||
form.value.businessLicense = '';
|
||||
}
|
||||
|
||||
function uploadLicense() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const tempFilePath = res.tempFilePaths[0];
|
||||
// 模拟上传,实际需调用后端上传接口
|
||||
// 这里暂时用本地路径模拟,实际项目需要上传到服务器获取URL
|
||||
uploadImageToServer(tempFilePath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function uploadImageToServer(filePath: string) {
|
||||
uni.showLoading({ title: '上传中...' });
|
||||
|
||||
// 实际项目中需要调用后端上传接口
|
||||
// 这里模拟上传成功,使用临时路径作为演示
|
||||
// TODO: 实现真实的文件上传接口
|
||||
setTimeout(() => {
|
||||
uni.hideLoading();
|
||||
// 模拟上传成功后的URL
|
||||
form.value.businessLicense = filePath;
|
||||
uni.showToast({ title: '上传成功', icon: 'success' });
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
const { shopName, phone, businessLicense } = form.value;
|
||||
|
||||
if (!shopName || shopName.length < 2) {
|
||||
uni.showToast({ title: '店铺名称至少2个字', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
uni.showToast({ title: '请填写联系电话', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!businessLicense) {
|
||||
uni.showToast({ title: '请上传营业执照', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const res = await applyMerchant({
|
||||
shopName: form.value.shopName,
|
||||
phone: form.value.phone,
|
||||
province: form.value.province,
|
||||
city: form.value.city,
|
||||
district: form.value.district,
|
||||
address: form.value.address,
|
||||
businessLicense: form.value.businessLicense,
|
||||
licenseNo: form.value.licenseNo,
|
||||
legalPerson: form.value.legalPerson,
|
||||
description: form.value.description,
|
||||
});
|
||||
|
||||
uni.showToast({ title: '提交成功', icon: 'success' });
|
||||
|
||||
// 更新 sellerInfo 中的 merchantId 和 merchantStatus
|
||||
if (sellerStore.sellerInfo) {
|
||||
sellerStore.sellerInfo.merchantId = res.data.merchant.id;
|
||||
sellerStore.sellerInfo.merchantStatus = 'pending';
|
||||
uni.setStorageSync('sellerInfo', sellerStore.sellerInfo);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pages/merchant/home' });
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error.message || '提交失败', icon: 'none' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-shop-create {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
|
||||
&.required::before {
|
||||
content: '*';
|
||||
color: #ff4d4f;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
height: 160rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
height: 88rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: -16rpx;
|
||||
right: -16rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background: #ff4d4f;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #FF6B35, #ff8a5c);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 44rpx;
|
||||
border: none;
|
||||
margin-top: 48rpx;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 32rpx;
|
||||
padding: 24rpx;
|
||||
background: #fffbe6;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.tips-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #faad14;
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tips-text {
|
||||
font-size: 24rpx;
|
||||
color: #d48806;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<view class="page-shop-edit">
|
||||
<view class="form-section">
|
||||
<!-- 店铺名称 -->
|
||||
<view class="form-item">
|
||||
<text class="label required">店铺名称</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="form.shopName"
|
||||
placeholder="请输入店铺名称"
|
||||
maxlength="100"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 联系电话 -->
|
||||
<view class="form-item">
|
||||
<text class="label required">联系电话</text>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
v-model="form.phone"
|
||||
placeholder="请输入联系电话"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 地区选择 -->
|
||||
<view class="form-item">
|
||||
<text class="label">所在地区</text>
|
||||
<picker mode="region" @change="onRegionChange" :value="region">
|
||||
<view class="picker-value">
|
||||
<text v-if="region.length">{{ region[0] }} {{ region[1] }} {{ region[2] }}</text>
|
||||
<text v-else class="placeholder">请选择省/市/区</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 详细地址 -->
|
||||
<view class="form-item">
|
||||
<text class="label">详细地址</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="form.address"
|
||||
placeholder="请输入详细地址"
|
||||
maxlength="255"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 营业执照图片 -->
|
||||
<view class="form-item">
|
||||
<text class="label required">营业执照</text>
|
||||
<view class="upload-section">
|
||||
<view v-if="form.businessLicense" class="image-preview">
|
||||
<image class="preview-img" :src="form.businessLicense" mode="aspectFit" />
|
||||
<view class="delete-btn" @tap="deleteLicense">×</view>
|
||||
</view>
|
||||
<view v-else class="upload-btn" @tap="uploadLicense">
|
||||
<text class="upload-icon">+</text>
|
||||
<text class="upload-text">上传营业执照</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 营业执照编号 -->
|
||||
<view class="form-item">
|
||||
<text class="label">营业执照编号</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="form.licenseNo"
|
||||
placeholder="请输入营业执照编号"
|
||||
maxlength="50"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 法人姓名 -->
|
||||
<view class="form-item">
|
||||
<text class="label">法人姓名</text>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="form.legalPerson"
|
||||
placeholder="请输入法人姓名"
|
||||
maxlength="50"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 店铺描述 -->
|
||||
<view class="form-item">
|
||||
<text class="label">店铺描述</text>
|
||||
<textarea
|
||||
class="textarea"
|
||||
v-model="form.description"
|
||||
placeholder="请输入店铺描述(选填)"
|
||||
maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="submit-btn" :disabled="loading" @tap="handleSubmit">
|
||||
{{ loading ? '提交中...' : '保存修改' }}
|
||||
</button>
|
||||
|
||||
<view class="tips">
|
||||
<text class="tips-title">温馨提示</text>
|
||||
<text class="tips-text">修改店铺信息后需要重新等待平台审核</text>
|
||||
<text class="tips-text">审核结果将通过消息通知您</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { updateMerchant, getMyMerchant } from '@/api/merchant';
|
||||
import { useSellerStore } from '@/store/seller';
|
||||
|
||||
const sellerStore = useSellerStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const region = ref<string[]>([]);
|
||||
|
||||
const form = ref({
|
||||
shopName: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
address: '',
|
||||
businessLicense: '',
|
||||
licenseNo: '',
|
||||
legalPerson: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getMyMerchant();
|
||||
const merchant = res.data;
|
||||
form.value = {
|
||||
shopName: merchant.shopName || '',
|
||||
phone: merchant.phone || '',
|
||||
province: merchant.province || '',
|
||||
city: merchant.city || '',
|
||||
district: merchant.district || '',
|
||||
address: merchant.address || '',
|
||||
businessLicense: merchant.businessLicense || '',
|
||||
licenseNo: merchant.licenseNo || '',
|
||||
legalPerson: merchant.legalPerson || '',
|
||||
description: merchant.description || '',
|
||||
};
|
||||
if (merchant.province || merchant.city || merchant.district) {
|
||||
region.value = [merchant.province || '', merchant.city || '', merchant.district || ''];
|
||||
}
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '获取店铺信息失败', icon: 'none' });
|
||||
}
|
||||
});
|
||||
|
||||
function onRegionChange(e: any) {
|
||||
const value = e.detail.value as string[];
|
||||
region.value = value;
|
||||
form.value.province = value[0] || '';
|
||||
form.value.city = value[1] || '';
|
||||
form.value.district = value[2] || '';
|
||||
}
|
||||
|
||||
function deleteLicense() {
|
||||
form.value.businessLicense = '';
|
||||
}
|
||||
|
||||
function uploadLicense() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const tempFilePath = res.tempFilePaths[0];
|
||||
uploadImageToServer(tempFilePath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function uploadImageToServer(filePath: string) {
|
||||
uni.showLoading({ title: '上传中...' });
|
||||
// TODO: 实现真实的文件上传接口
|
||||
setTimeout(() => {
|
||||
uni.hideLoading();
|
||||
form.value.businessLicense = filePath;
|
||||
uni.showToast({ title: '上传成功', icon: 'success' });
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
const { shopName, phone, businessLicense } = form.value;
|
||||
|
||||
if (!shopName || shopName.length < 2) {
|
||||
uni.showToast({ title: '店铺名称至少2个字', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
uni.showToast({ title: '请填写联系电话', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!businessLicense) {
|
||||
uni.showToast({ title: '请上传营业执照', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const res = await updateMerchant({
|
||||
shopName: form.value.shopName,
|
||||
phone: form.value.phone,
|
||||
province: form.value.province,
|
||||
city: form.value.city,
|
||||
district: form.value.district,
|
||||
address: form.value.address,
|
||||
businessLicense: form.value.businessLicense,
|
||||
licenseNo: form.value.licenseNo,
|
||||
legalPerson: form.value.legalPerson,
|
||||
description: form.value.description,
|
||||
});
|
||||
|
||||
uni.showToast({ title: '修改成功', icon: 'success' });
|
||||
|
||||
// 更新 sellerInfo 中的 merchantStatus 为 pending
|
||||
if (sellerStore.sellerInfo && res.data.status === 'pending') {
|
||||
sellerStore.sellerInfo.merchantStatus = 'pending';
|
||||
uni.setStorageSync('sellerInfo', sellerStore.sellerInfo);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pages/merchant/home' });
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error.message || '修改失败', icon: 'none' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-shop-edit {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
|
||||
&.required::before {
|
||||
content: '*';
|
||||
color: #ff4d4f;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
height: 160rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
height: 88rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: -16rpx;
|
||||
right: -16rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background: #ff4d4f;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #FF6B35, #ff8a5c);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 44rpx;
|
||||
border: none;
|
||||
margin-top: 48rpx;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 32rpx;
|
||||
padding: 24rpx;
|
||||
background: #fffbe6;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.tips-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #faad14;
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tips-text {
|
||||
font-size: 24rpx;
|
||||
color: #d48806;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface SellerInfo {
|
||||
id: number;
|
||||
phone: string;
|
||||
contactName: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
merchantId?: number;
|
||||
merchantStatus?: string;
|
||||
}
|
||||
|
||||
export const useSellerStore = defineStore('seller', () => {
|
||||
const sellerToken = ref(uni.getStorageSync('sellerToken') || '');
|
||||
const sellerInfo = ref<SellerInfo | null>(uni.getStorageSync('sellerInfo') || null);
|
||||
|
||||
function setSellerLogin(data: { accessToken: string; sellerInfo: SellerInfo }) {
|
||||
sellerToken.value = data.accessToken;
|
||||
sellerInfo.value = data.sellerInfo;
|
||||
uni.setStorageSync('sellerToken', data.accessToken);
|
||||
uni.setStorageSync('sellerInfo', data.sellerInfo);
|
||||
}
|
||||
|
||||
function logoutSeller() {
|
||||
sellerToken.value = '';
|
||||
sellerInfo.value = null;
|
||||
uni.removeStorageSync('sellerToken');
|
||||
uni.removeStorageSync('sellerInfo');
|
||||
}
|
||||
|
||||
function isSellerLoggedIn() {
|
||||
return !!sellerToken.value;
|
||||
}
|
||||
|
||||
function hasMerchant() {
|
||||
return !!sellerInfo.value?.merchantId;
|
||||
}
|
||||
|
||||
return { sellerToken, sellerInfo, setSellerLogin, logoutSeller, isSellerLoggedIn, hasMerchant };
|
||||
});
|
||||
@@ -6,6 +6,7 @@ interface RequestOptions {
|
||||
data?: any;
|
||||
header?: Record<string, string>;
|
||||
showLoading?: boolean;
|
||||
useSellerToken?: boolean;
|
||||
}
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
@@ -19,13 +20,18 @@ function getToken(): string {
|
||||
}
|
||||
|
||||
export function request<T = any>(options: RequestOptions): Promise<ApiResponse<T>> {
|
||||
const { url, method = 'GET', data, header = {}, showLoading = false } = options;
|
||||
const { url, method = 'GET', data, header = {}, showLoading = false, useSellerToken = false } = options;
|
||||
|
||||
if (showLoading) {
|
||||
uni.showLoading({ title: '加载中...' });
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
let token = '';
|
||||
if (useSellerToken) {
|
||||
token = uni.getStorageSync('sellerToken') || '';
|
||||
} else {
|
||||
token = getToken();
|
||||
}
|
||||
if (token) {
|
||||
header['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.0.3",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ioredis": "^5.4.2",
|
||||
|
||||
@@ -5,6 +5,7 @@ import databaseConfig from './config/database.config';
|
||||
import jwtConfig from './config/jwt.config';
|
||||
import redisConfig from './config/redis.config';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { SellerAuthModule } from './modules/seller-auth/seller-auth.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { MerchantModule } from './modules/merchant/merchant.module';
|
||||
import { RoomModule } from './modules/room/room.module';
|
||||
@@ -34,6 +35,7 @@ import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
||||
}),
|
||||
}),
|
||||
AuthModule,
|
||||
SellerAuthModule,
|
||||
UserModule,
|
||||
MerchantModule,
|
||||
RoomModule,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentSeller = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const seller = request.seller;
|
||||
return data ? seller?.[data] : seller;
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class SellerJwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('未登录或登录已过期');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
});
|
||||
|
||||
if (payload.role !== 'seller') {
|
||||
throw new UnauthorizedException('非商家账户');
|
||||
}
|
||||
|
||||
(request as any).seller = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractToken(request: Request): string | null {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from './filters/all-exceptions.filter';
|
||||
export * from './interceptors/transform.interceptor';
|
||||
export * from './guards/jwt-auth.guard';
|
||||
export * from './guards/seller-jwt-auth.guard';
|
||||
export * from './guards/roles.guard';
|
||||
export * from './decorators/roles.decorator';
|
||||
export * from './decorators/current-user.decorator';
|
||||
export * from './decorators/current-seller.decorator';
|
||||
@@ -1,4 +1,5 @@
|
||||
export { User } from './user.entity';
|
||||
export { Seller } from './seller.entity';
|
||||
export { Merchant } from './merchant.entity';
|
||||
export { Room } from './room.entity';
|
||||
export { RoomCalendar } from './room-calendar.entity';
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn, OneToOne } from 'typeorm';
|
||||
import { Seller } from './seller.entity';
|
||||
|
||||
@Entity('merchants')
|
||||
export class Merchant {
|
||||
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
|
||||
id: number;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
@OneToOne(() => Seller)
|
||||
@JoinColumn({ name: 'seller_id' })
|
||||
seller: Seller;
|
||||
|
||||
@Column({ name: 'user_id', type: 'bigint', unsigned: true, unique: true, comment: '关联用户ID' })
|
||||
userId: number;
|
||||
@Column({ name: 'seller_id', type: 'bigint', unsigned: true, unique: true, comment: '关联商家账户ID' })
|
||||
sellerId: number;
|
||||
|
||||
@Column({ length: 100, comment: '店铺名称' })
|
||||
shopName: string;
|
||||
@@ -57,7 +57,7 @@ export class Merchant {
|
||||
status: 'pending' | 'approved' | 'rejected' | 'frozen';
|
||||
|
||||
@Column({ length: 500, nullable: true, comment: '拒绝原因' })
|
||||
rejectReason: string;
|
||||
rejectReason?: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '保证金' })
|
||||
deposit: number;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, OneToOne } from 'typeorm';
|
||||
import { Merchant } from './merchant.entity';
|
||||
|
||||
@Entity('sellers')
|
||||
export class Seller {
|
||||
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
|
||||
id: number;
|
||||
|
||||
@Column({ length: 20, unique: true, comment: '手机号' })
|
||||
phone: string;
|
||||
|
||||
@Column({ length: 255, nullable: true, select: false, comment: '密码(bcrypt哈希)' })
|
||||
password?: string;
|
||||
|
||||
@Column({ length: 50, name: 'contact_name', comment: '联系人姓名' })
|
||||
contactName: string;
|
||||
|
||||
@Column({ length: 100, nullable: true, comment: '邮箱' })
|
||||
email: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'enum', enum: ['active', 'frozen', 'deleted'], default: 'active', comment: '状态' })
|
||||
status: 'active' | 'frozen' | 'deleted';
|
||||
|
||||
@Column({ type: 'datetime', nullable: true, name: 'last_login_at', comment: '最后登录时间' })
|
||||
lastLoginAt: Date;
|
||||
|
||||
@Column({ length: 50, nullable: true, name: 'last_login_ip', comment: '最后登录IP' })
|
||||
lastLoginIp: string;
|
||||
|
||||
@OneToOne(() => Merchant, merchant => merchant.seller)
|
||||
merchant: Merchant;
|
||||
|
||||
@CreateDateColumn({ comment: '创建时间' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ comment: '更新时间' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -26,10 +26,6 @@ export class User {
|
||||
@Column({ length: 255, nullable: true, select: false, comment: '身份证号' })
|
||||
idCard: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'enum', enum: ['user', 'merchant'], default: 'user', comment: '角色' })
|
||||
role: 'user' | 'merchant';
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'enum', enum: ['active', 'frozen', 'deleted'], default: 'active', comment: '状态' })
|
||||
status: 'active' | 'frozen' | 'deleted';
|
||||
|
||||
@@ -139,7 +139,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private async generateToken(user: User) {
|
||||
const payload = { sub: user.id, phone: user.phone, role: user.role };
|
||||
const payload = { sub: user.id, phone: user.phone };
|
||||
const accessToken = await this.jwtService.signAsync(payload);
|
||||
const refreshToken = await this.jwtService.signAsync(payload, {
|
||||
expiresIn: (this.configService.get<string>('jwt.refreshExpiresIn') ||
|
||||
@@ -154,7 +154,7 @@ export class AuthService {
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export class ApplyMerchantDto {
|
||||
legalPerson?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -61,15 +62,19 @@ export class UpdateMerchantDto {
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
province?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
district?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { MerchantService } from './merchant.service';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '@/common/guards/roles.guard';
|
||||
import { SellerJwtAuthGuard } from '@/common/guards/seller-jwt-auth.guard';
|
||||
import { JwtAuthGuard, RolesGuard } from '@/common';
|
||||
import { Roles } from '@/common/decorators/roles.decorator';
|
||||
import { CurrentUser } from '@/common/decorators/current-user.decorator';
|
||||
import { CurrentSeller } from '@/common/decorators/current-seller.decorator';
|
||||
import { ApplyMerchantDto, UpdateMerchantDto, QueryMerchantDto } from './dto/merchant.dto';
|
||||
|
||||
@ApiTags('商家')
|
||||
@Controller('merchant')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(SellerJwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class MerchantController {
|
||||
constructor(private readonly merchantService: MerchantService) {}
|
||||
|
||||
@Post('apply')
|
||||
@ApiOperation({ summary: '申请商家入驻' })
|
||||
async apply(@CurrentUser('sub') userId: number, @Body() dto: ApplyMerchantDto) {
|
||||
return this.merchantService.apply(userId, dto);
|
||||
async apply(@CurrentSeller('sub') sellerId: number, @Body() dto: ApplyMerchantDto) {
|
||||
return this.merchantService.apply(sellerId, dto);
|
||||
}
|
||||
|
||||
@Get('mine')
|
||||
@ApiOperation({ summary: '获取我的店铺信息' })
|
||||
async getMine(@CurrentUser('sub') userId: number) {
|
||||
return this.merchantService.findByUserId(userId);
|
||||
async getMine(@CurrentSeller('sub') sellerId: number) {
|
||||
return this.merchantService.findBySellerId(sellerId);
|
||||
}
|
||||
|
||||
@Put('update')
|
||||
@Roles('merchant')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: '更新店铺信息' })
|
||||
async update(@CurrentUser('sub') userId: number, @Body() dto: UpdateMerchantDto) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
async update(@CurrentSeller('sub') sellerId: number, @Body() dto: UpdateMerchantDto) {
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new Error('店铺不存在');
|
||||
return this.merchantService.update(merchant.id, dto);
|
||||
}
|
||||
|
||||
@@ -11,18 +11,18 @@ export class MerchantService {
|
||||
private merchantRepo: Repository<Merchant>,
|
||||
) {}
|
||||
|
||||
async apply(userId: number, dto: ApplyMerchantDto) {
|
||||
const existing = await this.merchantRepo.findOne({ where: { userId } });
|
||||
async apply(sellerId: number, dto: ApplyMerchantDto) {
|
||||
const existing = await this.merchantRepo.findOne({ where: { sellerId } });
|
||||
if (existing) {
|
||||
throw new BadRequestException('您已提交过商家申请');
|
||||
}
|
||||
|
||||
const merchant = this.merchantRepo.create({ userId, ...dto });
|
||||
const merchant = this.merchantRepo.create({ sellerId, ...dto });
|
||||
return this.merchantRepo.save(merchant);
|
||||
}
|
||||
|
||||
async findByUserId(userId: number) {
|
||||
return this.merchantRepo.findOne({ where: { userId } });
|
||||
async findBySellerId(sellerId: number) {
|
||||
return this.merchantRepo.findOne({ where: { sellerId } });
|
||||
}
|
||||
|
||||
async findById(id: number) {
|
||||
@@ -32,7 +32,13 @@ export class MerchantService {
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateMerchantDto) {
|
||||
await this.merchantRepo.update(id, dto);
|
||||
const merchant = await this.findById(id);
|
||||
// 审核通过或审核拒绝后修改信息,需要重新进入审核
|
||||
if (merchant.status === 'approved' || merchant.status === 'rejected') {
|
||||
await this.merchantRepo.update(id, { ...dto, status: 'pending', rejectReason: '' });
|
||||
} else {
|
||||
await this.merchantRepo.update(id, dto);
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Controller, Get, Post, Put, Param, Body, Query, UseGuards, NotFoundException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { OrderService } from './order.service';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '@/common/guards/roles.guard';
|
||||
import { JwtAuthGuard, RolesGuard } from '@/common';
|
||||
import { SellerJwtAuthGuard } from '@/common/guards/seller-jwt-auth.guard';
|
||||
import { Roles } from '@/common/decorators/roles.decorator';
|
||||
import { CurrentUser } from '@/common/decorators/current-user.decorator';
|
||||
import { CurrentSeller } from '@/common/decorators/current-seller.decorator';
|
||||
import { MerchantService } from '../merchant/merchant.service';
|
||||
import { CreateOrderDto, QueryOrderDto, ConfirmOrderDto } from './dto/order.dto';
|
||||
|
||||
@@ -42,8 +43,7 @@ export class UserOrderController {
|
||||
|
||||
@ApiTags('订单管理(商家)')
|
||||
@Controller('merchant/orders')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('merchant')
|
||||
@UseGuards(SellerJwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class MerchantOrderController {
|
||||
constructor(
|
||||
@@ -53,32 +53,32 @@ export class MerchantOrderController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '商家订单列表' })
|
||||
async findMine(@CurrentUser('sub') userId: number, @Query() query: QueryOrderDto) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
async findMine(@CurrentSeller('sub') sellerId: number, @Query() query: QueryOrderDto) {
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new NotFoundException('店铺不存在');
|
||||
return this.orderService.findByMerchant(merchant.id, query);
|
||||
}
|
||||
|
||||
@Put(':id/confirm')
|
||||
@ApiOperation({ summary: '确认订单' })
|
||||
async confirm(@CurrentUser('sub') userId: number, @Param('id') id: number) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
async confirm(@CurrentSeller('sub') sellerId: number, @Param('id') id: number) {
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new NotFoundException('店铺不存在');
|
||||
return this.orderService.confirm(merchant.id, id);
|
||||
}
|
||||
|
||||
@Put(':id/reject')
|
||||
@ApiOperation({ summary: '拒绝订单' })
|
||||
async reject(@CurrentUser('sub') userId: number, @Param('id') id: number, @Body('reason') reason: string) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
async reject(@CurrentSeller('sub') sellerId: number, @Param('id') id: number, @Body('reason') reason: string) {
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new NotFoundException('店铺不存在');
|
||||
return this.orderService.reject(merchant.id, id, reason);
|
||||
}
|
||||
|
||||
@Put(':id/checkin')
|
||||
@ApiOperation({ summary: '办理入住' })
|
||||
async checkin(@CurrentUser('sub') userId: number, @Param('id') id: number) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
async checkin(@CurrentSeller('sub') sellerId: number, @Param('id') id: number) {
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new NotFoundException('店铺不存在');
|
||||
return this.orderService.checkin(merchant.id, id);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { RoomService } from './room.service';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '@/common/guards/roles.guard';
|
||||
import { Roles } from '@/common/decorators/roles.decorator';
|
||||
import { CurrentUser } from '@/common/decorators/current-user.decorator';
|
||||
import { SellerJwtAuthGuard } from '@/common/guards/seller-jwt-auth.guard';
|
||||
import { CurrentSeller } from '@/common/decorators/current-seller.decorator';
|
||||
import { MerchantService } from '../merchant/merchant.service';
|
||||
import { CreateRoomDto, UpdateRoomDto, QueryRoomDto } from './dto/room.dto';
|
||||
|
||||
@@ -39,8 +37,7 @@ export class RoomPublicController {
|
||||
|
||||
@ApiTags('房源管理(商家)')
|
||||
@Controller('merchant/rooms')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('merchant')
|
||||
@UseGuards(SellerJwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class MerchantRoomController {
|
||||
constructor(
|
||||
@@ -51,18 +48,18 @@ export class MerchantRoomController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: '我的房源列表' })
|
||||
async findMine(
|
||||
@CurrentUser('sub') userId: number,
|
||||
@CurrentSeller('sub') sellerId: number,
|
||||
@Query() query: QueryRoomDto,
|
||||
) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new NotFoundException('店铺不存在');
|
||||
return this.roomService.findByMerchant(merchant.id, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '添加房源' })
|
||||
async create(@CurrentUser('sub') userId: number, @Body() dto: CreateRoomDto) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
async create(@CurrentSeller('sub') sellerId: number, @Body() dto: CreateRoomDto) {
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new NotFoundException('店铺不存在');
|
||||
return this.roomService.create(merchant.id, dto);
|
||||
}
|
||||
@@ -70,19 +67,19 @@ export class MerchantRoomController {
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新房源' })
|
||||
async update(
|
||||
@CurrentUser('sub') userId: number,
|
||||
@CurrentSeller('sub') sellerId: number,
|
||||
@Param('id') id: number,
|
||||
@Body() dto: UpdateRoomDto,
|
||||
) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new NotFoundException('店铺不存在');
|
||||
return this.roomService.update(id, merchant.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '下架房源' })
|
||||
async remove(@CurrentUser('sub') userId: number, @Param('id') id: number) {
|
||||
const merchant = await this.merchantService.findByUserId(userId);
|
||||
async remove(@CurrentSeller('sub') sellerId: number, @Param('id') id: number) {
|
||||
const merchant = await this.merchantService.findBySellerId(sellerId);
|
||||
if (!merchant) throw new NotFoundException('店铺不存在');
|
||||
return this.roomService.remove(id, merchant.id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IsString, IsNotEmpty, Length, Matches, IsOptional } from 'class-validator';
|
||||
|
||||
// 发送验证码
|
||||
export class SellerSendCodeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '手机号不能为空' })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||
phone: string;
|
||||
}
|
||||
|
||||
// 验证码注册
|
||||
export class SellerRegisterDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '手机号不能为空' })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '验证码不能为空' })
|
||||
@Length(6, 6, { message: '验证码为6位数字' })
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '联系人姓名不能为空' })
|
||||
@Length(2, 50, { message: '联系人姓名长度为2-50位' })
|
||||
contactName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/, { message: '邮箱格式不正确' })
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(6, 20, { message: '密码长度为6-20位' })
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// 登录(支持验证码或密码)
|
||||
export class SellerLoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '手机号不能为空' })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||
phone: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(6, 6, { message: '验证码为6位数字' })
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(6, 20, { message: '密码长度为6-20位' })
|
||||
password?: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Controller, Post, Body, UseGuards, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { SellerAuthService } from './seller-auth.service';
|
||||
import { SellerLoginDto, SellerRegisterDto, SellerSendCodeDto } from './dto/seller-auth.dto';
|
||||
import { SellerJwtAuthGuard } from '@/common/guards/seller-jwt-auth.guard';
|
||||
import { CurrentSeller } from '@/common/decorators/current-seller.decorator';
|
||||
|
||||
@ApiTags('商家认证')
|
||||
@Controller('seller-auth')
|
||||
export class SellerAuthController {
|
||||
constructor(private readonly sellerAuthService: SellerAuthService) {}
|
||||
|
||||
@Post('send-code')
|
||||
@ApiOperation({ summary: '发送商家验证码' })
|
||||
async sendCode(@Body() dto: SellerSendCodeDto) {
|
||||
return this.sellerAuthService.sendCode(dto.phone);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '商家登录(验证码或密码)' })
|
||||
async login(@Body() dto: SellerLoginDto) {
|
||||
return this.sellerAuthService.login(dto);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '商家注册(验证码)' })
|
||||
async register(@Body() dto: SellerRegisterDto) {
|
||||
return this.sellerAuthService.register(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: '刷新令牌' })
|
||||
async refresh(@Body('refreshToken') refreshToken: string) {
|
||||
return this.sellerAuthService.refresh(refreshToken);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(SellerJwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取当前商家信息' })
|
||||
async getProfile(@CurrentSeller('sub') sellerId: number) {
|
||||
return this.sellerAuthService.findById(sellerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SellerAuthController } from './seller-auth.controller';
|
||||
import { SellerAuthService } from './seller-auth.service';
|
||||
import { Seller } from '@/entities/seller.entity';
|
||||
import { Merchant } from '@/entities/merchant.entity';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Seller, Merchant]),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('jwt.secret') || 'dev_secret_key',
|
||||
signOptions: {
|
||||
expiresIn: (configService.get<string>('jwt.expiresIn') ||
|
||||
'2h') as any,
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [SellerAuthController],
|
||||
providers: [SellerAuthService],
|
||||
exports: [SellerAuthService, JwtModule],
|
||||
})
|
||||
export class SellerAuthModule {}
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { Seller } from '@/entities/seller.entity';
|
||||
import { Merchant } from '@/entities/merchant.entity';
|
||||
import { SellerLoginDto, SellerRegisterDto } from './dto/seller-auth.dto';
|
||||
|
||||
// 内存缓存(生产环境应替换为 Redis)
|
||||
const codeCache = new Map<string, { code: string; expiresAt: number }>();
|
||||
|
||||
@Injectable()
|
||||
export class SellerAuthService {
|
||||
private readonly logger = new Logger(SellerAuthService.name);
|
||||
private readonly isDev = process.env.NODE_ENV === 'development';
|
||||
private readonly devCode = '123456';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Seller)
|
||||
private sellerRepo: Repository<Seller>,
|
||||
@InjectRepository(Merchant)
|
||||
private merchantRepo: Repository<Merchant>,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
// 发送验证码
|
||||
async sendCode(phone: string) {
|
||||
const code = Math.random().toString().slice(2, 8);
|
||||
await this.cacheCode(phone, code);
|
||||
this.logger.log(`商家验证码已发送至 ${phone}: ${code}`);
|
||||
return { message: '验证码已发送' };
|
||||
}
|
||||
|
||||
// 注册(验证码方式)
|
||||
async register(dto: SellerRegisterDto) {
|
||||
// 验证验证码
|
||||
await this.verifyCode(dto.phone, dto.code);
|
||||
|
||||
const existing = await this.sellerRepo.findOne({
|
||||
where: { phone: dto.phone },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('该手机号已注册');
|
||||
}
|
||||
|
||||
const sellerData: Partial<Seller> = {
|
||||
phone: dto.phone,
|
||||
contactName: dto.contactName,
|
||||
email: dto.email,
|
||||
};
|
||||
|
||||
if (dto.password) {
|
||||
sellerData.password = await bcrypt.hash(dto.password, 10);
|
||||
}
|
||||
|
||||
const seller = this.sellerRepo.create(sellerData);
|
||||
await this.sellerRepo.save(seller);
|
||||
|
||||
this.clearCachedCode(dto.phone);
|
||||
return this.generateToken(seller);
|
||||
}
|
||||
|
||||
// 登录(支持验证码或密码)
|
||||
async login(dto: SellerLoginDto) {
|
||||
if (!dto.code && !dto.password) {
|
||||
throw new BadRequestException('请输入验证码或密码');
|
||||
}
|
||||
|
||||
if (dto.code) {
|
||||
const seller = await this.validateByCode(dto.phone, dto.code);
|
||||
this.clearCachedCode(dto.phone);
|
||||
return this.generateToken(seller);
|
||||
}
|
||||
|
||||
const seller = await this.validateByPassword(dto.phone, dto.password!);
|
||||
return this.generateToken(seller);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(refreshToken, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
});
|
||||
|
||||
const seller = await this.sellerRepo.findOne({ where: { id: payload.sub } });
|
||||
if (!seller) {
|
||||
throw new UnauthorizedException('商家账户不存在');
|
||||
}
|
||||
|
||||
return this.generateToken(seller);
|
||||
} catch {
|
||||
throw new UnauthorizedException('刷新令牌无效');
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: number) {
|
||||
return this.sellerRepo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
// 验证码验证
|
||||
private async verifyCode(phone: string, code: string): Promise<void> {
|
||||
if (this.isDev && code === this.devCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = await this.getCachedCode(phone);
|
||||
if (!cached || cached !== code) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码登录验证
|
||||
private async validateByCode(phone: string, code: string): Promise<Seller> {
|
||||
await this.verifyCode(phone, code);
|
||||
|
||||
const seller = await this.sellerRepo.findOne({ where: { phone } });
|
||||
if (!seller) {
|
||||
throw new UnauthorizedException('商家账户不存在');
|
||||
}
|
||||
if (seller.status !== 'active') {
|
||||
throw new UnauthorizedException('商家账户已被冻结或注销');
|
||||
}
|
||||
|
||||
return seller;
|
||||
}
|
||||
|
||||
// 密码登录验证
|
||||
private async validateByPassword(phone: string, password: string): Promise<Seller> {
|
||||
const seller = await this.sellerRepo
|
||||
.createQueryBuilder('seller')
|
||||
.where('seller.phone = :phone', { phone })
|
||||
.addSelect('seller.password')
|
||||
.getOne();
|
||||
|
||||
if (!seller || !seller.password) {
|
||||
throw new UnauthorizedException('手机号或密码错误');
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, seller.password);
|
||||
if (!isMatch) {
|
||||
throw new UnauthorizedException('手机号或密码错误');
|
||||
}
|
||||
|
||||
return seller;
|
||||
}
|
||||
|
||||
// 验证码缓存方法
|
||||
private async cacheCode(phone: string, code: string): Promise<void> {
|
||||
// TODO: 生产环境替换为 Redis,设置5分钟过期
|
||||
const expiresAt = Date.now() + 5 * 60 * 1000;
|
||||
codeCache.set(phone, { code, expiresAt });
|
||||
this.logger.debug(`缓存商家验证码: ${phone} -> ${code}`);
|
||||
}
|
||||
|
||||
private async getCachedCode(phone: string): Promise<string | null> {
|
||||
// TODO: 生产环境替换为 Redis
|
||||
const cached = codeCache.get(phone);
|
||||
if (!cached) return null;
|
||||
if (Date.now() > cached.expiresAt) {
|
||||
codeCache.delete(phone);
|
||||
return null;
|
||||
}
|
||||
return cached.code;
|
||||
}
|
||||
|
||||
private async clearCachedCode(phone: string): Promise<void> {
|
||||
codeCache.delete(phone);
|
||||
this.logger.debug(`清除商家验证码: ${phone}`);
|
||||
}
|
||||
|
||||
private async generateToken(seller: Seller) {
|
||||
const merchant = await this.merchantRepo.findOne({
|
||||
where: { sellerId: seller.id },
|
||||
});
|
||||
|
||||
const payload = {
|
||||
sub: seller.id,
|
||||
phone: seller.phone,
|
||||
role: 'seller',
|
||||
merchantId: merchant?.id,
|
||||
};
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload);
|
||||
const refreshToken = await this.jwtService.signAsync(payload, {
|
||||
expiresIn: (this.configService.get<string>('jwt.refreshExpiresIn') ||
|
||||
'7d') as any,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sellerInfo: {
|
||||
id: seller.id,
|
||||
phone: seller.phone,
|
||||
contactName: seller.contactName,
|
||||
email: seller.email,
|
||||
status: seller.status,
|
||||
merchantId: merchant?.id,
|
||||
merchantStatus: merchant?.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user