Files
hfb_sys/frontend/src/features/auth/views/ProfileView.vue
T

1330 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { readError } from '@/shared/utils/error'
import {
Camera,
CircleCheckFilled,
EditPen,
Loading,
Lock,
Picture,
Postcard,
RefreshRight,
Trophy,
User,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { uploadFile } from '@/shared/api/files'
import { useSessionStore } from '@/stores/session'
import { realnameStatusLabel } from '@/shared/utils/statusLabels'
import { setPassword } from '@/features/auth/api/auth'
import {
activateQualityOwner,
cancelQualityOwner,
fetchQualityOwnerStatus,
queryQualityOwnerPayment,
startQualityOwnerPayment,
type QualityOwnerStatus,
} from '@/features/quality-owner/api/qualityOwner'
import QualityOwnerAgreementContent from '@/features/quality-owner/components/QualityOwnerAgreementContent.vue'
import { useQualityOwnerAgreement } from '@/features/quality-owner/composables/useQualityOwnerAgreement'
import { formatCent } from '@/shared/utils/money'
import type { PaymentPayWay } from '@/features/orders/api/orders'
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
import PayWaySelectDialog from '@/features/orders/components/PayWaySelectDialog.vue'
const session = useSessionStore()
const router = useRouter()
const saving = ref(false)
const qualityOwnerLoading = ref(false)
const qualityOwnerOnlineLoading = ref(false)
const qualityOwnerCancelling = ref(false)
const qualityOwnerPayWayVisible = ref(false)
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
const {
visible: qualityOwnerAgreementVisible,
accepted: qualityOwnerAgreementAccepted,
remainingSeconds: qualityOwnerAgreementRemainingSeconds,
canConfirm: qualityOwnerAgreementCanConfirm,
open: openQualityOwnerAgreement,
close: closeQualityOwnerAgreement,
confirm: confirmQualityOwnerAgreement,
} = useQualityOwnerAgreement()
const uploadingAvatar = ref(false)
const avatarFileInput = ref<HTMLInputElement | null>(null)
const form = reactive({
nickname: '',
avatar_url: '',
})
const showPasswordDialog = ref(false)
const savingPassword = ref(false)
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
const qualityOwnerCashier = useOrderPaymentCashier({
notifySuccess: message => ElMessage.success(message),
notifyError: message => ElMessage.error(message),
notifyInfo: message => ElMessage.info(message),
notifyFallback: message => ElMessage.warning(message),
paidMessage: '保证金支付成功,优质号主已开通',
queryPayment: () => queryQualityOwnerPayment(),
})
const qualityOwnerPaymentVisible = qualityOwnerCashier.paymentPopupVisible
const activeQualityOwnerPayment = qualityOwnerCashier.activePayment
const qualityOwnerPaymentQRCodeURL = qualityOwnerCashier.paymentQRCodeURL
const qualityOwnerQrGenerating = qualityOwnerCashier.qrGenerating
const qualityOwnerCheckingPayment = qualityOwnerCashier.checkingPayment
const displayName = computed(() => session.displayName)
const maskedPhone = computed(() => {
if (!session.phone) return '未绑定手机号'
return `${session.phone.slice(0, 3)}****${session.phone.slice(-4)}`
})
const avatarText = computed(() => (form.nickname || displayName.value || 'U').slice(0, 1))
const realnameTone = computed(() => {
if (session.realnameStatus === 'verified') return 'verified'
if (session.realnameStatus === 'pending') return 'pending'
if (session.realnameStatus === 'rejected') return 'rejected'
return 'unverified'
})
onMounted(async () => {
try {
await session.loadMe()
} catch {
// 保留本地会话展示,避免资料页因网络短暂异常整页不可用。
}
resetForm()
void loadQualityOwnerStatus()
})
async function loadQualityOwnerStatus() {
try {
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
} catch {
qualityOwnerStatus.value = null
}
}
function handleActivateQualityOwner() {
const status = qualityOwnerStatus.value
if (!status || status.active) return
openQualityOwnerAgreement(status, activateQualityOwnerByBalance)
}
async function activateQualityOwnerByBalance() {
qualityOwnerLoading.value = true
try {
qualityOwnerStatus.value = await activateQualityOwner()
await session.loadMe()
ElMessage.success('优质号主已开通,可在发布时选择极品专区')
} catch (error) {
ElMessage.error(readError(error, '开通失败,请确认余额充足'))
} finally {
qualityOwnerLoading.value = false
}
}
function openQualityOwnerOnlinePayment() {
const status = qualityOwnerStatus.value
if (!status || status.active) return
openQualityOwnerAgreement(status, continueQualityOwnerOnlinePayment)
}
function continueQualityOwnerOnlinePayment() {
qualityOwnerPayWayVisible.value = true
}
async function chooseQualityOwnerPayWay(payWay: PaymentPayWay) {
qualityOwnerPayWayVisible.value = false
qualityOwnerOnlineLoading.value = true
try {
const payment = await startQualityOwnerPayment(payWay)
if (payment.paid) {
await handleQualityOwnerPaymentSucceeded()
return
}
await qualityOwnerCashier.openPaymentCashier(payment, handleQualityOwnerPaymentSucceeded)
} catch (error) {
ElMessage.error(readError(error, '发起在线支付失败'))
} finally {
qualityOwnerOnlineLoading.value = false
}
}
async function handleQualityOwnerPaymentSucceeded() {
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
await session.loadMe()
}
async function handleCancelQualityOwner() {
const status = qualityOwnerStatus.value
if (!status?.active) return
const activeListings = status.active_premium_listing_count || 0
if (activeListings > 0) {
ElMessage.warning(`极品专区还有 ${activeListings} 个进行中账号,请先下架或完成订单`)
return
}
const depositHint =
status.deposit_source === 'paid' && status.deposit_amount_cent > 0
? `保证金 ¥${formatCent(status.deposit_amount_cent)} 将退回可用余额。`
: '该资格由后台开通,无保证金退回。'
try {
await ElMessageBox.confirm(`取消后将立即失去极品专区发布权限,${depositHint}`, '取消优质号主', {
type: 'warning',
confirmButtonText: '确认取消',
cancelButtonText: '暂不取消',
})
} catch {
return
}
qualityOwnerCancelling.value = true
try {
qualityOwnerStatus.value = await cancelQualityOwner()
await session.loadMe()
ElMessage.success('优质号主已取消,极品专区权限已关闭')
} catch (error) {
ElMessage.error(readError(error, '取消优质号主失败,请先处理极品专区进行中账号'))
} finally {
qualityOwnerCancelling.value = false
}
}
function resetForm() {
form.nickname = session.nickname || session.displayName
form.avatar_url = session.avatarUrl || ''
}
function resolveAvatarURL(url: string | undefined | null) {
if (!url) return ''
if (url.includes('/api/files/object?key=avatar/')) {
return url.replace('/api/files/object', '/api/public/files/object')
}
return url
}
function triggerAvatarUpload() {
avatarFileInput.value?.click()
}
async function handleAvatarFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingAvatar.value = true
try {
const uploaded = await uploadFile(file, 'avatar')
form.avatar_url = uploaded.url
ElMessage.success('头像上传成功')
} catch (error) {
ElMessage.error(readError(error, '头像上传失败'))
} finally {
uploadingAvatar.value = false
}
}
async function saveProfile() {
const nickname = form.nickname.trim()
const avatarURL = form.avatar_url.trim()
if (!nickname) {
ElMessage.warning('请输入昵称')
return
}
if (nickname.length > 24) {
ElMessage.warning('昵称不能超过 24 个字符')
return
}
saving.value = true
try {
await session.updateProfile({ nickname, avatar_url: avatarURL })
resetForm()
ElMessage.success('个人资料已保存')
} catch (error) {
ElMessage.error(readError(error, '资料更新失败'))
} finally {
saving.value = false
}
}
function openPasswordDialog() {
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
showPasswordDialog.value = true
}
async function savePassword() {
const { newPassword, confirmPassword } = passwordForm
if (!newPassword) {
ElMessage.warning('请输入新密码')
return
}
if (newPassword.length < 8 || newPassword.length > 20) {
ElMessage.warning('密码长度应为 8-20 位')
return
}
if (newPassword !== confirmPassword) {
ElMessage.warning('两次输入的密码不一致')
return
}
savingPassword.value = true
try {
await setPassword(newPassword, passwordForm.oldPassword || undefined)
ElMessage.success('密码已更新')
showPasswordDialog.value = false
} catch (error) {
ElMessage.error(readError(error, '密码更新失败'))
} finally {
savingPassword.value = false
}
}
</script>
<template>
<section class="profile-page">
<div class="profile-hero" :class="{ 'is-quality-owner': qualityOwnerStatus?.active }">
<div class="hero-avatar">
<img v-if="session.avatarUrl" :src="resolveAvatarURL(session.avatarUrl)" alt="" />
<span v-else>{{ session.avatarText }}</span>
</div>
<div class="hero-copy">
<span class="eyebrow">Profile</span>
<div class="hero-title-row">
<h1>个人资料</h1>
<span v-if="qualityOwnerStatus?.active" class="hero-quality-chip">
<el-icon><Trophy /></el-icon>
优质号主
</span>
</div>
<p>{{ displayName }} · {{ maskedPhone }}</p>
</div>
<button
class="hero-realname"
:class="`is-${realnameTone}`"
type="button"
@click="router.push('/realname')"
>
<el-icon
><CircleCheckFilled v-if="session.realnameStatus === 'verified'" /><WarningFilled v-else
/></el-icon>
<span>{{ realnameStatusLabel(session.realnameStatus) }}</span>
</button>
</div>
<section v-if="qualityOwnerStatus?.active" class="quality-owner-spotlight">
<div class="quality-owner-spotlight-mark">
<el-icon :size="25"><Trophy /></el-icon>
</div>
<div class="quality-owner-spotlight-copy">
<span>PREMIUM OWNER</span>
<strong>优质号主身份已点亮</strong>
<p>
你的账号将优先展示优质号主标识,并可发布至首页极品专区,享受更高曝光与 4% 保险专享费率。
</p>
</div>
<div class="quality-owner-spotlight-benefits">
<span>极致曝光</span>
<span>身份认证</span>
<span>专享费率</span>
</div>
</section>
<div class="profile-layout">
<section class="edit-panel">
<div class="panel-head">
<div class="panel-icon">
<el-icon><EditPen /></el-icon>
</div>
<div>
<h2>资料编辑</h2>
<p>修改昵称和头像后会同步展示在顶部用户信息中。</p>
</div>
</div>
<div class="avatar-editor">
<button class="avatar-preview" type="button" @click="triggerAvatarUpload">
<img v-if="form.avatar_url" :src="resolveAvatarURL(form.avatar_url)" alt="" />
<span v-else>{{ avatarText }}</span>
<i
><el-icon><Camera /></el-icon
></i>
</button>
<div class="avatar-actions">
<strong>{{ form.nickname || displayName }}</strong>
<p>支持上传本地图片,也可以直接填写头像 URL。</p>
<el-button :icon="Camera" :loading="uploadingAvatar" @click="triggerAvatarUpload"
>上传头像</el-button
>
<input
ref="avatarFileInput"
type="file"
accept="image/*"
hidden
@change="handleAvatarFileChange"
/>
</div>
</div>
<el-form class="profile-form" label-position="top">
<el-form-item label="昵称">
<el-input
v-model="form.nickname"
:prefix-icon="User"
maxlength="24"
placeholder="请输入昵称"
size="large"
/>
</el-form-item>
<el-form-item label="头像地址">
<el-input
v-model="form.avatar_url"
:prefix-icon="Postcard"
maxlength="512"
placeholder="可填写图片 URL,留空使用文字头像"
size="large"
/>
</el-form-item>
<div class="form-actions">
<el-button size="large" :icon="RefreshRight" @click="resetForm">重置</el-button>
<el-button
type="primary"
size="large"
:icon="EditPen"
:loading="saving"
@click="saveProfile"
>保存资料</el-button
>
</div>
</el-form>
</section>
<aside class="account-card">
<span class="card-kicker">账号信息</span>
<dl>
<div>
<dt>用户 ID</dt>
<dd>{{ session.userId || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ maskedPhone }}</dd>
</div>
<div>
<dt>实名状态</dt>
<dd :class="`is-${realnameTone}`">{{ realnameStatusLabel(session.realnameStatus) }}</dd>
</div>
<div>
<dt>租客等级</dt>
<dd class="growth-level">{{ session.renterGrowthLevelName }}</dd>
</div>
<div>
<dt>累计积分</dt>
<dd>{{ session.renterGrowthPoints }}</dd>
</div>
</dl>
<button class="realname-shortcut" type="button" @click="router.push('/realname')">
<span>查看实名认证</span>
<el-icon><CircleCheckFilled /></el-icon>
</button>
<button class="realname-shortcut" type="button" @click="openPasswordDialog">
<span>修改密码</span>
<el-icon><Lock /></el-icon>
</button>
</aside>
</div>
<section class="quality-owner-card" :class="{ 'is-active': qualityOwnerStatus?.active }">
<div class="quality-owner-heading">
<div>
<span class="card-kicker">Premium Owner</span>
<h2>优质号主</h2>
<p>
开通后可发布到极品专区;在极品专区购买无忧租号保险可享
{{ ((qualityOwnerStatus?.premium_insurance_fee_bps || 400) / 100).toFixed(1) }}%
专享费率。
</p>
</div>
<span class="quality-owner-status">
{{ qualityOwnerStatus?.active ? '已开通' : '未开通' }}
</span>
</div>
<div class="quality-owner-metrics">
<div>
<span>保证金</span>
<strong>¥{{ formatCent(qualityOwnerStatus?.required_deposit_cent || 20000) }}</strong>
</div>
<div>
<span>普通保险服务费</span>
<strong>{{ ((qualityOwnerStatus?.insurance_fee_bps || 800) / 100).toFixed(1) }}%</strong>
</div>
<div>
<span>极品专区保险服务费</span>
<strong
>{{
((qualityOwnerStatus?.premium_insurance_fee_bps || 400) / 100).toFixed(1)
}}%</strong
>
</div>
<div>
<span>当前可用余额</span>
<strong>¥{{ formatCent(qualityOwnerStatus?.available_balance_cent || 0) }}</strong>
</div>
</div>
<div class="quality-owner-actions">
<span v-if="qualityOwnerStatus?.active">
<template v-if="qualityOwnerStatus.active_premium_listing_count > 0">
极品专区还有 {{ qualityOwnerStatus.active_premium_listing_count }}
个进行中账号,请先处理
</template>
<template v-else-if="qualityOwnerStatus.deposit_source === 'manual'"
>已由后台开通</template
>
<template v-else>保证金已冻结</template>
</span>
<span v-else>保证金取消资格时原路退回余额。</span>
<div v-if="qualityOwnerStatus?.active" class="quality-owner-action-buttons">
<el-button
type="danger"
plain
:loading="qualityOwnerCancelling"
:disabled="qualityOwnerStatus.active_premium_listing_count > 0"
@click="handleCancelQualityOwner"
>
取消优质号主
</el-button>
</div>
<div v-else class="quality-owner-action-buttons">
<el-button
type="primary"
:loading="qualityOwnerLoading"
:disabled="
(qualityOwnerStatus?.available_balance_cent || 0) <
(qualityOwnerStatus?.required_deposit_cent || 20000)
"
@click="handleActivateQualityOwner"
>
余额支付开通
</el-button>
<el-button
:loading="qualityOwnerOnlineLoading"
:disabled="!qualityOwnerStatus"
@click="openQualityOwnerOnlinePayment"
>
微信 / 支付宝支付开通
</el-button>
</div>
</div>
</section>
<section class="points-entry-card">
<div>
<span>POINTS MALL</span>
<strong>积分商城</strong>
<p>
可用 {{ session.pointsAvailable }} 积分 · 当前等级 {{ session.renterGrowthLevelName }}
</p>
</div>
<el-button type="primary" @click="router.push('/points-mall')">进入积分商城</el-button>
</section>
<PayWaySelectDialog v-model="qualityOwnerPayWayVisible" @choose="chooseQualityOwnerPayWay" />
<el-dialog
v-model="qualityOwnerAgreementVisible"
:title="qualityOwnerStatus?.agreement_title || '优质号主服务协议'"
width="760px"
append-to-body
:close-on-click-modal="false"
class="quality-owner-agreement-dialog"
@closed="closeQualityOwnerAgreement"
>
<div class="quality-owner-agreement-body">
<QualityOwnerAgreementContent :content="qualityOwnerStatus?.agreement_content || ''" />
<div class="quality-owner-agreement-consent">
<el-checkbox v-model="qualityOwnerAgreementAccepted">
本人已详细阅读并充分理解本协议全部内容,同意遵守上述条款
</el-checkbox>
<span v-if="qualityOwnerAgreementRemainingSeconds > 0">
还需阅读 {{ qualityOwnerAgreementRemainingSeconds }} 秒
</span>
<span v-else class="is-ready">已完成强制阅读</span>
</div>
</div>
<template #footer>
<el-button @click="closeQualityOwnerAgreement">取消</el-button>
<el-button
type="primary"
:disabled="!qualityOwnerAgreementCanConfirm"
@click="confirmQualityOwnerAgreement"
>
{{
qualityOwnerAgreementRemainingSeconds > 0
? `还需阅读 ${qualityOwnerAgreementRemainingSeconds} 秒`
: '同意并继续'
}}
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="qualityOwnerPaymentVisible"
title="优质号主保证金支付"
width="460px"
append-to-body
class="quality-owner-pay-dialog"
>
<div v-if="activeQualityOwnerPayment" class="quality-owner-pay-dialog-body">
<div class="quality-owner-pay-amount">
<span>支付金额</span>
<strong>¥{{ formatCent(activeQualityOwnerPayment.amount_cent) }}</strong>
</div>
<div class="quality-owner-pay-qr">
<el-icon v-if="qualityOwnerQrGenerating" class="is-loading" :size="30">
<Loading />
</el-icon>
<img
v-else-if="qualityOwnerPaymentQRCodeURL"
:src="qualityOwnerPaymentQRCodeURL"
alt="保证金支付二维码"
/>
<el-icon v-else :size="42"><Picture /></el-icon>
</div>
<p>请使用{{ activeQualityOwnerPayment.pay_way === 'WXZF' ? '微信' : '支付宝' }}扫码支付</p>
<el-button
type="primary"
:loading="qualityOwnerCheckingPayment"
@click="qualityOwnerCashier.refreshPaymentStatus(false)"
>
我已支付,刷新状态
</el-button>
</div>
</el-dialog>
<el-dialog
v-model="showPasswordDialog"
title="修改密码"
width="420px"
:close-on-click-modal="false"
>
<p class="password-hint">未设置过密码的老用户,"原密码"可留空直接设置新密码。</p>
<el-form label-position="top">
<el-form-item label="原密码">
<el-input
v-model="passwordForm.oldPassword"
type="password"
maxlength="20"
placeholder="未设置过密码可留空"
show-password
/>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="passwordForm.newPassword"
type="password"
maxlength="20"
placeholder="8-20 位"
show-password
/>
</el-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
maxlength="20"
placeholder="再次输入新密码"
show-password
@keyup.enter="savePassword"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPasswordDialog = false">取消</el-button>
<el-button type="primary" :loading="savingPassword" @click="savePassword"
>保存密码</el-button
>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.profile-page {
width: min(1180px, 100%);
margin: 0 auto;
padding: 10px 0 40px;
}
.profile-hero {
display: flex;
align-items: center;
gap: 18px;
padding: 30px 34px;
border: 1px solid #eef1f5;
border-radius: 18px;
background: linear-gradient(135deg, rgba(255, 106, 0, 0.12), rgba(37, 99, 235, 0.08)), #ffffff;
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08);
}
.profile-hero.is-quality-owner {
border-color: #d8c9fb;
background:
radial-gradient(circle at 86% 0%, rgba(245, 158, 11, 0.15), transparent 34%),
linear-gradient(135deg, #f3edff 0%, #ffffff 58%, #fff8e8 100%);
box-shadow: 0 16px 42px rgba(109, 40, 217, 0.12);
}
.profile-hero.is-quality-owner .hero-avatar {
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 62%, #f59e0b 100%);
box-shadow: 0 8px 20px rgba(109, 40, 217, 0.22);
}
.hero-avatar,
.avatar-preview {
display: grid;
place-items: center;
overflow: hidden;
border-radius: 999px;
background: #ff6a00;
color: #ffffff;
font-weight: 900;
}
.hero-avatar {
width: 74px;
height: 74px;
font-size: 28px;
flex-shrink: 0;
}
.hero-avatar img,
.avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-copy {
min-width: 0;
flex: 1;
}
.eyebrow {
color: #ff6a00;
font-size: 13px;
font-weight: 900;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 8px 0 0;
color: #17233d;
font-size: 34px;
line-height: 1.2;
}
.hero-title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.hero-quality-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
padding: 0 10px;
border: 1px solid #d8b4fe;
border-radius: 999px;
background: #f5f3ff;
color: #6d28d9;
font-size: 12px;
font-weight: 900;
}
.hero-copy p {
margin: 10px 0 0;
color: #64748b;
font-size: 15px;
font-weight: 700;
}
.hero-realname {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 0 14px;
border: none;
border-radius: 999px;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.hero-realname.is-verified,
.account-card dd.is-verified {
background: #ecfdf3;
color: #16a34a;
}
.hero-realname.is-pending,
.account-card dd.is-pending {
background: #fff7ed;
color: #f97316;
}
.hero-realname.is-rejected,
.account-card dd.is-rejected {
background: #fff1f2;
color: #e11d48;
}
.hero-realname.is-unverified,
.account-card dd.is-unverified {
background: #eff6ff;
color: #2563eb;
}
.quality-owner-spotlight {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
margin-top: 18px;
padding: 17px 20px;
border: 1px solid #e1d7fa;
border-radius: 14px;
background: linear-gradient(110deg, #faf7ff 0%, #ffffff 62%, #fffaf0 100%);
}
.quality-owner-spotlight-mark {
display: grid;
width: 46px;
height: 46px;
place-items: center;
border-radius: 12px;
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 65%, #f59e0b 100%);
color: #fff;
}
.quality-owner-spotlight-copy {
display: grid;
gap: 3px;
}
.quality-owner-spotlight-copy > span {
color: #7c3aed;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
}
.quality-owner-spotlight-copy strong {
color: #2e1065;
font-size: 17px;
font-weight: 900;
}
.quality-owner-spotlight-copy p {
margin: 0;
color: #70677f;
font-size: 12px;
line-height: 1.55;
}
.quality-owner-spotlight-benefits {
display: flex;
gap: 7px;
flex-wrap: wrap;
justify-content: flex-end;
}
.quality-owner-spotlight-benefits span {
padding: 5px 9px;
border-radius: 999px;
background: #f5f3ff;
color: #6d28d9;
font-size: 11px;
font-weight: 800;
}
.profile-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 22px;
margin-top: 22px;
}
.edit-panel,
.account-card {
border: 1px solid #eef1f5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.06);
}
.edit-panel {
padding: 28px;
}
.panel-head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 26px;
}
.panel-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 12px;
background: #fff3e8;
color: #ff6a00;
font-size: 24px;
}
.panel-head h2 {
margin: 0;
color: #17233d;
font-size: 22px;
}
.panel-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.avatar-editor {
display: flex;
align-items: center;
gap: 18px;
max-width: 640px;
padding: 20px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.avatar-preview {
position: relative;
width: 82px;
height: 82px;
border: none;
font-size: 30px;
cursor: pointer;
flex-shrink: 0;
}
.avatar-preview i {
position: absolute;
right: 4px;
bottom: 4px;
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 999px;
background: #ffffff;
color: #ff6a00;
box-shadow: 0 4px 12px rgba(23, 35, 61, 0.16);
}
.avatar-actions strong {
display: block;
color: #17233d;
font-size: 20px;
}
.avatar-actions p {
margin: 6px 0 12px;
color: #64748b;
font-size: 14px;
}
.profile-form {
display: grid;
gap: 6px;
max-width: 640px;
margin-top: 24px;
}
.profile-form :deep(.el-form-item__label) {
color: #475569;
font-size: 14px;
font-weight: 800;
}
.profile-form :deep(.el-input__wrapper) {
min-height: 48px;
border-radius: 10px;
box-shadow: 0 0 0 1px #dbe3ee inset;
}
.profile-form :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px #ff6a00 inset,
0 0 0 4px rgba(255, 106, 0, 0.08);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.form-actions .el-button {
height: 46px;
border-radius: 10px;
font-weight: 900;
}
.form-actions .el-button--primary {
border: none;
background: #ff6a00;
box-shadow: 0 10px 20px rgba(255, 106, 0, 0.18);
}
.account-card {
padding: 24px;
}
.card-kicker {
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.account-card dl {
display: grid;
gap: 12px;
margin: 18px 0 0;
}
.account-card dl div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 48px;
padding: 0 14px;
border-radius: 10px;
background: #f8fafc;
}
.account-card dt {
color: #8b9cb5;
font-size: 13px;
font-weight: 800;
}
.account-card dd {
margin: 0;
color: #334155;
font-size: 14px;
font-weight: 900;
text-align: right;
}
.account-card dd.growth-level {
color: #7c3aed;
}
.realname-shortcut {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 44px;
margin-top: 18px;
padding: 0 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
color: #475569;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.realname-shortcut:hover {
border-color: #ff6a00;
color: #ff6a00;
}
.password-hint {
margin: 0 0 12px;
color: #8b9cb5;
font-size: 13px;
line-height: 1.6;
}
.quality-owner-card {
margin-top: 22px;
padding: 26px 28px;
border: 1px solid #e8e4f4;
border-radius: 16px;
background: linear-gradient(135deg, #faf8ff 0%, #ffffff 68%);
box-shadow: 0 10px 28px rgba(76, 29, 149, 0.06);
}
.points-entry-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 18px;
padding: 20px 24px;
border: 1px solid #ffd6bf;
border-radius: 14px;
background: linear-gradient(135deg, #fff8f3 0%, #fff 72%);
}
.points-entry-card div {
display: grid;
gap: 4px;
}
.points-entry-card span {
color: #ff6a00;
font-size: 10px;
font-weight: 900;
}
.points-entry-card strong {
color: #c2410c;
font-size: 19px;
}
.points-entry-card p {
margin: 0;
color: #6b7280;
font-size: 12px;
}
.points-entry-card :deep(.el-button--primary) {
border: 1px solid #ff6a00 !important;
background: #ff6a00 !important;
box-shadow: 0 6px 14px rgba(255, 106, 0, 0.2) !important;
}
.quality-owner-card.is-active {
border-color: #c4b5fd;
}
.quality-owner-heading,
.quality-owner-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.quality-owner-heading h2 {
margin: 6px 0 0;
color: #312e81;
font-size: 24px;
}
.quality-owner-heading p {
max-width: 680px;
margin: 8px 0 0;
color: #6b7280;
font-size: 13px;
font-weight: 700;
line-height: 1.7;
}
.quality-owner-status {
flex-shrink: 0;
padding: 6px 12px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 12px;
font-weight: 900;
}
.quality-owner-card.is-active .quality-owner-status {
background: #ede9fe;
color: #6d28d9;
}
.quality-owner-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 20px;
}
.quality-owner-metrics div {
display: grid;
gap: 6px;
padding: 14px;
border: 1px solid #ede9fe;
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
}
.quality-owner-metrics span,
.quality-owner-actions span {
color: #7b8492;
font-size: 12px;
font-weight: 700;
}
.quality-owner-metrics strong {
color: #312e81;
font-size: 18px;
font-weight: 900;
}
.quality-owner-actions {
margin-top: 16px;
}
.quality-owner-actions :deep(.el-button--primary) {
border: none;
background: #7c3aed;
}
.quality-owner-action-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.quality-owner-action-buttons :deep(.el-button) {
margin-left: 0;
}
.quality-owner-agreement-body {
display: grid;
gap: 16px;
}
.quality-owner-agreement-body > :first-child {
max-height: 58vh;
overflow-y: auto;
padding: 4px 18px 16px 4px;
}
.quality-owner-agreement-consent {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #fed7aa;
border-radius: 8px;
background: #fff7ed;
}
.quality-owner-agreement-consent :deep(.el-checkbox__label) {
color: #7c2d12;
font-size: 13px;
font-weight: 700;
}
.quality-owner-agreement-consent > span {
flex-shrink: 0;
color: #c2410c;
font-size: 12px;
font-weight: 800;
}
.quality-owner-agreement-consent > span.is-ready {
color: #15803d;
}
.quality-owner-pay-dialog-body {
display: grid;
gap: 14px;
justify-items: center;
}
.quality-owner-pay-amount {
display: flex;
align-items: baseline;
gap: 10px;
color: #64748b;
font-size: 14px;
}
.quality-owner-pay-amount strong {
color: #f97316;
font-size: 26px;
}
.quality-owner-pay-qr {
display: grid;
width: 228px;
height: 228px;
place-items: center;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
}
.quality-owner-pay-qr img {
width: 214px;
height: 214px;
}
.quality-owner-pay-dialog-body p {
margin: 0;
color: #475569;
font-size: 13px;
}
@media (max-width: 1100px) {
.profile-layout {
grid-template-columns: 1fr;
}
.quality-owner-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.quality-owner-heading,
.quality-owner-actions {
align-items: flex-start;
flex-direction: column;
}
.quality-owner-action-buttons {
justify-content: flex-start;
}
.quality-owner-metrics {
grid-template-columns: 1fr;
}
.quality-owner-spotlight {
grid-template-columns: auto minmax(0, 1fr);
}
.quality-owner-spotlight-benefits {
grid-column: 1 / -1;
justify-content: flex-start;
}
}
</style>