mirror of
https://github.com/certd/certd.git
synced 2026-07-31 09:57:35 +08:00
1058 lines
26 KiB
Vue
1058 lines
26 KiB
Vue
<template>
|
||
<fs-page class="page-user-profile">
|
||
<template #header>
|
||
<div class="title">{{ t("certd.myInfo") }}</div>
|
||
</template>
|
||
<div class="profile-container md:p-8">
|
||
<div class="profile-card md:rounded">
|
||
<div class="card-header">
|
||
<div class="header-bg-gradient"></div>
|
||
<div class="header-content">
|
||
<div class="avatar-wrapper">
|
||
<a-avatar v-if="userInfo.avatar" :size="100" :src="userAvatar" class="user-avatar"> </a-avatar>
|
||
<a-avatar v-else size="100" class="user-avatar default-avatar">
|
||
{{ userInfo.username }}
|
||
</a-avatar>
|
||
<a-button type="text" size="small" class="avatar-edit-btn" title="修改资料" @click.stop="doUpdate">
|
||
<template #icon><fs-icon icon="ion:create-outline" /></template>
|
||
</a-button>
|
||
<!-- <div class="status-indicator"></div> -->
|
||
</div>
|
||
<div class="user-info">
|
||
<h2 class="user-name flex items-center">
|
||
<span>{{ userInfo.nickName }}</span>
|
||
<a-button type="text" size="small" class="detail-edit-btn" title="修改资料" @click.stop="doUpdate">
|
||
<template #icon><fs-icon icon="ion:create-outline" /></template>
|
||
</a-button>
|
||
</h2>
|
||
<div class="user-details">
|
||
<a-tag color="blue" class="detail-tag">
|
||
<span class="tag-icon">👤</span>
|
||
<span class="tag-text">{{ userInfo.username }}</span>
|
||
<fs-values-format :model-value="userInfo.roleIds" type="text" :dict="roleDict" color="blue" />
|
||
</a-tag>
|
||
<a-tag color="green" class="detail-tag">
|
||
<span class="tag-icon">📧</span>
|
||
<span class="tag-text">{{ userInfo.email || "未绑定邮箱" }}</span>
|
||
<a-button type="text" size="small" class="detail-edit-btn" title="修改邮箱" @click.stop="openBindContact('email')">
|
||
<template #icon><fs-icon icon="ion:create-outline" /></template>
|
||
</a-button>
|
||
</a-tag>
|
||
<a-tag v-if="contactCapability.smsEnabled" color="purple" class="detail-tag">
|
||
<span class="tag-icon">📱</span>
|
||
<span class="tag-text">{{ userInfo.mobile || "未绑定手机号" }}</span>
|
||
<a-button type="text" size="small" class="detail-edit-btn" title="修改手机号" @click.stop="openBindContact('mobile')">
|
||
<template #icon><fs-icon icon="ion:create-outline" /></template>
|
||
</a-button>
|
||
</a-tag>
|
||
</div>
|
||
</div>
|
||
<div class="action-buttons gap-2">
|
||
<change-password-button ref="changePasswordButtonRef" :show-button="true" />
|
||
|
||
<a-button type="primary" class="action-btn" @click="goSecuritySetting">
|
||
{{ t("authentication.securitySettingTip") }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap">
|
||
<div v-if="settingStore.sysPublic.oauthEnabled && settingStore.isPlus" class="w-full md:w-1/2">
|
||
<div class="bindings-card md:rounded">
|
||
<div class="card-title">
|
||
<fs-icon icon="ion:link-outline" class="title-icon" />
|
||
<span>第三方账号绑定</span>
|
||
</div>
|
||
<div class="bindings-list">
|
||
<template v-for="item in computedOauthBounds" :key="item.name">
|
||
<div v-if="item.addonId" class="binding-item">
|
||
<div class="binding-icon">
|
||
<fs-icon :icon="item.icon" class="icon" />
|
||
</div>
|
||
<div class="binding-info">
|
||
<span class="binding-name">{{ item.title }}</span>
|
||
<span>
|
||
<a-tag v-if="item.bound" color="green" class="bound-tag1">已绑定</a-tag>
|
||
<a-tag v-else color="red" class="bound-tag1">未绑定</a-tag>
|
||
</span>
|
||
</div>
|
||
<a-button v-if="item.bound" type="primary" danger class="action-btn" @click="unbind(item)">
|
||
<template #icon><fs-icon icon="ion:unlink-outline" /></template>
|
||
解绑
|
||
</a-button>
|
||
<a-button v-else type="primary" class="action-btn" @click="bind(item)">
|
||
<template #icon><fs-icon icon="ion:link-outline" /></template>
|
||
绑定
|
||
</a-button>
|
||
</div>
|
||
</template>
|
||
<div v-if="computedOauthBounds.length === 0" class="empty-text">暂无可用的第三方账号绑定</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="settingStore.sysPublic.passkeyEnabled && settingStore.isPlus" class="w-full md:w-1/2">
|
||
<div class="passkey-card md:rounded">
|
||
<div class="card-title">
|
||
<fs-icon icon="ion:finger-print" class="title-icon" />
|
||
<span>Passkey 安全密钥</span>
|
||
</div>
|
||
<div class="passkey-list">
|
||
<div v-for="passkey in passkeys" :key="passkey.id" class="passkey-item">
|
||
<div class="passkey-icon hidden md:flex">
|
||
<fs-icon icon="ion:finger-print" class="icon" />
|
||
</div>
|
||
<div class="passkey-info">
|
||
<div class="passkey-name">{{ passkey.deviceName }}</div>
|
||
<div class="passkey-meta flex items-center">
|
||
<span class="meta-item flex items-center">
|
||
<fs-icon icon="ion:globe-outline" class="meta-icon" />
|
||
{{ passkey.rpId || "-" }}
|
||
</span>
|
||
<span class="meta-item flex items-center">
|
||
<fs-icon icon="ion:calendar-outline" class="meta-icon" />
|
||
{{ formatDate(passkey.registeredAt) }}
|
||
</span>
|
||
<span class="meta-item flex items-center">
|
||
<fs-icon icon="ion:time-outline" class="meta-icon" />
|
||
最近使用:<fs-time-humanize :model-value="passkey.updateTime" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<a-button type="primary" danger class="remove-btn" @click="unbindPasskey(passkey.id)">
|
||
<template #icon><fs-icon icon="ion:trash-outline" /></template>
|
||
移除
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="passkeys.length === 0" class="empty-state">
|
||
<fs-icon icon="ion:finger-print" class="empty-icon" />
|
||
<p class="empty-text">暂无Passkey</p>
|
||
</div>
|
||
<div v-if="!passkeySupported" class="warning-box">
|
||
<fs-icon icon="ion:warning-outline" class="warning-icon" />
|
||
<span>{{ t("authentication.passkeyNotSupported") }}</span>
|
||
</div>
|
||
<a-button v-if="passkeySupported" type="primary" class="add-btn" @click="registerPasskey">
|
||
<template #icon><fs-icon icon="ion:add-circle-outline" /></template>
|
||
注册新的Passkey
|
||
</a-button>
|
||
<pre class="helper pre">{{ t("authentication.passkeyRegisterHelper") }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</fs-page>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import * as api from "./api";
|
||
import { computed, onMounted, Ref, ref } from "vue";
|
||
import ChangePasswordButton from "/@/views/certd/mine/change-password-button.vue";
|
||
import { useI18n } from "/src/locales";
|
||
import { useContactBind, useUserProfile } from "./use";
|
||
import { usePasskeyRegister } from "./use";
|
||
import { message, Modal, notification } from "ant-design-vue";
|
||
import { useSettingStore } from "/@/store/settings";
|
||
import { isEmpty } from "lodash-es";
|
||
import { dict } from "@fast-crud/fast-crud";
|
||
import dayjs from "dayjs";
|
||
import { useRouter } from "vue-router";
|
||
import { useUserStore } from "/@/store/user";
|
||
|
||
const { t } = useI18n();
|
||
|
||
defineOptions({
|
||
name: "UserProfile",
|
||
});
|
||
|
||
const settingStore = useSettingStore();
|
||
|
||
const userInfo: Ref = ref({});
|
||
const passkeys = ref([]);
|
||
const passkeySupported = ref(false);
|
||
const contactCapability = ref({
|
||
smsEnabled: false,
|
||
});
|
||
|
||
const getUserInfo = async () => {
|
||
userInfo.value = await api.getMineInfo();
|
||
};
|
||
const roleDict = dict({
|
||
url: "/basic/user/getSimpleRoles",
|
||
value: "id",
|
||
label: "name",
|
||
});
|
||
|
||
const { openEditProfileDialog } = useUserProfile();
|
||
const { openRegisterDialog } = usePasskeyRegister();
|
||
|
||
function doUpdate() {
|
||
openEditProfileDialog({
|
||
onUpdated: async () => {
|
||
await getUserInfo();
|
||
userStore.setUserInfo(userInfo.value);
|
||
},
|
||
});
|
||
}
|
||
|
||
const router = useRouter();
|
||
function goSecuritySetting() {
|
||
router.push("/certd/mine/security");
|
||
}
|
||
|
||
const oauthBounds = ref([]);
|
||
const oauthProviders = ref([]);
|
||
|
||
async function loadOauthBounds() {
|
||
const res = await api.GetOauthBounds();
|
||
oauthBounds.value = res;
|
||
}
|
||
|
||
async function loadOauthProviders() {
|
||
const res = await api.GetOauthProviders();
|
||
oauthProviders.value = res;
|
||
}
|
||
|
||
const computedOauthBounds = computed(() => {
|
||
const list = oauthProviders.value.map(item => {
|
||
const bound = oauthBounds.value.find(bound => bound.type === buildOauthBoundType(item));
|
||
return {
|
||
...item,
|
||
bound,
|
||
};
|
||
});
|
||
return list;
|
||
});
|
||
|
||
function buildOauthBoundType(item: any) {
|
||
return item.subtype ? `${item.name}:${item.subtype}` : item.name;
|
||
}
|
||
|
||
async function unbind(item: any) {
|
||
Modal.confirm({
|
||
title: "确认解绑吗?",
|
||
okText: "确认",
|
||
okType: "danger",
|
||
onOk: async () => {
|
||
await api.UnbindOauth(item.name, item.subtype);
|
||
await loadOauthBounds();
|
||
},
|
||
});
|
||
}
|
||
|
||
async function bind(item: any) {
|
||
const res = await api.OauthBoundUrl(item.name, item.subtype);
|
||
const loginUrl = res.loginUrl;
|
||
window.location.href = loginUrl;
|
||
}
|
||
|
||
async function loadPasskeys() {
|
||
try {
|
||
const res = await api.GetPasskeys();
|
||
passkeys.value = res;
|
||
} catch (e: any) {
|
||
console.error("加载Passkey失败:", e);
|
||
}
|
||
}
|
||
|
||
async function loadContactCapability() {
|
||
contactCapability.value = await api.GetContactCapability();
|
||
}
|
||
|
||
const { openContactBindDialog } = useContactBind();
|
||
async function openBindContact(type: "mobile" | "email") {
|
||
await openContactBindDialog({
|
||
type,
|
||
userInfo: userInfo.value,
|
||
contactCapability: contactCapability.value,
|
||
onUpdated: async () => {
|
||
await getUserInfo();
|
||
userStore.setUserInfo(userInfo.value);
|
||
},
|
||
});
|
||
}
|
||
|
||
async function unbindPasskey(id: number) {
|
||
Modal.confirm({
|
||
title: "确认解绑吗?",
|
||
okText: "确认",
|
||
okType: "danger",
|
||
onOk: async () => {
|
||
await api.UnbindPasskey(id);
|
||
await loadPasskeys();
|
||
},
|
||
});
|
||
}
|
||
|
||
const toBase64Url = (buffer: ArrayBuffer) => {
|
||
const bytes = new Uint8Array(buffer);
|
||
let binary = "";
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||
};
|
||
|
||
async function registerPasskey() {
|
||
if (!passkeySupported.value) {
|
||
Modal.error({ title: "错误", content: "浏览器不支持Passkey" });
|
||
return;
|
||
}
|
||
await openRegisterDialog({
|
||
onSubmit: async (ctx: any) => {
|
||
const deviceName = ctx.form.deviceName;
|
||
if (!deviceName) {
|
||
return;
|
||
}
|
||
await doRegisterPasskey(deviceName);
|
||
message.success("Passkey注册成功");
|
||
},
|
||
});
|
||
}
|
||
|
||
async function doRegisterPasskey(deviceName: string) {
|
||
try {
|
||
const res: any = await api.generatePasskeyRegistrationOptions();
|
||
const options = res;
|
||
|
||
// navigator.credentials.query({
|
||
// publicKey: options,
|
||
// });
|
||
|
||
// const excludeCredentials = passkeys.value.map(item => ({
|
||
// id: new TextEncoder().encode(item.passkeyId),
|
||
// type: "public-key",
|
||
// }));
|
||
|
||
console.log("passkey register options:", options, JSON.stringify(options));
|
||
const publicKey = {
|
||
challenge: Uint8Array.from(atob(options.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)),
|
||
rp: options.rp,
|
||
pubKeyCredParams: options.pubKeyCredParams,
|
||
timeout: options.timeout || 60000,
|
||
// attestation: options.attestation,
|
||
// excludeCredentials: excludeCredentials,
|
||
// extensions: options.extensions,
|
||
// authenticatorSelection: options.authenticatorSelection,
|
||
// hints: options.hints,
|
||
user: {
|
||
id: new TextEncoder().encode(options.userId + ""),
|
||
name: userInfo.value.username + "@" + deviceName,
|
||
displayName: deviceName,
|
||
},
|
||
// 关键配置在这里 👇
|
||
authenticatorSelection: {
|
||
residentKey: "required", // 或 "preferred",请求创建可发现凭证
|
||
requireResidentKey: true, // 为兼容旧浏览器,设置与 residentKey 相同的值
|
||
userVerification: "preferred", // 用户验证策略
|
||
},
|
||
};
|
||
console.log("passkey register publicKey:", publicKey, JSON.stringify(publicKey));
|
||
const credential = await (navigator.credentials as any).create({
|
||
publicKey,
|
||
});
|
||
|
||
if (!credential) {
|
||
throw new Error("Passkey注册失败");
|
||
}
|
||
|
||
const response = {
|
||
id: credential.id,
|
||
type: credential.type,
|
||
rawId: toBase64Url(credential.rawId),
|
||
response: {
|
||
attestationObject: toBase64Url(credential.response.attestationObject),
|
||
clientDataJSON: toBase64Url(credential.response.clientDataJSON),
|
||
},
|
||
};
|
||
console.log("credential", credential, response);
|
||
|
||
const verifyRes: any = await api.verifyPasskeyRegistration(response, options.challenge, deviceName);
|
||
console.log("verifyRes:", verifyRes, JSON.stringify(verifyRes));
|
||
await loadPasskeys();
|
||
} catch (e: any) {
|
||
console.error("Passkey注册失败:", e);
|
||
notification.error({ message: "错误", description: e.message || "Passkey注册失败" });
|
||
}
|
||
}
|
||
|
||
const formatDate = (dateString: string) => {
|
||
if (!dateString) return "";
|
||
return dayjs(dateString).format("YYYY-MM-DD HH:mm:ss");
|
||
};
|
||
|
||
const checkPasskeySupport = () => {
|
||
passkeySupported.value = false;
|
||
if (typeof window !== "undefined" && "credentials" in navigator && "PublicKeyCredential" in window) {
|
||
passkeySupported.value = true;
|
||
}
|
||
};
|
||
const userStore = useUserStore();
|
||
const changePasswordButtonRef = ref();
|
||
const userAvatar = computed(() => {
|
||
if (isEmpty(userInfo.value.avatar)) {
|
||
return "";
|
||
}
|
||
if (userInfo.value.avatar.startsWith("http")) {
|
||
return userInfo.value.avatar;
|
||
}
|
||
|
||
return `api/basic/file/download?key=${userInfo.value.avatar}`;
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await getUserInfo();
|
||
userStore.setUserInfo(userInfo.value);
|
||
if (userInfo.value.needInitPassword === true) {
|
||
Modal.confirm({
|
||
title: t("authentication.initPasswordTitle"),
|
||
content: t("authentication.initPasswordWarning"),
|
||
okText: t("authentication.setNow"),
|
||
cancelText: t("authentication.notNow"),
|
||
closable: true,
|
||
onOk: () => {
|
||
changePasswordButtonRef.value.open({
|
||
init: true,
|
||
});
|
||
},
|
||
});
|
||
}
|
||
await loadContactCapability();
|
||
await loadOauthBounds();
|
||
await loadOauthProviders();
|
||
await loadPasskeys();
|
||
checkPasskeySupport();
|
||
});
|
||
</script>
|
||
|
||
<style lang="less">
|
||
.page-user-profile {
|
||
:deep(.ant-descriptions-item-label) {
|
||
font-weight: 500;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
}
|
||
|
||
.dark {
|
||
.page-user-profile {
|
||
:deep(.ant-descriptions-item-label) {
|
||
color: rgba(255, 255, 255, 0.85);
|
||
}
|
||
}
|
||
|
||
.profile-container {
|
||
.profile-card,
|
||
.bindings-card,
|
||
.passkey-card {
|
||
background: linear-gradient(135deg, #1f1f1f 0%, #2d2d2d 100%);
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
|
||
&:hover {
|
||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||
border-color: rgba(255, 255, 255, 0.2);
|
||
}
|
||
}
|
||
|
||
.card-header {
|
||
background: linear-gradient(145deg, #1e1e1e, #252525);
|
||
|
||
.header-bg-gradient {
|
||
background: rgba(255, 255, 255, 0.04);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.header-content {
|
||
.user-avatar {
|
||
border-color: #3b3b3b;
|
||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.user-name {
|
||
color: #e5e5e5;
|
||
}
|
||
|
||
.detail-tag {
|
||
background: #3b3b3b;
|
||
border-color: rgba(255, 255, 255, 0.12);
|
||
color: #e5e5e5;
|
||
|
||
.tag-icon {
|
||
color: #e5e5e5;
|
||
}
|
||
}
|
||
}
|
||
|
||
.card-title {
|
||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.binding-icon {
|
||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.22) 0%, rgba(160, 120, 234, 0.22) 100%);
|
||
}
|
||
|
||
.passkey-icon {
|
||
background: linear-gradient(135deg, rgba(17, 153, 142, 0.22) 0%, rgba(56, 239, 125, 0.22) 100%);
|
||
}
|
||
|
||
.binding-icon .icon,
|
||
.passkey-icon .icon {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
}
|
||
|
||
.bindings-list {
|
||
.binding-item {
|
||
background: #2d2d2d;
|
||
border-color: rgba(255, 255, 255, 0.1);
|
||
color: #e5e5e5;
|
||
|
||
&:hover {
|
||
border-color: #667eea;
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||
}
|
||
|
||
.binding-name {
|
||
color: #e5e5e5;
|
||
}
|
||
|
||
.binding-status {
|
||
&.bound {
|
||
background: #1a3a2f;
|
||
color: #4caf50;
|
||
}
|
||
|
||
&.unbound {
|
||
background: #3a352a;
|
||
color: #ffb300;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.passkey-list {
|
||
.passkey-item {
|
||
background: #2d2d2d;
|
||
border-color: rgba(255, 255, 255, 0.1);
|
||
color: #e5e5e5;
|
||
|
||
&:hover {
|
||
border-color: #667eea;
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||
}
|
||
|
||
.passkey-name {
|
||
color: #e5e5e5;
|
||
}
|
||
|
||
.passkey-meta {
|
||
.meta-item {
|
||
color: #b0b0b0;
|
||
}
|
||
|
||
.meta-icon {
|
||
color: #888888;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.empty-state {
|
||
color: #b0b0b0;
|
||
|
||
.empty-icon {
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
|
||
.warning-box {
|
||
background: #3a2a2a;
|
||
border-color: #5a3a3a;
|
||
color: #e5e5e5;
|
||
|
||
.warning-icon {
|
||
color: #ef5350;
|
||
}
|
||
}
|
||
|
||
.helper {
|
||
background: #2d2d2d;
|
||
border-color: rgba(255, 255, 255, 0.1);
|
||
color: #b0b0b0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.profile-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
// max-width: 1000px;
|
||
|
||
.profile-card,
|
||
.bindings-card,
|
||
.passkey-card {
|
||
background: #fff;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||
transition: all 0.3s ease;
|
||
margin: 5px;
|
||
}
|
||
|
||
.bindings-card,
|
||
.passkey-card {
|
||
padding: 18px;
|
||
}
|
||
|
||
.profile-card:hover,
|
||
.bindings-card:hover,
|
||
.passkey-card:hover {
|
||
border-color: rgba(148, 163, 184, 0.34);
|
||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.1);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.card-header {
|
||
position: relative;
|
||
padding: 40px 30px;
|
||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.9)), hsl(var(--card));
|
||
}
|
||
|
||
.header-bg-gradient {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 100%;
|
||
background: radial-gradient(circle at 14% 22%, rgba(52, 120, 246, 0.08), transparent 34%), radial-gradient(circle at 86% 18%, rgba(197, 138, 53, 0.08), transparent 32%);
|
||
opacity: 1;
|
||
}
|
||
|
||
.header-content {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 30px;
|
||
}
|
||
|
||
.avatar-wrapper {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.avatar-edit-btn {
|
||
position: absolute;
|
||
right: 2px;
|
||
bottom: 2px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
min-width: 28px;
|
||
padding: 0;
|
||
color: #667eea;
|
||
background: #ffffff;
|
||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||
border-radius: 50%;
|
||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
|
||
}
|
||
|
||
.user-avatar {
|
||
border: 4px solid #ffffff;
|
||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.12);
|
||
}
|
||
|
||
.status-indicator {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
right: 8px;
|
||
width: 16px;
|
||
height: 16px;
|
||
background: #52c41a;
|
||
border: 3px solid #ffffff;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.user-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.user-name {
|
||
margin: 0 0 12px 0;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: #2c3e50;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.user-details {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.detail-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.72);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.detail-edit-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20px;
|
||
height: 20px;
|
||
min-width: 20px;
|
||
margin: -2px -6px -2px 0;
|
||
padding: 0;
|
||
color: #667eea;
|
||
}
|
||
|
||
.tag-icon {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.action-buttons {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.action-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 10px 20px;
|
||
font-weight: 500;
|
||
border-radius: 8px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.card-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 2px solid #f0f0f0;
|
||
}
|
||
|
||
.title-icon {
|
||
font-size: 20px;
|
||
color: #667eea;
|
||
}
|
||
|
||
.bindings-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.binding-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
border: 1px solid #f0f0f0;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.binding-item:hover {
|
||
border-color: #667eea;
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||
}
|
||
|
||
.binding-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #ebefff 0%, #e5d4ff 100%);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.binding-icon .icon {
|
||
font-size: 20px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.binding-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.binding-name {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: #2c3e50;
|
||
}
|
||
|
||
.binding-status {
|
||
font-size: 12px;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.binding-status.bound {
|
||
background: #e6fffa;
|
||
color: #38a169;
|
||
}
|
||
|
||
.binding-status.unbound {
|
||
background: #fffaf0;
|
||
color: #d69e2e;
|
||
}
|
||
|
||
.passkey-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.passkey-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
border: 1px solid #f0f0f0;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.passkey-item:hover {
|
||
border-color: #667eea;
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||
}
|
||
|
||
.passkey-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.passkey-icon .icon {
|
||
font-size: 24px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.passkey-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.passkey-name {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #2c3e50;
|
||
}
|
||
|
||
.passkey-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.meta-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.meta-icon {
|
||
font-size: 14px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.remove-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.remove-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
|
||
}
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px 10px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 12px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.empty-text {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.warning-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
background: #fff7ed;
|
||
border: 1px solid #fed7d7;
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.warning-icon {
|
||
font-size: 18px;
|
||
color: #f56565;
|
||
}
|
||
|
||
.add-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 10px 20px;
|
||
border-radius: 8px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.add-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.helper {
|
||
background: #f8f9fa;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
border: 1px solid #e5e7eb;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.card-header {
|
||
padding: 32px 16px;
|
||
}
|
||
|
||
.header-content {
|
||
flex-direction: column;
|
||
text-align: center;
|
||
gap: 18px;
|
||
}
|
||
|
||
.avatar-wrapper {
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.user-avatar {
|
||
width: 88px !important;
|
||
height: 88px !important;
|
||
line-height: 88px !important;
|
||
}
|
||
|
||
.avatar-edit-btn {
|
||
right: -2px;
|
||
bottom: 0;
|
||
}
|
||
|
||
.user-name {
|
||
justify-content: center;
|
||
margin-bottom: 14px;
|
||
font-size: 22px;
|
||
gap: 6px;
|
||
}
|
||
|
||
.user-details {
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6px;
|
||
width: 100%;
|
||
}
|
||
|
||
.detail-tag {
|
||
justify-content: flex-start;
|
||
width: min(230px, calc(100vw - 96px));
|
||
min-height: 34px;
|
||
padding: 5px 8px 5px 12px;
|
||
border-radius: 12px;
|
||
white-space: nowrap;
|
||
margin-right: 0;
|
||
text-align: left;
|
||
}
|
||
|
||
.tag-icon {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.tag-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.detail-edit-btn {
|
||
margin: 0 -4px 0 2px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.action-buttons {
|
||
justify-content: center;
|
||
width: 100%;
|
||
gap: 10px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.action-buttons :deep(.ant-btn) {
|
||
min-width: 90px;
|
||
height: 32px;
|
||
padding: 4px 12px;
|
||
}
|
||
}
|
||
}
|
||
</style>
|