feat: 迭代

This commit is contained in:
2026-05-15 19:06:32 +08:00
parent 8c908ea557
commit 848df4c873
22 changed files with 1545 additions and 355 deletions
+2 -1
View File
@@ -51,7 +51,8 @@
"Bash(curl -X GET \"http://localhost:3000/api/admin/finance/reports/weekly\" -H \"Content-Type: application/json\")",
"Bash(xargs grep -l \"room\" -i)",
"Bash(mysql -h localhost -u root -p123456 -D rent -e \"DESCRIBE user_coupons;\")",
"Bash(docker exec *)"
"Bash(docker exec *)",
"Bash(mysql -h localhost -P 3306 -u root -pquan131735 rent_platform -e \"DESC guests;\")"
]
}
}
+319
View File
@@ -0,0 +1,319 @@
/* 商家后台 - 居中卡片布局 */
.merchant-login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fff5f0 0%, #ffe8dc 100%);
position: relative;
overflow: hidden;
padding: 40px 20px;
}
/* 背景装饰 */
.merchant-login-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 0;
}
.bg-decoration {
position: absolute;
width: 100%;
height: 100%;
}
.bg-circle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(254, 202, 87, 0.1) 100%);
animation: bgFloat 25s infinite ease-in-out;
}
.bg-circle.circle-1 {
width: 600px;
height: 600px;
top: -200px;
right: -200px;
animation-delay: 0s;
}
.bg-circle.circle-2 {
width: 450px;
height: 450px;
bottom: -150px;
left: -150px;
animation-delay: 8s;
}
.bg-circle.circle-3 {
width: 350px;
height: 350px;
top: 40%;
left: 50%;
animation-delay: 16s;
}
@keyframes bgFloat {
0%, 100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -30px) scale(1.05);
}
66% {
transform: translate(-30px, 30px) scale(0.95);
}
}
/* 中央登录卡片 */
.merchant-login-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 480px;
background: #fff;
border-radius: 24px;
box-shadow: 0 20px 60px rgba(255, 107, 107, 0.15);
padding: 48px 40px;
animation: cardSlideUp 0.6s ease-out;
}
@keyframes cardSlideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 品牌区域 */
.merchant-brand {
text-align: center;
margin-bottom: 40px;
}
.merchant-brand-icon {
width: 100px;
height: 100px;
background: linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(254, 202, 87, 0.1) 100%);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
border: 2px solid rgba(255, 107, 107, 0.2);
}
.merchant-brand-title {
font-size: 28px;
font-weight: 800;
color: #2d3436;
margin: 0 0 8px;
letter-spacing: 0.5px;
}
.merchant-brand-subtitle {
font-size: 15px;
color: #636e72;
margin: 0;
font-weight: 500;
}
/* 表单区域 */
.merchant-form-section {
margin-bottom: 32px;
}
.merchant-form {
margin: 0;
}
.merchant-input {
height: 50px;
border-radius: 12px;
font-size: 15px;
border: 2px solid #f0f0f0;
transition: all 0.3s;
background: #fafafa;
}
.merchant-input:hover {
border-color: #ff6b6b;
background: #fff;
}
.merchant-input:focus,
.merchant-input.ant-input-focused {
border-color: #ff6b6b;
background: #fff;
box-shadow: 0 0 0 4px rgba(255, 107, 107, 0.1);
}
.merchant-input input {
height: 100%;
background: transparent;
}
.merchant-input-icon {
color: #ff6b6b;
font-size: 18px;
}
.merchant-code-wrapper {
display: flex;
gap: 12px;
}
.merchant-code-input {
flex: 1;
}
.merchant-code-button {
height: 50px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
padding: 0 24px;
white-space: nowrap;
border: 2px solid #f0f0f0;
background: #fafafa;
transition: all 0.3s;
color: #ff6b6b;
}
.merchant-code-button:hover:not(:disabled) {
color: #fff;
background: #ff6b6b;
border-color: #ff6b6b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
}
.merchant-code-button:disabled {
color: #bbb;
background: #f5f5f5;
border-color: #e8e8e8;
cursor: not-allowed;
}
.merchant-login-button {
height: 50px;
border-radius: 12px;
font-size: 16px;
font-weight: 700;
background: linear-gradient(135deg, #ff6b6b 0%, #feca57 100%);
border: none;
box-shadow: 0 6px 20px rgba(255, 107, 107, 0.35);
transition: all 0.3s;
letter-spacing: 0.5px;
}
.merchant-login-button:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(255, 107, 107, 0.45);
}
.merchant-login-button:active {
transform: translateY(-1px);
}
.merchant-dev-tip {
text-align: center;
margin-top: 16px;
}
.merchant-dev-tip p {
font-size: 13px;
color: #999;
margin: 0;
padding: 10px 18px;
background: #fff5f0;
border-radius: 10px;
display: inline-block;
border: 1px solid #ffe8dc;
}
/* 特性展示 */
.merchant-features {
display: flex;
justify-content: space-around;
padding: 24px 0;
margin-bottom: 24px;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
}
.merchant-feature-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.merchant-feature-icon {
font-size: 32px;
}
.merchant-feature-text {
font-size: 13px;
color: #636e72;
font-weight: 500;
}
/* 底部 */
.merchant-footer {
text-align: center;
}
.merchant-footer p {
font-size: 13px;
color: #999;
margin: 0;
}
/* 响应式设计 */
@media (max-width: 640px) {
.merchant-login-card {
padding: 36px 28px;
}
.merchant-brand-title {
font-size: 24px;
}
.merchant-code-wrapper {
flex-direction: column;
}
.merchant-code-button {
width: 100%;
}
.merchant-features {
flex-direction: column;
gap: 16px;
}
.bg-circle.circle-1 {
width: 400px;
height: 400px;
}
.bg-circle.circle-2 {
width: 300px;
height: 300px;
}
.bg-circle.circle-3 {
width: 250px;
height: 250px;
}
}
+100 -27
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, message } from 'antd';
import { MobileOutlined, SafetyOutlined } from '@ant-design/icons';
import { Form, Input, Button, message } from 'antd';
import { MobileOutlined, SafetyOutlined, ShopOutlined } from '@ant-design/icons';
import { loginByCode, sendSellerCode } from '@/api/auth';
import { useAuthStore } from '@/store/auth';
import './Login.css';
const Login: React.FC = () => {
const [loading, setLoading] = useState(false);
@@ -55,34 +56,106 @@ const Login: React.FC = () => {
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f0f2f5' }}>
<Card style={{ width: 400 }} title={<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 600 }}></div>}>
<Form form={form} onFinish={onFinish} size="large">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '手机号格式不正确' }]}>
<Input prefix={<MobileOutlined />} placeholder="手机号" maxLength={11} />
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Input
prefix={<SafetyOutlined />}
placeholder="验证码"
maxLength={6}
addonAfter={
<Button type="link" disabled={countdown > 0} onClick={handleSendCode} style={{ padding: 0, height: 'auto' }}>
<div className="merchant-login-container">
{/* 顶部装饰背景 */}
<div className="merchant-login-bg">
<div className="bg-decoration">
<div className="bg-circle circle-1"></div>
<div className="bg-circle circle-2"></div>
<div className="bg-circle circle-3"></div>
</div>
</div>
{/* 中央登录卡片 */}
<div className="merchant-login-card">
{/* 品牌区域 */}
<div className="merchant-brand">
<div className="merchant-brand-icon">
<ShopOutlined style={{ fontSize: 56, color: '#ff6b6b' }} />
</div>
<h1 className="merchant-brand-title"></h1>
<p className="merchant-brand-subtitle"></p>
</div>
{/* 登录表单 */}
<div className="merchant-form-section">
<Form form={form} onFinish={onFinish} size="large" className="merchant-form">
<Form.Item
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '手机号格式不正确' }
]}
>
<Input
prefix={<MobileOutlined className="merchant-input-icon" />}
placeholder="手机号"
maxLength={11}
className="merchant-input"
/>
</Form.Item>
<Form.Item
name="code"
rules={[{ required: true, message: '请输入验证码' }]}
>
<div className="merchant-code-wrapper">
<Input
prefix={<SafetyOutlined className="merchant-input-icon" />}
placeholder="验证码"
maxLength={6}
className="merchant-input merchant-code-input"
/>
<Button
type="default"
disabled={countdown > 0}
onClick={handleSendCode}
className="merchant-code-button"
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
开发环境验证码: 123456
</div>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="merchant-login-button"
>
</Button>
</Form.Item>
<div className="merchant-dev-tip">
<p>开发环境验证码: 123456</p>
</div>
</Form>
</div>
{/* 特性展示 */}
<div className="merchant-features">
<div className="merchant-feature-item">
<div className="merchant-feature-icon">📦</div>
<div className="merchant-feature-text"></div>
</div>
</Form>
</Card>
<div className="merchant-feature-item">
<div className="merchant-feature-icon">📋</div>
<div className="merchant-feature-text"></div>
</div>
<div className="merchant-feature-item">
<div className="merchant-feature-icon">💰</div>
<div className="merchant-feature-text"></div>
</div>
</div>
{/* 底部 */}
<div className="merchant-footer">
<p>© 2026 . All rights reserved.</p>
</div>
</div>
</div>
);
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { request } from '@/utils/request';
export function getRoomCalendar(roomId: number, startDate: string, endDate: string) {
return request({
url: `/seller/room-calendar/${roomId}`,
url: `/api/merchant/room-calendar/${roomId}`,
method: 'GET',
data: { startDate, endDate },
useSellerToken: true,
+4
View File
@@ -16,6 +16,10 @@ export function loginByWechat(code: string, nickname?: string, avatar?: string)
return post('/api/app/auth/login/wechat', { code, nickname, avatar });
}
export function loginByWechatPhone(code: string, encryptedData: string, iv: string) {
return post('/api/app/auth/login/wechat-phone', { code, encryptedData, iv });
}
export function register(data: { phone: string; code: string; password?: string; nickname?: string }) {
return post('/api/app/auth/register', data);
}
+3 -3
View File
@@ -21,15 +21,15 @@ export function getOrderList(params: { page?: number; pageSize?: number; status?
}
export function getOrderDetail(id: number) {
return get(`/api/user/orders/${id}`);
return get(`/api/app/orders/${id}`);
}
export function cancelOrder(id: number, reason: string) {
return put(`/api/user/orders/${id}/cancel`, { reason });
return put(`/api/app/orders/${id}/cancel`, { reason });
}
export function refundOrder(id: number, reason: string) {
return put(`/api/user/orders/${id}/refund`, { reason });
return put(`/api/app/orders/${id}/refund`, { reason });
}
export function payOrder(orderId: number, paymentMethod: 'wechat' | 'alipay' | 'balance' = 'wechat') {
+2 -2
View File
@@ -20,9 +20,9 @@ export function getRoomList(params: {
}
export function getRoomDetail(id: number) {
return get(`/api/public/rooms/${id}`);
return get(`/api/app/rooms/${id}`);
}
export function getRoomCalendar(id: number, startDate: string, endDate: string) {
return get(`/api/public/rooms/${id}/calendar`, { startDate, endDate });
return get(`/api/app/rooms/${id}/calendar`, { startDate, endDate });
}
+415 -201
View File
@@ -15,18 +15,34 @@
<text class="app-slogan">舒适住宿温馨服务</text>
</view>
<!-- 微信小程序模式微信授权登录 -->
<view v-if="isWechatMiniProgram" class="form-section">
<!-- 登录选项区域 -->
<view class="form-section">
<text class="form-title">欢迎使用</text>
<text class="form-subtitle">使用微信授权快速登录</text>
<text class="form-subtitle">选择登录方式</text>
<!-- 微信授权登录按钮 -->
<button class="wechat-login-button" @tap="handleWechatLogin">
<u-icon name="weixin-fill" :size="24" color="#fff" style="margin-right: 12rpx;" />
微信授权登录
<!-- 一键登录按钮微信获取手机号 -->
<button
v-if="isWechatMiniProgram"
class="quick-login-button"
open-type="getPhoneNumber"
@getphonenumber="handleQuickLogin"
>
<u-icon name="phone-fill" :size="24" color="#fff" style="margin-right: 12rpx;" />
一键登录
</button>
<!-- 协议 -->
<!-- 手机号安全登录按钮 -->
<button class="phone-login-button" @tap="handleShowPhoneModal">
<u-icon name="lock-fill" :size="24" color="#fff" style="margin-right: 12rpx;" />
手机号安全登录
</button>
<!-- 暂时不登录文字按钮 -->
<view class="skip-login-text" @tap="handleSkipLogin">
暂时不登录
</view>
<!-- 协议勾选 -->
<view class="agreement-section">
<view class="checkbox-wrapper" @tap="toggleAgreement">
<view class="checkbox" :class="{ checked: agreed }">
@@ -35,86 +51,93 @@
</view>
</view>
<text class="agreement-text">
登录即表示同意
<text class="link">用户协议</text>
我已阅读并同意
<text class="link" @tap.stop="handleViewAgreement('user')">用户协议</text>
<text class="link">隐私政策</text>
<text class="link" @tap.stop="handleViewAgreement('privacy')">隐私政策</text>
</text>
</view>
</view>
<text class="tip-text">首次登录将自动创建账号</text>
</view>
<!-- 非小程序模式手机号登录 -->
<view v-else class="form-section">
<text class="form-title">欢迎登录</text>
<!-- 手机号输入 -->
<view class="input-group">
<view class="input-wrapper">
<text class="input-prefix">+86</text>
<input
v-model="phone"
type="number"
maxlength="11"
placeholder="请输入手机号"
class="input"
placeholder-class="placeholder"
/>
<!-- 协议确认弹窗 -->
<view v-if="showAgreementModal" class="modal-overlay" @tap="closeAgreementModal">
<view class="modal-content" @tap.stop>
<view class="modal-header">
<text class="modal-title">服务协议及隐私保护</text>
</view>
</view>
<!-- 验证码输入 -->
<view class="input-group">
<view class="input-wrapper">
<input
v-model="code"
type="number"
maxlength="6"
placeholder="请输入验证码"
class="input"
placeholder-class="placeholder"
/>
<text
class="code-btn"
:class="{ disabled: countdown > 0 }"
@tap="handleSendCode"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
<view class="modal-body">
<text class="modal-text">
为了更好的保障您的合法权益请您阅读并同意以下协议
<text class="link" @tap.stop="handleViewAgreement('user')">用户协议</text>
<text class="link" @tap.stop="handleViewAgreement('privacy')">隐私政策</text>
</text>
</view>
<view class="modal-footer">
<button class="modal-button cancel" @tap="closeAgreementModal">取消</button>
<button class="modal-button confirm" @tap="confirmAgreement">确定</button>
</view>
</view>
</view>
<!-- 登录按钮 -->
<button class="login-button" @tap="handlePhoneLogin">登录</button>
<!-- 协议 -->
<view class="agreement-section">
<view class="checkbox-wrapper" @tap="toggleAgreement">
<view class="checkbox" :class="{ checked: agreed }">
<view v-if="agreed" class="check-icon">
<u-icon name="checkmark" :size="16" color="#fff" />
<!-- 手机号登录弹窗 -->
<view v-if="showPhoneModal" class="modal-overlay" @tap="closePhoneModal">
<view class="modal-content phone-modal" @tap.stop>
<view class="modal-header">
<text class="modal-title">手机号安全登录</text>
<view class="modal-close" @tap="closePhoneModal">
<u-icon name="close" :size="20" color="#999" />
</view>
</view>
<view class="modal-body">
<!-- 手机号输入 -->
<view class="input-group">
<view class="input-wrapper">
<text class="input-prefix">+86</text>
<input
v-model="phone"
type="number"
maxlength="11"
placeholder="请输入手机号"
class="input"
placeholder-class="placeholder"
/>
</view>
</view>
<text class="agreement-text">
登录即表示同意
<text class="link">用户协议</text>
<text class="link">隐私政策</text>
</text>
<!-- 验证码输入 -->
<view class="input-group">
<view class="input-wrapper">
<input
v-model="code"
type="number"
maxlength="6"
placeholder="请输入验证码"
class="input"
placeholder-class="placeholder"
/>
<text
class="code-btn"
:class="{ disabled: countdown > 0 }"
@tap="handleSendCode"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text>
</view>
</view>
<!-- 登录按钮 -->
<button class="modal-login-button" @tap="handlePhoneLogin">登录</button>
</view>
</view>
<!-- 提示 -->
<text class="tip-text">未注册手机号验证后将自动注册</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { sendCode, loginByPhone, loginByWechat } from '@/api/user/auth';
import { sendCode, loginByPhone, loginByWechatPhone } from '@/api/user/auth';
import { useUserStore } from '@/store/user';
const phone = ref('');
@@ -122,9 +145,12 @@ const code = ref('');
const countdown = ref(0);
const agreed = ref(false);
const isWechatMiniProgram = ref(false);
const showAgreementModal = ref(false);
const showPhoneModal = ref(false);
const userStore = useUserStore();
let timer: ReturnType<typeof setInterval> | null = null;
let pendingAction: (() => void) | null = null;
onMounted(() => {
// 判断运行环境
@@ -136,66 +162,107 @@ function toggleAgreement() {
agreed.value = !agreed.value;
}
// 微信授权登录
async function handleWechatLogin() {
// 检查协议是否同意
function checkAgreement(action: () => void) {
if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' });
pendingAction = action;
showAgreementModal.value = true;
return false;
}
return true;
}
// 关闭协议弹窗
function closeAgreementModal() {
showAgreementModal.value = false;
pendingAction = null;
}
// 确认协议
function confirmAgreement() {
agreed.value = true;
showAgreementModal.value = false;
if (pendingAction) {
pendingAction();
pendingAction = null;
}
}
// 一键登录(微信获取手机号)
async function handleQuickLogin(e: any) {
const action = async () => {
try {
const { code, encryptedData, iv } = e.detail;
if (!code) {
uni.showToast({ title: '获取手机号失败', icon: 'none' });
return;
}
uni.showLoading({ title: '登录中...' });
// 调用后端接口,使用微信手机号登录
const res = await loginByWechatPhone(code, encryptedData, iv);
userStore.setLogin(res.data);
uni.hideLoading();
uni.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' });
}, 500);
} catch (error: any) {
uni.hideLoading();
console.error('一键登录失败', error);
uni.showToast({
title: error.message || '登录失败,请重试',
icon: 'none'
});
}
};
if (!checkAgreement(action)) {
return;
}
try {
uni.showLoading({ title: '登录中...' });
await action();
}
// 1. 调用 wx.login 获取 code
const loginRes = await new Promise<any>((resolve, reject) => {
uni.login({
provider: 'weixin',
success: resolve,
fail: reject,
});
});
// 显示手机号登录弹窗
function handleShowPhoneModal() {
const action = () => {
showPhoneModal.value = true;
};
if (!loginRes.code) {
throw new Error('获取微信code失败');
}
// 2. 获取用户信息(可选)
let userInfo: any = {};
try {
const userInfoRes = await new Promise<any>((resolve, reject) => {
uni.getUserProfile({
desc: '用于完善用户资料',
success: resolve,
fail: reject,
});
});
userInfo = userInfoRes.userInfo || {};
} catch (e) {
console.log('用户取消授权或获取用户信息失败', e);
}
// 3. 调用后端接口登录
const res = await loginByWechat(
loginRes.code,
userInfo.nickName,
userInfo.avatarUrl
);
userStore.setLogin(res.data);
uni.hideLoading();
uni.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' });
}, 500);
} catch (e: any) {
uni.hideLoading();
console.error('微信登录失败', e);
uni.showToast({
title: e.message || '登录失败,请重试',
icon: 'none'
});
if (!checkAgreement(action)) {
return;
}
action();
}
// 关闭手机号登录弹窗
function closePhoneModal() {
showPhoneModal.value = false;
phone.value = '';
code.value = '';
if (timer) {
clearInterval(timer);
timer = null;
countdown.value = 0;
}
}
// 暂时不登录
function handleSkipLogin() {
uni.switchTab({ url: '/pages/index/index' });
}
// 查看协议
function handleViewAgreement(type: 'user' | 'privacy') {
uni.navigateTo({
url: `/pages/agreement/index?type=${type}`
});
}
// 发送验证码
@@ -225,11 +292,6 @@ async function handleSendCode() {
// 手机号登录
async function handlePhoneLogin() {
if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' });
return;
}
if (!phone.value || !code.value) {
uni.showToast({ title: '请填写完整信息', icon: 'none' });
return;
@@ -246,13 +308,17 @@ async function handlePhoneLogin() {
}
try {
uni.showLoading({ title: '登录中...' });
const res = await loginByPhone(phone.value, code.value);
userStore.setLogin(res.data);
uni.hideLoading();
uni.showToast({ title: '登录成功', icon: 'success' });
closePhoneModal();
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' });
}, 500);
} catch (e) {
uni.hideLoading();
console.error(e);
}
}
@@ -359,9 +425,229 @@ async function handlePhoneLogin() {
}
}
// 登录按钮样式
.quick-login-button,
.phone-login-button {
width: 100%;
height: 96rpx;
line-height: 96rpx;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 600;
border: none;
margin-bottom: 24rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
&:active {
transform: translateY(2rpx);
}
}
.quick-login-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
&:active {
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
}
}
.phone-login-button {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(245, 87, 108, 0.3);
&:active {
box-shadow: 0 4rpx 16rpx rgba(245, 87, 108, 0.4);
}
}
// 暂时不登录文字按钮
.skip-login-text {
text-align: center;
font-size: 28rpx;
color: #999;
padding: 24rpx 0;
margin-bottom: 16rpx;
transition: color 0.2s;
&:active {
color: #667eea;
}
}
// 协议
.agreement-section {
margin-bottom: 0;
}
.checkbox-wrapper {
display: flex;
align-items: flex-start;
gap: 12rpx;
.checkbox {
width: 32rpx;
height: 32rpx;
border: 2rpx solid #ddd;
border-radius: 6rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2rpx;
transition: all 0.2s;
&.checked {
background: #667eea;
border-color: #667eea;
.check-icon {
font-size: 20rpx;
color: #fff;
font-weight: 700;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.agreement-text {
flex: 1;
font-size: 24rpx;
color: #666;
line-height: 1.6;
.link {
color: #667eea;
}
}
}
// 弹窗遮罩
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 0 48rpx;
}
// 弹窗内容
.modal-content {
background: #fff;
border-radius: 24rpx;
width: 100%;
max-width: 600rpx;
overflow: hidden;
animation: modalSlideUp 0.3s ease-out;
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(50rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 40rpx 32rpx 24rpx;
position: relative;
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #222;
display: block;
text-align: center;
}
.modal-close {
position: absolute;
top: 32rpx;
right: 32rpx;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
.modal-body {
padding: 0 32rpx 32rpx;
.modal-text {
font-size: 28rpx;
color: #666;
line-height: 1.8;
display: block;
.link {
color: #667eea;
}
}
}
.modal-footer {
display: flex;
border-top: 1rpx solid #f0f0f0;
.modal-button {
flex: 1;
height: 96rpx;
line-height: 96rpx;
font-size: 30rpx;
font-weight: 600;
border: none;
background: transparent;
transition: all 0.2s;
&.cancel {
color: #999;
border-right: 1rpx solid #f0f0f0;
&:active {
background: #f7f8fa;
}
}
&.confirm {
color: #667eea;
&:active {
background: rgba(102, 126, 234, 0.1);
}
}
}
}
// 手机号登录弹窗
.phone-modal {
.modal-body {
padding: 24rpx 32rpx 40rpx;
}
}
// 输入框组
.input-group {
margin-bottom: 32rpx;
margin-bottom: 24rpx;
}
.input-wrapper {
@@ -420,9 +706,8 @@ async function handlePhoneLogin() {
}
}
// 登录按钮
.login-button,
.wechat-login-button {
// 弹窗内登录按钮
.modal-login-button {
width: 100%;
height: 96rpx;
line-height: 96rpx;
@@ -432,84 +717,13 @@ async function handlePhoneLogin() {
font-size: 32rpx;
font-weight: 600;
border: none;
margin: 48rpx 0 32rpx;
margin-top: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
&:active {
transform: translateY(2rpx);
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
}
}
.wechat-login-button {
background: linear-gradient(135deg, #09bb07 0%, #07c160 100%);
box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
&:active {
box-shadow: 0 4rpx 16rpx rgba(7, 193, 96, 0.4);
}
}
// 协议
.agreement-section {
margin-bottom: 24rpx;
}
.checkbox-wrapper {
display: flex;
align-items: flex-start;
gap: 12rpx;
.checkbox {
width: 32rpx;
height: 32rpx;
border: 2rpx solid #ddd;
border-radius: 6rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2rpx;
transition: all 0.2s;
&.checked {
background: #667eea;
border-color: #667eea;
.check-icon {
font-size: 20rpx;
color: #fff;
font-weight: 700;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.agreement-text {
flex: 1;
font-size: 24rpx;
color: #666;
line-height: 1.6;
.link {
color: #667eea;
}
}
}
// 提示文字
.tip-text {
display: block;
font-size: 24rpx;
color: #999;
text-align: center;
line-height: 1.5;
}
</style>
@@ -37,7 +37,7 @@
:color="i <= Math.floor(merchantData.rating || 0) ? '#FFB800' : '#E5E5E5'"
/>
</view>
<text class="rating-score">{{ merchantData.rating?.toFixed(1) || '暂无' }}</text>
<text class="rating-score">{{ formatRating(merchantData.rating) }}</text>
<text class="rating-count">({{ merchantData.reviewCount || 0 }}条评价)</text>
</view>
</view>
@@ -430,6 +430,13 @@ function transformReviewData(review: Review): ReviewCardData {
};
}
// 格式化评分
function formatRating(rating: any): string {
const num = Number(rating);
if (isNaN(num) || num === 0) return '暂无';
return num.toFixed(1);
}
// 格式化评价日期
function formatReviewDate(dateStr: string): string {
const date = new Date(dateStr);
@@ -563,7 +570,12 @@ function handleOpenMap() {
}
function handleRoomClick(roomId: number) {
uni.navigateTo({ url: `/pages/room-detail/index?id=${roomId}` });
const queryParams = [
`id=${roomId}`,
`checkIn=${bookingParams.checkIn}`,
`checkOut=${bookingParams.checkOut}`,
].join('&');
uni.navigateTo({ url: `/pages/room-detail/index?${queryParams}` });
}
function handleBookRoom(room: RoomCardData) {
+49 -15
View File
@@ -10,7 +10,6 @@
<text class="room-name">{{ room.name }}</text>
<tag-list v-if="roomTags.length" :tags="roomTags" type="primary" size="small" />
<view class="room-price-row">
<price-tag :price="room.price" size="medium" unit="/晚" />
<text class="room-stock-tip" v-if="maxRoomCount <= 3">仅剩{{ maxRoomCount }}</text>
</view>
</view>
@@ -96,7 +95,7 @@
<!-- 价格明细 -->
<view class="price-detail">
<view class="price-row">
<text class="price-label">房费 (¥{{ room?.price }} × {{ nightCount }} × {{ roomCount }})</text>
<text class="price-label">房费 ({{ nightCount }} × {{ roomCount }})</text>
<text class="price-value">¥{{ roomAmount }}</text>
</view>
@@ -278,7 +277,6 @@ import { createOrder } from '@/api/user/order';
import { getAvailableCoupons } from '@/api/coupon';
import { getGuestList } from '@/api/guest';
import RoomCalendarPicker from '@/components/RoomCalendarPicker.vue';
import PriceTag from '@/components/business/PriceTag.vue';
import TagList from '@/components/business/TagList.vue';
const roomId = ref(0);
@@ -305,6 +303,7 @@ const showGuestPicker = ref(false);
// 房态数据:记录每日剩余库存
const calendarStock = ref<Map<string, number>>(new Map());
const calendarPrice = ref<Map<string, number>>(new Map());
const typeLabels: Record<string, string> = {
hotel: '酒店',
@@ -380,13 +379,43 @@ const maxGuestCount = computed(() => {
// 房费总额
const roomAmount = computed(() => {
if (!room.value) return 0;
return (room.value.price * nightCount.value * roomCount.value).toFixed(2);
if (!room.value || !checkInDate.value || !checkOutDate.value) return 0;
// 如果有日历价格数据,使用日历价格计算
if (calendarPrice.value.size > 0) {
const checkIn = new Date(checkInDate.value);
const checkOut = new Date(checkOutDate.value);
let totalPrice = 0;
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const price = Number(calendarPrice.value.get(dateStr) || room.value.price || 0);
totalPrice += price;
}
return (totalPrice * roomCount.value).toFixed(2);
}
// 否则使用基础价格
return (Number(room.value.price || 0) * nightCount.value * roomCount.value).toFixed(2);
});
// 平台服务费(5%
const serviceFee = computed(() => {
const amount = parseFloat(roomAmount.value);
return (amount * 0.05).toFixed(2);
});
// 订单总额(房费 + 服务费)
const totalAmount = computed(() => {
const amount = parseFloat(roomAmount.value);
const fee = parseFloat(serviceFee.value);
return (amount + fee).toFixed(2);
});
// 优惠券折扣金额
const couponDiscount = computed(() => {
if (!selectedCoupon.value) return 0;
if (!selectedCoupon.value) return '0';
const coupon = selectedCoupon.value.coupon;
const amount = parseFloat(roomAmount.value);
@@ -395,14 +424,14 @@ const couponDiscount = computed(() => {
} else if (coupon.type === 'percent') {
return (amount * (1 - coupon.value / 100)).toFixed(2);
}
return 0;
return '0';
});
// 最终支付金额
// 最终支付金额(订单总额 - 优惠券)
const finalPrice = computed(() => {
const amount = parseFloat(roomAmount.value);
const total = parseFloat(totalAmount.value);
const discount = parseFloat(couponDiscount.value);
return Math.max(0, amount - discount).toFixed(2);
return Math.max(0, total - discount).toFixed(2);
});
// 可用优惠券数量
@@ -461,11 +490,14 @@ async function fetchCalendarStock() {
try {
const res = await getRoomCalendar(roomId.value, checkInDate.value, checkOutDate.value);
const data = res.data || [];
const map = new Map<string, number>();
const stockMap = new Map<string, number>();
const priceMap = new Map<string, number>();
for (const item of data) {
map.set(item.date, (item.stock || 0) - (item.sold || 0));
stockMap.set(item.date, (item.stock || 0) - (item.sold || 0));
priceMap.set(item.date, Number(item.price || room.value?.price || 0));
}
calendarStock.value = map;
calendarStock.value = stockMap;
calendarPrice.value = priceMap;
} catch (e) {
console.error(e);
}
@@ -486,10 +518,12 @@ async function fetchAvailableCoupons() {
async function fetchGuestList() {
try {
const res = await getGuestList();
guestList.value = res.data || [];
console.log('常住人列表数据:', res.data);
guestList.value = res.data?.data || [];
// 如果有默认常住人,自动填充
const defaultGuest = guestList.value.find((g: any) => g.isDefault);
if (defaultGuest && !contactName.value) {
console.log('默认常住人:', defaultGuest);
fillGuestInfo(defaultGuest);
}
} catch (e) {
@@ -717,7 +751,7 @@ function manageGuests() {
.room-price-row {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
}
.room-stock-tip {
+44 -34
View File
@@ -42,7 +42,7 @@
<view class="title-row">
<text class="room-title">{{ roomData.name }}</text>
<view class="price-badge">
<text class="price-amount">¥{{ roomData.price }}</text>
<text class="price-amount">¥{{ calculatedPrice > 0 ? calculatedPrice.toFixed(2) : roomData.price }}</text>
<text class="price-label">/</text>
</view>
</view>
@@ -77,9 +77,6 @@
:key="index"
class="highlight-item"
>
<view class="highlight-icon-wrapper">
<u-icon :name="highlight.icon" :size="32" color="#667eea" />
</view>
<text class="highlight-text">{{ highlight.text }}</text>
</view>
</view>
@@ -94,9 +91,6 @@
:key="index"
class="facility-chip"
>
<view class="facility-icon">
<u-icon :name="getFacilityIcon(facility)" :size="20" color="#667eea" />
</view>
<text class="facility-text">{{ facility }}</text>
</view>
</view>
@@ -185,7 +179,7 @@
<view class="booking-bar">
<view class="booking-left">
<view class="booking-price">
<text class="booking-amount">¥{{ roomData.price }}</text>
<text class="booking-amount">¥{{ calculatedPrice > 0 ? calculatedPrice.toFixed(2) : roomData.price }}</text>
<text class="booking-unit">/</text>
</view>
<text v-if="stockText" class="booking-stock">{{ stockText }}</text>
@@ -223,32 +217,14 @@ const ROOM_TYPE_LABELS: Record<string, string> = {
hostel: '青旅床位',
};
// 设施图标映射
const FACILITY_ICONS: Record<string, string> = {
'WiFi': 'wifi',
'空调': 'snow',
'热水': 'droplet',
'电视': 'play-circle',
'冰箱': 'grid',
'洗衣机': 'reload',
'停车场': 'car',
'电梯': 'arrow-up-circle',
'厨房': 'cut',
'阳台': 'flower',
'泳池': 'water',
'健身房': 'heart',
'暖气': 'fire',
'独立卫浴': 'droplet',
'吹风机': 'wind',
'衣柜': 'grid',
'书桌': 'edit-pen',
};
// 房间数据
const roomId = ref(0);
const roomData = ref<Partial<Room>>({});
const todayStock = ref(0);
const currentImageIndex = ref(0);
const checkInDate = ref('');
const checkOutDate = ref('');
const calculatedPrice = ref(0);
// UI状态
const showAllFacilities = ref(false);
@@ -319,17 +295,22 @@ const roomHighlights = computed(() => {
return highlights;
});
// 获取设施图标
function getFacilityIcon(facility: string): string {
return FACILITY_ICONS[facility] || '✓';
}
// 初始化
function initializeFromUrl() {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1] as any;
const options = currentPage.options || currentPage.$page?.options || {};
roomId.value = Number(options.id) || 0;
// 如果没有传入日期,使用当天日期
if (options.checkIn) {
checkInDate.value = options.checkIn;
} else {
const today = new Date();
checkInDate.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
}
checkOutDate.value = options.checkOut || '';
}
// 获取房间详情
@@ -339,12 +320,41 @@ async function fetchRoomDetail() {
try {
const response = await getRoomDetail(roomId.value);
roomData.value = response.data || {};
// 如果有入住日期,计算日历价格
if (checkInDate.value) {
await calculatePriceFromCalendar();
}
} catch (error) {
console.error('获取房间详情失败:', error);
uni.showToast({ title: '加载失败,请重试', icon: 'none' });
}
}
// 根据日历计算价格
async function calculatePriceFromCalendar() {
if (!roomId.value || !checkInDate.value) return;
try {
// 如果没有退房日期,使用入住日期的第二天
const checkOut = checkOutDate.value || (() => {
const date = new Date(checkInDate.value);
date.setDate(date.getDate() + 1);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
})();
const response = await getRoomCalendar(roomId.value, checkInDate.value, checkOut);
const calendarData = response.data || [];
if (calendarData.length > 0) {
// 使用入住日期当天的价格
calculatedPrice.value = Number(calendarData[0].price || 0);
}
} catch (error) {
console.error('计算价格失败:', error);
}
}
// 获取今日库存
async function fetchTodayStock() {
if (!roomId.value) return;
+3 -31
View File
@@ -133,9 +133,6 @@
:class="['facility-item', { selected: form.facilities.includes(f) }]"
@tap="toggleFacility(f)"
>
<view class="facility-icon">
<u-icon :name="getFacilityIcon(f)" :size="40" :color="form.facilities.includes(f) ? '#4263eb' : '#495057'" />
</view>
<text class="facility-name">{{ f }}</text>
</view>
</view>
@@ -212,24 +209,6 @@ const form = ref({
description: '',
});
function getFacilityIcon(facility: string): string {
const iconMap: Record<string, string> = {
'WiFi': 'wifi',
'空调': 'snow',
'热水': 'droplet',
'电视': 'play-circle',
'冰箱': 'grid',
'洗衣机': 'reload',
'停车场': 'car',
'电梯': 'arrow-up-circle',
'厨房': 'cut',
'阳台': 'flower',
'泳池': 'water',
'健身房': 'heart',
};
return iconMap[facility] || 'checkmark';
}
onMounted(() => {
const pages = getCurrentPages();
const page = pages[pages.length - 1] as any;
@@ -651,11 +630,10 @@ async function handleSubmit() {
.facility-item {
background: #f8f9fa;
border-radius: 12rpx;
padding: 20rpx 16rpx;
padding: 24rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
justify-content: center;
border: 2rpx solid #e9ecef;
transition: all 0.2s;
@@ -674,14 +652,8 @@ async function handleSubmit() {
}
}
.facility-icon {
display: flex;
align-items: center;
justify-content: center;
}
.facility-name {
font-size: 24rpx;
font-size: 26rpx;
color: #495057;
}
+267
View File
@@ -0,0 +1,267 @@
.login-container {
display: flex;
min-height: 100vh;
background: #0a0e27;
}
/* 左侧装饰区域 - 深色科技风 */
.login-left {
flex: 1;
background: linear-gradient(135deg, #1a1f3a 0%, #0a0e27 100%);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.login-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.decoration-circle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
animation: float 20s infinite ease-in-out;
border: 1px solid rgba(102, 126, 234, 0.2);
}
.decoration-circle.circle-1 {
width: 500px;
height: 500px;
top: -150px;
right: -150px;
animation-delay: 0s;
}
.decoration-circle.circle-2 {
width: 350px;
height: 350px;
bottom: -100px;
left: -100px;
animation-delay: 5s;
}
.decoration-circle.circle-3 {
width: 250px;
height: 250px;
top: 50%;
left: 15%;
animation-delay: 10s;
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-40px) rotate(180deg);
}
}
.login-brand {
position: relative;
z-index: 1;
text-align: center;
color: #fff;
padding: 40px;
}
.brand-icon {
width: 140px;
height: 140px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
backdrop-filter: blur(20px);
border-radius: 32px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 40px;
box-shadow: 0 12px 48px rgba(102, 126, 234, 0.3);
border: 2px solid rgba(102, 126, 234, 0.3);
}
.brand-title {
font-size: 56px;
font-weight: 800;
margin: 0 0 16px;
color: #fff;
letter-spacing: 3px;
text-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
}
.brand-subtitle {
font-size: 22px;
margin: 0 0 80px;
color: rgba(255, 255, 255, 0.85);
font-weight: 600;
letter-spacing: 1px;
}
.brand-features {
display: flex;
gap: 50px;
justify-content: center;
margin-top: 80px;
}
.feature-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.feature-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.25) 0%, rgba(118, 75, 162, 0.25) 100%);
backdrop-filter: blur(10px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
color: #667eea;
border: 2px solid rgba(102, 126, 234, 0.4);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
}
.feature-text {
font-size: 17px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
/* 右侧登录表单 */
.login-right {
width: 520px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
padding: 50px;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.15);
}
.login-form-wrapper {
width: 100%;
max-width: 420px;
}
.login-header {
margin-bottom: 56px;
text-align: center;
}
.login-title {
font-size: 36px;
font-weight: 800;
color: #0a0e27;
margin: 0 0 16px;
letter-spacing: -0.5px;
}
.login-subtitle {
font-size: 17px;
color: #666;
margin: 0;
font-weight: 500;
}
.login-form {
margin-bottom: 32px;
}
.login-input {
height: 52px;
border-radius: 10px;
font-size: 15px;
border: 2px solid #e8e8e8;
transition: all 0.3s;
}
.login-input:hover {
border-color: #667eea;
}
.login-input:focus,
.login-input.ant-input-focused {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.login-input input {
height: 100%;
}
.input-icon {
color: #999;
font-size: 19px;
}
.login-button {
height: 52px;
border-radius: 10px;
font-size: 17px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
transition: all 0.3s;
letter-spacing: 0.5px;
}
.login-button:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.5);
}
.login-button:active {
transform: translateY(-1px);
}
.login-footer {
text-align: center;
margin-top: 48px;
padding-top: 32px;
border-top: 1px solid #f0f0f0;
}
.footer-text {
font-size: 14px;
color: #999;
margin: 0;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.login-left {
display: none;
}
.login-right {
width: 100%;
}
}
@media (max-width: 640px) {
.login-right {
padding: 20px;
}
.login-form-wrapper {
max-width: 100%;
}
.login-title {
font-size: 28px;
}
}
+83 -18
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { Form, Input, Button, message } from 'antd';
import { UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons';
import { loginByPassword } from '@/api/admin';
import { useAuthStore } from '@/store/auth';
import './Login.css';
const Login: React.FC = () => {
const [loading, setLoading] = useState(false);
@@ -25,22 +26,86 @@ const Login: React.FC = () => {
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#001529' }}>
<Card style={{ width: 400 }} title={<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 600, color: '#1890ff' }}></div>}>
<Form onFinish={onFinish} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="管理员用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
</Form>
</Card>
<div className="login-container">
{/* 左侧装饰区域 */}
<div className="login-left">
<div className="login-decoration">
<div className="decoration-circle circle-1"></div>
<div className="decoration-circle circle-2"></div>
<div className="decoration-circle circle-3"></div>
</div>
<div className="login-brand">
<div className="brand-icon">
<SafetyOutlined style={{ fontSize: 64, color: '#fff' }} />
</div>
<h1 className="brand-title"></h1>
<p className="brand-subtitle"></p>
<div className="brand-features">
<div className="feature-item">
<div className="feature-icon"></div>
<div className="feature-text"></div>
</div>
<div className="feature-item">
<div className="feature-icon"></div>
<div className="feature-text"></div>
</div>
<div className="feature-item">
<div className="feature-icon"></div>
<div className="feature-text"></div>
</div>
</div>
</div>
</div>
{/* 右侧登录表单 */}
<div className="login-right">
<div className="login-form-wrapper">
<div className="login-header">
<h2 className="login-title"></h2>
<p className="login-subtitle"></p>
</div>
<Form onFinish={onFinish} size="large" className="login-form">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined className="input-icon" />}
placeholder="管理员用户名"
className="login-input"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined className="input-icon" />}
placeholder="密码"
className="login-input"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="login-button"
>
</Button>
</Form.Item>
</Form>
<div className="login-footer">
<p className="footer-text">© 2026 . All rights reserved.</p>
</div>
</div>
</div>
</div>
);
};
+2 -2
View File
@@ -12,8 +12,8 @@ REDIS_PASSWORD=
# JWT配置
JWT_SECRET=your_jwt_secret_key_change_in_production
JWT_EXPIRES_IN=2h
JWT_REFRESH_EXPIRES_IN=7d
JWT_EXPIRES_IN=30d
JWT_REFRESH_EXPIRES_IN=30d
# 应用配置
PORT=3000
@@ -7,6 +7,7 @@ import {
RegisterDto,
SendCodeDto,
WechatLoginDto,
WechatPhoneLoginDto,
} from './dto/auth.dto';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
@@ -39,6 +40,12 @@ export class AuthController {
return this.authService.loginByWechat(dto);
}
@Post('login/wechat-phone')
@ApiOperation({ summary: '微信手机号一键登录' })
async loginByWechatPhone(@Body() dto: WechatPhoneLoginDto) {
return this.authService.loginByWechatPhone(dto);
}
@Post('register')
@ApiOperation({ summary: '用户注册' })
async register(@Body() dto: RegisterDto) {
@@ -9,6 +9,7 @@ import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { User } from '@/entities/user.entity';
import { UserAccount } from '@/entities/user-account.entity';
import {
@@ -16,6 +17,7 @@ import {
LoginByPasswordDto,
RegisterDto,
WechatLoginDto,
WechatPhoneLoginDto,
} from './dto/auth.dto';
@Injectable()
@@ -84,6 +86,66 @@ export class AuthService {
}
}
async loginByWechatPhone(dto: WechatPhoneLoginDto) {
try {
// 1. 调用微信接口获取 session_key
const wechatData = await this.getWechatOpenid(dto.code);
if (!wechatData.session_key) {
throw new BadRequestException('获取微信会话失败');
}
// 2. 解密手机号
const phoneData = this.decryptWechatData(
dto.encryptedData,
dto.iv,
wechatData.session_key,
);
if (!phoneData || !phoneData.phoneNumber) {
throw new BadRequestException('解密手机号失败');
}
const phone = phoneData.phoneNumber;
this.logger.log(`微信手机号登录: phone=${phone}, openid=${wechatData.openid}`);
// 3. 查找或创建用户
let user = await this.userRepo.findOne({ where: { phone } });
if (!user) {
// 自动创建新用户
user = this.userRepo.create({
phone,
wechatOpenid: wechatData.openid,
wechatUnionid: wechatData.unionid,
nickname: `用户${phone.slice(-4)}`,
});
await this.userRepo.save(user);
this.logger.log(`微信手机号自动注册: phone=${phone}`);
// 自动创建用户账户
await this.createUserAccount(user.id);
} else {
// 更新微信信息
if (wechatData.openid && !user.wechatOpenid) {
user.wechatOpenid = wechatData.openid;
}
if (wechatData.unionid && !user.wechatUnionid) {
user.wechatUnionid = wechatData.unionid;
}
user.lastLoginAt = new Date();
await this.userRepo.save(user);
}
return this.generateToken(user);
} catch (error) {
this.logger.error('微信手机号登录失败', error);
throw new BadRequestException(
error.message || '微信手机号登录失败,请重试',
);
}
}
async register(dto: RegisterDto) {
const existing = await this.userRepo.findOne({
where: { phone: dto.phone },
@@ -146,7 +208,7 @@ export class AuthService {
this.logger.log(`[DEV] 模拟微信登录: code=${code}`);
return {
openid: `dev_openid_${Date.now()}`,
session_key: 'dev_session_key',
session_key: 'dev_session_key_1234567890abcdef',
unionid: `dev_unionid_${Date.now()}`,
};
}
@@ -181,6 +243,46 @@ export class AuthService {
}
}
private decryptWechatData(
encryptedData: string,
iv: string,
sessionKey: string,
): any {
try {
// 开发模式:返回模拟数据
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
this.logger.log('[DEV] 模拟解密微信手机号');
return {
phoneNumber: '13800138000',
purePhoneNumber: '13800138000',
countryCode: '86',
};
}
// 生产模式:解密数据
const sessionKeyBuffer = Buffer.from(sessionKey, 'base64');
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const decipher = crypto.createDecipheriv(
'aes-128-cbc',
sessionKeyBuffer,
ivBuffer,
);
decipher.setAutoPadding(true);
let decrypted = decipher.update(encryptedDataBuffer, undefined, 'utf8');
decrypted += decipher.final('utf8');
const result = JSON.parse(decrypted);
return result;
} catch (error) {
this.logger.error('解密微信数据失败', error);
throw new BadRequestException('解密失败');
}
}
private async validateByPhone(phone: string, code: string): Promise<User> {
const isDev = process.env.NODE_ENV === 'development';
const devCode = '123456';
@@ -37,6 +37,20 @@ export class WechatLoginDto {
avatar?: string;
}
export class WechatPhoneLoginDto {
@IsString()
@IsNotEmpty({ message: 'code不能为空' })
code: string;
@IsString()
@IsNotEmpty({ message: 'encryptedData不能为空' })
encryptedData: string;
@IsString()
@IsNotEmpty({ message: 'iv不能为空' })
iv: string;
}
export class RegisterDto {
@IsString()
@IsNotEmpty({ message: '手机号不能为空' })
@@ -80,6 +80,13 @@ export class OrderService {
const roomAmount = totalRoomPrice * roomCount;
// 计算平台服务费(基于房费)
const serviceFeeRate = await this.configService.getServiceFeeRate();
const serviceFee = Math.round(roomAmount * serviceFeeRate * 100) / 100;
// 订单总额 = 房费 + 服务费
const totalAmount = roomAmount + serviceFee;
// 处理优惠券
let couponDiscount = 0;
let userCouponId: number | null = null;
@@ -99,18 +106,16 @@ export class OrderService {
throw new BadRequestException('优惠券不可用');
}
// 计算优惠金额
// 计算优惠金额(优惠券只能抵扣房费部分,不能抵扣服务费)
couponDiscount = this.couponService.calculateDiscount(userCoupon.coupon, roomAmount);
userCouponId = userCoupon.id;
}
const totalAmount = roomAmount;
// 实付金额 = 订单总额 - 优惠券折扣
const payAmount = totalAmount - couponDiscount;
// 计算软件服务费和商家收入
const serviceFeeRate = await this.configService.getServiceFeeRate();
const serviceFee = Math.round(payAmount * serviceFeeRate * 100) / 100;
const merchantIncome = Math.round((payAmount - serviceFee) * 100) / 100;
// 商家收入 = 房费 - 优惠券折扣
const merchantIncome = Math.round((roomAmount - couponDiscount) * 100) / 100;
const now = new Date();
const orderNo = [
@@ -19,4 +19,14 @@ export class RoomController {
async findOne(@Param('id') id: number) {
return this.roomService.findById(id);
}
@Get(':id/calendar')
@ApiOperation({ summary: '获取房源日历' })
async getCalendar(
@Param('id') id: number,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
return this.roomService.getRoomCalendar(id, startDate, endDate);
}
}
@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { Repository, LessThan, MoreThanOrEqual } from 'typeorm';
import { Room } from '@/entities/room.entity';
import { RoomCalendar } from '@/entities/room-calendar.entity';
import { QueryAvailableRoomsDto } from './dto/room.dto';
@@ -23,6 +23,18 @@ export class RoomService {
return room;
}
async getRoomCalendar(roomId: number, startDate: string, endDate: string) {
const calendar = await this.calendarRepo.find({
where: {
roomId: Number(roomId),
date: MoreThanOrEqual(startDate) && LessThan(endDate),
},
order: { date: 'ASC' },
});
return calendar;
}
async findAvailable(query: QueryAvailableRoomsDto) {
const {
merchantId,
@@ -37,7 +49,6 @@ export class RoomService {
const qb = this.roomRepo
.createQueryBuilder('room')
.leftJoinAndSelect('room.merchant', 'merchant')
.where('room.status = :status', { status: 'on_sale' })
.andWhere('room.audit_status = :auditStatus', { auditStatus: 'approved' });
@@ -51,22 +62,51 @@ export class RoomService {
qb.andWhere('room.max_guests >= :totalGuests', { totalGuests });
// 如果提供了日期,检查可用性
let roomPriceMap = new Map<number, number>(); // 存储每个房间的实际价格
if (checkIn && checkOut) {
// 查询在该日期范围内有库存的房间
const availableRoomIds = await this.calendarRepo
// 计算需要的天数
const checkInDate = new Date(checkIn);
const checkOutDate = new Date(checkOut);
const daysDiff = Math.ceil((checkOutDate.getTime() - checkInDate.getTime()) / (1000 * 60 * 60 * 24));
// 查询日历记录
const calendarRecords = await this.calendarRepo
.createQueryBuilder('cal')
.select('DISTINCT cal.room_id', 'roomId')
.where('cal.date >= :checkIn', { checkIn })
.andWhere('cal.date < :checkOut', { checkOut })
.andWhere('cal.status = :status', { status: 'available' })
.andWhere('(cal.stock - cal.sold) >= :roomCount', { roomCount })
.groupBy('cal.room_id')
.having('COUNT(*) = DATEDIFF(:checkOut, :checkIn)', { checkIn, checkOut })
.getRawMany();
.getMany();
// 按房间ID分组,检查每个房间是否在所有日期都有足够库存,并计算总价
const roomAvailability = new Map<number, number>();
const roomTotalPrice = new Map<number, number>();
for (const record of calendarRecords) {
const availableStock = record.stock - record.sold;
if (availableStock >= roomCount) {
const count = roomAvailability.get(record.roomId) || 0;
roomAvailability.set(record.roomId, count + 1);
// 累加价格
const totalPrice = roomTotalPrice.get(record.roomId) || 0;
roomTotalPrice.set(record.roomId, totalPrice + Number(record.price));
}
}
// 筛选出在所有日期都有库存的房间
const availableRoomIds = Array.from(roomAvailability.entries())
.filter(([_, count]) => count >= daysDiff)
.map(([roomId, _]) => roomId);
if (availableRoomIds.length > 0) {
const roomIds = availableRoomIds.map(item => item.roomId);
qb.andWhere('room.id IN (:...roomIds)', { roomIds });
qb.andWhere('room.id IN (:...roomIds)', { roomIds: availableRoomIds });
// 计算每个房间的平均价格(用于显示)
availableRoomIds.forEach(roomId => {
const totalPrice = roomTotalPrice.get(roomId) || 0;
roomPriceMap.set(roomId, totalPrice / daysDiff);
});
} else {
// 没有可用房间,返回空结果
return { list: [], total: 0, page, pageSize };
@@ -78,6 +118,35 @@ export class RoomService {
const [list, total] = await qb.getManyAndCount();
// 批量加载 merchant 关系
if (list.length > 0) {
const merchantIds = [...new Set(list.map(room => room.merchantId))];
const merchants = await this.roomRepo.manager
.createQueryBuilder()
.from('merchants', 'merchant')
.where('merchant.id IN (:...merchantIds)', { merchantIds })
.getMany();
const merchantMap = new Map(merchants.map(m => [m.id, m]));
// 转换为普通对象并添加 currentPrice
const result = list.map(room => {
const roomData = {
...room,
merchant: merchantMap.get(room.merchantId),
};
// 如果有日历价格,添加 currentPrice 字段
if (roomPriceMap.has(room.id)) {
roomData['currentPrice'] = roomPriceMap.get(room.id);
}
return roomData;
});
return { list: result, total, page, pageSize };
}
return { list, total, page, pageSize };
}
}
@@ -87,6 +87,18 @@ export class QueryCouponDto {
@IsOptional()
@IsEnum(['platform', 'merchant', 'room'])
scope?: 'platform' | 'merchant' | 'room';
@ApiProperty({ description: '商家ID', required: false })
@IsOptional()
@Type(() => Number)
@IsNumber()
merchantId?: number;
@ApiProperty({ description: '房源ID', required: false })
@IsOptional()
@Type(() => Number)
@IsNumber()
roomId?: number;
}
export class ReceiveCouponDto {