This commit is contained in:
2026-04-22 00:34:13 +08:00
parent 25db7ecd66
commit 4047f87e8c
36 changed files with 12265 additions and 8193 deletions
+85
View File
@@ -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}`);
}
+54
View File
@@ -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 });
}
+18
View File
@@ -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": {
+263 -28
View File
@@ -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>
+26 -2
View File
@@ -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>
+408
View File
@@ -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>
+41
View File
@@ -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 };
});
+8 -2
View File
@@ -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}`;
}
+1 -1
View File
@@ -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",
+2
View File
@@ -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;
}
}
+2
View File
@@ -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
View File
@@ -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';
+8 -8
View File
@@ -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;
+39
View File
@@ -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;
}
-4
View File
@@ -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';
+2 -2
View File
@@ -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);
}
+11 -14
View File
@@ -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,
},
};
}
}
+40 -23
View File
@@ -17,14 +17,12 @@ CREATE TABLE `users` (
`gender` TINYINT UNSIGNED DEFAULT 0 COMMENT '性别 0-未知 1-男 2-女',
`real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名',
`id_card` VARCHAR(255) DEFAULT NULL COMMENT '身份证号(加密存储)',
`role` ENUM('user','merchant') NOT NULL DEFAULT 'user' COMMENT '角色',
`status` ENUM('active','frozen','deleted') NOT NULL DEFAULT 'active' COMMENT '状态',
`last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`),
KEY `idx_role` (`role`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
@@ -52,7 +50,26 @@ CREATE TABLE `admins` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台管理员表';
-- ============================================================
-- 3. 第三方账号绑定
-- 3. 商家账户
-- ============================================================
CREATE TABLE `sellers` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`phone` VARCHAR(20) NOT NULL COMMENT '手机号',
`password` VARCHAR(255) DEFAULT NULL COMMENT '密码(bcrypt哈希)',
`contact_name` VARCHAR(50) NOT NULL COMMENT '联系人姓名',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`status` ENUM('active','frozen','deleted') NOT NULL DEFAULT 'active' COMMENT '状态',
`last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商家账户表';
-- ============================================================
-- 4. 第三方账号绑定表
-- ============================================================
CREATE TABLE `user_oauth` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -70,11 +87,11 @@ CREATE TABLE `user_oauth` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='第三方账号绑定表';
-- ============================================================
-- 3. 商家表
-- 5. 商家表
-- ============================================================
CREATE TABLE `merchants` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '关联户ID',
`seller_id` BIGINT UNSIGNED NOT NULL COMMENT '关联商家账户ID',
`shop_name` VARCHAR(100) NOT NULL COMMENT '店铺名称',
`logo` VARCHAR(500) DEFAULT '' COMMENT '店铺Logo',
`description` TEXT COMMENT '店铺描述',
@@ -98,14 +115,14 @@ CREATE TABLE `merchants` (
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_id` (`user_id`),
UNIQUE KEY `uk_seller_id` (`seller_id`),
KEY `idx_status` (`status`),
KEY `idx_city` (`city`),
KEY `idx_rating` (`rating`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商家表';
-- ============================================================
-- 4. 房型/房源表
-- 6. 房型/房源表
-- ============================================================
CREATE TABLE `rooms` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -135,7 +152,7 @@ CREATE TABLE `rooms` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房型/房源表';
-- ============================================================
-- 5. 房量房价日历表
-- 7. 房量房价日历表
-- ============================================================
CREATE TABLE `room_calendar` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -154,7 +171,7 @@ CREATE TABLE `room_calendar` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房量房价日历表';
-- ============================================================
-- 6. 房态变更日志表
-- 8. 房态变更日志表
-- ============================================================
CREATE TABLE `room_calendar_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -171,7 +188,7 @@ CREATE TABLE `room_calendar_logs` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房态变更日志表';
-- ============================================================
-- 7. 订单表
-- 9. 订单表
-- ============================================================
CREATE TABLE `orders` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -217,7 +234,7 @@ CREATE TABLE `orders` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表';
-- ============================================================
-- 8. 评价表
-- 10. 评价表
-- ============================================================
CREATE TABLE `reviews` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -243,7 +260,7 @@ CREATE TABLE `reviews` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评价表';
-- ============================================================
-- 9. 收藏表
-- 11. 收藏表
-- ============================================================
CREATE TABLE `favorites` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -256,7 +273,7 @@ CREATE TABLE `favorites` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收藏表';
-- ============================================================
-- 10. 优惠券模板表
-- 12. 优惠券模板表
-- ============================================================
CREATE TABLE `coupons` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -281,7 +298,7 @@ CREATE TABLE `coupons` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='优惠券模板表';
-- ============================================================
-- 11. 用户优惠券表
-- 13. 用户优惠券表
-- ============================================================
CREATE TABLE `user_coupons` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -297,7 +314,7 @@ CREATE TABLE `user_coupons` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户优惠券表';
-- ============================================================
-- 12. 促销活动表
-- 14. 促销活动表
-- ============================================================
CREATE TABLE `promotions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -317,7 +334,7 @@ CREATE TABLE `promotions` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='促销活动表';
-- ============================================================
-- 13. 会员等级表
-- 15. 会员等级表
-- ============================================================
CREATE TABLE `member_levels` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -332,7 +349,7 @@ CREATE TABLE `member_levels` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员等级表';
-- ============================================================
-- 14. 用户会员信息表
-- 16. 用户会员信息表
-- ============================================================
CREATE TABLE `user_members` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -348,7 +365,7 @@ CREATE TABLE `user_members` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户会员信息表';
-- ============================================================
-- 15. 财务结算表
-- 17. 财务结算表
-- ============================================================
CREATE TABLE `settlements` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -368,7 +385,7 @@ CREATE TABLE `settlements` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务结算表';
-- ============================================================
-- 16. 提现记录表
-- 18. 提现记录表
-- ============================================================
CREATE TABLE `withdrawals` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -393,7 +410,7 @@ CREATE TABLE `withdrawals` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提现记录表';
-- ============================================================
-- 17. 消息通知表
-- 19. 消息通知表
-- ============================================================
CREATE TABLE `notifications` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -411,7 +428,7 @@ CREATE TABLE `notifications` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息通知表';
-- ============================================================
-- 18. 广告位表
-- 20. 广告位表
-- ============================================================
CREATE TABLE `advertisements` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -432,7 +449,7 @@ CREATE TABLE `advertisements` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='广告位表';
-- ============================================================
-- 19. 平台配置表
-- 21. 平台配置表
-- ============================================================
CREATE TABLE `platform_configs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -446,7 +463,7 @@ CREATE TABLE `platform_configs` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台配置表';
-- ============================================================
-- 20. 操作日志表
-- 22. 操作日志表
-- ============================================================
CREATE TABLE `operation_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+67 -5
View File
@@ -20,11 +20,73 @@
- 刷新机制: 过期前30分钟可无感刷新
### 角色权限
| 角色 | 可访问路由 | 权限范围 |
|------|------------|----------|
| user | /rooms, /orders, /user, /merchant/apply | 仅自己的数据 |
| merchant | /merchant/* | 仅自己店铺的数据 |
| admin | /admin/*, /auth/* | 全平台数据 |
| 角色 | 认证端点 | 可访问路由 | 权限范围 |
|------|----------|------------|----------|
| user | /auth/* | /rooms, /orders, /user | 仅自己的数据 |
| seller | /seller-auth/* | /merchant/* | 仅自己店铺的数据 |
| admin | /auth/* | /admin/*, /auth/* | 全平台数据 |
### 账户体系
| 账户类型 | 表名 | 说明 |
|----------|------|------|
| C端用户 | users | 小程序用户,支持第三方登录 |
| 商家账户 | sellers | B端商家,独立账户体系 |
| 平台管理员 | admins | 平台运营管理人员 |
### 商家入驻流程
```
小程序「个人中心」→ 点击「商家入驻」→ 跳转商家注册/登录页
→ 商家账号注册(手机号+密码+联系人+邮箱)
→ 注册成功后跳转商家中心
→ 创建店铺(店铺名称、地址、营业执照等)
→ 提交审核 → 平台审核 → 通过后可营业
```
#### 商家注册校验规则
| 字段 | 规则 | 错误提示 |
|------|------|----------|
| phone | 必填,正则 `/^1[3-9]\d{9}$/` | 手机号格式不正确 |
| code | 必填,6位数字 | 请输入6位验证码 |
| contactName | 必填,2-50字 | 请填写联系人姓名 |
| email | 选填,正则邮箱格式 | 邮箱格式不正确 |
| password | 选填,6-20位 | 密码长度为6-20位 |
#### 商家注册后端校验
- 验证码校验(开发环境万能验证码: 123456)
- 手机号唯一性检查(已注册则返回400: 该手机号已注册)
- 密码选填,填写时 bcrypt 加密存储(salt rounds: 10
- 注册成功自动生成 accessToken + refreshToken
- JWT payload 包含: `{ sub, phone, role: 'seller', merchantId }`
#### 商家登录方式
| 方式 | 参数 | 说明 |
|------|------|------|
| 验证码登录 | phone + code | 6位验证码,5分钟有效 |
| 密码登录 | phone + password | 需先设置密码 |
#### 验证码缓存
- 开发环境: 内存Map缓存,5分钟过期
- 生产环境: TODO 替换为 Redis
#### 商家中心页面状态机
| 条件 | 展示内容 |
|------|----------|
| 未登录商家账号 | 引导注册/登录 |
| 已登录但未创建店铺(merchantId为空) | 引导创建店铺 |
| 已登录且merchantId有值 | 店铺管理面板(状态、数据、菜单) |
#### 店铺审核状态展示
| status | 显示 | 可修改 | 功能菜单 | 特殊展示 |
|--------|------|--------|----------|----------|
| pending | 审核中 | 否 | 隐藏 | 提示"审核中,暂不可修改" |
| approved | 营业中 | 是 | 显示 | 数据概览+功能菜单 |
| rejected | 已拒绝 | 是 | 隐藏 | 红色拒绝原因区块 |
| frozen | 已冻结 | 否 | 隐藏 | 提示"联系客服" |
#### 修改店铺重新审核规则
- 审核通过(approved)或拒绝(rejected)状态下修改店铺信息
- 后端自动将 status 重置为 pending,清空 rejectReason
- 商家中心显示"审核中"状态,等待重新审核
---
+10
View File
@@ -8,6 +8,16 @@ INSERT INTO `admins` (`username`, `password`, `name`, `phone`, `role`, `status`)
('admin', '$2b$10$N9qo8uLOickgx2ZMRZoMy.Mrq7Y5g0kOPIl7vJfzOTfJ7qKqOqOAa', '超级管理员', '13800000000', 'super_admin', 'active'),
('operator', '$2b$10$N9qo8uLOickgx2ZMRZoMy.Mrq7Y5g0kOPIl7vJfzOTfJ7qKqOqOAa', '运营人员', '13800000001', 'operator', 'active');
-- 商家账号 (密码: seller123)
INSERT INTO `sellers` (`phone`, `password`, `contact_name`, `email`, `status`) VALUES
('13900000001', '$2b$10$N9qo8uLOickgx2ZMRZoMy.Mrq7Y5g0kOPIl7vJfzOTfJ7qKqOqOAa', '张经理', 'zhang@example.com', 'active'),
('13900000002', '$2b$10$N9qo8uLOickgx2ZMRZoMy.Mrq7Y5g0kOPIl7vJfzOTfJ7qKqOqOAa', '李老板', 'li@example.com', 'active');
-- 商家店铺信息
INSERT INTO `merchants` (`seller_id`, `shop_name`, `phone`, `city`, `address`, `business_license`, `status`, `rating`) VALUES
(1, '温馨民宿', '13900000001', '杭州', '西湖区文三路123号', 'https://example.com/license1.jpg', 'approved', 4.8),
(2, '阳光公寓', '13900000002', '上海', '浦东新区陆家嘴456号', 'https://example.com/license2.jpg', 'approved', 4.5);
-- 会员等级
INSERT INTO `member_levels` (`name`, `level`, `min_points`, `discount`, `benefits`) VALUES
('普通会员', 1, 0, 1.00, '["基础服务"]'),
+219 -8
View File
@@ -12,9 +12,10 @@
| 表名 | 说明 | 核心字段 |
|------|------|----------|
| admins | 平台管理员表 | id, username, name, role, status |
| users | 用户表 | id, phone, nickname, role, status |
| users | 用户表 | id, phone, nickname, status |
| user_oauth | 第三方账号绑定 | user_id, provider, openid |
| merchants | 商家表 | user_id, shop_name, status, rating |
| sellers | 商家账户表 | id, phone, password, contact_name, status |
| merchants | 商家表 | seller_id, shop_name, status, rating |
| rooms | 房源表 | merchant_id, name, type, price, status |
| room_calendar | 房量房价日历 | room_id, date, price, stock, status |
| room_calendar_logs | 房态变更日志 | room_id, operator_id, change_type |
@@ -38,14 +39,14 @@
```
users (1) ──── (n) orders
── (1) ──── (1) merchants ──── (n) rooms ──── (n) room_calendar
│ │
│ └── (n) orders
├── (n) ──── (n) coupons (user_coupons)
── (n) ──── (n) coupons (user_coupons)
└── (n) ──── (n) rooms (favorites)
sellers (1) ──── (1) merchants ──── (n) rooms ──── (n) room_calendar
└── (n) orders
orders (1) ──── (1) reviews
orders (1) ──── (1) settlements
```
@@ -66,6 +67,11 @@ orders (1) ──── (1) settlements
- `frozen` - 冻结
- `deleted` - 已注销
### 商家账户状态 (sellers.status)
- `active` - 正常
- `frozen` - 冻结
- `deleted` - 已注销
### 商家状态 (merchants.status)
- `pending` - 待审核
- `approved` - 已通过
@@ -101,7 +107,7 @@ orders (1) ──── (1) settlements
```sql
-- 高频查询索引
idx_users_phone (phone)
idx_users_role (role)
idx_sellers_phone (phone)
idx_merchants_status (status)
idx_merchants_city (city)
idx_rooms_merchant_id (merchant_id)
@@ -167,3 +173,208 @@ mysql -u root -p rent_platform < database/seeds/001_init_data.sql
4. JSON 字段用于存储数组/对象(如设施列表、活动规则)
5. 所有表都有 created_at 字段,核心表有 updated_at
6. 敏感操作需记录到 operation_logs 表
---
## 商家入驻流程
### 流程概述
```
用户点击商家入驻 → 商家注册/登录页面 → 注册商家账户 → 创建店铺 → 提交审核 → 审核通过 → 开始营业
```
### 账户体系说明
| 账户类型 | 表名 | Token存储 | 路由前缀 |
|----------|------|-----------|----------|
| C端用户 | users | `token` / `userInfo` | /rooms, /orders, /user |
| 商家账户 | sellers | `sellerToken` / `sellerInfo` | /merchant/* |
| 平台管理员 | admins | 管理后台独立存储 | /admin/* |
**注意**: 用户和商家是独立的账户体系,一个用户可以同时是商家。
### 商家注册接口
| 接口 | 路径 | 说明 |
|------|------|------|
| 发送验证码 | POST /api/seller-auth/send-code | 发送商家验证码(6位数字) |
| 商家注册 | POST /api/seller-auth/register | 手机号+验证码注册 |
| 商家登录 | POST /api/seller-auth/login | 手机号+验证码或密码登录 |
| 刷新令牌 | POST /api/seller-auth/refresh | 使用refreshToken刷新 |
| 获取商家信息 | GET /api/seller-auth/profile | 需Bearer Token |
### 商家注册参数
```typescript
interface SellerRegisterParams {
phone: string; // 手机号(必填,正则:/^1[3-9]\d{9}$/
code: string; // 验证码(必填,6位数字)
contactName: string; // 联系人姓名(必填,2-50字)
email?: string; // 邮箱(选填,需验证格式)
password?: string; // 密码(选填,6-20位,便于密码登录)
}
```
### 商家登录参数
```typescript
interface SellerLoginParams {
phone: string; // 手机号(必填)
code?: string; // 验证码(可选,6位数字)
password?: string; // 密码(可选,6-20位)
}
// 注:验证码和密码至少填写一种
```
### 商家注册响应
```typescript
interface SellerLoginResult {
accessToken: string;
refreshToken: string;
sellerInfo: {
id: number;
phone: string;
contactName: string;
email?: string;
status: 'active' | 'frozen' | 'deleted';
merchantId?: number; // 有值表示已创建店铺
merchantStatus?: string; // 店铺状态
};
}
```
### 商家入驻状态判断
| sellerInfo.merchantId | sellerInfo.merchantStatus | 状态说明 |
|-----------------------|---------------------------|----------|
| 无/undefined | - | 未创建店铺,需引导创建 |
| 有值 | pending | 店铺审核中 |
| 有值 | approved | 已通过审核,可营业 |
| 有值 | rejected | 已拒绝,显示原因 |
| 有值 | frozen | 店铺已冻结 |
### 小程序页面路由
| 页面 | 路径 | 说明 |
|------|------|------|
| 个人中心 | /pages/mine/index | 入口:商家中心、商家入驻按钮 |
| 商家注册/登录 | /pages/seller-register/index | 商家账号注册登录 |
| 商家中心 | /pages/merchant/home | 商家管理入口 |
| 创建店铺 | /pages/shop-create/index | 填写店铺信息提交审核 |
| 修改店铺 | /pages/shop-edit/index | 修改店铺信息重新审核 |
### Store设计
```typescript
// useSellerStore (apps/miniapp/src/store/seller.ts)
const sellerToken = ref(uni.getStorageSync('sellerToken') || '');
const sellerInfo = ref<SellerInfo | null>(uni.getStorageSync('sellerInfo') || null);
// 判断方法
isSellerLoggedIn() // 是否登录商家账号
hasMerchant() // 是否已创建店铺
```
---
## 店铺创建功能
### 创建店铺接口
| 接口 | 路径 | 说明 |
|------|------|------|
| 申请创建店铺 | POST /api/merchant/apply | 需商家Token,创建后状态为pending |
| 获取我的店铺 | GET /api/merchant/mine | 需商家Token |
| 更新店铺信息 | PUT /api/merchant/update | 需商家Token,审核通过/拒绝后修改会重置为pending |
### 创建店铺参数 (ApplyMerchantDto)
| 字段 | 类型 | 必填 | 校验规则 |
|------|------|------|----------|
| shopName | string | 是 | 2-100字 |
| phone | string | 是 | 联系电话不能为空 |
| province | string | 否 | 省 |
| city | string | 否 | 市 |
| district | string | 否 | 区 |
| address | string | 否 | 详细地址 |
| businessLicense | string | 是 | 营业执照图片URL |
| licenseNo | string | 否 | 营业执照编号 |
| legalPerson | string | 否 | 法人姓名 |
| description | string | 否 | 店铺描述 |
### 创建店铺流程
```
商家中心 → 点击"创建店铺" → 填写表单 → 提交申请
→ 调用 POST /merchant/apply
→ 更新 sellerInfo.merchantId 和 merchantStatus
→ 跳转商家中心显示审核中状态
```
### 前端 API 模块
**文件**: `apps/miniapp/src/api/merchant.ts`
```typescript
applyMerchant(data) // 申请创建店铺
getMyMerchant() // 获取我的店铺信息
updateMerchant(data) // 更新店铺信息
getMerchantById(id) // 获取商家详情(公开)
```
---
## 店铺审核状态展示
### 状态枚举与显示
| status | 显示文本 | 颜色 | 说明 |
|--------|----------|------|------|
| pending | 审核中 | 橙色 | 等待平台审核,不可修改 |
| approved | 营业中 | 绿色 | 审核通过,可正常营业 |
| rejected | 已拒绝 | 红色 | 审核拒绝,显示原因,可修改重新提交 |
| frozen | 已冻结 | 灰色 | 店铺被冻结,联系客服 |
### 商家中心页面状态展示逻辑
**审核中 (pending)**:
- 显示状态标签"审核中"
- 显示提示"店铺信息审核中,暂不可修改"
- 不显示修改按钮
- 不显示数据概览和功能菜单
**审核通过 (approved)**:
- 显示状态标签"营业中"
- 显示店铺信息卡片(电话、地址等)
- 显示数据概览(订单、房源、收入、评分)
- 显示"修改店铺信息"按钮
- 显示功能菜单(订单管理、房源管理)
**审核拒绝 (rejected)**:
- 显示状态标签"已拒绝"
- 显示拒绝原因区块(红色背景,包含拒绝原因)
- 显示店铺信息卡片
- 显示"修改店铺信息"按钮
- 不显示数据概览和功能菜单
**店铺冻结 (frozen)**:
- 显示状态标签"已冻结"
- 显示提示"店铺已被冻结,请联系平台客服"
- 不显示修改按钮
- 不显示数据概览和功能菜单
### 修改店铺重新审核逻辑
**后端逻辑** (`merchant.service.ts`):
```typescript
async update(id, dto) {
const merchant = await this.findById(id);
// 审核通过或拒绝后修改,重置为pending
if (merchant.status === 'approved' || merchant.status === 'rejected') {
await this.merchantRepo.update(id, { ...dto, status: 'pending', rejectReason: null });
} else {
await this.merchantRepo.update(id, dto);
}
return this.findById(id);
}
```
**修改流程**:
```
点击"修改店铺信息" → 填写表单 → 提交修改
→ 调用 PUT /merchant/update
→ 后端自动将 status 重置为 pending,清空 rejectReason
→ 跳转商家中心显示"审核中"状态
```
### 后续迭代事项
1. **商家管理后台集成**: 商家后台跳转入口
2. **商家实名认证**: 身份证上传、银行卡绑定
3. **图片上传接口**: 营业执照真实上传功能
+3
View File
@@ -22,5 +22,8 @@
"license": "ISC",
"devDependencies": {
"mysql2": "^3.12.0"
},
"pnpm": {
"onlyBuiltDependencies": ["bcrypt"]
}
}
+66 -9
View File
@@ -1,11 +1,5 @@
// ===================== 用户相关 =====================
export enum UserRole {
USER = 'user',
MERCHANT = 'merchant',
ADMIN = 'admin',
}
export enum UserStatus {
ACTIVE = 'active',
FROZEN = 'frozen',
@@ -17,13 +11,33 @@ export interface IUser {
phone: string;
nickname: string;
avatar: string;
gender: number;
realName?: string;
status: UserStatus;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
// ===================== 商家相关 =====================
// ===================== 商家账户相关 =====================
export enum SellerStatus {
ACTIVE = 'active',
FROZEN = 'frozen',
DELETED = 'deleted',
}
export interface ISeller {
id: number;
phone: string;
contactName: string;
email?: string;
status: SellerStatus;
lastLoginAt?: Date;
createdAt: Date;
updatedAt: Date;
}
// ===================== 商家店铺相关 =====================
export enum MerchantStatus {
PENDING = 'pending',
@@ -34,19 +48,44 @@ export enum MerchantStatus {
export interface IMerchant {
id: number;
userId: 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: MerchantStatus;
rejectReason?: string;
deposit: number;
rating: number;
reviewCount: number;
autoConfirm: boolean;
createdAt: Date;
updatedAt: Date;
}
// 申请创建店铺参数
export interface IApplyMerchantParams {
shopName: string;
phone: string;
province?: string;
city?: string;
district?: string;
address?: string;
businessLicense: string;
licenseNo?: string;
legalPerson?: string;
description?: string;
}
// ===================== 房源相关 =====================
export enum RoomStatus {
@@ -237,3 +276,21 @@ export interface ILoginResult {
refreshToken: string;
userInfo: IUser;
}
// ===================== 商家登录结果 =====================
export interface ISellerLoginResult {
accessToken: string;
refreshToken: string;
sellerInfo: ISellerInfo;
}
export interface ISellerInfo {
id: number;
phone: string;
contactName: string;
email?: string;
status: SellerStatus;
merchantId?: number;
merchantStatus?: MerchantStatus;
}
+9618 -8057
View File
File diff suppressed because it is too large Load Diff