feat:增加主包(tabbar)、system(系统管理)、infra(基础设施)、bpm(工作流程)的页面

This commit is contained in:
YunaiV
2025-12-12 19:16:46 +08:00
parent c14e0d04d0
commit cc94b2a5f7
97 changed files with 12198 additions and 81 deletions

View File

@@ -0,0 +1,135 @@
<template>
<view class="auth-container">
<!-- 顶部 -->
<Header />
<!-- 表单区域 -->
<view class="form-container">
<TenantPicker ref="tenantPickerRef" />
<view class="input-item">
<wd-icon name="phone" size="20px" color="#1890ff" />
<wd-input
v-model="formData.mobile"
placeholder="请输入手机号"
clearable
clear-trigger="focus"
no-border
type="number"
:maxlength="11"
/>
</view>
<CodeInput
v-model="formData.code"
:mobile="formData.mobile"
:scene="21"
:before-send="validateBeforeSend"
/>
<!-- 登录按钮 -->
<view class="mb-2 mt-2 flex justify-between">
<text class="text-28rpx text-[#1890ff]" @click="goToLogin">
账号登录
</text>
<text class="text-28rpx text-[#1890ff]" @click="goToForgetPassword">
忘记密码
</text>
</view>
<wd-button block :loading="loading" type="primary" @click="handleLogin">
登录
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import { useToast } from "wot-design-uni";
import { FORGET_PASSWORD_PAGE, LOGIN_PAGE } from "@/router/config";
import { useTokenStore } from "@/store/token";
import { ensureDecodeURIComponent, redirectAfterLogin } from "@/utils";
import { isMobile } from "@/utils/validator";
import CodeInput from "./components/code-input.vue";
import Header from "./components/header.vue";
import TenantPicker from "./components/tenant-picker.vue";
defineOptions({
name: "SmsLoginPage",
});
definePage({
style: {
navigationStyle: "custom",
},
excludeLoginPath: true,
});
const toast = useToast();
const loading = ref(false); // 加载状态
const redirectUrl = ref<string>(); // 重定向地址
const tenantPickerRef = ref<InstanceType<typeof TenantPicker>>(); // 租户选择器引用
const formData = reactive({
mobile: "",
code: "",
}); // 表单数据
/** 页面加载时处理重定向 */
onLoad((options) => {
if (options?.redirect) {
redirectUrl.value = ensureDecodeURIComponent(options.redirect);
}
});
/** 发送验证码前的校验 */
function validateBeforeSend(): boolean {
return tenantPickerRef.value?.validate() ?? false;
}
/** 登录处理 */
async function handleLogin() {
// 校验租户
if (!tenantPickerRef.value?.validate()) {
return;
}
if (!formData.mobile) {
toast.warning("请输入手机号");
return;
}
if (!isMobile(formData.mobile)) {
toast.warning("请输入正确的手机号");
return;
}
if (!formData.code) {
toast.warning("请输入验证码");
return;
}
loading.value = true;
try {
// 调用短信登录接口
const tokenStore = useTokenStore();
await tokenStore.login({
type: "sms",
...formData,
});
// 处理跳转
redirectAfterLogin(redirectUrl.value);
} finally {
loading.value = false;
}
}
/** 跳转到账号密码登录 */
function goToLogin() {
uni.navigateTo({ url: LOGIN_PAGE });
}
/** 跳转到忘记密码 */
function goToForgetPassword() {
uni.navigateTo({ url: FORGET_PASSWORD_PAGE });
}
</script>
<style lang="scss" scoped>
@import "./styles/auth.scss";
</style>

View File

