fix(avatar): prevent takeover loops and isolate settings

This commit is contained in:
stefanfeng
2026-08-27 11:30:12 +08:00
parent ef58c5f2d2
commit 46d42b7d98
14 changed files with 848 additions and 62 deletions
+11 -2
View File
@@ -191,19 +191,28 @@ export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'inter
export interface AvatarPermissionSettings {
avatarId: string
permissions: AvatarPermission[]
takeoverReplyDelaySeconds: number
disabledAvatarIds?: string[]
}
export const getAvatarPermissionSettings = (avatarId: string) =>
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) =>
request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, { permissions })
export const updateAvatarPermissionSettings = (
avatarId: string,
permissions: AvatarPermission[],
takeoverReplyDelaySeconds: number
) => request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, {
permissions,
takeoverReplyDelaySeconds,
})
export interface TakeoverStatus {
enabled: boolean
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
message: string
pendingCount: number
takeoverReplyDelaySeconds: number
lastPolledAt: string | null
}
@@ -64,7 +64,7 @@
<span class="permission-copy">
<strong>{{ item.title }}</strong>
<small>
{{ item.description }}
{{ item.key === 'takeover' ? takeoverDescription : item.description }}
<span
v-if="item.key === 'takeover' && takeoverConnectionLabel"
class="connection-state"
@@ -79,6 +79,33 @@
</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>
@@ -171,7 +198,7 @@ const permissionItems: Array<{
{
key: 'takeover',
title: '分身主动接管聊天回复',
description: '收到私聊消息 3 秒后回复,主人发言时暂停',
description: '收到私聊消息后按设定时间回复,主人发言时暂停',
tone: 'cyan',
},
]
@@ -190,6 +217,8 @@ 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
@@ -210,6 +239,38 @@ const takeoverConnectionTone = computed(() => {
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)
@@ -265,6 +326,7 @@ const loadSettings = async () => {
try {
const settings = await getAvatarPermissionSettings(activeAvatarId.value)
setPermissions(settings.permissions || [])
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
await loadTakeoverStatus()
scheduleTakeoverStatusRefresh()
} catch (error: any) {
@@ -291,8 +353,18 @@ const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
saving.value = true
errorMessage.value = ''
try {
const settings = await updateAvatarPermissionSettings(activeAvatarId.value, selectedPermissions())
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) {
@@ -468,6 +540,75 @@ svg {
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;