This commit is contained in:
李志强 2026-04-01 10:12:52 +08:00
parent f9db706769
commit 90b290af1f
13 changed files with 999 additions and 40 deletions

View File

@ -5,11 +5,25 @@ export function login(data) {
return request({ return request({
url: `/admin/login`, url: `/admin/login`,
method: "post", method: "post",
data: { data,
tenant_name: data.tenant_name, });
account: data.account, }
password: data.password,
}, // 发送登录验证码(手机号)
export function sendLoginCode(data) {
return request({
url: "/admin/sendLoginCode",
method: "post",
data,
});
}
// 手机号验证码登录
export function loginBySms(data) {
return request({
url: "/admin/loginBySms",
method: "post",
data,
}); });
} }
// 登出 // 登出
@ -65,3 +79,39 @@ export function getOpenVerify() {
method: 'get' method: 'get'
}); });
} }
// 注册
export function register(data) {
return request({
url: "/admin/register",
method: "post",
data,
});
}
// 发送注册验证码
export function sendRegisterCode(data) {
return request({
url: "/admin/sendRegisterCode",
method: "post",
data,
});
}
// 忘记密码重置
export function resetPassword(data) {
return request({
url: "/admin/resetPassword",
method: "post",
data,
});
}
// 发送找回密码验证码
export function sendResetCode(data) {
return request({
url: "/admin/sendResetCode",
method: "post",
data,
});
}

56
src/api/sms.ts Normal file
View File

@ -0,0 +1,56 @@
import request from "@/utils/request";
/**
*
*/
export function getSmsInfo() {
return request({
url: "/admin/sms/info",
method: "get",
});
}
/**
*
*/
export function editSmsInfo(data: any) {
return request({
url: "/admin/sms/editinfo",
method: "post",
data,
});
}
/**
*
*/
export function sendTestSms(data: any) {
return request({
url: "/admin/sms/sendtest",
method: "post",
data,
});
}
/**
*
*/
export function getSmsTaskList(params: { status?: string | number; phone?: string } = {}) {
return request({
url: "/admin/sms/taskList",
method: "get",
params,
});
}
/**
*
*/
export function editSmsTask(id: number | string, data: any) {
return request({
url: `/admin/sms/taskEdit/${id}`,
method: "post",
data,
});
}

View File

