Files
huihuiSquare/digital-avatar-app/src/views/AuthorizationManage.vue
T

897 lines
22 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.
<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>