Browse Source

新增忘记密码。注册账号页面。新增支付页面,新增扫描页面与接口

hetong_dev
zhouhaibin 2 weeks ago
parent
commit
788d73bcde
  1. 37
      src/api/contractReview/Payment/index.ts
  2. 71
      src/api/contractReview/Payment/model.ts
  3. 74
      src/router/routes/index.ts
  4. 322
      src/views/homepage/index.vue
  5. 752
      src/views/user/ForgotPassword.vue
  6. 860
      src/views/user/Register.vue
  7. 116
      src/views/user/home/components/ChecklistPage.vue
  8. 755
      src/views/user/home/components/MembershipModal.vue
  9. 370
      src/views/user/home/components/PaymentQRCodeModal.vue
  10. 286
      src/views/user/home/components/RecordsPage.vue
  11. 297
      src/views/user/home/components/ReviewPage.vue
  12. 725
      src/views/user/home/index.vue
  13. 728
      src/views/user/login/index.vue

37
src/api/contractReview/Payment/index.ts

@ -0,0 +1,37 @@
import { defHttp } from '@/utils/http/axios';
import { PaymentRequest, PaymentResponse } from './model';
/**
*
* @param data
* @returns
*/
export function createPayment(data: PaymentRequest) {
return defHttp.postWithMsg<PaymentResponse>({
url: '/contractreview/payment/create',
data
});
}
/**
*
* @param orderNo
* @returns
*/
export function queryPaymentStatus(orderNo: string) {
return defHttp.get<any>({
url: `/contractreview/payment/status/${orderNo}`
});
}
/**
*
* @param data
* @returns
*/
export function handlePaymentCallback(data: any) {
return defHttp.post<string>({
url: '/contractreview/payment/callback',
data
});
}

71
src/api/contractReview/Payment/model.ts

@ -0,0 +1,71 @@
/**
*
*/
export interface PaymentRequest {
/** 套餐类型 */
planType: string;
/** 套餐名称 */
planName: string;
/** 时长(月) */
duration: number;
/** 金额 */
amount: number;
/** 支付方式 */
paymentMethod: string;
/** 用户ID */
userId: number;
/** 客户端IP */
clientIp: string;
/** 异步通知地址 */
notifyUrl: string;
/** 同步回调地址 */
returnUrl: string;
}
/**
*
*/
export interface PaymentResponse {
/** 是否成功 */
success: boolean;
/** 错误信息 */
errorMsg?: string;
/** 订单号 */
orderNo?: string;
/** 支付状态 */
payStatus?: string;
/** 二维码链接 */
qrCodeUrl?: string;
/** 支付链接 */
payUrl?: string;
}
/**
*
*/
export interface PaymentStatusResponse {
/** 是否成功 */
success: boolean;
/** 错误信息 */
errorMsg?: string;
/** 商户订单号 */
mchOrderNo: string;
/** 支付状态文本 (INIT/ING/SUCCESS/FAIL/CANCEL/REFUND/CLOSED) */
payStatus: string;
/** Jeepay原始状态码 (0-6) */
jeepayState: number;
/** 支付金额 */
amount?: number;
/** 支付时间 */
payTime?: string;
/** Jeepay支付订单ID */
payOrderId?: string;
/** 支付方式代码 */
wayCode?: string;
/** 货币类型 */
currency?: string;
/** 商品标题 */
subject?: string;
/** 商品描述 */
body?: string;
}

74
src/router/routes/index.ts

