897 lines
22 KiB
Vue
897 lines
22 KiB
Vue
<template>
|
||
<div class="authorization-page" :class="{ embedded: isEmbedded }">
|
||
<header v-if="!isEmbedded" class="page-header">
|
||
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="m15 18-6-6 6-6" />
|
||
</svg>
|
||
</button>
|
||
<h1>分身授权管理</h1>
|
||
<span class="header-balance" aria-hidden="true"></span>
|
||
</header>
|
||
|
||
<main class="page-content">
|
||
<template v-if="activeAvatarId">
|
||
<section class="permission-intro" aria-label="授权说明">
|
||
<span class="intro-icon" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24">
|
||
<rect x="5" y="10" width="14" height="11" rx="2" />
|
||
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||
</svg>
|
||
</span>
|
||
<p>设置数字分身可以执行的操作权限。开启后,分身将代表你自动进行相应操作。</p>
|
||
</section>
|
||
|
||
<section class="permissions-card" :class="{ loading }" :aria-busy="loading">
|
||
<button
|
||
v-for="item in permissionItems"
|
||
:key="item.key"
|
||
class="permission-row"
|
||
:class="{ takeover: item.key === 'takeover' }"
|
||
type="button"
|
||
role="switch"
|
||
:aria-checked="permissionState[item.key]"
|
||
:aria-label="`${permissionState[item.key] ? '关闭' : '开启'}${item.title}`"
|
||
:disabled="loading || saving"
|
||
@click="togglePermission(item.key)"
|
||
>
|
||
<span class="permission-icon" :class="item.tone" aria-hidden="true">
|
||
<svg v-if="item.key === 'friend'" viewBox="0 0 24 24">
|
||
<path d="M9.5 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
|
||
<path d="M3.5 20a6 6 0 0 1 12 0M17 8v6m-3-3h6" />
|
||
</svg>
|
||
<svg v-else-if="item.key === 'chat'" viewBox="0 0 24 24">
|
||
<path d="M20 14a4 4 0 0 1-4 4H8l-5 3 1.5-5A7 7 0 0 1 3 12V8a4 4 0 0 1 4-4h9a4 4 0 0 1 4 4v6Z" />
|
||
</svg>
|
||
<svg v-else-if="item.key === 'publish'" viewBox="0 0 24 24">
|
||
<path d="M14.7 5.3 18.7 9.3M4 20l4.1-.8L19.5 7.8a2.8 2.8 0 0 0-4-4L4.8 14.5 4 20Z" />
|
||
</svg>
|
||
<svg v-else-if="item.key === 'browse'" viewBox="0 0 24 24">
|
||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||
</svg>
|
||
<svg v-else-if="item.key === 'interact'" viewBox="0 0 24 24">
|
||
<path d="M7 10v10H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h3Zm0 10h9.5a2 2 0 0 0 1.9-1.4l2.3-7A2 2 0 0 0 18.8 9H14l.8-3.2A2.3 2.3 0 0 0 12.6 3L7 10v10Z" />
|
||
</svg>
|
||
<svg v-else viewBox="0 0 24 24">
|
||
<path d="M20 13.5a4.5 4.5 0 0 1-4.5 4.5H9l-5 3 1.4-4.5A6.5 6.5 0 0 1 3 11.4V8.5A4.5 4.5 0 0 1 7.5 4H14" />
|
||
<path d="M18 3v5m-2.5-2.5h5" />
|
||
</svg>
|
||
</span>
|
||
|
||
<span class="permission-copy">
|
||
<strong>{{ item.title }}</strong>
|
||
<small>
|
||
{{ item.key === 'takeover' ? takeoverDescription : item.description }}
|
||
<span
|
||
v-if="item.key === 'takeover' && takeoverConnectionLabel"
|
||
class="connection-state"
|
||
:class="takeoverConnectionTone"
|
||
> · {{ takeoverConnectionLabel }}</span>
|
||
</small>
|
||
</span>
|
||
|
||
<span class="permission-switch" :class="{ active: permissionState[item.key] }" aria-hidden="true">
|
||
<span></span>
|
||
</span>
|
||
</button>
|
||
</section>
|
||
|
||
<section v-if="permissionState.takeover" class="takeover-delay-card" aria-label="自动回复等待时间">
|
||
<div class="delay-heading">
|
||
<div>
|
||
<strong>自动回复等待时间</strong>
|
||
<small>等待期间主人发言会取消本次回复,最短 3 秒</small>
|
||
</div>
|
||
<span>{{ formattedTakeoverDelay }}</span>
|
||
</div>
|
||
<div class="delay-control">
|
||
<input
|
||
v-model.number="takeoverDelayValue"
|
||
type="number"
|
||
inputmode="numeric"
|
||
step="1"
|
||
:min="takeoverDelayUnit === 'minutes' ? 1 : 3"
|
||
:max="takeoverDelayUnit === 'minutes' ? 1440 : 86400"
|
||
aria-label="等待时间"
|
||
:disabled="loading || saving"
|
||
@blur="normalizeTakeoverDelay"
|
||
/>
|
||
<select v-model="takeoverDelayUnit" aria-label="等待时间单位" :disabled="loading || saving">
|
||
<option value="seconds">秒</option>
|
||
<option value="minutes">分钟</option>
|
||
</select>
|
||
</div>
|
||
</section>
|
||
|
||
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
|
||
</template>
|
||
|
||
<section v-else-if="!loading" class="empty-state">
|
||
<span class="empty-icon" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24">
|
||
<path d="M8 11a4 4 0 1 1 8 0v1M5 20a7 7 0 0 1 14 0M19 8v4m-2-2h4" />
|
||
</svg>
|
||
</span>
|
||
<h2>还没有数字分身</h2>
|
||
<p>创建分身后即可设置操作权限。</p>
|
||
<button type="button" @click="goCreate">创建数字分身</button>
|
||
</section>
|
||
</main>
|
||
|
||
<footer v-if="activeAvatarId" class="save-area">
|
||
<button v-if="isEmbedded" class="footer-back-button" type="button" :disabled="saving" @click="goBack">
|
||
返回
|
||
</button>
|
||
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
|
||
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
||
{{ saving ? '保存中...' : '保存授权设置' }}
|
||
</button>
|
||
</footer>
|
||
|
||
<Transition name="toast">
|
||
<p v-if="toastMessage" class="page-toast" role="status">{{ toastMessage }}</p>
|
||
</Transition>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import {
|
||
getAvatarPermissionSettings,
|
||
getTakeoverStatus,
|
||
updateAvatarPermissionSettings,
|
||
type AvatarPermission,
|
||
type TakeoverStatus,
|
||
} from '@/api'
|
||
import { useAvatarStore } from '@/store/avatar'
|
||
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
|
||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||
|
||
type PermissionState = Record<AvatarPermission, boolean>
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const avatarStore = useAvatarStore()
|
||
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
|
||
const isEmbedded = isHuihuiEmbeddedMode()
|
||
|
||
const permissionItems: Array<{
|
||
key: AvatarPermission
|
||
title: string
|
||
description: string
|
||
tone: string
|
||
}> = [
|
||
{
|
||
key: 'friend',
|
||
title: '交友与添加好友',
|
||
description: '允许分身主动添加好友或接受好友请求',
|
||
tone: 'orange',
|
||
},
|
||
{
|
||
key: 'chat',
|
||
title: '主动发起聊天',
|
||
description: '允许分身主动与陌生人或好友发起聊天',
|
||
tone: 'blue',
|
||
},
|
||
{
|
||
key: 'publish',
|
||
title: '发布微博内容',
|
||
description: '允许分身自动发布动态内容',
|
||
tone: 'green',
|
||
},
|
||
{
|
||
key: 'browse',
|
||
title: '浏览会会广场',
|
||
description: '允许分身浏览广场内容',
|
||
tone: 'violet',
|
||
},
|
||
{
|
||
key: 'interact',
|
||
title: '广场互动操作',
|
||
description: '点赞、收藏、评论、回复等操作',
|
||
tone: 'pink',
|
||
},
|
||
{
|
||
key: 'takeover',
|
||
title: '分身主动接管聊天回复',
|
||
description: '收到私聊消息后按设定时间回复,主人发言时暂停',
|
||
tone: 'cyan',
|
||
},
|
||
]
|
||
|
||
const permissionState = reactive<PermissionState>({
|
||
friend: true,
|
||
chat: true,
|
||
publish: false,
|
||
browse: false,
|
||
interact: false,
|
||
takeover: false,
|
||
})
|
||
const activeAvatarId = ref('')
|
||
const loading = ref(true)
|
||
const saving = ref(false)
|
||
const errorMessage = ref('')
|
||
const toastMessage = ref('')
|
||
const takeoverStatus = ref<TakeoverStatus | null>(null)
|
||
const takeoverDelayValue = ref(3)
|
||
const takeoverDelayUnit = ref<'seconds' | 'minutes'>('minutes')
|
||
let toastTimer: number | undefined
|
||
let takeoverStatusTimer: number | undefined
|
||
|
||
const takeoverConnectionLabel = computed(() => {
|
||
if (takeoverStatus.value?.status === 'error' || takeoverStatus.value?.status === 'needs_login') {
|
||
return '连接失败'
|
||
}
|
||
if (takeoverStatus.value?.status === 'ready') return '已连接'
|
||
if (permissionState.takeover) return '正在连接'
|
||
return ''
|
||
})
|
||
|
||
const takeoverConnectionTone = computed(() => {
|
||
if (takeoverStatus.value?.status === 'ready') return 'ready'
|
||
if (takeoverStatus.value?.status === 'error' || takeoverStatus.value?.status === 'needs_login') {
|
||
return 'failed'
|
||
}
|
||
return 'connecting'
|
||
})
|
||
|
||
const takeoverDelaySeconds = computed(() => {
|
||
const value = Math.trunc(Number(takeoverDelayValue.value) || 0)
|
||
return takeoverDelayUnit.value === 'minutes' ? value * 60 : value
|
||
})
|
||
|
||
const formattedTakeoverDelay = computed(() => {
|
||
const seconds = takeoverDelaySeconds.value
|
||
if (seconds > 0 && seconds % 60 === 0) return `${seconds / 60} 分钟`
|
||
return `${seconds} 秒`
|
||
})
|
||
|
||
const takeoverDescription = computed(() =>
|
||
`收到私聊消息 ${formattedTakeoverDelay.value}后回复,主人发言时暂停`
|
||
)
|
||
|
||
const applyTakeoverDelay = (seconds: number) => {
|
||
const normalized = Number.isFinite(seconds) && seconds >= 3 ? Math.trunc(seconds) : 180
|
||
if (normalized % 60 === 0) {
|
||
takeoverDelayUnit.value = 'minutes'
|
||
takeoverDelayValue.value = normalized / 60
|
||
} else {
|
||
takeoverDelayUnit.value = 'seconds'
|
||
takeoverDelayValue.value = normalized
|
||
}
|
||
}
|
||
|
||
const normalizeTakeoverDelay = () => {
|
||
const min = takeoverDelayUnit.value === 'minutes' ? 1 : 3
|
||
const max = takeoverDelayUnit.value === 'minutes' ? 1440 : 86400
|
||
takeoverDelayValue.value = Math.min(max, Math.max(min, Math.trunc(Number(takeoverDelayValue.value) || min)))
|
||
}
|
||
|
||
const setPermissions = (permissions: AvatarPermission[]) => {
|
||
const enabled = new Set(permissions)
|
||
for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key)
|
||
}
|
||
|
||
const selectedPermissions = () => permissionItems
|
||
.filter((item) => permissionState[item.key])
|
||
.map((item) => item.key)
|
||
|
||
const readableError = (error: any, fallback: string) =>
|
||
error?.response?.data?.detail || error?.response?.data?.message || error?.message || fallback
|
||
|
||
const showToast = (message: string) => {
|
||
toastMessage.value = message
|
||
if (toastTimer) window.clearTimeout(toastTimer)
|
||
toastTimer = window.setTimeout(() => { toastMessage.value = '' }, 2400)
|
||
}
|
||
|
||
const togglePermission = async (permission: AvatarPermission) => {
|
||
errorMessage.value = ''
|
||
const previousValue = permissionState[permission]
|
||
permissionState[permission] = !permissionState[permission]
|
||
if (permission === 'takeover') {
|
||
takeoverStatus.value = null
|
||
if (takeoverStatusTimer) {
|
||
window.clearTimeout(takeoverStatusTimer)
|
||
takeoverStatusTimer = undefined
|
||
}
|
||
const saved = await saveSettings(true)
|
||
if (!saved) permissionState.takeover = previousValue
|
||
}
|
||
}
|
||
|
||
const scheduleTakeoverStatusRefresh = () => {
|
||
if (takeoverStatusTimer) window.clearTimeout(takeoverStatusTimer)
|
||
if (!permissionState.takeover || !activeAvatarId.value) {
|
||
takeoverStatusTimer = undefined
|
||
return
|
||
}
|
||
takeoverStatusTimer = window.setTimeout(async () => {
|
||
await loadTakeoverStatus()
|
||
scheduleTakeoverStatusRefresh()
|
||
}, 3000)
|
||
}
|
||
|
||
const loadSettings = async () => {
|
||
if (!activeAvatarId.value) {
|
||
loading.value = false
|
||
return
|
||
}
|
||
loading.value = true
|
||
errorMessage.value = ''
|
||
try {
|
||
const settings = await getAvatarPermissionSettings(activeAvatarId.value)
|
||
setPermissions(settings.permissions || [])
|
||
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
|
||
await loadTakeoverStatus()
|
||
scheduleTakeoverStatusRefresh()
|
||
} catch (error: any) {
|
||
errorMessage.value = readableError(error, '授权设置加载失败,请稍后重试')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const loadTakeoverStatus = async () => {
|
||
if (!activeAvatarId.value) return
|
||
try {
|
||
takeoverStatus.value = await getTakeoverStatus(activeAvatarId.value)
|
||
if (!takeoverStatus.value.enabled && permissionState.takeover) {
|
||
permissionState.takeover = false
|
||
}
|
||
} catch {
|
||
takeoverStatus.value = null
|
||
}
|
||
}
|
||
|
||
const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
|
||
if (!activeAvatarId.value || loading.value || saving.value) return false
|
||
saving.value = true
|
||
errorMessage.value = ''
|
||
try {
|
||
normalizeTakeoverDelay()
|
||
if (takeoverDelaySeconds.value < 3 || takeoverDelaySeconds.value > 86400) {
|
||
errorMessage.value = '自动回复等待时间需在 3 秒到 24 小时之间'
|
||
return false
|
||
}
|
||
const settings = await updateAvatarPermissionSettings(
|
||
activeAvatarId.value,
|
||
selectedPermissions(),
|
||
takeoverDelaySeconds.value,
|
||
)
|
||
setPermissions(settings.permissions || [])
|
||
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
|
||
await loadTakeoverStatus()
|
||
scheduleTakeoverStatusRefresh()
|
||
if (takeoverToggle) {
|
||
showToast(permissionState.takeover ? '接管已开启,正在连接' : '接管已关闭')
|
||
} else {
|
||
showToast('授权设置已保存')
|
||
}
|
||
return true
|
||
} catch (error: any) {
|
||
errorMessage.value = readableError(error, '授权设置保存失败,请重试')
|
||
return false
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
const goBack = () => router.push('/avatar/manage')
|
||
const goCreate = () => router.push('/avatar/create')
|
||
|
||
onMounted(async () => {
|
||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||
activeAvatarId.value = avatarId.value || ''
|
||
if (activeAvatarId.value) avatarStore.currentAvatarId = activeAvatarId.value
|
||
await loadSettings()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (toastTimer) window.clearTimeout(toastTimer)
|
||
if (takeoverStatusTimer) window.clearTimeout(takeoverStatusTimer)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.authorization-page {
|
||
--orange: #f58632;
|
||
--orange-deep: #f2792f;
|
||
--ink: #18191c;
|
||
--muted: #a0a5b4;
|
||
position: relative;
|
||
width: min(100%, 390px);
|
||
min-height: 100dvh;
|
||
margin: 0 auto;
|
||
padding-bottom: calc(92px + env(safe-area-inset-bottom));
|
||
overflow-x: hidden;
|
||
color: var(--ink);
|
||
background: #fafafa;
|
||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
button {
|
||
font: inherit;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
|
||
button:disabled {
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
svg {
|
||
width: 1em;
|
||
height: 1em;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 1.7;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.page-header {
|
||
height: calc(72px + env(safe-area-inset-top));
|
||
padding: env(safe-area-inset-top) 20px 0;
|
||
display: grid;
|
||
grid-template-columns: 34px 1fr 34px;
|
||
align-items: center;
|
||
}
|
||
|
||
.page-header h1 {
|
||
margin: 0;
|
||
text-align: center;
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
line-height: 1.4;
|
||
letter-spacing: .01em;
|
||
}
|
||
|
||
.back-button {
|
||
width: 32px;
|
||
height: 32px;
|
||
padding: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
color: #7f8490;
|
||
background: #f3f4f6;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.back-button svg {
|
||
font-size: 19px;
|
||
}
|
||
|
||
.page-content {
|
||
padding: 0 20px;
|
||
}
|
||
|
||
.authorization-page.embedded .page-content {
|
||
padding-top: 16px;
|
||
}
|
||
|
||
.permission-intro {
|
||
min-height: 96px;
|
||
padding: 15px 16px 14px;
|
||
border: 1px solid rgba(255, 227, 195, .45);
|
||
border-radius: 13px;
|
||
background: linear-gradient(115deg, #fff8eb 0%, #fff4ec 100%);
|
||
}
|
||
|
||
.intro-icon {
|
||
width: 26px;
|
||
height: 26px;
|
||
display: grid;
|
||
place-items: center;
|
||
color: #f18837;
|
||
}
|
||
|
||
.intro-icon svg {
|
||
font-size: 24px;
|
||
stroke-width: 1.9;
|
||
}
|
||
|
||
.permission-intro p {
|
||
margin: 7px 0 0;
|
||
color: #a66d45;
|
||
font-size: 12px;
|
||
line-height: 1.75;
|
||
}
|
||
|
||
.permissions-card {
|
||
margin-top: 16px;
|
||
padding: 5px 0;
|
||
overflow: hidden;
|
||
border-radius: 15px;
|
||
background: #fff;
|
||
box-shadow: 0 8px 26px rgba(69, 55, 43, .025);
|
||
transition: opacity .2s ease;
|
||
}
|
||
|
||
.permissions-card.loading {
|
||
opacity: .62;
|
||
}
|
||
|
||
.permission-row {
|
||
width: 100%;
|
||
min-height: 65px;
|
||
padding: 8px 15px;
|
||
display: grid;
|
||
grid-template-columns: 34px minmax(0, 1fr) 43px;
|
||
align-items: center;
|
||
gap: 11px;
|
||
border: 0;
|
||
color: inherit;
|
||
text-align: left;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.permission-row:active:not(:disabled) {
|
||
background: #fcfcfd;
|
||
}
|
||
|
||
.permission-row.takeover {
|
||
min-height: 76px;
|
||
}
|
||
|
||
.takeover-delay-card {
|
||
margin-top: 12px;
|
||
padding: 16px;
|
||
border: 1px solid #dff1ef;
|
||
border-radius: 15px;
|
||
background: linear-gradient(135deg, #f5fcfb 0%, #fff 100%);
|
||
box-shadow: 0 8px 24px rgba(53, 166, 162, .06);
|
||
}
|
||
|
||
.delay-heading {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.delay-heading strong,
|
||
.delay-heading small {
|
||
display: block;
|
||
}
|
||
|
||
.delay-heading strong {
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.delay-heading small {
|
||
margin-top: 5px;
|
||
color: #8c929f;
|
||
font-size: 11px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.delay-heading > span {
|
||
flex: none;
|
||
padding: 4px 8px;
|
||
border-radius: 999px;
|
||
color: #258e8a;
|
||
background: #e8f8f6;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.delay-control {
|
||
margin-top: 14px;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 88px;
|
||
gap: 10px;
|
||
}
|
||
|
||
.delay-control input,
|
||
.delay-control select {
|
||
min-width: 0;
|
||
height: 42px;
|
||
padding: 0 12px;
|
||
border: 1px solid #dfe5e8;
|
||
border-radius: 11px;
|
||
outline: none;
|
||
color: #222528;
|
||
background: #fff;
|
||
font: inherit;
|
||
}
|
||
|
||
.delay-control input:focus,
|
||
.delay-control select:focus {
|
||
border-color: #35a6a2;
|
||
box-shadow: 0 0 0 3px rgba(53, 166, 162, .1);
|
||
}
|
||
|
||
.permission-icon {
|
||
width: 34px;
|
||
height: 34px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.permission-icon svg {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.permission-icon.orange {
|
||
color: #ec9a45;
|
||
background: #fff9ee;
|
||
}
|
||
|
||
.permission-icon.blue {
|
||
color: #619ae7;
|
||
background: #f3f8ff;
|
||
}
|
||
|
||
.permission-icon.green {
|
||
color: #64c792;
|
||
background: #f0fbf5;
|
||
}
|
||
|
||
.permission-icon.violet {
|
||
color: #8b7de4;
|
||
background: #f6f4ff;
|
||
}
|
||
|
||
.permission-icon.pink {
|
||
color: #ec78a3;
|
||
background: #fff3f7;
|
||
}
|
||
|
||
.permission-icon.cyan {
|
||
color: #35a6a2;
|
||
background: #effaf9;
|
||
}
|
||
|
||
.permission-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
.permission-copy strong,
|
||
.permission-copy small {
|
||
display: block;
|
||
}
|
||
|
||
.connection-state {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.connection-state.connecting {
|
||
color: #d78532;
|
||
}
|
||
|
||
.connection-state.ready {
|
||
color: #31966b;
|
||
}
|
||
|
||
.connection-state.failed {
|
||
color: #d95849;
|
||
}
|
||
|
||
.permission-copy strong {
|
||
color: #26272b;
|
||
font-size: 14px;
|
||
font-weight: 650;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.permission-copy small {
|
||
margin-top: 3px;
|
||
overflow: hidden;
|
||
color: var(--muted);
|
||
font-size: 10.5px;
|
||
font-weight: 400;
|
||
line-height: 1.45;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.permission-row.takeover .permission-copy small {
|
||
display: -webkit-box;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: normal;
|
||
-webkit-box-orient: vertical;
|
||
-webkit-line-clamp: 2;
|
||
}
|
||
|
||
.permission-switch {
|
||
position: relative;
|
||
width: 43px;
|
||
height: 25px;
|
||
justify-self: end;
|
||
border-radius: 999px;
|
||
background: #d8dde5;
|
||
box-shadow: inset 0 0 0 1px rgba(140, 148, 160, .08);
|
||
transition: background .2s ease;
|
||
}
|
||
|
||
.permission-switch span {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 21px;
|
||
height: 21px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
box-shadow: 0 1px 4px rgba(74, 72, 69, .2);
|
||
transition: transform .2s cubic-bezier(.22, .85, .35, 1);
|
||
}
|
||
|
||
.permission-switch.active {
|
||
background: linear-gradient(120deg, #f59d3c, #f47d31);
|
||
}
|
||
|
||
.permission-switch.active span {
|
||
transform: translateX(18px);
|
||
}
|
||
|
||
.error-message {
|
||
margin: 13px 4px 0;
|
||
color: #d95849;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
text-align: center;
|
||
}
|
||
|
||
.save-area {
|
||
position: fixed;
|
||
z-index: 20;
|
||
left: 50%;
|
||
bottom: 0;
|
||
width: min(100%, 390px);
|
||
padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
|
||
display: flex;
|
||
gap: 10px;
|
||
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.save-button {
|
||
min-width: 0;
|
||
flex: 1;
|
||
height: 48px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
border: 0;
|
||
border-radius: 24px;
|
||
color: #fff;
|
||
background: linear-gradient(105deg, #f7a33d 0%, #f47b2f 100%);
|
||
box-shadow: 0 8px 20px rgba(240, 119, 43, .16);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.footer-back-button {
|
||
flex: 0 0 96px;
|
||
height: 48px;
|
||
border: 1px solid #eadfd6;
|
||
border-radius: 24px;
|
||
color: #6f665f;
|
||
background: #fff;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.footer-back-button:disabled { opacity: .58; }
|
||
|
||
.save-button:disabled {
|
||
opacity: .68;
|
||
}
|
||
|
||
.saving-spinner {
|
||
width: 15px;
|
||
height: 15px;
|
||
border: 2px solid rgba(255, 255, 255, .48);
|
||
border-top-color: #fff;
|
||
border-radius: 50%;
|
||
animation: spin .8s linear infinite;
|
||
}
|
||
|
||
.empty-state {
|
||
min-height: 470px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-icon {
|
||
width: 58px;
|
||
height: 58px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 18px;
|
||
color: #ed8739;
|
||
background: #fff4e9;
|
||
}
|
||
|
||
.empty-icon svg {
|
||
font-size: 28px;
|
||
}
|
||
|
||
.empty-state h2 {
|
||
margin: 18px 0 0;
|
||
font-size: 17px;
|
||
}
|
||
|
||
.empty-state p {
|
||
margin: 7px 0 20px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.empty-state button {
|
||
padding: 11px 24px;
|
||
border: 0;
|
||
border-radius: 22px;
|
||
color: #fff;
|
||
background: linear-gradient(105deg, #f7a33d, #f47b2f);
|
||
}
|
||
|
||
.page-toast {
|
||
position: fixed;
|
||
z-index: 50;
|
||
left: 50%;
|
||
bottom: calc(88px + env(safe-area-inset-bottom));
|
||
max-width: min(310px, calc(100vw - 40px));
|
||
margin: 0;
|
||
padding: 10px 18px;
|
||
border-radius: 22px;
|
||
color: #fff;
|
||
background: rgba(36, 36, 39, .9);
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, .14);
|
||
font-size: 12px;
|
||
text-align: center;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.toast-enter-active,
|
||
.toast-leave-active {
|
||
transition: opacity .2s ease, transform .2s ease;
|
||
}
|
||
|
||
.toast-enter-from,
|
||
.toast-leave-to {
|
||
opacity: 0;
|
||
transform: translate(-50%, 8px);
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
@media (min-width: 600px) {
|
||
.authorization-page {
|
||
border-radius: 0 0 34px 34px;
|
||
box-shadow: 0 18px 55px rgba(32, 30, 28, .1);
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.permission-switch,
|
||
.permission-switch span,
|
||
.toast-enter-active,
|
||
.toast-leave-active {
|
||
transition: none;
|
||
}
|
||
|
||
.saving-spinner {
|
||
animation: none;
|
||
}
|
||
}
|
||
</style>
|