@@ -0,0 +1,90 @@
<template>
<view class="input-item">
<wd-icon name="lock-on" size="20px" color="#1890ff" />
<wd-input
:model-value="modelValue"
placeholder="请输入验证码"
clearable
clear-trigger="focus"
no-border
type="number"
:maxlength="6"
@update:model-value="$emit('update:modelValue', $event)"
/>
<view
class="whitespace-nowrap border-l-1rpx border-l-[#e5e5e5] border-l-solid px-20rpx text-28rpx text-[#1890ff]"
@click="handleSendCode"
>
<text :class="{ 'text-gray-400': countdown > 0 }">
{{ countdown > 0 ? `${countdown} 秒后重发` : "获取验证码" }}
</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { onUnmounted, ref } from "vue";
import { useToast } from "wot-design-uni";
import { sendSmsCode } from "@/api/login";
import { isMobile } from "@/utils/validator";
defineOptions({
name: "CodeInput",
});
const props = defineProps<{
modelValue: string; // 验证码值 (v-model)
mobile: string; // 手机号
scene: number; // 短信场景21-登录 23-重置密码
beforeSend?: () => boolean; // 发送前的校验函数,返回 false 则不发送
}>();
defineEmits<{
"update:modelValue": [value: string];
}>();
const toast = useToast();
const countdown = ref(0); // 验证码倒计时,单位秒
let countdownTimer: ReturnType<typeof setInterval> | null = null; // 倒计时定时器
/** 页面卸载时清除倒计时定时器 */
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
});
/** 发送验证码 */
async function handleSendCode() {
// 执行前置校验
if (props.beforeSend && !props.beforeSend()) {
return;
}
if (countdown.value > 0) {
return;
}
if (!props.mobile) {
toast.warning("请输入手机号");
return;
}
if (!isMobile(props.mobile)) {
toast.warning("请输入正确的手机号");
return;
}
// 发送验证码
await sendSmsCode({ mobile: props.mobile, scene: props.scene });
toast.success("验证码已发送");
// 开始倒计时
countdown.value = 60;
countdownTimer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(countdownTimer!);
countdownTimer = null;
}
}, 1000);
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<view class="header flex flex-col items-center pb-60rpx pt-120rpx">
<image class="mb-24rpx h-160rpx w-160rpx" src="/static/logo.svg" mode="aspectFit" />
<view class="text-44rpx text-[#1890ff] font-bold">
{{ title }}
</view>
</view>
</template>
<script lang="ts" setup>
const title = import.meta.env.VITE_APP_TITLE // 应用标题
</script>

View File

@@ -0,0 +1,134 @@
<template>
<view v-if="tenantEnabled" class="input-item">
<wd-icon name="home" size="20px" color="#1890ff" />
<wd-picker
:model-value="tenantId"
:columns="tenantList"
label-key="name"
value-key="id"
label=""
placeholder="请选择租户"
@confirm="handleConfirm"
/>
</view>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from "vue";
import { useToast } from "wot-design-uni";
import {
getTenantByWebsite,
getTenantSimpleList,
type TenantVO,
} from "@/api/login";
import { useUserStore } from "@/store/user";
const toast = useToast();
const userStore = useUserStore();
const tenantEnabled = computed(
() => import.meta.env.VITE_APP_TENANT_ENABLE === "true",
); // 租户开关:通过环境变量控制
const tenantList = ref<TenantVO[]>([]); // 租户列表数据
const tenantId = computed(
() =>
userStore.tenantId ||
Number(import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT_ID) ||
undefined,
); // 当前选中的租户
/** 获取租户列表,并根据域名/appId 自动选中租户 */
async function fetchTenantList() {
if (!tenantEnabled.value) {
return;
}
try {
// 1. 并行获取租户列表和域名对应的租户
const websiteTenantPromise = fetchTenantByWebsite();
const list = await getTenantSimpleList();
tenantList.value = list || [];
// 2. 确定选中的租户:域名/appId > store 中的租户 > 列表第一个
let selectedTenantId: number | null = null;
// 2.1 优先使用域名/appId 对应的租户
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
selectedTenantId = websiteTenant.id;
}
// 2.2 如果没有从域名获取到,使用 store 中的租户
if (!selectedTenantId && userStore.tenantId) {
selectedTenantId = userStore.tenantId;
}
// 2.3 如果还是没有,使用列表第一个
if (!selectedTenantId && tenantList.value.length > 0) {
selectedTenantId = tenantList.value[0].id;
}
// 3. 设置选中的租户
if (selectedTenantId && selectedTenantId !== userStore.tenantId) {
userStore.setTenantId(selectedTenantId);
}
} catch (error) {
console.error("获取租户列表失败:", error);
}
}
/** 根据域名或 appId 获取租户 */
async function fetchTenantByWebsite(): Promise<TenantVO | null> {
try {
let website: string | null = null;
// #ifdef H5
// H5 环境:使用域名
if (window?.location?.hostname) {
website = window.location.hostname;
}
// #endif
// #ifdef MP
// 小程序环境:使用 appId
const appId = uni.getAccountInfoSync?.()?.miniProgram?.appId;
if (appId) {
website = appId;
}
// #endif
if (website) {
return await getTenantByWebsite(website);
}
} catch (error) {
// 域名未配置租户时会报错,忽略即可
console.debug("根据域名获取租户失败:", error);
}
return null;
}
/** 租户选择确认 */
function handleConfirm({ value }: { value: number }) {
userStore.setTenantId(value);
}
/** 校验租户是否已选择 */
function validate(): boolean {
if (!tenantEnabled.value) {
return true;
}
if (!tenantId.value) {
toast.warning("请选择租户");
return false;
}
return true;
}
/** 页面加载时获取租户列表 */
onMounted(() => {
fetchTenantList();
});
defineExpose({ validate });
</script>
<style lang="scss" scoped>
@import "../styles/auth.scss";
</style>