@ -20,18 +20,29 @@ Object.keys(modules).forEach((key) => {
export const asyncRoutes = [PAGE_NOT_FOUND_ROUTE, ...routeModuleList];
// 路由
export const RootRoute: AppRouteRecordRaw = {
// 用户展示页面路由
export const UserPageRoute: AppRouteRecordRaw = {
path: '/',
name: 'Root',
redirect: PageEnum.BASE_HOME,
name: 'UserHomepage',
component: () => import('@/views/homepage/index.vue'),
meta: {
title: 'Root',
title: '小研智审 - 合同全能助手',
ignoreAuth: true,
},
};
// 管理后台根路由
export const AdminRootRoute: AppRouteRecordRaw = {
path: '/admin',
name: 'AdminRoot',
redirect: '/admin' + PageEnum.BASE_HOME,
meta: {
title: 'Admin Root',
},
};
export const LoginRoute: AppRouteRecordRaw = {
path: '/login',
path: '/admin/login',
name: 'Login',
component: () => import('@/views/auth/login/Login.vue'),
meta: {
@ -39,11 +50,60 @@ export const LoginRoute: AppRouteRecordRaw = {
},
};
// 用户登录页面路由
export const UserLoginRoute: AppRouteRecordRaw = {
path: '/user/login',
name: 'UserLogin',
component: () => import('@/views/user/login/index.vue'),
meta: {
title: '用户登录 - 小研智审',
ignoreAuth: true,
},
};
// 用户注册页面路由
export const UserRegisterRoute: AppRouteRecordRaw = {
path: '/user/register',
name: 'UserRegister',
component: () => import('@/views/user/Register.vue'),
meta: {
title: '用户注册 - 小研智审',
ignoreAuth: true,
},
};
// 用户忘记密码页面路由
export const UserForgotPasswordRoute: AppRouteRecordRaw = {
path: '/user/forgot-password',
name: 'UserForgotPassword',
component: () => import('@/views/user/ForgotPassword.vue'),
meta: {
title: '忘记密码 - 小研智审',
ignoreAuth: true,
},
};
// 用户主页路由
export const UserHomeRoute: AppRouteRecordRaw = {
path: '/user/home',
name: 'UserHome',
component: () => import('@/views/user/home/index.vue'),
meta: {
title: '小研智审 - 合同审查助手',
ignoreAuth: false,
},
};
// Basic routing without permission
// 基本路由 就是不在后台返回内容中的路由
export const basicRoutes = [
UserPageRoute,
UserLoginRoute,
UserRegisterRoute,
UserForgotPasswordRoute,
UserHomeRoute,
LoginRoute,
RootRoute,
AdminRootRoute,
REDIRECT_ROUTE,
PAGE_NOT_FOUND_ROUTE,
...localRoutes,

322
src/views/homepage/index.vue

@ -0,0 +1,322 @@
<template>
<div class="user-homepage">
<!-- 导航栏 -->
<header class="navbar">
<div class="nav-container">
<div class="logo">
<img src="/logo.png" alt="小研智审" class="logo-img" />
<span class="logo-text">小研智审</span>
</div>
<nav class="nav-links">
<a href="/user/login" class="admin-btn">立即体验</a>
</nav>
</div>
</header>
<!-- 主banner区域 -->
<section class="hero-section">
<div class="hero-container">
<div class="hero-content">
<h1 class="hero-title">
小研智审<br />
<span class="highlight">合同审核全能助手AI赋能读改审</span>
</h1>
<p class="hero-subtitle">
识别合同条款中的不平等内容确保权利义务分配合理<br>
依据现行法律法规行业规范和监管要求对合同条款进行全面审查确保合同条款规定不违反法律法规<br>
将合同条款与招标文件中的合同要求进行逐比对防止关键条款偏离
</p>
<div class="hero-buttons">
<button class="btn btn-primary" @click="startTrial">立即体验</button>
<button class="btn btn-secondary" @click="contactUs">联系我们</button>
</div>
</div>
<div class="hero-visual">
<div class="ai-card">
<div class="card-header">
<div class="card-icon">🤖</div>
<h3>AI智能分析</h3>
</div>
<div class="card-content">
<div class="analysis-item">
<span class="dot dot-success"></span>
<span>合同条款完整性检查</span>
</div>
<div class="analysis-item">
<span class="dot dot-warning"></span>
<span>风险点识别与标注</span>
</div>
<div class="analysis-item">
<span class="dot dot-info"></span>
<span>法律条文智能匹配</span>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
const startTrial = () => {
//
window.open('/user/login', '_blank');
};
const contactUs = () => {
//
alert('联系我们:contact@markup.ai');
};
onMounted(() => {
});
</script>
<style lang="less" scoped>
* {
box-sizing: border-box;
}
.user-homepage {
min-height: 100vh;
background: #ffffff;
}
/* 导航栏 */
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid #eee;
z-index: 1000;
padding: 0.5rem 0;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: bold;
color: #2c3e50;
}
.logo-img {
width: 32px;
height: 32px;
}
.nav-links {
display: flex;
align-items: center;
gap: 2rem;
a {
text-decoration: none;
color: #2c3e50;
font-weight: 500;
transition: color 0.3s;
&:hover {
color: #3498db;
}
&.admin-btn {
background: #3498db;
color: white;
padding: 0.5rem 1rem;
border-radius: 6px;
&:hover {
background: #2980b9;
color: white;
}
}
}
}
/* Hero区域 */
.hero-section {
padding: 8rem 0 4rem;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
display: flex;
align-items: center;
}
.hero-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
color: #2c3e50;
margin-bottom: 1.5rem;
line-height: 1.2;
.highlight {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.hero-subtitle {
font-size: 1.2rem;
color: #7f8c8d;
margin-bottom: 2rem;
line-height: 1.6;
}
.hero-buttons {
display: flex;
gap: 1rem;
}
.btn {
padding: 1rem 2rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
&.btn-primary {
background: #3498db;
color: white;
&:hover {
background: #2980b9;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(52, 152, 219, 0.3);
}
}
&.btn-secondary {
background: white;
color: #3498db;
border: 2px solid #3498db;
&:hover {
background: #3498db;
color: white;
transform: translateY(-2px);
}
}
}
.hero-visual {
display: flex;
justify-content: center;
}
.ai-card {
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
max-width: 350px;
width: 100%;
}
.card-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
.card-icon {
font-size: 2rem;
}
h3 {
color: #2c3e50;
margin: 0;
}
}
.analysis-item {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
font-size: 0.95rem;
color: #555;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.dot-success {
background: #27ae60;
}
&.dot-warning {
background: #f39c12;
}
&.dot-info {
background: #3498db;
}
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.nav-container {
padding: 0 1rem;
}
.nav-links {
gap: 1rem;
a {
font-size: 0.9rem;
}
}
.hero-container {
grid-template-columns: 1fr;
gap: 2rem;
text-align: center;
}
.hero-title {
font-size: 2rem;
}
}
</style>

752
src/views/user/ForgotPassword.vue

@ -0,0 +1,752 @@
<template>
<div class="user-forgot-password-page">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="decoration-circle circle-1"></div>
<div class="decoration-circle circle-2"></div>
<div class="decoration-circle circle-3"></div>
</div>
<div class="forgot-password-container">
<!-- 左侧功能展示 -->
<div class="left-section">
<div class="brand-info">
<div class="logo-section">
<img src="/logo.png" alt="小研智审" class="logo" />
<h1 class="brand-name">小研智审</h1>
</div>
<p class="brand-desc">合同全能助手AI赋能读改审</p>
</div>
<div class="features-showcase">
<div class="feature-card">
<div class="feature-icon">📋</div>
<h3>合同审查</h3>
<p>智能识别合同条款中的不平等内容确保权利义务分配合理</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>法规检查</h3>
<p>依据现行法律法规行业规范和监管要求进行全面审查</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<h3>对比审查</h3>
<p>将合同条款与招标文件中的合同要求进行逐一比对</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>结果生成</h3>
<p>生成详细的审查报告和改进建议</p>
</div>
</div>
</div>
<!-- 右侧忘记密码区域 -->
<div class="right-section">
<div class="forgot-password-form-container">
<!-- 标题 -->
<div class="form-header">
<h2>忘记密码</h2>
<p>通过手机验证码重置您的密码</p>
</div>
<!-- 忘记密码表单 -->
<form @submit.prevent="handleResetPassword" class="forgot-password-form">
<div class="form-group">
<input
v-model="formData.username"
type="text"
placeholder="请输入用户名"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
v-model="formData.mobile"
type="tel"
placeholder="请输入手机号"
class="form-input"
required
/>
</div>
<div class="form-group sms-group">
<input
v-model="formData.sms"
type="text"
placeholder="请输入短信验证码"
class="form-input sms-input"
required
/>
<button
type="button"
class="sms-btn"
@click="handleSendSmsCode"
:disabled="smsLoading || smsCountdown > 0"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
</button>
</div>
<div class="form-group">
<div class="password-input-wrapper">
<input
v-model="formData.password"
:type="passwordVisible ? 'text' : 'password'"
placeholder="请输入新密码"
class="form-input password-input"
required
/>
<button
type="button"
class="password-toggle-btn"
@click="togglePasswordVisibility"
>
<svg
v-if="!passwordVisible"
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg
v-else
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
</div>
<div class="form-group">
<div class="password-input-wrapper">
<input
v-model="formData.confirmPassword"
:type="confirmPasswordVisible ? 'text' : 'password'"
placeholder="请确认新密码"
class="form-input password-input"
required
/>
<button
type="button"
class="password-toggle-btn"
@click="toggleConfirmPasswordVisibility"
>
<svg
v-if="!confirmPasswordVisible"
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg
v-else
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
</div>
<button
type="submit"
class="reset-btn"
:disabled="loading"
:class="{ loading }"
>
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '重置中...' : '重置密码' }}
</button>
</form>
<!-- 其他选项 -->
<div class="other-options">
<p class="login-link">
想起密码了<a href="#" @click="handleBackToLogin($event)">立即登录</a>
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@/hooks/web/useMessage';
import { resetPasswordApi } from '@/api/auth';
import { sendSmsCode } from '@/api/auth/captcha';
const router = useRouter();
const { notification, createErrorModal } = useMessage();
//
const formData = reactive({
username: '',
mobile: '',
sms: '',
password: '',
confirmPassword: '',
});
//
const loading = ref(false);
const smsLoading = ref(false);
const smsCountdown = ref(0);
// /
const passwordVisible = ref(false);
const confirmPasswordVisible = ref(false);
//
const handleSendSmsCode = async () => {
//
if (!formData.mobile) {
notification.error({
message: '请先填写手机号码',
duration: 3,
});
return;
}
//
const mobileRegex = /^1[3-9]\d{9}$/;
if (!mobileRegex.test(formData.mobile)) {
notification.error({
message: '请输入正确的手机号码',
duration: 3,
});
return;
}
try {
smsLoading.value = true;
await sendSmsCode(formData.mobile);
notification.success({
message: '验证码发送成功',
duration: 3,
});
//
smsCountdown.value = 60;
const timer = setInterval(() => {
smsCountdown.value--;
if (smsCountdown.value <= 0) {
clearInterval(timer);
}
}, 1000);
} catch (error) {
notification.error({
message: '验证码发送失败,请重试',
duration: 3,
});
} finally {
smsLoading.value = false;
}
};
//
const handleResetPassword = async () => {
if (loading.value) return;
//
if (formData.password !== formData.confirmPassword) {
notification.error({
message: '两次输入的密码不一致',
duration: 3,
});
return;
}
//
if (formData.password.length < 6) {
notification.error({
message: '密码长度不能少于6位',
duration: 3,
});
return;
}
try {
loading.value = true;
await resetPasswordApi({
username: formData.username,
password: formData.password,
mobile: formData.mobile,
code: formData.sms,
uuid: formData.mobile, // 使uuid
tenantId: '000000', // 使
});
notification.success({
message: '密码重置成功!',
description: '您的密码已重置成功,请使用新密码登录系统',
duration: 5,
});
//
setTimeout(() => {
router.push('/user/login');
}, 1000);
} catch (error: any) {
const content = error.message || '密码重置失败,请稍后重试';
createErrorModal({
title: '密码重置失败',
content,
});
} finally {
loading.value = false;
}
};
// /
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value;
};
// /
const toggleConfirmPasswordVisibility = () => {
confirmPasswordVisible.value = !confirmPasswordVisible.value;
};
//
const handleBackToLogin = (event?: Event) => {
//
if (event) {
event.preventDefault();
}
router.push('/user/login');
};
</script>
<style lang="less" scoped>
* {
box-sizing: border-box;
}
.user-forgot-password-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
//
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
.decoration-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.circle-1 {
width: 300px;
height: 300px;
top: -150px;
right: -150px;
}
&.circle-2 {
width: 200px;
height: 200px;
bottom: -100px;
left: -100px;
}
&.circle-3 {
width: 150px;
height: 150px;
top: 50%;
left: 20%;
transform: translateY(-50%);
}
}
}
.forgot-password-container {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1200px;
width: 90%;
min-height: 600px;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
z-index: 2;
}
//
.left-section {
padding: 3rem;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
display: flex;
flex-direction: column;
justify-content: center;
}
.brand-info {
text-align: center;
margin-bottom: 3rem;
.logo-section {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1rem;
.logo {
width: 48px;
height: 48px;
}
.brand-name {
font-size: 2rem;
font-weight: 700;
color: #2c3e50;
margin: 0;
}
}
.brand-desc {
font-size: 1.1rem;
color: #7f8c8d;
margin: 0;
}
}
.features-showcase {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.feature-card {
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.feature-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
h3 {
font-size: 1rem;
font-weight: 600;
color: #2c3e50;
margin: 0 0 0.5rem 0;
}
p {
font-size: 0.85rem;
color: #7f8c8d;
line-height: 1.4;
margin: 0;
}
}
//
.right-section {
padding: 2rem;
display: flex;
flex-direction: column;
position: relative;
}
.forgot-password-form-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 400px;
margin: 0 auto;
width: 100%;
}
.form-header {
text-align: center;
margin-bottom: 2rem;
h2 {
font-size: 1.8rem;
font-weight: 600;
color: #2c3e50;
margin: 0 0 0.5rem 0;
}
p {
color: #7f8c8d;
margin: 0;
}
}
//
.forgot-password-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
position: relative;
&.sms-group {
display: flex;
gap: 1rem;
.sms-input {
flex: 1;
}
.sms-btn {
width: 120px;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fafafa;
color: #666;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s;
white-space: nowrap;
&:hover:not(:disabled) {
border-color: #667eea;
color: #667eea;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s, box-shadow 0.3s;
&:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
&::placeholder {
color: #bfbfbf;
}
}
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
.password-input {
padding-right: 40px; //
}
.password-toggle-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.3s ease;
.eye-icon {
width: 18px;
height: 18px;
color: #999;
transition: color 0.3s ease;
}
&:hover {
background-color: rgba(102, 126, 234, 0.1);
.eye-icon {
color: #667eea;
}
}
&:active {
transform: translateY(-50%) scale(0.95);
}
}
}
.reset-btn {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover:not(:disabled) {
background: #5a6fd8;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
&.loading {
pointer-events: none;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
//
.other-options {
margin-top: 2rem;
text-align: center;
.login-link {
color: #666;
font-size: 0.9rem;
margin: 0;
a {
color: #667eea;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
//
@media (max-width: 768px) {
.forgot-password-container {
grid-template-columns: 1fr;
width: 95%;
min-height: auto;
}
.left-section {
display: none;
}
.right-section {
padding: 2rem 1.5rem;
}
.features-showcase {
grid-template-columns: 1fr;
gap: 1rem;
}
}
@media (max-width: 480px) {
.user-forgot-password-page {
padding: 1rem;
}
.forgot-password-container {
width: 100%;
border-radius: 12px;
}
.right-section {
padding: 1.5rem 1rem;
}
.form-group.sms-group {
flex-direction: column;
gap: 0.5rem;
.sms-btn {
width: 100%;
}
}
}
</style>

860
src/views/user/Register.vue

@ -0,0 +1,860 @@
<template>
<div class="user-register-page">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="decoration-circle circle-1"></div>
<div class="decoration-circle circle-2"></div>
<div class="decoration-circle circle-3"></div>
</div>
<div class="register-container">
<!-- 左侧功能展示 -->
<div class="left-section">
<div class="brand-info">
<div class="logo-section">
<img src="/logo.png" alt="小研智审" class="logo" />
<h1 class="brand-name">小研智审</h1>
</div>
<p class="brand-desc">合同全能助手AI赋能读改审</p>
</div>
<div class="features-showcase">
<div class="feature-card">
<div class="feature-icon">📋</div>
<h3>合同审查</h3>
<p>智能识别合同条款中的不平等内容确保权利义务分配合理</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>法规检查</h3>
<p>依据现行法律法规行业规范和监管要求进行全面审查</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<h3>对比审查</h3>
<p>将合同条款与招标文件中的合同要求进行逐一比对</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>结果生成</h3>
<p>生成详细的审查报告和改进建议</p>
</div>
</div>
</div>
<!-- 右侧注册区域 -->
<div class="right-section">
<div class="register-form-container">
<!-- 标题 -->
<div class="form-header">
<h2>用户注册</h2>
<p>创建您的账号开始使用小研智审</p>
</div>
<!-- 注册表单 -->
<form @submit.prevent="handleRegister" class="register-form">
<div class="form-group">
<input
v-model="formData.username"
type="text"
placeholder="请输入用户名"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
v-model="formData.mobile"
type="tel"
placeholder="请输入手机号"
class="form-input"
required
/>
</div>
<div class="form-group sms-group">
<input
v-model="formData.sms"
type="text"
placeholder="请输入短信验证码"
class="form-input sms-input"
required
/>
<button
type="button"
class="sms-btn"
@click="handleSendSmsCode"
:disabled="smsLoading || smsCountdown > 0"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
</button>
</div>
<div class="form-group">
<div class="password-input-wrapper">
<input
v-model="formData.password"
:type="passwordVisible ? 'text' : 'password'"
placeholder="请输入密码"
class="form-input password-input"
required
/>
<button
type="button"
class="password-toggle-btn"
@click="togglePasswordVisibility"
>
<svg
v-if="!passwordVisible"
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg
v-else
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
</div>
<div class="form-group">
<div class="password-input-wrapper">
<input
v-model="formData.confirmPassword"
:type="confirmPasswordVisible ? 'text' : 'password'"
placeholder="请确认密码"
class="form-input password-input"
required
/>
<button
type="button"
class="password-toggle-btn"
@click="toggleConfirmPasswordVisibility"
>
<svg
v-if="!confirmPasswordVisible"
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg
v-else
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
</div>
<div class="form-options">
<label class="policy-agreement">
<input v-model="formData.policy" type="checkbox" required />
<span class="checkmark"></span>
我已阅读并同意
<a href="#" class="policy-link">用户协议</a>
<a href="#" class="policy-link">隐私政策</a>
</label>
</div>
<button
type="submit"
class="register-btn"
:disabled="loading"
:class="{ loading }"
>
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '注册中...' : '立即注册' }}
</button>
</form>
<!-- 其他选项 -->
<div class="other-options">
<p class="login-link">
已有账号<a href="#" @click="handleBackToLogin($event)">立即登录</a>
</p>
<p class="forgot-password-link">
<a href="#" @click="handleForgotPassword($event)">忘记密码</a>
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@/hooks/web/useMessage';
import { registerApi } from '@/api/auth';
import { sendSmsCode } from '@/api/auth/captcha';
const router = useRouter();
const { notification, createErrorModal } = useMessage();
//
const formData = reactive({
username: '',
mobile: '',
sms: '',
password: '',
confirmPassword: '',
policy: false,
});
//
const loading = ref(false);
const smsLoading = ref(false);
const smsCountdown = ref(0);
//
const passwordVisible = ref(false);
const confirmPasswordVisible = ref(false);
//
const handleSendSmsCode = async () => {
//
if (!formData.mobile) {
notification.error({
message: '请先填写手机号码',
duration: 3,
});
return;
}
//
const mobileRegex = /^1[3-9]\d{9}$/;
if (!mobileRegex.test(formData.mobile)) {
notification.error({
message: '请输入正确的手机号码',
duration: 3,
});
return;
}
try {
smsLoading.value = true;
await sendSmsCode(formData.mobile);
notification.success({
message: '验证码发送成功',
duration: 3,
});
//
smsCountdown.value = 60;
const timer = setInterval(() => {
smsCountdown.value--;
if (smsCountdown.value <= 0) {
clearInterval(timer);
}
}, 1000);
} catch (error) {
notification.error({
message: '验证码发送失败,请重试',
duration: 3,
});
} finally {
smsLoading.value = false;
}
};
//
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value;
};
//
const toggleConfirmPasswordVisibility = () => {
confirmPasswordVisible.value = !confirmPasswordVisible.value;
};
//
const handleRegister = async () => {
if (loading.value) return;
//
if (formData.password !== formData.confirmPassword) {
notification.error({
message: '两次输入的密码不一致',
duration: 3,
});
return;
}
//
if (formData.password.length < 6) {
notification.error({
message: '密码长度不能少于6位',
duration: 3,
});
return;
}
//
if (!formData.policy) {
notification.error({
message: '请先同意用户协议和隐私政策',
duration: 3,
});
return;
}
try {
loading.value = true;
await registerApi({
username: formData.username,
password: formData.password,
mobile: formData.mobile,
code: formData.sms,
uuid: formData.mobile, // 使uuid
tenantId: '000000', // 使
userType: 'sys_user',
});
notification.success({
message: '注册成功!',
description: '您的账号已创建成功,请使用新账号登录系统',
duration: 5,
});
//
setTimeout(() => {
router.push('/user/login');
}, 1000);
} catch (error: any) {
const content = error.message || '注册失败,请稍后重试';
createErrorModal({
title: '注册失败',
content,
});
} finally {
loading.value = false;
}
};
//
const handleBackToLogin = (event?: Event) => {
//
if (event) {
event.preventDefault();
}
router.push('/user/login');
};
//
const handleForgotPassword = (event?: Event) => {
//
if (event) {
event.preventDefault();
}
router.push('/user/forgot-password');
};
</script>
<style lang="less" scoped>
* {
box-sizing: border-box;
}
.user-register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
//
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
.decoration-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.circle-1 {
width: 300px;
height: 300px;
top: -150px;
right: -150px;
}
&.circle-2 {
width: 200px;
height: 200px;
bottom: -100px;
left: -100px;
}
&.circle-3 {
width: 150px;
height: 150px;
top: 50%;
left: 20%;
transform: translateY(-50%);
}
}
}
.register-container {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1200px;
width: 90%;
min-height: 600px;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
z-index: 2;
}
//
.left-section {
padding: 3rem;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
display: flex;
flex-direction: column;
justify-content: center;
}
.brand-info {
text-align: center;
margin-bottom: 3rem;
.logo-section {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1rem;
.logo {
width: 48px;
height: 48px;
}
.brand-name {
font-size: 2rem;
font-weight: 700;
color: #2c3e50;
margin: 0;
}
}
.brand-desc {
font-size: 1.1rem;
color: #7f8c8d;
margin: 0;
}
}
.features-showcase {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.feature-card {
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.feature-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
h3 {
font-size: 1rem;
font-weight: 600;
color: #2c3e50;
margin: 0 0 0.5rem 0;
}
p {
font-size: 0.85rem;
color: #7f8c8d;
line-height: 1.4;
margin: 0;
}
}
//
.right-section {
padding: 2rem;
display: flex;
flex-direction: column;
position: relative;
}
.register-form-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 400px;
margin: 0 auto;
width: 100%;
}
.form-header {
text-align: center;
margin-bottom: 2rem;
h2 {
font-size: 1.8rem;
font-weight: 600;
color: #2c3e50;
margin: 0 0 0.5rem 0;
}
p {
color: #7f8c8d;
margin: 0;
}
}
//
.register-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
position: relative;
&.sms-group {
display: flex;
gap: 1rem;
.sms-input {
flex: 1;
}
.sms-btn {
width: 120px;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fafafa;
color: #666;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s;
white-space: nowrap;
&:hover:not(:disabled) {
border-color: #667eea;
color: #667eea;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s, box-shadow 0.3s;
&:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
&::placeholder {
color: #bfbfbf;
}
}
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
.password-input {
padding-right: 40px; //
}
.password-toggle-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
.eye-icon {
width: 18px;
height: 18px;
color: #999;
transition: color 0.3s ease;
}
&:hover {
background-color: rgba(102, 126, 234, 0.1);
.eye-icon {
color: #667eea;
}
}
&:active {
transform: translateY(-50%) scale(0.95);
}
}
}
.form-options {
display: flex;
justify-content: flex-start;
align-items: flex-start;
}
.policy-agreement {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
font-size: 0.9rem;
color: #666;
line-height: 1.4;
input[type="checkbox"] {
display: none;
}
.checkmark {
width: 16px;
height: 16px;
border: 2px solid #d9d9d9;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
flex-shrink: 0;
margin-top: 2px;
&::after {
content: '✓';
font-size: 10px;
color: white;
opacity: 0;
transition: opacity 0.3s;
}
}
input[type="checkbox"]:checked + .checkmark {
background: #667eea;
border-color: #667eea;
&::after {
opacity: 1;
}
}
.policy-link {
color: #667eea;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.register-btn {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover:not(:disabled) {
background: #5a6fd8;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
&.loading {
pointer-events: none;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
//
.other-options {
margin-top: 2rem;
text-align: center;
.login-link {
color: #666;
font-size: 0.9rem;
margin: 0;
a {
color: #667eea;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.forgot-password-link {
margin-top: 0.5rem;
text-align: center;
a {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
&:hover {
text-decoration: underline;
}
}
}
}
//
@media (max-width: 768px) {
.register-container {
grid-template-columns: 1fr;
width: 95%;
min-height: auto;
}
.left-section {
display: none;
}
.right-section {
padding: 2rem 1.5rem;
}
.features-showcase {
grid-template-columns: 1fr;
gap: 1rem;
}
}
@media (max-width: 480px) {
.user-register-page {
padding: 1rem;
}
.register-container {
width: 100%;
border-radius: 12px;
}
.right-section {
padding: 1.5rem 1rem;
}
.form-group.sms-group {
flex-direction: column;
gap: 0.5rem;
.sms-btn {
width: 100%;
}
}
}
</style>

116
src/views/user/home/components/ChecklistPage.vue

@ -0,0 +1,116 @@
<template>
<div class="checklist-page">
<div class="page-header">
<h2 class="page-title">审查清单</h2>
</div>
<div class="checklist-content">
<div class="empty-state">
<div class="empty-icon">📋</div>
<p>功能开发中敬请期待...</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
//
</script>
<style lang="less" scoped>
.checklist-page {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
flex: 1;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.8rem;
border-bottom: 1px solid #eee;
.page-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
margin: 0;
}
}
.checklist-content {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem 0;
}
.empty-state {
text-align: center;
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.6;
}
p {
font-size: 1.1rem;
color: #999;
margin: 0;
}
}
//
@media (max-width: 992px) {
.checklist-page {
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
}
.page-header {
margin-bottom: 1.5rem;
padding-bottom: 0.8rem;
}
.page-title {
font-size: 1.1rem;
}
}
@media (max-width: 768px) {
.checklist-page {
padding: 1.2rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.05);
}
.page-header {
margin-bottom: 1.5rem;
padding-bottom: 0.8rem;
}
.page-title {
font-size: 1rem;
}
}
@media (max-width: 480px) {
.checklist-page {
padding: 1rem;
margin-bottom: 1.5rem;
}
.checklist-page {
min-height: calc(100vh - 200px);
}
}
</style>

755
src/views/user/home/components/MembershipModal.vue

@ -0,0 +1,755 @@
<template>
<Modal
v-model:open="visible"
title="开通会员"
:width="900"
:footer="null"
@cancel="handleCancel"
class="membership-modal"
>
<div class="membership-content">
<!-- 会员权益介绍 -->
<div class="membership-benefits">
<div class="benefits-header">
<CrownOutlined class="crown-icon" />
<h3>会员专享权益</h3>
</div>
<div class="benefits-list">
<div class="benefit-item">
<CheckCircleOutlined class="benefit-icon" />
<span>无限次合同审查</span>
</div>
<div class="benefit-item">
<CheckCircleOutlined class="benefit-icon" />
<span>优先处理队列</span>
</div>
</div>
</div>
<!-- 会员套餐选择 -->
<div class="membership-plans">
<h4>选择套餐</h4>
<div class="plans-grid">
<div
v-for="plan in membershipPlans"
:key="plan.id"
:class="['plan-card', { active: selectedPlan?.id === plan.id }]"
@click="selectPlan(plan)"
>
<div class="plan-header">
<div class="plan-duration">{{ plan.duration }}</div>
<div class="plan-price">
<span class="currency">¥</span>
<span class="amount">{{ plan.price }}</span>
</div>
</div>
<div class="plan-features">
<div class="feature-item">
<CheckOutlined class="feature-icon" />
<span>{{ plan.duration }}会员服务</span>
</div>
<div class="feature-item">
<CheckOutlined class="feature-icon" />
<span>所有会员权益</span>
</div>
<div v-if="plan.discount" class="feature-item discount">
<Tag color="red">{{ plan.discount }}</Tag>
</div>
</div>
<div class="plan-action">
<Button
:type="selectedPlan?.id === plan.id ? 'primary' : 'default'"
:ghost="selectedPlan?.id !== plan.id"
size="large"
block
>
{{ selectedPlan?.id === plan.id ? '已选择' : '选择套餐' }}
</Button>
</div>
</div>
</div>
</div>
<!-- 支付方式选择 -->
<div class="payment-methods">
<h4>支付方式</h4>
<Radio.Group v-model:value="selectedPayment" class="payment-options">
<Radio value="wechat" class="payment-option">
<div class="payment-item">
<WechatOutlined class="payment-icon wechat" />
<span>微信支付</span>
</div>
</Radio>
<Radio value="alipay" class="payment-option">
<div class="payment-item">
<AlipayCircleOutlined class="payment-icon alipay" />
<span>支付宝</span>
</div>
</Radio>
</Radio.Group>
</div>
<!-- 订单信息 -->
<div v-if="selectedPlan" class="order-summary">
<Divider />
<div class="summary-item">
<span>套餐</span>
<span>{{ selectedPlan.duration }}</span>
</div>
<div class="summary-item">
<span>价格</span>
<span class="price">¥{{ selectedPlan.price }}</span>
</div>
<div v-if="selectedPlan.originalPrice" class="summary-item discount">
<span>原价</span>
<span class="original-price">¥{{ selectedPlan.originalPrice }}</span>
</div>
<div v-if="currentOrderNo" class="summary-item">
<span>订单号</span>
<span class="order-no">{{ currentOrderNo }}</span>
</div>
<div v-if="paymentStatus" class="summary-item">
<span>支付状态</span>
<span :class="['status', getStatusClass(paymentStatus)]">{{ getStatusText(paymentStatus) }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="modal-actions">
<Button @click="handleCancel" size="large">
取消
</Button>
<Button
type="primary"
size="large"
:loading="loading"
:disabled="!selectedPlan || !selectedPayment"
@click="handlePurchase"
>
立即购买 ¥{{ selectedPlan?.price || 0 }}
</Button>
</div>
</div>
</Modal>
<!-- 二维码支付弹窗 -->
<PaymentQRCodeModal
v-model:visible="qrCodeVisible"
:qr-code-url="qrCodeUrl"
:payment-method="selectedPayment"
:order-no="currentOrderNo"
:amount="selectedPlan?.price || 0"
@payment-success="handlePaymentSuccess"
@payment-failed="handlePaymentFailed"
@cancel="handleQrCodeCancel"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
Modal,
Button,
Radio,
Tag,
Divider,
message
} from 'ant-design-vue';
import {
CrownOutlined,
CheckCircleOutlined,
CheckOutlined,
WechatOutlined,
AlipayCircleOutlined,
LoadingOutlined
} from '@ant-design/icons-vue';
import { createPayment, queryPaymentStatus } from '@/api/contractReview/Payment';
import { useUserStore } from '@/store/modules/user';
import PaymentQRCodeModal from './PaymentQRCodeModal.vue';
interface MembershipPlan {
id: string;
duration: string;
price: number;
originalPrice?: number;
discount?: string;
}
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
'purchase-success': [plan: MembershipPlan];
}>();
//
const selectedPlan = ref<MembershipPlan | null>(null);
const selectedPayment = ref<string>('');
const loading = ref(false);
const currentOrderNo = ref<string>('');
const paymentStatus = ref<string>('');
const qrCodeVisible = ref(false);
const qrCodeUrl = ref<string>('');
//
const userStore = useUserStore();
//
const membershipPlans: MembershipPlan[] = [
{
id: '1month',
duration: '1个月',
price: 0.01,
originalPrice: 129,
discount: '限时优惠'
},
{
id: '3months',
duration: '季度',
price: 269,
originalPrice: 387,
discount: '省¥118'
},
{
id: '6months',
duration: '半年',
price: 499,
originalPrice: 774,
discount: '省¥275'
},
{
id: '12months',
duration: '12个月',
price: 899,
originalPrice: 1548,
discount: '省¥649'
}
];
//
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
//
function selectPlan(plan: MembershipPlan) {
selectedPlan.value = plan;
}
function handleCancel() {
visible.value = false;
selectedPlan.value = null;
selectedPayment.value = '';
currentOrderNo.value = '';
paymentStatus.value = '';
qrCodeVisible.value = false;
qrCodeUrl.value = '';
}
function handleQrCodeCancel() {
qrCodeVisible.value = false;
qrCodeUrl.value = '';
}
function handlePaymentSuccess() {
//
if (selectedPlan.value) {
emit('purchase-success', selectedPlan.value);
}
qrCodeVisible.value = false;
handleCancel();
}
function handlePaymentFailed() {
//
qrCodeVisible.value = false;
}
async function handlePurchase() {
if (!selectedPlan.value || !selectedPayment.value) {
message.warning('请选择套餐和支付方式');
return;
}
loading.value = true;
try {
//
const paymentRequest = {
planType: selectedPlan.value.id,
planName: selectedPlan.value.duration,
duration: getDurationInMonths(selectedPlan.value.duration),
amount: selectedPlan.value.price,
paymentMethod: selectedPayment.value,
userId: userStore.getUserInfo?.userId || 1,
clientIp: '',
notifyUrl: '',
returnUrl: ''
};
// API
const result = await createPayment(paymentRequest);
if (result.success) {
//
currentOrderNo.value = result.orderNo || '';
paymentStatus.value = result.payStatus || 'PENDING';
//
if (result.qrCodeUrl) {
qrCodeUrl.value = result.qrCodeUrl;
qrCodeVisible.value = true;
}
message.success('支付订单创建成功,请扫码支付');
} else {
message.error(result.errorMsg || '支付创建失败');
}
} catch (error) {
console.error('支付失败:', error);
message.error('购买失败,请重试');
} finally {
loading.value = false;
}
}
//
function getDurationInMonths(duration: string): number {
switch (duration) {
case '1个月':
return 1;
case '季度':
return 3;
case '半年':
return 6;
case '12个月':
return 12;
default:
return 1;
}
}
// PaymentQRCodeModal
//
function getStatusText(status: string): string {
const statusMap: Record<string, string> = {
'INIT': '订单生成',
'ING': '支付中',
'SUCCESS': '支付成功',
'FAIL': '支付失败',
'CANCEL': '已撤销',
'REFUND': '已退款',
'CLOSED': '已关闭',
'PENDING': '待支付' //
};
return statusMap[status] || '未知状态';
}
//
function getStatusClass(status: string): string {
const statusClassMap: Record<string, string> = {
'INIT': 'status-pending', // -
'ING': 'status-pending', // -
'SUCCESS': 'status-success', //
'FAIL': 'status-failed', //
'CANCEL': 'status-failed', // -
'REFUND': 'status-closed', // 退 -
'CLOSED': 'status-closed', //
'PENDING': 'status-pending' // -
};
return statusClassMap[status] || 'status-unknown';
}
</script>
<style lang="less" scoped>
.membership-modal {
:deep(.ant-modal-content) {
border-radius: 16px;
overflow: hidden;
}
:deep(.ant-modal-header) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-bottom: none;
padding: 24px 24px 20px;
.ant-modal-title {
color: white;
font-size: 18px;
font-weight: 600;
}
}
:deep(.ant-modal-close) {
color: white;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
:deep(.ant-modal-body) {
padding: 0;
}
}
.membership-content {
padding: 24px;
}
.membership-benefits {
margin-bottom: 32px;
padding: 20px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px;
.benefits-header {
display: flex;
align-items: center;
margin-bottom: 16px;
.crown-icon {
font-size: 24px;
color: #ffd700;
margin-right: 12px;
}
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.benefits-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
.benefit-item {
display: flex;
align-items: center;
padding: 8px 0;
.benefit-icon {
color: #52c41a;
margin-right: 8px;
font-size: 16px;
}
span {
color: #666;
font-size: 14px;
}
}
}
}
.membership-plans {
margin-bottom: 32px;
h4 {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
color: #333;
}
.plans-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.plan-card {
border: 2px solid #e8e8e8;
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: all 0.3s ease;
background: white;
min-width: 0;
&:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
}
&.active {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
color: #1890ff;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(24, 144, 255, 0.15);
.plan-price {
color: #1890ff;
.currency {
color: #1890ff;
}
.amount {
color: #1890ff;
}
}
.feature-item {
color: #333;
.feature-icon {
color: #52c41a;
}
}
}
.plan-header {
text-align: center;
margin-bottom: 16px;
.plan-duration {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
}
.plan-price {
.currency {
font-size: 12px;
color: #666;
}
.amount {
font-size: 20px;
font-weight: 700;
color: #667eea;
}
}
}
.plan-features {
margin-bottom: 16px;
.feature-item {
display: flex;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
color: #666;
.feature-icon {
margin-right: 4px;
color: #52c41a;
font-size: 10px;
}
&.discount {
justify-content: center;
margin-top: 6px;
}
}
}
.plan-action {
.ant-btn {
border-radius: 8px;
font-weight: 500;
}
}
}
}
}
.payment-methods {
margin-bottom: 24px;
h4 {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
color: #333;
}
.payment-options {
display: flex;
gap: 16px;
.payment-option {
flex: 1;
.payment-item {
display: flex;
align-items: center;
padding: 12px 16px;
border: 2px solid #e8e8e8;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: #667eea;
}
.payment-icon {
font-size: 20px;
margin-right: 8px;
&.wechat {
color: #07c160;
}
&.alipay {
color: #1677ff;
}
}
span {
font-size: 14px;
color: #333;
}
}
}
:deep(.ant-radio-wrapper) {
width: 100%;
margin-right: 0;
.ant-radio {
display: none;
}
&.ant-radio-wrapper-checked .payment-item {
border-color: #667eea;
background: #f0f2ff;
}
}
}
}
.order-summary {
margin-bottom: 24px;
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
.price {
font-weight: 600;
color: #667eea;
font-size: 16px;
}
.order-no {
font-family: monospace;
color: #666;
font-size: 12px;
}
.status {
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
&.status-pending {
background: #fff7e6;
color: #fa8c16;
}
&.status-success {
background: #f6ffed;
color: #52c41a;
}
&.status-failed {
background: #fff2f0;
color: #ff4d4f;
}
&.status-closed {
background: #f5f5f5;
color: #999;
}
&.status-unknown {
background: #f0f0f0;
color: #666;
}
}
&.discount {
.original-price {
text-decoration: line-through;
color: #999;
}
}
}
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
.ant-btn {
min-width: 100px;
border-radius: 8px;
font-weight: 500;
}
}
//
@media (max-width: 1000px) {
.plans-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 12px !important;
}
}
@media (max-width: 768px) {
.membership-content {
padding: 16px;
}
.plans-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 12px !important;
}
.payment-options {
flex-direction: column;
gap: 12px;
}
.modal-actions {
flex-direction: column;
.ant-btn {
width: 100%;
}
}
}
@media (max-width: 480px) {
.plans-grid {
grid-template-columns: 1fr !important;
}
.benefits-list {
grid-template-columns: 1fr !important;
}
}
// PaymentQRCodeModal.vue
</style>

