add UserAgreement.vue

This commit is contained in:
Timi
2026-09-12 23:51:38 +08:00
parent 45818de9f1
commit 2080a88eb8
8 changed files with 350 additions and 24 deletions
+9 -2
View File
@@ -49,7 +49,8 @@
/>
</div>
</t-form>
<t-button class="submit" theme="primary" size="medium" :loading="isSubmitting" :disabled="isExpired || isSubmitting" @click="register">注册并登录</t-button>
<user-agreement v-model="hasAgreed" />
<t-button class="submit" theme="primary" size="medium" :loading="isSubmitting" :disabled="isExpired || isSubmitting || !hasAgreed" @click="register">注册并登录</t-button>
</section>
</div>
</template>
@@ -61,6 +62,7 @@ import { CommonAPI, NotifyAPI, useUserStore, type LoginResponse } from "timi-web
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import Logo from "@/pages/user/component/Logo.vue";
import UserAgreement from "@/pages/user/component/UserAgreement.vue";
type CaptchaExpose = { captchaId: string; update: () => Promise<void> };
@@ -75,6 +77,7 @@ const isValid = ref(false);
const expireAt = ref(0);
const invalidMessage = ref("邀请码无效或已过期,请联系邀请人重新生成。");
const isSubmitting = ref(false);
const hasAgreed = ref(false);
const captchaRef = ref<CaptchaExpose | null>(null);
const formModel = reactive({
phoneNo: "",
@@ -193,7 +196,11 @@ async function sendSms(): Promise<void> {
}
async function register(): Promise<void> {
if (isSubmitting.value || isExpired.value || !validateForm()) {
if (isSubmitting.value || !hasAgreed.value || isExpired.value || !validateForm()) {
if (!hasAgreed.value) {
Message.warning("请先阅读并同意用户协议");
return;
}
if (isExpired.value) {
Message.warning("邀请码已失效,请联系邀请人重新生成");
}
+5 -2
View File
@@ -11,8 +11,9 @@
:options="loginTypeOptions"
v-entrance-transition="140"
/>
<password-login v-if="loginType === 'password'" class="form" />
<sms-login v-else class="form" />
<password-login v-if="loginType === 'password'" class="form" :agreement-accepted="hasAgreed" />
<sms-login v-else class="form" :agreement-accepted="hasAgreed" />
<user-agreement v-model="hasAgreed" />
</section>
</div>
</template>
@@ -21,6 +22,7 @@
import PasswordLogin from "@/pages/user/component/PasswordLogin.vue";
import SmsLogin from "@/pages/user/component/SmsLogin.vue";
import Logo from "@/pages/user/component/Logo.vue";
import UserAgreement from "@/pages/user/component/UserAgreement.vue";
defineOptions({
name: "LoginIndex"
@@ -29,6 +31,7 @@ defineOptions({
type LoginType = "password" | "sms";
const loginType = ref<LoginType>("password");
const hasAgreed = ref(false);
const loginTypeOptions = [
{ value: "password" as LoginType, label: "密码登录" },
{ value: "sms" as LoginType, label: "短信登录" }
-1
View File
@@ -39,7 +39,6 @@
<t-button
class="red bg-white cell-button"
variant="text"
block
v-entrance-transition="140"
@click="confirmLogout"
>退出登录</t-button>
+11 -1
View File
@@ -13,7 +13,7 @@
<captcha class="captcha" ref="captchaRef" :width="120" :height="40" :request="CommonAPI.captcha" />
</div>
<div class="exec">
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting">登录</t-button>
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting || !props.agreementAccepted">登录</t-button>
</div>
</t-form>
</template>
@@ -39,6 +39,12 @@ defineOptions({
name: "PasswordLogin"
});
const props = withDefaults(defineProps<{
agreementAccepted?: boolean;
}>(), {
agreementAccepted: false
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
@@ -56,6 +62,10 @@ function getRedirectPath(): string {
}
function validateForm(): boolean {
if (!props.agreementAccepted) {
Message.warning("请先阅读并同意用户协议");
return false;
}
if (!formModel.user || !formModel.password || !formModel.captcha || !captchaRef.value?.captchaId) {
Message.warning("请输入账号、密码和验证码");
return false;
+11 -1
View File
@@ -24,7 +24,7 @@
/>
</div>
<div class="exec">
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting">登录</t-button>
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting || !props.agreementAccepted">登录</t-button>
</div>
</t-form>
</template>
@@ -51,6 +51,12 @@ defineOptions({
name: "SmsLogin"
});
const props = withDefaults(defineProps<{
agreementAccepted?: boolean;
}>(), {
agreementAccepted: false
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
@@ -136,6 +142,10 @@ async function sendSms(): Promise<void> {
}
function validateForm(): boolean {
if (!props.agreementAccepted) {
Message.warning("请先阅读并同意用户协议");
return false;
}
if (!/^1\d{10}$/.test(formModel.mobilePhone)) {
Message.warning("请输入正确的 11 位手机号");
return false;
+311
View File
@@ -0,0 +1,311 @@
<template>
<div class="user-agreement">
<t-checkbox
:model-value="props.modelValue"
class="checkbox"
borderless
@change="handleCheckboxChange"
>
<span>我已阅读并同意用户协议</span>
</t-checkbox>
</div>
<t-dialog
class="user-agreement-dialog"
v-model:visible="dialogVisible"
title="用户协议"
cancel-btn="拒绝"
confirm-btn="同意"
:close-btn="false"
:close-on-overlay-click="false"
destroy-on-close
@cancel="rejectAgreement"
@confirm="agreeAgreement"
>
<div class="dialog-content">
<t-tabs
v-model="activeAgreementCode"
class="tabs"
theme="line"
bottom-line-mode="full"
:split="false"
:space-evenly="true"
:swipeable="true"
>
<t-tab-panel
v-for="item in agreementList"
:key="item.code"
:value="item.code"
:label="item.title"
lazy
>
<div class="agreement-item">
<t-loading v-if="item.loading" class="loading" text="正在加载协议" />
<article-view
v-else-if="item.article"
:article="item.article"
empty-text="暂无协议正文"
:resolve-attachment-url="resolveAttachmentUrl"
/>
<p v-else class="error" v-text="item.errorMessage || '协议加载失败,请拒绝后重试。'" />
</div>
</t-tab-panel>
</t-tabs>
<div class="tips">
<span>请阅读以上全部协议</span>
<br />
<span>阅读完成后点击“同意”继续</span>
</div>
</div>
</t-dialog>
</template>
<script setup lang="ts">
import { ArticleAPI, AttachmentAPI, SettingAPI, type Article } from "timi-web-api";
import { ArticleView } from "timi-tdesign-mobile";
import { getErrorMessage } from "@/utils/error";
type AgreementCode = "SERVICE_LICENSE" | "PRIVACY_LICENSE" | "OPEN_SOURCE_LICENSE";
type AgreementItem = {
code: AgreementCode;
title: string;
article?: Article;
errorMessage?: string;
loading: boolean;
};
const baseAgreementList: Array<Pick<AgreementItem, "code" | "title">> = [
{
code: "SERVICE_LICENSE",
title: "服务协议"
},
{
code: "PRIVACY_LICENSE",
title: "隐私协议"
},
{
code: "OPEN_SOURCE_LICENSE",
title: "开源软件协议"
}
];
defineOptions({
name: "UserAgreement"
});
const props = withDefaults(defineProps<{
modelValue?: boolean;
}>(), {
modelValue: false
});
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
const dialogVisible = ref(false);
const agreementList = ref<AgreementItem[]>(createAgreementList());
const activeAgreementCode = ref<AgreementCode>("SERVICE_LICENSE");
function createAgreementList(): AgreementItem[] {
return baseAgreementList.map(item => ({
...item,
loading: false
}));
}
function handleCheckboxChange(checked: boolean): void {
if (!checked) {
emit("update:modelValue", false);
return;
}
emit("update:modelValue", false);
dialogVisible.value = true;
activeAgreementCode.value = "SERVICE_LICENSE";
void loadAgreements();
}
async function loadAgreements(): Promise<void> {
agreementList.value = createAgreementList().map(item => ({
...item,
loading: true
}));
try {
const settingMap = await SettingAPI.map("GAO_SYSTEM");
agreementList.value = await Promise.all(agreementList.value.map(async item => {
const articleId = settingMap[item.code]?.value?.trim();
if (!articleId) {
return {
...item,
loading: false,
errorMessage: `${item.title}暂未配置`
};
}
try {
return {
...item,
article: await ArticleAPI.detail(articleId),
loading: false
};
} catch (error) {
return {
...item,
loading: false,
errorMessage: getErrorMessage(error, `${item.title}加载失败`)
};
}
}));
} catch (error) {
const errorMessage = getErrorMessage(error, "协议配置加载失败");
agreementList.value = createAgreementList().map(item => ({
...item,
loading: false,
errorMessage
}));
}
}
function resolveAttachmentUrl(attachmentId: string): string {
return AttachmentAPI.getReadURL(attachmentId);
}
function rejectAgreement(): void {
emit("update:modelValue", false);
}
function agreeAgreement(): void {
emit("update:modelValue", true);
}
</script>
<style scoped lang="less">
.user-agreement {
display: flex;
margin-top: .75rem;
justify-content: center;
.checkbox {
color: #52607a;
font-size: .8125rem;
}
}
.user-agreement-dialog {
:global(.user-agreement-dialog.t-dialog .t-dialog__content) {
height: min(62vh, 36rem);
max-height: min(62vh, 36rem);
overflow: hidden;
}
:global(.user-agreement-dialog.t-dialog .t-dialog__body) {
flex: 1 1 auto;
min-height: 0;
display: flex;
overflow: hidden;
}
:global(.user-agreement-dialog.t-dialog .t-dialog__body-text) {
width: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
}
:global(.user-agreement-dialog.t-dialog .t-dialog__footer) {
--td-spacer-1: 0;
--td-button-border-radius: 1rem;
padding: 10px;
}
.dialog-content {
--td-spacer-3: 10px;
gap: .5rem;
width: 100%;
height: 100%;
flex: 1 1 auto;
display: flex;
min-height: 0;
flex-direction: column;
.tabs {
--td-tab-item-height: 2rem;
width: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
:deep(.t-tabs__content) {
flex: 1 1 auto;
height: 0;
min-height: 0;
overflow-y: auto;
}
:deep(.t-tabs__scroll) {
overflow-x: hidden;
}
:deep(.t-tabs__item) {
padding: 0;
font-size: .75rem;
}
}
.agreement-item {
padding-top: 1rem;
.loading {
padding: 1.5rem 0;
}
.error {
margin: 0;
color: #b42318;
font-size: .8125rem;
line-height: 1.6;
}
:deep(.content) {
border: none;
padding: 0;
}
}
.tips {
flex: 0 0 auto;
margin: 0;
color: #52607a;
line-height: 1.5;
font-size: .75rem;
}
}
}
:global(html.theme-dark) {
.user-agreement {
.checkbox {
color: #94a3b8;
}
}
.user-agreement-dialog {
.dialog-content {
.hint {
color: #94a3b8;
}
.agreement-item {
.error {
color: #fda29b;
}
}
}
}
}
</style>