feat(avatar): optimize embedded H5 management flow

This commit is contained in:
stefanfeng
2026-08-27 09:27:17 +08:00
parent c37294be17
commit e2b02ddf30
10 changed files with 307 additions and 63 deletions
+1 -1
View File
@@ -188,7 +188,7 @@ class KnowledgeDoc(Base):
file_type = Column(String, default="") # pdf | doc | docx | xlsx file_type = Column(String, default="") # pdf | doc | docx | xlsx
file_size = Column(Integer, default=0) file_size = Column(Integer, default=0)
file_url = Column(String, default="") file_url = Column(String, default="")
status = Column(String, default="uploaded") # uploaded | parsing | ready status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
vectorized = Column(Boolean, default=False) # 是否已向量化 vectorized = Column(Boolean, default=False) # 是否已向量化
embedding_model = Column(String, default="") # 向量模型标识 embedding_model = Column(String, default="") # 向量模型标识
chunk_count = Column(Integer, default=0) # 切片数量 chunk_count = Column(Integer, default=0) # 切片数量
+39 -19
View File
@@ -1,5 +1,6 @@
import os import os
import json import json
import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -13,6 +14,7 @@ from responses import ok, fail
import embeddings import embeddings
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))) UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
@@ -69,6 +71,15 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
.order_by(KnowledgeDoc.created_at.desc()) .order_by(KnowledgeDoc.created_at.desc())
.all() .all()
) )
# Older synchronous uploads could be interrupted after persisting "parsing".
# New uploads are committed only after indexing finishes, so these rows are stale.
stale_docs = [doc for doc in docs if doc.status == "parsing"]
if stale_docs:
for doc in stale_docs:
doc.status = "failed"
doc.vectorized = False
doc.chunk_count = 0
db.commit()
return ok([_doc_payload(d) for d in docs]) return ok([_doc_payload(d) for d in docs])
@@ -88,6 +99,7 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(content) f.write(content)
doc = KnowledgeDoc( doc = KnowledgeDoc(
id=uuid.uuid4().hex,
avatar_id=avatar_id, avatar_id=avatar_id,
filename=file.filename, filename=file.filename,
file_type=ext.lstrip("."), file_type=ext.lstrip("."),
@@ -95,39 +107,47 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
file_url=f"/api/files/{avatar_id}/{stored}", file_url=f"/api/files/{avatar_id}/{stored}",
status="parsing", status="parsing",
) )
db.add(doc)
db.commit()
db.refresh(doc)
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片 # Complete extraction and embedding before the first database commit so a
# process restart cannot leave a permanent "parsing" row behind.
try: try:
text = embeddings.extract_text(path, ext) text = embeddings.extract_text(path, ext)
chunks = embeddings.chunk_text(text) chunks = embeddings.chunk_text(text)
if chunks: if not chunks:
raise ValueError("文档没有可建立索引的文字内容")
vectors = embeddings.embed(chunks) vectors = embeddings.embed(chunks)
for i, (c, v) in enumerate(zip(chunks, vectors)): if len(vectors) != len(chunks):
db.add( raise ValueError("向量服务返回数量与文档分段不一致")
KnowledgeChunk(
doc_id=doc.id,
avatar_id=avatar_id,
content=c,
vector=json.dumps(v),
chunk_index=i,
embedding_model=embeddings.MODEL,
)
)
doc.vectorized = True doc.vectorized = True
doc.embedding_model = embeddings.MODEL doc.embedding_model = embeddings.MODEL
doc.chunk_count = len(chunks) doc.chunk_count = len(chunks)
doc.vectorized_at = datetime.now(timezone.utc) doc.vectorized_at = datetime.now(timezone.utc)
doc.status = "ready" doc.status = "ready"
db.add(doc)
for i, (chunk, vector) in enumerate(zip(chunks, vectors)):
db.add(
KnowledgeChunk(
doc_id=doc.id,
avatar_id=avatar_id,
content=chunk,
vector=json.dumps(vector),
chunk_index=i,
embedding_model=embeddings.MODEL,
)
)
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
except Exception as e: except Exception as exc:
print("vectorize failed:", e) db.rollback()
doc.status = "ready" # 上传成功但向量化失败,仍可展示 doc.status = "failed"
doc.vectorized = False
doc.embedding_model = ""
doc.chunk_count = 0
doc.vectorized_at = None
db.add(doc)
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
logger.exception("knowledge vectorization failed for %s: %s", doc.id, exc)
return ok(_doc_payload(doc)) return ok(_doc_payload(doc))
@@ -2,9 +2,17 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app
from models import KnowledgeChunk, KnowledgeDoc
from routers.knowledge import _doc_payload from routers.knowledge import _doc_payload
client = TestClient(app)
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path): def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
avatar_id = "avatar-1" avatar_id = "avatar-1"
stored_name = "knowledge.md" stored_name = "knowledge.md"
@@ -21,3 +29,66 @@ def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
assert _doc_payload(doc)["filePresent"] is False assert _doc_payload(doc)["filePresent"] is False
stored_file.write_text("knowledge", encoding="utf-8") stored_file.write_text("knowledge", encoding="utf-8")
assert _doc_payload(doc)["filePresent"] is True assert _doc_payload(doc)["filePresent"] is True
def test_upload_marks_vectorization_failure_instead_of_staying_processing(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.embeddings.embed", side_effect=RuntimeError("provider unavailable")),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
)
payload = response.json()["data"]
assert payload["status"] == "failed"
assert payload["vectorized"] is False
assert payload["chunkCount"] == 0
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "failed"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
db.delete(stored)
db.commit()
finally:
db.close()
def test_markdown_upload_commits_ready_document_and_chunks_together(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.embeddings.embed", return_value=[[1.0, 0.0]]),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
)
payload = response.json()["data"]
assert payload["status"] == "ready"
assert payload["vectorized"] is True
assert payload["chunkCount"] == 1
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "ready"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 1
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
db.delete(stored)
db.commit()
finally:
db.close()
+13 -4
View File
@@ -10,6 +10,7 @@ import {
type SmsLoginResult, type SmsLoginResult,
type UserProfile type UserProfile
} from '@/api' } from '@/api'
import { clearHuihuiEmbeddedMode, markHuihuiEmbeddedMode } from '@/utils/embed-mode'
const TOKEN_KEY = 'hh_app_token' const TOKEN_KEY = 'hh_app_token'
const USER_KEY = 'hh_app_user' const USER_KEY = 'hh_app_user'
@@ -70,16 +71,23 @@ export const useUserStore = defineStore('smsuser', () => {
// 短信登录 // 短信登录
const login = async (phone: string, code: string) => { const login = async (phone: string, code: string) => {
return acceptLogin(await loginBySms(phone, code)) const result = await loginBySms(phone, code)
clearHuihuiEmbeddedMode()
return acceptLogin(result)
} }
// 账号密码登录 // 账号密码登录
const loginByPwd = async (account: string, password: string) => { const loginByPwd = async (account: string, password: string) => {
return acceptLogin(await loginByPassword(account, password)) const result = await loginByPassword(account, password)
clearHuihuiEmbeddedMode()
return acceptLogin(result)
} }
const loginByToken = async (huihuiToken: string) => const loginByToken = async (huihuiToken: string) => {
acceptLogin(await loginByHuihuiToken(huihuiToken)) const result = await loginByHuihuiToken(huihuiToken)
markHuihuiEmbeddedMode()
return acceptLogin(result)
}
// 退出 // 退出
const logout = async () => { const logout = async () => {
@@ -88,6 +96,7 @@ export const useUserStore = defineStore('smsuser', () => {
} catch { } catch {
/* 忽略网络错误,本地清除即可 */ /* 忽略网络错误,本地清除即可 */
} }
clearHuihuiEmbeddedMode()
clearSession() clearSession()
} }
@@ -0,0 +1,13 @@
const HUIHUI_EMBED_MODE_KEY = 'hh_huihui_embed_mode'
export function markHuihuiEmbeddedMode(): void {
sessionStorage.setItem(HUIHUI_EMBED_MODE_KEY, '1')
}
export function clearHuihuiEmbeddedMode(): void {
sessionStorage.removeItem(HUIHUI_EMBED_MODE_KEY)
}
export function isHuihuiEmbeddedMode(): boolean {
return sessionStorage.getItem(HUIHUI_EMBED_MODE_KEY) === '1'
}
@@ -1,6 +1,6 @@
<template> <template>
<div class="authorization-page"> <div class="authorization-page" :class="{ embedded: isEmbedded }">
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack"> <button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m15 18-6-6 6-6" /> <path d="m15 18-6-6 6-6" />
@@ -95,6 +95,9 @@
</main> </main>
<footer v-if="activeAvatarId" class="save-area"> <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()"> <button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span> <span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
{{ saving ? '保存中...' : '保存授权设置' }} {{ saving ? '保存中...' : '保存授权设置' }}
@@ -119,6 +122,7 @@ import {
} from '@/api' } from '@/api'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js' import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
type PermissionState = Record<AvatarPermission, boolean> type PermissionState = Record<AvatarPermission, boolean>
@@ -126,6 +130,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars)) const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
const isEmbedded = isHuihuiEmbeddedMode()
const permissionItems: Array<{ const permissionItems: Array<{
key: AvatarPermission key: AvatarPermission
@@ -394,6 +399,10 @@ svg {
padding: 0 20px; padding: 0 20px;
} }
.authorization-page.embedded .page-content {
padding-top: 16px;
}
.permission-intro { .permission-intro {
min-height: 96px; min-height: 96px;
padding: 15px 16px 14px; padding: 15px 16px 14px;
@@ -599,12 +608,15 @@ svg {
bottom: 0; bottom: 0;
width: min(100%, 390px); width: min(100%, 390px);
padding: 12px 20px calc(20px + env(safe-area-inset-bottom)); 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%); background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
transform: translateX(-50%); transform: translateX(-50%);
} }
.save-button { .save-button {
width: 100%; min-width: 0;
flex: 1;
height: 48px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -620,6 +632,20 @@ svg {
cursor: pointer; 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 { .save-button:disabled {
opacity: .68; opacity: .68;
} }
+56 -15
View File
@@ -1,12 +1,10 @@
<template> <template>
<div class="edit-avatar-page"> <div class="edit-avatar-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">分身微调</h1> <h1 class="page-title">分身微调</h1>
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges"> <span class="header-spacer" aria-hidden="true"></span>
{{ saving ? '保存中...' : '保存' }}
</button>
</header> </header>
<div v-if="loading" class="status-banner">加载中...</div> <div v-if="loading" class="status-banner">加载中...</div>
@@ -159,6 +157,13 @@
{{ deleting ? '删除中...' : '删除数字分身' }} {{ deleting ? '删除中...' : '删除数字分身' }}
</button> </button>
</section> </section>
<footer class="edit-action-bar">
<button class="action-back-btn" type="button" :disabled="saving" @click="goBack">返回</button>
<button class="action-save-btn" type="button" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
{{ saving ? '保存中...' : '保存修改' }}
</button>
</footer>
</div> </div>
</template> </template>
@@ -168,11 +173,13 @@ import { useRoute, useRouter } from 'vue-router'
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api' import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js' import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const avatarId = route.params.id as string const avatarId = route.params.id as string
const isEmbedded = isHuihuiEmbeddedMode()
// 表单数据 // 表单数据
const formData = reactive({ const formData = reactive({
@@ -288,7 +295,7 @@ onMounted(async () => {
.edit-avatar-page { .edit-avatar-page {
min-height: 100vh; min-height: 100vh;
background: #F8F9FA; background: #F8F9FA;
padding-bottom: 40px; padding-bottom: calc(104px + env(safe-area-inset-bottom));
} }
/* 顶部导航 */ /* 顶部导航 */
@@ -331,16 +338,7 @@ onMounted(async () => {
color: #B91C1C; color: #B91C1C;
} }
.save-btn { .header-spacer { width: 40px; }
background: #F97316;
color: white;
border: none;
padding: 8px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
/* 头像上传 */ /* 头像上传 */
.photo-section { .photo-section {
@@ -580,4 +578,47 @@ onMounted(async () => {
background: #EF4444; background: #EF4444;
color: white; color: white;
} }
.edit-action-bar {
position: fixed;
z-index: 30;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 12px;
padding: 12px 20px calc(14px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(229, 231, 235, .9);
background: rgba(248, 249, 250, .96);
box-shadow: 0 -8px 24px rgba(56, 38, 24, .06);
backdrop-filter: blur(12px);
}
.action-back-btn,
.action-save-btn {
height: 48px;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.action-back-btn {
flex: 0 0 104px;
border: 1px solid #E4E0DC;
color: #655E58;
background: #fff;
}
.action-save-btn {
min-width: 0;
flex: 1;
border: 0;
color: #fff;
background: linear-gradient(105deg, #F79A38, #F97316);
box-shadow: 0 8px 18px rgba(249, 115, 22, .18);
}
.action-back-btn:disabled,
.action-save-btn:disabled { opacity: .6; cursor: not-allowed; }
</style> </style>
+35 -6
View File
@@ -1,15 +1,11 @@
<template> <template>
<div class="avatar-manage-page"> <div class="avatar-manage-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<div class="header-left"> <div class="header-left">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">数字分身管理</h1> <h1 class="page-title">数字分身管理</h1>
</div> </div>
<!-- 右上角创建入口 -->
<div class="header-right">
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
</div>
</header> </header>
<!-- 用户资料头(会会登录账号的头像 / 昵称) --> <!-- 用户资料头(会会登录账号的头像 / 昵称) -->
@@ -39,9 +35,14 @@
<!-- 数字分身列表(只放分身相关) --> <!-- 数字分身列表(只放分身相关) -->
<section class="avatar-list-section"> <section class="avatar-list-section">
<div class="section-head"> <div class="section-head">
<div class="section-heading-copy">
<h3 class="section-title">我的数字分身</h3> <h3 class="section-title">我的数字分身</h3>
<span class="count-badge">{{ avatars.length }}</span> <span class="count-badge">{{ avatars.length }}</span>
</div> </div>
<button class="section-create-btn" type="button" @click="goCreate">
<span aria-hidden="true">+</span> 添加分身
</button>
</div>
<div v-if="avatars.length" class="avatar-list"> <div v-if="avatars.length" class="avatar-list">
<div class="avatar-card" v-for="a in avatars" :key="a.id"> <div class="avatar-card" v-for="a in avatars" :key="a.id">
@@ -87,10 +88,12 @@ import { useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { createAvatarShareLink } from '@/api' import { createAvatarShareLink } from '@/api'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter() const router = useRouter()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const userStore = useUserStore() const userStore = useUserStore()
const isEmbedded = isHuihuiEmbeddedMode()
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。 // 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = false const SHOW_POINTS_BALANCE_CARD = false
@@ -354,10 +357,36 @@ onMounted(() => {
.section-head { .section-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
gap: 12px;
margin: 8px 0 12px; margin: 8px 0 12px;
} }
.section-heading-copy {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.section-create-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 8px 12px;
border: 1px solid #FED7B5;
border-radius: 999px;
color: #E9650C;
background: #FFF7ED;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.section-create-btn span { font-size: 17px; line-height: 1; }
.section-create-btn:active { background: #FFEDD5; }
.section-title { .section-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@@ -1,7 +1,7 @@
<template> <template>
<div class="knowledge-page"> <div class="knowledge-page" :class="{ embedded: isEmbedded }">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<div class="header-left"> <div class="header-left">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">知识库管理</h1> <h1 class="page-title">知识库管理</h1>
@@ -23,7 +23,7 @@
<div class="upload-section"> <div class="upload-section">
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop"> <div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
<div class="upload-icon">📥</div> <div class="upload-icon">📥</div>
<p class="upload-title">拖拽文件到此处,或<span class="upload-link">点击上传</span></p> <p class="upload-title"><span class="upload-link">点击上传</span></p>
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p> <p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" /> <input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
</div> </div>
@@ -37,10 +37,10 @@
<div class="card-content"> <div class="card-content">
<div class="card-title-row"> <div class="card-title-row">
<strong>{{ doc.filename }}</strong> <strong>{{ doc.filename }}</strong>
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span> <span class="status-pill" :class="documentState(doc).tone">{{ documentState(doc).label }}</span>
</div> </div>
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p> <p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p> <p class="card-detail">{{ documentState(doc).detail }}</p>
</div> </div>
<button class="card-delete" @click="removeDoc(doc.id)">删除</button> <button class="card-delete" @click="removeDoc(doc.id)">删除</button>
</article> </article>
@@ -82,6 +82,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js' import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
import { import {
getKnowledgeDocs, getKnowledgeDocs,
uploadKnowledgeDoc, uploadKnowledgeDoc,
@@ -95,6 +96,7 @@ import {
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const store = useAvatarStore() const store = useAvatarStore()
const isEmbedded = isHuihuiEmbeddedMode()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars)) const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
const activeTab = ref<'docs' | 'qa'>('docs') const activeTab = ref<'docs' | 'qa'>('docs')
@@ -111,6 +113,19 @@ const searching = ref(false)
const searched = ref(false) const searched = ref(false)
const searchResults = ref<any[]>([]) const searchResults = ref<any[]>([])
const documentState = (doc: any) => {
if (doc.filePresent === false) {
return { tone: 'missing', label: '文件缺失', detail: '原文件不可用,请删除后重新上传' }
}
if (doc.vectorized) {
return { tone: 'ready', label: '已入库', detail: `已切分 ${doc.chunkCount || 0} 段,可用于对话` }
}
if (['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())) {
return { tone: 'pending', label: '处理中', detail: '正在解析并建立知识索引' }
}
return { tone: 'failed', label: '处理失败', detail: '未能建立知识索引,请删除后重新上传' }
}
const loadDocs = async () => { const loadDocs = async () => {
if (!avatarId.value) return if (!avatarId.value) return
try { try {
@@ -283,6 +298,7 @@ onMounted(async () => {
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } .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; } .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; }
.status-pill.missing { color: #B91C1C; background: #FEF2F2; } .status-pill.missing { color: #B91C1C; background: #FEF2F2; }
.status-pill.failed { color: #B91C1C; background: #FEF2F2; }
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; } .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-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; } .card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
+22 -3
View File
@@ -10,10 +10,12 @@
<section class="form-section"> <section class="form-section">
<label class="field-label">问题</label> <label class="field-label">问题</label>
<textarea <textarea
ref="questionInput"
v-model="form.question" v-model="form.question"
class="field-input" class="field-input question-input"
rows="3" rows="1"
placeholder="例如:你们的退款政策是什么?" placeholder="例如:你们的退款政策是什么?"
@input="resizeQuestion"
></textarea> ></textarea>
<label class="field-label">标准答案</label> <label class="field-label">标准答案</label>
@@ -46,7 +48,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, nextTick, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js' import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
@@ -63,6 +65,14 @@ const isEdit = computed(() => !!qaId.value)
const form = reactive({ question: '', answer: '', enabled: true }) const form = reactive({ question: '', answer: '', enabled: true })
const saving = ref(false) const saving = ref(false)
const error = ref('') const error = ref('')
const questionInput = ref<HTMLTextAreaElement | null>(null)
const resizeQuestion = (event?: Event) => {
const element = (event?.target as HTMLTextAreaElement | null) || questionInput.value
if (!element) return
element.style.height = 'auto'
element.style.height = `${element.scrollHeight}px`
}
const goBack = () => router.back() const goBack = () => router.back()
@@ -124,6 +134,8 @@ onMounted(async () => {
if (isEdit.value) { if (isEdit.value) {
await loadForEdit() await loadForEdit()
} }
await nextTick()
resizeQuestion()
}) })
</script> </script>
@@ -194,6 +206,13 @@ onMounted(async () => {
border-color: #F97316; border-color: #F97316;
} }
.question-input {
min-height: 44px;
overflow: hidden;
resize: none;
line-height: 1.55;
}
.switch-row { .switch-row {
display: flex; display: flex;
align-items: center; align-items: center;