View File

@@ -0,0 +1,160 @@
<template>
<view class="auth-container">
<!-- 顶部 -->
<Header />
<!-- 表单区域 -->
<view class="form-container">
<TenantPicker ref="tenantPickerRef" />
<view class="input-item">
<wd-icon name="phone" size="20px" color="#1890ff" />
<wd-input
v-model="formData.mobile"
placeholder="请输入手机号"
clearable
clear-trigger="focus"
no-border
type="number"
:maxlength="11"
/>
</view>
<CodeInput
v-model="formData.code"
:mobile="formData.mobile"
:scene="23"
:before-send="validateBeforeSend"
/>
<view class="input-item">
<wd-icon name="lock-on" size="20px" color="#1890ff" />
<wd-input
v-model="formData.password"
placeholder="请输入新密码"
clearable
clear-trigger="focus"
show-password
no-border
/>
</view>
<view class="input-item">
<wd-icon name="lock-on" size="20px" color="#1890ff" />
<wd-input
v-model="formData.confirmPassword"
placeholder="请确认新密码"
clearable
clear-trigger="focus"
show-password
no-border
/>
</view>
<!-- 重置密码按钮 -->
<wd-button
block
:loading="loading"
type="primary"
@click="handleResetPassword"
>
重置密码
</wd-button>
<wd-button class="mt-2" block type="info" @click="goToLogin">
返回登录
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import { useToast } from "wot-design-uni";
import { smsResetPassword } from "@/api/login";
import { LOGIN_PAGE } from "@/router/config";
import { isMobile } from "@/utils/validator";
import CodeInput from "./components/code-input.vue";
import Header from "./components/header.vue";
import TenantPicker from "./components/tenant-picker.vue";
defineOptions({
name: "ForgetPasswordPage",
});
definePage({
style: {
navigationStyle: "custom",
},
excludeLoginPath: true,
});
const toast = useToast();
const loading = ref(false); // 加载状态
const tenantPickerRef = ref<InstanceType<typeof TenantPicker>>(); // 租户选择器引用
const formData = reactive({
mobile: "",
code: "",
password: "",
confirmPassword: "",
}); // 表单数据
/** 发送验证码前的校验 */
function validateBeforeSend(): boolean {
return tenantPickerRef.value?.validate() ?? false;
}
/** 重置密码处理 */
async function handleResetPassword() {
// 校验租户
if (!tenantPickerRef.value?.validate()) {
return;
}
if (!formData.mobile) {
toast.warning("请输入手机号");
return;
}
if (!isMobile(formData.mobile)) {
toast.warning("请输入正确的手机号");
return;
}
if (!formData.code) {
toast.warning("请输入验证码");
return;
}
if (!formData.password) {
toast.warning("请输入新密码");
return;
}
if (!formData.confirmPassword) {
toast.warning("请确认新密码");
return;
}
if (formData.password !== formData.confirmPassword) {
toast.warning("两次输入的密码不一致");
return;
}
loading.value = true;
try {
// 调用重置密码接口
await smsResetPassword({
mobile: formData.mobile,
code: formData.code,
password: formData.password,
});
toast.success("密码重置成功");
// 跳转到登录页
setTimeout(() => {
goToLogin();
}, 500);
} finally {
loading.value = false;
}
}
/** 跳转到登录页面 */
function goToLogin() {
uni.navigateTo({ url: LOGIN_PAGE });
}
</script>
<style lang="scss" scoped>
@import "./styles/auth.scss";
</style>

185
src/pages/auth/login.vue Normal file
View File

