feat 个人中心
This commit is contained in:
@@ -4,9 +4,11 @@ import { computed } from 'vue'
|
||||
interface Props {
|
||||
keyword: string
|
||||
favoriteOnly: boolean
|
||||
/** 是否显示「收藏列表」切换按钮(案例库显示,收藏页本身不显示) */
|
||||
showFavoriteToggle?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), { showFavoriteToggle: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [keyword: string]
|
||||
@@ -44,6 +46,7 @@ const keyword = computed<string>({
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showFavoriteToggle"
|
||||
class="case-search__fav"
|
||||
:class="{ 'is-active': favoriteOnly }"
|
||||
@click="emit('toggleFavoriteOnly')"
|
||||
69
src/components/EllipsisText.vue
Normal file
69
src/components/EllipsisText.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElTooltip } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placement: 'top',
|
||||
})
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const overflow = ref(false)
|
||||
|
||||
function check(): void {
|
||||
const el = root.value
|
||||
if (!el) return
|
||||
overflow.value = el.scrollWidth > el.clientWidth + 1
|
||||
}
|
||||
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
check()
|
||||
if (root.value && typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(() => check())
|
||||
ro.observe(root.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect()
|
||||
ro = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.text,
|
||||
() => check(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="root" class="ellipsis-text">
|
||||
<ElTooltip :content="text" :disabled="!overflow" :placement="placement" effect="dark">
|
||||
<span class="ellipsis-text__inner">{{ text }}</span>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ellipsis-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
.ellipsis();
|
||||
|
||||
&__inner {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,9 +4,11 @@ import { getPasswordLevel } from '@/utils/validate'
|
||||
|
||||
interface Props {
|
||||
password: string
|
||||
/** 是否显示右侧「密码强度:X」文字(登录/注册页显示,个人中心设计稿不显示) */
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), { showLabel: true })
|
||||
|
||||
const level = computed(() => getPasswordLevel(props.password))
|
||||
|
||||
@@ -28,7 +30,7 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
|
||||
:class="{ 'is-on': password && i <= levelIndex, [`is-${level}`]: password && i <= levelIndex }"
|
||||
/>
|
||||
</div>
|
||||
<span class="pwd-strength__label" :class="`is-${level}`">{{ password ? `密码强度:${levelText}` : '' }}</span>
|
||||
<span v-if="showLabel" class="pwd-strength__label" :class="`is-${level}`">{{ password ? `密码强度:${levelText}` : '' }}</span>
|
||||
<p v-if="password && level === 'weak'" class="pwd-strength__tip">
|
||||
密码等级过低,请重新输入(建议字母+数字+符号组合)
|
||||
</p>
|
||||
|
||||
@@ -15,3 +15,7 @@ export { default as SmsCodeInput } from './SmsCodeInput.vue'
|
||||
export { default as PasswordStrength } from './PasswordStrength.vue'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
||||
export { default as QrCodePanel } from './QrCodePanel.vue'
|
||||
export { default as CasesFilter } from './CasesFilter.vue'
|
||||
export { default as CasesSearchBar } from './CasesSearchBar.vue'
|
||||
export { default as CasesGrid } from './CasesGrid.vue'
|
||||
export { default as EllipsisText } from './EllipsisText.vue'
|
||||
|
||||
@@ -8,6 +8,7 @@ const layoutRoutes: RouteRecordRaw = {
|
||||
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
|
||||
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
|
||||
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
|
||||
{ path: 'favorites', name: 'Favorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } },
|
||||
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } },
|
||||
|
||||
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
|
||||
@@ -35,8 +36,11 @@ const layoutRoutes: RouteRecordRaw = {
|
||||
children: [
|
||||
{ path: '', redirect: '/profile/security' },
|
||||
{ path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } },
|
||||
{ path: 'security/change-password', name: 'ProfileChangePassword', component: () => import('@/views/profile/change-password.vue'), meta: { title: '修改密码', requiresAuth: true } },
|
||||
{ path: 'security/bind-phone', name: 'ProfileBindPhone', component: () => import('@/views/profile/bind-phone.vue'), meta: { title: '绑定手机号', requiresAuth: true } },
|
||||
{ path: 'security/bind-email', name: 'ProfileBindEmail', component: () => import('@/views/profile/bind-email.vue'), meta: { title: '绑定邮箱', requiresAuth: true } },
|
||||
{ path: 'security/delete-account', name: 'ProfileDeleteAccount', component: () => import('@/views/profile/delete-account.vue'), meta: { title: '注销账号', requiresAuth: true } },
|
||||
{ path: 'address', name: 'ProfileAddress', component: () => import('@/views/profile/address.vue'), meta: { title: '地址管理', requiresAuth: true } },
|
||||
{ path: 'favorites', name: 'ProfileFavorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } },
|
||||
{ path: 'qualifications', name: 'ProfileQualifications', component: () => import('@/views/profile/qualifications.vue'), meta: { title: '资质列表', requiresAuth: true } },
|
||||
{ path: 'feedback', name: 'ProfileFeedback', component: () => import('@/views/profile/feedback.vue'), meta: { title: '意见反馈', requiresAuth: true } },
|
||||
{ path: 'help', name: 'ProfileHelp', component: () => import('@/views/profile/help.vue'), meta: { title: '帮助中心', requiresAuth: true } },
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
@profile-line: #e5e7eb;
|
||||
@profile-input-border: #dde1e7;
|
||||
@profile-radius: 6px;
|
||||
@profile-page-bg: #f4f6f8;
|
||||
@profile-page-bg: #f4f5f9;
|
||||
@profile-ink: #1f2937;
|
||||
@profile-hotline-bg: #d6e4fb;
|
||||
@profile-icon-pwd: #c7d2fe;
|
||||
@@ -265,6 +265,9 @@
|
||||
@profile-user-avatar: #8976ed;
|
||||
@profile-page-active-bg: #d1e0fe;
|
||||
@profile-quiet-btn: #eef0f3;
|
||||
@profile-tag-ok-bg: #e0f0e0;
|
||||
@profile-input-disabled-bg: #f3f4f6;
|
||||
@profile-verify-banner-bg: #edf3fe;
|
||||
|
||||
@contract-ink: #121212;
|
||||
@contract-page-bg: #f4f6f8;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export { default } from './index.vue'
|
||||
export { default as CasesFilter } from './cpns/CasesFilter.vue'
|
||||
export { default as CasesSearchBar } from './cpns/CasesSearchBar.vue'
|
||||
export { default as CasesGrid } from './cpns/CasesGrid.vue'
|
||||
export { CasesFilter, CasesSearchBar, CasesGrid } from '@/components'
|
||||
export { default as CaseHero } from './cpns/CaseHero.vue'
|
||||
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
|
||||
export { default as CaseRichText } from './cpns/CaseRichText.vue'
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PaginationBar } from '@/components'
|
||||
import { CasesFilter, CasesGrid, CasesSearchBar, PaginationBar } from '@/components'
|
||||
import { useCasesStore } from '@/stores/modules/cases'
|
||||
import type { CaseQuery } from '@/types/business/cases'
|
||||
import CasesFilter from './cpns/CasesFilter.vue'
|
||||
import CasesSearchBar from './cpns/CasesSearchBar.vue'
|
||||
import CasesGrid from './cpns/CasesGrid.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const casesStore = useCasesStore()
|
||||
@@ -48,6 +45,8 @@ function onViewDetail(id: string): void {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 收藏页复用本 store 并会置 favoriteOnly=true,进入案例库时重置,避免状态残留
|
||||
casesStore.resetQuery()
|
||||
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,7 @@ import AboutPanel from './cpns/AboutPanel.vue'
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
@@ -38,7 +38,7 @@ import AboutPanel from './cpns/AboutPanel.vue'
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { AppButton, ConfirmDialog, PaginationBar, StatusTag } from '@/components'
|
||||
import { ConfirmDialog, PaginationBar, EllipsisText } from '@/components'
|
||||
import { useProfileStore } from '@/stores/modules/profile'
|
||||
import type { Address } from '@/types/business/profile'
|
||||
import AddressDialog from './cpns/AddressDialog.vue'
|
||||
@@ -64,51 +64,50 @@ onMounted(() => {
|
||||
<div class="address-page">
|
||||
<header class="address-page__head">
|
||||
<h2 class="address-page__title">
|
||||
收货地址
|
||||
地址管理
|
||||
</h2>
|
||||
<AppButton type="primary" @click="onCreate">
|
||||
<button type="button" class="address-page__create" @click="onCreate">
|
||||
+ 新增地址
|
||||
</AppButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="address-page__grid">
|
||||
<div class="address-page__list">
|
||||
<article
|
||||
v-for="address in addresses"
|
||||
:key="address.id"
|
||||
class="address-card"
|
||||
class="address-row"
|
||||
>
|
||||
<div class="address-card__head">
|
||||
<h3 class="address-card__name">
|
||||
{{ address.receiverName }}
|
||||
</h3>
|
||||
<StatusTag v-if="address.isDefault" label="默认" variant="success" />
|
||||
<span v-if="address.isDefault" class="address-row__tag">默认</span>
|
||||
<div class="address-row__col address-row__col--name">
|
||||
<p class="address-row__label">收货人姓名</p>
|
||||
<EllipsisText class="address-row__value" :text="address.receiverName" />
|
||||
</div>
|
||||
<ul class="address-card__meta">
|
||||
<li><span>联系方式:</span>{{ address.phone }}</li>
|
||||
<li><span>所在地区:</span>{{ address.region }}</li>
|
||||
<li><span>详细地址:</span>{{ address.detailAddress }}</li>
|
||||
</ul>
|
||||
<div class="address-card__actions">
|
||||
<div class="address-row__col">
|
||||
<p class="address-row__label">联系方式</p>
|
||||
<EllipsisText class="address-row__value" :text="address.phone" />
|
||||
</div>
|
||||
<div class="address-row__col">
|
||||
<p class="address-row__label">所在地区</p>
|
||||
<EllipsisText class="address-row__value" :text="address.region" />
|
||||
</div>
|
||||
<div class="address-row__col address-row__col--detail">
|
||||
<p class="address-row__label">详细地址</p>
|
||||
<EllipsisText class="address-row__value" :text="address.detailAddress" />
|
||||
</div>
|
||||
<div class="address-row__actions">
|
||||
<button
|
||||
v-if="!address.isDefault"
|
||||
class="address-card__btn"
|
||||
type="button"
|
||||
class="address-row__btn"
|
||||
@click="onToggleDefault(address)"
|
||||
>
|
||||
设为默认
|
||||
{{ address.isDefault ? '取消默认' : '设为默认' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="address-card__btn address-card__btn--muted"
|
||||
disabled
|
||||
>
|
||||
取消默认
|
||||
</button>
|
||||
<button class="address-card__btn" @click="onEdit(address)">
|
||||
编辑
|
||||
</button>
|
||||
<button class="address-card__btn address-card__btn--danger" @click="onDelete(address)">
|
||||
<button type="button" class="address-row__btn" @click="onDelete(address)">
|
||||
删除
|
||||
</button>
|
||||
<button type="button" class="address-row__btn" @click="onEdit(address)">
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -145,21 +144,20 @@ onMounted(() => {
|
||||
<style lang="less" scoped>
|
||||
.address-page {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
|
||||
&__head {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
padding-left: 14px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
@@ -167,25 +165,41 @@ onMounted(() => {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @spacing-base;
|
||||
&__create {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: @spacing-xxl 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.pagination) {
|
||||
@@ -198,115 +212,80 @@ onMounted(() => {
|
||||
background: @profile-page-active-bg;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
.app-button--primary {
|
||||
height: 24px;
|
||||
.address-row {
|
||||
position: relative;
|
||||
.flex(row, flex-start, center);
|
||||
min-height: 78px;
|
||||
padding: 14px 24px 14px 52px;
|
||||
border: 1px solid @profile-card-border;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__tag {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
left: 28px;
|
||||
height: 18px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
border-radius: @radius-sm;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.address-card {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-lg;
|
||||
padding: @spacing-base;
|
||||
border: 1px solid @profile-card-border;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
|
||||
&__head {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-xs;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-xl;
|
||||
flex: 1;
|
||||
&__col {
|
||||
flex: 0 0 405px;
|
||||
min-width: 0;
|
||||
list-style: none;
|
||||
font-size: 11px;
|
||||
padding-right: 24px;
|
||||
box-sizing: border-box;
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: @heading-color;
|
||||
&--detail {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-axis-text;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
margin-top: 6px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: @spacing-xs;
|
||||
color: @text-color-secondary;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
.flex(row, flex-start, center);
|
||||
flex-shrink: 0;
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
gap: 12px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid @profile-line;
|
||||
border-radius: @profile-radius;
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid @profile-card-border;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: @finance-entry-text;
|
||||
font-size: @font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&:hover {
|
||||
border-color: @primary-strong;
|
||||
color: @primary-strong;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--muted {
|
||||
color: @text-color;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: @text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.address-card .status-tag {
|
||||
padding: 2px 8px;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
257
src/views/profile/bind-email.vue
Normal file
257
src/views/profile/bind-email.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useProfileStore } from '@/stores/modules/profile'
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
const { profile } = storeToRefs(profileStore)
|
||||
|
||||
const hasEmail = computed(() => Boolean(profile.value?.email))
|
||||
|
||||
const email = ref('')
|
||||
const code = ref('')
|
||||
|
||||
const { seconds, isCounting, start } = useCountDown(60)
|
||||
|
||||
function onGetCode(): void {
|
||||
if (!hasEmail.value && !email.value) {
|
||||
ElMessage.warning('请输入新邮箱')
|
||||
return
|
||||
}
|
||||
start(60)
|
||||
ElMessage.success('验证码已发送')
|
||||
}
|
||||
|
||||
function onBind(): void {
|
||||
if (!hasEmail.value && !email.value) {
|
||||
ElMessage.warning('请输入新邮箱')
|
||||
return
|
||||
}
|
||||
if (!code.value) {
|
||||
ElMessage.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
// 换绑态设计稿无新邮箱输入框(#43),按现状提交当前邮箱 + 验证码
|
||||
const target = hasEmail.value ? profile.value?.email ?? '' : email.value
|
||||
void profileStore.bindEmail(target, code.value).then(() => {
|
||||
void router.push('/profile/security')
|
||||
})
|
||||
}
|
||||
|
||||
function onUnbind(): void {
|
||||
void profileStore.unbindEmail().then(() => {
|
||||
void router.push('/profile/security')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bind-email-page">
|
||||
<h2 class="bind-email-page__title">
|
||||
绑定邮箱
|
||||
</h2>
|
||||
|
||||
<form class="bind-email-page__form" @submit.prevent="onBind">
|
||||
<div class="bind-email-page__field">
|
||||
<label class="bind-email-page__label">{{ hasEmail ? '邮箱' : '新邮箱' }}</label>
|
||||
<input
|
||||
v-if="hasEmail"
|
||||
class="bind-email-page__input bind-email-page__input--disabled"
|
||||
type="text"
|
||||
:value="profile?.email ?? ''"
|
||||
readonly
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="email"
|
||||
class="bind-email-page__input"
|
||||
type="text"
|
||||
placeholder="请输入新邮箱"
|
||||
maxlength="50"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="bind-email-page__field">
|
||||
<label class="bind-email-page__label">邮箱验证码</label>
|
||||
<div class="bind-email-page__code">
|
||||
<input
|
||||
v-model="code"
|
||||
class="bind-email-page__input"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="6"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="bind-email-page__get-code"
|
||||
:disabled="isCounting"
|
||||
@click="onGetCode"
|
||||
>
|
||||
{{ isCounting ? `${seconds}秒后重获` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasEmail" class="bind-email-page__actions">
|
||||
<button type="button" class="bind-email-page__quit" @click="onUnbind">
|
||||
取消绑定
|
||||
</button>
|
||||
<button type="submit" class="bind-email-page__submit">
|
||||
更换绑定邮箱
|
||||
</button>
|
||||
</div>
|
||||
<button v-else type="submit" class="bind-email-page__submit">
|
||||
验证并绑定
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.bind-email-page {
|
||||
.flex(column, flex-start, stretch);
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 24px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__form {
|
||||
width: 860px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-bottom: 12px;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 500;
|
||||
color: @profile-ink;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
font-size: 13px;
|
||||
color: @profile-ink;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-strong;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @finance-axis-text;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
border-color: transparent;
|
||||
background: @profile-input-disabled-bg;
|
||||
color: @finance-muted-text;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__code {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.bind-email-page__input {
|
||||
padding-right: 124px;
|
||||
}
|
||||
}
|
||||
|
||||
&__get-code {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 107px;
|
||||
height: 38px;
|
||||
border: 1px solid @primary-strong;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
color: @primary-strong;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: @text-color-disabled;
|
||||
color: @text-color-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 28px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__quit,
|
||||
&__submit {
|
||||
height: 46px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&__quit {
|
||||
background: @profile-quiet-btn;
|
||||
color: @finance-muted-text;
|
||||
|
||||
&:hover {
|
||||
background: darken(@profile-quiet-btn, 4%);
|
||||
}
|
||||
}
|
||||
|
||||
&__submit {
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
235
src/views/profile/bind-phone.vue
Normal file
235
src/views/profile/bind-phone.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useProfileStore } from '@/stores/modules/profile'
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
const { profile } = storeToRefs(profileStore)
|
||||
|
||||
const hasPhone = computed(() => Boolean(profile.value?.phone))
|
||||
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
|
||||
const { seconds, isCounting, start } = useCountDown(60)
|
||||
|
||||
function onGetCode(): void {
|
||||
const target = hasPhone.value ? profile.value?.phone : phone.value
|
||||
if (!target) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
start(60)
|
||||
ElMessage.success('验证码已发送')
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
const target = hasPhone.value ? profile.value?.phone : phone.value
|
||||
if (!target) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!code.value) {
|
||||
ElMessage.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
void profileStore.bindPhone(target, code.value).then(() => {
|
||||
void router.push('/profile/security')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bind-phone-page">
|
||||
<h2 class="bind-phone-page__title">
|
||||
绑定手机号
|
||||
</h2>
|
||||
|
||||
<form class="bind-phone-page__form" @submit.prevent="onSubmit">
|
||||
<div class="bind-phone-page__field">
|
||||
<label class="bind-phone-page__label">手机号</label>
|
||||
<input
|
||||
v-if="hasPhone"
|
||||
class="bind-phone-page__input bind-phone-page__input--disabled"
|
||||
type="text"
|
||||
:value="profile?.phone ?? ''"
|
||||
readonly
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="phone"
|
||||
class="bind-phone-page__input"
|
||||
type="text"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="20"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="bind-phone-page__field">
|
||||
<label class="bind-phone-page__label">验证码</label>
|
||||
<div class="bind-phone-page__code">
|
||||
<input
|
||||
v-model="code"
|
||||
class="bind-phone-page__input"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="6"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="bind-phone-page__get-code"
|
||||
:disabled="isCounting"
|
||||
@click="onGetCode"
|
||||
>
|
||||
{{ isCounting ? `${seconds}秒后重获` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="bind-phone-page__hint">
|
||||
注:更换新手机号之后,可以用新的手机号充当当前账号登录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bind-phone-page__submit">
|
||||
{{ hasPhone ? '验证并换绑' : '验证并绑定' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.bind-phone-page {
|
||||
.flex(column, flex-start, stretch);
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 24px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__form {
|
||||
width: 860px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-bottom: 12px;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 500;
|
||||
color: @profile-ink;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
font-size: 13px;
|
||||
color: @profile-ink;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-strong;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @finance-axis-text;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
border-color: transparent;
|
||||
background: @profile-input-disabled-bg;
|
||||
color: @finance-muted-text;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__code {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.bind-phone-page__input {
|
||||
padding-right: 124px;
|
||||
}
|
||||
}
|
||||
|
||||
&__get-code {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 107px;
|
||||
height: 38px;
|
||||
border: 1px solid @primary-strong;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
color: @primary-strong;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: @text-color-disabled;
|
||||
color: @text-color-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin-top: 8px;
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-axis-text;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
margin-top: 2px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
283
src/views/profile/change-password.vue
Normal file
283
src/views/profile/change-password.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { PasswordStrength } from '@/components'
|
||||
import { isValidPassword, getPasswordLevel } from '@/utils/validate'
|
||||
import { useProfileStore } from '@/stores/modules/profile'
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
const { profile } = storeToRefs(profileStore)
|
||||
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const agree = ref(false)
|
||||
|
||||
const { seconds, isCounting, start } = useCountDown(60)
|
||||
|
||||
function onGetCode(): void {
|
||||
if (!phone.value) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
start(60)
|
||||
ElMessage.success('验证码已发送')
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
if (!phone.value) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!code.value) {
|
||||
ElMessage.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
if (!password.value) {
|
||||
ElMessage.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
if (password.value !== confirm.value) {
|
||||
ElMessage.warning('两次密码不一致')
|
||||
return
|
||||
}
|
||||
if (!isValidPassword(password.value) || getPasswordLevel(password.value) === 'weak') {
|
||||
ElMessage.warning('密码等级过低,请重新输入')
|
||||
return
|
||||
}
|
||||
if (!agree.value) {
|
||||
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
|
||||
return
|
||||
}
|
||||
void profileStore.changePassword({ code: code.value, password: password.value }).then(() => {
|
||||
void router.push('/profile/security')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="change-pwd-page">
|
||||
<h2 class="change-pwd-page__title">
|
||||
修改密码
|
||||
</h2>
|
||||
|
||||
<form class="change-pwd-page__form" @submit.prevent="onSubmit">
|
||||
<div class="change-pwd-page__field">
|
||||
<label class="change-pwd-page__label">手机号</label>
|
||||
<input
|
||||
v-model="phone"
|
||||
class="change-pwd-page__input"
|
||||
type="text"
|
||||
:placeholder="profile?.phone ?? '请输入手机号'"
|
||||
maxlength="20"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="change-pwd-page__field">
|
||||
<label class="change-pwd-page__label">验证码</label>
|
||||
<div class="change-pwd-page__code">
|
||||
<input
|
||||
v-model="code"
|
||||
class="change-pwd-page__input"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="6"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="change-pwd-page__get-code"
|
||||
:disabled="isCounting"
|
||||
@click="onGetCode"
|
||||
>
|
||||
{{ isCounting ? `${seconds}秒后重获` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="change-pwd-page__field">
|
||||
<label class="change-pwd-page__label">设置密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
class="change-pwd-page__input"
|
||||
type="password"
|
||||
placeholder="请设置8-20位密码"
|
||||
maxlength="20"
|
||||
>
|
||||
<PasswordStrength :password="password" :show-label="false" />
|
||||
</div>
|
||||
|
||||
<div class="change-pwd-page__field change-pwd-page__field--last">
|
||||
<label class="change-pwd-page__label">确认密码</label>
|
||||
<input
|
||||
v-model="confirm"
|
||||
class="change-pwd-page__input"
|
||||
type="password"
|
||||
placeholder="请设置8-20位密码"
|
||||
maxlength="20"
|
||||
>
|
||||
</div>
|
||||
|
||||
<label class="change-pwd-page__agree">
|
||||
<input v-model="agree" type="checkbox">
|
||||
<span>已阅读并同意<em>《用户协议》</em>和<em>《隐私政策》</em></span>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="change-pwd-page__submit">
|
||||
确认修改
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.change-pwd-page {
|
||||
.flex(column, flex-start, stretch);
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 24px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__form {
|
||||
width: 860px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start);
|
||||
margin-bottom: 20px;
|
||||
|
||||
&--last {
|
||||
margin-top: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-bottom: 12px;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 500;
|
||||
color: @profile-ink;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
font-size: 13px;
|
||||
color: @profile-ink;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-strong;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @finance-axis-text;
|
||||
}
|
||||
}
|
||||
|
||||
&__code {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.change-pwd-page__input {
|
||||
padding-right: 124px;
|
||||
}
|
||||
}
|
||||
|
||||
&__get-code {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 107px;
|
||||
height: 38px;
|
||||
border: 1px solid @primary-strong;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
color: @primary-strong;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: @text-color-disabled;
|
||||
color: @text-color-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.pwd-strength) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&__agree {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-muted-text;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: @primary-strong;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__submit {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 18px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,153 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
const intro = '虫子智造是一站式研发智造一体化服务平台,聚焦零部件加工、模具开发、样品试制与批量生产需求,打通需求发布、方案对接、项目管控、合同结算全流程,为企业提供高效透明的智能制造外协解决方案。'
|
||||
|
||||
const mission = '连接需求方与制造资源,简化外协生产流程,降低企业研发与制造成本,助力中小制造企业数字化转型。'
|
||||
|
||||
const advantages = [
|
||||
{ title: '全流程线上化', desc: '需求发布、方案对接、进度跟踪、合同结算全程线上完成,状态实时可见。' },
|
||||
{ title: '优质制造资源', desc: '汇聚软硬件研发与量产优质服务商,严格资质审核,匹配更精准。' },
|
||||
{ title: '规范化项目管控', desc: '标准化交付节点与验收流程,里程碑透明管控,项目风险早发现早处理。' },
|
||||
{ title: '一站式服务', desc: '从零部件加工到模具开发、样品试制与批量生产,一个平台完成全部外协。' },
|
||||
{ title: '全流程线上化', desc: '需求提报、项目跟进、合同管理、财务对账全部线上完成,进度实时可见。' },
|
||||
{ title: '优质制造资源', desc: '汇聚各类加工厂商,覆盖 3D 打印、机加工、注塑、钣金等多种工艺。' },
|
||||
{ title: '规范化项目管理', desc: '统一项目流程、质量标准、售后保障,降低合作风险。' },
|
||||
{ title: '一站式服务', desc: '从样品打样到批量生产,提供完整的智造交付支撑。' },
|
||||
]
|
||||
|
||||
const contacts = [
|
||||
{ label: '客服热线:', value: '400-XXXX-XXXX' },
|
||||
{ label: '工作时间:', value: '周一至周五 09:00 - 18:00' },
|
||||
{ label: '平台官网:', value: 'www.chongzizhizao.com', link: true },
|
||||
{ label: '公司地址:', value: '山东省日照市' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="about-panel">
|
||||
<section class="about-panel__section">
|
||||
<h3 class="about-panel__title">
|
||||
<h3 class="about-panel__section-title">
|
||||
平台简介
|
||||
</h3>
|
||||
<p class="about-panel__text">
|
||||
虫子智造是一站式研发智造一体化服务平台,聚焦零部件加工、模具开发、样品试制与批量生产需求,打通需求发布、方案对接、项目管控、合同结算全流程,为企业提供高效透明的智能制造外协解决方案。
|
||||
<p class="about-panel__center-text">
|
||||
{{ intro }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-panel__section">
|
||||
<h3 class="about-panel__title">
|
||||
<h3 class="about-panel__section-title">
|
||||
我们的使命
|
||||
</h3>
|
||||
<p class="about-panel__text">
|
||||
连接需求方与制造资源,简化外协生产流程,降低企业研发与制造成本,助力中小制造企业数字化转型。
|
||||
<p class="about-panel__center-text">
|
||||
{{ mission }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-panel__section">
|
||||
<h3 class="about-panel__title">
|
||||
核心优势
|
||||
</h3>
|
||||
<ul class="about-panel__advantages">
|
||||
<ol class="about-panel__advantages">
|
||||
<li
|
||||
v-for="item in advantages"
|
||||
:key="item.title"
|
||||
class="about-panel__advantage"
|
||||
>
|
||||
<h4 class="about-panel__advantage-title">
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<p class="about-panel__advantage-desc text-secondary">
|
||||
{{ item.desc }}
|
||||
</p>
|
||||
<span class="about-panel__advantage-title">{{ item.title }}:</span>
|
||||
<span class="about-panel__advantage-desc">{{ item.desc }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</ol>
|
||||
|
||||
<section class="about-panel__section">
|
||||
<h3 class="about-panel__title">
|
||||
联系信息
|
||||
</h3>
|
||||
<ul class="about-panel__contact">
|
||||
<li><span>客服热线:</span>400-XXXX-XXXX</li>
|
||||
<li><span>工作时间:</span>周一至周五 09:00 - 18:00</li>
|
||||
<li>
|
||||
<span>平台官网:</span>
|
||||
<a
|
||||
<ul class="about-panel__contacts">
|
||||
<li v-for="item in contacts" :key="item.label" class="about-panel__contact">
|
||||
{{ item.label }}<a
|
||||
v-if="item.link"
|
||||
href="https://www.chongzizhizao.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="about-panel__link"
|
||||
>
|
||||
https://www.chongzizhizao.com
|
||||
</a>
|
||||
>{{ item.value }}</a>
|
||||
<template v-else>{{ item.value }}</template>
|
||||
</li>
|
||||
<li><span>公司地址:</span>山东省日照市</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="about-panel__section">
|
||||
<h3 class="about-panel__title">
|
||||
版本信息
|
||||
</h3>
|
||||
<p class="about-panel__text">
|
||||
当前系统版本:V1.0.0<br>
|
||||
更新时间:2026 年 09 月
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.about-panel {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-lg;
|
||||
width: 100%;
|
||||
|
||||
&__section {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
& + & {
|
||||
margin-top: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 11px;
|
||||
&__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__center-text {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: @text-color;
|
||||
line-height: 1.9;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__advantages {
|
||||
margin: 56px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: advantage;
|
||||
}
|
||||
|
||||
&__advantage {
|
||||
counter-increment: advantage;
|
||||
font-size: 13px;
|
||||
color: @text-color;
|
||||
line-height: 32px;
|
||||
|
||||
&::before {
|
||||
content: counter(advantage) '. ';
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__advantage-title {
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 11px;
|
||||
&__advantage-desc {
|
||||
color: @text-color;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
&__advantages {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
&__contacts {
|
||||
margin: 46px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__advantage {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&__advantage-title {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
&__advantage-desc {
|
||||
font-size: 11px;
|
||||
color: @text-color;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__contact {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
color: @text-color;
|
||||
|
||||
span {
|
||||
color: @text-color;
|
||||
}
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
&__link {
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AppButton, SmsCodeInput } from '@/components'
|
||||
|
||||
export type BindContactMode =
|
||||
| 'phone-bind'
|
||||
| 'phone-rebind'
|
||||
| 'email-bind'
|
||||
| 'email-rebind'
|
||||
| 'identity'
|
||||
|
||||
interface Props {
|
||||
mode: BindContactMode
|
||||
hintPhone?: string
|
||||
hintEmail?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hintPhone: '',
|
||||
hintEmail: '',
|
||||
})
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const emit = defineEmits<{ submit: [payload: { contact: string; code: string }] }>()
|
||||
|
||||
const contact = ref('')
|
||||
const code = ref('')
|
||||
const agree = ref(false)
|
||||
|
||||
const isEmailMode = computed(() => props.mode === 'email-bind' || props.mode === 'email-rebind')
|
||||
const isIdentity = computed(() => props.mode === 'identity')
|
||||
|
||||
const title = computed<string>(() => {
|
||||
const map: Record<BindContactMode, string> = {
|
||||
'phone-bind': '绑定手机号',
|
||||
'phone-rebind': '验证并换绑',
|
||||
'email-bind': '绑定邮箱',
|
||||
'email-rebind': '更换绑定邮箱',
|
||||
identity: '安全身份验证',
|
||||
}
|
||||
return map[props.mode]
|
||||
})
|
||||
|
||||
const fieldLabel = computed<string>(() => {
|
||||
const map: Record<BindContactMode, string> = {
|
||||
'phone-bind': '手机号',
|
||||
'phone-rebind': '新手机号',
|
||||
'email-bind': '邮箱',
|
||||
'email-rebind': '新邮箱',
|
||||
identity: '手机号',
|
||||
}
|
||||
return map[props.mode]
|
||||
})
|
||||
|
||||
const submitText = computed<string>(() => {
|
||||
const map: Record<BindContactMode, string> = {
|
||||
'phone-bind': '验证并绑定',
|
||||
'phone-rebind': '验证并换绑',
|
||||
'email-bind': '确认修改',
|
||||
'email-rebind': '确认修改',
|
||||
identity: '验证',
|
||||
}
|
||||
return map[props.mode]
|
||||
})
|
||||
|
||||
const contactPlaceholder = computed<string>(() => {
|
||||
if (isEmailMode.value) return props.hintEmail || '请输入邮箱'
|
||||
return props.mode === 'phone-rebind' ? '请输入新手机号' : '请输入手机号'
|
||||
})
|
||||
|
||||
const identityDesc = computed(() =>
|
||||
isIdentity.value
|
||||
? `安全身份验证:为确保账号安全,请完成身份验证后再进行账号操作,发送验证码至 ${props.hintPhone}`
|
||||
: '',
|
||||
)
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (!open) return
|
||||
contact.value = isIdentity.value ? props.hintPhone : ''
|
||||
code.value = ''
|
||||
agree.value = false
|
||||
})
|
||||
|
||||
function onGetCode(): void {
|
||||
ElMessage.success('验证码已发送')
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
if (!contact.value.trim()) {
|
||||
ElMessage.warning(isEmailMode.value ? '请输入邮箱' : '请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!code.value) {
|
||||
ElMessage.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
if (!isIdentity.value && !agree.value) {
|
||||
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
|
||||
return
|
||||
}
|
||||
emit('submit', { contact: contact.value.trim(), code: code.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="bind-dialog" @click.self="visible = false">
|
||||
<div class="bind-dialog__panel">
|
||||
<h3 class="bind-dialog__title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="identityDesc" class="bind-dialog__desc text-secondary">
|
||||
{{ identityDesc }}
|
||||
</p>
|
||||
|
||||
<div class="bind-dialog__field">
|
||||
<label class="bind-dialog__label">{{ fieldLabel }}</label>
|
||||
<input
|
||||
v-model="contact"
|
||||
class="bind-dialog__input"
|
||||
type="text"
|
||||
:placeholder="contactPlaceholder"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="bind-dialog__field">
|
||||
<label class="bind-dialog__label">验证码</label>
|
||||
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
|
||||
</div>
|
||||
|
||||
<label v-if="!isIdentity" class="bind-dialog__agree">
|
||||
<input v-model="agree" type="checkbox">
|
||||
<span>我已阅读并同意<em>《用户协议》</em>和<em>《隐私政策》</em></span>
|
||||
</label>
|
||||
|
||||
<div class="bind-dialog__footer">
|
||||
<AppButton type="default" @click="visible = false">
|
||||
取消
|
||||
</AppButton>
|
||||
<AppButton type="primary" @click="onSubmit">
|
||||
{{ submitText }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.bind-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
.flex(row, center);
|
||||
background: transparent;
|
||||
overflow-y: auto;
|
||||
|
||||
&__panel {
|
||||
width: 700px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
padding: @spacing-base;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
.scrollbar();
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__title:has(+ .bind-dialog__desc) {
|
||||
padding-left: 0;
|
||||
font-size: 11px;
|
||||
color: @primary-strong;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__desc {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: @text-color-secondary;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
font-size: 11px;
|
||||
color: @text-color;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-strong;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @heading-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__agree {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
font-size: 11px;
|
||||
color: @text-color-secondary;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
accent-color: @primary-strong;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-button {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: @profile-radius;
|
||||
}
|
||||
|
||||
.app-button--primary {
|
||||
border-color: @primary-strong;
|
||||
background: @primary-strong;
|
||||
|
||||
&:hover {
|
||||
border-color: darken(@primary-strong, 6%);
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
|
||||
.app-button--default {
|
||||
border: none;
|
||||
background: @profile-quiet-btn;
|
||||
color: @text-color;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
background: darken(@profile-quiet-btn, 4%);
|
||||
color: @text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,256 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AppButton, PasswordStrength, SmsCodeInput } from '@/components'
|
||||
|
||||
interface Props {
|
||||
phone: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { code: string; password: string; confirm: string }]
|
||||
}>()
|
||||
|
||||
const code = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const agree = ref(false)
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (!open) return
|
||||
code.value = ''
|
||||
password.value = ''
|
||||
confirm.value = ''
|
||||
agree.value = false
|
||||
})
|
||||
|
||||
function onGetCode(): void {
|
||||
ElMessage.success('验证码已发送')
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
if (!code.value) {
|
||||
ElMessage.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
if (!password.value) {
|
||||
ElMessage.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
if (!agree.value) {
|
||||
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
|
||||
return
|
||||
}
|
||||
emit('submit', {
|
||||
code: code.value,
|
||||
password: password.value,
|
||||
confirm: confirm.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="change-pwd-dialog" @click.self="visible = false">
|
||||
<div class="change-pwd-dialog__panel">
|
||||
<h3 class="change-pwd-dialog__title">
|
||||
修改密码
|
||||
</h3>
|
||||
|
||||
<div class="change-pwd-dialog__field">
|
||||
<label class="change-pwd-dialog__label">手机号</label>
|
||||
<input
|
||||
class="change-pwd-dialog__input"
|
||||
type="text"
|
||||
:value="props.phone"
|
||||
readonly
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="change-pwd-dialog__field">
|
||||
<label class="change-pwd-dialog__label">验证码</label>
|
||||
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
|
||||
</div>
|
||||
|
||||
<div class="change-pwd-dialog__field">
|
||||
<label class="change-pwd-dialog__label">设置密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
class="change-pwd-dialog__input"
|
||||
type="password"
|
||||
placeholder="请输入密码(6-20位)"
|
||||
maxlength="20"
|
||||
>
|
||||
<PasswordStrength :password="password" />
|
||||
</div>
|
||||
|
||||
<div class="change-pwd-dialog__field">
|
||||
<label class="change-pwd-dialog__label">确认密码</label>
|
||||
<input
|
||||
v-model="confirm"
|
||||
class="change-pwd-dialog__input"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
maxlength="20"
|
||||
@keyup.enter="onSubmit"
|
||||
>
|
||||
</div>
|
||||
|
||||
<label class="change-pwd-dialog__agree">
|
||||
<input v-model="agree" type="checkbox">
|
||||
<span>我已阅读并同意<em>《用户协议》</em>和<em>《隐私政策》</em></span>
|
||||
</label>
|
||||
|
||||
<div class="change-pwd-dialog__footer">
|
||||
<AppButton type="default" @click="visible = false">
|
||||
取消
|
||||
</AppButton>
|
||||
<AppButton type="primary" @click="onSubmit">
|
||||
确认修改
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.change-pwd-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
.flex(row, center);
|
||||
background: transparent;
|
||||
overflow-y: auto;
|
||||
|
||||
&__panel {
|
||||
width: 700px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
padding: @spacing-base;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
.scrollbar();
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
font-size: 11px;
|
||||
color: @text-color;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-strong;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&:read-only {
|
||||
color: @text-color;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__agree {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
font-size: 11px;
|
||||
color: @text-color-secondary;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
accent-color: @primary-strong;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-button {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: @profile-radius;
|
||||
}
|
||||
|
||||
.app-button--primary {
|
||||
border-color: @primary-strong;
|
||||
background: @primary-strong;
|
||||
|
||||
&:hover {
|
||||
border-color: darken(@primary-strong, 6%);
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
|
||||
.app-button--default {
|
||||
border: none;
|
||||
background: @profile-quiet-btn;
|
||||
color: @text-color;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
background: darken(@profile-quiet-btn, 4%);
|
||||
color: @text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { CaseCard } from '@/components'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
cases: Case[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ view: [id: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="favorites-grid">
|
||||
<template v-if="cases.length > 0">
|
||||
<CaseCard
|
||||
v-for="item in cases"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@view="emit('view', $event)"
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="favorites-grid__empty text-secondary">
|
||||
暂无收藏案例,快去案例库挑选感兴趣的内容吧
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.favorites-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: @spacing-base;
|
||||
|
||||
&__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: @spacing-xxl 0;
|
||||
text-align: center;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { AppButton, FileList, UploadArea } from '@/components'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import type { Feedback } from '@/types/business/profile'
|
||||
|
||||
@@ -12,7 +12,6 @@ const props = withDefaults(defineProps<Props>(), { resetSignal: 0 })
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [feedback: Feedback]
|
||||
reset: []
|
||||
preview: [file: FileInfo]
|
||||
}>()
|
||||
|
||||
@@ -22,6 +21,7 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const attachments = reactive<FileInfo[]>([])
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function clear(): void {
|
||||
form.title = ''
|
||||
@@ -34,7 +34,15 @@ watch(
|
||||
() => clear(),
|
||||
)
|
||||
|
||||
function onUpload(file: File): void {
|
||||
function openFilePicker(): void {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files) return
|
||||
for (const file of Array.from(files)) {
|
||||
attachments.push({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
@@ -42,23 +50,44 @@ function onUpload(file: File): void {
|
||||
url: '',
|
||||
})
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function extOf(name: string): string {
|
||||
const index = name.lastIndexOf('.')
|
||||
return index >= 0 ? name.slice(index + 1, index + 4).toUpperCase() : 'FILE'
|
||||
}
|
||||
|
||||
function sizeText(size: number): string {
|
||||
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)}MB`
|
||||
if (size >= 1024) return `${(size / 1024).toFixed(0)}KB`
|
||||
return `${size}B`
|
||||
}
|
||||
|
||||
function onDeleteFile(file: FileInfo): void {
|
||||
const index = attachments.indexOf(file)
|
||||
if (index >= 0) attachments.splice(index, 1)
|
||||
}
|
||||
|
||||
function onPreview(file: FileInfo): void {
|
||||
emit('preview', file)
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
if (!form.title.trim()) {
|
||||
ElMessage.warning('请填写反馈标题')
|
||||
return
|
||||
}
|
||||
if (!form.description.trim()) {
|
||||
ElMessage.warning('请填写详细描述')
|
||||
return
|
||||
}
|
||||
emit('submit', {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
attachments: [...attachments],
|
||||
})
|
||||
}
|
||||
|
||||
function onReset(): void {
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,7 +98,7 @@ function onReset(): void {
|
||||
v-model="form.title"
|
||||
class="feedback-form__input"
|
||||
type="text"
|
||||
placeholder="请简要描述您的反馈标题"
|
||||
placeholder="请输入反馈标题"
|
||||
maxlength="50"
|
||||
>
|
||||
</div>
|
||||
@@ -79,34 +108,57 @@ function onReset(): void {
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="feedback-form__textarea"
|
||||
placeholder="请详细描述您遇到的问题或建议,便于我们更快为您处理"
|
||||
placeholder="请详细描述您的问题..."
|
||||
maxlength="500"
|
||||
rows="6"
|
||||
/>
|
||||
<span class="feedback-form__counter text-secondary">{{ form.description.length }}/500</span>
|
||||
<span class="feedback-form__counter">{{ form.description.length }}/500</span>
|
||||
</div>
|
||||
|
||||
<p class="feedback-form__tip">
|
||||
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
|
||||
<div class="feedback-form__attach-head">
|
||||
<h3 class="feedback-form__attach-title">
|
||||
附件上传
|
||||
</h3>
|
||||
<span class="feedback-form__attach-hint">支持图片/文档/压缩包,大小不超过1G</span>
|
||||
<button class="feedback-form__upload-btn" @click="openFilePicker">
|
||||
上传
|
||||
</button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="feedback-form__file-input"
|
||||
type="file"
|
||||
multiple
|
||||
@change="onFileChange"
|
||||
>
|
||||
</div>
|
||||
|
||||
<ul class="feedback-form__files">
|
||||
<li v-for="file in attachments" :key="file.name" class="feedback-form__file">
|
||||
<span class="feedback-form__file-icon" :class="`is-${extOf(file.name).toLowerCase()}`">
|
||||
{{ extOf(file.name) }}
|
||||
</span>
|
||||
<div class="feedback-form__file-info">
|
||||
<p class="feedback-form__file-name">
|
||||
{{ file.name }}
|
||||
</p>
|
||||
<p class="feedback-form__file-meta">
|
||||
{{ sizeText(file.size) }} · 上传于 {{ file.uploadTime }}
|
||||
</p>
|
||||
|
||||
<div class="feedback-form__field">
|
||||
<label class="feedback-form__label">附件</label>
|
||||
<UploadArea @upload="onUpload" />
|
||||
<FileList
|
||||
:files="attachments"
|
||||
@preview="emit('preview', $event)"
|
||||
@delete="onDeleteFile"
|
||||
/>
|
||||
</div>
|
||||
<div class="feedback-form__file-actions">
|
||||
<button class="feedback-form__file-link" @click="onPreview(file)">
|
||||
预览
|
||||
</button>
|
||||
<button class="feedback-form__file-link is-danger" @click="onDeleteFile(file)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="feedback-form__footer">
|
||||
<AppButton type="primary" @click="onSubmit">
|
||||
<button class="feedback-form__submit" @click="onSubmit">
|
||||
提交反馈
|
||||
</AppButton>
|
||||
<AppButton type="default" @click="onReset">
|
||||
重置
|
||||
</AppButton>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -114,21 +166,20 @@ function onReset(): void {
|
||||
<style lang="less" scoped>
|
||||
.feedback-form {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__required {
|
||||
@@ -139,11 +190,11 @@ function onReset(): void {
|
||||
&__input,
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @profile-radius;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
color: @text-color;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
@@ -154,71 +205,175 @@ function onReset(): void {
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @heading-color;
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
padding: 8px 12px;
|
||||
line-height: 1.6;
|
||||
height: 150px;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__counter {
|
||||
align-self: flex-end;
|
||||
color: @text-color-disabled;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border-radius: @profile-radius;
|
||||
background: @primary-light;
|
||||
&__attach-head {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
&__attach-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&__attach-hint {
|
||||
font-size: 12px;
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
|
||||
&__upload-btn {
|
||||
margin-left: auto;
|
||||
height: 32px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-strong;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__files {
|
||||
.flex(column, flex-start);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__file {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 13px 16px;
|
||||
border-radius: @radius-base;
|
||||
background: @service-input-bg;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__file-icon {
|
||||
.flex(row, center);
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: @radius-base;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
font-weight: 700;
|
||||
|
||||
&.is-pdf {
|
||||
background: @profile-icon-phone;
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&.is-doc,
|
||||
&.is-docx {
|
||||
background: @profile-page-active-bg;
|
||||
color: @primary-strong;
|
||||
}
|
||||
|
||||
&.is-xls,
|
||||
&.is-xlsx {
|
||||
background: @profile-tag-ok-bg;
|
||||
color: @success-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__file-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__file-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: @text-color-disabled;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
&__file-actions {
|
||||
.flex(row, flex-end, center);
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__file-link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: @primary-strong;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: @primary-hover;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
:deep(.upload-area) {
|
||||
min-height: 56px;
|
||||
border: 1px solid @profile-input-border;
|
||||
background: @white;
|
||||
|
||||
&:hover,
|
||||
&.is-dragging {
|
||||
background: @bg-color-light;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.upload-area__hint) {
|
||||
color: @text-color-disabled;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.app-button {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border-radius: @profile-radius;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-button--primary {
|
||||
border-color: @primary-strong;
|
||||
&__submit {
|
||||
height: 40px;
|
||||
padding: 0 32px;
|
||||
border: none;
|
||||
border-radius: @radius-base;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: darken(@primary-strong, 6%);
|
||||
background: darken(@primary-strong, 6%);
|
||||
background: @primary-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,30 +10,27 @@ defineProps<Props>()
|
||||
|
||||
<template>
|
||||
<div class="help-faq">
|
||||
<details
|
||||
v-for="group in groups"
|
||||
<section
|
||||
v-for="(group, gi) in groups"
|
||||
:key="group.title"
|
||||
class="help-faq__group"
|
||||
open
|
||||
>
|
||||
<summary class="help-faq__summary">
|
||||
{{ group.title }}
|
||||
</summary>
|
||||
<ul class="help-faq__list">
|
||||
<li
|
||||
<h3 class="help-faq__group-title">
|
||||
{{ gi + 1 }}、{{ group.title }}
|
||||
</h3>
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.q"
|
||||
class="help-faq__item"
|
||||
>
|
||||
<p class="help-faq__q">
|
||||
Q:{{ item.q }}
|
||||
Q: {{ item.q }}
|
||||
</p>
|
||||
<p class="help-faq__a text-secondary">
|
||||
A:{{ item.a }}
|
||||
{{ item.a }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="groups.length === 0" class="help-faq__empty text-secondary">
|
||||
暂无常见问题
|
||||
@@ -44,68 +41,43 @@ defineProps<Props>()
|
||||
<style lang="less" scoped>
|
||||
.help-faq {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
width: 100%;
|
||||
|
||||
&__group {
|
||||
background: transparent;
|
||||
& + & {
|
||||
margin-top: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
&__summary {
|
||||
.flex(row, center, center);
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
&__group-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
.flex(column, flex-start);
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: @spacing-sm 0 0;
|
||||
list-style: none;
|
||||
border-top: none;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__item {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
padding-top: 0;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
&__q {
|
||||
font-size: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__a {
|
||||
font-size: 11px;
|
||||
color: @text-color;
|
||||
line-height: 1.7;
|
||||
margin: 8px 0 0 184px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: @spacing-xxl 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,77 +6,69 @@ const WORK_TIME = '工作日 8:00-12:00 14:00-18:00'
|
||||
<template>
|
||||
<div class="hotline-panel">
|
||||
<div class="hotline-panel__card">
|
||||
<span class="hotline-panel__icon" aria-hidden="true">📞</span>
|
||||
<span class="hotline-panel__icon" aria-hidden="true" />
|
||||
<div class="hotline-panel__info">
|
||||
<h3 class="hotline-panel__title">
|
||||
专属客服热线
|
||||
</h3>
|
||||
<p class="hotline-panel__number">
|
||||
{{ HOTLINE }}
|
||||
</p>
|
||||
<p class="hotline-panel__time text-secondary">
|
||||
<p class="hotline-panel__time">
|
||||
{{ WORK_TIME }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.hotline-panel {
|
||||
.flex(row, flex-start);
|
||||
padding: 0;
|
||||
|
||||
&__card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
'icon title'
|
||||
'icon number'
|
||||
'icon time';
|
||||
column-gap: @spacing-base;
|
||||
align-content: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: @spacing-lg;
|
||||
|
||||
// 设计稿 #01:浅蓝横幅卡,蓝圆 + 标题/电话/时间三行左对齐
|
||||
&__card {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 32px;
|
||||
width: 100%;
|
||||
padding: 30px 32px;
|
||||
border-radius: @radius-base;
|
||||
background: @profile-hotline-bg;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
.flex(row, center);
|
||||
grid-area: icon;
|
||||
flex-shrink: 0;
|
||||
width: 49px;
|
||||
height: 49px;
|
||||
margin: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-strong;
|
||||
font-size: @font-size-lg;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__info {
|
||||
.flex(column, flex-start);
|
||||
}
|
||||
|
||||
&__title {
|
||||
grid-area: title;
|
||||
align-self: end;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
&__number {
|
||||
grid-area: number;
|
||||
font-size: 18px;
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: @primary-strong;
|
||||
line-height: 1.3;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
&__time {
|
||||
grid-area: time;
|
||||
align-self: start;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: @text-color-secondary;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface MenuItem {
|
||||
const menus: MenuItem[] = [
|
||||
{ to: '/profile/security', label: '账号安全' },
|
||||
{ to: '/profile/address', label: '地址管理' },
|
||||
{ to: '/profile/favorites', label: '收藏列表' },
|
||||
{ to: '/favorites', label: '收藏列表' },
|
||||
{ to: '/profile/qualifications', label: '资质列表' },
|
||||
{ to: '/profile/feedback', label: '意见反馈' },
|
||||
{ to: '/profile/help', label: '帮助中心' },
|
||||
@@ -33,17 +33,14 @@ const menus: MenuItem[] = [
|
||||
.profile-sider {
|
||||
.flex(column, flex-start);
|
||||
gap: 0;
|
||||
padding: 4px 0;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
|
||||
&__item {
|
||||
.flex(row, flex-start, center);
|
||||
height: 36px;
|
||||
padding: 0 24px;
|
||||
padding: 0 16px;
|
||||
border-radius: @profile-radius;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
transition: all 0.2s;
|
||||
|
||||
@@ -54,7 +51,7 @@ const menus: MenuItem[] = [
|
||||
&.router-link-active {
|
||||
background: @profile-nav-active-bg;
|
||||
color: @primary-strong;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function save(): void {
|
||||
<style lang="less" scoped>
|
||||
.profile-user-card {
|
||||
.flex(column, center, center);
|
||||
height: 110px;
|
||||
height: 146px;
|
||||
border-radius: @radius-lg;
|
||||
background: linear-gradient(180deg, @profile-user-from 0%, @profile-user-to 100%);
|
||||
box-sizing: border-box;
|
||||
@@ -69,13 +69,13 @@ function save(): void {
|
||||
|
||||
&__name {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
margin-top: 6px;
|
||||
height: 22px;
|
||||
margin-top: 8px;
|
||||
padding: 0 @spacing-xs;
|
||||
border: 1px solid transparent;
|
||||
border-radius: @profile-radius;
|
||||
background: transparent;
|
||||
font-size: @font-size-lg;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @white;
|
||||
text-align: center;
|
||||
@@ -92,14 +92,14 @@ function save(): void {
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
font-size: @font-size-lg;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 400;
|
||||
color: fade(@white, 75%);
|
||||
}
|
||||
}
|
||||
|
||||
&__phone {
|
||||
margin-top: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: @font-size-sm;
|
||||
color: @white;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { StatusTag } from '@/components'
|
||||
import { EllipsisText } from '@/components'
|
||||
import type { Qualification, QualificationStatus } from '@/types/business/profile'
|
||||
|
||||
interface Props {
|
||||
@@ -14,144 +14,144 @@ const emit = defineEmits<{
|
||||
reupload: [qualification: Qualification]
|
||||
}>()
|
||||
|
||||
const STATUS_VARIANTS: Record<QualificationStatus, 'success' | 'warning' | 'danger'> = {
|
||||
已认证: 'success',
|
||||
审核中: 'warning',
|
||||
未通过: 'danger',
|
||||
const STATUS_CLASS: Record<QualificationStatus, string> = {
|
||||
已认证: 'is-certified',
|
||||
审核中: 'is-reviewing',
|
||||
未通过: 'is-rejected',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="qual-card">
|
||||
<div class="qual-card__head">
|
||||
<h3 class="qual-card__name">
|
||||
{{ qualification.certName }}
|
||||
</h3>
|
||||
<StatusTag
|
||||
:label="qualification.status"
|
||||
:variant="STATUS_VARIANTS[qualification.status]"
|
||||
<article class="qual-row">
|
||||
<div class="qual-row__col">
|
||||
<p class="qual-row__label">资质类型</p>
|
||||
<EllipsisText class="qual-row__value" :text="qualification.type" />
|
||||
</div>
|
||||
<div class="qual-row__col qual-row__col--name">
|
||||
<p class="qual-row__label">证书名称</p>
|
||||
<EllipsisText class="qual-row__value" :text="qualification.certName" />
|
||||
</div>
|
||||
<div class="qual-row__col">
|
||||
<p class="qual-row__label">证书编号</p>
|
||||
<EllipsisText class="qual-row__value" :text="qualification.certNo" />
|
||||
</div>
|
||||
<div class="qual-row__col">
|
||||
<p class="qual-row__label">发证单位</p>
|
||||
<EllipsisText class="qual-row__value" :text="qualification.issuer" />
|
||||
</div>
|
||||
<div class="qual-row__col qual-row__col--valid">
|
||||
<p class="qual-row__label">有效期</p>
|
||||
<EllipsisText
|
||||
class="qual-row__value"
|
||||
:text="qualification.validFrom + ' 至 ' + qualification.validTo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="qual-card__meta">
|
||||
<li><span>资质类型:</span>{{ qualification.type }}</li>
|
||||
<li><span>证书编号:</span>{{ qualification.certNo }}</li>
|
||||
<li><span>发证单位:</span>{{ qualification.issuer }}</li>
|
||||
<li><span>有效期:</span>{{ qualification.validFrom }} 至 {{ qualification.validTo }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="qual-card__actions">
|
||||
<button class="qual-card__btn" @click="emit('edit', qualification)">
|
||||
编辑
|
||||
</button>
|
||||
<button class="qual-card__btn qual-card__btn--danger" @click="emit('delete', qualification)">
|
||||
<div class="qual-row__col qual-row__col--status">
|
||||
<p class="qual-row__label">状态</p>
|
||||
<p class="qual-row__value qual-row__status" :class="STATUS_CLASS[qualification.status]">
|
||||
{{ qualification.status }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="qual-row__actions">
|
||||
<button type="button" class="qual-row__btn" @click="emit('delete', qualification)">
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
v-if="qualification.status === '未通过'"
|
||||
class="qual-card__btn qual-card__btn--warning"
|
||||
type="button"
|
||||
class="qual-row__btn"
|
||||
@click="emit('reupload', qualification)"
|
||||
>
|
||||
重新上传
|
||||
</button>
|
||||
<button v-else type="button" class="qual-row__btn" @click="emit('edit', qualification)">
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.qual-card {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
padding: @spacing-base;
|
||||
.qual-row {
|
||||
position: relative;
|
||||
.flex(row, flex-start, center);
|
||||
min-height: 78px;
|
||||
padding: 14px 24px 14px 52px;
|
||||
border: 1px solid @profile-card-border;
|
||||
border-radius: @radius-base;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__head {
|
||||
.flex(row, space-between, flex-start);
|
||||
gap: @spacing-sm;
|
||||
&__col {
|
||||
flex: 0 0 200px;
|
||||
min-width: 0;
|
||||
padding-right: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&--name {
|
||||
flex: 0 0 270px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 11px;
|
||||
&--valid {
|
||||
flex: 0 0 250px;
|
||||
}
|
||||
|
||||
&--status {
|
||||
flex: 0 0 100px;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-axis-text;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
margin-top: 6px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
.multi-ellipsis(2);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
list-style: none;
|
||||
font-size: 11px;
|
||||
color: @heading-color;
|
||||
|
||||
li {
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
.ellipsis();
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
color: @text-color-secondary;
|
||||
font-weight: 400;
|
||||
&__status {
|
||||
&.is-reviewing {
|
||||
color: @primary-strong;
|
||||
}
|
||||
|
||||
&.is-certified {
|
||||
color: @success-color;
|
||||
}
|
||||
|
||||
&.is-rejected {
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
margin-top: auto;
|
||||
padding-top: @spacing-sm;
|
||||
border-top: 1px solid @profile-card-border;
|
||||
width: 100%;
|
||||
.flex(row, flex-start, center);
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid @profile-line;
|
||||
border-radius: @profile-radius;
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid @profile-card-border;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: @finance-entry-text;
|
||||
font-size: @font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-strong;
|
||||
color: @primary-strong;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
color: @text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qual-card .status-tag {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
&--success {
|
||||
background: transparent;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: transparent;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: transparent;
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Profile } from '@/types/business/profile'
|
||||
|
||||
interface Props {
|
||||
@@ -8,57 +9,89 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changePassword: []
|
||||
bindPhone: []
|
||||
bindEmail: [mode: 'email-bind' | 'email-rebind']
|
||||
unbindEmail: []
|
||||
deleteAccount: []
|
||||
}>()
|
||||
const router = useRouter()
|
||||
|
||||
const hasEmail = computed(() => Boolean(props.profile?.email))
|
||||
|
||||
function goChangePassword(): void {
|
||||
void router.push('/profile/security/change-password')
|
||||
}
|
||||
|
||||
function goBindPhone(): void {
|
||||
void router.push('/profile/security/bind-phone')
|
||||
}
|
||||
|
||||
function goBindEmail(): void {
|
||||
void router.push('/profile/security/bind-email')
|
||||
}
|
||||
|
||||
function goDeleteAccount(): void {
|
||||
void router.push('/profile/security/delete-account')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="security-panel">
|
||||
<h2 class="security-panel__title">
|
||||
账号安全
|
||||
</h2>
|
||||
|
||||
<section
|
||||
class="security-panel__row"
|
||||
@click="emit('changePassword')"
|
||||
@click="goChangePassword"
|
||||
>
|
||||
<span class="security-panel__icon security-panel__icon--pwd" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" width="18" height="18">
|
||||
<path d="M5 7V5a3 3 0 0 1 6 0v2" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||
<rect x="3.5" y="7" width="9" height="6.5" rx="1.5" fill="#fff" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="security-panel__main">
|
||||
<h3 class="security-panel__title">
|
||||
<h3 class="security-panel__name">
|
||||
修改密码
|
||||
</h3>
|
||||
<p class="security-panel__sub">
|
||||
上次修改:{{ profile?.passwordModifiedAt }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="security-panel__state security-panel__state--ok">已设置</span>
|
||||
<span class="security-panel__state">已设置</span>
|
||||
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="security-panel__row"
|
||||
@click="emit('bindPhone')"
|
||||
@click="goBindPhone"
|
||||
>
|
||||
<span class="security-panel__icon security-panel__icon--phone" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" width="18" height="18">
|
||||
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||
<line x1="7" y1="12.2" x2="9" y2="12.2" stroke="#fff" stroke-width="1.2" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="security-panel__main">
|
||||
<h3 class="security-panel__title">
|
||||
<h3 class="security-panel__name">
|
||||
绑定手机
|
||||
</h3>
|
||||
<p class="security-panel__sub">
|
||||
{{ profile?.phone ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="security-panel__state security-panel__state--ok">已绑定</span>
|
||||
<span class="security-panel__state">已绑定</span>
|
||||
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="security-panel__row"
|
||||
@click="emit('bindEmail', hasEmail ? 'email-rebind' : 'email-bind')"
|
||||
@click="goBindEmail"
|
||||
>
|
||||
<span class="security-panel__icon security-panel__icon--email" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" width="18" height="18">
|
||||
<rect x="1.5" y="3.5" width="13" height="9" rx="1.5" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||
<path d="M2 4.5 8 9l6-4.5" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="security-panel__main">
|
||||
<h3 class="security-panel__title">
|
||||
<h3 class="security-panel__name">
|
||||
绑定邮箱
|
||||
</h3>
|
||||
<p class="security-panel__sub">
|
||||
@@ -67,7 +100,7 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
||||
</div>
|
||||
<span
|
||||
class="security-panel__state"
|
||||
:class="{ 'security-panel__state--ok': hasEmail }"
|
||||
:class="{ 'security-panel__state--idle': !hasEmail }"
|
||||
>{{ hasEmail ? '已绑定' : '未绑定' }}</span>
|
||||
<span class="security-panel__arrow" aria-hidden="true">›</span>
|
||||
</section>
|
||||
@@ -75,7 +108,7 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
||||
<button
|
||||
type="button"
|
||||
class="security-panel__delete"
|
||||
@click="emit('deleteAccount')"
|
||||
@click="goDeleteAccount"
|
||||
>
|
||||
注销账号
|
||||
</button>
|
||||
@@ -85,13 +118,33 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
||||
<style lang="less" scoped>
|
||||
.security-panel {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 22px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
.flex(row, space-between, center);
|
||||
height: 86px;
|
||||
padding: 0 24px;
|
||||
border: 1px solid @profile-card-border;
|
||||
.flex(row, flex-start, center);
|
||||
height: 85px;
|
||||
padding: 0 48px 0 52px;
|
||||
border: 1px solid @profile-line;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-sizing: border-box;
|
||||
@@ -99,7 +152,32 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @profile-line;
|
||||
border-color: @profile-nav-active-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&__row + &__row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
.flex(row, center);
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 12px;
|
||||
border-radius: @radius-base;
|
||||
|
||||
&--pwd {
|
||||
background: @profile-icon-pwd;
|
||||
}
|
||||
|
||||
&--phone {
|
||||
background: @profile-icon-phone;
|
||||
}
|
||||
|
||||
&--email {
|
||||
background: @profile-icon-email;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,32 +185,42 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
&__name {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-axis-text; // #9CA3AF
|
||||
color: @finance-axis-text;
|
||||
line-height: 16px;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__state {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 500;
|
||||
color: @finance-muted-text;
|
||||
|
||||
&--ok {
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: @radius-round;
|
||||
background: @profile-tag-ok-bg;
|
||||
color: @success-color;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 22px;
|
||||
|
||||
&--idle {
|
||||
background: @profile-quiet-btn;
|
||||
color: @finance-axis-text;
|
||||
}
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
flex-shrink: 0;
|
||||
margin-left: 20px;
|
||||
font-size: 21px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: @finance-axis-text;
|
||||
}
|
||||
@@ -140,8 +228,9 @@ const hasEmail = computed(() => Boolean(props.profile?.email))
|
||||
&__delete {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 108px;
|
||||
border: 1px solid @profile-line;
|
||||
border-radius: @radius-base;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
color: @finance-axis-text;
|
||||
font-size: @font-size-base;
|
||||
|
||||
590
src/views/profile/delete-account.vue
Normal file
590
src/views/profile/delete-account.vue
Normal file
@@ -0,0 +1,590 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useProfileStore } from '@/stores/modules/profile'
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
const { profile } = storeToRefs(profileStore)
|
||||
|
||||
type DeleteStep = 'confirm' | 'verify'
|
||||
const step = ref<DeleteStep>('confirm')
|
||||
|
||||
const agree = ref(false)
|
||||
|
||||
const conditions = [
|
||||
{ label: '账号无未完成的订单', ok: true },
|
||||
{ label: '账号无未结清的款项', ok: false },
|
||||
{ label: '账号无进行中的合同', ok: true },
|
||||
{ label: '账号无处理中的工单', ok: true },
|
||||
]
|
||||
|
||||
const risks = [
|
||||
'账号注销后,您将无法登录本应用,所有个人信息将被永久删除。',
|
||||
'您名下的订单、合同、工单等业务数据将一并清除,无法找回。',
|
||||
'注销后,同一手机号/邮箱将无法再次注册本应用。',
|
||||
'如有未完成的订单或服务,请先处理完毕后再进行注销。',
|
||||
]
|
||||
|
||||
function onConfirm(): void {
|
||||
if (!agree.value) {
|
||||
ElMessage.warning('请先阅读并同意《账号注销协议》')
|
||||
return
|
||||
}
|
||||
step.value = 'verify'
|
||||
}
|
||||
|
||||
function onCancel(): void {
|
||||
void router.push('/profile/security')
|
||||
}
|
||||
|
||||
// 第二步:身份验证
|
||||
const code = ref('')
|
||||
const codeInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const codeDigits = computed<string[]>(() => {
|
||||
const chars = code.value.split('')
|
||||
return Array.from({ length: 6 }, (_, i) => chars[i] ?? '')
|
||||
})
|
||||
|
||||
const maskedPhone = computed(() => {
|
||||
const phone = profile.value?.phone ?? ''
|
||||
if (phone.length < 7) return phone
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||
})
|
||||
|
||||
const { seconds, isCounting, start } = useCountDown(60)
|
||||
|
||||
function onGetCode(): void {
|
||||
if (isCounting.value) return
|
||||
start(60)
|
||||
ElMessage.success('验证码已发送')
|
||||
}
|
||||
|
||||
function focusCode(): void {
|
||||
codeInputRef.value?.focus()
|
||||
}
|
||||
|
||||
function onCodeInput(event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value.replace(/\D/g, '').slice(0, 6)
|
||||
code.value = value
|
||||
}
|
||||
|
||||
function onDeleteAccount(): void {
|
||||
if (code.value.length !== 6) {
|
||||
ElMessage.warning('请输入 6 位验证码')
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
await profileStore.verifyIdentity(profile.value?.phone ?? '', code.value)
|
||||
await profileStore.deleteAccount()
|
||||
void router.push('/login')
|
||||
})()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="delete-page">
|
||||
<h2 class="delete-page__title">
|
||||
注销账号
|
||||
</h2>
|
||||
|
||||
<!-- 第一步:注销确认(设计稿 #08) -->
|
||||
<div v-if="step === 'confirm'" class="delete-page__confirm">
|
||||
<span class="delete-page__icon" aria-hidden="true">i</span>
|
||||
<h3 class="delete-page__question">确定要注销账号吗?</h3>
|
||||
|
||||
<h4 class="delete-page__section-title delete-page__section-title--risk">
|
||||
⚠ 注销风险提示
|
||||
</h4>
|
||||
<ul class="delete-page__risks">
|
||||
<li v-for="(item, index) in risks" :key="item">
|
||||
{{ index + 1 }}、{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="delete-page__section-title delete-page__section-title--ok">
|
||||
✓ 注销条件
|
||||
</h4>
|
||||
<ul class="delete-page__conditions">
|
||||
<li v-for="item in conditions" :key="item.label" class="delete-page__condition">
|
||||
<span>{{ item.label }}</span>
|
||||
<span
|
||||
class="delete-page__mark"
|
||||
:class="item.ok ? 'is-ok' : 'is-fail'"
|
||||
>{{ item.ok ? '✓' : '✕' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<label class="delete-page__agree">
|
||||
<input v-model="agree" type="checkbox">
|
||||
<span>已阅读并同意<em>《账号注销协议》</em></span>
|
||||
</label>
|
||||
|
||||
<div class="delete-page__buttons">
|
||||
<button type="button" class="delete-page__btn delete-page__btn--quiet" @click="onConfirm">
|
||||
注销账号
|
||||
</button>
|
||||
<button type="button" class="delete-page__btn delete-page__btn--danger" @click="onCancel">
|
||||
暂不注销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:安全身份验证(设计稿 #41) -->
|
||||
<div v-else class="delete-page__verify">
|
||||
<div class="delete-page__banner">
|
||||
<span class="delete-page__banner-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16">
|
||||
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5" fill="none" stroke="#fff" stroke-width="1.5" />
|
||||
<line x1="7" y1="12.2" x2="9" y2="12.2" stroke="#fff" stroke-width="1.2" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="delete-page__banner-title">安全身份验证</h3>
|
||||
<p class="delete-page__banner-desc">为保障账号安全,需先完成身份验证后再进行注销</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="delete-page__verify-card">
|
||||
<div class="delete-page__verify-head">
|
||||
<span class="delete-page__verify-avatar" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16">
|
||||
<circle cx="8" cy="5.5" r="2.8" fill="#fff" />
|
||||
<path d="M2.5 14a5.5 5.5 0 0 1 11 0Z" fill="#fff" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="delete-page__verify-info">
|
||||
<h3 class="delete-page__verify-name">手机验证码</h3>
|
||||
<p class="delete-page__verify-sub">发送验证码至{{ maskedPhone }}</p>
|
||||
</div>
|
||||
<span class="delete-page__verify-tag">默认</span>
|
||||
<span class="delete-page__radio" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div class="delete-page__code-row">
|
||||
<input
|
||||
class="delete-page__code-input"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="6"
|
||||
@input="onCodeInput"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="delete-page__get-code"
|
||||
:disabled="isCounting"
|
||||
@click="onGetCode"
|
||||
>
|
||||
{{ isCounting ? `${seconds}秒后重获` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="delete-page__boxes"
|
||||
@click="focusCode"
|
||||
>
|
||||
<span
|
||||
v-for="(digit, index) in codeDigits"
|
||||
:key="index"
|
||||
class="delete-page__box"
|
||||
:class="{ 'is-filled': Boolean(digit) }"
|
||||
>{{ digit }}</span>
|
||||
<input
|
||||
ref="codeInputRef"
|
||||
class="delete-page__ghost-input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
:value="code"
|
||||
@input="onCodeInput"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="delete-page__submit" @click="onDeleteAccount">
|
||||
注销账号
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.delete-page {
|
||||
.flex(column, flex-start, stretch);
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 24px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 第一步:确认 ----------
|
||||
&__confirm {
|
||||
width: 760px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
.flex(column, flex-start, stretch);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
.flex(row, center);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 52px auto 0;
|
||||
border: 2px solid @danger-color;
|
||||
border-radius: @radius-round;
|
||||
color: @danger-color;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__question {
|
||||
margin-top: 36px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: @profile-ink;
|
||||
text-align: center;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin-top: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
|
||||
&--risk {
|
||||
margin-top: 18px;
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
margin-top: 27px;
|
||||
color: @success-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__risks {
|
||||
margin: 9px 0 0;
|
||||
padding: 0;
|
||||
.flex(column, flex-start);
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
color: @finance-entry-text;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&__conditions {
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
.flex(column, flex-start);
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__condition {
|
||||
.flex(row, space-between, center);
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
color: @finance-entry-text;
|
||||
}
|
||||
|
||||
&__mark {
|
||||
.flex(row, center);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: @radius-round;
|
||||
color: @white;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
|
||||
&.is-ok {
|
||||
background: @success-color;
|
||||
}
|
||||
|
||||
&.is-fail {
|
||||
background: @danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__agree {
|
||||
.flex(row, flex-start, center);
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 30px;
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-muted-text;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: @primary-strong;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: 12px;
|
||||
margin-top: 19px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 46px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--quiet {
|
||||
background: @profile-quiet-btn;
|
||||
color: @finance-muted-text;
|
||||
|
||||
&:hover {
|
||||
background: darken(@profile-quiet-btn, 4%);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: @danger-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: darken(@danger-color, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 第二步:身份验证 ----------
|
||||
&__verify {
|
||||
width: 860px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__banner {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 14px;
|
||||
height: 75px;
|
||||
margin-top: 24px;
|
||||
padding: 0 22px;
|
||||
border-radius: @radius-base;
|
||||
background: @profile-verify-banner-bg;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__banner-icon {
|
||||
.flex(row, center);
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-strong;
|
||||
}
|
||||
|
||||
&__banner-title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__banner-desc {
|
||||
margin-top: 3px;
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-muted-text;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__verify-card {
|
||||
position: relative;
|
||||
margin-top: 19px;
|
||||
padding: 20px 22px 22px;
|
||||
border: 1px solid @primary-strong;
|
||||
border-radius: @radius-base;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__verify-head {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__verify-avatar {
|
||||
.flex(row, center);
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: @radius-round;
|
||||
background: @profile-nav-active-bg;
|
||||
}
|
||||
|
||||
&__verify-name {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__verify-sub {
|
||||
margin-top: 3px;
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-axis-text;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__verify-tag {
|
||||
margin-left: auto;
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-muted-text;
|
||||
}
|
||||
|
||||
&__radio {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 10px;
|
||||
border: 4px solid @primary-strong;
|
||||
border-radius: @radius-round;
|
||||
background: @white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__code-row {
|
||||
position: relative;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
&__code-input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 124px 0 14px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
font-size: 13px;
|
||||
color: @profile-ink;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-strong;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @finance-axis-text;
|
||||
}
|
||||
}
|
||||
|
||||
&__get-code {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 107px;
|
||||
height: 30px;
|
||||
border: 1px solid @primary-strong;
|
||||
border-radius: @profile-radius;
|
||||
background: @white;
|
||||
color: @primary-strong;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: @text-color-disabled;
|
||||
color: @text-color-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__boxes {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 13px;
|
||||
margin-top: 16px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
&__box {
|
||||
.flex(row, center);
|
||||
width: 46px;
|
||||
height: 47px;
|
||||
border: 1px solid @profile-input-border;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: @primary-strong;
|
||||
|
||||
&.is-filled {
|
||||
border-color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__ghost-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: @primary-strong;
|
||||
font-size: 18px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
margin-top: 21px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,54 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { FilterPanel, PaginationBar } from '@/components'
|
||||
import type { BusinessType } from '@/types/api'
|
||||
import { useProfileStore } from '@/stores/modules/profile'
|
||||
import FavoritesGrid from './cpns/FavoritesGrid.vue'
|
||||
|
||||
interface CategoryOption {
|
||||
key: BusinessType | 'all'
|
||||
label: string
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS: CategoryOption[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'internet', label: '互联网软件' },
|
||||
{ key: 'hardware', label: '硬件电路' },
|
||||
{ key: 'iot', label: '物联网全案' },
|
||||
{ key: 'manufacturing', label: '生产制造' },
|
||||
]
|
||||
import { CasesFilter, CasesGrid, CasesSearchBar, PaginationBar } from '@/components'
|
||||
import { useCasesStore } from '@/stores/modules/cases'
|
||||
import type { CaseQuery } from '@/types/business/cases'
|
||||
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
const { favorites, favoritesTotal, favoritesCounts, favoritesQuery } = storeToRefs(profileStore)
|
||||
const casesStore = useCasesStore()
|
||||
const { query, list, total, previewTotal, typeOptions, industryOptions, scaleOptions, caseTotal }
|
||||
= storeToRefs(casesStore)
|
||||
|
||||
const keyword = ref('')
|
||||
const category = ref<BusinessType | 'all'>('all')
|
||||
function onUpdateQuery(next: CaseQuery): void {
|
||||
casesStore.setQuery(next)
|
||||
}
|
||||
|
||||
function countOf(key: BusinessType | 'all'): number {
|
||||
if (key === 'all') return favoritesTotal.value
|
||||
return favoritesCounts.value[key] ?? 0
|
||||
function onUpdateKeyword(keyword: string): void {
|
||||
casesStore.setQuery({ ...query.value, keyword })
|
||||
}
|
||||
|
||||
function onSearch(): void {
|
||||
void profileStore.searchFavorites({ keyword: keyword.value, category: category.value })
|
||||
void casesStore.search()
|
||||
}
|
||||
|
||||
function onReset(): void {
|
||||
keyword.value = ''
|
||||
category.value = 'all'
|
||||
void profileStore.resetFavorites()
|
||||
void casesStore.resetQuery()
|
||||
}
|
||||
|
||||
function onSelectCategory(key: BusinessType | 'all'): void {
|
||||
category.value = key
|
||||
void profileStore.searchFavorites({ keyword: keyword.value, category: key })
|
||||
async function onFavorite(id: string): Promise<void> {
|
||||
await casesStore.toggleFavorite(id)
|
||||
// 收藏页取消收藏后,立即按 favoriteOnly 重新拉取,让卡片消失
|
||||
await casesStore.fetchCases()
|
||||
}
|
||||
|
||||
function onPageChange(page: number): void {
|
||||
void profileStore.changeFavoritesPage(page)
|
||||
void casesStore.changePage(page)
|
||||
}
|
||||
|
||||
function onViewDetail(id: string): void {
|
||||
@@ -56,90 +42,76 @@ function onViewDetail(id: string): void {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
keyword.value = favoritesQuery.value.keyword
|
||||
category.value = favoritesQuery.value.category
|
||||
void Promise.all([profileStore.fetchFavorites(), profileStore.fetchFavoritesCounts()])
|
||||
// 复用案例库 store,强制只看收藏;case 库进入时会 resetQuery 清除该态,互不污染
|
||||
casesStore.setQuery({ ...casesStore.query, favoriteOnly: true, page: 1 })
|
||||
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="favorites-page">
|
||||
<FilterPanel
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索收藏案例标题"
|
||||
<div class="favorites">
|
||||
<div class="container favorites__body">
|
||||
<CasesFilter
|
||||
:query="query"
|
||||
:type-options="typeOptions"
|
||||
:industry-options="industryOptions"
|
||||
:scale-options="scaleOptions"
|
||||
:case-total="caseTotal"
|
||||
:preview-total="previewTotal"
|
||||
@update:query="onUpdateQuery"
|
||||
@search="onSearch"
|
||||
@reset="onReset"
|
||||
/>
|
||||
|
||||
<div class="favorites-page__categories">
|
||||
<button
|
||||
v-for="item in CATEGORY_OPTIONS"
|
||||
:key="item.key"
|
||||
class="favorites-page__cat"
|
||||
:class="{ 'is-active': category === item.key }"
|
||||
@click="onSelectCategory(item.key)"
|
||||
>
|
||||
{{ item.label }}({{ countOf(item.key) }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="favorites__main">
|
||||
<CasesSearchBar
|
||||
:keyword="query.keyword"
|
||||
:favorite-only="query.favoriteOnly"
|
||||
:show-favorite-toggle="false"
|
||||
@update:keyword="onUpdateKeyword"
|
||||
@search="onSearch"
|
||||
@reset="onReset"
|
||||
/>
|
||||
|
||||
<FavoritesGrid :cases="favorites" @view="onViewDetail" />
|
||||
<CasesGrid
|
||||
:cases="list"
|
||||
:total="total"
|
||||
@view="onViewDetail"
|
||||
@favorite="onFavorite"
|
||||
/>
|
||||
|
||||
<div class="favorites__pager">
|
||||
<PaginationBar
|
||||
:page="favoritesQuery.page"
|
||||
:page-size="favoritesQuery.pageSize"
|
||||
:total="favoritesTotal"
|
||||
:page="query.page"
|
||||
:page-size="query.pageSize"
|
||||
:total="total"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.favorites-page {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
width: 100%;
|
||||
.favorites {
|
||||
padding-bottom: 78px;
|
||||
|
||||
&__categories {
|
||||
.flex(row, flex-start);
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
&__body {
|
||||
.flex(row, flex-start, flex-start);
|
||||
gap: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
&__cat {
|
||||
height: 28px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @profile-line;
|
||||
border-radius: @radius-round;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: 11px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-strong;
|
||||
color: @primary-strong;
|
||||
&__main {
|
||||
.flex(column, flex-start, stretch);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @primary-strong;
|
||||
background: @profile-nav-active-bg;
|
||||
color: @primary-strong;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.pagination) {
|
||||
justify-content: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.pagination__btn.is-active) {
|
||||
border-color: @profile-page-active-bg;
|
||||
background: @profile-page-active-bg;
|
||||
color: @primary-strong;
|
||||
&__pager {
|
||||
.flex(row, flex-end);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,24 +13,12 @@ const ossStore = useOssStore()
|
||||
const resetSignal = ref(0)
|
||||
|
||||
function onSubmit(feedback: Feedback): void {
|
||||
if (!feedback.title.trim()) {
|
||||
ElMessage.warning('请填写反馈标题')
|
||||
return
|
||||
}
|
||||
if (!feedback.description.trim()) {
|
||||
ElMessage.warning('请填写详细描述')
|
||||
return
|
||||
}
|
||||
void profileStore.submitFeedback(feedback).then(() => {
|
||||
ElMessage.success('提交成功')
|
||||
resetSignal.value += 1
|
||||
})
|
||||
}
|
||||
|
||||
function onReset(): void {
|
||||
resetSignal.value += 1
|
||||
}
|
||||
|
||||
function onPreview(file: FileInfo): void {
|
||||
ossStore.openPreview(file)
|
||||
}
|
||||
@@ -42,15 +30,11 @@ function onPreview(file: FileInfo): void {
|
||||
<h2 class="feedback-page__title">
|
||||
意见反馈
|
||||
</h2>
|
||||
<p class="feedback-page__sub text-secondary">
|
||||
您的每一条建议都是我们前进的动力
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackForm
|
||||
:reset-signal="resetSignal"
|
||||
@submit="onSubmit"
|
||||
@reset="onReset"
|
||||
@preview="onPreview"
|
||||
/>
|
||||
</div>
|
||||
@@ -63,8 +47,6 @@ function onPreview(file: FileInfo): void {
|
||||
width: 100%;
|
||||
|
||||
&__head {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -72,7 +54,7 @@ function onPreview(file: FileInfo): void {
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
@@ -82,15 +64,11 @@ function onPreview(file: FileInfo): void {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,9 +18,6 @@ onMounted(() => {
|
||||
<h2 class="help-page__title">
|
||||
帮助中心
|
||||
</h2>
|
||||
<p class="help-page__sub text-secondary">
|
||||
常见问题与使用指南,帮您快速上手平台
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<HelpFaq :groups="helpFaqs" />
|
||||
@@ -34,8 +31,6 @@ onMounted(() => {
|
||||
width: 100%;
|
||||
|
||||
&__head {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -43,7 +38,7 @@ onMounted(() => {
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
@@ -53,15 +48,11 @@ onMounted(() => {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,12 +4,45 @@ import HotlinePanel from './cpns/HotlinePanel.vue'
|
||||
|
||||
<template>
|
||||
<div class="hotline-page">
|
||||
<header class="hotline-page__head">
|
||||
<h2 class="hotline-page__title">
|
||||
专属客服热线
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<HotlinePanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.hotline-page {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
width: 100%;
|
||||
|
||||
&__head {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,7 @@ export { default } from './index.vue'
|
||||
export { default as ProfileSider } from './cpns/ProfileSider.vue'
|
||||
export { default as ProfileUserCard } from './cpns/ProfileUserCard.vue'
|
||||
export { default as SecurityPanel } from './cpns/SecurityPanel.vue'
|
||||
export { default as ChangePasswordDialog } from './cpns/ChangePasswordDialog.vue'
|
||||
export { default as BindContactDialog } from './cpns/BindContactDialog.vue'
|
||||
export { default as AddressDialog } from './cpns/AddressDialog.vue'
|
||||
export { default as FavoritesGrid } from './cpns/FavoritesGrid.vue'
|
||||
export { default as QualificationCard } from './cpns/QualificationCard.vue'
|
||||
export { default as QualificationDialog } from './cpns/QualificationDialog.vue'
|
||||
export { default as FeedbackForm } from './cpns/FeedbackForm.vue'
|
||||
|
||||
@@ -40,9 +40,7 @@ onMounted(() => {
|
||||
background: @profile-page-bg;
|
||||
|
||||
&__container {
|
||||
padding-top: 26px;
|
||||
padding-left: clamp(@spacing-base, calc((100% - @profile-content) / 2), @profile-gutter);
|
||||
padding-right: clamp(@spacing-base, calc((100% - @profile-content) / 2), @profile-gutter);
|
||||
padding: 24px 16px 0 10px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
@@ -52,19 +50,17 @@ onMounted(() => {
|
||||
|
||||
&__aside {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: 6px;
|
||||
flex: 0 0 200px;
|
||||
width: 200px;
|
||||
margin-right: 0;
|
||||
gap: 16px;
|
||||
flex: 0 0 156px;
|
||||
width: 156px;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 425px;
|
||||
margin-left: 40px;
|
||||
padding: 24px 24px 0;
|
||||
border: 1px solid @profile-card-border;
|
||||
min-height: 534px;
|
||||
margin-left: 10px;
|
||||
padding: 26px 28px 22px;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AppButton, ConfirmDialog, PaginationBar } from '@/components'
|
||||
import { ConfirmDialog, PaginationBar } from '@/components'
|
||||
import { useProfileStore } from '@/stores/modules/profile'
|
||||
import type { Qualification, QualificationInput } from '@/types/business/profile'
|
||||
import QualificationCard from './cpns/QualificationCard.vue'
|
||||
@@ -64,11 +64,11 @@ onMounted(() => {
|
||||
<div class="qual-page">
|
||||
<header class="qual-page__head">
|
||||
<h2 class="qual-page__title">
|
||||
资质管理
|
||||
资质列表
|
||||
</h2>
|
||||
<AppButton type="primary" @click="onCreate">
|
||||
<button type="button" class="qual-page__create" @click="onCreate">
|
||||
+ 新增资质
|
||||
</AppButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="qual-page__grid">
|
||||
@@ -114,21 +114,20 @@ onMounted(() => {
|
||||
<style lang="less" scoped>
|
||||
.qual-page {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
|
||||
&__head {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @heading-color;
|
||||
padding-left: 14px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @profile-ink;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
@@ -136,25 +135,41 @@ onMounted(() => {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__create {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: @spacing-base;
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: @spacing-xxl 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.pagination) {
|
||||
@@ -167,21 +182,5 @@ onMounted(() => {
|
||||
background: @profile-page-active-bg;
|
||||
color: @primary-strong;
|
||||
}
|
||||
|
||||
.app-button--primary {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
background: darken(@primary-strong, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,314 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ConfirmDialog } from '@/components'
|
||||
import { getPasswordLevel, isValidPassword } from '@/utils/validate'
|
||||
import { useProfileStore } from '@/stores/modules/profile'
|
||||
import SecurityPanel from './cpns/SecurityPanel.vue'
|
||||
import ChangePasswordDialog from './cpns/ChangePasswordDialog.vue'
|
||||
import BindContactDialog from './cpns/BindContactDialog.vue'
|
||||
import type { BindContactMode } from './cpns/BindContactDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
const { profile } = storeToRefs(profileStore)
|
||||
|
||||
const deleteVisible = ref(false)
|
||||
const agree = ref(false)
|
||||
const identityVisible = ref(false)
|
||||
|
||||
const conditions = [
|
||||
{ label: '账号无未完成的订单', ok: true },
|
||||
{ label: '账号无未结清的款项', ok: false },
|
||||
{ label: '账号无进行中的合同', ok: true },
|
||||
{ label: '账号无处理中的工单', ok: true },
|
||||
]
|
||||
|
||||
const risks = [
|
||||
'账号注销后,您将无法登录本应用,所有个人信息将被永久删除。',
|
||||
'您名下的订单、合同、工单等业务数据将一并清除,无法找回。',
|
||||
'注销后,同一手机号/邮箱将无法再次注册本应用。',
|
||||
'如有未完成的订单或服务,请先处理完毕后再进行注销。',
|
||||
]
|
||||
|
||||
function onDeleteConfirm(): void {
|
||||
if (!agree.value) {
|
||||
ElMessage.warning('请先阅读并同意《账号注销协议》')
|
||||
return
|
||||
}
|
||||
identityVisible.value = true
|
||||
}
|
||||
|
||||
const pwdVisible = ref(false)
|
||||
|
||||
function onChangePasswordSubmit(payload: { code: string; password: string; confirm: string }): void {
|
||||
if (payload.password !== payload.confirm) {
|
||||
ElMessage.warning('两次密码不一致')
|
||||
return
|
||||
}
|
||||
if (!isValidPassword(payload.password) || getPasswordLevel(payload.password) === 'weak') {
|
||||
ElMessage.warning('密码等级过低,请重新输入')
|
||||
return
|
||||
}
|
||||
void profileStore
|
||||
.changePassword({ code: payload.code, password: payload.password })
|
||||
.then(() => {
|
||||
pwdVisible.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const bindVisible = ref(false)
|
||||
const bindMode = ref<BindContactMode>('phone-rebind')
|
||||
|
||||
function onBindPhone(): void {
|
||||
bindMode.value = 'phone-rebind'
|
||||
bindVisible.value = true
|
||||
}
|
||||
|
||||
function onBindEmail(mode: 'email-bind' | 'email-rebind'): void {
|
||||
bindMode.value = mode
|
||||
bindVisible.value = true
|
||||
}
|
||||
|
||||
function onBindSubmit(payload: { contact: string; code: string }): void {
|
||||
const action
|
||||
= bindMode.value === 'phone-rebind' || bindMode.value === 'phone-bind'
|
||||
? profileStore.bindPhone(payload.contact, payload.code)
|
||||
: profileStore.bindEmail(payload.contact, payload.code)
|
||||
void action.then(() => {
|
||||
bindVisible.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const unbindVisible = ref(false)
|
||||
|
||||
function onUnbindConfirm(): void {
|
||||
void profileStore.unbindEmail()
|
||||
}
|
||||
|
||||
function onIdentitySubmit(payload: { contact: string; code: string }): void {
|
||||
void (async () => {
|
||||
await profileStore.verifyIdentity(payload.contact, payload.code)
|
||||
await profileStore.deleteAccount()
|
||||
setTimeout(() => {
|
||||
void router.push('/login')
|
||||
}, 3000)
|
||||
})()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="security-page">
|
||||
<SecurityPanel
|
||||
:profile="profile"
|
||||
@change-password="pwdVisible = true"
|
||||
@bind-phone="onBindPhone"
|
||||
@bind-email="onBindEmail"
|
||||
@unbind-email="unbindVisible = true"
|
||||
@delete-account="deleteVisible = true"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteVisible"
|
||||
title="注销账号"
|
||||
confirm-text="注销账号"
|
||||
cancel-text="暂不注销"
|
||||
danger
|
||||
@cancel="agree = false"
|
||||
@confirm="onDeleteConfirm"
|
||||
>
|
||||
<div class="delete-confirm">
|
||||
<span class="delete-confirm__icon" aria-hidden="true">i</span>
|
||||
<h3 class="delete-confirm__title">
|
||||
确定要注销账号吗?
|
||||
</h3>
|
||||
|
||||
<div class="delete-confirm__block">
|
||||
<h4 class="delete-confirm__block-title delete-confirm__block-title--risk">
|
||||
⚠ 注销风险提示
|
||||
</h4>
|
||||
<ul class="delete-confirm__risks">
|
||||
<li v-for="(item, index) in risks" :key="item">
|
||||
{{ index + 1 }}、{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="delete-confirm__block">
|
||||
<h4 class="delete-confirm__block-title delete-confirm__block-title--ok">
|
||||
✓ 注销条件
|
||||
</h4>
|
||||
<ul class="delete-confirm__conditions">
|
||||
<li
|
||||
v-for="item in conditions"
|
||||
:key="item.label"
|
||||
class="delete-confirm__condition"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<span
|
||||
class="delete-confirm__mark"
|
||||
:class="item.ok ? 'is-ok' : 'is-fail'"
|
||||
>{{ item.ok ? '✓' : '✕' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<label class="delete-confirm__agree">
|
||||
<input v-model="agree" type="checkbox">
|
||||
<span>已阅读并同意<em>《账号注销协议》</em></span>
|
||||
</label>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ChangePasswordDialog
|
||||
v-model:visible="pwdVisible"
|
||||
:phone="profile?.phone ?? ''"
|
||||
@submit="onChangePasswordSubmit"
|
||||
/>
|
||||
|
||||
<BindContactDialog
|
||||
v-model:visible="bindVisible"
|
||||
:mode="bindMode"
|
||||
:hint-phone="profile?.phone ?? ''"
|
||||
:hint-email="profile?.email ?? ''"
|
||||
@submit="onBindSubmit"
|
||||
/>
|
||||
|
||||
<BindContactDialog
|
||||
v-model:visible="identityVisible"
|
||||
mode="identity"
|
||||
:hint-phone="profile?.phone ?? ''"
|
||||
@submit="onIdentitySubmit"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="unbindVisible"
|
||||
title="取消绑定"
|
||||
message="确定要取消绑定该邮箱吗?取消后将无法通过邮箱接收通知。"
|
||||
confirm-text="取消绑定"
|
||||
cancel-text="暂不取消"
|
||||
danger
|
||||
@confirm="onUnbindConfirm"
|
||||
/>
|
||||
<SecurityPanel :profile="profile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.security-page {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
.delete-confirm {
|
||||
.flex(column, flex-start);
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
|
||||
&__icon {
|
||||
.flex(row, center);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: @radius-round;
|
||||
background: fade(@danger-color, 12%);
|
||||
color: @danger-color;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-top: 10px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: @profile-ink;
|
||||
}
|
||||
|
||||
&__block {
|
||||
width: 100%;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
&__block-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&--risk {
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
color: @success-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__risks {
|
||||
margin-top: 12px;
|
||||
.flex(column, flex-start);
|
||||
gap: 9px;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
color: @finance-entry-text;
|
||||
}
|
||||
|
||||
&__conditions {
|
||||
margin-top: 12px;
|
||||
.flex(column, flex-start);
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__condition {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
font-size: 13px;
|
||||
color: @finance-entry-text;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__mark {
|
||||
.flex(row, center);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: @radius-round;
|
||||
color: @white;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
|
||||
&.is-ok {
|
||||
background: @success-color;
|
||||
}
|
||||
|
||||
&.is-fail {
|
||||
background: @danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__agree {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 22px;
|
||||
font-size: @font-size-sm;
|
||||
color: @finance-muted-text;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: @primary-strong;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user