370
src/views/user/home/components/PaymentQRCodeModal.vue

@ -0,0 +1,370 @@
<template>
<Modal
v-model:open="visible"
title="等待支付"
:width="400"
:footer="null"
:closable="true"
@cancel="handleCancel"
class="qr-code-modal"
>
<div class="qr-code-content">
<div class="qr-code-wrapper">
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="支付二维码" class="qr-code-image" />
<div v-else class="qr-code-placeholder">
<LoadingOutlined class="loading-icon" />
<span>正在生成二维码...</span>
</div>
</div>
<div class="payment-instruction">
<WechatOutlined v-if="paymentMethod === 'wechat'" class="payment-icon wechat" />
<AlipayCircleOutlined v-else class="payment-icon alipay" />
<span>请使用{{ paymentMethod === 'wechat' ? '微信' : '支付宝' }}扫码支付</span>
</div>
<div class="order-info">
<div class="order-item">
<span>订单号</span>
<span class="order-no">{{ orderNo }}</span>
</div>
<div class="order-item">
<span>金额</span>
<span class="amount">¥{{ amount }}</span>
</div>
<div v-if="paymentStatus" class="order-item">
<span>支付状态</span>
<span :class="['status', getStatusClass(paymentStatus)]">{{ getStatusText(paymentStatus) }}</span>
</div>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue';
import { Modal, message } from 'ant-design-vue';
import { WechatOutlined, AlipayCircleOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import { queryPaymentStatus } from '@/api/contractReview/Payment';
interface Props {
visible: boolean;
qrCodeUrl: string;
paymentMethod: string;
orderNo: string;
amount: number;
}
interface Emits {
(e: 'update:visible', value: boolean): void;
(e: 'payment-success'): void;
(e: 'payment-failed'): void;
(e: 'cancel'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
//
const paymentStatus = ref<string>('');
//
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
//
let pollingTimer: NodeJS.Timeout | null = null;
//
watch(
() => props.visible,
(newVisible) => {
if (newVisible && props.orderNo) {
//
startPaymentStatusPolling();
} else {
//
stopPaymentStatusPolling();
}
},
{ immediate: true }
);
//
watch(
() => props.orderNo,
(newOrderNo) => {
if (newOrderNo && props.visible) {
//
startPaymentStatusPolling();
}
}
);
//
function handleCancel() {
stopPaymentStatusPolling();
paymentStatus.value = '';
emit('cancel');
}
//
function startPaymentStatusPolling() {
if (!props.orderNo) return;
//
stopPaymentStatusPolling();
console.log('开始轮询支付状态,订单号:', props.orderNo);
//
checkPaymentStatus();
// 3
pollingTimer = setInterval(() => {
checkPaymentStatus();
}, 3000);
}
//
function stopPaymentStatusPolling() {
if (pollingTimer) {
console.log('停止轮询支付状态');
clearInterval(pollingTimer);
pollingTimer = null;
}
}
//
async function checkPaymentStatus() {
if (!props.orderNo) return;
try {
console.log('查询支付状态:', props.orderNo);
const result = await queryPaymentStatus(props.orderNo);
if (result.success) {
paymentStatus.value = result.payStatus;
console.log('支付状态:', result.payStatus);
if (result.payStatus === 'SUCCESS') {
//
message.success('支付成功!会员权益已生效');
emit('payment-success');
stopPaymentStatusPolling();
} else if (['CLOSED', 'FAIL', 'CANCEL'].includes(result.payStatus)) {
//
message.error('支付失败或已关闭');
emit('payment-failed');
stopPaymentStatusPolling();
}
} else {
console.error('查询支付状态失败:', result.errorMsg);
}
} catch (error) {
console.error('查询支付状态异常:', error);
}
}
//
function getStatusText(status: string): string {
const statusMap: Record<string, string> = {
'INIT': '订单生成',
'ING': '支付中',
'SUCCESS': '支付成功',
'FAIL': '支付失败',
'CANCEL': '已撤销',
'REFUND': '已退款',
'CLOSED': '已关闭',
'PENDING': '待支付' //
};
return statusMap[status] || '未知状态';
}
//
function getStatusClass(status: string): string {
const statusClassMap: Record<string, string> = {
'INIT': 'status-pending', // -
'ING': 'status-pending', // -
'SUCCESS': 'status-success', //
'FAIL': 'status-failed', //
'CANCEL': 'status-failed', // -
'REFUND': 'status-closed', // 退 -
'CLOSED': 'status-closed', //
'PENDING': 'status-pending' // -
};
return statusClassMap[status] || 'status-unknown';
}
//
onUnmounted(() => {
stopPaymentStatusPolling();
});
</script>
<style lang="less" scoped>
//
.qr-code-modal {
:deep(.ant-modal-content) {
border-radius: 16px;
overflow: hidden;
}
:deep(.ant-modal-header) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-bottom: none;
padding: 20px 24px 16px;
.ant-modal-title {
color: white;
font-size: 16px;
font-weight: 600;
}
}
:deep(.ant-modal-close) {
color: white;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
:deep(.ant-modal-body) {
padding: 24px;
}
}
.qr-code-content {
text-align: center;
.qr-code-wrapper {
margin-bottom: 20px;
.qr-code-image {
width: 200px;
height: 200px;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 8px;
background: white;
}
.qr-code-placeholder {
width: 200px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #f8f9fa;
color: #666;
.loading-icon {
font-size: 24px;
color: #667eea;
margin-bottom: 8px;
}
span {
font-size: 14px;
}
}
}
.payment-instruction {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
.payment-icon {
font-size: 20px;
margin-right: 8px;
&.wechat {
color: #07c160;
}
&.alipay {
color: #1677ff;
}
}
span {
font-size: 14px;
color: #333;
}
}
.order-info {
text-align: left;
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
.order-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
&:last-child {
margin-bottom: 0;
}
.order-no {
font-family: monospace;
color: #666;
font-size: 12px;
}
.amount {
font-weight: 600;
color: #667eea;
font-size: 16px;
}
.status {
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
&.status-pending {
background: #fff7e6;
color: #fa8c16;
}
&.status-success {
background: #f6ffed;
color: #52c41a;
}
&.status-failed {
background: #fff2f0;
color: #ff4d4f;
}
&.status-closed {
background: #f5f5f5;
color: #999;
}
&.status-unknown {
background: #f0f0f0;
color: #666;
}
}
}
}
}
</style>

286
src/views/user/home/components/RecordsPage.vue

@ -0,0 +1,286 @@
<template>
<div class="records-page">
<div class="page-header">
<h5 class="page-title">审查记录</h5>
</div>
<div class="records-content">
<BasicTable @register="registerTable" @expand="tableExpand">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<TableAction
stopButtonPropagation
:actions="[
{
label: record.childrenTasks.length > 1 ? '下载全部' : '下载',
icon: IconEnum.DOWNLOAD,
type: 'primary',
color: 'success',
ghost: true,
ifShow: () => {
return record.progress.includes('100%');
},
onClick: handleDownload.bind(null, record),
},
]"
/>
</template>
</template>
<template #expandedRowRender>
<BasicTable @register="registerChildTable">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<TableAction
stopButtonPropagation
:actions="[
{
label: '详情',
icon: IconEnum.EDIT,
type: 'primary',
ghost: true,
ifShow: () => {
return record.progressStatus != 'PENDING' &&
record.progressStatus != 'STARTED' &&
record.progressStatus != 'REVOKED';
},
onClick: handleDetail.bind(null, record),
},
{
label: '下载',
icon: IconEnum.DOWNLOAD,
type: 'primary',
color: 'success',
ghost: true,
ifShow: () => {
return record.progressStatus != 'PENDING' &&
record.progressStatus != 'STARTED' &&
record.progressStatus != 'REVOKED';
},
onClick: handleDownload.bind(null, record),
},
{
label: '终止任务',
icon: IconEnum.DELETE,
type: 'primary',
danger: true,
ghost: true,
ifShow: () => {
return record.progressStatus == 'PENDING';
},
popConfirm: {
placement: 'left',
title: '是否终止当前任务?',
confirm: handleStop.bind(null, record),
},
},
]"
/>
</template>
</template>
</BasicTable>
</template>
</BasicTable>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BasicTable, useTable, TableAction } from '@/components/Table';
import { IconEnum } from '@/enums/appEnum';
import { DocumentTasksStop } from '@/api/documentReview/DocumentTasks';
import { ContractualTasksList } from '@/api/contractReview/ContractualTasks';
import { getDetailResultsByTaskId, ContractualTaskResultDownload } from '@/api/contractReview/ContractualTaskResults';
import { ContractualTaskResultDetailVO } from '@/api/contractReview/ContractualTaskResults/model';
import { formSchemas, columns, childColumns } from '@/views/contractReview/ContractualTasks/ContractualTasks.data';
//
const emit = defineEmits<{
detail: [record: Recordable];
resultDetailVisible: [visible: boolean];
resultDetail: [detail: ContractualTaskResultDetailVO[]];
taskInfo: [info: Recordable];
}>();
//
const childTableData = ref([]);
//
const [registerTable, { reload, multipleRemove, selected, getForm }] = useTable({
rowSelection: {
type: 'checkbox',
},
title: '合同任务列表',
api: ContractualTasksList,
showIndexColumn: false,
clickToRowSelect: false,
rowKey: 'id',
expandRowByClick: false,
useSearchForm: true,
formConfig: {
schemas: formSchemas,
baseColProps: {
xs: 24,
sm: 24,
md: 24,
lg: 6,
},
},
columns: columns,
actionColumn: {
width: 200,
title: '操作',
key: 'action',
fixed: 'right',
},
});
const [registerChildTable, { setProps: setChildProps }] = useTable({
size: 'small',
api: getchildTableData,
columns: childColumns,
rowKey: 'id',
useSearchForm: false,
showIndexColumn: false,
showTableSetting: false,
pagination: false,
maxHeight: 50,
actionColumn: {
width: 200,
title: '操作',
key: 'action',
fixed: 'right',
},
});
//
async function handleDetail(record: Recordable) {
try {
const detailRes = await getDetailResultsByTaskId(record.id);
if (detailRes && detailRes.length > 0) {
emit('resultDetail', detailRes);
emit('taskInfo', record);
emit('resultDetailVisible', true);
return;
}
} catch (detailEx) {
console.error('获取详细结果失败', detailEx);
}
}
function tableExpand(expanded, record) {
if (expanded) {
childTableData.value = record.childrenTasks;
console.log('expanded, record', expanded, record);
}
}
function getchildTableData() {
console.log('childTableData', childTableData.value);
const height = 50 * childTableData.value.length;
setChildProps({ maxHeight: height });
return Promise.resolve(childTableData.value);
}
async function handleStop(record: Recordable) {
await DocumentTasksStop(record.id);
await reload();
}
async function handleDownload(record: Recordable) {
if (record.childrenTasks?.length > 1) {
await ContractualTaskResultDownload(record.childrenTasks.map((item) => item.id));
await reload();
} else if (record.childrenTasks?.length == 1) {
await ContractualTaskResultDownload([record.childrenTasks[0].id]);
await reload();
} else {
await ContractualTaskResultDownload([record.id]);
await reload();
}
}
//
defineExpose({
reload,
});
</script>
<style lang="less" scoped>
.records-page {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
flex: 1;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.8rem;
border-bottom: 1px solid #eee;
.page-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
margin: 0;
}
}
.records-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
//
@media (max-width: 992px) {
.records-page {
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
}
.page-header {
margin-bottom: 1.5rem;
padding-bottom: 0.8rem;
}
.page-title {
font-size: 1.1rem;
}
}
@media (max-width: 768px) {
.records-page {
padding: 1.2rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.05);
}
.page-header {
margin-bottom: 1.5rem;
padding-bottom: 0.8rem;
}
.page-title {
font-size: 1rem;
}
}
@media (max-width: 480px) {
.records-page {
padding: 1rem;
margin-bottom: 1.5rem;
}
.records-page {
min-height: calc(100vh - 200px);
}
}
</style>