@@ -0,0 +1,185 @@
<template>
<view class="auth-container">
<!-- 顶部 -->
<Header />
<!-- 表单区域 -->
<view class="form-container">
<TenantPicker ref="tenantPickerRef" />
<view class="input-item">
<wd-icon name="user" size="20px" color="#1890ff" />
<wd-input
v-model="formData.username"
placeholder="请输入用户名"
clearable
clear-trigger="focus"
no-border
/>
</view>
<view class="input-item">
<wd-icon name="lock-on" size="20px" color="#1890ff" />
<wd-input
v-model="formData.password"
placeholder="请输入密码"
clearable
clear-trigger="focus"
show-password
no-border
/>
</view>
<!-- 登录按钮 -->
<view class="mb-2 mt-2 flex justify-between">
<text class="text-28rpx text-[#1890ff]" @click="goToSmsLogin">
验证码登录
</text>
<text class="text-28rpx text-[#1890ff]" @click="goToForgetPassword">
忘记密码
</text>
</view>
<wd-button block :loading="loading" type="primary" @click="handleLogin">
登录
</wd-button>
<!-- 第三方登录 -->
<view class="mt-100rpx">
<view class="divider mb-40rpx flex items-center justify-center">
<view class="h-1rpx flex-1 bg-[#e5e5e5]" />
<text class="px-24rpx text-26rpx text-[#999]">其他登录方式</text>
<view class="h-1rpx flex-1 bg-[#e5e5e5]" />
</view>
<!-- TODO @芋艿图标换下 -->
<view class="icons flex justify-center gap-60rpx">
<view class="icon-item" @click="handleWechatLogin">
<wd-icon name="chat" size="24px" color="#07c160" />
</view>
<view class="icon-item" @click="handleDingTalkLogin">
<wd-icon name="computer" size="24px" color="#3370ff" />
</view>
</view>
</view>
<!-- 创建账号 -->
<view class="mt-40rpx flex items-center justify-center">
<text class="text-28rpx text-[#666]">还没有账号</text>
<text class="text-28rpx text-[#1890ff]" @click="goToRegister">
创建账号
</text>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import { useToast } from "wot-design-uni";
import {
CODE_LOGIN_PAGE,
FORGET_PASSWORD_PAGE,
REGISTER_PAGE,
} from "@/router/config";
import { useTokenStore } from "@/store/token";
import { ensureDecodeURIComponent, redirectAfterLogin } from "@/utils";
import Header from "./components/header.vue";
import TenantPicker from "./components/tenant-picker.vue";
defineOptions({
name: "LoginPage",
style: {
navigationStyle: "custom",
},
});
definePage({
style: {
navigationStyle: "custom",
},
});
const toast = useToast();
const loading = ref(false); // 加载状态
const redirectUrl = ref<string>(); // 重定向地址
const tenantPickerRef = ref<InstanceType<typeof TenantPicker>>(); // 租户选择器引用
const formData = reactive({
username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || "",
password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || "",
}); // 表单数据
/** 页面加载时处理重定向 */
onLoad((options) => {
if (options?.redirect) {
redirectUrl.value = ensureDecodeURIComponent(options.redirect);
}
});
/** 登录处理 */
async function handleLogin() {
if (!tenantPickerRef.value?.validate()) {
return;
}
if (!formData.username) {
toast.warning("请输入用户名");
return;
}
if (!formData.password) {
toast.warning("请输入密码");
return;
}
loading.value = true;
try {
// 调用登录接口
const tokenStore = useTokenStore();
await tokenStore.login({
type: "username",
...formData,
});
// 处理跳转
redirectAfterLogin(redirectUrl.value);
} finally {
loading.value = false;
}
}
/** 跳转到注册页面 */
function goToRegister() {
uni.navigateTo({ url: REGISTER_PAGE });
}
/** 跳转到验证码登录 */
function goToSmsLogin() {
uni.navigateTo({ url: CODE_LOGIN_PAGE });
}
/** 跳转到忘记密码 */
function goToForgetPassword() {
uni.navigateTo({ url: FORGET_PASSWORD_PAGE });
}
/** 微信登录 */
// TODO @芋艿:后续开发
function handleWechatLogin() {
toast.info("微信登录功能开发中");
}
/** 钉钉登录 */
// TODO @芋艿:后续开发
function handleDingTalkLogin() {
toast.info("钉钉登录功能开发中");
}
</script>
<style lang="scss" scoped>
@import "./styles/auth.scss";
// 第三方登录图标
.icon-item {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f5f7fa;
}
</style>

179
src/pages/auth/register.vue Normal file
View File