@ -24,6 +24,18 @@ const staticRoutes = [
component: () => import("@/views/login/index.vue"), component: () => import("@/views/login/index.vue"),
meta: { requiresAuth: false } meta: { requiresAuth: false }
}, },
{
path: "/register",
name: "Register",
component: () => import("@/views/login/register.vue"),
meta: { requiresAuth: false }
},
{
path: "/forget",
name: "ForgetPassword",
component: () => import("@/views/login/forget.vue"),
meta: { requiresAuth: false }
},
{ {
path: "/home", path: "/home",
name: "Home", name: "Home",
@ -151,8 +163,9 @@ function findFirstValidRoute(routes) {
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const publicPaths = ["/login", "/register", "/forget"];
if (to.path === "/login") { if (publicPaths.includes(to.path)) {
if (token) { if (token) {
if (!dynamicRoutesAdded) { if (!dynamicRoutesAdded) {
await loadAndAddDynamicRoutes(); await loadAndAddDynamicRoutes();

View File

@ -96,8 +96,8 @@
import { ref, reactive, watch, nextTick } from 'vue'; import { ref, reactive, watch, nextTick } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'; import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { Plus, Picture } from '@element-plus/icons-vue'; import { Plus, Picture } from '@element-plus/icons-vue';
import { createBaby, editBaby } from '@/api/babyhealth'; // import { createBaby, editBaby } from '@/api/babyhealth';
import { uploadAvatar } from '@/api/file'; // import { uploadAvatar } from '@/api/file';
import ImgCutter from 'vue-img-cutter'; import ImgCutter from 'vue-img-cutter';

View File

@ -87,8 +87,8 @@ import { ref, computed, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Plus } from '@element-plus/icons-vue'; import { Plus } from '@element-plus/icons-vue';
import type { UploadProps, UploadRequestOptions } from 'element-plus'; import type { UploadProps, UploadRequestOptions } from 'element-plus';
import { createUser, updateUser, getUserDetail } from "@/api/babyhealth"; // import { createUser, updateUser, getUserDetail } from "@/api/babyhealth";
import { uploadAvatar } from '@/api/file'; // import { uploadAvatar } from '@/api/file';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {

View File

@ -205,7 +205,7 @@ const cateOptions = ref([]);
const form = reactive({ const form = reactive({
title: "", title: "",
author: "云泽网", author: "",
cate: "", cate: "",
content: "", content: "",
image: "", image: "",
@ -503,7 +503,7 @@ function resetForm() {
// //
Object.assign(form, { Object.assign(form, {
title: "", title: "",
author: "云泽网", author: "",
cate: "", cate: "",
content: "", content: "",
image: "", image: "",

View File

@ -20,6 +20,16 @@
<el-divider></el-divider> <el-divider></el-divider>
<div class="filter-bar">
<el-form inline>
<el-form-item label="租户筛选">
<el-select v-model="selectedTenantId" style="width: 220px" disabled>
<el-option :label="`当前租户ID: ${selectedTenantId || '-'})`" :value="selectedTenantId" />
</el-select>
</el-form-item>
</el-form>
</div>
<!-- 用户列表表格 --> <!-- 用户列表表格 -->
<el-table :data="users" style="width: 100%" v-loading="loading"> <el-table :data="users" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" align="center" fixed="left" /> <el-table-column prop="id" label="ID" align="center" fixed="left" />
@ -84,14 +94,16 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh } from "@element-plus/icons-vue"; import { Plus, Refresh } from "@element-plus/icons-vue";
import { getAllUsers, deleteUser } from "@/api/user"; import { getTenantUsers, deleteUser } from "@/api/user";
import { getAllRoles } from "@/api/role"; import { getAllRoles } from "@/api/role";
import { useAuthStore } from "@/stores/auth";
import UserEditDialog from "./components/userEdit.vue"; import UserEditDialog from "./components/userEdit.vue";
import ChangePasswordDialog from "./components/changePassword.vue"; import ChangePasswordDialog from "./components/changePassword.vue";
import PreviewDialog from "./components/preview.vue"; import PreviewDialog from "./components/preview.vue";
interface User { interface User {
id: number; id: number;
tid?: number;
account: string; account: string;
name: string; name: string;
phone: string; phone: string;
@ -121,6 +133,8 @@ const total = ref(0);
const users = ref<User[]>([]); const users = ref<User[]>([]);
const roles = ref<Role[]>([]); const roles = ref<Role[]>([]);
const loading = ref(false); const loading = ref(false);
const authStore = useAuthStore();
const selectedTenantId = ref<number>(Number(authStore.user.tid || 0));
// //
const userEditRef = ref(); const userEditRef = ref();
@ -174,10 +188,17 @@ function getRoleTagText(roles: Role[] | undefined, group_id: number): string {
const fetchUsers = async () => { const fetchUsers = async () => {
loading.value = true; loading.value = true;
try { try {
const res = await getAllUsers(); if (!selectedTenantId.value) {
users.value = res.data.list; users.value = [];
total.value = 0;
return;
}
const res = await getTenantUsers(selectedTenantId.value);
users.value = res?.data?.list || [];
total.value = Number(res?.data?.total || 0);
} catch (e) { } catch (e) {
users.value = []; users.value = [];
total.value = 0;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -254,12 +275,17 @@ const handleDelete = async (user: User) => {
}; };
onMounted(async () => { onMounted(async () => {
selectedTenantId.value = Number(authStore.user.tid || 0);
fetchUsers(); fetchUsers();
fetchRoles(); fetchRoles();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.filter-bar {
margin-bottom: 12px;
}
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

127
src/views/login/forget.vue Normal file
View File

@ -0,0 +1,127 @@
<template>
<div class="page">
<div class="card">
<h2>忘记密码</h2>
<p class="desc">通过租户账号和手机号重置密码</p>
<input v-model="form.tenant_name" class="input" placeholder="租户名称" />
<input v-model="form.account" class="input" placeholder="账号" />
<input v-model="form.phone" class="input" placeholder="手机号" />
<div class="code-row">
<input v-model="form.sms_code" class="input code-input" placeholder="短信验证码" />
<button class="code-btn" :disabled="codeLoading || countdown > 0" @click="handleSendCode">
{{ countdown > 0 ? `${countdown}s` : (codeLoading ? "发送中..." : "发送验证码") }}
</button>
</div>
<input v-model="form.new_password" class="input" type="password" placeholder="新密码" />
<input v-model="form.confirm_password" class="input" type="password" placeholder="确认新密码" />
<div v-if="errorMsg" class="error">{{ errorMsg }}</div>
<button class="btn" :disabled="loading" @click="handleSubmit">
{{ loading ? "提交中..." : "重置密码" }}
</button>
<a class="back" @click.prevent="router.push('/login')">返回登录</a>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { resetPassword, sendResetCode } from "@/api/login";
const router = useRouter();
const loading = ref(false);
const codeLoading = ref(false);
const countdown = ref(0);
let timer = null;
const errorMsg = ref("");
const form = reactive({
tenant_name: "",
account: "",
phone: "",
sms_code: "",
new_password: "",
confirm_password: "",
});
const startCountdown = () => {
countdown.value = 60;
if (timer) clearInterval(timer);
timer = setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0) {
clearInterval(timer);
timer = null;
}
}, 1000);
};
const handleSendCode = async () => {
if (codeLoading.value || countdown.value > 0) return;
if (!form.tenant_name || !form.account || !form.phone) {
errorMsg.value = "请先填写租户名称、账号和手机号";
return;
}
codeLoading.value = true;
errorMsg.value = "";
try {
const res = await sendResetCode({
tenant_name: form.tenant_name,
account: form.account,
phone: form.phone,
});
if (res.code === 200) {
ElMessage.success("验证码已发送");
startCountdown();
} else {
errorMsg.value = res.msg || "验证码发送失败";
}
} catch {
errorMsg.value = "验证码发送失败,请稍后重试";
} finally {
codeLoading.value = false;
}
};
const handleSubmit = async () => {
errorMsg.value = "";
if (loading.value) return;
loading.value = true;
try {
const res = await resetPassword(form);
if (res.code === 200) {
ElMessage.success("密码重置成功,请重新登录");
router.push("/login");
} else {
errorMsg.value = res.msg || "重置失败";
}
} catch {
errorMsg.value = "重置失败,请稍后重试";
} finally {
loading.value = false;
}
};
onUnmounted(() => {
if (timer) clearInterval(timer);
});
</script>
<style scoped>
.page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f4f8ff; }
.card { width: 420px; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 8px 24px rgba(0,0,0,.08); }
.desc { color: #6b7b93; margin-bottom: 12px; }
.input { width: 100%; margin-bottom: 10px; padding: 10px; border: 1px solid #dbe3ee; border-radius: 6px; box-sizing: border-box; }
.code-row { display: flex; gap: 8px; margin-bottom: 10px; }
.code-input { margin-bottom: 0; flex: 1; }
.code-btn { width: 120px; border: 1px solid #dbe3ee; border-radius: 6px; background: #fff; cursor: pointer; }
.code-btn:disabled { opacity: .6; cursor: not-allowed; }
.btn { width: 100%; margin-top: 8px; padding: 10px; border: none; border-radius: 6px; background: #3f8cff; color: #fff; cursor: pointer; }
.btn:disabled { opacity: .65; cursor: not-allowed; }
.error { color: #e34d4d; font-size: 13px; margin: 6px 0; }
.back { display: inline-block; margin-top: 10px; color: #3f8cff; cursor: pointer; }
</style>

View File

@ -20,6 +20,10 @@
<div class="login-panel"> <div class="login-panel">
<h2 class="login-title">欢迎登录</h2> <h2 class="login-title">欢迎登录</h2>
<div class="login-desc">请填写您的账号信息</div> <div class="login-desc">请填写您的账号信息</div>
<div class="mode-switch">
<button class="mode-btn" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</button>
<button class="mode-btn" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机号验证码登录</button>
</div>
<div class="form-group icon-input-group"> <div class="form-group icon-input-group">
<span class="input-icon"> <span class="input-icon">
<i class="fa-solid fa-building-columns"></i> <i class="fa-solid fa-building-columns"></i>
@ -27,13 +31,13 @@
<input v-model="tenant_name" type="text" placeholder="租户名称" autocomplete="tenant_name" <input v-model="tenant_name" type="text" placeholder="租户名称" autocomplete="tenant_name"
class="input input-with-icon" /> class="input input-with-icon" />
</div> </div>
<div class="form-group icon-input-group"> <div v-if="loginMode === 'password'" class="form-group icon-input-group">
<span class="input-icon"> <span class="input-icon">
<i class="fa-solid fa-user"></i> <i class="fa-solid fa-user"></i>
</span> </span>
<input v-model="account" type="text" placeholder="用户名" autocomplete="account" class="input input-with-icon" /> <input v-model="account" type="text" placeholder="用户名" autocomplete="account" class="input input-with-icon" />
</div> </div>
<div class="form-group icon-input-group"> <div v-if="loginMode === 'password'" class="form-group icon-input-group">
<span class="input-icon"> <span class="input-icon">
<i class="fa-solid fa-lock"></i> <i class="fa-solid fa-lock"></i>
</span> </span>
@ -45,16 +49,29 @@
<i v-else class="fa-solid fa-eye-slash"></i> <i v-else class="fa-solid fa-eye-slash"></i>
</span> </span>
</div> </div>
<div v-if="loginMode === 'sms'" class="form-group icon-input-group">
<span class="input-icon">
<i class="fa-solid fa-mobile-screen-button"></i>
</span>
<input v-model="phone" type="text" placeholder="手机号" autocomplete="tel" class="input input-with-icon" />
</div>
<div v-if="loginMode === 'sms'" class="form-group code-row">
<input v-model="smsCode" type="text" placeholder="短信验证码" class="input code-input" />
<button class="code-btn" :disabled="codeLoading || countdown > 0" @click="handleSendLoginCode">
{{ countdown > 0 ? `${countdown}s` : (codeLoading ? "发送中..." : "发送验证码") }}
</button>
</div>
<!-- 极验验证码容器 --> <!-- 极验验证码容器 -->
<div style="display: none;" v-if="showCaptchaContainer" class="geetest-container" ref="captchaContainer"></div> <div style="display: none;" v-if="showCaptchaContainer" class="geetest-container" ref="captchaContainer"></div>
<div class="remember-me-row"> <div class="remember-me-row">
<label class="remember-me-label"> <label v-if="loginMode === 'password'" class="remember-me-label">
<input type="checkbox" v-model="rememberMe" class="remember-me-checkbox" @change="handleRememberMeChange" /> <input type="checkbox" v-model="rememberMe" class="remember-me-checkbox" @change="handleRememberMeChange" />
<span>记住我</span> <span>记住我</span>
</label> </label>
<span v-else></span>
<div class="action-links"> <div class="action-links">
<a class="register-link" @click.prevent="goRegister">注册账号</a> <a class="register-link" @click.prevent="goRegister">注册账号</a>
<span class="divider">|</span> <span class="divider">|</span>
@ -76,10 +93,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick } from "vue"; import { ref, onMounted, nextTick, onUnmounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { login, getOpenVerify, getGeetest4Infos } from "@/api/login"; import { login, getOpenVerify, getGeetest4Infos, sendLoginCode, loginBySms } from "@/api/login";
import "@/assets/js/gt4.js"; import "@/assets/js/gt4.js";
import { ElMessageBox, ElMessage } from "element-plus"; import { ElMessageBox, ElMessage } from "element-plus";
@ -88,11 +105,17 @@ const authStore = useAuthStore();
// --- --- // --- ---
const tenant_name = ref(""); const tenant_name = ref("");
const loginMode = ref<"password" | "sms">("password");
const account = ref(""); const account = ref("");
const password = ref(""); const password = ref("");
const phone = ref("");
const smsCode = ref("");
const passwordVisible = ref(false); const passwordVisible = ref(false);
const rememberMe = ref(false); const rememberMe = ref(false);
const loading = ref(false); const loading = ref(false);
const codeLoading = ref(false);
const countdown = ref(0);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
const errorMsg = ref(""); const errorMsg = ref("");
// --- --- // --- ---
@ -127,6 +150,25 @@ const cleanCaptchaInstance = () => {
// --- --- // --- ---
const performLoginRequest = async () => { const performLoginRequest = async () => {
if (loginMode.value === "sms") {
const res = await loginBySms({
tenant_name: tenant_name.value,
phone: phone.value,
sms_code: smsCode.value,
});
if (res && res.code === 200) {
authStore.setLoginInfo(res.data);
const { useTabsStore } = await import("@/stores");
const tabsStore = useTabsStore();
tabsStore.resetTabs();
router.push({ path: "/home" });
ElMessage.success("登录成功!");
return true;
}
errorMsg.value = res.msg || "登录失败";
return false;
}
const res = await login({ const res = await login({
tenant_name: tenant_name.value, tenant_name: tenant_name.value,
account: account.value, account: account.value,
@ -163,6 +205,53 @@ const performLoginRequest = async () => {
} }
}; };
const switchMode = (mode: "password" | "sms") => {
loginMode.value = mode;
errorMsg.value = "";
};
const startCountdown = () => {
countdown.value = 60;
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0) {
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = null;
}
}, 1000);
};
const handleSendLoginCode = async () => {
if (codeLoading.value || countdown.value > 0) return;
if (!tenant_name.value.trim()) {
errorMsg.value = "请输入租户名称";
return;
}
if (!phone.value.trim()) {
errorMsg.value = "请输入手机号";
return;
}
codeLoading.value = true;
errorMsg.value = "";
try {
const res = await sendLoginCode({
tenant_name: tenant_name.value,
phone: phone.value,
});
if (res && res.code === 200) {
ElMessage.success("验证码已发送");
startCountdown();
} else {
errorMsg.value = res?.msg || "发送验证码失败";
}
} catch (err: any) {
errorMsg.value = err?.response?.data?.msg || err?.message || "发送验证码失败";
} finally {
codeLoading.value = false;
}
};
// --- 4.0 --- // --- 4.0 ---
const startGeetest4 = async () => { const startGeetest4 = async () => {
showCaptchaContainer.value = true; showCaptchaContainer.value = true;
@ -277,34 +366,50 @@ const handleLogin = async () => {
errorMsg.value = "请输入租户名称"; errorMsg.value = "请输入租户名称";
return; return;
} }
if (!account.value.trim()) { if (loginMode.value === "password") {
errorMsg.value = "请输入用户名"; if (!account.value.trim()) {
return; errorMsg.value = "请输入用户名";
} return;
if (!password.value.trim()) { }
errorMsg.value = "请输入密码"; if (!password.value.trim()) {
return; errorMsg.value = "请输入密码";
return;
}
} else {
if (!phone.value.trim()) {
errorMsg.value = "请输入手机号";
return;
}
if (!smsCode.value.trim()) {
errorMsg.value = "请输入短信验证码";
return;
}
} }
if (loading.value) return; if (loading.value) return;
loading.value = true; loading.value = true;
try { try {
// if (loginMode.value === "password") {
const verifyRes = await getOpenVerify(); //
let openVerify = "0"; const verifyRes = await getOpenVerify();
let openVerify = "0";
if (verifyRes && verifyRes.code === 200 && Array.isArray(verifyRes.data)) { if (verifyRes && verifyRes.code === 200 && Array.isArray(verifyRes.data)) {
verifyRes.data.forEach((item: any) => { verifyRes.data.forEach((item: any) => {
if (item.label === "openVerify") { if (item.label === "openVerify") {
openVerify = item.value || "0"; openVerify = item.value || "0";
} }
}); });
} }
// 使4.0 // 使4.0
if (openVerify === "1") { if (openVerify === "1") {
await startGeetest4(); await startGeetest4();
} else {
//
await performLoginRequest();
}
} else { } else {
// //
await performLoginRequest(); await performLoginRequest();
@ -348,6 +453,10 @@ onMounted(() => {
} }
}); });
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer);
});
const goRegister = () => router.push("/register"); const goRegister = () => router.push("/register");
const goForget = () => router.push("/forget"); const goForget = () => router.push("/forget");
</script> </script>
@ -447,6 +556,29 @@ const goForget = () => router.push("/forget");
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
.mode-switch {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.mode-btn {
flex: 1;
padding: 8px 10px;
border: 1px solid #d6e6fa;
border-radius: 7px;
background: #f7fbfe;
color: #407ad6;
cursor: pointer;
}
.mode-btn.active {
background: #e8f3ff;
border-color: #7cb8ff;
color: #1d63c2;
font-weight: 600;
}
.form-group { .form-group {
margin-bottom: 15px; margin-bottom: 15px;
position: relative; position: relative;
@ -520,6 +652,32 @@ const goForget = () => router.push("/forget");
box-shadow: 0 0 0 2px #e3f2ffb1; box-shadow: 0 0 0 2px #e3f2ffb1;
} }
.code-row {
display: flex;
gap: 8px;
align-items: center;
}
.code-input {
margin-bottom: 0;
flex: 1;
}
.code-btn {
width: 120px;
height: 44px;
border: 1.3px solid #d6e6fa;
border-radius: 7px;
background: #fff;
color: #3a78ca;
cursor: pointer;
}
.code-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
/* 极验验证码容器样式 */ /* 极验验证码容器样式 */
.geetest-container { .geetest-container {
margin: 10px 0; margin: 10px 0;

View File

@ -0,0 +1,131 @@
<template>
<div class="page">
<div class="card">
<h2>注册账号</h2>
<p class="desc">按租户创建管理员账号</p>
<input v-model="form.tenant_name" class="input" placeholder="租户名称" />
<input v-model="form.account" class="input" placeholder="账号" />
<input v-model="form.name" class="input" placeholder="姓名" />
<input v-model="form.phone" class="input" placeholder="手机号" />
<div class="code-row">
<input v-model="form.sms_code" class="input code-input" placeholder="短信验证码" />
<button class="code-btn" :disabled="codeLoading || countdown > 0" @click="handleSendCode">
{{ countdown > 0 ? `${countdown}s` : (codeLoading ? "发送中..." : "发送验证码") }}
</button>
</div>
<input v-model="form.email" class="input" placeholder="邮箱(可选)" />
<input v-model="form.password" class="input" type="password" placeholder="密码" />
<input v-model="form.confirm_password" class="input" type="password" placeholder="确认密码" />
<div v-if="errorMsg" class="error">{{ errorMsg }}</div>
<button class="btn" :disabled="loading" @click="handleSubmit">
{{ loading ? "提交中..." : "注 册" }}
</button>
<a class="back" @click.prevent="router.push('/login')">返回登录</a>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { register, sendRegisterCode } from "@/api/login";
const router = useRouter();
const loading = ref(false);
const codeLoading = ref(false);
const countdown = ref(0);
let timer = null;
const errorMsg = ref("");
const form = reactive({
tenant_name: "",
account: "",
name: "",
phone: "",
sms_code: "",
email: "",
password: "",
confirm_password: "",
});
const startCountdown = () => {
countdown.value = 60;
if (timer) clearInterval(timer);
timer = setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0) {
clearInterval(timer);
timer = null;
}
}, 1000);
};
const handleSendCode = async () => {
if (codeLoading.value || countdown.value > 0) return;
if (!form.tenant_name || !form.account || !form.phone) {
errorMsg.value = "请先填写租户名称、账号和手机号";
return;
}
codeLoading.value = true;
errorMsg.value = "";
try {
const res = await sendRegisterCode({
tenant_name: form.tenant_name,
account: form.account,
phone: form.phone,
});
if (res.code === 200) {
ElMessage.success("验证码已发送");
startCountdown();
} else {
errorMsg.value = res.msg || "验证码发送失败";
}
} catch {
errorMsg.value = "验证码发送失败,请稍后重试";
} finally {
codeLoading.value = false;
}
};
onUnmounted(() => {
if (timer) clearInterval(timer);
});
const handleSubmit = async () => {
errorMsg.value = "";
if (loading.value) return;
loading.value = true;
try {
const res = await register(form);
if (res.code === 200) {
ElMessage.success("注册成功,请登录");
router.push("/login");
} else {
errorMsg.value = res.msg || "注册失败";
}
} catch (e) {
errorMsg.value = "注册失败,请稍后重试";
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f4f8ff; }
.card { width: 420px; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 8px 24px rgba(0,0,0,.08); }
.desc { color: #6b7b93; margin-bottom: 12px; }
.input { width: 100%; margin-bottom: 10px; padding: 10px; border: 1px solid #dbe3ee; border-radius: 6px; box-sizing: border-box; }
.code-row { display: flex; gap: 8px; margin-bottom: 10px; }
.code-input { margin-bottom: 0; flex: 1; }
.code-btn { width: 120px; border: 1px solid #dbe3ee; border-radius: 6px; background: #fff; cursor: pointer; }
.code-btn:disabled { opacity: .6; cursor: not-allowed; }
.btn { width: 100%; margin-top: 8px; padding: 10px; border: none; border-radius: 6px; background: #3f8cff; color: #fff; cursor: pointer; }
.btn:disabled { opacity: .65; cursor: not-allowed; }
.error { color: #e34d4d; font-size: 13px; margin: 6px 0; }
.back { display: inline-block; margin-top: 10px; color: #3f8cff; cursor: pointer; }
</style>

View File

@ -0,0 +1,149 @@
<template>
<div class="sms-edit">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
class="sms-form"
>
<el-form-item label="短信网关地址" prop="backendUrl">
<el-input
v-model="form.backendUrl"
placeholder="例如https://yzsms.yunzer.cn"
clearable
/>
</el-form-item>
<el-form-item label="短信API KEY" prop="apiKey">
<el-input v-model="form.apiKey" placeholder="请输入 API_KEY" clearable />
</el-form-item>
<el-form-item label="测试收件手机号">
<el-input
v-model="testPhone"
placeholder="国际格式号码,例如 +8613712345678"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave" :loading="saveLoading">
保存配置
</el-button>
<el-button @click="handleTest" :loading="testLoading">发送测试短信</el-button>
<el-button @click="handleReset" :disabled="saveLoading || testLoading">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { editSmsInfo, sendTestSms } from "../../../../api/sms";
const props = defineProps<{
config: {
backendUrl?: string;
apiKey?: string;
};
}>();
const emit = defineEmits<{
(e: "saved"): void;
(e: "tested"): void;
}>();
const formRef = ref<FormInstance>();
const form = reactive({
backendUrl: "",
apiKey: "",
});
const rules: FormRules = {
backendUrl: [{ required: true, message: "请输入短信网关地址", trigger: "blur" }],
apiKey: [{ required: true, message: "请输入API KEY", trigger: "blur" }],
};
const testPhone = ref("");
const saveLoading = ref(false);
const testLoading = ref(false);
watch(
() => props.config,
(v) => {
form.backendUrl = v?.backendUrl ?? "";
form.apiKey = v?.apiKey ?? "";
},
{ deep: true, immediate: true }
);
const handleSave = async () => {
if (!formRef.value) return;
try {
saveLoading.value = true;
await formRef.value.validate();
const res = await editSmsInfo(form);
if (res.code === 200) {
ElMessage.success(res.msg || "短信配置保存成功");
emit("saved");
} else {
ElMessage.error(res.msg || "保存失败,请稍后重试");
}
} catch (e: any) {
ElMessage.error(e?.message || "保存失败,请稍后重试");
} finally {
saveLoading.value = false;
}
};
const handleTest = async () => {
const phone = (testPhone.value || "").trim();
if (!phone) {
ElMessage.warning("请先输入测试收件手机号");
return;
}
// Android +
if (!/^\+\d{6,15}$/.test(phone)) {
ElMessage.warning("请使用国际格式号码(以 + 开头,后面为数字)");
return;
}
if (!formRef.value) return;
try {
testLoading.value = true;
await formRef.value.validate();
const res = await sendTestSms({ ...form, phone });
if (res.code === 200) {
ElMessage.success(res.msg || "短信测试任务入队成功");
emit("tested");
} else {
ElMessage.error(res.msg || "短信测试失败,请稍后重试");
}
} catch (e: any) {
ElMessage.error(e?.message || "短信测试失败,请稍后重试");
} finally {
testLoading.value = false;
}
};
const handleReset = () => {
form.backendUrl = "";
form.apiKey = "";
testPhone.value = "";
formRef.value?.clearValidate();
};
</script>
<style scoped lang="less">
.sms-edit {
max-width: 700px;
}
.sms-form {
max-width: 700px;
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>短信配置</h2>
</div>
<el-divider />
<div class="content-box">
<Edit :config="smsForm" @saved="loadSmsConfig" />
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from "vue";
import Edit from "./components/edit.vue";
import { getSmsInfo } from "../../../api/sms";
const smsForm = reactive({
backendUrl: "",
apiKey: "",
});
const loadSmsConfig = async () => {
const res = await getSmsInfo();
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
const item = res.data[0];
smsForm.backendUrl = item.backend_url || item.backendUrl || "";
smsForm.apiKey = item.api_key || item.apiKey || "";
}
};
onMounted(() => {
loadSmsConfig();
});
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.content-box {
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,189 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>短信任务列表</h2>
<div>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="fetchTasks" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider />
<div class="search-bar">
<el-form :inline="true" :model="searchForm">
<el-form-item label="手机号">
<el-input
v-model="searchForm.phone"
placeholder="请输入手机号关键词"
style="width: 220px"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" style="width: 160px">
<el-option label="全部" value="" />
<el-option label="待发送" :value="0" />
<el-option label="处理中" :value="1" />
<el-option label="失败" :value="2" />
<el-option label="已上报" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="pagedList" style="width: 100%" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" min-width="150" />
<el-table-column
prop="content"
label="短信内容"
min-width="260"
show-overflow-tooltip
/>
<el-table-column prop="code" label="验证码" width="120" />
<el-table-column prop="status" label="状态" width="130">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column prop="update_time" label="更新时间" width="180" />
</el-table>
<div class="pagination" v-if="total > 0">
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:page-sizes="[10, 20, 50, 100]"
@size-change="applyPagination"
@current-change="applyPagination"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { getSmsTaskList } from "../../../api/sms";
const loading = ref(false);
const searchForm = reactive({
phone: "",
status: "",
});
const tasks = ref<any[]>([]);
const pagedList = ref<any[]>([]);
const pagination = reactive({
current: 1,
size: 10,
});
const total = ref(0);
const getStatusTagType = (status: any) => {
const map: Record<string | number, string> = {
0: "info",
1: "warning",
2: "danger",
3: "success",
};
return map[status] || "info";
};
const getStatusText = (status: any) => {
const map: Record<string | number, string> = {
0: "待发送",
1: "发送中",
2: "发送失败",
3: "发送成功",
};
return map[status] || String(status ?? "");
};
const applyPagination = () => {
total.value = tasks.value.length;
const start = (pagination.current - 1) * pagination.size;
const end = start + pagination.size;
pagedList.value = tasks.value.slice(start, end);
};
const fetchTasks = async () => {
loading.value = true;
try {
const res = await getSmsTaskList({
status: searchForm.status,
phone: searchForm.phone,
});
if (res.code === 200) {
tasks.value = res.list || [];
pagination.current = 1;
applyPagination();
} else {
ElMessage.error(res.msg || "获取短信任务列表失败");
}
} catch (error: any) {
ElMessage.error(error?.message || "获取短信任务列表失败,请稍后重试");
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchTasks();
};
const resetSearch = () => {
searchForm.phone = "";
searchForm.status = "";
pagination.current = 1;
fetchTasks();
};
onMounted(() => {
fetchTasks();
});
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-bar {
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>