297
src/views/user/home/components/ReviewPage.vue

@ -0,0 +1,297 @@
<template>
<div class="review-page">
<!-- 文件上传区域 -->
<div class="upload-section">
<!-- 审查选项 -->
<div class="review-options">
<div class="section-title">选择审查任务类型可多选</div>
<div class="option-buttons">
<div
:class="['option-button', selectedOptions.includes('substantive') ? 'selected' : '']"
@click="toggleOption('substantive')"
>
<span class="option-icon">📄</span>
<div class="option-content">
<div class="option-name">实质性审查</div>
<div class="option-desc">对合同条款的合理性完整性进行深度分析</div>
</div>
</div>
<div
:class="['option-button', 'disabled']"
>
<span class="option-icon"></span>
<div class="option-content">
<div class="option-name">合规性审查</div>
<div class="option-desc">检查合同是否符合相关法律法规要求</div>
<div class="coming-soon">即将推出</div>
</div>
</div>
<div
:class="['option-button', 'disabled']"
>
<span class="option-icon">🔄</span>
<div class="option-content">
<div class="option-name">一致性审查</div>
<div class="option-desc">检查合同与投标文件或招标文件是否一致</div>
<div class="coming-soon">即将推出</div>
</div>
</div>
</div>
</div>
<!-- 文件上传组件 -->
<div class="file-upload-area">
<InferenceReview :reviewTypes="selectedOptions" @success="handleReviewSuccess" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import InferenceReview from '@/views/contractReview/ContractualTasks/components/InferenceReview.vue';
//
const emit = defineEmits<{
reviewSuccess: [data: any];
}>();
//
const selectedOptions = ref<string[]>(['substantive']);
//
function toggleOption(option: string) {
//
if (option !== 'substantive') {
return;
}
const index = selectedOptions.value.indexOf(option);
if (index > -1) {
selectedOptions.value.splice(index, 1);
} else {
selectedOptions.value.push(option);
}
}
function handleReviewSuccess(data: any) {
console.log('审查任务创建成功:', data);
message.success('合同审查任务已创建成功,正在后台处理');
emit('reviewSuccess', data);
}
</script>
<style lang="less" scoped>
.review-page {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 0;
padding: 1rem;
box-shadow: none;
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.upload-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 1rem;
justify-content: flex-start;
}
//
.section-title {
text-align: center;
font-size: 1.3rem;
font-weight: 600;
color: #333;
margin-bottom: 1.5rem;
}
//
.review-options {
margin-bottom: 1.5rem;
flex-shrink: 0;
}
.option-buttons {
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.option-button {
width: 280px;
min-height: 110px;
display: flex;
align-items: center;
padding: 1.5rem;
background-color: #fff;
border: 2px solid #e8e8e8;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.05);
&:hover {
border-color: #667eea;
box-shadow: 0 6px 18px rgba(102, 126, 234, 0.15);
transform: translateY(-2px);
}
&.selected {
border-color: #667eea;
background: linear-gradient(135deg, #f6f9ff 0%, #e8f4ff 100%);
box-shadow: 0 6px 18px rgba(102, 126, 234, 0.25);
transform: translateY(-1px);
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #f5f5f5;
border-color: #d9d9d9;
&:hover {
border-color: #d9d9d9;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.05);
transform: none;
}
.option-icon {
opacity: 0.6;
}
.option-name {
color: #999;
}
.option-desc {
color: #999;
}
.coming-soon {
font-size: 0.75rem;
color: #ff6b6b;
font-weight: 500;
margin-top: 0.3rem;
background: rgba(255, 107, 107, 0.1);
padding: 0.2rem 0.5rem;
border-radius: 4px;
display: inline-block;
}
}
.option-icon {
font-size: 1.8rem;
margin-right: 1rem;
flex-shrink: 0;
}
.option-content {
flex: 1;
}
.option-name {
font-size: 1rem;
font-weight: 600;
color: #333;
margin-bottom: 0.4rem;
}
.option-desc {
font-size: 0.85rem;
color: #666;
line-height: 1.4;
}
}
//
.file-upload-area {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: white;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
margin-top: 3rem;
}
//
@media (max-width: 992px) {
.option-buttons {
flex-direction: column;
align-items: center;
gap: 1rem;
}
.option-button {
width: 100%;
max-width: 350px;
min-height: 100px;
padding: 1.2rem;
}
}
@media (max-width: 768px) {
.option-buttons {
flex-direction: column;
align-items: center;
gap: 0.8rem;
}
.option-button {
width: 100%;
max-width: 300px;
padding: 0.8rem 1.5rem;
min-height: 90px;
.option-icon {
font-size: 1.5rem;
margin-right: 0.8rem;
}
.option-name {
font-size: 0.95rem;
}
.option-desc {
font-size: 0.8rem;
}
}
}
@media (max-width: 480px) {
.option-buttons {
flex-direction: column;
align-items: center;
gap: 0.8rem;
}
.option-button {
width: 100%;
max-width: 300px;
padding: 1rem;
min-height: 90px;
.option-icon {
font-size: 1.5rem;
margin-right: 0.8rem;
}
.option-name {
font-size: 0.95rem;
}
.option-desc {
font-size: 0.8rem;
}
}
}
</style>

725
src/views/user/home/index.vue

@ -0,0 +1,725 @@
<template>
<div class="user-home-layout">
<!-- 左侧导航栏 -->
<div class="sidebar">
<!-- 品牌区域 -->
<div class="brand-section">
<div class="brand-logo">
<img src="/logo.png" alt="小研智审" class="logo-img" />
<span class="brand-text">小研智审</span>
</div>
</div>
<!-- 导航菜单 -->
<div class="nav-menu">
<div class="menu-section">
<div
:class="['menu-item', { active: activeTab === 'review' }]"
@click="setActiveTab('review')"
>
<span class="menu-icon">🏠</span>
<span class="menu-text">开始</span>
</div>
</div>
<div class="menu-section">
<div class="menu-title">合同审查</div>
<div
:class="['menu-item', 'sub-menu', { active: activeTab === 'records' }]"
@click="setActiveTab('records')"
>
<span class="menu-icon">📊</span>
<span class="menu-text">审查记录</span>
</div>
</div>
<div class="menu-section">
<div class="menu-title">合同配置</div>
<div
:class="['menu-item', 'sub-menu', { active: activeTab === 'checklist' }]"
@click="setActiveTab('checklist')"
>
<span class="menu-icon"></span>
<span class="menu-text">审查清单</span>
</div>
</div>
</div>
<!-- 底部用户区域 -->
<div class="user-section">
<Dropdown :trigger="['click']" placement="topRight">
<div class="user-info-card">
<div class="user-avatar-section">
<Avatar size="default" class="user-avatar-card">
<template #icon>
<UserOutlined />
</template>
</Avatar>
</div>
<div class="user-details">
<div class="user-name-card">
{{ userInfo.nickName || '超级管理员' }}
<Tag v-if="isVipMember" color="gold" class="vip-tag">
<CrownOutlined />
VIP
</Tag>
</div>
</div>
<div class="user-menu-trigger">
<DownOutlined class="dropdown-icon-card" />
</div>
</div>
<template #overlay>
<Menu @click="handleMenuClick">
<MenuItem key="profile">
<UserOutlined />
个人信息
</MenuItem>
<MenuItem key="membership">
<CrownOutlined />
开通会员
</MenuItem>
<MenuItem key="settings">
<SettingOutlined />
系统设置
</MenuItem>
<MenuDivider />
<MenuItem key="logout" class="logout-item">
<LogoutOutlined />
退出登录
</MenuItem>
</Menu>
</template>
</Dropdown>
</div>
</div>
<!-- 右侧主内容区 -->
<div class="main-area">
<!-- 主内容区 -->
<div class="content-area">
<!-- 开始审核页面 -->
<ReviewPage
v-if="activeTab === 'review'"
@review-success="handleReviewSuccess"
/>
<!-- 审查记录页面 -->
<RecordsPage
v-if="activeTab === 'records'"
ref="recordsPageRef"
@detail="handleDetail"
@result-detail-visible="resultDetailDrawerVisible = $event"
@result-detail="taskResultDetail = $event"
@task-info="currentTaskInfo = $event"
/>
<!-- 审查清单页面 -->
<ChecklistPage v-if="activeTab === 'checklist'" />
</div>
</div>
<!-- 弹窗组件 -->
<ContractualTasksModal @register="registerModal" @reload="() => recordsPageRef.value?.reload()" />
<DocsDrawer @register="registerDrawer" />
<ContractualResultDetailDrawer
:visible="resultDetailDrawerVisible"
:taskResultDetail="taskResultDetail"
:taskInfo="currentTaskInfo"
@update:visible="resultDetailDrawerVisible = $event"
@close="handleResultDetailDrawerClose"
/>
<MembershipModal
:visible="membershipModalVisible"
@update:visible="membershipModalVisible = $event"
@purchase-success="handleMembershipPurchaseSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal, Dropdown, Menu, MenuItem, MenuDivider, Avatar, Tag } from 'ant-design-vue';
import {
UserOutlined,
DownOutlined,
SettingOutlined,
LogoutOutlined,
CrownOutlined
} from '@ant-design/icons-vue';
import { BasicTable, useTable, TableAction } from '@/components/Table';
import { useModal } from '@/components/Modal';
import { useDrawer } from '@/components/Drawer';
import { IconEnum } from '@/enums/appEnum';
import { useUserStore } from '@/store/modules/user';
//
import ReviewPage from './components/ReviewPage.vue';
import RecordsPage from './components/RecordsPage.vue';
import ChecklistPage from './components/ChecklistPage.vue';
//
import ContractualTasksModal from '@/views/contractReview/ContractualTasks/ContractualTasksModal.vue';
import DocsDrawer from '@/views/documentReview/DocumentTasks/DocsDrawer.vue';
import ContractualResultDetailDrawer from '@/views/contractReview/ContractualTasks/ContractualResultDetailDrawer.vue';
import MembershipModal from './components/MembershipModal.vue';
//
import { ContractualTaskResultDetailVO } from '@/api/contractReview/ContractualTaskResults/model';
defineOptions({ name: 'UserHome' });
const router = useRouter();
const userStore = useUserStore();
//
const activeTab = ref<'review' | 'records' | 'checklist'>('review');
const resultDetailDrawerVisible = ref(false);
const taskResultDetail = ref<ContractualTaskResultDetailVO[]>([]);
const currentTaskInfo = ref<Recordable>({});
const membershipModalVisible = ref(false);
const isVipMember = ref(false); //
//
const userInfo = computed(() => userStore.getUserInfo || {});
//
const recordsPageRef = ref();
//
const [registerDrawer, { openDrawer }] = useDrawer();
const [registerModal, { openModal }] = useModal();
//
function setActiveTab(tab: 'review' | 'records' | 'checklist') {
activeTab.value = tab;
if (tab === 'records' && recordsPageRef.value) {
recordsPageRef.value.reload();
}
}
//
function handleMenuClick({ key }: any) {
switch (key) {
case 'profile':
//
message.info('个人信息功能开发中');
break;
case 'membership':
//
membershipModalVisible.value = true;
break;
case 'settings':
//
message.info('系统设置功能开发中');
break;
case 'logout':
// 退
Modal.confirm({
title: '确认退出',
content: '您确定要退出登录吗?',
okText: '确定',
cancelText: '取消',
onOk() {
handleLogout();
},
});
break;
}
}
// 退
async function handleLogout() {
try {
await userStore.logout();
message.success('退出登录成功');
router.push('/user/login');
} catch (error) {
console.error('退出登录失败:', error);
message.error('退出登录失败');
}
}
function handleReviewSuccess(data: any) {
console.log('审查任务创建成功:', data);
message.success('合同审查任务已创建成功,正在后台处理');
//
setTimeout(() => {
setActiveTab('records');
}, 1000);
}
function handleDetail(record: Recordable) {
// RecordsPage
console.log('handleDetail called with:', record);
}
function handleResultDetailDrawerClose() {
resultDetailDrawerVisible.value = false;
}
function handleMembershipPurchaseSuccess(plan: any) {
console.log('会员购买成功:', plan);
message.success(`恭喜!${plan.duration}会员开通成功`);
//
isVipMember.value = true;
// API
}
// refresh
watch(
() => router.currentRoute.value.query.refresh,
(newVal) => {
if (newVal && activeTab.value === 'records' && recordsPageRef.value) {
recordsPageRef.value.reload();
router.replace({
path: router.currentRoute.value.path,
query: { ...router.currentRoute.value.query, refresh: undefined }
});
}
}
);
</script>
<style lang="less" scoped>
.user-home-layout {
display: flex;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
//
.sidebar {
width: 280px;
background: #fff;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.08);
position: fixed;
top: 0;
left: 0;
height: 100%;
z-index: 100;
}
.brand-section {
text-align: center;
margin-bottom: 2.5rem;
.brand-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
margin-bottom: 1rem;
.logo-img {
width: 50px;
height: 50px;
border-radius: 10px;
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
}
.brand-text {
font-size: 1.8rem;
font-weight: 700;
color: #333;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
}
}
.nav-menu {
flex: 1;
overflow-y: auto;
padding-right: 10px;
.menu-section {
margin-bottom: 1.5rem;
&:last-child {
margin-bottom: 0;
}
.menu-title {
font-size: 0.9rem;
color: #888;
margin-bottom: 0.5rem;
padding-left: 0.5rem;
}
}
}
.menu-item {
display: flex;
align-items: center;
padding: 0.8rem 1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
color: #666;
font-weight: 500;
&:hover {
background: #f8f9fa;
color: #333;
}
&.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
transform: translateY(-2px);
}
.menu-icon {
font-size: 1.2rem;
margin-right: 0.8rem;
flex-shrink: 0;
}
.menu-text {
font-size: 0.95rem;
}
&.sub-menu {
padding-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
border-left: 2px solid #e8e8e8;
&:hover {
background: #f8f9fa;
border-left-color: #667eea;
}
&.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
transform: translateY(-2px);
border-left-color: #667eea;
}
}
}
.user-section {
margin-top: 2rem;
.user-info-card {
display: flex;
align-items: center;
padding: 1rem;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: #e9ecef;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.user-avatar-section {
margin-right: 0.8rem;
.user-avatar-card {
background: #667eea;
color: white;
border: none;
}
}
.user-details {
flex: 1;
.user-name-card {
font-size: 0.9rem;
font-weight: 600;
color: #333;
margin-bottom: 0.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
.vip-tag {
font-size: 0.7rem;
padding: 0.1rem 0.3rem;
border-radius: 4px;
display: flex;
align-items: center;
gap: 0.2rem;
}
}
.user-role {
font-size: 0.75rem;
color: #666;
}
}
.user-menu-trigger {
padding: 0.3rem;
border-radius: 6px;
transition: all 0.3s ease;
.dropdown-icon-card {
color: #666;
font-size: 0.8rem;
}
}
}
}
//
.main-area {
flex: 1;
margin-left: 280px; /* Match sidebar width */
padding: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
.content-area {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 1rem;
}
//
:deep(.ant-dropdown-menu) {
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 0.5rem 0;
.ant-dropdown-menu-item {
padding: 0.6rem 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
&:hover {
background: #f8f9fa;
}
}
.logout-item {
color: #ff4d4f;
border-top: 1px solid #f0f0f0;
margin-top: 0.25rem;
&:hover {
background: #fff2f0;
color: #ff4d4f;
}
}
}
//
@media (max-width: 1200px) {
.sidebar {
width: 220px;
padding: 1.5rem 1rem;
}
.brand-section .brand-text {
font-size: 1.5rem;
}
.nav-menu .menu-item .menu-text {
font-size: 0.85rem;
}
.main-area {
margin-left: 220px;
padding: 0;
}
.content-area {
padding: 1rem;
}
}
@media (max-width: 992px) {
.user-home-layout {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
flex-direction: row;
justify-content: space-around;
padding: 1rem 0.5rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.brand-section {
flex: 1;
text-align: left;
margin-bottom: 0;
padding-left: 1rem;
}
.nav-menu {
flex: 2;
padding: 0 1rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.user-section {
margin-top: 0;
padding: 1rem 0.5rem;
border-top: 1px solid #eee;
}
.main-area {
margin-left: 0;
padding: 0;
}
.content-area {
padding: 1rem;
}
.content-area {
flex-direction: row;
}
}
@media (max-width: 768px) {
.sidebar {
flex-direction: column;
padding: 0.8rem 0.5rem;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
}
.brand-section {
padding-left: 0;
margin-bottom: 1rem;
}
.nav-menu {
padding: 0 0.5rem;
overflow-x: hidden;
}
.nav-menu .menu-section {
margin-bottom: 1rem;
}
.nav-menu .menu-title {
font-size: 0.8rem;
padding-left: 0;
}
.nav-menu .menu-item .menu-text {
font-size: 0.8rem;
}
.user-section {
padding: 0.8rem 0.5rem;
border-top: none;
}
.user-info-card {
padding: 0.8rem;
.user-name-card {
display: none;
}
.user-role {
display: none;
}
.dropdown-icon-card {
font-size: 0.7rem;
}
}
.main-area {
padding: 0;
}
.content-area {
padding: 1rem;
}
.content-area {
flex-direction: column;
}
}
@media (max-width: 480px) {
.sidebar {
padding: 0.6rem 0.4rem;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.03);
}
.brand-section .brand-text {
font-size: 1.3rem;
}
.nav-menu .menu-item .menu-text {
font-size: 0.75rem;
}
.user-section {
padding: 0.6rem 0.4rem;
}
.user-info-card {
padding: 0.6rem;
border-radius: 16px;
.user-name-card {
display: none;
}
.user-role {
display: none;
}
.dropdown-icon-card {
font-size: 0.6rem;
}
}
.main-area {
padding: 0;
}
.content-area {
padding: 0.8rem;
}
}
</style>

728
src/views/user/login/index.vue

@ -0,0 +1,728 @@
<template>
<div class="user-login-page">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="decoration-circle circle-1"></div>
<div class="decoration-circle circle-2"></div>
<div class="decoration-circle circle-3"></div>
</div>
<div class="login-container">
<!-- 左侧功能展示 -->
<div class="left-section">
<div class="brand-info">
<div class="logo-section">
<img src="/logo.png" alt="小研智审" class="logo" />
<h1 class="brand-name">小研智审</h1>
</div>
<p class="brand-desc">合同全能助手AI赋能读改审</p>
</div>
<div class="features-showcase">
<div class="feature-card">
<div class="feature-icon">📋</div>
<h3>合同审查</h3>
<p>智能识别合同条款中的不平等内容确保权利义务分配合理</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>法规检查</h3>
<p>依据现行法律法规行业规范和监管要求进行全面审查</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<h3>对比审查</h3>
<p>将合同条款与招标文件中的合同要求进行逐一比对</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>结果生成</h3>
<p>生成详细的审查报告和改进建议</p>
</div>
</div>
</div>
<!-- 右侧登录区域 -->
<div class="right-section">
<div class="login-form-container">
<!-- 标题 -->
<div class="form-header">
<h2>欢迎使用</h2>
<p>请输入您的账号信息</p>
</div>
<!-- 账号密码登录表单 -->
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<input
v-model="formData.username"
type="text"
placeholder="请输入用户名/手机号"
class="form-input"
required
/>
</div>
<div class="form-group">
<div class="password-input-wrapper">
<input
v-model="formData.password"
:type="passwordVisible ? 'text' : 'password'"
placeholder="请输入密码"
class="form-input password-input"
required
/>
<button
type="button"
class="password-toggle-btn"
@click="togglePasswordVisibility"
>
<svg
v-if="!passwordVisible"
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg
v-else
class="eye-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
</div>
<div class="form-group captcha-group" v-if="captcha.required">
<input
v-model="formData.code"
type="text"
placeholder="请输入验证码"
class="form-input captcha-input"
required
/>
<div class="captcha-image" @click="refreshCaptcha">
<img v-if="captcha.image" :src="captcha.image" alt="验证码" />
<span v-else class="captcha-loading">加载中...</span>
</div>
</div>
<div class="form-options">
<label class="remember-me">
<input v-model="rememberMe" type="checkbox" />
<span class="checkmark"></span>
记住密码
</label>
<a href="#" class="forgot-password" @click="handleForgotPassword($event)">忘记密码</a>
</div>
<button
type="submit"
class="login-btn"
:disabled="loading"
:class="{ loading }"
>
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<!-- 其他登录选项 -->
<div class="other-options">
<p class="register-link">
还没有账号<a href="#" @click="handleRegister($event)">立即注册</a>
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@/hooks/web/useMessage';
import { loginApi } from '@/api/auth';
import { captchaImage } from '@/api/auth/captcha';
import { useUserStore } from '@/store/modules/user';
const router = useRouter();
const { notification, createErrorModal } = useMessage();
const userStore = useUserStore();
//
const formData = reactive({
username: '',
password: '',
code: '',
uuid: '',
tenantId: '000000', // ID
});
//
const captcha = reactive({
required: false,
image: '',
loading: false,
});
//
const loading = ref(false);
const rememberMe = ref(false);
const passwordVisible = ref(false); // /
//
const refreshCaptcha = async () => {
try {
captcha.loading = true;
const result = await captchaImage();
captcha.required = result.captchaEnabled;
captcha.image = result.captchaEnabled ? `data:image/gif;base64,${result.img}` : '';
formData.uuid = result.uuid;
formData.code = '';
} catch (error) {
console.error('获取验证码失败:', error);
} finally {
captcha.loading = false;
}
};
// /
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value;
};
//
const handleLogin = async () => {
if (loading.value) return;
try {
loading.value = true;
const params = {
username: formData.username,
password: formData.password,
grantType: 'password' as const,
tenantId: formData.tenantId,
...(captcha.required && { code: formData.code, uuid: formData.uuid }),
};
const userInfo = await userStore.login({
...params,
mode: 'none',
});
if (userInfo) {
notification.success({
message: '登录成功',
description: `欢迎回来,${userInfo.nickName}`,
duration: 3,
});
//
router.push('/user/home');
}
} catch (error: any) {
const content = error.message || '登录失败,请稍后重试';
createErrorModal({
title: '登录失败',
content,
onOk() {
if (captcha.required && (content.includes('验证码') || content.includes('Captcha'))) {
formData.code = '';
refreshCaptcha();
}
},
});
} finally {
loading.value = false;
}
};
//
const handleRegister = (event?: Event) => {
//
if (event) {
event.preventDefault();
}
//
router.push('/user/register');
};
//
const handleForgotPassword = (event?: Event) => {
//
if (event) {
event.preventDefault();
}
//
router.push('/user/forgot-password');
};
//
onMounted(async () => {
await refreshCaptcha();
});
</script>
<style lang="less" scoped>
* {
box-sizing: border-box;
}
.user-login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
//
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
.decoration-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.circle-1 {
width: 300px;
height: 300px;
top: -150px;
right: -150px;
}
&.circle-2 {
width: 200px;
height: 200px;
bottom: -100px;
left: -100px;
}
&.circle-3 {
width: 150px;
height: 150px;
top: 50%;
left: 20%;
transform: translateY(-50%);
}
}
}
.login-container {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1200px;
width: 90%;
min-height: 600px;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
z-index: 2;
}
//
.left-section {
padding: 3rem;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
display: flex;
flex-direction: column;
justify-content: center;
}
.brand-info {
text-align: center;
margin-bottom: 3rem;
.logo-section {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1rem;
.logo {
width: 48px;
height: 48px;
}
.brand-name {
font-size: 2rem;
font-weight: 700;
color: #2c3e50;
margin: 0;
}
}
.brand-desc {
font-size: 1.1rem;
color: #7f8c8d;
margin: 0;
}
}
.features-showcase {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.feature-card {
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.feature-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
h3 {
font-size: 1rem;
font-weight: 600;
color: #2c3e50;
margin: 0 0 0.5rem 0;
}
p {
font-size: 0.85rem;
color: #7f8c8d;
line-height: 1.4;
margin: 0;
}
}
//
.right-section {
padding: 2rem;
display: flex;
flex-direction: column;
position: relative;
}
.login-form-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 400px;
margin: 0 auto;
width: 100%;
}
.form-header {
text-align: center;
margin-bottom: 2rem;
h2 {
font-size: 1.8rem;
font-weight: 600;
color: #2c3e50;
margin: 0 0 0.5rem 0;
}
p {
color: #7f8c8d;
margin: 0;
}
}
//
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
position: relative;
&.captcha-group {
display: flex;
gap: 1rem;
.captcha-input {
flex: 1;
}
.captcha-image {
width: 120px;
height: 40px;
border: 1px solid #d9d9d9;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
img {
max-width: 100%;
max-height: 100%;
}
.captcha-loading {
font-size: 0.8rem;
color: #999;
}
}
}
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s, box-shadow 0.3s;
&:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
&::placeholder {
color: #bfbfbf;
}
}
.password-input-wrapper {
position: relative;
.form-input {
padding-right: 40px; //
}
}
.password-toggle-btn {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 5px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.3s ease;
.eye-icon {
width: 18px;
height: 18px;
color: #999;
transition: color 0.3s ease;
}
&:hover {
background-color: rgba(102, 126, 234, 0.1);
.eye-icon {
color: #667eea;
}
}
&:active {
transform: translateY(-50%) scale(0.95);
}
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
}
.remember-me {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.9rem;
color: #666;
input[type="checkbox"] {
display: none;
}
.checkmark {
width: 16px;
height: 16px;
border: 2px solid #d9d9d9;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
&::after {
content: '✓';
font-size: 10px;
color: white;
opacity: 0;
transition: opacity 0.3s;
}
}
input[type="checkbox"]:checked + .checkmark {
background: #667eea;
border-color: #667eea;
&::after {
opacity: 1;
}
}
}
.forgot-password {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
&:hover {
text-decoration: underline;
}
}
.login-btn {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover:not(:disabled) {
background: #5a6fd8;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
&.loading {
pointer-events: none;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
//
.other-options {
margin-top: 2rem;
text-align: center;
.register-link {
color: #666;
font-size: 0.9rem;
margin: 0;
a {
color: #667eea;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
//
@media (max-width: 768px) {
.login-container {
grid-template-columns: 1fr;
width: 95%;
min-height: auto;
}
.left-section {
display: none;
}
.right-section {
padding: 2rem 1.5rem;
}
.features-showcase {
grid-template-columns: 1fr;
gap: 1rem;
}
}
@media (max-width: 480px) {
.user-login-page {
padding: 1rem;
}
.login-container {
width: 100%;
border-radius: 12px;
}
.right-section {
padding: 1.5rem 1rem;
}
}
</style>
Loading…
Cancel
Save