@@ -0,0 +1,179 @@
<template>
<view class="auth-container">
<!-- 顶部 -->
<Header />
<!-- 表单区域 -->
<view class="form-container">
<TenantPicker ref="tenantPickerRef" />
<view class="input-item">
<wd-icon name="user" size="20px" color="#1890ff" />
<wd-input
v-model="formData.username"
placeholder="请输入用户名"
clearable
clear-trigger="focus"
no-border
/>
</view>
<view class="input-item">
<wd-icon name="person" size="20px" color="#1890ff" />
<wd-input
v-model="formData.nickname"
placeholder="请输入昵称"
clearable
clear-trigger="focus"
no-border
/>
</view>
<view class="input-item">
<wd-icon name="lock-on" size="20px" color="#1890ff" />
<wd-input
v-model="formData.password"
placeholder="请输入密码"
clearable
clear-trigger="focus"
show-password
no-border
/>
</view>
<view class="input-item">
<wd-icon name="lock-on" size="20px" color="#1890ff" />
<wd-input
v-model="formData.confirmPassword"
placeholder="请确认密码"
clearable
clear-trigger="focus"
show-password
no-border
/>
</view>
<!-- 用户协议 -->
<view class="mb-24rpx flex items-center">
<wd-checkbox v-model="agreePolicy" shape="square" />
<text class="text-24rpx text-[#666]">我已阅读并同意</text>
<text class="text-24rpx text-[#1890ff]" @click="goToUserAgreement">
用户协议
</text>
<text class="text-24rpx text-[#666]"></text>
<text class="text-24rpx text-[#1890ff]" @click="goToPrivacyPolicy">
隐私政策
</text>
</view>
<!-- 注册按钮 -->
<wd-button
block
:loading="loading"
type="primary"
@click="handleRegister"
>
注册
</wd-button>
<!-- 已有账号 -->
<view class="mt-40rpx flex items-center justify-center">
<text class="text-28rpx text-[#666]">已有账号</text>
<text class="text-28rpx text-[#1890ff]" @click="goToLogin">去登录</text>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import { useToast } from "wot-design-uni";
import { LOGIN_PAGE } from "@/router/config";
import { useTokenStore } from "@/store/token";
import { redirectAfterLogin } from "@/utils";
import Header from "./components/header.vue";
import TenantPicker from "./components/tenant-picker.vue";
defineOptions({
name: "RegisterPage",
});
definePage({
style: {
navigationStyle: "custom",
},
});
const toast = useToast();
const loading = ref(false); // 加载状态
const agreePolicy = ref(false); // 用户协议勾选
const tenantPickerRef = ref<InstanceType<typeof TenantPicker>>(); // 租户选择器引用
const formData = reactive({
username: "",
nickname: "",
password: "",
confirmPassword: "",
}); // 表单数据
/** 注册处理 */
async function handleRegister() {
if (!tenantPickerRef.value?.validate()) {
return;
}
if (!agreePolicy.value) {
toast.warning("请阅读并同意《用户协议》与《隐私政策》");
return;
}
if (!formData.username) {
toast.warning("请输入用户名");
return;
}
if (!formData.nickname) {
toast.warning("请输入昵称");
return;
}
if (!formData.password) {
toast.warning("请输入密码");
return;
}
if (!formData.confirmPassword) {
toast.warning("请确认密码");
return;
}
if (formData.password !== formData.confirmPassword) {
toast.warning("两次输入的密码不一致");
return;
}
loading.value = true;
try {
// 调用注册接口
const tokenStore = useTokenStore();
await tokenStore.login({
type: "register",
...formData,
});
toast.success("注册成功");
// 处理跳转
redirectAfterLogin();
} finally {
loading.value = false;
}
}
/** 跳转到登录页面 */
function goToLogin() {
uni.navigateTo({ url: LOGIN_PAGE });
}
/** 跳转到用户协议 */
function goToUserAgreement() {
uni.navigateTo({ url: "/pages/user/settings/agreement/index" });
}
/** 跳转到隐私政策 */
function goToPrivacyPolicy() {
uni.navigateTo({ url: "/pages/user/settings/privacy/index" });
}
</script>
<style lang="scss" scoped>
@import "./styles/auth.scss";
</style>

View File

@@ -0,0 +1,63 @@
/** 认证页面公共样式 */
// 页面容器
.auth-container {
display: flex;
flex-direction: column;
min-height: 100vh;
background: linear-gradient(180deg, #e8f4ff 0%, #fff 50%);
box-sizing: border-box;
}
// 表单容器
.form-container {
flex: 1;
border-radius: 24rpx 24rpx 0 0;
background: #fff;
padding: 40rpx;
}
// 输入项
.input-item {
display: flex;
align-items: center;
padding: 20rpx 24rpx;
background: #f5f7fa;
border-radius: 12rpx;
margin-bottom: 24rpx;
:deep(.wd-input) {
flex: 1;
margin-left: 16rpx;
background: transparent;
}
:deep(.wd-picker) {
flex: 1;
margin-left: 16rpx;
}
:deep(.wd-picker__field) {
background: transparent;
padding: 0;
}
// 移除 picker 的边框,保持与 input 一致
:deep(.wd-picker__cell) {
background: transparent !important;
padding: 0 !important;
}
:deep(.wd-cell) {
background: transparent !important;
padding: 0 !important;
&::after {
display: none !important;
}
}
:deep(.wd-cell__wrapper) {
padding: 0 !important;
}
}