feat: complete grounded digital avatar chat experience
This commit is contained in:
@@ -1,40 +1,69 @@
|
||||
<template>
|
||||
<div class="chat-page">
|
||||
<header class="chat-header">
|
||||
<button class="back-btn" @click="router.back()">‹</button>
|
||||
<button v-if="!isPublic" class="back-btn" @click="router.back()">‹</button>
|
||||
<div class="avatar-heading">
|
||||
<div class="avatar-mark">{{ avatar?.emoji || '🤖' }}</div>
|
||||
<div class="avatar-mark">
|
||||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1>{{ avatar?.displayName || avatar?.name || '数字分身' }}</h1>
|
||||
<span class="online-state">● 随时可以和我聊聊</span>
|
||||
<span class="online-state" :class="avatarStatus.tone"><i></i>{{ avatarStatus.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="settings-btn" title="编辑分身" @click="router.push(`/avatar/edit/${avatarId}`)">⚙</button>
|
||||
<button v-if="!isPublic" class="settings-btn" title="编辑分身" @click="router.push(`/avatar/edit/${avatarId}`)">⚙</button>
|
||||
</header>
|
||||
|
||||
<main ref="messageList" class="message-list">
|
||||
<div v-if="!messages.length" class="welcome-card">
|
||||
<div class="welcome-icon">✦</div>
|
||||
<h2>你好,我是{{ avatar?.displayName || '你的数字分身' }}</h2>
|
||||
<p>我会优先参考标准问答和知识库,再结合自己的理解回答你。</p>
|
||||
<div class="starter-list">
|
||||
<button v-for="starter in starters" :key="starter" @click="sendMessage(starter)">{{ starter }}</button>
|
||||
<div class="welcome-avatar">
|
||||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<h2>有什么想聊的?</h2>
|
||||
<p class="welcome-description">{{ avatar?.description || '很高兴和你聊聊。' }}</p>
|
||||
</div>
|
||||
|
||||
<article v-for="(message, index) in messages" :key="`${message.role}-${index}`" class="message-row" :class="message.role">
|
||||
<div v-if="message.role === 'assistant'" class="message-avatar">{{ avatar?.emoji || '🤖' }}</div>
|
||||
<div v-if="message.role === 'assistant'" class="message-avatar avatar-message-face">
|
||||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<div class="message-column">
|
||||
<div class="message-bubble">{{ message.content }}</div>
|
||||
<div class="message-bubble" :class="{ streaming: sending && message.role === 'assistant' && index === messages.length - 1 }">
|
||||
<template v-if="message.role === 'assistant'">
|
||||
<span
|
||||
v-for="character in renderChatMarkdownCharacters(message.characters?.length ? message.characters : message.content)"
|
||||
:key="character.key"
|
||||
class="typing-character"
|
||||
:class="{
|
||||
newline: character.newline,
|
||||
'markdown-bold': character.bold,
|
||||
'markdown-italic': character.italic,
|
||||
'markdown-code': character.code,
|
||||
'markdown-heading': character.heading
|
||||
}"
|
||||
>{{ character.text }}</span>
|
||||
</template>
|
||||
<template v-else>{{ message.content }}</template>
|
||||
</div>
|
||||
<div v-if="message.source || message.references?.length" class="message-source">
|
||||
{{ sourceLabel(message.source) }}
|
||||
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.role === 'user'" class="message-avatar user-message-face">
|
||||
<img v-if="userAvatarUrl" :src="userAvatarUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ userAvatarInitial }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="sending" class="message-row assistant">
|
||||
<div class="message-avatar">{{ avatar?.emoji || '🤖' }}</div>
|
||||
<div v-if="thinking" class="message-row assistant">
|
||||
<div class="message-avatar avatar-message-face">
|
||||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<div class="message-bubble typing"><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="chat-error">{{ errorMessage }} <button @click="retryLast">重试</button></p>
|
||||
@@ -48,33 +77,50 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getAvatarDetail, sendAvatarChat, type ChatMessage } from '@/api'
|
||||
import { getAvatarDetail, getPublicAvatar, streamAvatarChat, streamPublicAvatarChat, type ChatMessage } from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { renderChatMarkdownCharacters } from '@/utils/chat-markdown.js'
|
||||
|
||||
type DisplayMessage = ChatMessage & {
|
||||
source?: 'qa' | 'knowledge' | 'qwen'
|
||||
source?: 'qa' | 'knowledge' | 'qwen' | 'public'
|
||||
references?: Array<{ filename?: string }>
|
||||
characters?: string[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAvatarStore()
|
||||
const avatarId = String(route.params.id || '')
|
||||
const userStore = useUserStore()
|
||||
const shareToken = String(route.params.shareToken || '')
|
||||
const isPublic = Boolean(shareToken)
|
||||
const avatarId = ref(String(route.params.id || ''))
|
||||
const avatar = ref<any>(null)
|
||||
const messages = ref<DisplayMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
const thinking = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastQuestion = ref('')
|
||||
const messageList = ref<HTMLElement | null>(null)
|
||||
const starters = ['介绍一下你自己', '你能帮我做什么?', '请根据我的知识库回答一个问题']
|
||||
let scrollFrame: number | null = null
|
||||
|
||||
const userAvatarUrl = computed(() => userStore.user?.avatarUrl || store.userProfile?.avatarUrl || '')
|
||||
const userAvatarInitial = computed(() => (userStore.user?.nickname || store.userProfile?.nickname || '我').trim().slice(0, 1))
|
||||
const avatarStatus = computed(() => {
|
||||
const status = avatar.value?.status || 'active'
|
||||
if (status === 'inactive') return { tone: 'inactive', label: '当前已停用' }
|
||||
if (status === 'training') return { tone: 'training', label: '知识训练中' }
|
||||
return { tone: 'active', label: '在线,随时可以和我聊聊' }
|
||||
})
|
||||
|
||||
const sourceLabel = (source?: DisplayMessage['source']) => ({
|
||||
qa: '标准问答对',
|
||||
knowledge: '参考文件知识库',
|
||||
qwen: 'Qwen 智能回答'
|
||||
qwen: '智能回答',
|
||||
public: ''
|
||||
}[source || ''] || '')
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
@@ -82,9 +128,89 @@ const scrollToBottom = async () => {
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||
}
|
||||
|
||||
const scrollDuringStream = () => {
|
||||
if (scrollFrame !== null) return
|
||||
scrollFrame = window.requestAnimationFrame(() => {
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||
scrollFrame = null
|
||||
})
|
||||
}
|
||||
|
||||
const sleep = (delay: number) => new Promise<void>((resolve) => window.setTimeout(resolve, delay))
|
||||
|
||||
const createStreamReply = () => {
|
||||
const reply = reactive<DisplayMessage>({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
characters: []
|
||||
})
|
||||
let attached = false
|
||||
const attach = () => {
|
||||
if (attached) return
|
||||
messages.value.push(reply)
|
||||
attached = true
|
||||
}
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
const queue: string[] = []
|
||||
let draining: Promise<void> | null = null
|
||||
|
||||
const drain = async () => {
|
||||
while (queue.length) {
|
||||
let character = queue.shift() || ''
|
||||
if (character === '\r') continue
|
||||
if (/\s/.test(character) && character !== '\n') character = ' '
|
||||
const previous = reply.characters?.[reply.characters.length - 1] || ''
|
||||
if (character === ' ' && (!previous || previous === ' ' || previous === '\n')) continue
|
||||
if (character === '\n') {
|
||||
while (reply.characters?.[reply.characters.length - 1] === ' ') {
|
||||
reply.characters.pop()
|
||||
reply.content = reply.content.slice(0, -1)
|
||||
}
|
||||
if (!reply.characters?.length || reply.characters[reply.characters.length - 1] === '\n') continue
|
||||
}
|
||||
reply.content += character
|
||||
reply.characters?.push(character)
|
||||
scrollDuringStream()
|
||||
await sleep(/[,。!?;:\n]/.test(character) ? 140 : 28)
|
||||
}
|
||||
draining = null
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
append: (content: string) => {
|
||||
if (!content) return
|
||||
attach()
|
||||
if (reduceMotion) {
|
||||
reply.content += content
|
||||
void scrollToBottom()
|
||||
return
|
||||
}
|
||||
queue.push(...Array.from(content))
|
||||
if (!draining) draining = drain()
|
||||
},
|
||||
finish: async () => {
|
||||
if (draining) await draining
|
||||
while (reply.characters?.length && /[\s\n]/.test(reply.characters[reply.characters.length - 1])) {
|
||||
reply.characters.pop()
|
||||
reply.content = reply.content.slice(0, -1)
|
||||
}
|
||||
if (reduceMotion) reply.characters = []
|
||||
if (attached) await scrollToBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadAvatar = async () => {
|
||||
avatar.value = store.avatars.find((item) => String(item.id) === avatarId)
|
||||
if (!avatar.value) avatar.value = await getAvatarDetail(avatarId)
|
||||
if (isPublic) {
|
||||
avatar.value = await getPublicAvatar(shareToken)
|
||||
avatarId.value = String(avatar.value?.id || '')
|
||||
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
||||
return
|
||||
}
|
||||
avatar.value = store.avatars.find((item) => String(item.id) === avatarId.value)
|
||||
if (!avatar.value) avatar.value = await getAvatarDetail(avatarId.value)
|
||||
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
||||
}
|
||||
|
||||
const sendMessage = async (value: string) => {
|
||||
@@ -95,17 +221,35 @@ const sendMessage = async (value: string) => {
|
||||
errorMessage.value = ''
|
||||
messages.value.push({ role: 'user', content: question })
|
||||
sending.value = true
|
||||
thinking.value = true
|
||||
await scrollToBottom()
|
||||
try {
|
||||
const response = await sendAvatarChat(avatarId, {
|
||||
const payload = {
|
||||
message: question,
|
||||
history: messages.value.slice(-10).map(({ role, content }) => ({ role, content }))
|
||||
})
|
||||
messages.value.push({ role: 'assistant', content: response.answer, source: response.source, references: response.references })
|
||||
await scrollToBottom()
|
||||
}
|
||||
const streamed = createStreamReply()
|
||||
const handlers = {
|
||||
onMeta: (meta: Pick<DisplayMessage, 'source' | 'references'>) => {
|
||||
streamed.reply.source = meta.source
|
||||
streamed.reply.references = meta.references
|
||||
},
|
||||
onDelta: (content: string) => {
|
||||
thinking.value = false
|
||||
streamed.append(content)
|
||||
}
|
||||
}
|
||||
if (isPublic) {
|
||||
await streamPublicAvatarChat(shareToken, payload, handlers)
|
||||
} else {
|
||||
await streamAvatarChat(avatarId.value, payload, handlers)
|
||||
}
|
||||
thinking.value = false
|
||||
await streamed.finish()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error?.message || '暂时无法回答,请稍后重试'
|
||||
} finally {
|
||||
thinking.value = false
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
@@ -121,32 +265,37 @@ onMounted(loadAvatar)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-page { min-height: 100dvh; display: flex; flex-direction: column; background: #FFF8F1; color: #3B2417; }
|
||||
.chat-header { flex: 0 0 auto; display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: white; background: linear-gradient(135deg, #F97316, #FB923C); box-shadow: 0 5px 18px rgba(249, 115, 22, .2); }
|
||||
.chat-page { height: 100dvh; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #FFF8F1; color: #3B2417; }
|
||||
.chat-header { position: sticky; top: 0; z-index: 10; flex: 0 0 auto; display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: white; background: linear-gradient(135deg, #F97316, #FB923C); box-shadow: 0 5px 18px rgba(249, 115, 22, .2); }
|
||||
.back-btn, .settings-btn { border: 0; background: transparent; color: white; cursor: pointer; font-size: 25px; padding: 2px 6px; }
|
||||
.settings-btn { font-size: 20px; margin-left: auto; }
|
||||
.avatar-heading { display: flex; align-items: center; gap: 10px; }
|
||||
.avatar-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 13px; background: rgba(255,255,255,.24); font-size: 23px; }
|
||||
.avatar-mark { width: 38px; height: 38px; display: grid; place-items: center; overflow: hidden; border-radius: 13px; background: rgba(255,255,255,.24); font-size: 23px; }.avatar-mark img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar-heading h1 { margin: 0; font-size: 17px; }
|
||||
.online-state { display: block; margin-top: 3px; font-size: 11px; opacity: .86; }
|
||||
.message-list { flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; }
|
||||
.online-state { display: flex; align-items: center; gap: 4px; margin-top: 3px; font-size: 11px; opacity: .9; }.online-state i { width: 7px; height: 7px; border-radius: 50%; background: #86EFAC; box-shadow: 0 0 0 2px rgba(255,255,255,.22); }.online-state.training i { background: #FDE68A; }.online-state.inactive i { background: #FDA4AF; }
|
||||
.message-list { min-height: 0; flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.welcome-card { padding: 28px 20px; text-align: center; background: rgba(255,255,255,.72); border: 1px solid #FFE1C2; border-radius: 22px; box-shadow: 0 10px 28px rgba(181, 99, 35, .08); }
|
||||
.welcome-icon { color: #F97316; font-size: 30px; }
|
||||
.welcome-card h2 { margin: 9px 0 8px; font-size: 20px; }
|
||||
.welcome-card p { margin: 0 auto 20px; max-width: 420px; color: #8B6B58; line-height: 1.6; font-size: 14px; }
|
||||
.starter-list { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
|
||||
.starter-list button { border: 1px solid #FFD1A8; color: #C15F18; background: #FFF4E8; border-radius: 20px; padding: 8px 12px; cursor: pointer; }
|
||||
.message-row { display: flex; gap: 9px; margin: 18px 0; align-items: flex-end; }
|
||||
.welcome-avatar { width: 64px; height: 64px; display: grid; place-items: center; margin: 0 auto 14px; overflow: hidden; border: 3px solid #fff; border-radius: 50%; background: #FFE4C7; box-shadow: 0 7px 16px rgba(181, 99, 35, .18); font-size: 32px; }.welcome-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.welcome-card h2 { margin: 0 0 8px; font-size: 20px; }.welcome-description { max-width: 340px; margin: 0 auto; color: #8B6B58; font-size: 14px; line-height: 1.65; }
|
||||
.message-row { display: flex; gap: 10px; margin: 18px 0; align-items: flex-start; }
|
||||
.message-row.user { justify-content: flex-end; }
|
||||
.message-avatar { flex: 0 0 auto; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 10px; background: #FFE4C7; }
|
||||
.message-avatar { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; border: 2px solid rgba(255,255,255,.9); border-radius: 14px; background: #FFE4C7; box-shadow: 0 3px 10px rgba(96, 52, 21, .12); font-size: 16px; }.message-avatar img { width: 100%; height: 100%; object-fit: cover; }.user-message-face { color: #fff; background: #D97706; }
|
||||
.message-column { max-width: min(78%, 560px); }
|
||||
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 16px 16px 16px 4px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
|
||||
.user .message-bubble { color: white; border-radius: 16px 16px 4px 16px; background: #F97316; }
|
||||
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 4px 16px 16px 16px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
|
||||
.message-bubble.streaming::after { content: ''; display: inline-block; width: 2px; height: 1.05em; margin-left: 3px; vertical-align: -0.16em; background: currentColor; animation: type-cursor .75s step-end infinite; }
|
||||
.typing-character { display: inline-block; animation: character-in .24s cubic-bezier(.2,.72,.25,1) both; }.typing-character.newline { display: block; height: 0; }
|
||||
.typing-character.markdown-bold { font-weight: 750; color: #2F1A10; }
|
||||
.typing-character.markdown-italic { font-style: italic; }
|
||||
.typing-character.markdown-code { margin: 0 1px; padding: 0 3px; border-radius: 4px; color: #9A3412; background: #FFF0E3; font-family: "SFMono-Regular", Consolas, monospace; font-size: .92em; }
|
||||
.typing-character.markdown-heading { font-weight: 750; font-size: 1.08em; }
|
||||
.user .message-bubble { color: white; border-radius: 16px 4px 16px 16px; background: #F97316; }
|
||||
.message-source { margin: 5px 4px 0; font-size: 11px; color: #A77A5B; }
|
||||
.typing { display: flex; gap: 4px; padding: 14px 16px; }
|
||||
.typing i { width: 5px; height: 5px; border-radius: 50%; background: #F97316; animation: blink 1s infinite alternate; }
|
||||
.typing i:nth-child(2) { animation-delay: .2s; }.typing i:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes blink { from { opacity: .25; } to { opacity: 1; } }
|
||||
@keyframes type-cursor { 50% { opacity: 0; } }
|
||||
@keyframes character-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.chat-error { margin: 4px auto; color: #B42318; font-size: 13px; }.chat-error button { border: 0; background: none; color: #C15F18; cursor: pointer; text-decoration: underline; }
|
||||
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 10px; padding: 12px max(18px, calc((100vw - 760px) / 2 + 18px)); background: rgba(255,255,255,.92); border-top: 1px solid #F4DCC7; backdrop-filter: blur(12px); }
|
||||
.composer textarea { flex: 1; resize: none; min-height: 22px; max-height: 100px; padding: 11px 13px; border: 1px solid #EED8C5; border-radius: 13px; font: inherit; color: #3B2417; outline: none; }.composer textarea:focus { border-color: #F97316; }
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">形象微调编辑</h1>
|
||||
<button class="save-btn" :disabled="loading || saving" @click="saveChanges">
|
||||
<h1 class="page-title">分身微调</h1>
|
||||
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</header>
|
||||
@@ -14,13 +14,15 @@
|
||||
|
||||
<!-- 头像预览 -->
|
||||
<section class="photo-section">
|
||||
<div class="photo-container">
|
||||
<label class="photo-container" for="avatar-photo-input">
|
||||
<div class="photo-preview">
|
||||
<img v-if="formData.photoUrl" :src="formData.photoUrl" alt="" class="photo-image" referrerpolicy="no-referrer" />
|
||||
<div v-else class="photo-placeholder">🤖</div>
|
||||
<span class="photo-edit-mark">更换</span>
|
||||
</div>
|
||||
<span class="photo-hint">可直接修改下方头像链接</span>
|
||||
</div>
|
||||
<span class="photo-hint">{{ uploadingPhoto ? '头像上传中...' : '点击头像上传新图片' }}</span>
|
||||
</label>
|
||||
<input id="avatar-photo-input" class="photo-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" :disabled="uploadingPhoto" @change="selectPhoto" />
|
||||
</section>
|
||||
|
||||
<!-- 基本信息表单 -->
|
||||
@@ -56,12 +58,23 @@
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">头像链接</label>
|
||||
<input
|
||||
v-model="formData.photoUrl"
|
||||
class="form-input"
|
||||
placeholder="请输入头像图片 URL"
|
||||
/>
|
||||
<label class="form-label">职业</label>
|
||||
<input v-model="formData.profession" class="form-input" placeholder="例如:医生、律师、产品经理" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">职位</label>
|
||||
<input v-model="formData.position" class="form-input" placeholder="例如:主任医师、部门负责人" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">单位</label>
|
||||
<input v-model="formData.organization" class="form-input" placeholder="请输入所在单位" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">单位地址</label>
|
||||
<input v-model="formData.organizationAddress" class="form-input" placeholder="请输入单位详细地址" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
@@ -152,7 +165,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar } from '@/api'
|
||||
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
||||
|
||||
@@ -174,6 +187,10 @@ const formData = reactive({
|
||||
humor: 30,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: '',
|
||||
profession: '',
|
||||
position: '',
|
||||
organization: '',
|
||||
organizationAddress: '',
|
||||
autoReply: true
|
||||
})
|
||||
|
||||
@@ -185,6 +202,7 @@ const responseLengths = [
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const uploadingPhoto = ref(false)
|
||||
const deleting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
@@ -201,6 +219,23 @@ const loadAvatar = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectPhoto = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || uploadingPhoto.value) return
|
||||
uploadingPhoto.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const result = await uploadAvatarPhoto(avatarId, file)
|
||||
formData.photoUrl = result.photoUrl
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '头像上传失败,请重试'
|
||||
} finally {
|
||||
uploadingPhoto.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存修改
|
||||
const saveChanges = async () => {
|
||||
if (loading.value || saving.value) return
|
||||
@@ -323,6 +358,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
@@ -331,6 +367,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-image {
|
||||
@@ -349,6 +386,19 @@ onMounted(async () => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.photo-input { display: none; }
|
||||
.photo-edit-mark {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 5px 0 7px;
|
||||
color: white;
|
||||
background: rgba(47, 26, 16, .68);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 表单区域 */
|
||||
.form-section {
|
||||
padding: 20px;
|
||||
|
||||
@@ -44,22 +44,21 @@
|
||||
|
||||
<div v-if="avatars.length" class="avatar-list">
|
||||
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
||||
<div class="avatar-photo">
|
||||
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
|
||||
<div v-else class="avatar-placeholder">{{ a.emoji || '🤖' }}</div>
|
||||
</div>
|
||||
<div class="avatar-details">
|
||||
<h2 class="avatar-name">{{ a.displayName || a.name }}</h2>
|
||||
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
|
||||
<div class="avatar-status">
|
||||
<span class="status-dot" :class="a.status"></span>
|
||||
<span class="status-text">{{ statusText(a.status) }}</span>
|
||||
<div class="avatar-card-main">
|
||||
<div class="avatar-photo">
|
||||
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
|
||||
<div v-else class="avatar-placeholder">{{ a.emoji || '🤖' }}</div>
|
||||
</div>
|
||||
<div class="avatar-details">
|
||||
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
|
||||
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<button class="chat-link" @click="goToChat(a.id)">对话</button>
|
||||
<button class="chat-link" @click="goToChat(a.id)"><span>💬</span> 对话</button>
|
||||
<button class="share-link" @click="shareAvatar(a)"><span>↗</span> 分享</button>
|
||||
<button class="edit-link" @click="goToEdit(a.id)">编辑</button>
|
||||
<button class="del-link" @click="askDelete(a)">删除</button>
|
||||
<button class="del-link" @click="askDelete(a)" aria-label="删除分身">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,6 +70,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="shareToast" class="share-toast">{{ shareToast }}</p>
|
||||
|
||||
<!-- 分身工具入口 -->
|
||||
<section class="tools-section">
|
||||
<h3 class="section-title">分身工具</h3>
|
||||
@@ -161,6 +162,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { createAvatarShareLink } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
@@ -177,6 +179,7 @@ const avatars = computed(() => avatarStore.avatars)
|
||||
const showDelete = ref(false)
|
||||
const pendingDelete = ref<any>(null)
|
||||
const deleting = ref(false)
|
||||
const shareToast = ref('')
|
||||
|
||||
const activities = ref<Array<{ id: string; type: string; text: string; createdAt: string }>>([
|
||||
{ id: '1', type: 'create', text: '数字分身创建成功', createdAt: new Date(Date.now() - 86400000).toISOString() },
|
||||
@@ -268,6 +271,38 @@ const goToChat = (id: string) => {
|
||||
router.push(`/avatar/chat/${id}`)
|
||||
}
|
||||
|
||||
const showShareToast = (message: string) => {
|
||||
shareToast.value = message
|
||||
window.setTimeout(() => { shareToast.value = '' }, 2400)
|
||||
}
|
||||
|
||||
const copyShareLink = async (link: string) => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(link)
|
||||
showShareToast('公开对话链接已复制')
|
||||
return
|
||||
}
|
||||
window.prompt('复制公开对话链接', link)
|
||||
}
|
||||
|
||||
const shareAvatar = async (avatar: any) => {
|
||||
try {
|
||||
const result: any = await createAvatarShareLink(avatar.id)
|
||||
const token = result?.shareToken
|
||||
if (!token) throw new Error('未能生成分享链接')
|
||||
const link = `${window.location.origin}${window.location.pathname}#/share/${token}`
|
||||
const title = `${avatar.displayName || avatar.name},和我聊聊`
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title, text: avatar.description || '点击和我聊聊', url: link })
|
||||
showShareToast('已唤起分享')
|
||||
return
|
||||
}
|
||||
await copyShareLink(link)
|
||||
} catch (error: any) {
|
||||
if (error?.name !== 'AbortError') showShareToast(error?.message || '分享链接生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
const goToAvatarCard = () => {
|
||||
router.push('/avatar/card')
|
||||
}
|
||||
@@ -481,22 +516,21 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #F4E5D9;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 22px rgba(112, 62, 22, .07);
|
||||
}
|
||||
.avatar-card-main { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.avatar-photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: #F3F4F6;
|
||||
background: #FFF0E6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -516,9 +550,12 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.avatar-name-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
|
||||
.avatar-name {
|
||||
font-size: 16px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: #18191C;
|
||||
@@ -527,16 +564,19 @@ onMounted(() => {
|
||||
.avatar-desc {
|
||||
font-size: 13px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 8px;
|
||||
margin: 5px 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar-status {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
color: #75809A;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -557,30 +597,28 @@ onMounted(() => {
|
||||
background: #F59E0B;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.chat-link {
|
||||
padding: 7px 14px;
|
||||
background: #FFF0E6;
|
||||
color: #F97316;
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-link span, .share-link span { margin-right: 3px; }
|
||||
.share-link { flex: 1; padding: 10px 8px; border: 1px solid #FFD5AF; border-radius: 10px; color: #C15F18; background: #FFF8F1; font-size: 13px; cursor: pointer; }
|
||||
|
||||
.edit-link {
|
||||
padding: 7px 14px;
|
||||
padding: 10px 10px;
|
||||
background: #F3F4F6;
|
||||
color: #6B7280;
|
||||
border: none;
|
||||
@@ -595,9 +633,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.del-link {
|
||||
padding: 7px 14px;
|
||||
background: #FEF2F2;
|
||||
color: #EF4444;
|
||||
padding: 10px 2px;
|
||||
background: transparent;
|
||||
color: #B6BCC8;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
@@ -605,6 +643,8 @@ onMounted(() => {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.share-toast { position: fixed; left: 50%; bottom: 92px; z-index: 300; max-width: calc(100vw - 48px); transform: translateX(-50%); padding: 10px 14px; border-radius: 10px; color: white; background: rgba(39, 32, 28, .88); font-size: 13px; text-align: center; }
|
||||
|
||||
.del-link:hover {
|
||||
background: #FEE2E2;
|
||||
}
|
||||
|
||||
@@ -31,22 +31,21 @@
|
||||
<p v-if="uploadError" class="error-text">{{ uploadError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table class="knowledge-table">
|
||||
<thead><tr><th>文档</th><th>类型</th><th>大小</th><th>状态</th><th>上传时间</th><th>操作</th></tr></thead>
|
||||
<tbody v-if="docs.length">
|
||||
<tr v-for="doc in docs" :key="doc.id">
|
||||
<td><div class="file-cell"><span class="doc-icon">{{ fileEmoji(doc.fileType) }}</span><strong>{{ doc.filename }}</strong></div></td>
|
||||
<td>{{ doc.fileType.toUpperCase() }}</td>
|
||||
<td>{{ formatSize(doc.fileSize) }}</td>
|
||||
<td><span class="status-pill" :class="{ pending: !doc.vectorized }">{{ doc.vectorized ? `已向量化 · ${doc.chunkCount || 0} 段` : '处理中' }}</span></td>
|
||||
<td>{{ formatDate(doc.createdAt) }}</td>
|
||||
<td><button class="table-delete" @click="removeDoc(doc.id)">删除</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!docs.length" class="table-empty">📂 暂无文档,先上传一个知识文件</div>
|
||||
<div v-if="docs.length" class="mobile-card-list">
|
||||
<article v-for="doc in docs" :key="doc.id" class="knowledge-card">
|
||||
<div class="card-icon">{{ fileEmoji(doc.fileType) }}</div>
|
||||
<div class="card-content">
|
||||
<div class="card-title-row">
|
||||
<strong>{{ doc.filename }}</strong>
|
||||
<span class="status-pill" :class="{ pending: !doc.vectorized }">{{ doc.vectorized ? '已入库' : '处理中' }}</span>
|
||||
</div>
|
||||
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
|
||||
<p class="card-detail">{{ doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引' }}</p>
|
||||
</div>
|
||||
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="card-empty">📂 暂无文档,先上传一个知识文件</div>
|
||||
|
||||
<section class="search-section">
|
||||
<h3 class="section-title">向量检索测试</h3>
|
||||
@@ -57,21 +56,22 @@
|
||||
</section>
|
||||
|
||||
<section v-else class="knowledge-panel">
|
||||
<div class="panel-heading"><div><h3 class="section-title">标准问答对</h3><p>命中后优先使用标准答案,不调用 Qwen。</p></div><button class="add-qa-btn" @click="goAddQa">+ 添加</button></div>
|
||||
<div class="table-scroll">
|
||||
<table class="knowledge-table qa-table">
|
||||
<thead><tr><th>问题</th><th>标准答案</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead>
|
||||
<tbody v-if="qaPairs.length">
|
||||
<tr v-for="qa in qaPairs" :key="qa.id" :class="{ 'qa-disabled': qa.enabled === false }">
|
||||
<td class="question-cell">{{ qa.question }}</td><td class="answer-cell">{{ qa.answer }}</td>
|
||||
<td><label class="switch" :title="qa.enabled === false ? '已停用' : '已启用'"><input type="checkbox" :checked="qa.enabled !== false" @change="toggleQa(qa, $event)" /><span class="slider"></span></label></td>
|
||||
<td>{{ formatDate(qa.updatedAt || qa.createdAt) }}</td>
|
||||
<td><div class="row-actions"><button class="qa-edit" @click="goEditQa(qa)">编辑</button><button class="qa-del" @click="removeQa(qa.id)">删除</button></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!qaPairs.length" class="table-empty">💡 暂无问答对,添加后分身会优先按此作答</div>
|
||||
<div class="panel-heading"><div><h3 class="section-title">标准问答对</h3><p>相似问法命中后优先使用标准答案。</p></div><button class="add-qa-btn" @click="goAddQa">+ 添加</button></div>
|
||||
<div v-if="qaPairs.length" class="mobile-card-list qa-card-list">
|
||||
<article v-for="qa in qaPairs" :key="qa.id" class="knowledge-card qa-card" :class="{ 'qa-disabled': qa.enabled === false }">
|
||||
<div class="card-content">
|
||||
<div class="qa-card-head">
|
||||
<span class="qa-label">标准问题</span>
|
||||
<label class="switch" :title="qa.enabled === false ? '已停用' : '已启用'"><input type="checkbox" :checked="qa.enabled !== false" @change="toggleQa(qa, $event)" /><span class="slider"></span></label>
|
||||
</div>
|
||||
<strong class="qa-question">{{ qa.question }}</strong>
|
||||
<p class="qa-answer">{{ qa.answer }}</p>
|
||||
<p class="card-meta">更新于 {{ formatDate(qa.updatedAt || qa.createdAt) }}</p>
|
||||
<div class="qa-card-actions"><button class="qa-edit" @click="goEditQa(qa)">编辑</button><button class="qa-del" @click="removeQa(qa.id)">删除</button></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="card-empty">💡 暂无问答对,添加后分身会优先按此作答</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
@@ -258,20 +258,23 @@ onMounted(async () => {
|
||||
.tab-btn b { margin-left: 4px; font-size: 12px; color: #B0896C; }
|
||||
.tab-btn.active { background: white; border-color: #F97316; color: #F97316; font-weight: 700; }
|
||||
.tab-btn.active b { color: #F97316; }
|
||||
.knowledge-panel { padding: 0 20px; }
|
||||
.knowledge-panel { min-width: 0; padding: 0 16px; }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 0 12px; }
|
||||
.panel-heading p { margin: -5px 0 0; color: #9398AE; font-size: 12px; }
|
||||
.table-scroll { max-height: 420px; overflow: auto; border: 1px solid #F1E1D3; border-radius: 14px; background: white; }
|
||||
.knowledge-table { width: 100%; min-width: 720px; border-collapse: collapse; text-align: left; font-size: 13px; }
|
||||
.knowledge-table th { position: sticky; top: 0; z-index: 1; padding: 12px 14px; background: #FFF8F1; color: #8B6B58; font-weight: 600; white-space: nowrap; }
|
||||
.knowledge-table td { padding: 13px 14px; border-top: 1px solid #F5EEE7; color: #6B7280; vertical-align: middle; }
|
||||
.knowledge-table tr.qa-disabled { opacity: .55; }
|
||||
.file-cell { display: flex; align-items: center; gap: 9px; min-width: 190px; color: #27201C; }.file-cell strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status-pill { display: inline-flex; padding: 4px 8px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 11px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
|
||||
.table-delete { border: 0; color: #EF4444; background: #FEF2F2; border-radius: 7px; padding: 6px 10px; cursor: pointer; }
|
||||
.table-empty { padding: 48px 20px; color: #9398AE; text-align: center; }
|
||||
.question-cell { min-width: 190px; max-width: 280px; color: #27201C !important; font-weight: 600; }.answer-cell { min-width: 240px; max-width: 360px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.row-actions { display: flex; gap: 6px; white-space: nowrap; }
|
||||
.mobile-card-list { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; min-width: 0; gap: 10px; }
|
||||
.knowledge-card { display: flex; align-items: center; width: 100%; min-width: 0; box-sizing: border-box; gap: 11px; padding: 14px; background: #fff; border: 1px solid #F1E1D3; border-radius: 16px; box-shadow: 0 5px 16px rgba(112, 62, 22, .04); }
|
||||
.card-icon { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; border-radius: 13px; background: #FFF3E6; font-size: 22px; }
|
||||
.card-content { min-width: 0; flex: 1; overflow: hidden; }
|
||||
.card-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
|
||||
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
|
||||
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
|
||||
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
|
||||
.qa-card { align-items: stretch; }.qa-card.qa-disabled { opacity: .58; }
|
||||
.qa-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }.qa-label { color: #C15F18; font-size: 11px; font-weight: 700; }
|
||||
.qa-question { display: block; color: #27201C; font-size: 15px; line-height: 1.5; }.qa-answer { display: -webkit-box; margin: 7px 0 0; overflow: hidden; color: #6B7280; font-size: 13px; line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
||||
.qa-card-actions { display: flex; gap: 8px; margin-top: 11px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
@@ -304,7 +307,7 @@ onMounted(async () => {
|
||||
|
||||
/* 上传区 */
|
||||
.upload-section {
|
||||
padding: 16px 20px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
@@ -369,7 +372,7 @@ onMounted(async () => {
|
||||
.docs-section,
|
||||
.qa-section,
|
||||
.search-section {
|
||||
padding: 0 20px 16px;
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -455,6 +458,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
@@ -483,6 +487,17 @@ onMounted(async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.knowledge-panel { padding: 0 12px; }
|
||||
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
|
||||
.card-content { grid-column: 2; }
|
||||
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; }
|
||||
.card-title-row { align-items: flex-start; flex-wrap: wrap; gap: 5px 7px; }
|
||||
.status-pill { order: 2; }
|
||||
.search-bar { gap: 8px; }
|
||||
.search-btn { width: 68px; }
|
||||
}
|
||||
|
||||
.search-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
Reference in New Issue
Block a user